Files
ajp_anton 8ec3cde4f2
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8 context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; } name:ph… (push) Successful in 48s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18 POSTGIS_VERSION=3 VCHORD_VERSION=0.5.3 context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 55s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3 context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:linkki-tiedotus oci_labels:… (push) Successful in 39s
Add Linkki broadcast image
2026-08-19 20:51:26 +00:00

3260 lines
112 KiB
JavaScript

(function () {
const root = document.getElementById("editor-root");
if (!root) return;
const state = {
broadcast: JSON.parse(root.dataset.broadcast),
savedBroadcast: JSON.parse(root.dataset.broadcast),
permissions: JSON.parse(root.dataset.permissions),
translations: JSON.parse(root.dataset.translations),
busy: false,
busyMessage: "",
operation: null,
pollTimer: null,
localScheduleValue: "",
localUnpinScheduleValue: "",
announcements: [],
previewTimers: {},
imageImportScope: null,
activeTab: "main",
};
const INLINE_STYLE_ORDER = ["bold", "italic", "underline", "strikethrough"];
const INLINE_STYLE_MARKERS = {
bold: "**",
italic: "_",
underline: "++",
strikethrough: "~~",
};
const titleInput = document.getElementById("title-input");
const headerTitle = document.querySelector(".editor-header h1");
const headerDirectory = document.querySelector(".editor-header p");
const telegramBotSelect = document.getElementById("telegram-bot-select");
const telegramChatSelect = document.getElementById("telegram-chat-select");
const orderSelect = document.getElementById("language-order-select");
const discussionPinningSelect = document.getElementById("discussion-pinning-select");
const deletePinServiceMessagesCheckbox = document.getElementById("delete-pin-service-messages");
const disableLinkPreviewsGlobal = document.getElementById("disable-link-previews-global");
const scheduleInput = document.getElementById("schedule-input");
const statusBoxContent = document.getElementById("status-box-content");
const clearStatusButton = document.getElementById("clear-status-button");
const telegramBindingPanel = document.getElementById("telegram-binding-panel");
const tabButtons = Array.from(document.querySelectorAll(".editor-tab-button"));
const tabPanels = Array.from(document.querySelectorAll(".editor-tab-panel"));
const pinningTabPanel = document.getElementById("editor-tab-panel-pinning");
const languagesContainer = document.getElementById("languages-container");
const contentContainer = document.querySelector(".content");
const sidebarContainer = document.querySelector(".sidebar");
const globalUpload = document.getElementById("upload-global");
const openGlobalImageImportButton = document.getElementById("open-global-image-import-button");
const imageImportModal = document.getElementById("image-import-modal");
const imageImportFormatSelect = document.getElementById("image-import-format-select");
const imageImportUrlInput = document.getElementById("image-import-url-input");
const imageImportSubmitUrlButton = document.getElementById("image-import-submit-url-button");
const imageImportCloseButton = document.getElementById("image-import-close-button");
const imageImportCancelButton = document.getElementById("image-import-cancel-button");
const saveButton = document.getElementById("save-button");
const publishButton = document.getElementById("publish-button");
const scheduleButton = document.getElementById("schedule-button");
const deleteTelegramButton = document.getElementById("delete-telegram-button");
const deleteStorageButton = document.getElementById("delete-storage-button");
const unpinScheduleInput = document.getElementById("unpin-schedule-input");
const unpinScheduleButton = document.getElementById("unpin-schedule-button");
const SIDEBAR_SCROLL_STORAGE_KEY = "linkki.sidebar.scrollTop";
function clone(obj) {
return JSON.parse(JSON.stringify(obj));
}
function persistSidebarScrollPosition() {
if (!sidebarContainer) return;
window.sessionStorage.setItem(SIDEBAR_SCROLL_STORAGE_KEY, String(sidebarContainer.scrollTop));
}
function restoreSidebarScrollPosition() {
if (!sidebarContainer) return;
const savedValue = window.sessionStorage.getItem(SIDEBAR_SCROLL_STORAGE_KEY);
if (!savedValue) return;
const scrollTop = Number(savedValue);
if (!Number.isFinite(scrollTop)) return;
sidebarContainer.scrollTop = scrollTop;
window.requestAnimationFrame(() => {
sidebarContainer.scrollTop = scrollTop;
});
}
function renderPreservingContentScroll(callback) {
const scroller = contentContainer || document.scrollingElement;
if (!scroller) {
callback();
return;
}
const scrollTop = scroller.scrollTop;
const scrollLeft = scroller.scrollLeft;
callback();
scroller.scrollTop = scrollTop;
scroller.scrollLeft = scrollLeft;
window.requestAnimationFrame(() => {
scroller.scrollTop = scrollTop;
scroller.scrollLeft = scrollLeft;
});
}
function t(key, fallback) {
return Object.prototype.hasOwnProperty.call(state.translations, key) ? state.translations[key] : (fallback || key);
}
function setActiveTab(tabName) {
state.activeTab = tabName;
for (const button of tabButtons) {
const active = button.dataset.tab === tabName;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", active ? "true" : "false");
}
for (const panel of tabPanels) {
const active = panel.dataset.tabPanel === tabName;
panel.classList.toggle("active", active);
panel.hidden = !active;
}
}
function renderTabLabels() {
for (const button of tabButtons) {
const tabName = button.dataset.tab || "";
if (tabName === "telegram") {
button.textContent = t("editor.tab_telegram");
if (hasIncompleteTelegramMapping()) {
button.textContent += ` (${t("editor.telegram_binding.incomplete")})`;
}
button.classList.toggle("tab-missing", hasIncompleteTelegramMapping());
} else {
button.classList.remove("tab-missing");
}
}
}
function can(permission) {
return !!state.permissions[permission];
}
function setPermissionDisabledState(element, permissionDenied) {
if (!element) return;
element.classList.toggle("permission-disabled", permissionDenied);
}
function canEditContent() {
return can("edit_content");
}
function canEditTargets() {
return can("edit_targets");
}
function canSaveAnnouncements() {
return can("save_announcements");
}
function canOpenTelegram() {
return can("open_telegram");
}
function updateSaveButtonState() {
const canSave = canSaveAnnouncements();
saveButton.disabled = state.busy || !canSave || !hasUnsavedChanges();
saveButton.title = !canSave ? permissionReason("save_announcements") : "";
}
function syncGlobalLinkPreviewToggle() {
const previewFlags = state.broadcast.metadata.disable_link_previews || {};
const previewValues = state.broadcast.metadata.language_order.map((language) => !!previewFlags[language]);
const allPreviewDisabled = previewValues.length > 0 && previewValues.every(Boolean);
const noPreviewDisabled = previewValues.every((value) => !value);
disableLinkPreviewsGlobal.checked = allPreviewDisabled;
disableLinkPreviewsGlobal.indeterminate = !allPreviewDisabled && !noPreviewDisabled;
}
function normalizeComparableText(value) {
return String(value || "")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.split("\n")
.map((line) => line.replace(/\s+$/u, ""))
.join("\n")
.replace(/\s+$/u, "");
}
function buildEditableSnapshot(broadcast) {
const metadata = broadcast.metadata;
const includePersistedTarget = !metadata.telegram_binding;
return {
title: metadata.title,
telegram_bot_id: includePersistedTarget ? (metadata.telegram_bot_id || "") : "",
telegram_chat_id: includePersistedTarget ? (metadata.telegram_chat_id || "") : "",
language_order: [...metadata.language_order],
discussion_pinning_mode: metadata.discussion_pinning_mode || "first",
delete_pin_service_messages: !!metadata.delete_pin_service_messages,
schedule_draft_for: metadata.schedule_draft_for || "",
enabled_languages: clone(metadata.enabled_languages),
disable_link_previews: clone(metadata.disable_link_previews || {}),
texts: {
fi: normalizeComparableText(broadcast.texts.fi || ""),
sv: normalizeComparableText(broadcast.texts.sv || ""),
en: normalizeComparableText(broadcast.texts.en || ""),
},
images: {
global: (broadcast.images.global || []).map((item) => item.name),
fi: (broadcast.images.fi || []).map((item) => item.name),
sv: (broadcast.images.sv || []).map((item) => item.name),
en: (broadcast.images.en || []).map((item) => item.name),
},
};
}
function scheduleInputValueForMetadata(metadata) {
if (metadata.scheduled_for) {
return toDatetimeLocal(new Date(metadata.scheduled_for));
}
return metadata.schedule_draft_for || "";
}
function unpinScheduleInputValueForMetadata(metadata) {
if (!metadata.scheduled_unpin_for) {
return "";
}
return toDatetimeLocal(new Date(metadata.scheduled_unpin_for));
}
function hasUnsavedChanges() {
return JSON.stringify(buildEditableSnapshot(state.broadcast)) !== JSON.stringify(buildEditableSnapshot(state.savedBroadcast));
}
function applyServerBroadcast(broadcast) {
state.broadcast = broadcast;
state.savedBroadcast = clone(broadcast);
root.dataset.broadcast = JSON.stringify(broadcast);
state.localScheduleValue = scheduleInputValueForMetadata(broadcast.metadata);
state.localUnpinScheduleValue = unpinScheduleInputValueForMetadata(broadcast.metadata);
}
function applyServerBroadcastPreservingLocalEdits(broadcast) {
const localBroadcast = clone(state.broadcast);
state.savedBroadcast = clone(broadcast);
state.broadcast = clone(broadcast);
state.broadcast.metadata.title = localBroadcast.metadata.title;
state.broadcast.metadata.telegram_bot_id = localBroadcast.metadata.telegram_bot_id || "";
state.broadcast.metadata.telegram_chat_id = localBroadcast.metadata.telegram_chat_id || "";
state.broadcast.metadata.language_order = [...localBroadcast.metadata.language_order];
state.broadcast.metadata.discussion_pinning_mode = localBroadcast.metadata.discussion_pinning_mode || "first";
state.broadcast.metadata.delete_pin_service_messages = !!localBroadcast.metadata.delete_pin_service_messages;
state.broadcast.metadata.schedule_draft_for = localBroadcast.metadata.schedule_draft_for || null;
state.broadcast.metadata.enabled_languages = clone(localBroadcast.metadata.enabled_languages);
state.broadcast.metadata.disable_link_previews = clone(localBroadcast.metadata.disable_link_previews || {});
state.broadcast.texts = clone(localBroadcast.texts);
state.broadcast.images = clone(localBroadcast.images);
root.dataset.broadcast = JSON.stringify(state.broadcast);
state.localScheduleValue = scheduleInputValueForMetadata(state.broadcast.metadata);
state.localUnpinScheduleValue = unpinScheduleInputValueForMetadata(state.savedBroadcast.metadata);
}
function applyApiPayload(payload, preserveLocalEdits = false) {
if (payload.announcements) {
state.announcements = payload.announcements;
}
if (payload.broadcast) {
if (preserveLocalEdits) {
applyServerBroadcastPreservingLocalEdits(payload.broadcast);
} else {
applyServerBroadcast(payload.broadcast);
}
}
}
function confirmUnsavedChanges(messageKey) {
if (!hasUnsavedChanges()) return true;
return window.confirm(t(messageKey));
}
function isLinked() {
return !!state.broadcast.metadata.telegram_binding;
}
function isDeleteFromTelegramExpired() {
if (!state.broadcast.telegram_delete_deadline) return false;
return Date.now() >= new Date(state.broadcast.telegram_delete_deadline).getTime();
}
function isTextOnlyEditable() {
return isLinked();
}
function areTargetsLocked() {
return isLinked() || state.broadcast.metadata.status === "scheduled";
}
function linkedLockReason() {
return t("locked.text_only");
}
function targetsLockReason() {
if (isLinked()) {
return linkedLockReason();
}
if (state.broadcast.metadata.status === "scheduled") {
return t("locked.targets_scheduled");
}
return "";
}
function permissionReason(permission) {
return t(`permission.${permission}`, "You do not have permission for this action.");
}
function contentEditReasonFor(locked = isTextOnlyEditable()) {
return locked ? linkedLockReason() : (!canEditContent() ? permissionReason("edit_content") : "");
}
function telegramTargetCatalog() {
return state.broadcast.telegram_targets || { bots: [], default_bot_id: "" };
}
function selectedBot() {
return telegramTargetCatalog().bots.find((bot) => bot.id === (state.broadcast.metadata.telegram_bot_id || "")) || null;
}
function selectedChatTarget() {
const bot = selectedBot();
if (!bot) {
return null;
}
return bot.chats.find((item) => item.chat_id === (state.broadcast.metadata.telegram_chat_id || "")) || null;
}
function defaultChatIdForBot(botId) {
const bot = telegramTargetCatalog().bots.find((item) => item.id === botId);
if (!bot) return "";
return bot.default_chat_id || (bot.chats[0]?.chat_id || "");
}
function selectedChatHasDiscussionGroup() {
return !!selectedChatTarget()?.has_discussion_group;
}
function hasCompleteDiscussionLinks(binding = telegramBinding()) {
if (!binding) {
return false;
}
const rows = bindingPrimaryRows(binding);
return rows.length > 0 && rows.every((row) => !!row.discussionMessageId);
}
function supportsGroupLinks() {
return !!selectedDiscussionLinkTarget().internalId;
}
function discussionPinningAvailability() {
if (!selectedChatHasDiscussionGroup()) {
return {
available: false,
reason: t("editor.discussion_pinning_requires_group"),
};
}
if (isLinked() && !hasCompleteDiscussionLinks()) {
return {
available: false,
reason: t("editor.discussion_pinning_requires_links"),
};
}
return {
available: true,
reason: "",
};
}
function hasSelectedTarget() {
const bot = selectedBot();
if (!bot) {
return false;
}
return bot.chats.some((chat) => chat.chat_id === (state.broadcast.metadata.telegram_chat_id || ""));
}
function hasPersistedTarget() {
return !!(state.savedBroadcast.metadata.telegram_bot_id && state.savedBroadcast.metadata.telegram_chat_id);
}
function linkLabelForLanguage(language) {
if (language === "fi") return "\uD83C\uDDEB\uD83C\uDDEE t\u00e4st\u00e4";
if (language === "sv") return "\uD83C\uDDF8\uD83C\uDDEA h\u00e4r";
return "\uD83C\uDDEC\uD83C\uDDE7 here";
}
function flagEmojiForLanguage(language) {
if (language === "fi") return "\uD83C\uDDEB\uD83C\uDDEE";
if (language === "sv") return "\uD83C\uDDF8\uD83C\uDDEA";
return "\uD83C\uDDEC\uD83C\uDDE7";
}
function buildCrossLanguageSnippet(language) {
const enabledLanguages = state.broadcast.metadata.language_order.filter(
(candidate) => candidate !== language && state.broadcast.metadata.enabled_languages[candidate],
);
return enabledLanguages.map((candidate) => `[${linkLabelForLanguage(candidate)}](${candidate})`).join("\n");
}
function buildLanguageFlagsSnippet() {
return state.broadcast.metadata.language_order
.map((language) => flagEmojiForLanguage(language))
.join("");
}
function insertTextWithSpacing(textarea, language, textToInsert) {
if (!textToInsert) return;
const currentText = state.broadcast.texts[language] || "";
const start = typeof textarea.selectionStart === "number" ? textarea.selectionStart : currentText.length;
const end = typeof textarea.selectionEnd === "number" ? textarea.selectionEnd : start;
const before = currentText.slice(0, start);
const after = currentText.slice(end);
let insertion = textToInsert;
if (before.length > 0 && !before.endsWith("\n")) {
insertion = `\n${insertion}`;
}
if (after.length > 0 && !after.startsWith("\n")) {
insertion = `${insertion}\n`;
}
const updatedText = `${before}${insertion}${after}`;
const caretPosition = before.length + insertion.length;
updateTextareaValue(textarea, language, updatedText, caretPosition, caretPosition);
}
function emptyInlineStyleState() {
return {
bold: false,
italic: false,
underline: false,
strikethrough: false,
};
}
function cloneInlineStyleState(styles) {
return { ...emptyInlineStyleState(), ...(styles || {}) };
}
function updateTextareaValue(textarea, language, value, selectionStart, selectionEnd) {
state.broadcast.texts[language] = value;
textarea.value = value;
textarea.focus();
textarea.selectionStart = selectionStart;
textarea.selectionEnd = selectionEnd;
syncTextareaHeight(textarea);
updateSaveButtonState();
}
function replaceTextRange(textarea, language, start, end, replacement, selectionStart, selectionEnd) {
const currentText = state.broadcast.texts[language] || "";
const updatedText = `${currentText.slice(0, start)}${replacement}${currentText.slice(end)}`;
updateTextareaValue(textarea, language, updatedText, selectionStart, selectionEnd);
}
function getSelectionRange(textarea, text) {
const fallback = text.length;
const start = typeof textarea.selectionStart === "number" ? textarea.selectionStart : fallback;
const end = typeof textarea.selectionEnd === "number" ? textarea.selectionEnd : start;
return start <= end ? { start, end } : { start: end, end: start };
}
function isWhitespaceCharacter(character) {
return /\s/u.test(character || "");
}
function isWordCharacter(character) {
return /[\p{L}\p{N}_]/u.test(character || "");
}
function expandRangeToWord(text, range) {
if (range.start !== range.end) {
return range;
}
if (!text) {
return range;
}
let anchor = range.start;
if (anchor < text.length && isWordCharacter(text[anchor])) {
// keep current anchor
} else if (anchor > 0 && isWordCharacter(text[anchor - 1])) {
anchor -= 1;
} else {
while (anchor < text.length && !isWordCharacter(text[anchor])) {
anchor += 1;
}
if (anchor >= text.length) {
anchor = range.start - 1;
while (anchor >= 0 && !isWordCharacter(text[anchor])) {
anchor -= 1;
}
}
if (anchor < 0 || anchor >= text.length || !isWordCharacter(text[anchor])) {
return range;
}
}
let start = anchor;
let end = anchor + 1;
while (start > 0 && isWordCharacter(text[start - 1])) {
start -= 1;
}
while (end < text.length && isWordCharacter(text[end])) {
end += 1;
}
return { start, end };
}
function expandRangeToWholeLines(text, range) {
let start = range.start;
let end = range.end;
while (start > 0 && text[start - 1] !== "\n") {
start -= 1;
}
while (end < text.length && text[end] !== "\n") {
end += 1;
}
return { start, end };
}
function trimRangeWhitespace(text, range) {
let { start, end } = range;
while (start < end && isWhitespaceCharacter(text[start])) {
start += 1;
}
while (start < end && isWhitespaceCharacter(text[end - 1])) {
end -= 1;
}
return { start, end };
}
function collectOpaqueRanges(text) {
const ranges = [];
let index = 0;
while (index < text.length) {
if (text.startsWith("```", index)) {
const closeIndex = text.indexOf("```", index + 3);
const end = closeIndex === -1 ? text.length : closeIndex + 3;
ranges.push({ start: index, end, kind: "code_block" });
index = end;
continue;
}
if (text[index] === "`") {
const closeIndex = text.indexOf("`", index + 1);
const end = closeIndex === -1 ? text.length : closeIndex + 1;
ranges.push({ start: index, end, kind: "inline_code" });
index = end;
continue;
}
if (text.startsWith("||", index)) {
const closeIndex = text.indexOf("||", index + 2);
const end = closeIndex === -1 ? text.length : closeIndex + 2;
ranges.push({ start: index, end, kind: "spoiler" });
index = end;
continue;
}
const linkMatch = text.slice(index).match(/^\[[^\]\n]+\]\((?:\\.|[^)\n])+\)/u);
if (linkMatch) {
const end = index + linkMatch[0].length;
ranges.push({ start: index, end, kind: "link" });
index = end;
continue;
}
index += 1;
}
return ranges;
}
function findOpaqueRangeAt(ranges, index) {
return ranges.find((range) => range.start <= index && index < range.end) || null;
}
function selectionIntersectsOpaqueRange(ranges, start, end) {
return ranges.some((range) => range.start < end && start < range.end);
}
function selectionContainsBlockedLineMarkup(text) {
return /(^|\n)\s*(?:>\s|[-+*]\s|\d+\.\s)/u.test(text);
}
function isQuotedLine(line) {
return /^\s*>\s?/u.test(line);
}
function removeQuotedLinePrefix(line) {
return line.replace(/^(\s*)>\s?/u, "$1");
}
function parseLinkRange(text, range) {
const raw = text.slice(range.start, range.end);
const match = raw.match(/^\[([^\]\n]+)\]\(((?:\\.|[^)\n])+)\)$/u);
if (!match) {
return null;
}
return {
label: match[1],
url: match[2],
};
}
function unwrapLinkRange(text, range) {
const parsed = parseLinkRange(text, range);
if (!parsed) {
return null;
}
return {
prefixLength: 1,
suffixLength: parsed.url.length + 3,
innerText: parsed.label,
};
}
function linkRangeAt(ranges, start, end) {
if (start === end) {
return ranges.find((range) => range.kind === "link" && range.start <= start && start < range.end) || null;
}
return ranges.find((range) => range.kind === "link" && range.start < end && start < range.end) || null;
}
function touchedLinkRanges(ranges, start, end) {
if (start === end) {
const containing = linkRangeAt(ranges, start, end);
return containing ? [containing] : [];
}
return ranges.filter((range) => range.kind === "link" && range.start < end && start < range.end);
}
function looksLikeLinkTarget(text) {
const candidate = String(text || "").trim();
if (!candidate) {
return false;
}
if (/^(fi|sv|en)$/iu.test(candidate)) {
return true;
}
return /^(?:https?:\/\/|www\.)\S+$/iu.test(candidate);
}
function matchingOpaqueKinds(actionType) {
if (actionType === "code") {
return new Set(["inline_code", "code_block"]);
}
if (actionType === "spoiler") {
return new Set(["spoiler"]);
}
return new Set();
}
function isMatchingOpaqueRange(range, actionType) {
return matchingOpaqueKinds(actionType).has(range.kind);
}
function opaqueContentBounds(text, range) {
const unwrapped = unwrapOpaqueRange(text, range);
return {
start: range.start + unwrapped.prefixLength,
end: range.end - unwrapped.suffixLength,
};
}
function findContainingOpaqueRange(text, ranges, start, end, actionType) {
return ranges.find((range) => {
if (!isMatchingOpaqueRange(range, actionType)) {
return false;
}
const bounds = opaqueContentBounds(text, range);
if (start === end) {
return bounds.start <= start && start <= bounds.end;
}
return bounds.start <= start && end <= bounds.end;
}) || null;
}
function findIntersectingOpaqueRanges(ranges, start, end) {
return ranges.filter((range) => range.start < end && start < range.end);
}
function unwrapOpaqueRange(text, range) {
const raw = text.slice(range.start, range.end);
if (range.kind === "inline_code") {
return {
prefixLength: 1,
suffixLength: 1,
innerText: raw.slice(1, -1),
};
}
if (range.kind === "spoiler") {
return {
prefixLength: 2,
suffixLength: 2,
innerText: raw.slice(2, -2),
};
}
if (range.kind === "code_block") {
if (raw.startsWith("```\n") && raw.endsWith("\n```")) {
return {
prefixLength: 4,
suffixLength: 4,
innerText: raw.slice(4, -4),
};
}
return {
prefixLength: 3,
suffixLength: 3,
innerText: raw.slice(3, -3),
};
}
return {
prefixLength: 0,
suffixLength: 0,
innerText: raw,
};
}
function removeContainingOpaqueRange(textarea, language, text, range, selectionStart, selectionEnd) {
const unwrapped = unwrapOpaqueRange(text, range);
const contentStart = range.start + unwrapped.prefixLength;
const relativeStart = Math.max(0, Math.min(selectionStart - contentStart, unwrapped.innerText.length));
const relativeEnd = Math.max(0, Math.min(selectionEnd - contentStart, unwrapped.innerText.length));
replaceTextRange(
textarea,
language,
range.start,
range.end,
unwrapped.innerText,
range.start + relativeStart,
range.start + relativeEnd,
);
return true;
}
function stripContainedOpaqueRanges(text, start, end, ranges) {
const parts = [];
let cursor = start;
for (const range of ranges) {
parts.push(text.slice(cursor, range.start));
parts.push(unwrapOpaqueRange(text, range).innerText);
cursor = range.end;
}
parts.push(text.slice(cursor, end));
return parts.join("");
}
function buildOpaqueReplacement(selectedText, actionType) {
if (actionType === "code") {
if (selectedText.includes("\n")) {
if (selectedText.includes("```")) {
return null;
}
return {
replacement: `\`\`\`\n${selectedText}\n\`\`\``,
contentOffset: 4,
};
}
if (selectedText.includes("`")) {
return null;
}
return {
replacement: `\`${selectedText}\``,
contentOffset: 1,
};
}
if (actionType === "spoiler") {
return {
replacement: `||${selectedText}||`,
contentOffset: 2,
};
}
return null;
}
function applyOpaqueFormatting(textarea, language, actionType) {
const text = state.broadcast.texts[language] || "";
const opaqueRanges = collectOpaqueRanges(text);
const originalRange = getSelectionRange(textarea, text);
if (originalRange.start === originalRange.end) {
const containingRange = findContainingOpaqueRange(text, opaqueRanges, originalRange.start, originalRange.end, actionType);
if (containingRange) {
return removeContainingOpaqueRange(textarea, language, text, containingRange, originalRange.start, originalRange.end);
}
}
let range = originalRange.start === originalRange.end
? expandRangeToWord(text, originalRange)
: originalRange;
if (range.start === range.end) {
return false;
}
if (actionType === "code" && text.slice(range.start, range.end).includes("\n")) {
range = expandRangeToWholeLines(text, range);
}
const containingRange = findContainingOpaqueRange(text, opaqueRanges, range.start, range.end, actionType);
if (containingRange) {
return removeContainingOpaqueRange(textarea, language, text, containingRange, range.start, range.end);
}
const intersectingRanges = findIntersectingOpaqueRanges(opaqueRanges, range.start, range.end);
const partialIntersectingRanges = intersectingRanges.filter(
(item) => !(range.start <= item.start && item.end <= range.end),
);
if (partialIntersectingRanges.length > 0) {
return false;
}
if (actionType !== "spoiler") {
const otherIntersectingRanges = intersectingRanges.filter((item) => !isMatchingOpaqueRange(item, actionType));
if (otherIntersectingRanges.length > 0) {
return false;
}
}
const containedMatchingRanges = intersectingRanges.filter(
(item) => isMatchingOpaqueRange(item, actionType) && range.start <= item.start && item.end <= range.end,
);
const selectedText = containedMatchingRanges.length > 0
? stripContainedOpaqueRanges(text, range.start, range.end, containedMatchingRanges)
: text.slice(range.start, range.end);
if (!selectedText) {
return false;
}
const built = buildOpaqueReplacement(selectedText, actionType);
if (!built) {
return false;
}
replaceTextRange(
textarea,
language,
range.start,
range.end,
built.replacement,
range.start + built.contentOffset,
range.start + built.contentOffset + selectedText.length,
);
return true;
}
function inlineMarkerAt(text, index) {
if (text.startsWith("**", index) || text.startsWith("__", index)) {
return { style: "bold", marker: text.slice(index, index + 2) };
}
if (text.startsWith("++", index)) {
return { style: "underline", marker: "++" };
}
if (text.startsWith("~~", index)) {
return { style: "strikethrough", marker: "~~" };
}
if (text[index] === "*" || text[index] === "_") {
return { style: "italic", marker: text[index] };
}
return null;
}
function inlineStyleStateAt(text, end, opaqueRanges) {
const active = emptyInlineStyleState();
const stack = inlineStyleStackAt(text, end, opaqueRanges);
for (const style of stack) {
active[style] = true;
}
return active;
}
function inlineStyleStackAt(text, end, opaqueRanges) {
const activeStack = [];
let index = 0;
while (index < end) {
const opaqueRange = findOpaqueRangeAt(opaqueRanges, index);
if (opaqueRange) {
index = Math.min(opaqueRange.end, end);
continue;
}
if (text[index] === "\\" && index + 1 < end) {
index += 2;
continue;
}
const marker = inlineMarkerAt(text, index);
if (marker && index + marker.marker.length <= end) {
const existingIndex = activeStack.lastIndexOf(marker.style);
if (existingIndex === -1) {
activeStack.push(marker.style);
} else {
activeStack.splice(existingIndex, 1);
}
index += marker.marker.length;
continue;
}
index += 1;
}
return activeStack;
}
function parseInlineSelection(text, start, end, initialStyles, opaqueRanges) {
const characters = [];
const active = cloneInlineStyleState(initialStyles);
let index = start;
while (index < end) {
const opaqueRange = findOpaqueRangeAt(opaqueRanges, index);
if (opaqueRange) {
return null;
}
if (text[index] === "\\" && index + 1 < end) {
characters.push({ value: text[index], styles: cloneInlineStyleState(active) });
characters.push({ value: text[index + 1], styles: cloneInlineStyleState(active) });
index += 2;
continue;
}
const marker = inlineMarkerAt(text, index);
if (marker && index + marker.marker.length <= end) {
active[marker.style] = !active[marker.style];
index += marker.marker.length;
continue;
}
characters.push({ value: text[index], styles: cloneInlineStyleState(active) });
index += 1;
}
return {
characters,
finalStyles: active,
};
}
function collectInlineStyleSpans(text, opaqueRanges) {
const spans = {
bold: [],
italic: [],
underline: [],
strikethrough: [],
};
const activeStarts = {
bold: null,
italic: null,
underline: null,
strikethrough: null,
};
let index = 0;
while (index < text.length) {
const opaqueRange = findOpaqueRangeAt(opaqueRanges, index);
if (opaqueRange) {
index = opaqueRange.end;
continue;
}
if (text[index] === "\\" && index + 1 < text.length) {
index += 2;
continue;
}
const marker = inlineMarkerAt(text, index);
if (marker) {
if (activeStarts[marker.style] === null) {
activeStarts[marker.style] = index;
} else {
spans[marker.style].push({
start: activeStarts[marker.style],
end: index + marker.marker.length,
});
activeStarts[marker.style] = null;
}
index += marker.marker.length;
continue;
}
index += 1;
}
return spans;
}
function flattenInlineStyleSpans(spansByStyle) {
const spans = [];
for (const style of INLINE_STYLE_ORDER) {
for (const span of spansByStyle[style] || []) {
spans.push({ ...span, style });
}
}
return spans;
}
function inlineSpanContentBounds(text, span) {
const marker = inlineMarkerAt(text, span.start);
const markerLength = marker ? marker.marker.length : 0;
return {
start: span.start + markerLength,
end: span.end - markerLength,
};
}
function expandRangeToInlineStyleSpans(range, spans) {
let expanded = { ...range };
let changed = true;
while (changed) {
changed = false;
for (const span of spans) {
if (span.start < expanded.end && expanded.start < span.end) {
const nextStart = Math.min(expanded.start, span.start);
const nextEnd = Math.max(expanded.end, span.end);
if (nextStart !== expanded.start || nextEnd !== expanded.end) {
expanded = { start: nextStart, end: nextEnd };
changed = true;
}
}
}
}
return expanded;
}
function expandRangeToCoveredInlineStyleSpans(text, range, spans) {
let expanded = { ...range };
let changed = true;
while (changed) {
changed = false;
for (const span of spans) {
const bounds = inlineSpanContentBounds(text, span);
if (expanded.start <= bounds.start && bounds.end <= expanded.end) {
const nextStart = Math.min(expanded.start, span.start);
const nextEnd = Math.max(expanded.end, span.end);
if (nextStart !== expanded.start || nextEnd !== expanded.end) {
expanded = { start: nextStart, end: nextEnd };
changed = true;
}
}
}
}
return expanded;
}
function balanceInlineSelectionEnd(text, range, opaqueRanges) {
let expanded = { ...range };
while (expanded.end < text.length) {
const initialStyles = inlineStyleStateAt(text, expanded.start, opaqueRanges);
const parsedSelection = parseInlineSelection(text, expanded.start, expanded.end, initialStyles, opaqueRanges);
if (!parsedSelection || inlineStyleStatesMatch(parsedSelection.finalStyles, initialStyles)) {
return expanded;
}
const trailingMarker = inlineMarkerAt(text, expanded.end);
if (!trailingMarker) {
return expanded;
}
if (parsedSelection.finalStyles[trailingMarker.style] === initialStyles[trailingMarker.style]) {
return expanded;
}
expanded = {
start: expanded.start,
end: expanded.end + trailingMarker.marker.length,
};
}
return expanded;
}
function inlineStyleStatesMatch(first, second) {
return INLINE_STYLE_ORDER.every((style) => !!first[style] === !!second[style]);
}
function buildTargetStyleStack(activeStack, desiredStyles) {
const targetStack = activeStack.filter((style) => !!desiredStyles[style]);
for (const style of INLINE_STYLE_ORDER) {
if (!!desiredStyles[style] && !targetStack.includes(style)) {
targetStack.push(style);
}
}
return targetStack;
}
function transitionInlineStyles(parts, activeStack, desiredStyles) {
const targetStack = buildTargetStyleStack(activeStack, desiredStyles);
let sharedPrefixLength = 0;
while (
sharedPrefixLength < activeStack.length
&& sharedPrefixLength < targetStack.length
&& activeStack[sharedPrefixLength] === targetStack[sharedPrefixLength]
) {
sharedPrefixLength += 1;
}
while (activeStack.length > sharedPrefixLength) {
const style = activeStack.pop();
parts.push(INLINE_STYLE_MARKERS[style]);
}
while (activeStack.length < targetStack.length) {
const style = targetStack[activeStack.length];
activeStack.push(style);
parts.push(INLINE_STYLE_MARKERS[style]);
}
}
function serializeInlineSelection(characters, surroundingStyles, surroundingStack) {
const parts = [];
const activeStack = [...(surroundingStack || [])];
for (const character of characters) {
transitionInlineStyles(parts, activeStack, character.styles);
parts.push(character.value);
}
transitionInlineStyles(parts, activeStack, surroundingStyles);
return parts.join("");
}
function toggleInlineStyle(textarea, language, style) {
const text = state.broadcast.texts[language] || "";
const opaqueRanges = collectOpaqueRanges(text);
const inlineStyleSpans = collectInlineStyleSpans(text, opaqueRanges);
const allInlineSpans = flattenInlineStyleSpans(inlineStyleSpans);
let range = expandRangeToWord(text, getSelectionRange(textarea, text));
if (range.start === range.end) {
return false;
}
range = expandRangeToInlineStyleSpans(range, inlineStyleSpans[style] || []);
range = expandRangeToCoveredInlineStyleSpans(text, range, allInlineSpans);
range = balanceInlineSelectionEnd(text, range, opaqueRanges);
const selectedText = text.slice(range.start, range.end);
if (selectedText.includes("\n")) {
return false;
}
if (selectionIntersectsOpaqueRange(opaqueRanges, range.start, range.end)) {
return false;
}
if (selectionContainsBlockedLineMarkup(selectedText)) {
return false;
}
const surroundingStack = inlineStyleStackAt(text, range.start, opaqueRanges);
const initialStyles = cloneInlineStyleState();
for (const activeStyle of surroundingStack) {
initialStyles[activeStyle] = true;
}
const parsedSelection = parseInlineSelection(text, range.start, range.end, initialStyles, opaqueRanges);
if (!parsedSelection || parsedSelection.characters.length === 0) {
return false;
}
if (!inlineStyleStatesMatch(parsedSelection.finalStyles, initialStyles)) {
return false;
}
const shouldApply = parsedSelection.characters.some((character) => !character.styles[style]);
for (const character of parsedSelection.characters) {
character.styles[style] = shouldApply;
}
const replacement = serializeInlineSelection(parsedSelection.characters, initialStyles, surroundingStack);
replaceTextRange(textarea, language, range.start, range.end, replacement, range.start, range.start + replacement.length);
return true;
}
function applyCodeFormatting(textarea, language) {
return applyOpaqueFormatting(textarea, language, "code");
}
function applySpoilerFormatting(textarea, language) {
return applyOpaqueFormatting(textarea, language, "spoiler");
}
function applyLinkFormatting(textarea, language) {
const text = state.broadcast.texts[language] || "";
const opaqueRanges = collectOpaqueRanges(text);
const originalRange = getSelectionRange(textarea, text);
const touchedLinks = touchedLinkRanges(opaqueRanges, originalRange.start, originalRange.end);
if (touchedLinks.length > 0) {
const replaceStart = touchedLinks[0].start;
const replaceEnd = touchedLinks[touchedLinks.length - 1].end;
const parts = [];
let cursor = replaceStart;
for (const range of touchedLinks) {
const unwrapped = unwrapLinkRange(text, range);
if (!unwrapped) {
return false;
}
parts.push(text.slice(cursor, range.start));
parts.push(unwrapped.innerText);
cursor = range.end;
}
parts.push(text.slice(cursor, replaceEnd));
const replacement = parts.join("");
replaceTextRange(textarea, language, replaceStart, replaceEnd, replacement, replaceStart, replaceStart + replacement.length);
return true;
}
let range = originalRange.start === originalRange.end
? expandRangeToWord(text, originalRange)
: originalRange;
range = trimRangeWhitespace(text, range);
if (range.start === range.end || selectionIntersectsOpaqueRange(opaqueRanges, range.start, range.end)) {
return false;
}
const selectedText = text.slice(range.start, range.end);
if (!selectedText || selectedText.includes("\n")) {
return false;
}
const selectionLooksLikeLink = looksLikeLinkTarget(selectedText);
const replacement = `[${selectedText}](${selectedText})`;
const labelStart = range.start + 1;
const urlStart = range.start + selectedText.length + 3;
if (selectionLooksLikeLink) {
replaceTextRange(textarea, language, range.start, range.end, replacement, labelStart, labelStart + selectedText.length);
} else {
replaceTextRange(textarea, language, range.start, range.end, replacement, urlStart, urlStart + selectedText.length);
}
return true;
}
function applyBlockquoteFormatting(textarea, language) {
const text = state.broadcast.texts[language] || "";
let range = expandRangeToWholeLines(text, getSelectionRange(textarea, text));
const selectedText = text.slice(range.start, range.end);
if (!selectedText) {
return false;
}
const lines = selectedText.split("\n");
const shouldApply = lines.some((line) => !isQuotedLine(line));
const replacement = lines.map((line) => {
if (shouldApply) {
return isQuotedLine(line) ? line : `> ${line}`;
}
return isQuotedLine(line) ? removeQuotedLinePrefix(line) : line;
}).join("\n");
replaceTextRange(textarea, language, range.start, range.end, replacement, range.start, range.start + replacement.length);
return true;
}
function applyLinePrefix(textarea, language, prefixBuilder) {
const text = state.broadcast.texts[language] || "";
const range = expandRangeToWholeLines(text, getSelectionRange(textarea, text));
const selectedText = text.slice(range.start, range.end);
if (!selectedText) {
return false;
}
const lines = selectedText.split("\n");
const replacement = lines.map((line, index) => `${prefixBuilder(index)}${line}`).join("\n");
replaceTextRange(textarea, language, range.start, range.end, replacement, range.start, range.start + replacement.length);
return true;
}
function applyLanguageLinks(textarea, language, snippet) {
if (!snippet) {
return false;
}
insertTextWithSpacing(textarea, language, snippet);
return true;
}
function applyFormattingAction(textarea, language, action, previewContent) {
let changed = false;
if (action.type === "inline_style") {
changed = toggleInlineStyle(textarea, language, action.style);
} else if (action.type === "code") {
changed = applyCodeFormatting(textarea, language);
} else if (action.type === "spoiler") {
changed = applySpoilerFormatting(textarea, language);
} else if (action.type === "link") {
changed = applyLinkFormatting(textarea, language);
} else if (action.type === "blockquote") {
changed = applyBlockquoteFormatting(textarea, language);
} else if (action.type === "bullet_list") {
changed = applyLinePrefix(textarea, language, () => "- ");
} else if (action.type === "numbered_list") {
changed = applyLinePrefix(textarea, language, (index) => `${index + 1}. `);
} else if (action.type === "language_links") {
changed = applyLanguageLinks(textarea, language, action.snippet || "");
} else if (action.type === "language_flags") {
changed = applyLanguageLinks(textarea, language, action.snippet || "");
}
if (changed) {
schedulePreviewUpdate(language, previewContent);
}
return changed;
}
function syncTextareaHeight(textarea) {
if (!textarea) {
return;
}
textarea.style.height = "auto";
const previewPanel = textarea._previewPanel || null;
const previewHeight = previewPanel && !previewPanel.hidden ? previewPanel.offsetHeight : 0;
const targetHeight = Math.max(240, textarea.scrollHeight, previewHeight);
textarea.style.height = `${targetHeight}px`;
}
function syncAllTextareaHeights() {
for (const textarea of languagesContainer.querySelectorAll("textarea")) {
syncTextareaHeight(textarea);
}
}
function isModifierShortcut(event) {
return (event.ctrlKey || event.metaKey) && !event.altKey;
}
function handleTextareaShortcut(event, textarea, language, previewContent) {
if (!isModifierShortcut(event)) {
return;
}
let action = null;
const key = String(event.key || "").toLowerCase();
if (key === "b") {
action = { type: "inline_style", style: "bold" };
} else if (key === "i") {
action = { type: "inline_style", style: "italic" };
} else if (key === "u") {
action = { type: "inline_style", style: "underline" };
} else {
return;
}
event.preventDefault();
applyFormattingAction(textarea, language, action, previewContent);
}
function sourceBroadcastForOutboundAction() {
return hasUnsavedChanges() ? state.savedBroadcast : state.broadcast;
}
function collectInactiveLinkWarnings(broadcast) {
const issues = [];
const seen = new Set();
const placeholderPattern = /\[([^\]]+)\]\((fi|sv|en)\)/gi;
for (const sourceLanguage of broadcast.metadata.language_order) {
if (!broadcast.metadata.enabled_languages[sourceLanguage]) continue;
const text = String(broadcast.texts[sourceLanguage] || "");
let match;
while ((match = placeholderPattern.exec(text)) !== null) {
const targetLanguage = match[2].toLowerCase();
const targetEnabled = !!broadcast.metadata.enabled_languages[targetLanguage];
const targetHasText = String(broadcast.texts[targetLanguage] || "").trim().length > 0;
if (targetEnabled && targetHasText) {
continue;
}
const reason = !targetEnabled ? t("warning.target_disabled") : t("warning.target_empty");
const issueKey = `${sourceLanguage}:${targetLanguage}:${reason}`;
if (seen.has(issueKey)) {
continue;
}
seen.add(issueKey);
issues.push(
`${displayName(sourceLanguage)} -> ${displayName(targetLanguage)}: ${reason}`
);
}
}
return issues;
}
function confirmOutboundAction(actionKey) {
const messages = [];
if (actionKey === "publish" && hasUnsavedChanges()) {
messages.push(t("confirm.publish_unsaved"));
}
if (actionKey === "schedule" && hasUnsavedChanges()) {
messages.push(t("confirm.schedule_unsaved"));
}
const inactiveLinkWarnings = collectInactiveLinkWarnings(sourceBroadcastForOutboundAction());
if (inactiveLinkWarnings.length > 0) {
messages.push(`${t("confirm.inactive_links")}\n- ${inactiveLinkWarnings.join("\n- ")}`);
}
if (messages.length === 0) {
return true;
}
return window.confirm(messages.join("\n\n"));
}
function announcementUrl(path) {
return `/api/announcements/${state.broadcast.directory}${path}`;
}
function announcementPageUrl() {
return `/announcements/${state.broadcast.directory}`;
}
async function fetchJson(url, options) {
const response = await fetch(url, options);
if (response.status === 401) {
window.location.href = "/login";
throw new Error("Authentication required.");
}
let payload = {};
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
payload = await response.json();
} else {
const text = await response.text();
if (!response.ok) {
throw new Error(text || `Request failed with status ${response.status}.`);
}
}
if (!response.ok) {
throw new Error(payload.detail || `Request failed with status ${response.status}.`);
}
return payload;
}
function renderTelegramTargetSelects(locked, canEdit, editReason) {
const catalog = telegramTargetCatalog();
const currentBotId = state.broadcast.metadata.telegram_bot_id || "";
const currentChatId = state.broadcast.metadata.telegram_chat_id || "";
const botIds = new Set(catalog.bots.map((bot) => bot.id));
const temporaryDeleteSelectionAllowed = isLinked() && !hasPersistedTarget() && can("delete_telegram");
const targetControlsLocked = state.busy || (!temporaryDeleteSelectionAllowed && (locked || !canEdit));
const allowEmptySelection = !(state.broadcast.metadata.status === "scheduled" && !isLinked());
telegramBotSelect.innerHTML = "";
if (allowEmptySelection || !currentBotId) {
telegramBotSelect.appendChild(new Option(t("editor.select_bot"), ""));
}
for (const bot of catalog.bots) {
telegramBotSelect.appendChild(new Option(bot.name, bot.id));
}
if (currentBotId && !botIds.has(currentBotId)) {
telegramBotSelect.appendChild(new Option(currentBotId, currentBotId));
}
telegramBotSelect.value = currentBotId;
telegramBotSelect.disabled = targetControlsLocked;
telegramBotSelect.title = temporaryDeleteSelectionAllowed
? t("editor.temporary_delete_target")
: editReason;
setPermissionDisabledState(telegramBotSelect, !canEdit);
const activeBot = selectedBot();
const chatIds = new Set((activeBot?.chats || []).map((chat) => chat.chat_id));
telegramChatSelect.innerHTML = "";
if (allowEmptySelection || !currentChatId) {
telegramChatSelect.appendChild(new Option(t("editor.select_chat"), ""));
}
for (const chat of activeBot?.chats || []) {
telegramChatSelect.appendChild(new Option(chat.name, chat.chat_id));
}
if (currentChatId && !chatIds.has(currentChatId)) {
telegramChatSelect.appendChild(new Option(currentChatId, currentChatId));
}
telegramChatSelect.value = currentChatId;
telegramChatSelect.disabled = targetControlsLocked || !currentBotId;
telegramChatSelect.title = !currentBotId
? t("editor.select_bot_first")
: (temporaryDeleteSelectionAllowed ? t("editor.temporary_delete_target") : editReason);
setPermissionDisabledState(telegramChatSelect, !canEdit);
}
function renderSidebarAnnouncements() {
if (!state.announcements || state.announcements.length === 0) {
return;
}
const container = document.querySelector(".announcement-list");
if (!container) {
return;
}
container.innerHTML = "";
for (const item of state.announcements) {
const entry = document.createElement("div");
entry.className = "announcement-entry";
const link = document.createElement("a");
link.className = "announcement-item";
if (item.directory === state.broadcast.directory) {
link.classList.add("active");
}
link.href = `/announcements/${item.directory}`;
const titleRow = document.createElement("span");
titleRow.className = "announcement-title-row";
if (item.sidebar_has_pins) {
const primaryMarker = document.createElement("span");
primaryMarker.className = `status-primary-marker status-${item.sidebar_pin_status || item.status} status-primary-marker-pinned`;
primaryMarker.classList.add("status-primary-marker-pinned");
primaryMarker.setAttribute("aria-hidden", "true");
titleRow.appendChild(primaryMarker);
}
const titleSpan = document.createElement("span");
titleSpan.className = "announcement-title";
titleSpan.textContent = item.title;
if (item.status_symbol) {
const symbol = document.createElement("span");
symbol.className = `status-symbol status-${item.status}`;
if (item.sidebar_links_incomplete) {
symbol.classList.add("status-symbol-incomplete");
}
symbol.textContent = item.status_symbol;
titleRow.appendChild(symbol);
}
titleRow.appendChild(titleSpan);
link.appendChild(titleRow);
const targetLine = document.createElement("small");
targetLine.textContent = item.target_summary;
link.appendChild(targetLine);
for (const metaLine of item.sidebar_meta_lines || []) {
const meta = document.createElement("small");
meta.className = "announcement-meta";
meta.textContent = metaLine;
link.appendChild(meta);
}
entry.appendChild(link);
const controls = document.createElement("div");
controls.className = "announcement-order-controls";
controls.appendChild(buildMoveForm(item, "up", item.can_move_up));
controls.appendChild(buildMoveForm(item, "down", item.can_move_down));
entry.appendChild(controls);
container.appendChild(entry);
}
}
function buildMoveForm(item, direction, enabled) {
const form = document.createElement("form");
form.method = "post";
form.action = `/announcements/${item.directory}/move`;
const directionInput = document.createElement("input");
directionInput.type = "hidden";
directionInput.name = "direction";
directionInput.value = direction;
form.appendChild(directionInput);
const returnToInput = document.createElement("input");
returnToInput.type = "hidden";
returnToInput.name = "return_to";
returnToInput.value = window.location.pathname;
form.appendChild(returnToInput);
const button = document.createElement("button");
button.type = "submit";
button.innerHTML = direction === "up" ? "&uarr;" : "&darr;";
button.disabled = !enabled || !canSaveAnnouncements();
form.appendChild(button);
return form;
}
async function updatePreview(language, previewContent) {
const availableTargets = state.broadcast.metadata.language_order.filter((candidate) => {
if (candidate === language) {
return false;
}
if (!state.broadcast.metadata.enabled_languages[candidate]) {
return false;
}
return String(state.broadcast.texts[candidate] || "").trim().length > 0;
});
const payload = await fetchJson("/api/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: state.broadcast.texts[language] || "",
available_targets: availableTargets,
}),
});
previewContent.innerHTML = payload.html || "";
if (previewContent._textarea) {
syncTextareaHeight(previewContent._textarea);
}
}
function schedulePreviewUpdate(language, previewContent) {
if (state.previewTimers[language]) {
window.clearTimeout(state.previewTimers[language]);
}
state.previewTimers[language] = window.setTimeout(() => {
updatePreview(language, previewContent).catch((error) => {
previewContent.textContent = error.message;
if (previewContent._textarea) {
syncTextareaHeight(previewContent._textarea);
}
});
}, 150);
}
function applyDisabledState(element, disabled, title, permissionDenied = false) {
element.disabled = disabled;
element.title = title || "";
setPermissionDisabledState(element, permissionDenied);
}
function createSecondaryButton({ label, disabled = false, title = "", permissionDenied = false, onClick = null }) {
const button = document.createElement("button");
button.type = "button";
button.className = "secondary-button";
button.textContent = label;
applyDisabledState(button, disabled, title, permissionDenied);
if (onClick) {
button.addEventListener("click", onClick);
}
return button;
}
function applyContentEditControlState(element, { locked, canContentEdit, contentEditReason }) {
applyDisabledState(
element,
locked || state.busy || !canContentEdit,
contentEditReason,
!canContentEdit,
);
}
function renderContext() {
const metadata = state.broadcast.metadata;
const locked = isTextOnlyEditable();
const targetsLocked = areTargetsLocked();
const canContentEdit = canEditContent();
const canTargetEdit = canEditTargets();
const canSave = canSaveAnnouncements();
const hasSelectedTelegramTarget = hasSelectedTarget();
const hasSchedule = !!metadata.scheduled_for;
const hasScheduledUnpin = !!metadata.scheduled_unpin_for;
const pinningAvailability = discussionPinningAvailability();
const canUseSchedule = hasSchedule ? can("unschedule") : can("schedule");
const canUseUnpinSchedule = hasScheduledUnpin ? can("unschedule") : can("schedule");
return {
metadata,
locked,
targetsLocked,
canContentEdit,
canTargetEdit,
canSave,
hasSelectedTelegramTarget,
hasSchedule,
hasScheduledUnpin,
pinningAvailability,
canUseSchedule,
canUseUnpinSchedule,
contentEditReason: locked ? linkedLockReason() : (!canContentEdit ? permissionReason("edit_content") : ""),
targetEditReason: targetsLocked ? targetsLockReason() : (!canTargetEdit ? permissionReason("edit_targets") : ""),
titleEditReason: !canContentEdit ? permissionReason("edit_content") : "",
scheduleEditReason: !canSave ? permissionReason("save_announcements") : "",
};
}
function renderCoreFieldValues(context) {
const { metadata } = context;
if (headerTitle) {
headerTitle.textContent = metadata.title;
}
if (headerDirectory) {
headerDirectory.textContent = state.broadcast.directory;
}
titleInput.value = metadata.title;
orderSelect.value = metadata.language_order.join(",");
renderLanguageOrderOptions();
discussionPinningSelect.value = metadata.discussion_pinning_mode || "first";
deletePinServiceMessagesCheckbox.checked = !!metadata.delete_pin_service_messages;
scheduleInput.value = scheduleInputValueForMetadata(metadata);
unpinScheduleInput.value = state.localUnpinScheduleValue || unpinScheduleInputValueForMetadata(metadata);
}
function renderPrimaryControls(context) {
const {
metadata,
locked,
targetsLocked,
canContentEdit,
canTargetEdit,
canSave,
hasSelectedTelegramTarget,
hasSchedule,
hasScheduledUnpin,
pinningAvailability,
canUseSchedule,
canUseUnpinSchedule,
contentEditReason,
targetEditReason,
titleEditReason,
scheduleEditReason,
} = context;
applyDisabledState(titleInput, state.busy || !canContentEdit, titleEditReason, !canContentEdit);
applyDisabledState(orderSelect, locked || state.busy || !canContentEdit, contentEditReason, !canContentEdit);
pinningTabPanel.classList.toggle("tab-panel-disabled", !pinningAvailability.available);
pinningTabPanel.setAttribute("aria-disabled", pinningAvailability.available ? "false" : "true");
const pinningDisabled = state.busy || !canTargetEdit || !pinningAvailability.available;
const pinningTitle = !canTargetEdit ? permissionReason("edit_targets") : pinningAvailability.reason;
applyDisabledState(discussionPinningSelect, pinningDisabled, pinningTitle, !canTargetEdit);
applyDisabledState(deletePinServiceMessagesCheckbox, pinningDisabled, pinningTitle, !canTargetEdit);
const canEditScheduleDraft = !hasSchedule && canSave;
applyDisabledState(scheduleInput, state.busy || !canEditScheduleDraft, scheduleEditReason, !canSave);
applyContentEditControlState(globalUpload, context);
applyContentEditControlState(openGlobalImageImportButton, context);
syncImageImportModalState();
syncGlobalLinkPreviewToggle();
applyDisabledState(
disableLinkPreviewsGlobal,
state.busy || !canContentEdit,
!canContentEdit ? permissionReason("edit_content") : "",
!canContentEdit,
);
renderTelegramTargetSelects(targetsLocked, canTargetEdit, targetEditReason);
updateSaveButtonState();
setPermissionDisabledState(saveButton, !canSave);
const deleteExpired = isDeleteFromTelegramExpired();
let deleteTelegramTitle = "";
if (!can("delete_telegram")) {
deleteTelegramTitle = permissionReason("delete_telegram");
} else if (deleteExpired) {
deleteTelegramTitle = t("locked.delete_telegram_expired");
}
applyDisabledState(
deleteTelegramButton,
!isLinked() || state.busy || !can("delete_telegram") || deleteExpired,
deleteTelegramTitle,
!can("delete_telegram"),
);
applyDisabledState(
deleteStorageButton,
state.busy || !can("delete_storage"),
!can("delete_storage") ? permissionReason("delete_storage") : "",
!can("delete_storage"),
);
publishButton.disabled = state.busy || hasSchedule || metadata.status === "published" || !can("post_now") || !hasSelectedTelegramTarget;
publishButton.title = !can("post_now")
? permissionReason("post_now")
: (!hasSelectedTelegramTarget ? t("editor.target_required") : "");
publishButton.textContent = metadata.status === "modified" ? t("editor.post_modifications_now") : t("editor.post_now");
setPermissionDisabledState(publishButton, !can("post_now"));
scheduleButton.disabled = state.busy || !canUseSchedule || (!hasSchedule && !hasSelectedTelegramTarget) || (isLinked() && metadata.status !== "modified" && metadata.status !== "scheduled");
scheduleButton.title = !canUseSchedule
? permissionReason(hasSchedule ? "unschedule" : "schedule")
: ((!hasSchedule && !hasSelectedTelegramTarget) ? t("editor.target_required") : "");
scheduleButton.textContent = hasSchedule
? t("editor.unschedule")
: (metadata.status === "modified" ? t("editor.schedule_modifications") : t("editor.schedule"));
setPermissionDisabledState(scheduleButton, !canUseSchedule);
const unpinAllowedForState = isLinked() || metadata.status === "scheduled";
const unpinTitle = !canTargetEdit
? permissionReason("edit_targets")
: (!unpinAllowedForState ? t("editor.discussion_pinning_requires_links") : (!canUseUnpinSchedule ? permissionReason(hasScheduledUnpin ? "unschedule" : "schedule") : pinningAvailability.reason));
const unpinDisabled = state.busy || !unpinAllowedForState || !canTargetEdit || !canUseUnpinSchedule || !pinningAvailability.available;
applyDisabledState(unpinScheduleInput, unpinDisabled, unpinTitle, !canTargetEdit || !canUseUnpinSchedule);
applyDisabledState(unpinScheduleButton, unpinDisabled, unpinTitle, !canTargetEdit || !canUseUnpinSchedule);
unpinScheduleButton.textContent = hasScheduledUnpin ? t("editor.cancel_unpin") : t("editor.schedule_unpin");
}
function renderStaticSections(locked) {
renderImageList("global", document.querySelector('.image-list[data-scope="global"]'), locked);
renderLanguages(locked);
renderStatus();
renderTelegramBindingPanel();
renderTabLabels();
setActiveTab(state.activeTab);
renderSidebarAnnouncements();
}
function renderSidebarBusyState() {
for (const item of document.querySelectorAll(".announcement-item")) {
item.classList.toggle("disabled-nav", state.busy);
item.title = state.busy ? t("status.wait_finish") : "";
}
}
async function loadBroadcastIntoEditor(directoryName, { replaceHistory = false } = {}) {
const payload = await fetchJson(`/api/announcements/${encodeURIComponent(directoryName)}`, { method: "GET" });
state.operation = null;
closeImageImportModal();
stopOperationPolling();
setBusy(false, "");
applyApiPayload(payload, false);
render();
const url = announcementPageUrl();
if (replaceHistory) {
window.history.replaceState({ directory: directoryName }, "", url);
} else {
window.history.pushState({ directory: directoryName }, "", url);
}
const scroller = contentContainer || document.scrollingElement;
if (scroller) {
scroller.scrollTop = 0;
scroller.scrollLeft = 0;
}
}
function navigationDirectoryFromHref(href) {
const url = new URL(href, window.location.origin);
const match = url.pathname.match(/^\/announcements\/([^/]+)$/);
return match ? decodeURIComponent(match[1]) : null;
}
function shouldInterceptSidebarAnnouncementLink(link) {
if (!link.closest(".sidebar")) return false;
if (link.target === "_blank" || link.hasAttribute("download")) return false;
const href = link.getAttribute("href") || "";
return /^\/announcements\/[^/]+$/.test(href);
}
function render() {
const context = renderContext();
renderCoreFieldValues(context);
renderPrimaryControls(context);
renderStaticSections(context.locked);
renderSidebarBusyState();
}
function renderStatus() {
const metadata = state.broadcast.metadata;
const lines = [`${t("status.prefix")}: ${statusLabel(metadata.status)}`];
const canClearStatus = !!(metadata.last_attempt_at || metadata.last_output || metadata.last_result);
if (state.busy && state.busyMessage) {
lines.push(state.busyMessage);
}
if (metadata.scheduled_for) {
lines.push(`${t("status.scheduled_for")} (${state.broadcast.timezone}): ${formatTimestamp(metadata.scheduled_for)}`);
}
if (metadata.last_attempt_at) {
lines.push(`${lastAttemptLabel(metadata)} (${state.broadcast.timezone}): ${formatTimestamp(metadata.last_attempt_at)}`);
}
const output = state.operation && (state.operation.running || state.operation.completed)
? state.operation.output
: metadata.last_output;
if (output) {
lines.push("");
lines.push(output);
}
statusBoxContent.textContent = lines.join("\n");
clearStatusButton.hidden = !canClearStatus;
clearStatusButton.disabled = state.busy || !canSaveAnnouncements();
clearStatusButton.title = !canSaveAnnouncements() ? permissionReason("save_announcements") : "";
setPermissionDisabledState(clearStatusButton, !canSaveAnnouncements());
}
function telegramBinding() {
const binding = state.broadcast.metadata.telegram_binding;
return binding && typeof binding === "object" ? binding : null;
}
function bindingPrimaryRows(binding) {
const rows = [];
const globalPrimaryMessageId = Number(binding.global_primary_message_id || 0);
const globalDiscussionMessageId = Number(binding.global_discussion_message_id || 0);
if ((globalPrimaryMessageId || globalDiscussionMessageId) && !binding.global_attached_to_language) {
rows.push({
kind: "global",
label: t("editor.telegram_binding.global"),
channelMessageId: globalPrimaryMessageId || "",
discussionMessageId: globalDiscussionMessageId || "",
});
}
const languageBindings = binding.languages || {};
for (const language of state.broadcast.metadata.language_order) {
const languageBinding = languageBindings[language];
if (!languageBinding || typeof languageBinding !== "object") continue;
const primaryMessageId = Number(languageBinding.primary_message_id || 0) || "";
const discussionMessageId = Number(languageBinding.discussion_message_id || 0) || "";
if (!primaryMessageId && !discussionMessageId) continue;
rows.push({
kind: "language",
language,
label: displayName(language),
channelMessageId: primaryMessageId,
discussionMessageId,
});
}
return rows;
}
function mappingRows() {
const binding = telegramBinding();
const boundRows = new Map();
if (binding) {
for (const row of bindingPrimaryRows(binding)) {
const rowKey = row.kind === "global" ? "global" : row.language;
boundRows.set(rowKey, row);
}
}
const rows = [];
if (boundRows.has("global")) {
rows.push(boundRows.get("global"));
}
for (const language of state.broadcast.metadata.language_order) {
if (!state.broadcast.metadata.enabled_languages[language] && !boundRows.has(language)) {
continue;
}
rows.push(boundRows.get(language) || {
kind: "language",
language,
label: displayName(language),
channelMessageId: "",
discussionMessageId: "",
});
}
return rows;
}
function hasIncompleteTelegramMapping(binding = telegramBinding()) {
if (!binding) {
return false;
}
return bindingPrimaryRows(binding)
.filter((row) => row.kind === "language")
.some((row) => !row.channelMessageId || !row.discussionMessageId);
}
function selectedChannelLinkTarget() {
const chat = selectedChatTarget();
return {
username: String(chat?.channel_username || "").trim().replace(/^@/, ""),
internalId: String(chat?.channel_internal_id || "").trim(),
};
}
function selectedDiscussionLinkTarget() {
const chat = selectedChatTarget();
return {
username: "",
internalId: String(chat?.discussion_group_internal_id || state.broadcast.discussion_group_link_chat_id || "").trim(),
};
}
function normalizeTelegramLinkValue(value, target) {
const text = String(value || "").trim();
if (!text) {
return "";
}
if (/^\d+$/.test(text)) {
return text;
}
const cLinkMatch = /^(?:https?:\/\/)?(?:www\.)?(?:t\.me|telegram\.me)\/c\/(\d+)\/(\d+)(?:[/?#].*)?$/i.exec(text);
if (cLinkMatch) {
const [, chatId, messageId] = cLinkMatch;
if (target.internalId && chatId !== target.internalId) {
return "";
}
return messageId;
}
const publicLinkMatch = /^(?:https?:\/\/)?(?:www\.)?(?:t\.me|telegram\.me)\/([A-Za-z0-9_]+)\/(\d+)(?:[/?#].*)?$/i.exec(text);
if (publicLinkMatch) {
const [, username, messageId] = publicLinkMatch;
if (target.username && username.toLowerCase() !== target.username.toLowerCase()) {
return "";
}
return messageId;
}
return "";
}
function buildTelegramMessageUrl(messageId, target) {
const resolvedId = String(messageId || "").trim();
if (!resolvedId) {
return "";
}
if (target.username) {
return `https://t.me/${target.username}/${resolvedId}`;
}
if (target.internalId) {
return `https://t.me/c/${target.internalId}/${resolvedId}`;
}
return "";
}
function telegramMappingRowKey(row) {
return row.kind === "global" ? "global" : row.language;
}
function telegramMappingEditReason(canEdit, hasTarget) {
if (!canEdit) {
return permissionReason("edit_targets");
}
if (!hasTarget) {
return t("editor.target_required");
}
return "";
}
function telegramMappingGroupBlockReason(groupLinksAvailable, row) {
if (groupLinksAvailable && !row.channelMessageId) {
return t("editor.group_link_requires_channel");
}
if (!groupLinksAvailable) {
return t("editor.discussion_pinning_requires_group");
}
return "";
}
function telegramMappingRowsPayload(targetRow, side, normalizedValue) {
const targetRowKey = telegramMappingRowKey(targetRow);
return mappingRows().map((currentRow) => {
const isSameRow = telegramMappingRowKey(currentRow) === targetRowKey;
const channelMessageId = isSameRow
? (side === "channel" ? normalizedValue : String(currentRow.channelMessageId || "").trim())
: String(currentRow.channelMessageId || "").trim();
const discussionMessageId = isSameRow
? (side === "group" ? normalizedValue : String(currentRow.discussionMessageId || "").trim())
: String(currentRow.discussionMessageId || "").trim();
return {
kind: currentRow.kind,
language: currentRow.language || null,
channel_message_id: channelMessageId || null,
discussion_message_id: discussionMessageId || null,
};
});
}
function saveTelegramMappingCell(row, side, rawValue, channelTarget, discussionTarget) {
const target = side === "channel" ? channelTarget : discussionTarget;
const normalized = normalizeTelegramLinkValue(rawValue, target);
return actionPost("/set-telegram-mapping", {
telegram_bot_id: state.broadcast.metadata.telegram_bot_id || "",
telegram_chat_id: state.broadcast.metadata.telegram_chat_id || "",
rows: telegramMappingRowsPayload(row, side, normalized),
}, {
busyMessage: t("status.saving_telegram_mapping"),
preserveLocalEdits: true,
});
}
function buildTelegramMappingActionButton(label, href, disabled, reason) {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.disabled = disabled || !href;
button.title = reason || "";
if (href && !disabled) {
button.addEventListener("click", () => {
window.open(href, "_blank", "noopener,noreferrer");
});
}
return button;
}
function buildTelegramMappingLinkedCell({
row,
side,
messageId,
target,
canEdit,
canOpen,
groupBlocked,
groupBlockedReason,
channelUnlinkBlocked,
channelTarget,
discussionTarget,
}) {
const cell = document.createElement("td");
cell.className = "telegram-mapping-cell";
const actions = document.createElement("div");
actions.className = "telegram-cell-actions";
actions.appendChild(buildTelegramMappingActionButton(
t("editor.open"),
buildTelegramMessageUrl(messageId, target),
state.busy || !canOpen || groupBlocked,
!canOpen
? permissionReason("open_telegram")
: (groupBlocked ? groupBlockedReason : ""),
));
const unlinkCellButton = document.createElement("button");
unlinkCellButton.type = "button";
unlinkCellButton.textContent = t("editor.unlink_message_id").replace("{id}", String(messageId));
unlinkCellButton.disabled = state.busy || !canEdit || groupBlocked || channelUnlinkBlocked;
unlinkCellButton.title = !canEdit
? permissionReason("edit_targets")
: (groupBlocked ? groupBlockedReason : (channelUnlinkBlocked ? t("editor.unlink_channel_requires_group_clear") : ""));
setPermissionDisabledState(unlinkCellButton, !canEdit);
unlinkCellButton.addEventListener("click", () => {
saveTelegramMappingCell(row, side, "", channelTarget, discussionTarget).catch((error) => alert(error.message));
});
actions.appendChild(unlinkCellButton);
cell.appendChild(actions);
return cell;
}
function buildTelegramMappingEditableCell({
row,
side,
canEdit,
hasTarget,
editReason,
groupBlocked,
groupBlockedReason,
channelTarget,
discussionTarget,
}) {
const cell = document.createElement("td");
cell.className = "telegram-mapping-cell";
const editor = document.createElement("div");
editor.className = "telegram-cell-editor";
const input = document.createElement("input");
input.type = "text";
input.value = "";
input.placeholder = t("editor.telegram_binding_manual_placeholder");
input.disabled = state.busy || !canEdit || !hasTarget || groupBlocked;
input.title = groupBlocked ? groupBlockedReason : editReason;
const saveButton = document.createElement("button");
saveButton.type = "button";
saveButton.textContent = t("editor.save");
saveButton.disabled = state.busy || !canEdit || !hasTarget || groupBlocked;
saveButton.title = input.title;
setPermissionDisabledState(saveButton, !canEdit);
const submit = () => {
saveTelegramMappingCell(row, side, input.value, channelTarget, discussionTarget)
.catch((error) => alert(error.message));
};
saveButton.addEventListener("click", submit);
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
submit();
}
});
editor.appendChild(input);
editor.appendChild(saveButton);
cell.appendChild(editor);
return cell;
}
function buildTelegramMappingCell({
row,
side,
channelTarget,
discussionTarget,
canEdit,
canOpen,
hasTarget,
editReason,
groupLinksAvailable,
}) {
const messageId = side === "channel" ? row.channelMessageId : row.discussionMessageId;
const target = side === "channel" ? channelTarget : discussionTarget;
const groupBlocked = side === "group" && (!groupLinksAvailable || !row.channelMessageId);
const groupBlockedReason = telegramMappingGroupBlockReason(groupLinksAvailable, row);
if (messageId) {
return buildTelegramMappingLinkedCell({
row,
side,
messageId,
target,
canEdit,
canOpen,
groupBlocked,
groupBlockedReason,
channelUnlinkBlocked: side === "channel" && !!row.discussionMessageId,
channelTarget,
discussionTarget,
});
}
return buildTelegramMappingEditableCell({
row,
side,
canEdit,
hasTarget,
editReason,
groupBlocked,
groupBlockedReason,
channelTarget,
discussionTarget,
});
}
function renderTelegramBindingPanel() {
telegramBindingPanel.innerHTML = "";
const canEdit = canEditTargets();
const hasTarget = hasSelectedTarget();
const editReason = telegramMappingEditReason(canEdit, hasTarget);
const body = document.createElement("div");
body.className = "telegram-binding-body";
const help = document.createElement("p");
help.className = "telegram-binding-help";
help.textContent = t("editor.telegram_binding_help");
body.appendChild(help);
const rows = mappingRows();
const table = document.createElement("table");
table.className = "telegram-mapping-table";
const head = document.createElement("thead");
head.innerHTML = `
<tr>
<th></th>
<th>${t("editor.telegram_binding.channel_short")}</th>
<th>${t("editor.telegram_binding.group_short")}</th>
</tr>
`;
table.appendChild(head);
const bodyElement = document.createElement("tbody");
const colgroup = document.createElement("colgroup");
colgroup.innerHTML = `
<col class="telegram-mapping-col-label">
<col class="telegram-mapping-col-link">
<col class="telegram-mapping-col-link">
`;
table.appendChild(colgroup);
const channelTarget = selectedChannelLinkTarget();
const discussionTarget = selectedDiscussionLinkTarget();
const groupLinksAvailable = supportsGroupLinks();
for (const row of rows) {
const rowWrap = document.createElement("tr");
rowWrap.className = "telegram-mapping-row";
const label = document.createElement("th");
label.className = "telegram-mapping-label";
label.textContent = row.label;
rowWrap.appendChild(label);
for (const side of ["channel", "group"]) {
const cell = buildTelegramMappingCell({
row,
side,
channelTarget,
discussionTarget,
canEdit,
canOpen: canOpenTelegram(),
hasTarget,
editReason,
groupLinksAvailable,
});
rowWrap.appendChild(cell);
}
bodyElement.appendChild(rowWrap);
}
table.appendChild(bodyElement);
body.appendChild(table);
telegramBindingPanel.appendChild(body);
}
function createLanguageTitleToggle(language, enabled, locked, editReason) {
const toggleLabel = document.createElement("label");
toggleLabel.className = "language-title-toggle";
toggleLabel.title = editReason;
const toggle = document.createElement("input");
toggle.type = "checkbox";
toggle.checked = enabled;
toggle.disabled = locked || state.busy || !canEditContent();
toggle.title = editReason;
setPermissionDisabledState(toggle, !canEditContent());
toggle.addEventListener("change", () => {
state.broadcast.metadata.enabled_languages[language] = toggle.checked;
renderPreservingContentScroll(() => {
render();
});
});
toggleLabel.appendChild(toggle);
const title = document.createElement("h2");
title.textContent = displayName(language);
toggleLabel.appendChild(title);
return toggleLabel;
}
function createLanguagePreviewToggle(language) {
const disablePreviewLabel = document.createElement("label");
disablePreviewLabel.className = "checkbox-label language-subtoggle";
disablePreviewLabel.title = !canEditContent() ? permissionReason("edit_content") : "";
const disablePreviewToggle = document.createElement("input");
disablePreviewToggle.type = "checkbox";
disablePreviewToggle.className = "disable-link-preview-toggle";
disablePreviewToggle.dataset.language = language;
disablePreviewToggle.checked = !!state.broadcast.metadata.disable_link_previews?.[language];
disablePreviewToggle.disabled = state.busy || !canEditContent();
disablePreviewToggle.title = !canEditContent() ? permissionReason("edit_content") : "";
setPermissionDisabledState(disablePreviewToggle, !canEditContent());
disablePreviewToggle.addEventListener("change", () => {
state.broadcast.metadata.disable_link_previews[language] = disablePreviewToggle.checked;
syncGlobalLinkPreviewToggle();
updateSaveButtonState();
});
disablePreviewLabel.appendChild(disablePreviewToggle);
const disablePreviewText = document.createElement("span");
disablePreviewText.textContent = t("editor.disable_link_previews");
disablePreviewLabel.appendChild(disablePreviewText);
return disablePreviewLabel;
}
function createLanguageHeading(language, enabled, locked, editReason) {
const toolbar = document.createElement("div");
toolbar.className = "language-toolbar";
const heading = document.createElement("div");
heading.className = "language-heading";
heading.appendChild(createLanguageTitleToggle(language, enabled, locked, editReason));
if (enabled) {
heading.appendChild(createLanguagePreviewToggle(language));
}
toolbar.appendChild(heading);
return toolbar;
}
function createLanguageUploadControls(language, locked, editReason) {
const uploadWrap = document.createElement("div");
uploadWrap.className = "image-controls";
uploadWrap.title = editReason;
const uploadLabel = document.createElement("span");
uploadLabel.className = "upload-label";
uploadLabel.textContent = t("editor.add_images_for");
uploadWrap.appendChild(uploadLabel);
const uploadInput = document.createElement("input");
uploadInput.type = "file";
uploadInput.accept = "image/*";
uploadInput.multiple = true;
uploadInput.disabled = locked || state.busy || !canEditContent();
uploadInput.title = editReason;
setPermissionDisabledState(uploadInput, !canEditContent());
uploadInput.addEventListener("change", async () => {
if (!uploadInput.files || uploadInput.files.length === 0) return;
try {
await uploadImages(language, uploadInput.files);
uploadInput.value = "";
} catch (error) {
alert(error.message);
}
});
uploadWrap.appendChild(uploadInput);
return uploadWrap;
}
function languageFormattingActions(language, snippet, flagsSnippet) {
return [
{ labelKey: "editor.format_bold", action: { type: "inline_style", style: "bold" } },
{ labelKey: "editor.format_italic", action: { type: "inline_style", style: "italic" } },
{ labelKey: "editor.format_underline", action: { type: "inline_style", style: "underline" } },
{ labelKey: "editor.format_strikethrough", action: { type: "inline_style", style: "strikethrough" } },
{ labelKey: "editor.format_code", action: { type: "code" } },
{ labelKey: "editor.format_spoiler", action: { type: "spoiler" } },
{ labelKey: "editor.format_link", action: { type: "link" } },
{ labelKey: "editor.insert_language_links", action: { type: "language_links", snippet } },
{ labelKey: "editor.insert_language_flags", action: { type: "language_flags", snippet: flagsSnippet } },
{ labelKey: "editor.format_blockquote", action: { type: "blockquote" } },
{ labelKey: "editor.format_bullets", action: { type: "bullet_list" } },
{ labelKey: "editor.format_numbered_list", action: { type: "numbered_list" } },
];
}
function createLanguageFormatToolbar(language, textarea, previewContent, snippet, flagsSnippet) {
const formatToolbar = document.createElement("div");
formatToolbar.className = "format-toolbar";
for (const item of languageFormattingActions(language, snippet, flagsSnippet)) {
const missingLanguageLinks = item.action.type === "language_links" && !snippet;
formatToolbar.appendChild(createSecondaryButton({
label: t(item.labelKey),
disabled: state.busy || !canEditContent() || missingLanguageLinks,
title: missingLanguageLinks
? t("editor.no_language_links_available")
: (contentEditReasonFor(false) || t(item.labelKey)),
permissionDenied: !canEditContent(),
onClick: () => applyFormattingAction(textarea, language, item.action, previewContent),
}));
}
return formatToolbar;
}
function createLanguageEditorLayout(language, snippet, flagsSnippet) {
const splitLayout = document.createElement("div");
splitLayout.className = "editor-preview-layout";
const previewPanel = document.createElement("div");
previewPanel.className = "preview-panel";
previewPanel.hidden = false;
const previewContent = document.createElement("div");
previewContent.className = "preview-content";
previewPanel.appendChild(previewContent);
const textarea = document.createElement("textarea");
textarea.value = state.broadcast.texts[language] || "";
textarea.disabled = state.busy || !canEditContent();
textarea.title = !canEditContent() ? permissionReason("edit_content") : "";
setPermissionDisabledState(textarea, !canEditContent());
textarea._previewPanel = previewPanel;
previewContent._textarea = textarea;
textarea.addEventListener("input", () => {
state.broadcast.texts[language] = textarea.value;
syncTextareaHeight(textarea);
updateSaveButtonState();
schedulePreviewUpdate(language, previewContent);
});
textarea.addEventListener("keydown", (event) => {
handleTextareaShortcut(event, textarea, language, previewContent);
});
const inputPanel = document.createElement("div");
inputPanel.className = "editor-input-panel";
inputPanel.appendChild(createLanguageFormatToolbar(language, textarea, previewContent, snippet, flagsSnippet));
inputPanel.appendChild(textarea);
splitLayout.appendChild(inputPanel);
splitLayout.appendChild(previewPanel);
return { splitLayout, textarea, previewContent };
}
function createLanguagePanel(language, locked) {
const panel = document.createElement("section");
panel.className = "language-panel";
const enabled = !!state.broadcast.metadata.enabled_languages[language];
if (!enabled) {
panel.classList.add("disabled");
}
const editReason = contentEditReasonFor(locked);
const snippet = buildCrossLanguageSnippet(language);
const flagsSnippet = buildLanguageFlagsSnippet();
panel.appendChild(createLanguageHeading(language, enabled, locked, editReason));
const body = document.createElement("div");
body.className = "language-body";
body.appendChild(createLanguageUploadControls(language, locked, editReason));
body.appendChild(buildImagePasteControls(language, locked, editReason));
const imageList = document.createElement("div");
imageList.className = "image-list";
renderImageList(language, imageList, locked);
body.appendChild(imageList);
const { splitLayout, textarea, previewContent } = createLanguageEditorLayout(language, snippet, flagsSnippet);
body.appendChild(splitLayout);
panel.appendChild(body);
return { panel, textarea, previewContent };
}
function renderLanguages(locked) {
languagesContainer.innerHTML = "";
for (const language of state.broadcast.metadata.language_order) {
const { panel, textarea, previewContent } = createLanguagePanel(language, locked);
languagesContainer.appendChild(panel);
syncTextareaHeight(textarea);
schedulePreviewUpdate(language, previewContent);
}
}
function renderImageList(scope, container, locked) {
container.innerHTML = "";
const images = state.broadcast.images[scope] || [];
const editReason = contentEditReasonFor(locked);
images.forEach((image, index) => {
const card = document.createElement("div");
card.className = "image-card";
card.draggable = !locked && !state.busy && canEditContent();
card.dataset.index = String(index);
card.addEventListener("dragstart", (event) => {
event.dataTransfer.setData("text/plain", String(index));
});
card.addEventListener("dragover", (event) => event.preventDefault());
card.addEventListener("drop", (event) => {
event.preventDefault();
const sourceIndex = Number(event.dataTransfer.getData("text/plain"));
reorderImages(scope, sourceIndex, index);
});
const img = document.createElement("img");
img.src = image.url;
img.alt = image.name;
img.draggable = false;
card.appendChild(img);
const label = document.createElement("small");
label.textContent = image.name;
card.appendChild(label);
const moveControls = document.createElement("div");
moveControls.className = "move-controls";
moveControls.title = editReason;
const moveLeft = document.createElement("button");
moveLeft.type = "button";
moveLeft.textContent = t("editor.move_left");
moveLeft.disabled = locked || state.busy || !canEditContent() || index === 0;
moveLeft.title = editReason;
setPermissionDisabledState(moveLeft, !canEditContent());
moveLeft.addEventListener("click", () => reorderImages(scope, index, index - 1));
moveControls.appendChild(moveLeft);
const moveRight = document.createElement("button");
moveRight.type = "button";
moveRight.textContent = t("editor.move_right");
moveRight.disabled = locked || state.busy || !canEditContent() || index === images.length - 1;
moveRight.title = editReason;
setPermissionDisabledState(moveRight, !canEditContent());
moveRight.addEventListener("click", () => reorderImages(scope, index, index + 1));
moveControls.appendChild(moveRight);
card.appendChild(moveControls);
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = t("editor.remove_image");
remove.disabled = locked || state.busy || !canEditContent();
remove.title = editReason;
setPermissionDisabledState(remove, !canEditContent());
remove.addEventListener("click", () => {
state.broadcast.images[scope] = images.filter((_, itemIndex) => itemIndex !== index);
render();
});
card.appendChild(remove);
container.appendChild(card);
});
}
async function uploadImage(scope, file) {
const formData = new FormData();
formData.append("scope", scope);
formData.append("file", file, file.name);
const payload = await fetchJson(announcementUrl("/upload-image"), {
method: "POST",
body: formData,
});
state.broadcast.images[scope].push(payload);
render();
}
async function uploadImages(scope, files) {
for (const file of Array.from(files)) {
await uploadImage(scope, file);
}
}
function buildImagePasteControls(scope, locked, editReason) {
const wrap = document.createElement("div");
wrap.className = "image-paste-controls";
wrap.appendChild(createSecondaryButton({
label: t("editor.open_image_import"),
disabled: locked || state.busy || !canEditContent(),
title: editReason,
permissionDenied: !canEditContent(),
onClick: () => openImageImportModal(scope),
}));
return wrap;
}
function openImageImportModal(scope) {
if (state.busy || !canEditContent() || isTextOnlyEditable()) {
return;
}
state.imageImportScope = scope;
imageImportFormatSelect.value = "auto";
imageImportUrlInput.value = "";
imageImportModal.hidden = false;
syncImageImportModalState();
imageImportUrlInput.focus();
}
function closeImageImportModal() {
state.imageImportScope = null;
imageImportModal.hidden = true;
imageImportUrlInput.value = "";
}
function syncImageImportModalState() {
const disabled = state.busy || !canEditContent() || isTextOnlyEditable() || !state.imageImportScope;
const reason = contentEditReasonFor();
imageImportFormatSelect.disabled = disabled;
imageImportFormatSelect.title = disabled ? reason : "";
imageImportUrlInput.disabled = disabled;
imageImportUrlInput.title = disabled ? reason : "";
imageImportSubmitUrlButton.disabled = disabled;
imageImportSubmitUrlButton.title = disabled ? reason : "";
}
async function importImageUrl(scope, url) {
const payload = await fetchJson(announcementUrl("/import-image-url"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope, url }),
});
state.broadcast.images[scope].push(payload);
render();
}
async function submitImageImportUrl() {
if (!state.imageImportScope) return;
const url = imageImportUrlInput.value.trim();
if (!url) return;
await importImageUrl(state.imageImportScope, url);
closeImageImportModal();
}
function clipboardImageFile(clipboardData) {
if (!clipboardData?.items) {
return null;
}
for (const item of Array.from(clipboardData.items)) {
if (!item.type.startsWith("image/")) continue;
const file = item.getAsFile();
if (file) {
return file;
}
}
return null;
}
async function uploadPastedImage(scope, blob, format) {
const convertedFile = await convertImageBlobToFile(blob, format);
await uploadImage(scope, convertedFile);
}
async function detectPreferredImageFormat(blob) {
if (blob.type === "image/jpeg") {
return "jpeg";
}
let bitmap;
try {
bitmap = await createImageBitmap(blob);
const maxDimension = 128;
const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height));
const sampleWidth = Math.max(1, Math.round(bitmap.width * scale));
const sampleHeight = Math.max(1, Math.round(bitmap.height * scale));
const canvas = document.createElement("canvas");
canvas.width = sampleWidth;
canvas.height = sampleHeight;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
return "png";
}
context.drawImage(bitmap, 0, 0, sampleWidth, sampleHeight);
const imageData = context.getImageData(0, 0, sampleWidth, sampleHeight).data;
const colorBuckets = new Set();
for (let index = 0; index < imageData.length; index += 4) {
if (imageData[index + 3] < 250) {
return "png";
}
const bucket =
((imageData[index] >> 4) << 8)
| ((imageData[index + 1] >> 4) << 4)
| (imageData[index + 2] >> 4);
colorBuckets.add(bucket);
if (colorBuckets.size > 224) {
return "jpeg";
}
}
return "png";
} catch {
return "png";
} finally {
bitmap?.close?.();
}
}
async function convertImageBlobToFile(blob, format) {
const resolvedFormat = format === "auto" ? await detectPreferredImageFormat(blob) : format;
const mimeType = resolvedFormat === "png" ? "image/png" : "image/jpeg";
const extension = resolvedFormat === "png" ? "png" : "jpg";
if (blob.type === mimeType) {
return new File([blob], `clipboard-image.${extension}`, { type: mimeType });
}
let bitmap;
try {
bitmap = await createImageBitmap(blob);
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error(t("alert.image_conversion_failed"));
}
if (resolvedFormat === "jpeg") {
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
}
context.drawImage(bitmap, 0, 0);
const convertedBlob = await new Promise((resolve, reject) => {
canvas.toBlob(
(result) => {
if (result) {
resolve(result);
} else {
reject(new Error(t("alert.image_conversion_failed")));
}
},
mimeType,
resolvedFormat === "jpeg" ? 0.92 : undefined,
);
});
return new File([convertedBlob], `clipboard-image.${extension}`, { type: mimeType });
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error(t("alert.image_conversion_failed"));
} finally {
bitmap?.close?.();
}
}
function reorderImages(scope, sourceIndex, targetIndex) {
if (sourceIndex === targetIndex) return;
const images = clone(state.broadcast.images[scope]);
const [moved] = images.splice(sourceIndex, 1);
images.splice(targetIndex, 0, moved);
state.broadcast.images[scope] = images;
render();
}
function buildSavePayload() {
const metadata = state.broadcast.metadata;
const savedMetadata = state.savedBroadcast.metadata;
const payload = { texts: {}, images: {} };
if (metadata.title !== savedMetadata.title) {
payload.title = {
base_revision: metadata.field_revisions.title,
value: metadata.title,
};
}
if (!areTargetsLocked()) {
if ((metadata.telegram_bot_id || "") !== (savedMetadata.telegram_bot_id || "")) {
payload.telegram_bot_id = {
base_revision: metadata.field_revisions.telegram_bot_id,
value: metadata.telegram_bot_id || "",
};
}
if ((metadata.telegram_chat_id || "") !== (savedMetadata.telegram_chat_id || "")) {
payload.telegram_chat_id = {
base_revision: metadata.field_revisions.telegram_chat_id,
value: metadata.telegram_chat_id || "",
};
}
}
if (!isLinked()) {
if (JSON.stringify(metadata.language_order) !== JSON.stringify(savedMetadata.language_order)) {
payload.language_order = {
base_revision: metadata.field_revisions.language_order,
value: [...metadata.language_order],
};
}
if (JSON.stringify(metadata.enabled_languages) !== JSON.stringify(savedMetadata.enabled_languages)) {
payload.enabled_languages = {
base_revision: metadata.field_revisions.enabled_languages,
value: clone(metadata.enabled_languages),
};
}
for (const scope of ["global", "fi", "sv", "en"]) {
const currentNames = (state.broadcast.images[scope] || []).map((item) => item.name);
const savedNames = (state.savedBroadcast.images[scope] || []).map((item) => item.name);
if (JSON.stringify(currentNames) !== JSON.stringify(savedNames)) {
payload.images[scope] = {
base_revision: metadata.field_revisions[`${scope}.images`],
value: currentNames,
};
}
}
}
if ((metadata.discussion_pinning_mode || "first") !== (savedMetadata.discussion_pinning_mode || "first")) {
payload.discussion_pinning_mode = {
base_revision: metadata.field_revisions.discussion_pinning_mode,
value: metadata.discussion_pinning_mode || "first",
};
}
if (!!metadata.delete_pin_service_messages !== !!savedMetadata.delete_pin_service_messages) {
payload.delete_pin_service_messages = {
base_revision: metadata.field_revisions.delete_pin_service_messages,
value: !!metadata.delete_pin_service_messages,
};
}
if ((metadata.schedule_draft_for || "") !== (savedMetadata.schedule_draft_for || "")) {
payload.schedule_draft_for = {
base_revision: metadata.field_revisions.schedule_draft_for,
value: metadata.schedule_draft_for || "",
};
}
if (JSON.stringify(metadata.disable_link_previews || {}) !== JSON.stringify(savedMetadata.disable_link_previews || {})) {
payload.disable_link_previews = {
base_revision: metadata.field_revisions.disable_link_previews,
value: clone(metadata.disable_link_previews || {}),
};
}
for (const language of ["fi", "sv", "en"]) {
const currentText = state.broadcast.texts[language] || "";
const savedText = state.savedBroadcast.texts[language] || "";
if (normalizeComparableText(currentText) !== normalizeComparableText(savedText)) {
payload.texts[language] = {
base_revision: metadata.field_revisions[`${language}.text`],
value: currentText,
};
}
}
return payload;
}
async function saveBroadcast() {
const previousDirectory = state.broadcast.directory;
const payload = await fetchJson(announcementUrl("/save"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildSavePayload()),
});
applyApiPayload(payload, false);
if (state.broadcast.directory !== previousDirectory) {
window.history.replaceState({}, "", announcementPageUrl());
}
render();
}
async function startPublishOperation() {
if (!confirmOutboundAction("publish")) return;
setBusy(true, state.broadcast.metadata.status === "modified" ? t("status.posting_modifications") : t("status.posting"));
try {
const payload = await fetchJson(announcementUrl("/publish"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
applyApiPayload(payload, false);
state.operation = payload.operation || null;
render();
startOperationPolling();
} catch (error) {
setBusy(false, "");
throw error;
}
}
function startOperationPolling() {
stopOperationPolling();
state.pollTimer = window.setInterval(async () => {
try {
const payload = await fetchJson(announcementUrl("/operation"), { method: "GET" });
state.operation = payload.operation || null;
applyApiPayload(payload, false);
render();
if (!state.operation || !state.operation.running) {
stopOperationPolling();
setBusy(false, "");
}
} catch (error) {
stopOperationPolling();
setBusy(false, "");
alert(error.message);
}
}, 1000);
}
function stopOperationPolling() {
if (state.pollTimer !== null) {
window.clearInterval(state.pollTimer);
state.pollTimer = null;
}
}
async function actionPost(path, body, { busyMessage = t("status.busy_updating"), preserveLocalEdits = false } = {}) {
setBusy(true, busyMessage);
try {
const payload = await fetchJson(announcementUrl(path), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : "{}",
});
if (payload.broadcast || payload.announcements) {
applyApiPayload(payload, preserveLocalEdits);
window.history.replaceState({}, "", announcementPageUrl());
}
if (payload.warning) {
alert(payload.warning);
}
state.operation = null;
render();
return payload;
} finally {
setBusy(false, "");
}
}
saveButton.addEventListener("click", async () => {
try {
await saveBroadcast();
} catch (error) {
alert(error.message);
}
});
publishButton.addEventListener("click", async () => {
try {
await startPublishOperation();
} catch (error) {
alert(error.message);
}
});
scheduleButton.addEventListener("click", async () => {
try {
if (state.broadcast.metadata.scheduled_for) {
if (!confirmUnsavedChanges("confirm.navigate_unsaved")) {
return;
}
await actionPost("/unschedule", null, { busyMessage: t("status.unscheduling") });
return;
}
if (!scheduleInput.value) {
alert(t("alert.pick_schedule"));
return;
}
if (!confirmOutboundAction("schedule")) {
return;
}
await actionPost("/schedule", { scheduled_for: scheduleInput.value }, {
busyMessage: state.broadcast.metadata.status === "modified" ? t("status.scheduling_modifications") : t("status.scheduling"),
preserveLocalEdits: hasUnsavedChanges(),
});
} catch (error) {
alert(error.message);
}
});
for (const button of tabButtons) {
button.addEventListener("click", () => setActiveTab(button.dataset.tab || "main"));
}
deleteTelegramButton.addEventListener("click", () => {
if (!confirmUnsavedChanges("confirm.navigate_unsaved")) return;
if (!confirm(t("confirm.delete_telegram"))) return;
actionPost(
"/delete-telegram",
{
telegram_bot_id: state.broadcast.metadata.telegram_bot_id || "",
telegram_chat_id: state.broadcast.metadata.telegram_chat_id || "",
},
{ busyMessage: t("status.deleting_telegram") },
).catch((error) => alert(error.message));
});
clearStatusButton.addEventListener("click", () => {
actionPost("/clear-status", null, { busyMessage: t("status.clearing_status") }).catch((error) => alert(error.message));
});
unpinScheduleButton.addEventListener("click", async () => {
try {
if (state.broadcast.metadata.scheduled_unpin_for) {
await actionPost("/cancel-unpin", null, { busyMessage: t("status.canceling_unpin") });
return;
}
if (!unpinScheduleInput.value) {
alert(t("alert.pick_schedule"));
return;
}
await actionPost("/schedule-unpin", { scheduled_for: unpinScheduleInput.value }, {
busyMessage: t("status.scheduling_unpin"),
});
} catch (error) {
alert(error.message);
}
});
deleteStorageButton.addEventListener("click", async () => {
if (!confirmUnsavedChanges("confirm.navigate_unsaved")) return;
if (!confirm(t("confirm.delete_storage"))) return;
try {
await actionPost("/delete-storage", null, { busyMessage: t("status.deleting_storage") });
window.location.href = "/";
} catch (error) {
alert(error.message);
}
});
globalUpload.addEventListener("change", async () => {
if (!globalUpload.files || globalUpload.files.length === 0) return;
try {
await uploadImages("global", globalUpload.files);
globalUpload.value = "";
} catch (error) {
alert(error.message);
}
});
openGlobalImageImportButton.addEventListener("click", () => {
openImageImportModal("global");
});
imageImportCloseButton.addEventListener("click", closeImageImportModal);
imageImportCancelButton.addEventListener("click", closeImageImportModal);
imageImportSubmitUrlButton.addEventListener("click", () => {
submitImageImportUrl().catch((error) => alert(error.message));
});
imageImportUrlInput.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
submitImageImportUrl().catch((error) => alert(error.message));
});
imageImportModal.addEventListener("click", (event) => {
if (event.target === imageImportModal) {
closeImageImportModal();
}
});
imageImportModal.addEventListener("paste", (event) => {
const clipboardFile = clipboardImageFile(event.clipboardData);
if (!clipboardFile || !state.imageImportScope) {
return;
}
event.preventDefault();
uploadPastedImage(state.imageImportScope, clipboardFile, imageImportFormatSelect.value || "auto")
.then(() => closeImageImportModal())
.catch((error) => alert(error.message));
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !imageImportModal.hidden) {
closeImageImportModal();
}
});
titleInput.addEventListener("input", () => {
state.broadcast.metadata.title = titleInput.value;
updateSaveButtonState();
});
telegramBotSelect.addEventListener("change", () => {
state.broadcast.metadata.telegram_bot_id = telegramBotSelect.value;
const defaultChatId = defaultChatIdForBot(telegramBotSelect.value);
state.broadcast.metadata.telegram_chat_id = defaultChatId;
render();
});
telegramChatSelect.addEventListener("change", () => {
state.broadcast.metadata.telegram_chat_id = telegramChatSelect.value;
updateSaveButtonState();
render();
});
orderSelect.addEventListener("change", () => {
state.broadcast.metadata.language_order = orderSelect.value.split(",");
render();
});
discussionPinningSelect.addEventListener("change", () => {
state.broadcast.metadata.discussion_pinning_mode = discussionPinningSelect.value;
updateSaveButtonState();
});
deletePinServiceMessagesCheckbox.addEventListener("change", () => {
state.broadcast.metadata.delete_pin_service_messages = deletePinServiceMessagesCheckbox.checked;
updateSaveButtonState();
});
disableLinkPreviewsGlobal.addEventListener("change", () => {
const nextValue = disableLinkPreviewsGlobal.checked;
for (const language of state.broadcast.metadata.language_order) {
state.broadcast.metadata.disable_link_previews[language] = nextValue;
const languageToggle = document.querySelector(`.disable-link-preview-toggle[data-language="${language}"]`);
if (languageToggle) {
languageToggle.checked = nextValue;
}
}
syncGlobalLinkPreviewToggle();
updateSaveButtonState();
});
scheduleInput.addEventListener("input", () => {
state.localScheduleValue = scheduleInput.value;
state.broadcast.metadata.schedule_draft_for = scheduleInput.value || null;
updateSaveButtonState();
});
unpinScheduleInput.addEventListener("input", () => {
state.localUnpinScheduleValue = unpinScheduleInput.value;
});
document.addEventListener("click", (event) => {
const link = event.target.closest("a");
if (!link) return;
if (link.closest(".sidebar")) {
persistSidebarScrollPosition();
}
if (
shouldInterceptSidebarAnnouncementLink(link)
&& event.button === 0
&& !event.metaKey
&& !event.ctrlKey
&& !event.shiftKey
&& !event.altKey
) {
event.preventDefault();
if (state.busy) {
return;
}
if (!confirmUnsavedChanges("confirm.navigate_unsaved")) {
return;
}
const directoryName = navigationDirectoryFromHref(link.href);
if (!directoryName || directoryName === state.broadcast.directory) {
return;
}
loadBroadcastIntoEditor(directoryName).catch((error) => alert(error.message));
return;
}
if (state.busy) {
event.preventDefault();
return;
}
if (!hasUnsavedChanges()) return;
if (link.target === "_blank") return;
const href = link.getAttribute("href") || "";
if (!href.startsWith("/")) return;
if (!window.confirm(t("confirm.navigate_unsaved"))) {
event.preventDefault();
}
});
document.addEventListener("submit", (event) => {
if (state.busy || !hasUnsavedChanges()) return;
const form = event.target;
if (!(form instanceof HTMLFormElement)) return;
if (form.closest(".sidebar")) {
persistSidebarScrollPosition();
}
if (!window.confirm(t("confirm.navigate_unsaved"))) {
event.preventDefault();
}
});
window.addEventListener("resize", syncAllTextareaHeights);
window.addEventListener("popstate", () => {
const path = window.location.pathname;
const match = path.match(/^\/announcements\/([^/]+)$/);
if (!confirmUnsavedChanges("confirm.navigate_unsaved")) {
window.history.pushState({ directory: state.broadcast.directory }, "", announcementPageUrl());
return;
}
if (!match) {
window.location.reload();
return;
}
const directoryName = decodeURIComponent(match[1]);
if (directoryName === state.broadcast.directory) {
return;
}
if (state.busy) {
window.location.reload();
return;
}
loadBroadcastIntoEditor(directoryName, { replaceHistory: true }).catch(() => {
window.location.reload();
});
});
if (sidebarContainer) {
sidebarContainer.addEventListener("scroll", persistSidebarScrollPosition, { passive: true });
}
window.addEventListener("beforeunload", persistSidebarScrollPosition);
state.localScheduleValue = scheduleInputValueForMetadata(state.broadcast.metadata);
state.localUnpinScheduleValue = unpinScheduleInputValueForMetadata(state.broadcast.metadata);
restoreSidebarScrollPosition();
render();
function displayName(language) {
return t(`language.${language}`, language);
}
function renderLanguageOrderOptions() {
for (const option of orderSelect.options) {
const languages = String(option.value || "").split(",");
option.textContent = languages.map((language) => {
const label = displayName(language);
return state.broadcast.metadata.enabled_languages[language] ? label : `(${label})`;
}).join(" / ");
}
}
function toDatetimeLocal(date) {
const pad = (value) => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function formatTimestamp(value) {
const date = new Date(value);
return new Intl.DateTimeFormat(undefined, {
timeZone: state.broadcast.timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(date);
}
function lastAttemptLabel(metadata) {
if (metadata.last_result === "published") return t("status.last_published");
if (metadata.last_result === "updated") return t("status.last_updated_telegram");
if (metadata.last_result === "update_failed") return t("status.last_failed_update");
if (metadata.last_result === "publish_failed") return t("status.last_failed_publish");
return t("status.last_activity");
}
function statusLabel(status) {
return t(`status.${status}`, status);
}
function setBusy(busy, message) {
state.busy = busy;
state.busyMessage = message;
document.body.classList.toggle("busy", busy);
render();
}
})();