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
3260 lines
112 KiB
JavaScript
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" ? "↑" : "↓";
|
|
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();
|
|
}
|
|
})();
|