const ABOUT_ENDPOINT = "/api/about"; const DEFAULT_ABOUT_CONTENT = { developer: { alias: "horoli", email: "sunha321@gmail.com", github: "https://github.com/Horoli", }, privacyPolicy: { markdown: "", }, }; export function createAboutDialog() { const openButton = document.querySelector("#about-button"); const backdrop = document.querySelector("#about-dialog"); const dialog = backdrop?.querySelector("[data-about-dialog]"); const closeButton = backdrop?.querySelector("[data-about-close]"); const tabs = [...(backdrop?.querySelectorAll("[data-about-tab]") ?? [])]; const panels = [...(backdrop?.querySelectorAll("[data-about-panel]") ?? [])]; const privacyContent = backdrop?.querySelector("#privacy-policy-content"); if (!openButton || !backdrop || !dialog || !closeButton || tabs.length === 0) { return null; } let lastFocusedElement = null; let loadPromise = null; let loaded = false; renderAboutContent(DEFAULT_ABOUT_CONTENT); openButton.addEventListener("click", () => { openDialog(); }); closeButton.addEventListener("click", () => { closeDialog(); }); backdrop.addEventListener("click", (event) => { if (event.target === backdrop) { closeDialog(); } }); dialog.addEventListener("keydown", trapFocus); window.addEventListener( "keydown", (event) => { if (event.key !== "Escape" || !isOpen()) { return; } event.preventDefault(); event.stopImmediatePropagation(); closeDialog(); }, true, ); tabs.forEach((tab) => { tab.addEventListener("click", () => { selectTab(tab.dataset.aboutTab, { focus: true }); }); }); function openDialog() { lastFocusedElement = document.activeElement; backdrop.hidden = false; document.body.classList.add("about-dialog-open"); openButton.setAttribute("aria-expanded", "true"); selectTab("developer"); window.requestAnimationFrame(() => { dialog.focus(); }); loadAboutContent(); } function closeDialog() { backdrop.hidden = true; document.body.classList.remove("about-dialog-open"); openButton.setAttribute("aria-expanded", "false"); if (lastFocusedElement instanceof HTMLElement) { lastFocusedElement.focus(); } } function isOpen() { return !backdrop.hidden; } function selectTab(tabName, { focus = false } = {}) { const nextTabName = tabName || "developer"; tabs.forEach((tab) => { const selected = tab.dataset.aboutTab === nextTabName; tab.setAttribute("aria-selected", String(selected)); tab.tabIndex = selected ? 0 : -1; if (selected && focus) { tab.focus(); } }); panels.forEach((panel) => { panel.hidden = panel.dataset.aboutPanel !== nextTabName; }); } async function loadAboutContent() { if (loaded) { return; } if (!loadPromise) { loadPromise = fetchAboutContent() .then((content) => { renderAboutContent(content); loaded = true; }) .catch((error) => { console.warn(error); renderMarkdown( privacyContent, "", "개인정보처리방침을 불러오지 못했습니다.", ); }) .finally(() => { loadPromise = null; }); } await loadPromise; } function renderAboutContent(content) { const normalizedContent = normalizeAboutContent(content); setField("alias", normalizedContent.developer.alias); setField("email", normalizedContent.developer.email); setLinkField("github", normalizedContent.developer.github); renderMarkdown(privacyContent, normalizedContent.privacyPolicy.markdown); } return { close: closeDialog, isOpen, open: openDialog, }; } async function fetchAboutContent() { const response = await fetch(ABOUT_ENDPOINT, { headers: { Accept: "application/json", }, }); if (!response.ok) { throw new Error(`About content fetch failed: ${response.status}`); } return response.json(); } function setField(fieldName, value) { const field = document.querySelector(`[data-about-field="${fieldName}"]`); if (field) { field.textContent = value || "-"; } } function setLinkField(fieldName, value) { const field = document.querySelector(`[data-about-field="${fieldName}"]`); if (!field) { return; } field.textContent = ""; if (!value) { field.textContent = "-"; return; } const link = document.createElement("a"); link.href = value; link.rel = "noreferrer"; link.target = "_blank"; link.textContent = value; field.appendChild(link); } function normalizeAboutContent(content = {}) { return { developer: { alias: stringValue(content?.developer?.alias, DEFAULT_ABOUT_CONTENT.developer.alias), email: stringValue(content?.developer?.email, DEFAULT_ABOUT_CONTENT.developer.email), github: stringValue(content?.developer?.github, DEFAULT_ABOUT_CONTENT.developer.github), }, privacyPolicy: { markdown: stringValue( content?.privacyPolicy?.markdown, DEFAULT_ABOUT_CONTENT.privacyPolicy.markdown, ), }, }; } function renderMarkdown(container, markdown, emptyMessage = "개인정보처리방침이 아직 작성되지 않았습니다.") { if (!container) { return; } container.textContent = ""; const text = String(markdown || "").trim(); if (!text) { const message = document.createElement("p"); message.className = "about-empty"; message.textContent = emptyMessage; container.appendChild(message); return; } const lines = text.replace(/\r\n?/g, "\n").split("\n"); let paragraphLines = []; let list = null; let blockquote = null; const flushParagraph = () => { if (paragraphLines.length === 0) { return; } const paragraph = document.createElement("p"); appendInlineMarkdown(paragraph, paragraphLines.join(" ")); (blockquote || container).appendChild(paragraph); paragraphLines = []; }; const closeList = () => { list = null; }; const closeBlockquote = () => { blockquote = null; }; lines.forEach((line) => { const trimmed = line.trim(); // Horizontal Rule if (/^(?:---|[*]{3}|_{3})$/.test(trimmed)) { flushParagraph(); closeList(); closeBlockquote(); container.appendChild(document.createElement("hr")); return; } // Headings const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed); if (heading) { flushParagraph(); closeList(); closeBlockquote(); const level = Math.min(heading[1].length + 2, 6); const headingNode = document.createElement(`h${level}`); appendInlineMarkdown(headingNode, heading[2]); container.appendChild(headingNode); return; } // Blockquote const bqMatch = /^>\s?(.*)$/.exec(line); if (bqMatch) { flushParagraph(); closeList(); if (!blockquote) { blockquote = document.createElement("blockquote"); container.appendChild(blockquote); } const content = bqMatch[1].trim(); if (content) { const p = document.createElement("p"); appendInlineMarkdown(p, content); blockquote.appendChild(p); } return; } // List Item const listItem = /^[-*]\s+(.+)$/.exec(trimmed); if (listItem) { flushParagraph(); closeBlockquote(); if (!list) { list = document.createElement("ul"); container.appendChild(list); } const item = document.createElement("li"); appendInlineMarkdown(item, listItem[1]); list.appendChild(item); return; } if (!trimmed) { flushParagraph(); closeList(); closeBlockquote(); } else { paragraphLines.push(trimmed); } }); flushParagraph(); } function appendInlineMarkdown(parent, text) { const escaped = text .replace(/&/g, "&") .replace(//g, ">"); const html = escaped .replace(/\*\*\*([^*]+)\*\*\*/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/__([^_]+)__/g, "$1") .replace(/\*([^*]+)\*/g, "$1") .replace(/_([^_]+)_/g, "$1") .replace(/`([^`]+)`/g, "$1") .replace( /\[([^\]]+)\]\((https?:\/\/[^)\s]+|mailto:[^)]+)\)/g, '$1', ); parent.innerHTML = html; } function trapFocus(event) { if (event.key !== "Tab") { return; } const focusableElements = [ ...event.currentTarget.querySelectorAll( 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])', ), ].filter((element) => element.offsetParent !== null); if (focusableElements.length === 0) { event.preventDefault(); return; } const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; if (event.shiftKey && document.activeElement === firstElement) { event.preventDefault(); lastElement.focus(); } else if (!event.shiftKey && document.activeElement === lastElement) { event.preventDefault(); firstElement.focus(); } } function stringValue(...values) { const value = values.find((candidate) => typeof candidate === "string"); return value?.trim() ?? ""; }