380 lines
9.3 KiB
JavaScript
380 lines
9.3 KiB
JavaScript
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, "<")
|
|
.replace(/>/g, ">");
|
|
|
|
const html = escaped
|
|
.replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>")
|
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
|
|
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
|
.replace(/_([^_]+)_/g, "<em>$1</em>")
|
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
.replace(
|
|
/\[([^\]]+)\]\((https?:\/\/[^)\s]+|mailto:[^)]+)\)/g,
|
|
'<a href="$2" rel="noreferrer" target="_blank">$1</a>',
|
|
);
|
|
|
|
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() ?? "";
|
|
}
|