bibimbap/.atp/work-session/20261014-091658/artifacts/final-prechange-ssr-regress...

2726 lines
113 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

TAP version 13
# Subtest: SSR player ships the stored ratio and responsive stage without the old hard limit
not ok 1 - SSR player ships the stored ratio and responsive stage without the old hard limit
---
duration_ms: 2.454666
type: 'test'
location: '/private/var/folders/g5/hclggccx15qdjf89b6vn1fw80000gn/T/bibimbap-prechange-oo0st4cd/src/test/js/webgl-player.test.cjs:29:1'
failureType: 'testCodeFailure'
error: |-
The input did not match the regular expression /data-display-width="<%= displayWidth %>"/. Input:
'<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>\n' +
'<%@ page import="org.springframework.web.util.HtmlUtils" %>\n' +
'<%\n' +
' boolean owner = Boolean.TRUE.equals(request.getAttribute("owner"));\n' +
' String ctx = request.getContextPath();\n' +
' String gameIdValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("gameId")));\n' +
' String gameNameValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("gameName")));\n' +
' String creatorValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("creator")));\n' +
' String likeCountFormattedValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("likeCountFormatted")));\n' +
' String creatorNoteValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("creatorNote")));\n' +
' String gitUrlValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("gitUrl")));\n' +
' String webglFrameSrc = String.valueOf(request.getAttribute("webglFrameSrc"));\n' +
' if (webglFrameSrc == null || webglFrameSrc.isBlank() || "null".equals(webglFrameSrc)) {\n' +
' webglFrameSrc = String.valueOf(request.getAttribute("webglUrl"));\n' +
' }\n' +
' if (webglFrameSrc == null || webglFrameSrc.isBlank() || "null".equals(webglFrameSrc)) {\n' +
' webglFrameSrc = "";\n' +
' } else if (webglFrameSrc.startsWith("/")) {\n' +
' webglFrameSrc = ctx + webglFrameSrc;\n' +
' }\n' +
' String webglFrameSrcValue = HtmlUtils.htmlEscape(webglFrameSrc);\n' +
'%>\n' +
'<!DOCTYPE html>\n' +
'<html lang="ko">\n' +
'<head>\n' +
' <meta charset="UTF-8">\n' +
' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n' +
' <jsp:include page="/WEB-INF/views/theme-init.jsp"/>\n' +
' <title><%= gameNameValue %> — bibimbap</title>\n' +
' <style>\n' +
' html {\n' +
' color-scheme: light;\n' +
' --surface: #faf8f5;\n' +
' --card-bg: #fff;\n' +
' --text: #1a1a1a;\n' +
' --text-muted: #5c5c5c;\n' +
' --accent: #e8a54b;\n' +
' --accent-soft: rgba(232, 165, 75, 0.14);\n' +
' --border: rgba(0, 0, 0, 0.08);\n' +
' --webgl-bg: #0d0d0d;\n' +
' --panel-shadow: 0 4px 24px rgba(26, 26, 26, 0.06);\n' +
' --radius: 16px;\n' +
' }\n' +
' html[data-theme="dark"] {\n' +
' color-scheme: dark;\n' +
' --surface: #121212;\n' +
' --card-bg: #1e1e1e;\n' +
' --text: #ece8e1;\n' +
' --text-muted: #a39e96;\n' +
' --border: rgba(255, 255, 255, 0.1);\n' +
' --accent-soft: rgba(232, 165, 75, 0.12);\n' +
' --webgl-bg: #080808;\n' +
' --panel-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);\n' +
' }\n' +
' body {\n' +
' margin: 0;\n' +
' min-height: 100vh;\n' +
' font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;\n' +
' background: var(--surface);\n' +
' color: var(--text);\n' +
' }\n' +
' html[data-theme="dark"] body {\n' +
' background: linear-gradient(165deg, #1c1a18 0%, var(--surface) 38%, #0e0e0e 100%);\n' +
' background-attachment: fixed;\n' +
' }\n' +
' html:not([data-theme="dark"]) body {\n' +
' background: linear-gradient(165deg, #fff9f0 0%, var(--surface) 45%, #f3ede6 100%);\n' +
' background-attachment: fixed;\n' +
' }\n' +
' .game-page {\n' +
' max-width: 56rem;\n' +
' margin: 0 auto;\n' +
' padding: 1.25rem max(1rem, env(safe-area-inset-left)) 2.75rem max(1rem, env(safe-area-inset-right));\n' +
' }\n' +
' .game-back {\n' +
' display: inline-flex;\n' +
' align-items: center;\n' +
' gap: 0.45rem;\n' +
' margin-bottom: 1.25rem;\n' +
' padding: 0.5rem 0.95rem 0.5rem 0.75rem;\n' +
' font-size: 0.8125rem;\n' +
' font-weight: 600;\n' +
' color: var(--text-muted);\n' +
' text-decoration: none;\n' +
' background: var(--card-bg);\n' +
' border: 1px solid var(--border);\n' +
' border-radius: 999px;\n' +
' box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);\n' +
' transition: color 0.2s ease, border-color 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;\n' +
' }\n' +
' .game-back svg {\n' +
' width: 1rem;\n' +
' height: 1rem;\n' +
' flex-shrink: 0;\n' +
' opacity: 0.85;\n' +
' }\n' +
' .game-back:hover {\n' +
' color: var(--accent);\n' +
' border-color: rgba(232, 165, 75, 0.45);\n' +
' box-shadow: 0 4px 16px rgba(232, 165, 75, 0.12);\n' +
' transform: translateX(-2px);\n' +
' }\n' +
' .game-topbar {\n' +
' display: flex;\n' +
' align-items: center;\n' +
' justify-content: space-between;\n' +
' gap: 0.75rem;\n' +
' margin-bottom: 1.25rem;\n' +
' }\n' +
' .game-topbar .game-back {\n' +
' margin-bottom: 0;\n' +
' }\n' +
' .game-owner-actions {\n' +
' display: flex;\n' +
' gap: 0.5rem;\n' +
' flex-shrink: 0;\n' +
' }\n' +
' .game-owner-button {\n' +
' min-height: 2.25rem;\n' +
' padding: 0 0.8rem;\n' +
' border: 1px solid var(--border);\n' +
' border-radius: 999px;\n' +
' display: inline-flex;\n' +
' align-items: center;\n' +
' justify-content: center;\n' +
' background: var(--card-bg);\n' +
' color: var(--text);\n' +
' font-size: 0.8125rem;\n' +
' font-weight: 800;\n' +
' text-decoration: none;\n' +
' cursor: pointer;\n' +
' box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);\n' +
' }\n' +
' .game-owner-button:hover {\n' +
' border-color: rgba(232, 165, 75, 0.45);\n' +
' color: var(--accent);\n' +
' }\n' +
' .game-owner-button--danger:hover {\n' +
' border-color: rgba(180, 50, 50, 0.32);\n' +
' color: #b33;\n' +
' background: rgba(180, 50, 50, 0.06);\n' +
' }\n' +
' @media (max-width: 520px) {\n' +
' .game-topbar {\n' +
' align-items: stretch;\n' +
' flex-direction: column;\n' +
' }\n' +
' .game-owner-actions {\n' +
' justify-content: flex-end;\n' +
' }\n' +
' }\n' +
'\n' +
' .game-info-card {\n' +
' margin-bottom: 1.75rem;\n' +
' padding: 1.5rem 1.35rem 1.6rem;\n' +
' background: var(--card-bg);\n' +
' border: 1px solid var(--border);\n' +
' border-radius: var(--radius);\n' +
' box-shadow: var(--panel-shadow);\n' +
' position: relative;\n' +
' overflow: hidden;\n' +
' }\n' +
' .game-info-card::after {\n' +
' content: "";\n' +
' position: absolute;\n' +
' top: 0;\n' +
' left: 0;\n' +
' right: 0;\n' +
' height: 3px;\n' +
' background: linear-gradient(90deg, var(--accent), rgba(232, 165, 75, 0.25), transparent);\n' +
' opacity: 0.9;\n' +
' }\n' +
' .game-info-card h1 {\n' +
' margin: 0 0 1.25rem;\n' +
' padding-bottom: 0.85rem;\n' +
' font-size: clamp(1.5rem, 4.5vw, 1.85rem);\n' +
' font-weight: 800;\n' +
' letter-spacing: -0.04em;\n' +
' line-height: 1.2;\n' +
' border-bottom: 1px solid var(--border);\n' +
' }\n' +
'\n' +
' .game-meta-grid {\n' +
' display: grid;\n' +
' grid-template-columns: repeat(3, minmax(0, 1fr));\n' +
' gap: 0.65rem;\n' +
' }\n' +
' @media (max-width: 640px) {\n' +
' .game-meta-grid {\n' +
' grid-template-columns: 1fr;\n' +
' }\n' +
' }\n' +
' .game-meta-item {\n' +
' display: flex;\n' +
' flex-direction: column;\n' +
' gap: 0.35rem;\n' +
' padding: 0.85rem 0.9rem;\n' +
' background: var(--surface);\n' +
' border: 1px solid var(--border);\n' +
' border-radius: 12px;\n' +
' transition: border-color 0.2s ease, box-shadow 0.2s ease;\n' +
' }\n' +
' .game-meta-item:hover {\n' +
' border-color: rgba(232, 165, 75, 0.25);\n' +
' box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);\n' +
' }\n' +
' html[data-theme="dark"] .game-meta-item:hover {\n' +
' box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);\n' +
' }\n' +
' .game-meta-item__row {\n' +
' display: flex;\n' +
' align-items: center;\n' +
' gap: 0.45rem;\n' +
' }\n' +
' .game-meta-item__icon {\n' +
' display: flex;\n' +
' align-items: center;\n' +
' justify-content: center;\n' +
' width: 1.75rem;\n' +
' height: 1.75rem;\n' +
' border-radius: 8px;\n' +
' background: var(--accent-soft);\n' +
' color: var(--accent);\n' +
' flex-shrink: 0;\n' +
' }\n' +
' .game-meta-item__icon svg {\n' +
' width: 0.95rem;\n' +
' height: 0.95rem;\n' +
' }\n' +
' .game-meta-item__label {\n' +
' font-size: 0.6875rem;\n' +
' font-weight: 700;\n' +
' letter-spacing: 0.06em;\n' +
' text-transform: uppercase;\n' +
' color: var(--text-muted);\n' +
' }\n' +
' .game-meta-item__value {\n' +
' font-size: 0.9375rem;\n' +
' font-weight: 700;\n' +
' letter-spacing: -0.02em;\n' +
' color: var(--text);\n' +
' word-break: break-word;\n' +
' }\n' +
' .game-meta-item--likes {\n' +
' padding: 0.65rem 0.75rem 0.75rem;\n' +
' }\n' +
' .game-meta-like-btn {\n' +
' display: inline-flex;\n' +
' align-items: center;\n' +
' justify-content: center;\n' +
' gap: 0.45rem;\n' +
' width: 100%;\n' +
' margin: 0;\n' +
' margin-top: 0.1rem;\n' +
' padding: 0.55rem 0.7rem;\n' +
' font: inherit;\n' +
' border: 1px solid var(--border);\n' +
' border-radius: 10px;\n' +
' background: var(--card-bg);\n' +
' color: var(--text);\n' +
' cursor: pointer;\n' +
' transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease, transform 0.12s ease;\n' +
' -webkit-tap-highlight-color: transparent;\n' +
' box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);\n' +
' }\n' +
' .game-meta-like-btn:hover {\n' +
' border-color: rgba(232, 165, 75, 0.45);\n' +
' box-shadow: 0 3px 12px rgba(232, 165, 75, 0.12);\n' +
' }\n' +
' .game-meta-like-btn:active {\n' +
' transform: scale(0.98);\n' +
' }\n' +
' .game-meta-like-btn[aria-pressed="true"] {\n' +
' background: var(--accent-soft);\n' +
' border-color: rgba(232, 165, 75, 0.55);\n' +
' box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);\n' +
' }\n' +
' .game-meta-item--likes:has(.game-meta-like-btn[aria-pressed="true"]) {\n' +
' border-color: rgba(232, 165, 75, 0.38);\n' +
' }\n' +
' .game-meta-like-btn__heart {\n' +
' width: 1.15rem;\n' +
' height: 1.15rem;\n' +
' flex-shrink: 0;\n' +
' }\n' +
' .game-meta-like-btn__heart path {\n' +
' fill: none;\n' +
' stroke: var(--accent);\n' +
' stroke-width: 2;\n' +
' stroke-linecap: round;\n' +
' stroke-linejoin: round;\n' +
' }\n' +
' .game-meta-like-btn[aria-pressed="true"] .game-meta-like-btn__heart path {\n' +
' fill: var(--accent);\n' +
' stroke: none;\n' +
' }\n' +
' .game-meta-like-btn__count {\n' +
' font-size: 1.0625rem;\n' +
' font-weight: 800;\n' +
' letter-spacing: -0.03em;\n' +
' font-variant-numeric: tabular-nums;\n' +
' color: var(--accent);\n' +
' }\n' +
' html[data-theme="dark"] .game-meta-like-btn[aria-pressed="true"] .game-meta-like-btn__'... 77596 more characters
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected:
actual: |-
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<%@ page import="org.springframework.web.util.HtmlUtils" %>
<%
boolean owner = Boolean.TRUE.equals(request.getAttribute("owner"));
String ctx = request.getContextPath();
String gameIdValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("gameId")));
String gameNameValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("gameName")));
String creatorValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("creator")));
String likeCountFormattedValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("likeCountFormatted")));
String creatorNoteValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("creatorNote")));
String gitUrlValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("gitUrl")));
String webglFrameSrc = String.valueOf(request.getAttribute("webglFrameSrc"));
if (webglFrameSrc == null || webglFrameSrc.isBlank() || "null".equals(webglFrameSrc)) {
webglFrameSrc = String.valueOf(request.getAttribute("webglUrl"));
}
if (webglFrameSrc == null || webglFrameSrc.isBlank() || "null".equals(webglFrameSrc)) {
webglFrameSrc = "";
} else if (webglFrameSrc.startsWith("/")) {
webglFrameSrc = ctx + webglFrameSrc;
}
String webglFrameSrcValue = HtmlUtils.htmlEscape(webglFrameSrc);
%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
<title><%= gameNameValue %> — bibimbap</title>
<style>
html {
color-scheme: light;
--surface: #faf8f5;
--card-bg: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #e8a54b;
--accent-soft: rgba(232, 165, 75, 0.14);
--border: rgba(0, 0, 0, 0.08);
--webgl-bg: #0d0d0d;
--panel-shadow: 0 4px 24px rgba(26, 26, 26, 0.06);
--radius: 16px;
}
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212;
--card-bg: #1e1e1e;
--text: #ece8e1;
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--accent-soft: rgba(232, 165, 75, 0.12);
--webgl-bg: #080808;
--panel-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
}
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
background: var(--surface);
color: var(--text);
}
html[data-theme="dark"] body {
background: linear-gradient(165deg, #1c1a18 0%, var(--surface) 38%, #0e0e0e 100%);
background-attachment: fixed;
}
html:not([data-theme="dark"]) body {
background: linear-gradient(165deg, #fff9f0 0%, var(--surface) 45%, #f3ede6 100%);
background-attachment: fixed;
}
.game-page {
max-width: 56rem;
margin: 0 auto;
padding: 1.25rem max(1rem, env(safe-area-inset-left)) 2.75rem max(1rem, env(safe-area-inset-right));
}
.game-back {
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 1.25rem;
padding: 0.5rem 0.95rem 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
text-decoration: none;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 999px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
transition: color 0.2s ease, border-color 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;
}
.game-back svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
opacity: 0.85;
}
.game-back:hover {
color: var(--accent);
border-color: rgba(232, 165, 75, 0.45);
box-shadow: 0 4px 16px rgba(232, 165, 75, 0.12);
transform: translateX(-2px);
}
.game-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.game-topbar .game-back {
margin-bottom: 0;
}
.game-owner-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.game-owner-button {
min-height: 2.25rem;
padding: 0 0.8rem;
border: 1px solid var(--border);
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--card-bg);
color: var(--text);
font-size: 0.8125rem;
font-weight: 800;
text-decoration: none;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
.game-owner-button:hover {
border-color: rgba(232, 165, 75, 0.45);
color: var(--accent);
}
.game-owner-button--danger:hover {
border-color: rgba(180, 50, 50, 0.32);
color: #b33;
background: rgba(180, 50, 50, 0.06);
}
@media (max-width: 520px) {
.game-topbar {
align-items: stretch;
flex-direction: column;
}
.game-owner-actions {
justify-content: flex-end;
}
}
.game-info-card {
margin-bottom: 1.75rem;
padding: 1.5rem 1.35rem 1.6rem;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--panel-shadow);
position: relative;
overflow: hidden;
}
.game-info-card::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent), rgba(232, 165, 75, 0.25), transparent);
opacity: 0.9;
}
.game-info-card h1 {
margin: 0 0 1.25rem;
padding-bottom: 0.85rem;
font-size: clamp(1.5rem, 4.5vw, 1.85rem);
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1.2;
border-bottom: 1px solid var(--border);
}
.game-meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
@media (max-width: 640px) {
.game-meta-grid {
grid-template-columns: 1fr;
}
}
.game-meta-item {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.85rem 0.9rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.game-meta-item:hover {
border-color: rgba(232, 165, 75, 0.25);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
}
html[data-theme="dark"] .game-meta-item:hover {
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}
.game-meta-item__row {
display: flex;
align-items: center;
gap: 0.45rem;
}
.game-meta-item__icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 8px;
background: var(--accent-soft);
color: var(--accent);
flex-shrink: 0;
}
.game-meta-item__icon svg {
width: 0.95rem;
height: 0.95rem;
}
.game-meta-item__label {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
}
.game-meta-item__value {
font-size: 0.9375rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
word-break: break-word;
}
.game-meta-item--likes {
padding: 0.65rem 0.75rem 0.75rem;
}
.game-meta-like-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
width: 100%;
margin: 0;
margin-top: 0.1rem;
padding: 0.55rem 0.7rem;
font: inherit;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--card-bg);
color: var(--text);
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease, transform 0.12s ease;
-webkit-tap-highlight-color: transparent;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.game-meta-like-btn:hover {
border-color: rgba(232, 165, 75, 0.45);
box-shadow: 0 3px 12px rgba(232, 165, 75, 0.12);
}
.game-meta-like-btn:active {
transform: scale(0.98);
}
.game-meta-like-btn[aria-pressed="true"] {
background: var(--accent-soft);
border-color: rgba(232, 165, 75, 0.55);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.game-meta-item--likes:has(.game-meta-like-btn[aria-pressed="true"]) {
border-color: rgba(232, 165, 75, 0.38);
}
.game-meta-like-btn__heart {
width: 1.15rem;
height: 1.15rem;
flex-shrink: 0;
}
.game-meta-like-btn__heart path {
fill: none;
stroke: var(--accent);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.game-meta-like-btn[aria-pressed="true"] .game-meta-like-btn__heart path {
fill: var(--accent);
stroke: none;
}
.game-meta-like-btn__count {
font-size: 1.0625rem;
font-weight: 800;
letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
color: var(--accent);
}
html[data-theme="dark"] .game-meta-like-btn[aria-pressed="true"] .game-meta-like-btn__count {
color: #f0c47a;
}
.game-webgl {
margin-bottom: 1.75rem;
}
.game-webgl__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-bottom: 0.85rem;
}
.game-webgl__head h2 {
margin: 0;
font-size: 1.05rem;
font-weight: 800;
letter-spacing: -0.03em;
}
.game-webgl__icon {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 10px;
background: var(--accent-soft);
color: var(--accent);
}
.game-webgl__icon svg {
width: 1.1rem;
height: 1.1rem;
}
.game-webgl__badge {
margin-left: auto;
padding: 0.2rem 0.55rem;
font-size: 0.625rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
background: var(--accent-soft);
border: 1px solid rgba(232, 165, 75, 0.35);
border-radius: 6px;
}
@media (max-width: 480px) {
.game-webgl__badge {
margin-left: 0;
}
}
.game-webgl__shell {
position: relative;
padding: 3px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(232, 165, 75, 0.45) 0%, rgba(232, 165, 75, 0.08) 50%, rgba(255, 255, 255, 0.06) 100%);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
}
html:not([data-theme="dark"]) .game-webgl__shell {
box-shadow: 0 12px 36px rgba(26, 26, 26, 0.12);
}
.game-webgl__frame-wrap {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
max-height: min(70vh, 520px);
background: var(--webgl-bg);
border-radius: 15px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.35);
}
html:not([data-theme="dark"]) .game-webgl__frame-wrap {
border-color: rgba(0, 0, 0, 0.12);
}
.game-webgl__frame-wrap iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
}
/* 제작자 한마디 ~ 덧글: 공통 패널 */
.game-community {
margin-top: 2rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.game-panel {
position: relative;
padding: 1.35rem 1.35rem 1.5rem;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--panel-shadow);
overflow: hidden;
}
.game-panel::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: linear-gradient(180deg, var(--accent) 0%, rgba(232, 165, 75, 0.35) 100%);
border-radius: 4px 0 0 4px;
}
.game-panel__head {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1rem;
}
.game-panel__icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 10px;
background: var(--accent-soft);
color: var(--accent);
flex-shrink: 0;
}
.game-panel__icon svg {
width: 1.15rem;
height: 1.15rem;
}
.game-panel__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.game-panel__subtitle {
margin: 0.15rem 0 0;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted);
}
.game-panel--creator .game-panel__quote {
margin: 0;
padding: 1rem 1rem 1rem 1.15rem;
font-size: 0.9375rem;
line-height: 1.75;
font-style: normal;
color: var(--text);
background: linear-gradient(135deg, var(--surface) 0%, transparent 55%);
border-radius: 12px;
border-left: 3px solid var(--accent);
}
.game-panel__git-wrap {
margin-top: 1.15rem;
padding-top: 1rem;
border-top: 1px dashed var(--border);
}
.game-panel__git {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 1rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
text-decoration: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
word-break: break-all;
}
.game-panel__git svg {
flex-shrink: 0;
width: 1.1rem;
height: 1.1rem;
color: var(--text-muted);
}
.game-panel__git:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-soft);
}
.game-panel__git:hover svg {
color: var(--accent);
}
.game-comments__composer {
margin-bottom: 1.25rem;
}
.game-comments__label {
display: block;
margin-bottom: 0.45rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
}
.game-comments__form {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.game-comments__form textarea {
width: 100%;
box-sizing: border-box;
min-height: 6.5rem;
padding: 0.85rem 1rem;
font-size: 0.875rem;
font-family: inherit;
line-height: 1.55;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
resize: vertical;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.game-comments__form textarea::placeholder {
color: var(--text-muted);
opacity: 0.85;
}
.game-comments__form textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(232, 165, 75, 0.18);
}
.game-comments__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.game-comments__form button[type="submit"] {
padding: 0.55rem 1.5rem;
font-size: 0.875rem;
font-weight: 700;
color: #1a1a1a;
background: linear-gradient(180deg, #f0c978 0%, var(--accent) 100%);
border: none;
border-radius: 10px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(232, 165, 75, 0.35);
transition: transform 0.15s ease, box-shadow 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.game-comments__form button[type="submit"]:hover {
box-shadow: 0 4px 14px rgba(232, 165, 75, 0.45);
}
.game-comments__form button[type="submit"]:active {
transform: scale(0.98);
}
.game-comments__hint {
font-size: 0.6875rem;
color: var(--text-muted);
}
.game-comments__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.game-comments__empty {
margin: 0;
padding: 1.5rem 1rem;
font-size: 0.875rem;
text-align: center;
color: var(--text-muted);
background: var(--surface);
border-radius: 12px;
border: 1px dashed var(--border);
}
.game-comments__item {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.85rem 1rem;
font-size: 0.875rem;
line-height: 1.55;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
transition: border-color 0.2s ease;
}
.game-comments__item:hover {
border-color: rgba(232, 165, 75, 0.2);
}
.game-comments__avatar {
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 800;
color: var(--accent);
background: var(--accent-soft);
border: 1px solid rgba(232, 165, 75, 0.25);
}
.game-comments__body {
flex: 1;
min-width: 0;
}
.game-comments__meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.65rem;
margin-bottom: 0.4rem;
}
.game-comments__nick {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.game-comments__meta time {
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--text-muted);
}
.game-comments__item p {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--text);
}
.game-comments__footer {
margin-top: 0.5rem;
display: flex;
justify-content: flex-end;
}
.game-comments__delete {
padding: 0.25rem 0.6rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.game-comments__delete:hover {
color: #b33;
border-color: rgba(180, 50, 50, 0.25);
background: rgba(180, 50, 50, 0.06);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
@media (max-width: 480px) {
.game-panel {
padding: 1.15rem 1.1rem 1.35rem;
}
.game-comments__actions {
flex-direction: column-reverse;
align-items: stretch;
}
.game-comments__form button[type="submit"] {
width: 100%;
}
.game-comments__hint {
text-align: center;
}
.game-reviews__form-buttons {
width: 100%;
flex-direction: column-reverse;
}
.game-reviews__cancel,
.game-reviews__form-buttons #game-review-submit {
width: 100%;
}
}
/* ── 로그인 게이트 (덧글/리뷰 공통) ───────────────────── */
.game-comments__login-gate {
margin: 0 0 0.25rem;
padding: 1.1rem 1rem;
font-size: 0.875rem;
text-align: center;
color: var(--text-muted);
background: var(--surface);
border: 1px dashed var(--border);
border-radius: 12px;
}
.game-comments__login-gate a {
font-weight: 700;
color: var(--accent);
text-decoration: none;
border-bottom: 1px solid rgba(232, 165, 75, 0.4);
}
.game-comments__login-gate a:hover {
border-bottom-color: var(--accent);
}
/* ── 별점 위젯 ───────────────────────────────────────── */
.game-stars {
display: inline-flex;
gap: 0.15rem;
line-height: 1;
}
.game-stars__btn {
padding: 0.1rem;
font-size: 1.6rem;
line-height: 1;
color: var(--border);
background: none;
border: none;
cursor: pointer;
transition: color 0.12s ease, transform 0.12s ease;
-webkit-tap-highlight-color: transparent;
}
.game-stars__btn:hover {
transform: scale(1.12);
}
.game-stars__btn.is-on {
color: var(--accent);
text-shadow: 0 1px 4px rgba(232, 165, 75, 0.45);
}
.game-stars__btn:active {
transform: scale(0.88);
}
.game-stars--input:focus-within .game-stars__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
/* 읽기 전용 별 표시 (리뷰 카드/요약) */
.game-stars--display,
.game-stars--avg {
gap: 0.05rem;
font-size: 0.95rem;
}
.game-stars--display .game-star,
.game-stars--avg .game-star {
color: var(--border);
}
.game-stars--display .game-star.is-on,
.game-stars--avg .game-star.is-on {
color: var(--accent);
}
/* ── 리뷰 작성/수정 폼 ───────────────────────────────── */
.game-reviews__composer {
margin-bottom: 1.25rem;
padding: 1.1rem 1.15rem 1.2rem;
background:
radial-gradient(140% 120% at 100% 0%, rgba(232, 165, 75, 0.16), transparent 60%),
var(--accent-soft);
border: 1px solid rgba(232, 165, 75, 0.22);
border-radius: 14px;
}
.game-reviews__rating-row {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 0.85rem;
}
.game-reviews__rating-row .game-comments__label {
margin-bottom: 0;
}
.game-reviews__form-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}
.game-reviews__cancel {
padding: 0.55rem 1.1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
}
.game-reviews__cancel:hover {
color: var(--text);
border-color: var(--accent);
}
/* ── 리뷰 요약 (평균 별점) ───────────────────────────── */
.game-reviews__summary {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.game-reviews__avg {
font-size: 1.35rem;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--accent);
}
.game-reviews__count {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
}
/* ── 리뷰 목록 (덧글 카드 패턴 확장) ─────────────────── */
.game-reviews__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.game-reviews__item {
padding: 0.95rem 1.1rem;
font-size: 0.875rem;
line-height: 1.55;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
transition: border-color 0.2s ease;
}
.game-reviews__item.is-mine {
border-color: rgba(232, 165, 75, 0.4);
background: var(--accent-soft);
}
.game-reviews__item-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.65rem;
margin-bottom: 0.5rem;
}
.game-reviews__nick {
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.game-reviews__mine-badge {
padding: 0.05rem 0.45rem;
font-size: 0.625rem;
font-weight: 700;
color: var(--accent);
background: rgba(232, 165, 75, 0.16);
border-radius: 999px;
}
.game-reviews__badges {
display: inline-flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.game-reviews__badge {
padding: 0.05rem 0.45rem;
font-size: 0.625rem;
font-weight: 700;
color: var(--text-muted);
background: var(--accent-soft);
border-radius: 999px;
}
.game-reviews__badge--reviewer {
color: #2563eb;
background: rgba(37, 99, 235, 0.14);
}
.game-reviews__badge--technician {
color: #16a34a;
background: rgba(22, 163, 74, 0.14);
}
.game-reviews__edited {
font-size: 0.6875rem;
font-weight: 500;
color: var(--text-muted);
}
.game-reviews__time {
margin-left: auto;
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--text-muted);
}
.game-reviews__body {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--text);
}
.game-reviews__footer {
margin-top: 0.6rem;
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.game-reviews__action {
padding: 0.25rem 0.6rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.game-reviews__action--edit:hover {
color: var(--accent);
border-color: rgba(232, 165, 75, 0.3);
background: var(--accent-soft);
}
.game-reviews__action--delete:hover {
color: #b33;
border-color: rgba(180, 50, 50, 0.25);
background: rgba(180, 50, 50, 0.06);
}
/* 덧글 수정 폼 (인라인) */
.game-comments__edit-form {
margin-top: 0.6rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.game-comments__edit-form textarea {
width: 100%;
box-sizing: border-box;
min-height: 4.5rem;
padding: 0.65rem 0.85rem;
font-size: 0.875rem;
font-family: inherit;
line-height: 1.55;
color: var(--text);
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
resize: vertical;
}
.game-comments__edit-form textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.18);
}
html[data-theme="dark"] .game-comments__form textarea,
html[data-theme="dark"] .game-comments__edit-form textarea {
border-color: rgba(255, 255, 255, 0.26);
}
/* ── 6축 평가 입력 그리드 ─────────────────────────────── */
.game-reviews__axes {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem 1.1rem;
margin-bottom: 0.85rem;
}
@media (max-width: 480px) {
.game-reviews__axes {
grid-template-columns: 1fr;
}
}
.game-reviews__axis {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.3rem 0.5rem;
border-radius: 10px;
transition: background 0.15s ease;
}
.game-reviews__axis:hover {
background: rgba(232, 165, 75, 0.08);
}
.game-reviews__axis-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
}
.game-stars--axis {
gap: 0.1rem;
}
.game-stars--axis .game-stars__btn {
font-size: 1.25rem;
}
/* ── 육각형 레이더 ───────────────────────────────────── */
.game-reviews__summary-panel {
margin: 0 0 1.25rem;
display: flex;
align-items: center;
justify-content: center;
gap: 1.25rem;
padding: 1rem 1.25rem;
background:
radial-gradient(125% 125% at 0% 0%, var(--accent-soft), transparent 62%),
var(--card-bg);
border: 1px solid rgba(232, 165, 75, 0.22);
border-radius: var(--radius);
box-shadow: var(--panel-shadow);
}
@media (max-width: 480px) {
.game-reviews__summary-panel {
flex-direction: column;
align-items: stretch;
gap: 0.85rem;
}
.game-reviews__radar { justify-content: center; }
}
.game-reviews__radar {
display: inline-flex;
flex-shrink: 0;
gap: 1.75rem;
}
.game-reviews__radar svg {
display: block;
}
/* drop-shadow 가 viewBox 밖으로 번질 수 있어 클립 방지 */
.game-axis-radar {
overflow: visible;
}
.game-reviews__radar .game-axis-grid {
fill: none;
stroke: var(--border);
stroke-width: 1;
}
.game-reviews__radar .game-axis-grid--outer {
stroke: rgba(232, 165, 75, 0.4);
stroke-width: 1.25;
}
.game-reviews__radar .game-axis-line {
stroke: var(--border);
stroke-width: 1;
stroke-dasharray: 2 3;
}
/* fill 은 JS 가 url(#gradient) 프레젠테이션 속성으로 지정 — 여기서 fill 지정 금지(우선순위 역전) */
.game-reviews__radar .game-axis-poly {
stroke: var(--accent);
stroke-width: 1.75;
stroke-linejoin: round;
filter: drop-shadow(0 2px 7px rgba(232, 165, 75, 0.38));
transform-box: fill-box;
transform-origin: center;
animation: game-radar-pop 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.game-reviews__radar .game-axis-dot {
fill: var(--accent);
stroke: var(--card-bg);
stroke-width: 1.5;
animation: game-radar-fade 0.7s ease both;
}
.game-reviews__radar .game-axis-axislabel {
font-size: 11px;
font-weight: 600;
fill: var(--text-muted);
}
.game-reviews__radar .game-axis-scorelabel {
font-size: 10px;
font-weight: 700;
fill: var(--accent);
font-variant-numeric: tabular-nums;
animation: game-radar-fade 0.7s ease both;
}
@keyframes game-radar-pop {
from { opacity: 0; transform: scale(0.55); }
to { opacity: 1; transform: scale(1); }
}
@keyframes game-radar-fade {
from { opacity: 0; }
to { opacity: 1; }
}
.game-reviews__radar-empty {
font-size: 0.75rem;
color: var(--text-muted);
}
.game-reviews__title-meter {
display: inline-flex;
align-items: flex-end;
gap: 0.15rem;
height: 0.95rem;
}
.game-reviews__title-meter .stick {
position: relative;
width: 0.2rem;
height: 100%;
border-radius: 2px;
background: var(--border);
overflow: hidden;
}
.game-reviews__title-meter .stick span {
position: absolute;
bottom: 0;
left: 0;
right: 0;
border-radius: 2px;
background: linear-gradient(180deg, #f0c978, var(--accent));
animation: game-title-meter-grow 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
transform-origin: bottom;
}
@keyframes game-title-meter-grow {
from { transform: scaleY(0); }
to { transform: scaleY(1); }
}
@media (prefers-reduced-motion: reduce) {
.game-reviews__radar .game-axis-poly,
.game-reviews__radar .game-axis-dot,
.game-reviews__title-meter .stick span { animation: none; }
}
/* ── sort 토글 / 더보기 ──────────────────────────────── */
.game-comments__toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 0.85rem;
}
.game-comments__sort {
display: inline-flex;
gap: 0.25rem;
padding: 0.2rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
}
.game-comments__sort-btn {
padding: 0.3rem 0.7rem;
font-size: 0.6875rem;
font-weight: 700;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: 999px;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
.game-comments__sort-btn.is-active {
color: #1a1a1a;
background: linear-gradient(180deg, #f0c978, var(--accent));
box-shadow: 0 1px 5px rgba(232, 165, 75, 0.4);
}
html[data-theme="dark"] .game-comments__sort-btn.is-active {
color: #1a1a1a;
}
.game-comments__more {
margin-top: 1rem;
display: flex;
justify-content: center;
}
.game-comments__more-btn {
padding: 0.5rem 1.4rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--text);
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
}
.game-comments__more-btn:hover {
border-color: var(--accent);
color: var(--accent);
transform: translateY(-1px);
box-shadow: 0 5px 16px rgba(232, 165, 75, 0.16);
}
@media (prefers-reduced-motion: reduce) {
.game-comments__more-btn:hover { transform: none; }
}
/* ── 제출 잠금/로딩 ──────────────────────────────────── */
.game-comments__hint.is-warn {
color: #b33;
font-weight: 700;
}
button[aria-busy="true"] {
opacity: 0.65;
cursor: progress;
}
.game-comments__edited {
margin-left: 0.35rem;
font-size: 0.6875rem;
font-weight: 500;
color: var(--text-muted);
}
</style>
</head>
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="game-page">
<div class="game-topbar">
<a class="game-back" href="<%= ctx %>/">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
목록으로
</a>
<% if (owner) { %>
<div class="game-owner-actions" aria-label="게시물 관리">
<a class="game-owner-button" href="<%= ctx %>/game/<%= gameIdValue %>/edit">수정</a>
<button type="button" class="game-owner-button game-owner-button--danger" id="game-delete-button">삭제</button>
</div>
<% } %>
</div>
<section class="game-info-card" aria-labelledby="game-title">
<h1 id="game-title"><%= gameNameValue %></h1>
<div class="game-meta-grid">
<div class="game-meta-item">
<div class="game-meta-item__row">
<span class="game-meta-item__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18"/></svg>
</span>
<span class="game-meta-item__label">번호</span>
</div>
<span class="game-meta-item__value">#<%= gameIdValue %></span>
</div>
<div class="game-meta-item">
<div class="game-meta-item__row">
<span class="game-meta-item__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</span>
<span class="game-meta-item__label">제작자</span>
</div>
<span class="game-meta-item__value"><%= creatorValue %></span>
</div>
<div class="game-meta-item game-meta-item--likes">
<div class="game-meta-item__row">
<span class="game-meta-item__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
</span>
<span class="game-meta-item__label">좋아요</span>
</div>
<button type="button" id="game-like-btn" class="game-meta-like-btn" aria-pressed="false" aria-label="좋아요">
<svg class="game-meta-like-btn__heart" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
<span id="game-like-count" class="game-meta-like-btn__count"><%= likeCountFormattedValue %></span>
</button>
</div>
</div>
</section>
<section class="game-webgl" aria-labelledby="webgl-heading">
<div class="game-webgl__head">
<span class="game-webgl__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</span>
<h2 id="webgl-heading">플레이</h2>
<span class="game-webgl__badge">WebGL</span>
</div>
<div class="game-webgl__shell">
<div class="game-webgl__frame-wrap">
<iframe title="<%= gameNameValue %> WebGL"
src="<%= webglFrameSrcValue %>"
sandbox="allow-scripts allow-same-origin allow-pointer-lock"
allow="fullscreen; autoplay; xr-spatial-tracking"
loading="eager"
referrerpolicy="same-origin"></iframe>
</div>
</div>
</section>
<div class="game-community">
<section class="game-panel game-panel--creator" aria-labelledby="creator-heading">
<div class="game-panel__head">
<span class="game-panel__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>
</span>
<div>
<h2 id="creator-heading" class="game-panel__title">제작자의 한마디</h2>
<p class="game-panel__subtitle">이 게임을 만들며 전하고 싶었던 이야기예요.</p>
</div>
</div>
<blockquote class="game-panel__quote"><%= creatorNoteValue %></blockquote>
<div class="game-panel__git-wrap">
<a class="game-panel__git" href="<%= gitUrlValue %>" target="_blank" rel="noopener noreferrer">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
소스 코드 · GitHub
</a>
</div>
</section>
<section class="game-panel game-panel--reviews" aria-labelledby="reviews-heading">
<div class="game-panel__head">
<span class="game-panel__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l2.9 6.2 6.8.9-5 4.7 1.3 6.7L12 17.8 5.9 21.2 7.2 14.5l-5-4.7 6.8-.9z"/></svg>
</span>
<div>
<h2 id="reviews-heading" class="game-panel__title">리뷰</h2>
<p class="game-panel__subtitle">별점과 함께 플레이 평가를 남겨 주세요. 게임당 한 번 작성할 수 있어요.</p>
</div>
<div class="game-reviews__summary" id="game-reviews-summary" hidden>
<span class="game-reviews__avg" id="game-reviews-avg">–</span>
<div class="game-stars game-stars--avg" id="game-reviews-avg-stars" aria-hidden="true"></div>
<span class="game-reviews__count" id="game-reviews-count"></span>
</div>
</div>
<div class="game-reviews__summary-panel" id="game-reviews-summary-panel" hidden>
<div class="game-reviews__radar" id="game-reviews-summary-radar"></div>
</div>
<div class="game-reviews__composer" id="game-review-composer" hidden>
<form id="game-review-form" class="game-comments__form" novalidate>
<div class="game-reviews__axes" id="game-review-axes" aria-label="6축 평가"></div>
<div class="game-reviews__rating-row">
<span class="game-comments__label" id="game-review-stars-label">종합 별점 (선택)</span>
<div class="game-stars game-stars--input" id="game-review-stars" role="radiogroup" aria-labelledby="game-review-stars-label"></div>
</div>
<label class="game-comments__label" for="game-review-input">평가</label>
<textarea id="game-review-input" name="body" rows="4" maxlength="1000" placeholder="이 게임을 어떻게 즐기셨나요? (최소 10자)"></textarea>
<div class="game-comments__actions">
<span class="game-comments__hint" id="game-review-hint"><span id="game-review-counter">0/1000</span> · 6축은 필수, 종합 별점은 선택이에요</span>
<div class="game-reviews__form-buttons">
<button type="button" class="game-reviews__cancel" id="game-review-cancel" hidden>취소</button>
<button type="submit" id="game-review-submit">리뷰 등록</button>
</div>
</div>
</form>
</div>
<p class="game-comments__login-gate" id="game-review-login-gate" hidden>리뷰를 남기려면 <a href="<%= ctx %>/login">로그인</a>이 필요해요.</p>
<div class="game-comments__toolbar" id="game-reviews-toolbar" hidden>
<div class="game-comments__sort" role="group" aria-label="리뷰 정렬">
<button type="button" class="game-comments__sort-btn is-active" data-sort="newest" aria-pressed="true">최신순</button>
<button type="button" class="game-comments__sort-btn" data-sort="rating_desc" aria-pressed="false">별점 높은순</button>
<button type="button" class="game-comments__sort-btn" data-sort="rating_asc" aria-pressed="false">별점 낮은순</button>
</div>
</div>
<p id="game-reviews-empty" class="game-comments__empty" hidden>아직 리뷰가 없습니다.<br>첫 번째 리뷰를 남겨 보세요.</p>
<ul id="game-review-list" class="game-reviews__list" aria-labelledby="reviews-heading"></ul>
<div class="game-comments__more" hidden>
<button type="button" class="game-comments__more-btn" id="game-reviews-more">더보기</button>
</div>
</section>
<section class="game-panel game-panel--comments" aria-labelledby="comments-heading">
<div class="game-panel__head">
<span class="game-panel__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
</span>
<div>
<h2 id="comments-heading" class="game-panel__title">덧글</h2>
<p class="game-panel__subtitle">플레이 소감이나 버그 제보를 남겨 주세요.</p>
</div>
</div>
<div class="game-comments__composer" id="game-comment-composer" hidden>
<form id="game-comment-form" class="game-comments__form" novalidate>
<label class="game-comments__label" for="game-comment-input">내용</label>
<textarea id="game-comment-input" name="comment" rows="4" maxlength="200" placeholder="여기에 덧글을 작성해 주세요." required></textarea>
<div class="game-comments__actions">
<span class="game-comments__hint"><span id="game-comment-counter">0/200</span></span>
<button type="submit" id="game-comment-submit">덧글 등록</button>
</div>
</form>
</div>
<p class="game-comments__login-gate" id="game-comment-login-gate" hidden>덧글을 남기려면 <a href="<%= ctx %>/login">로그인</a>이 필요해요.</p>
<div class="game-comments__toolbar" id="game-comments-toolbar" hidden>
<div class="game-comments__sort" role="group" aria-label="덧글 정렬">
<button type="button" class="game-comments__sort-btn is-active" data-sort="oldest" aria-pressed="true">등록순</button>
<button type="button" class="game-comments__sort-btn" data-sort="newest" aria-pressed="false">최신순</button>
</div>
</div>
<p id="game-comments-empty" class="game-comments__empty" hidden>아직 덧글이 없습니다.<br>첫 번째 덧글을 남겨 보세요.</p>
<ul id="game-comment-list" class="game-comments__list" aria-labelledby="comments-heading"></ul>
<div class="game-comments__more" hidden>
<button type="button" class="game-comments__more-btn" id="game-comments-more">더보기</button>
</div>
</section>
</div>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
<script>
(function () {
var ctx = '${pageContext.request.contextPath}';
var gameId = ${gameId};
var liked = ${empty liked ? 'false' : liked};
var viewerId = ${empty currentUserId ? 'null' : currentUserId};
var viewerRole = '${empty userRole ? "" : userRole}';
function formatCount(n) {
return n.toLocaleString('ko-KR');
}
var likeBtn = document.getElementById('game-like-btn');
var likeCountEl = document.getElementById('game-like-count');
var gid = String(gameId);
var deleteBtn = document.getElementById('game-delete-button');
function deleteGame() {
fetch(ctx + '/game/' + encodeURIComponent(gid), {
method: 'DELETE',
headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}) : {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
}).then(function (res) {
return res.json().catch(function () {
return { message: '게시물을 삭제하지 못했습니다.' };
}).then(function (data) {
if (!res.ok) {
throw new Error(data && data.message ? data.message : '게시물을 삭제하지 못했습니다.');
}
return data;
});
}).then(function () {
window.location.href = ctx + '/';
}).catch(function (err) {
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({
title: '삭제 실패',
message: err.message || '게시물을 삭제하지 못했습니다.',
confirmText: '확인'
});
} else {
alert(err.message || '게시물을 삭제하지 못했습니다.');
}
});
}
if (deleteBtn) {
deleteBtn.addEventListener('click', function () {
if (window.BibimbapModal && typeof window.BibimbapModal.confirm === 'function') {
window.BibimbapModal.confirm({
title: '게시물 삭제',
message: '삭제하시겠습니까? 삭제 후 복구가 불가능합니다.',
confirmText: '삭제',
cancelText: '취소',
onConfirm: deleteGame
});
} else if (confirm('게시물을 삭제할까요?')) {
deleteGame();
}
});
}
function applyLikeUi() {
likeBtn.setAttribute('aria-pressed', liked ? 'true' : 'false');
likeBtn.setAttribute('aria-label', liked ? '좋아요 취소' : '좋아요');
}
var pending = false;
likeBtn.addEventListener('click', function () {
if (pending) return;
pending = true;
fetch(ctx + '/game/' + encodeURIComponent(gid) + '/like', {
method: 'POST',
headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}) : {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
}).then(function (res) {
if (res.status === 401) {
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({ title: '로그인 필요', message: '좋아요는 로그인 후 이용할 수 있습니다.', confirmText: '확인' });
} else {
alert('좋아요는 로그인 후 이용할 수 있습니다.');
}
return null;
}
if (res.status === 403) {
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({ title: '요청 실패', message: '요청 보안 토큰이 유효하지 않습니다. 새로고침 후 다시 시도해 주세요.', confirmText: '확인' });
} else {
alert('요청 보안 토큰이 유효하지 않습니다.');
}
return null;
}
if (!res.ok) { return null; }
return res.json().catch(function () { return null; });
}).then(function (data) {
if (data && typeof data.liked !== 'undefined') {
liked = !!data.liked;
applyLikeUi();
if (typeof data.likeCount === 'number') {
likeCountEl.textContent = formatCount(data.likeCount);
}
}
}).catch(function () {
// 네트워크 실패 — 상태 원복(현재 liked 유지). 별도 처리 없음.
}).then(function () {
pending = false;
});
});
applyLikeUi();
// ===== 공통 헬퍼 (서버 연동 덧글/리뷰) =====
var AXIS_KEYS = ['immersion', 'creativity', 'controls', 'completeness', 'sound', 'visual'];
var AXIS_LABELS_KO = {
immersion: '몰입성', creativity: '창의성', controls: '조작성',
completeness: '완성도', sound: '사운드', visual: '비주얼'
};
var SVG_NS = 'http://www.w3.org/2000/svg';
var labelSeq = 0;
function fmtAbsolute(iso) {
try { return iso ? new Date(iso).toLocaleString('ko-KR') : ''; }
catch (e) { return ''; }
}
// 상대시각: 7일 이내는 "n분/시간/일 전", 초과는 절대 날짜
function fmtRelative(iso) {
if (!iso) return '';
var d = new Date(iso);
if (isNaN(d.getTime())) return '';
var diff = Date.now() - d.getTime();
if (diff < 0) diff = 0;
var sec = Math.floor(diff / 1000);
if (sec < 60) return '방금 전';
var min = Math.floor(sec / 60);
if (min < 60) return min + '분 전';
var hr = Math.floor(min / 60);
if (hr < 24) return hr + '시간 전';
var day = Math.floor(hr / 24);
if (day <= 7) return day + '일 전';
return d.toLocaleDateString('ko-KR');
}
// edited 면 "(수정됨)" 뱃지 동반. <time datetime=ISO title=절대시각>
function buildTimeEl(iso, edited) {
var t = document.createElement('time');
t.dateTime = iso || '';
t.title = fmtAbsolute(iso);
t.textContent = fmtRelative(iso);
if (!edited) return t;
var frag = document.createDocumentFragment();
frag.appendChild(t);
var badge = document.createElement('span');
badge.className = 'game-comments__edited';
badge.textContent = '(수정됨)';
frag.appendChild(badge);
return frag;
}
function notifyError(title, message) {
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({ title: title, message: message, confirmText: '확인' });
} else { alert(message); }
}
function confirmAction(title, message, okText, onOk) {
if (window.BibimbapModal && typeof window.BibimbapModal.confirm === 'function') {
window.BibimbapModal.confirm({ title: title, message: message, confirmText: okText, cancelText: '취소', onConfirm: onOk });
} else if (confirm(message)) { onOk(); }
}
function baseHeaders(extra) {
var h = { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' };
if (extra) { Object.keys(extra).forEach(function (k) { h[k] = extra[k]; }); }
return window.BibimbapCsrf ? window.BibimbapCsrf.headers(h) : h;
}
function formHeaders() {
return baseHeaders({ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' });
}
function encodeForm(obj) {
return Object.keys(obj).map(function (k) {
return encodeURIComponent(k) + '=' + encodeURIComponent(obj[k] == null ? '' : obj[k]);
}).join('&');
}
function api(url, opts) {
return fetch(url, opts).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) {
if (!res.ok) { throw new Error((data && data.message) || ('요청을 처리하지 못했습니다. (' + res.status + ')')); }
return data;
});
});
}
// 본인(작성자) 또는 운영자(ADMIN)만 수정/삭제 버튼 노출 — 서버가 최종 권한 재검증
function canActOn(authorUserId) {
if (viewerId == null) return false;
if (authorUserId != null && Number(authorUserId) === Number(viewerId)) return true;
return viewerRole === 'ADMIN';
}
// 제출 버튼 잠금/로딩 — 연타 차단. lock() 호출 후 unlock() 으로 해제
function lockButton(btn) {
if (!btn) return function () {};
var origText = btn.textContent;
btn.disabled = true;
btn.setAttribute('aria-busy', 'true');
btn.textContent = '처리 중…';
return function () {
btn.disabled = false;
btn.removeAttribute('aria-busy');
btn.textContent = origText;
};
}
// ===== 육각형 레이더 SVG (요약/카드 공용) =====
// scores: AXIS_KEYS 순 6값(1~5, null/0=데이터 없음). cx/cy/R 좌표.
var hexSeq = 0; // svg 별 그라디언트 id 고유화
function buildHexRadar(scores, cx, cy, R, withLabels) {
var canvas = cx * 2; // cx==cy 가정. 요약 200 / 카드 120
var svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('viewBox', '0 0 ' + canvas + ' ' + canvas);
svg.setAttribute('width', String(Math.round(canvas * 0.85)));
svg.setAttribute('height', String(Math.round(canvas * 0.85)));
svg.setAttribute('role', 'img');
svg.setAttribute('class', 'game-axis-radar');
var nums = AXIS_KEYS.map(function (k, i) {
var v = scores ? Number(scores[i]) : 0;
return (isFinite(v) && v > 0) ? v : 0;
});
var hasData = nums.some(function (v) { return v > 0; });
function ptOnAxis(i, ratio) {
var ang = (-90 + 60 * i) * Math.PI / 180;
return {
x: cx + R * ratio * Math.cos(ang),
y: cy + R * ratio * Math.sin(ang)
};
}
function polyPoints(ratioFn) {
return AXIS_KEYS.map(function (k, i) {
var p = ptOnAxis(i, ratioFn(i));
return p.x.toFixed(2) + ',' + p.y.toFixed(2);
}).join(' ');
}
// 채움용 방사형 앰버 그라디언트 (svg 별 고유 id — 한 페이지 다중 레이더 충돌 방지)
var gid = 'hexfill-' + (++hexSeq);
var defs = document.createElementNS(SVG_NS, 'defs');
var grad = document.createElementNS(SVG_NS, 'radialGradient');
grad.setAttribute('id', gid);
grad.setAttribute('cx', '50%');
grad.setAttribute('cy', '50%');
grad.setAttribute('r', '62%');
[['0%', 'rgba(240, 201, 120, 0.55)'], ['100%', 'rgba(232, 165, 75, 0.26)']].forEach(function (s) {
var st = document.createElementNS(SVG_NS, 'stop');
st.setAttribute('offset', s[0]);
st.setAttribute('stop-color', s[1]);
grad.appendChild(st);
});
defs.appendChild(grad);
svg.appendChild(defs);
// 동심 그리드 (5단계 = 1~5점 정수 등급과 1:1 대응, 외곽 강조)
for (var level = 1; level <= 5; level++) {
var grid = document.createElementNS(SVG_NS, 'polygon');
grid.setAttribute('class', 'game-axis-grid' + (level === 5 ? ' game-axis-grid--outer' : ''));
(function (lv) {
grid.setAttribute('points', polyPoints(function () { return lv / 5; }));
})(level);
svg.appendChild(grid);
}
// 축선 + (withLabels 시) 축 이름 라벨 — 육각형만으론 어떤 축인지 식별 불가(§6.6)
// 6축 순서상 위치 고정: 0=위, 1=우상, 2=우하, 3=아래, 4=좌하, 5=좌상
var AXIS_LABEL_ANCHOR = ['middle', 'start', 'start', 'middle', 'end', 'end'];
AXIS_KEYS.forEach(function (k, i) {
var line = document.createElementNS(SVG_NS, 'line');
line.setAttribute('class', 'game-axis-line');
var p = ptOnAxis(i, 1);
line.setAttribute('x1', String(cx));
line.setAttribute('y1', String(cy));
line.setAttribute('x2', p.x.toFixed(2));
line.setAttribute('y2', p.y.toFixed(2));
svg.appendChild(line);
if (withLabels) {
var lp = ptOnAxis(i, 1.14);
var label = document.createElementNS(SVG_NS, 'text');
label.setAttribute('class', 'game-axis-axislabel');
label.setAttribute('x', lp.x.toFixed(2));
label.setAttribute('y', lp.y.toFixed(2));
label.setAttribute('text-anchor', AXIS_LABEL_ANCHOR[i]);
label.setAttribute('dominant-baseline', i === 0 ? 'text-after-edge' : (i === 3 ? 'text-before-edge' : 'middle'));
label.textContent = AXIS_LABELS_KO[k];
svg.appendChild(label);
}
});
// score 폴리곤 + 꼭지점 점 (데이터 있을 때만)
if (hasData) {
var poly = document.createElementNS(SVG_NS, 'polygon');
poly.setAttribute('class', 'game-axis-poly');
poly.setAttribute('points', polyPoints(function (i) { return nums[i] / 5; }));
poly.setAttribute('fill', 'url(#' + gid + ')');
svg.appendChild(poly);
AXIS_KEYS.forEach(function (k, i) {
if (nums[i] <= 0) return;
var ratio = nums[i] / 5;
var p = ptOnAxis(i, ratio);
var dot = document.createElementNS(SVG_NS, 'circle');
dot.setAttribute('class', 'game-axis-dot');
dot.setAttribute('cx', p.x.toFixed(2));
dot.setAttribute('cy', p.y.toFixed(2));
dot.setAttribute('r', Math.max(1.6, R * 0.05).toFixed(2));
svg.appendChild(dot);
if (withLabels) {
// 꼭지점에서 살짝 바깥으로 띄워 점 위에 겹치지 않게 배치
var sp = ptOnAxis(i, ratio + 14 / R);
var score = document.createElementNS(SVG_NS, 'text');
score.setAttribute('class', 'game-axis-scorelabel');
score.setAttribute('x', sp.x.toFixed(2));
score.setAttribute('y', sp.y.toFixed(2));
score.setAttribute('text-anchor', AXIS_LABEL_ANCHOR[i]);
score.setAttribute('dominant-baseline', 'middle');
score.textContent = String(nums[i]);
svg.appendChild(score);
}
});
}
// a11y aria-label
if (hasData) {
svg.setAttribute('aria-label',
'6축 평가: ' + AXIS_KEYS.map(function (k, i) {
return AXIS_LABELS_KO[k] + ' ' + nums[i];
}).join(', '));
} else {
svg.setAttribute('aria-label', '6축 평가 없음');
}
return { svg: svg, nums: nums, hasData: hasData };
}
// 리뷰 카드 제목행 삽입용 6축 미니 미터바 — axes 없으면 null(§6.6: 그래프는 요약에만, 카드는 이 미터바로 대체)
function buildTitleMeter(axes) {
var nums = AXIS_KEYS.map(function (k) {
var v = axes ? Number(axes[k]) : 0;
return (isFinite(v) && v > 0) ? v : 0;
});
var hasData = nums.some(function (v) { return v > 0; });
if (!hasData) return null;
var wrap = document.createElement('div');
wrap.className = 'game-reviews__title-meter';
wrap.setAttribute('role', 'img');
wrap.setAttribute('aria-label', '6축 평가: ' + AXIS_KEYS.map(function (k, i) {
return AXIS_LABELS_KO[k] + ' ' + nums[i];
}).join(', '));
nums.forEach(function (v) {
var stick = document.createElement('span');
stick.className = 'stick';
var fill = document.createElement('span');
fill.style.height = (v / 5 * 100) + '%';
stick.appendChild(fill);
wrap.appendChild(stick);
});
return wrap;
}
// ===== 별점 radiogroup 완전 패턴 (C6) =====
// axisScores: axisKey 별 선택값(0=미선택). buildStarRadioGroup 으로 위젯 생성.
var axisScores = {};
function buildStarRadioGroup(axisKey, labelText) {
var labelId = 'star-label-' + axisKey + '-' + (++labelSeq);
var group = document.createElement('div');
group.className = 'game-stars game-stars--input game-stars--axis';
group.setAttribute('role', 'radiogroup');
group.setAttribute('aria-labelledby', labelId);
var radios = [];
function paint() {
var sel = axisScores[axisKey] || 0;
radios.forEach(function (b, idx) {
var v = idx + 1;
b.classList.toggle('is-on', v <= sel);
b.setAttribute('aria-checked', v === sel ? 'true' : 'false');
b.tabIndex = (v === sel) ? 0 : -1;
});
if (!sel) radios[0].tabIndex = 0; // 미선택 시 첫 radio tabindex=0
}
function select(v) {
axisScores[axisKey] = v;
paint();
}
for (var i = 1; i <= 5; i++) {
(function (v) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'game-stars__btn';
b.setAttribute('role', 'radio');
b.setAttribute('aria-checked', 'false');
b.setAttribute('aria-label', labelText + ' ' + v + '점');
b.tabIndex = -1;
b.textContent = '★';
b.addEventListener('click', function () { select(v); b.focus(); });
b.addEventListener('keydown', function (ev) {
var cur = axisScores[axisKey] || 0;
var next = null;
switch (ev.key) {
case 'ArrowRight': case 'ArrowUp': next = Math.min(5, (cur || v) + 1); break;
case 'ArrowLeft': case 'ArrowDown': next = Math.max(1, (cur || v) - 1); break;
case 'Home': next = 1; break;
case 'End': next = 5; break;
case ' ': case 'Spacebar': case 'Enter': next = v; break;
default: return;
}
ev.preventDefault();
select(next);
radios[next - 1].focus();
});
radios.push(b);
group.appendChild(b);
})(i);
}
paint();
return { group: group, labelId: labelId, set: select, paint: paint };
}
// ===== 덧글 (서버 연동) =====
var cListEl = document.getElementById('game-comment-list');
var cEmptyEl = document.getElementById('game-comments-empty');
var cForm = document.getElementById('game-comment-form');
var cInput = document.getElementById('game-comment-input');
var cSubmit = document.getElementById('game-comment-submit');
var cCounter = document.getElementById('game-comment-counter');
var cComposer = document.getElementById('game-comment-composer');
var cLoginGate = document.getElementById('game-comment-login-gate');
var cToolbar = document.getElementById('game-comments-toolbar');
var cMoreBtn = document.getElementById('game-comments-more');
var cMoreWrap = cMoreBtn ? cMoreBtn.parentElement : null;
var commentsUrl = ctx + '/game/' + encodeURIComponent(gid) + '/comments';
var currentCommentPage = 0;
var currentCommentSort = 'oldest';
var COMMENT_MAX = 200;
function openCommentEdit(li, body, p, c) {
if (li.querySelector('.game-comments__edit-form')) return;
var ef = document.createElement('form');
ef.className = 'game-comments__edit-form';
var ta = document.createElement('textarea');
ta.maxLength = 200;
ta.value = c.content || '';
var actions = document.createElement('div');
actions.className = 'game-comments__actions';
var hint = document.createElement('span');
hint.className = 'game-comments__hint';
hint.textContent = '최대 200자';
var btnWrap = document.createElement('div');
btnWrap.className = 'game-reviews__form-buttons';
var cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'game-reviews__cancel';
cancel.textContent = '취소';
var save = document.createElement('button');
save.type = 'submit';
save.textContent = '저장';
btnWrap.appendChild(cancel);
btnWrap.appendChild(save);
actions.appendChild(hint);
actions.appendChild(btnWrap);
ef.appendChild(ta);
ef.appendChild(actions);
p.hidden = true;
body.appendChild(ef);
ta.focus();
cancel.addEventListener('click', function () { ef.remove(); p.hidden = false; });
ta.addEventListener('input', function () { hint.textContent = ta.value.length + '/' + COMMENT_MAX; });
hint.textContent = ta.value.length + '/' + COMMENT_MAX;
ef.addEventListener('submit', function (ev) {
ev.preventDefault();
var text = (ta.value || '').trim();
if (!text) { notifyError('입력 필요', '덧글 내용을 입력해 주세요.'); return; }
if (text.length > COMMENT_MAX) { notifyError('입력 초과', '덧글은 200자 이내로 입력해 주세요.'); return; }
var unlock = lockButton(save);
// A1: PUT 응답 commentView 로 해당 li 갱신 (전체 재조회 대신 단건 교체)
api(commentsUrl + '/' + encodeURIComponent(c.commentId), {
method: 'PUT', headers: formHeaders(), body: encodeForm({ content: text })
}).then(function (data) {
unlock();
var view = data && data.commentId != null ? data : null;
if (view) {
var fresh = buildCommentItem(view);
li.parentNode.replaceChild(fresh, li);
} else {
ef.remove(); p.hidden = false;
}
}).catch(function (err) { unlock(); notifyError('수정 실패', err.message); });
});
}
function buildCommentItem(c) {
var li = document.createElement('li');
li.className = 'game-comments__item';
var nick = (c.authorName && String(c.authorName).trim()) ? String(c.authorName).trim() : '익명';
var av = document.createElement('div');
av.className = 'game-comments__avatar';
av.setAttribute('aria-hidden', 'true');
av.textContent = nick.charAt(0).toUpperCase();
var body = document.createElement('div');
body.className = 'game-comments__body';
var meta = document.createElement('div');
meta.className = 'game-comments__meta';
var nickEl = document.createElement('span');
nickEl.className = 'game-comments__nick';
nickEl.textContent = nick;
meta.appendChild(nickEl);
meta.appendChild(buildTimeEl(c.updatedAt || c.createdAt, !!c.edited));
var p = document.createElement('p');
p.textContent = c.content || '';
body.appendChild(meta);
body.appendChild(p);
if (canActOn(c.userId)) {
var foot = document.createElement('div');
foot.className = 'game-comments__footer';
var editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'game-reviews__action game-reviews__action--edit';
editBtn.textContent = '수정';
editBtn.addEventListener('click', function () { openCommentEdit(li, body, p, c); });
var del = document.createElement('button');
del.type = 'button';
del.className = 'game-reviews__action game-reviews__action--delete';
del.textContent = '삭제';
del.setAttribute('aria-label', '이 덧글 삭제');
del.addEventListener('click', function () {
confirmAction('덧글 삭제', '이 덧글을 삭제할까요?', '삭제', function () {
api(commentsUrl + '/' + encodeURIComponent(c.commentId), { method: 'DELETE', headers: baseHeaders() })
.then(function () {
li.remove(); // C5: DOM 완전 제거 (tombstone 잔재 없음)
cEmptyEl.hidden = cListEl.children.length > 0;
})
.catch(function (err) { notifyError('삭제 실패', err.message); });
});
});
foot.appendChild(editBtn);
foot.appendChild(del);
body.appendChild(foot);
}
li.appendChild(av);
li.appendChild(body);
return li;
}
function setCommentMore(hasMore) {
if (cMoreWrap) cMoreWrap.hidden = !hasMore;
}
function appendComments(list) {
list.forEach(function (c) { cListEl.appendChild(buildCommentItem(c)); });
cEmptyEl.hidden = cListEl.children.length > 0;
}
// page=0 일 때 목록 초기화, 이후 page 는 append
function loadComments(page) {
var p = page || 0;
if (p === 0) { cListEl.innerHTML = ''; }
var url = commentsUrl + '?page=' + p + '&sort=' + encodeURIComponent(currentCommentSort);
return api(url, { headers: baseHeaders() })
.then(function (data) {
currentCommentPage = p;
appendComments(Array.isArray(data.comments) ? data.comments : []);
setCommentMore(!!data.hasMore);
if (cToolbar) cToolbar.hidden = false;
})
.catch(function () { if (p === 0) cEmptyEl.hidden = false; });
}
if (cMoreBtn) {
cMoreBtn.addEventListener('click', function () { loadComments(currentCommentPage + 1); });
}
if (cToolbar) {
Array.prototype.forEach.call(cToolbar.querySelectorAll('.game-comments__sort-btn'), function (btn) {
btn.addEventListener('click', function () {
var sort = btn.getAttribute('data-sort');
if (sort === currentCommentSort) return;
currentCommentSort = sort;
Array.prototype.forEach.call(cToolbar.querySelectorAll('.game-comments__sort-btn'), function (b) {
var on = b === btn;
b.classList.toggle('is-active', on);
b.setAttribute('aria-pressed', on ? 'true' : 'false');
});
loadComments(0);
});
});
}
if (cForm && cInput) {
if (cCounter) {
cInput.addEventListener('input', function () { cCounter.textContent = cInput.value.length + '/' + COMMENT_MAX; });
}
if (viewerId == null) {
if (cComposer) cComposer.hidden = true;
if (cLoginGate) cLoginGate.hidden = false;
} else {
if (cComposer) cComposer.hidden = false;
if (cLoginGate) cLoginGate.hidden = true;
cForm.addEventListener('submit', function (ev) {
ev.preventDefault();
var text = (cInput.value || '').trim();
if (!text) return;
if (text.length > COMMENT_MAX) { notifyError('입력 초과', '덧글은 200자 이내로 입력해 주세요.'); return; }
var unlock = lockButton(cSubmit);
// A1: POST 응답 commentView 로 새 li 생성·삽입 (oldest=append / newest=prepend)
api(commentsUrl, { method: 'POST', headers: formHeaders(), body: encodeForm({ content: text }) })
.then(function (data) {
unlock();
cInput.value = '';
if (cCounter) cCounter.textContent = '0/' + COMMENT_MAX;
var view = data && data.commentId != null ? data : null;
if (view) {
var li = buildCommentItem(view);
if (currentCommentSort === 'newest') cListEl.insertBefore(li, cListEl.firstChild);
else cListEl.appendChild(li);
cEmptyEl.hidden = true;
} else {
loadComments(0);
}
})
.catch(function (err) { unlock(); notifyError('등록 실패', err.message); });
});
}
}
loadComments(0);
// ===== 리뷰 (서버 연동) =====
var rListEl = document.getElementById('game-review-list');
var rEmptyEl = document.getElementById('game-reviews-empty');
var rComposer = document.getElementById('game-review-composer');
var rLoginGate = document.getElementById('game-review-login-gate');
var rForm = document.getElementById('game-review-form');
var rInput = document.getElementById('game-review-input');
var rCounter = document.getElementById('game-review-counter');
var rHint = document.getElementById('game-review-hint');
var rAxesEl = document.getElementById('game-review-axes');
var rStarsEl = document.getElementById('game-review-stars');
var rStarsLabel = document.getElementById('game-review-stars-label');
var rSubmit = document.getElementById('game-review-submit');
var rCancel = document.getElementById('game-review-cancel');
var rSummary = document.getElementById('game-reviews-summary');
var rSummaryPanel = document.getElementById('game-reviews-summary-panel');
var rSummaryRadar = document.getElementById('game-reviews-summary-radar');
var rAvg = document.getElementById('game-reviews-avg');
var rAvgStars = document.getElementById('game-reviews-avg-stars');
var rCount = document.getElementById('game-reviews-count');
var rToolbar = document.getElementById('game-reviews-toolbar');
var rMoreBtn = document.getElementById('game-reviews-more');
var rMoreWrap = rMoreBtn ? rMoreBtn.parentElement : null;
var reviewsUrl = ctx + '/game/' + encodeURIComponent(gid) + '/reviews';
var editingReviewId = null; // null = 작성 모드, 값 = 수정 모드
var currentReviewPage = 0;
var currentReviewSort = 'newest';
var REVIEW_MAX = 1000;
var REVIEW_MIN = 10;
// overall(종합) 별점 위젯 — 선택사항(빈값 허용)
var overallWidget = null;
// 6축 위젯 핸들 (axisKey -> widget)
var axisWidgets = {};
function buildAxisWidgets() {
if (!rAxesEl) return;
rAxesEl.innerHTML = '';
axisWidgets = {};
AXIS_KEYS.forEach(function (k) {
var row = document.createElement('div');
row.className = 'game-reviews__axis';
var label = document.createElement('span');
label.className = 'game-reviews__axis-label';
label.textContent = AXIS_LABELS_KO[k];
var w = buildStarRadioGroup(k, AXIS_LABELS_KO[k]);
label.id = w.labelId;
row.appendChild(label);
row.appendChild(w.group);
rAxesEl.appendChild(row);
axisWidgets[k] = w;
});
}
function buildOverallWidget() {
if (!rStarsEl) return;
rStarsEl.innerHTML = '';
// overall 은 axisScores['overall'] 키 사용. 빈값 허용(미선택 시 rating 미전송)
overallWidget = buildStarRadioGroup('overall', '종합 별점');
rStarsEl.setAttribute('aria-labelledby', rStarsLabel ? rStarsLabel.id : '');
// group wrapper 대신 rStarsEl 자체(마크업의 radiogroup)에 radio 들을 이식
while (overallWidget.group.firstChild) {
rStarsEl.appendChild(overallWidget.group.firstChild);
}
}
function fillStars(container, rating) {
container.innerHTML = '';
for (var i = 1; i <= 5; i++) {
var s = document.createElement('span');
s.className = 'game-star' + (i <= rating ? ' is-on' : '');
s.textContent = '★';
container.appendChild(s);
}
}
function buildStarDisplay(rating) {
var wrap = document.createElement('div');
wrap.className = 'game-stars game-stars--display';
wrap.setAttribute('aria-hidden', 'true');
fillStars(wrap, rating);
return wrap;
}
function resetAxisInputs() {
AXIS_KEYS.forEach(function (k) { axisScores[k] = 0; if (axisWidgets[k]) axisWidgets[k].paint(); });
axisScores.overall = 0;
repaintOverall();
}
function repaintOverall() {
if (!rStarsEl) return;
var sel = axisScores.overall || 0;
var btns = rStarsEl.querySelectorAll('.game-stars__btn');
Array.prototype.forEach.call(btns, function (b, idx) {
var v = idx + 1;
b.classList.toggle('is-on', v <= sel);
b.setAttribute('aria-checked', v === sel ? 'true' : 'false');
b.tabIndex = (v === sel) ? 0 : -1;
});
if (!sel && btns[0]) btns[0].tabIndex = 0;
}
function updateReviewCounter() {
var len = ((rInput && rInput.value) || '').length;
if (rCounter) rCounter.textContent = len + '/' + REVIEW_MAX;
if (rHint) {
var tooShort = len > 0 && len < REVIEW_MIN;
rHint.classList.toggle('is-warn', tooShort);
}
}
function startReviewEdit(r) {
editingReviewId = r.reviewId;
// 6축 prefill
var ax = r.axes || {};
AXIS_KEYS.forEach(function (k) {
var v = Number(ax[k]) || 0;
axisScores[k] = v;
if (axisWidgets[k]) axisWidgets[k].paint();
});
// ratingManual=true 면 overall prefill, 아니면 미선택
axisScores.overall = (r.ratingManual && Number(r.rating)) ? Number(r.rating) : 0;
repaintOverall();
if (rInput) rInput.value = r.body || '';
updateReviewCounter();
if (rSubmit) rSubmit.textContent = '리뷰 수정';
if (rCancel) rCancel.hidden = false;
if (rComposer) {
rComposer.hidden = false;
if (rComposer.scrollIntoView) rComposer.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
if (rInput) rInput.focus();
}
function showComposerWrite() {
editingReviewId = null;
resetAxisInputs();
if (rInput) rInput.value = '';
updateReviewCounter();
if (rSubmit) rSubmit.textContent = '리뷰 등록';
if (rCancel) rCancel.hidden = true;
if (rComposer) rComposer.hidden = false;
}
// W4 배지 키→표시명. 서버 카탈로그(display_name)와 동일 한글명.
function badgeLabel(key) {
switch (String(key)) {
case 'REVIEWER': return '리뷰어';
case 'TECHNICIAN': return '테크니션';
default: return String(key);
}
}
function buildReviewItem(r) {
var mine = viewerId != null && r.userId != null && Number(r.userId) === Number(viewerId);
var li = document.createElement('li');
li.className = 'game-reviews__item' + (mine ? ' is-mine' : '');
var head = document.createElement('div');
head.className = 'game-reviews__item-head';
head.appendChild(buildStarDisplay(Number(r.rating) || 0));
var nick = (r.authorName && String(r.authorName).trim()) ? String(r.authorName).trim() : '익명';
var nickEl = document.createElement('span');
nickEl.className = 'game-reviews__nick';
nickEl.textContent = nick;
head.appendChild(nickEl);
// W4 유저 배지 표시: 작성자 활성 배지(REVIEWER/TECHNICIAN) 칩. textContent 로 XSS 안전.
if (Array.isArray(r.authorBadges) && r.authorBadges.length) {
var badgeWrap = document.createElement('span');
badgeWrap.className = 'game-reviews__badges';
r.authorBadges.forEach(function (key) {
var chip = document.createElement('span');
chip.className = 'game-reviews__badge game-reviews__badge--' + String(key).toLowerCase();
chip.textContent = badgeLabel(key);
badgeWrap.appendChild(chip);
});
head.appendChild(badgeWrap);
}
if (mine) {
var mb = document.createElement('span');
mb.className = 'game-reviews__mine-badge';
mb.textContent = '내 리뷰';
head.appendChild(mb);
}
// 6축 미니 미터바 — 제목행 노출(§6.6: 요약 그래프와 별개로 카드 단위 시각화)
var titleMeter = buildTitleMeter(r.axes);
if (titleMeter) head.appendChild(titleMeter);
var t = document.createElement('time');
t.className = 'game-reviews__time';
t.dateTime = (r.updatedAt || r.createdAt) || '';
t.title = fmtAbsolute(r.updatedAt || r.createdAt);
t.textContent = fmtRelative(r.updatedAt || r.createdAt);
head.appendChild(t);
if (r.edited) {
var ed = document.createElement('span');
ed.className = 'game-reviews__edited';
ed.textContent = '(수정됨)';
head.appendChild(ed);
}
li.appendChild(head);
if (r.body && String(r.body).trim()) {
var p = document.createElement('p');
p.className = 'game-reviews__body';
p.textContent = r.body;
li.appendChild(p);
}
if (canActOn(r.userId)) {
var foot = document.createElement('div');
foot.className = 'game-reviews__footer';
var editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'game-reviews__action game-reviews__action--edit';
editBtn.textContent = '수정';
editBtn.addEventListener('click', function () { startReviewEdit(r); });
var del = document.createElement('button');
del.type = 'button';
del.className = 'game-reviews__action game-reviews__action--delete';
del.textContent = '삭제';
del.addEventListener('click', function () {
confirmAction('리뷰 삭제', '이 리뷰를 삭제할까요?', '삭제', function () {
api(reviewsUrl + '/' + encodeURIComponent(r.reviewId), { method: 'DELETE', headers: baseHeaders() })
.then(function () { loadReviews(0); }) // C5: page0 재조회로 완전 제거 + summary 갱신
.catch(function (err) { notifyError('삭제 실패', err.message); });
});
});
foot.appendChild(editBtn);
foot.appendChild(del);
li.appendChild(foot);
}
return li;
}
// C3: 서버 summary 로 표시 (클라 평균계산 폐기)
function renderSummary(summary) {
if (!rSummary) return;
if (!summary || !summary.reviewCount) {
rSummary.hidden = true;
if (rSummaryPanel) rSummaryPanel.hidden = true;
return;
}
rSummary.hidden = false;
var avg = Number(summary.avgRating) || 0;
if (rAvg) rAvg.textContent = avg.toFixed(1);
if (rCount) rCount.textContent = '(' + summary.reviewCount + ')';
if (rAvgStars) fillStars(rAvgStars, Math.round(avg));
// 요약 육각형 (200 viewBox). axes 값 null 이면 데이터 없음
if (rSummaryPanel && rSummaryRadar) {
rSummaryRadar.innerHTML = '';
var scores = AXIS_KEYS.map(function (k) { return summary.axes ? Number(summary.axes[k]) || 0 : 0; });
var radar = buildHexRadar(scores, 100, 100, 80, true);
rSummaryPanel.hidden = false;
if (radar.hasData) {
rSummaryRadar.appendChild(radar.svg);
} else {
var none = document.createElement('span');
none.className = 'game-reviews__radar-empty';
none.textContent = '6축 평가 데이터 없음';
rSummaryRadar.appendChild(none);
}
}
}
function applyReviewGate(mineExists) {
if (viewerId == null) {
if (rComposer) rComposer.hidden = true;
if (rLoginGate) rLoginGate.hidden = false;
return;
}
if (rLoginGate) rLoginGate.hidden = true;
if (editingReviewId != null) return; // 수정 진행 중이면 폼 유지
if (mineExists) {
if (rComposer) rComposer.hidden = true; // 이미 작성 — 수정은 본인 카드의 '수정' 버튼으로
} else {
showComposerWrite(); // 미작성 — 작성 폼 노출
}
}
var reviewMineSeen = false;
function appendReviews(list) {
list.forEach(function (r) {
rListEl.appendChild(buildReviewItem(r));
if (r.userId != null && viewerId != null && Number(r.userId) === Number(viewerId)) reviewMineSeen = true;
});
rEmptyEl.hidden = rListEl.children.length > 0;
}
function setReviewMore(hasMore) {
if (rMoreWrap) rMoreWrap.hidden = !hasMore;
}
function loadReviews(page) {
var p = page || 0;
if (p === 0) { rListEl.innerHTML = ''; reviewMineSeen = false; editingReviewId = null; }
var url = reviewsUrl + '?page=' + p + '&sort=' + encodeURIComponent(currentReviewSort);
return api(url, { headers: baseHeaders() })
.then(function (data) {
currentReviewPage = p;
appendReviews(Array.isArray(data.reviews) ? data.reviews : []);
setReviewMore(!!data.hasMore);
if (rToolbar) rToolbar.hidden = false;
if (p === 0) renderSummary(data.summary);
if (!data.hasMore) applyReviewGate(reviewMineSeen); // 전체 로드 후 작성 폼 노출 판단
})
.catch(function () { if (p === 0) rEmptyEl.hidden = false; });
}
if (rMoreBtn) {
rMoreBtn.addEventListener('click', function () { loadReviews(currentReviewPage + 1); });
}
if (rToolbar) {
Array.prototype.forEach.call(rToolbar.querySelectorAll('.game-comments__sort-btn'), function (btn) {
btn.addEventListener('click', function () {
var sort = btn.getAttribute('data-sort');
if (sort === currentReviewSort) return;
currentReviewSort = sort;
Array.prototype.forEach.call(rToolbar.querySelectorAll('.game-comments__sort-btn'), function (b) {
var on = b === btn;
b.classList.toggle('is-active', on);
b.setAttribute('aria-pressed', on ? 'true' : 'false');
});
loadReviews(0);
});
});
}
// 위젯 초기화 (6축 + overall)
buildAxisWidgets();
buildOverallWidget();
resetAxisInputs();
if (rInput) {
rInput.addEventListener('input', updateReviewCounter);
updateReviewCounter();
}
if (rCancel) {
rCancel.addEventListener('click', function () { loadReviews(0); });
}
if (rForm) {
rForm.addEventListener('submit', function (ev) {
ev.preventDefault();
if (viewerId == null) { notifyError('로그인 필요', '리뷰를 남기려면 로그인이 필요합니다.'); return; }
// 6축 전부 필수 (UX 선검증, 서버도 400 대칭)
var missing = AXIS_KEYS.filter(function (k) { return !(axisScores[k] >= 1 && axisScores[k] <= 5); });
if (missing.length) {
notifyError('평가 필요', '6개 항목(' + missing.map(function (k) { return AXIS_LABELS_KO[k]; }).join(', ') + ')을 모두 선택해 주세요.');
return;
}
var bodyText = ((rInput && rInput.value) || '').trim();
if (bodyText.length < REVIEW_MIN) { notifyError('입력 필요', '평가는 최소 ' + REVIEW_MIN + '자 이상 입력해 주세요.'); return; }
if (bodyText.length > REVIEW_MAX) { notifyError('입력 초과', '평가는 1,000자 이내로 입력해 주세요.'); return; }
var payload = { body: bodyText };
AXIS_KEYS.forEach(function (k) { payload[k] = axisScores[k]; });
// overall 선택됐을 때만 rating 전송 (미선택이면 서버 6축 자동평균)
if (axisScores.overall >= 1 && axisScores.overall <= 5) { payload.rating = axisScores.overall; }
var isEdit = editingReviewId != null;
var url = isEdit ? (reviewsUrl + '/' + encodeURIComponent(editingReviewId)) : reviewsUrl;
var unlock = lockButton(rSubmit);
api(url, { method: isEdit ? 'PUT' : 'POST', headers: formHeaders(), body: encodeForm(payload) })
.then(function () { unlock(); loadReviews(0); })
.catch(function (err) { unlock(); notifyError(isEdit ? '수정 실패' : '등록 실패', err.message); });
});
}
loadReviews(0);
})();
</script>
</body>
</html>
operator: 'match'
stack: |-
TestContext.<anonymous> (/private/var/folders/g5/hclggccx15qdjf89b6vn1fw80000gn/T/bibimbap-prechange-oo0st4cd/src/test/js/webgl-player.test.cjs:32:10)
Test.runInAsyncScope (node:async_hooks:214:14)
Test.run (node:internal/test_runner/test:1047:25)
Test.start (node:internal/test_runner/test:944:17)
startSubtestAfterBootstrap (node:internal/test_runner/harness:296:17)
...
1..1
# tests 1
# suites 0
# pass 0
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 46.247958