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() ?? "";
}