feat: About 다이얼로그 추가, UI 최적화 및 서버 캐싱 강화

- About 다이얼로그 추가 (개발자 정보 및 개인정보처리방침)
- Markdown 렌더러 구현 (Bold, Italic, Code, Blockquote 지원)
- 전투 화면 하단 'About' 및 방문자 카운팅 UI 재배치 및 디자인 통일
- 프로덕션 환경에서 정적 파일 강력 캐싱 설정 (7일 유지)
- 파비콘 404 오류 해결을 위한 이모지 데이터 URI 추가
- 모바일 전투 화면 레이아웃 최적화 및 승리 연출 개선
- 일일 운영 지표(Daily Metrics) 수집 API 및 로직 추가
This commit is contained in:
2026-05-23 11:32:01 +09:00
parent e4f542d487
commit 3e5c079e68
18 changed files with 1811 additions and 79 deletions
+11 -1
View File
@@ -30,6 +30,7 @@ import { createFighter, syncFighterHud } from "../fighter/fighterFactory.js";
import { fighterManifest } from "../fighter/fighterManifest.js";
import { pickFighters } from "../fighter/fighterSelection.js";
import { createMatchSetup, matchStatusText } from "../match/matchSetup.js";
import { trackMatchFinish, trackMatchStart } from "../../ui/dailyMetrics.js";
import { addTodayDeathStats, fetchTodayDeathStats } from "../../ui/deathStats.js";
import { createFighterPlans, clusterSpawnPosition, syncTeamSizes } from "../match/arenaMatchRuntime.js";
import {
@@ -68,13 +69,14 @@ import {
} from "../../ui/battleDeathNotice.js";
export class ArenaScene extends Phaser.Scene {
constructor({ getInitialMatchConfig, setStatus }) {
constructor({ getInitialMatchConfig, onMatchEnd, setStatus }) {
super("arena");
this.fighters = [];
this.getInitialMatchConfig = getInitialMatchConfig;
this.matchId = 0;
this.matchOver = false;
this.matchPaused = false;
this.onMatchEnd = typeof onMatchEnd === "function" ? onMatchEnd : () => {};
this.presentationMode = true;
this.ready = false;
this.updateStatus = typeof setStatus === "function" ? setStatus : () => {};
@@ -197,6 +199,7 @@ export class ArenaScene extends Phaser.Scene {
this.fighters = fighterPlans.map((fighterPlan) => createFighter(this, fighterPlan));
if (!silent) {
trackMatchStart();
this.setStatus(matchStatusText(this.teams));
} else {
this.focusPresentationCombat();
@@ -906,6 +909,10 @@ update(time) {
}
finishMatch() {
if (this.matchOver) {
return;
}
const livingFighters = this.fighters.filter((fighter) => !fighter.isDead);
const livingTeams = new Set(livingFighters.map((fighter) => fighter.team.id));
@@ -934,6 +941,7 @@ update(time) {
this.clearBattleNotice();
this.persistDailyDeathStats();
trackMatchFinish();
if (livingTeams.size === 1) {
const winningTeamId = Array.from(livingTeams)[0];
@@ -942,5 +950,7 @@ update(time) {
} else {
this.setStatus("무승부!");
}
this.onMatchEnd();
}
}
+38 -1
View File
@@ -6,9 +6,11 @@ import {
PRESENTATION_TEAM_SIZE,
} from "./constants.js";
import { createMatchForm } from "./ui/matchForm.js";
import { createAboutDialog } from "./ui/aboutDialog.js";
import { trackVisitor } from "./ui/visitorCounter.js";
const matchForm = createMatchForm();
const aboutDialog = createAboutDialog();
const appNode = document.querySelector("#app");
const startButton = document.querySelector("#start-button");
const drawer = document.querySelector("#fighter-entry");
@@ -18,6 +20,7 @@ const drawerToggleButton = document.querySelector("#drawer-toggle");
const playerNamesInput = document.querySelector("#player-names");
const pauseButton = document.querySelector("#pause-button");
const restartButton = document.querySelector("#restart-button");
const MOBILE_MATCH_MEDIA_QUERY = "(max-width: 960px)";
function isMatchLive() {
return appNode?.classList.contains("match-live") ?? false;
@@ -26,6 +29,7 @@ function isMatchLive() {
function openOptionsDrawer({ focus = true } = {}) {
appNode?.classList.add("options-open");
setDrawerCollapsed(false);
resetDrawerScroll();
drawer?.setAttribute("aria-hidden", "false");
startButton?.setAttribute("aria-expanded", "true");
@@ -53,8 +57,15 @@ function startConfiguredMatch(matchConfig) {
return;
}
appNode?.classList.remove("match-ended");
appNode?.classList.add("match-live");
openOptionsDrawer({ focus: false });
if (shouldCompactOptionsDrawer()) {
setDrawerCollapsed(true);
} else {
openOptionsDrawer({ focus: false });
}
arenaScene.startMatch(matchConfig);
syncPauseButton();
}
@@ -71,6 +82,11 @@ function setDrawerCollapsed(collapsed) {
appNode?.classList.toggle("drawer-collapsed", nextCollapsed);
drawer?.setAttribute("aria-hidden", "false");
if (!nextCollapsed) {
resetDrawerScroll();
}
syncDrawerToggleButton();
}
@@ -95,6 +111,22 @@ function syncPauseButton() {
pauseButton.setAttribute("aria-pressed", String(isPaused));
}
function shouldCompactOptionsDrawer() {
return window.matchMedia?.(MOBILE_MATCH_MEDIA_QUERY).matches ?? window.innerWidth <= 960;
}
function resetDrawerScroll() {
if (drawer) {
drawer.scrollTop = 0;
}
}
function handleMatchEnd() {
appNode?.classList.add("match-ended");
setDrawerCollapsed(true);
syncPauseButton();
}
function revealAppWhenStylesAreReady() {
const stylesheet = document.querySelector('link[data-app-styles], link[rel="stylesheet"]');
const reveal = () => {
@@ -127,12 +159,17 @@ restartButton?.addEventListener("click", () => {
});
window.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (aboutDialog?.isOpen()) {
return;
}
closeOptionsDrawer();
}
});
const arenaScene = new ArenaScene({
getInitialMatchConfig: getPresentationMatchConfig,
onMatchEnd: handleMatchEnd,
setStatus: matchForm.setStatus,
});
+469 -31
View File
@@ -273,28 +273,47 @@ textarea:focus-visible {
text-transform: uppercase;
}
.visitor-count {
.arena-meta {
position: fixed;
right: clamp(10px, 2vw, 18px);
bottom: clamp(10px, 2vw, 18px);
z-index: 5;
min-height: 26px;
z-index: 10;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
pointer-events: none;
transition: opacity 220ms ease;
}
.visitor-count,
.about-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
margin: 0;
border: 1px solid rgb(238 185 73 / 0.18);
border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 999px;
padding: 5px 9px;
background: rgb(8 10 7 / 0.58);
padding: 5px 12px;
background: rgb(8 10 7 / 0.68);
color: #e7c879;
font-size: 0.72rem;
font-weight: 800;
line-height: 1.2;
opacity: 0;
pointer-events: none;
transform: translateY(8px);
line-height: 1;
text-decoration: none;
backdrop-filter: blur(10px);
pointer-events: auto;
transition:
opacity 220ms ease,
transform 220ms ease;
backdrop-filter: blur(8px);
background 180ms ease,
border-color 180ms ease,
transform 180ms ease,
opacity 220ms ease;
}
.visitor-count {
opacity: 0;
transform: translateY(8px);
}
#app.match-live .visitor-count {
@@ -302,6 +321,19 @@ textarea:focus-visible {
transform: translateY(0);
}
.about-button {
min-width: 72px;
color: #ffe8b4;
font-weight: 900;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.22);
}
.about-button:hover {
border-color: rgb(238 185 73 / 0.42);
background: rgb(255 246 216 / 0.14);
transform: translateY(-1px);
}
.start-button,
form button[type="submit"],
.pause-button,
@@ -373,6 +405,10 @@ form button[type="submit"]:hover,
color: #120f08;
}
#app.match-ended .pause-button {
display: none;
}
.drawer-scrim {
position: fixed;
inset: 0;
@@ -549,6 +585,200 @@ h2 {
background: rgb(255 246 216 / 0.14);
}
.about-backdrop {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: clamp(16px, 4vw, 34px);
background: rgb(3 5 4 / 0.66);
backdrop-filter: blur(8px);
}
.about-backdrop[hidden] {
display: none;
}
.about-dialog {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
width: min(560px, calc(100vw - 32px));
max-height: min(760px, calc(100svh - 32px));
overflow: hidden;
border: 1px solid rgb(239 199 103 / 0.28);
border-radius: 8px;
background:
linear-gradient(180deg, rgb(29 33 22 / 0.98), rgb(10 13 9 / 0.98)),
#11140f;
box-shadow:
0 24px 100px rgb(0 0 0 / 0.62),
inset 0 1px 0 rgb(255 255 255 / 0.06);
outline: none;
}
.about-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
padding: clamp(20px, 4vw, 28px) clamp(20px, 4vw, 30px) 14px;
}
.about-close {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: 0 0 auto;
border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 8px;
background: rgb(255 246 216 / 0.08);
color: #f8deb0;
font-weight: 900;
}
.about-close:hover {
background: rgb(255 246 216 / 0.14);
}
.about-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
padding: 0 clamp(20px, 4vw, 30px) 14px;
border-bottom: 1px solid rgb(238 185 73 / 0.16);
}
.about-tab {
min-width: 0;
min-height: 42px;
border: 1px solid transparent;
border-radius: 6px;
padding: 8px 10px;
background: rgb(255 246 216 / 0.06);
color: #ead8ad;
font-size: 0.86rem;
font-weight: 900;
line-height: 1.2;
}
.about-tab[aria-selected="true"] {
border-color: rgb(238 185 73 / 0.36);
background: #323822;
color: #fff7df;
}
.about-panel {
min-height: 0;
overflow: auto;
padding: clamp(18px, 4vw, 26px) clamp(20px, 4vw, 30px) clamp(22px, 5vw, 34px);
}
.about-fields {
display: grid;
gap: 0;
margin: 0;
}
.about-field-row {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
align-items: center;
gap: 14px;
min-height: 50px;
border-bottom: 1px solid rgb(238 185 73 / 0.14);
}
.about-field-row:first-child {
border-top: 1px solid rgb(238 185 73 / 0.14);
}
.about-field-row dt {
color: #e3b24f;
font-size: 0.78rem;
font-weight: 950;
text-transform: uppercase;
}
.about-field-row dd {
min-width: 0;
margin: 0;
color: #fff7df;
font-weight: 800;
overflow-wrap: anywhere;
}
.about-field-row a,
.about-markdown a {
color: #85dcc7;
text-decoration-color: rgb(133 220 199 / 0.42);
text-underline-offset: 3px;
}
.about-markdown {
display: grid;
gap: 12px;
color: #ead8ad;
font-size: 0.92rem;
font-weight: 600;
line-height: 1.56;
}
.about-markdown :is(h3, h4, h5, h6, p, ul, blockquote) {
margin: 0;
}
.about-markdown h3,
.about-markdown h4,
.about-markdown h5,
.about-markdown h6 {
color: #fff3d2;
font-size: 1rem;
line-height: 1.3;
}
.about-markdown blockquote {
border-left: 3px solid rgb(238 185 73 / 0.36);
padding: 4px 0 4px 16px;
color: #c4b693;
font-style: italic;
}
.about-markdown hr {
margin: 8px 0;
border: 0;
border-top: 1px solid rgb(238 185 73 / 0.16);
}
.about-markdown code {
border: 1px solid rgb(238 185 73 / 0.14);
border-radius: 4px;
padding: 2px 5px;
background: rgb(255 246 216 / 0.08);
color: #f1c761;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.88em;
}
.about-markdown li {
line-height: 1.5;
}
.about-markdown li strong {
color: #fff3d2;
}
.about-markdown ul {
display: grid;
gap: 8px;
padding-left: 1.2rem;
}
.about-empty {
color: #bfae83;
}
form {
display: grid;
gap: 16px;
@@ -749,8 +979,8 @@ input[type="range"] {
.team-score.is-focused {
box-shadow:
0 0 0 2px rgb(255 244 209 / 0.92),
0 0 24px rgb(227 178 79 / 0.34);
inset 0 0 0 2px rgb(255 244 209 / 0.92),
0 0 18px rgb(227 178 79 / 0.26);
}
.team-score:disabled {
@@ -1028,7 +1258,17 @@ input[type="range"] {
inset: 0;
background: rgb(4 6 4 / 0.2);
isolation: isolate;
pointer-events: none;
opacity: 1;
pointer-events: auto;
transform: scale(1);
transition:
opacity 220ms ease,
transform 220ms ease;
}
.victory-celebration.is-leaving {
opacity: 0;
transform: scale(0.98);
}
.victory-celebration::before {
@@ -1151,7 +1391,7 @@ input[type="range"] {
display: block;
max-width: 100%;
overflow-wrap: anywhere;
animation: victory-message-pulse 1.1s 0.2s ease-out both;
animation: victory-message-pulse 720ms 80ms ease-out both;
}
.victory-celebration.is-draw .victory-banner {
@@ -1374,7 +1614,7 @@ input[type="range"] {
@keyframes victory-message-pulse {
from {
opacity: 0;
opacity: 0.72;
transform: scale(0.88);
}
58% {
@@ -1405,6 +1645,13 @@ input[type="range"] {
#app {
--arena-gap: 0px;
--mobile-game-size: min(100vw, calc(100svh - var(--score-band-height)));
--mobile-kill-log-top: calc(var(--score-band-height) + var(--mobile-game-size) + 10px);
--mobile-options-button-width: 54px;
--mobile-options-gap: 8px;
--mobile-team-card-width: clamp(56px, calc((100vw - 120px) / 4), 72px);
--mobile-visitor-space: calc(104px + env(safe-area-inset-bottom));
--score-band-height: 132px;
--score-panel-left: 10px;
--score-panel-width: calc(100vw - 20px);
--score-rail-width: 0px;
@@ -1415,8 +1662,8 @@ input[type="range"] {
}
#app.match-live #game {
width: min(100vw, calc(100svh - var(--score-band-height)));
height: min(100vw, calc(100svh - var(--score-band-height)));
width: var(--mobile-game-size);
height: var(--mobile-game-size);
margin-top: var(--score-band-height);
margin-left: 0;
}
@@ -1434,6 +1681,101 @@ input[type="range"] {
padding: 22px;
}
#app.match-live .fighter-entry {
top: calc(10px + env(safe-area-inset-top));
right: 10px;
left: 10px;
width: auto;
max-height: calc(100svh - 20px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
gap: 10px;
padding: 12px;
}
#app.match-live.drawer-collapsed .fighter-entry {
top: calc(22px + env(safe-area-inset-top));
right: 10px;
left: auto;
width: auto;
}
#app.match-live .fighter-entry h2 {
font-size: clamp(1.45rem, 7vw, 1.8rem);
}
#app.match-live .fighter-entry textarea {
height: 112px;
min-height: 112px;
resize: none;
}
#app.match-live .fighter-entry fieldset {
gap: 7px;
padding: 9px;
}
#app.match-live .fighter-entry form {
gap: 10px;
}
#app.match-live .entry-copy {
gap: 4px;
}
#app.match-live .eyebrow {
font-size: 0.68rem;
}
#app.match-live label,
#app.match-live .spawn-placement-label {
font-size: 0.82rem;
}
#app.match-live input:not([type="range"]):not([type="radio"]),
#app.match-live textarea {
min-height: 40px;
padding-inline: 10px;
}
#app.match-live textarea {
padding-block: 9px;
}
#app.match-live .team-size-number {
width: 64px;
min-width: 64px;
}
#app.match-live .spawn-placement-option span {
min-height: 36px;
padding: 6px;
font-size: 0.76rem;
}
#app.match-live .match-actions {
gap: 8px;
}
#app.match-live .match-actions button {
min-height: 44px;
}
#app.match-live .drawer-toggle {
min-width: 116px;
}
#app.match-live.drawer-collapsed .drawer-toggle {
width: var(--mobile-options-button-width);
min-width: var(--mobile-options-button-width);
padding-inline: 6px;
font-size: 0;
}
#app.match-live.drawer-collapsed .drawer-toggle::before {
content: "옵션";
font-size: 0.78rem;
line-height: 1;
}
.battle-preview {
opacity: 0.62;
}
@@ -1454,22 +1796,62 @@ input[type="range"] {
}
.scoreboard {
align-items: flex-start;
top: 10px;
left: var(--score-panel-left);
width: var(--score-panel-width);
max-height: calc(var(--score-band-height) - 20px);
padding: 7px;
max-height: calc(var(--score-band-height) - 12px);
overflow-x: auto;
overflow-y: hidden;
padding: 9px min(148px, 38vw) 9px 9px;
scrollbar-color: rgb(238 185 73 / 0.38) transparent;
scrollbar-width: thin;
touch-action: pan-x;
}
#app.match-live.drawer-collapsed .scoreboard {
width: calc(
100vw - 20px - var(--mobile-options-button-width) - var(--mobile-options-gap)
);
padding-right: 9px;
}
.score-side {
gap: 5px;
display: grid;
grid-auto-columns: var(--mobile-team-card-width);
grid-auto-flow: column;
grid-template-rows: repeat(2, 48px);
gap: 5px 5px;
}
.scoreboard::-webkit-scrollbar {
height: 4px;
}
.scoreboard::-webkit-scrollbar-track {
background: transparent;
}
.scoreboard::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgb(238 185 73 / 0.38);
}
.team-score {
width: 90px;
min-height: 54px;
padding: 7px 8px 6px;
font-size: 0.72rem;
width: auto;
min-height: 0;
height: 48px;
gap: 3px;
padding: 5px 6px;
font-size: 0.66rem;
}
.team-score-count {
font-size: 0.74rem;
}
.team-score.is-focused {
box-shadow: inset 0 0 0 2px rgb(255 244 209 / 0.92);
}
.battle-notice {
@@ -1487,21 +1869,77 @@ input[type="range"] {
}
.kill-log {
bottom: 10px;
top: var(--mobile-kill-log-top);
bottom: auto;
left: 10px;
width: calc(100vw - 20px);
max-height: 25vh;
max-height: calc(100svh - var(--mobile-kill-log-top) - var(--mobile-visitor-space));
padding: 8px;
}
#app.match-live .victory-celebration {
padding:
var(--score-band-height)
14px
min(30svh, 230px);
}
.victory-banner {
width: min(calc(100vw - 48px), 520px);
min-height: 92px;
padding: 1rem 1.1rem;
font-size: clamp(1.35rem, 7vw, 2rem);
}
.match-status {
bottom: 10px;
width: calc(100vw - 20px);
}
.visitor-count {
bottom: calc(10px + env(safe-area-inset-bottom));
.arena-meta {
right: 10px;
bottom: calc(10px + env(safe-area-inset-bottom));
z-index: 10;
gap: 8px;
opacity: 1;
pointer-events: auto;
}
.visitor-count {
font-size: 0.68rem;
}
.about-button {
min-width: 68px;
min-height: 26px;
font-size: 0.68rem;
}
.about-backdrop {
align-items: end;
padding: 12px;
}
.about-dialog {
width: 100%;
max-height: calc(100svh - 24px);
}
.about-header {
padding: 18px 18px 12px;
}
.about-tabs {
padding: 0 18px 12px;
}
.about-panel {
padding: 16px 18px 22px;
}
.about-field-row {
grid-template-columns: 78px minmax(0, 1fr);
min-height: 48px;
gap: 10px;
}
}
+379
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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() ?? "";
}
+45
View File
@@ -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;
}
}
+37
View File
@@ -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;