/*! * GoodBuddy Control Center — script.js * Vanilla JS only. No external dependencies. * CONTINUE_WEB_DEMO_OK */ /* global document, window */ (function () { "use strict"; /* --------------------------------------------------------- * Utilities * ------------------------------------------------------- */ /** * Escapes HTML-sensitive characters in a string so that it is * safe to insert as text content inside markup. * @param {string} value * @returns {string} */ function escapeHtml(value) { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function qs(selector, scope) { return (scope || document).querySelector(selector); } function qsa(selector, scope) { return Array.prototype.slice.call((scope || document).querySelectorAll(selector)); } /* --------------------------------------------------------- * Theme toggle (persisted via localStorage) * ------------------------------------------------------- */ var THEME_STORAGE_KEY = "goodbuddy-theme"; function initTheme() { var themeToggleBtn = qs("#themeToggleBtn"); var iconEl = qs(".theme-toggle-icon", themeToggleBtn); var labelEl = qs(".theme-toggle-label", themeToggleBtn); function applyTheme(theme) { var label; if (theme === "dark") { document.documentElement.setAttribute("data-theme", "dark"); themeToggleBtn.setAttribute("aria-pressed", "true"); if (iconEl) iconEl.textContent = "☀️"; label = "浅色模式"; } else { document.documentElement.removeAttribute("data-theme"); themeToggleBtn.setAttribute("aria-pressed", "false"); if (iconEl) iconEl.textContent = "🌙"; label = "深色模式"; } if (labelEl) labelEl.textContent = label; // .theme-toggle-label is visually hidden on narrow viewports and the // icon is aria-hidden, so without an explicit aria-label the button // has no accessible name on mobile. Keep it in sync with the visible // desktop label on every theme change. themeToggleBtn.setAttribute("aria-label", label); } var stored = null; try { stored = window.localStorage.getItem(THEME_STORAGE_KEY); } catch { /* localStorage unavailable, use the default theme */ } var prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; var initialTheme = stored || (prefersDark ? "dark" : "light"); applyTheme(initialTheme); themeToggleBtn.addEventListener("click", function () { var isDark = document.documentElement.getAttribute("data-theme") === "dark"; var nextTheme = isDark ? "light" : "dark"; applyTheme(nextTheme); try { window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme); } catch { /* localStorage unavailable — ignore silently */ } }); } /* --------------------------------------------------------- * Mobile navigation toggle * ------------------------------------------------------- */ function initNavToggle() { var navToggleBtn = qs("#navToggleBtn"); var primaryNav = qs("#primaryNav"); if (!navToggleBtn || !primaryNav) return; navToggleBtn.addEventListener("click", function () { var isOpen = primaryNav.classList.toggle("is-open"); navToggleBtn.setAttribute("aria-expanded", String(isOpen)); }); qsa("a", primaryNav).forEach(function (link) { link.addEventListener("click", function () { primaryNav.classList.remove("is-open"); navToggleBtn.setAttribute("aria-expanded", "false"); }); }); } /* --------------------------------------------------------- * Task board: filtering + adding tasks * ------------------------------------------------------- */ var STATUS_LABELS = { running: "运行中", completed: "已完成" }; var STATUS_BADGE_CLASS = { running: "status-running", completed: "status-completed" }; function initTaskBoard() { var taskList = qs("#taskList"); var filterButtons = qsa(".filter-btn"); var emptyState = qs("#taskEmptyState"); function currentFilter() { var activeBtn = qs(".filter-btn.is-active"); return activeBtn ? activeBtn.getAttribute("data-filter") : "all"; } function applyFilter() { var filter = currentFilter(); var cards = qsa(".task-card", taskList); var visibleCount = 0; cards.forEach(function (card) { var status = card.getAttribute("data-status"); var matches = filter === "all" || status === filter; card.hidden = !matches; if (matches) visibleCount += 1; }); if (emptyState) { emptyState.hidden = visibleCount !== 0; } } filterButtons.forEach(function (btn) { btn.addEventListener("click", function () { filterButtons.forEach(function (b) { b.classList.remove("is-active"); b.setAttribute("aria-pressed", "false"); }); btn.classList.add("is-active"); btn.setAttribute("aria-pressed", "true"); applyFilter(); }); }); /** * Builds a task card list item using safe DOM APIs so that * user-provided text can never be interpreted as markup. */ function createTaskCard(title, assignee, status) { var li = document.createElement("li"); li.className = "task-card"; li.setAttribute("data-status", status); var top = document.createElement("div"); top.className = "task-card-top"; var heading = document.createElement("h3"); heading.className = "task-title"; // textContent assigns the raw string as plain text — the browser // never parses it as markup, so no HTML-escaping is needed (or // wanted) here. Escaping first would make textContent render the // escaped entities literally (e.g. "<img...") instead of the // user's exact text. heading.textContent = title; var badge = document.createElement("span"); badge.className = "status-badge " + STATUS_BADGE_CLASS[status]; badge.textContent = STATUS_LABELS[status]; top.appendChild(heading); top.appendChild(badge); var meta = document.createElement("p"); meta.className = "task-meta"; meta.textContent = "负责人:" + assignee; var metaTime = document.createElement("p"); metaTime.className = "task-meta"; var now = new Date(); var timeLabel = status === "completed" ? "完成时间:刚刚" : "截止时间:" + String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"); metaTime.textContent = timeLabel; li.appendChild(top); li.appendChild(meta); li.appendChild(metaTime); return li; } applyFilter(); return { addTask: function (title, assignee, status) { // Pass the trimmed, un-escaped user text straight through. // createTaskCard assigns it via textContent (never innerHTML), // so it is rendered as inert plain text and can never execute as // markup/script regardless of its contents. var card = createTaskCard(title.trim(), assignee, status); taskList.insertBefore(card, taskList.firstChild); applyFilter(); return card; } }; } /* --------------------------------------------------------- * Activity log search * ------------------------------------------------------- */ function initActivitySearch() { var searchInput = qs("#activitySearch"); var activityList = qs("#activityList"); var emptyState = qs("#activityEmptyState"); if (!searchInput || !activityList) return; function runSearch() { var query = searchInput.value.trim().toLowerCase(); var items = qsa(".activity-item", activityList); var visibleCount = 0; items.forEach(function (item) { var text = item.textContent.toLowerCase(); var matches = query === "" || text.indexOf(query) !== -1; item.hidden = !matches; if (matches) visibleCount += 1; }); if (emptyState) { emptyState.hidden = visibleCount !== 0; } } searchInput.addEventListener("input", runSearch); } /* --------------------------------------------------------- * Add-task modal (accessible dialog) * ------------------------------------------------------- */ function initTaskModal(taskBoard) { var overlay = qs("#taskModalOverlay"); var modal = qs("#taskModal"); var openBtn = qs("#addTaskBtn"); var closeBtn = qs("#modalCloseBtn"); var cancelBtn = qs("#modalCancelBtn"); var form = qs("#taskForm"); var titleInput = qs("#taskTitleInput"); var assigneeSelect = qs("#taskAssigneeSelect"); var statusSelect = qs("#taskStatusSelect"); var errorMsg = qs("#taskFormError"); if (!overlay || !modal || !openBtn || !form) return; var lastFocusedElement = null; function getFocusableElements() { return qsa( 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])', modal ).filter(function (el) { return el.offsetParent !== null; }); } function openModal() { lastFocusedElement = document.activeElement; overlay.hidden = false; modal.hidden = false; errorMsg.hidden = true; form.reset(); document.body.style.overflow = "hidden"; titleInput.focus(); document.addEventListener("keydown", handleKeydown); } function closeModal() { overlay.hidden = true; modal.hidden = true; document.body.style.overflow = ""; document.removeEventListener("keydown", handleKeydown); if (lastFocusedElement && typeof lastFocusedElement.focus === "function") { lastFocusedElement.focus(); } } function handleKeydown(event) { if (event.key === "Escape") { event.preventDefault(); closeModal(); return; } if (event.key === "Tab") { var focusable = getFocusableElements(); if (focusable.length === 0) return; var first = focusable[0]; var last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } } } openBtn.addEventListener("click", openModal); closeBtn.addEventListener("click", closeModal); cancelBtn.addEventListener("click", closeModal); overlay.addEventListener("click", closeModal); form.addEventListener("submit", function (event) { event.preventDefault(); var title = titleInput.value.trim(); if (title === "") { errorMsg.hidden = false; titleInput.focus(); return; } errorMsg.hidden = true; taskBoard.addTask(title, assigneeSelect.value, statusSelect.value); closeModal(); }); } /* --------------------------------------------------------- * Bootstrap * ------------------------------------------------------- */ function init() { initTheme(); initNavToggle(); var taskBoard = initTaskBoard(); initActivitySearch(); initTaskModal(taskBoard); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } // Expose escapeHtml for testability / verification purposes only. window.__goodbuddyEscapeHtml = escapeHtml; })(); // CONTINUE_WEB_DEMO_OK