feat: About 다이얼로그 추가, UI 최적화 및 서버 캐싱 강화
- About 다이얼로그 추가 (개발자 정보 및 개인정보처리방침) - Markdown 렌더러 구현 (Bold, Italic, Code, Blockquote 지원) - 전투 화면 하단 'About' 및 방문자 카운팅 UI 재배치 및 디자인 통일 - 프로덕션 환경에서 정적 파일 강력 캐싱 설정 (7일 유지) - 파비콘 404 오류 해결을 위한 이모지 데이터 URI 추가 - 모바일 전투 화면 레이아웃 최적화 및 승리 연출 개선 - 일일 운영 지표(Daily Metrics) 수집 API 및 로직 추가
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
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() ?? "";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const DAILY_METRIC_ENDPOINTS = {
|
||||
donationClicked: "/api/daily-metrics/donation-clicked",
|
||||
matchFinished: "/api/daily-metrics/match-finished",
|
||||
matchStarted: "/api/daily-metrics/match-started",
|
||||
};
|
||||
|
||||
export function trackMatchStart() {
|
||||
return postDailyMetric("matchStarted");
|
||||
}
|
||||
|
||||
export function trackMatchFinish() {
|
||||
return postDailyMetric("matchFinished");
|
||||
}
|
||||
|
||||
export function trackDonationClick() {
|
||||
return postDailyMetric("donationClicked");
|
||||
}
|
||||
|
||||
async function postDailyMetric(type) {
|
||||
const endpoint = DAILY_METRIC_ENDPOINTS[type];
|
||||
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Daily metric update failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
const VICTORY_CONFETTI_COLORS = ["#ffe8a8", "#f7b842", "#f36f45", "#85dcc7", "#f7f2df"];
|
||||
const VICTORY_CONFETTI_COUNT = 40;
|
||||
const VICTORY_CELEBRATION_EXIT_MS = 260;
|
||||
const VICTORY_CELEBRATION_VISIBLE_MS = 5200;
|
||||
const VICTORY_FANFARE_NOTES = [
|
||||
{ duration: 0.16, frequency: 392, offset: 0, volume: 0.065 },
|
||||
{ duration: 0.16, frequency: 523.25, offset: 0, volume: 0.052 },
|
||||
@@ -34,8 +36,11 @@ export function createVictoryConfettiPiece(index) {
|
||||
export { VICTORY_CONFETTI_COUNT, VICTORY_FANFARE_NOTES };
|
||||
|
||||
let victoryAudioContext = null;
|
||||
let victoryDismissTimer = null;
|
||||
let victoryRemoveTimer = null;
|
||||
|
||||
export function removeVictoryCelebration() {
|
||||
clearVictoryCelebrationTimers();
|
||||
document.querySelector(".victory-celebration")?.remove();
|
||||
}
|
||||
|
||||
@@ -72,13 +77,45 @@ export function createVictoryCelebration(message) {
|
||||
|
||||
banner.appendChild(messageNode);
|
||||
celebration.append(rays, confetti, banner);
|
||||
celebration.addEventListener("click", () => {
|
||||
dismissVictoryCelebration(celebration);
|
||||
});
|
||||
celebrationHost.appendChild(celebration);
|
||||
scheduleVictoryCelebrationDismiss(celebration);
|
||||
|
||||
if (isVictory) {
|
||||
playVictoryFanfare();
|
||||
}
|
||||
}
|
||||
|
||||
function clearVictoryCelebrationTimers() {
|
||||
window.clearTimeout(victoryDismissTimer);
|
||||
window.clearTimeout(victoryRemoveTimer);
|
||||
victoryDismissTimer = null;
|
||||
victoryRemoveTimer = null;
|
||||
}
|
||||
|
||||
function scheduleVictoryCelebrationDismiss(celebration) {
|
||||
clearVictoryCelebrationTimers();
|
||||
|
||||
victoryDismissTimer = window.setTimeout(() => {
|
||||
dismissVictoryCelebration(celebration);
|
||||
}, VICTORY_CELEBRATION_VISIBLE_MS);
|
||||
}
|
||||
|
||||
function dismissVictoryCelebration(celebration) {
|
||||
if (!celebration?.isConnected || celebration.classList.contains("is-leaving")) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearVictoryCelebrationTimers();
|
||||
celebration.classList.add("is-leaving");
|
||||
victoryRemoveTimer = window.setTimeout(() => {
|
||||
celebration.remove();
|
||||
victoryRemoveTimer = null;
|
||||
}, VICTORY_CELEBRATION_EXIT_MS);
|
||||
}
|
||||
|
||||
export function primeVictoryFanfareAudio() {
|
||||
const AudioContextClass = window.AudioContext ?? window.webkitAudioContext;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user