bibimbap/src/main/webapp/WEB-INF/views/game-register.jsp

715 lines
26 KiB
Plaintext

<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
<%@ page import="org.springframework.web.util.HtmlUtils" %>
<!DOCTYPE html>
<%
String ctx = request.getContextPath();
String rawDisplayName = session.getAttribute("displayName") == null ? "" : String.valueOf(session.getAttribute("displayName"));
String creatorValue = HtmlUtils.htmlEscape(rawDisplayName);
boolean editMode = Boolean.TRUE.equals(request.getAttribute("editMode"));
String editGameId = request.getAttribute("editGameId") == null ? "" : String.valueOf(request.getAttribute("editGameId"));
String formAction = ctx + (editMode ? "/game/" + editGameId + "/edit" : "/game/new");
String pageTitle = editMode ? "게임 수정" : "신규 게임 게시";
String submitText = editMode ? "수정" : "등록";
String editGameName = HtmlUtils.htmlEscape(request.getAttribute("editGameName") == null ? "" : String.valueOf(request.getAttribute("editGameName")));
String editCreatorNote = HtmlUtils.htmlEscape(request.getAttribute("editCreatorNote") == null ? "" : String.valueOf(request.getAttribute("editCreatorNote")));
String editGitUrl = HtmlUtils.htmlEscape(request.getAttribute("editGitUrl") == null ? "" : String.valueOf(request.getAttribute("editGitUrl")));
String editWebglPath = HtmlUtils.htmlEscape(request.getAttribute("editWebglPath") == null ? "" : String.valueOf(request.getAttribute("editWebglPath")));
String editThumbnailUrl = HtmlUtils.htmlEscape(request.getAttribute("editThumbnailUrl") == null ? "" : String.valueOf(request.getAttribute("editThumbnailUrl")));
String editGameUuid = HtmlUtils.htmlEscape(request.getAttribute("editGameUuid") == null ? "" : String.valueOf(request.getAttribute("editGameUuid")));
boolean editVisible = !editMode || Boolean.TRUE.equals(request.getAttribute("editVisible"));
%>
<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><%= pageTitle %> | bibimbap</title>
<style>
html {
color-scheme: light;
--surface: #faf8f5;
--card-bg: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #e8a54b;
--border: rgba(0, 0, 0, 0.08);
--card-media-1: #f0ebe3;
--card-media-2: #e5ddd2;
--card-media-3: #dccfb8;
--card-shadow: rgba(0, 0, 0, 0.06);
--button-text: #1a1a1a;
--field-bg: #fff;
}
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212;
--card-bg: #1e1e1e;
--text: #ece8e1;
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--card-media-1: #2a2620;
--card-media-2: #1f1c18;
--card-media-3: #3d3528;
--card-shadow: rgba(0, 0, 0, 0.35);
--button-text: #1a1a1a;
--field-bg: #181818;
}
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);
display: flex;
flex-direction: column;
}
.game-register-main {
width: 100%;
max-width: 72rem;
box-sizing: border-box;
margin: 0 auto;
padding: 2rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
flex: 1;
}
.game-register-heading {
width: min(100%, 56rem);
margin: 0 auto 1rem;
}
.game-register-heading__eyebrow {
margin: 0 0 0.35rem;
font-size: 0.75rem;
font-weight: 800;
color: var(--accent);
letter-spacing: 0.02em;
}
.game-register-heading__title {
margin: 0;
font-size: 1.75rem;
line-height: 1.2;
letter-spacing: 0;
}
.game-register-layout {
width: min(100%, 56rem);
margin: 0 auto;
display: grid;
grid-template-columns: minmax(0, 1fr) 18rem;
gap: 1rem;
align-items: start;
}
.game-register-panel,
.game-preview {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card-bg);
box-shadow: 0 2px 8px var(--card-shadow);
}
.game-register-panel {
padding: 1.5rem;
}
.game-form {
display: grid;
gap: 1rem;
}
.game-form__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.875rem;
}
.game-field {
display: grid;
gap: 0.375rem;
}
.game-field--full {
grid-column: 1 / -1;
}
.game-field__label {
font-size: 0.8125rem;
font-weight: 700;
color: var(--text);
}
.game-field__input,
.game-field__textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--field-bg);
color: var(--text);
font-size: 1rem;
}
.game-field__input {
height: 3rem;
padding: 0 0.875rem;
}
.game-file {
min-height: 3rem;
padding: 0.625rem 0.75rem;
border: 1px solid var(--border);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
background: var(--field-bg);
color: var(--text);
cursor: pointer;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
.game-file:hover {
border-color: rgba(232, 165, 75, 0.45);
}
.game-file__action {
flex-shrink: 0;
min-height: 2rem;
border-radius: 8px;
padding: 0 0.625rem;
display: inline-flex;
align-items: center;
background: rgba(232, 165, 75, 0.18);
color: var(--text);
font-size: 0.8125rem;
font-weight: 800;
}
.game-file__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-muted);
font-size: 0.875rem;
}
.game-file__input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.game-field__textarea {
min-height: 8rem;
resize: vertical;
padding: 0.875rem;
line-height: 1.55;
}
.game-field__input::placeholder,
.game-field__textarea::placeholder {
color: var(--text-muted);
}
.game-field__input:focus,
.game-field__textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.25);
}
.game-req {
color: var(--accent);
font-weight: 900;
margin-left: 0.15em;
}
.game-preview__label {
margin: 0;
padding: 0.875rem 1rem 0;
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 900;
}
.game-check {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-muted);
}
.game-check input {
width: 1rem;
height: 1rem;
accent-color: var(--accent);
}
.game-form__actions {
display: flex;
justify-content: flex-end;
gap: 0.625rem;
}
.game-button {
min-height: 2.875rem;
border: 1px solid var(--border);
border-radius: 10px;
padding: 0 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: var(--card-bg);
color: var(--text);
font-size: 0.9375rem;
font-weight: 800;
text-decoration: none;
cursor: pointer;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
.game-button:hover {
border-color: rgba(232, 165, 75, 0.45);
box-shadow: 0 4px 12px var(--card-shadow);
}
.game-button:active {
transform: scale(0.98);
}
.game-button--primary {
border-color: transparent;
background: var(--accent);
color: var(--button-text);
}
.game-button:disabled {
cursor: not-allowed;
opacity: 0.58;
box-shadow: none;
}
.game-button svg {
width: 1.125rem;
height: 1.125rem;
}
.game-preview {
overflow: hidden;
position: sticky;
top: 5rem;
}
.game-preview__media {
position: relative;
width: 100%;
aspect-ratio: 4 / 5;
background: linear-gradient(160deg, var(--card-media-1) 0%, var(--card-media-2) 45%, var(--card-media-3) 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 14%;
box-sizing: border-box;
}
.game-preview__media img {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
inset: 0;
}
.game-preview__media img[hidden] {
display: none;
}
.game-preview__fallback {
width: min(52%, 7.5rem);
height: auto;
opacity: 0.88;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.12));
}
html[data-theme="dark"] .game-preview__fallback {
opacity: 0.92;
filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.45));
}
.game-preview__body {
padding: 0.875rem 1rem 1rem;
}
.game-preview__name {
margin: 0;
font-size: 1rem;
line-height: 1.35;
letter-spacing: 0;
word-break: break-word;
}
.game-preview__creator,
.game-preview__note {
margin: 0.35rem 0 0;
color: var(--text-muted);
font-size: 0.8125rem;
line-height: 1.45;
word-break: break-word;
}
.game-preview__note {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (max-width: 900px) {
.game-register-layout {
grid-template-columns: 1fr;
}
.game-preview {
position: static;
width: min(100%, 24rem);
}
}
@media (max-width: 560px) {
.game-register-main {
padding-top: 1.5rem;
}
.game-register-panel {
padding: 1.25rem;
}
.game-form__grid {
grid-template-columns: 1fr;
}
.game-form__actions {
flex-direction: column-reverse;
}
.game-button {
width: 100%;
}
}
</style>
</head>
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="game-register-main">
<section class="game-register-heading" aria-labelledby="game-register-title">
<p class="game-register-heading__eyebrow">BIBIMBAP GAME</p>
<h1 class="game-register-heading__title" id="game-register-title"><%= pageTitle %></h1>
</section>
<div class="game-register-layout">
<section class="game-register-panel" aria-label="게임 등록 양식">
<form class="game-form" action="<%= formAction %>" method="post" id="game-register-form" data-edit-mode="<%= editMode %>">
<div class="game-form__grid">
<div class="game-field game-field--full">
<label class="game-field__label" for="game-name">게임 이름 <span class="game-req">*</span></label>
<input class="game-field__input" type="text" id="game-name" name="name" maxlength="80" autocomplete="off" value="<%= editGameName %>" required />
</div>
<div class="game-field">
<label class="game-field__label" for="game-git-url">소스 코드 URL</label>
<input class="game-field__input" type="url" id="game-git-url" name="gitUrl" placeholder="https://example.com/repository" autocomplete="url" value="<%= editGitUrl %>" />
</div>
<div class="game-field">
<label class="game-field__label" for="game-webgl-zip">WebGL zip <% if (!editMode) { %><span class="game-req">*</span><% } %></label>
<label class="game-file" for="game-webgl-zip">
<span class="game-file__action">zip 선택</span>
<span class="game-file__name" id="game-webgl-file-name"><%= editMode && !editWebglPath.isBlank() ? "기존 WebGL 유지" : "선택된 파일 없음" %></span>
</label>
<input class="game-file__input" type="file" id="game-webgl-zip" name="webglZip" accept=".zip,application/zip,application/x-zip-compressed" <%= editMode ? "" : "required" %> />
</div>
<div class="game-field">
<label class="game-field__label" for="game-thumbnail-image">썸네일 이미지</label>
<label class="game-file" for="game-thumbnail-image">
<span class="game-file__action">이미지 선택</span>
<span class="game-file__name" id="game-thumbnail-file-name"><%= editMode && !editThumbnailUrl.isBlank() ? "기존 썸네일 유지" : "선택된 파일 없음" %></span>
</label>
<input class="game-file__input" type="file" id="game-thumbnail-image" name="thumbnailImage" accept="image/png,image/jpeg,image/webp,image/gif" />
</div>
<div class="game-field game-field--full">
<label class="game-field__label" for="game-creator-note">소개</label>
<textarea class="game-field__textarea" id="game-creator-note" name="creatorNote" maxlength="600"><%= editCreatorNote %></textarea>
</div>
</div>
<input type="hidden" id="game-uploaded-game-uuid" name="gameUuid" value="<%= editGameUuid %>" />
<input type="hidden" id="game-uploaded-webgl-path" name="webglPath" value="<%= editWebglPath %>" />
<input type="hidden" id="game-uploaded-thumbnail-url" name="thumbnailUrl" value="<%= editThumbnailUrl %>" />
<label class="game-check">
<input type="checkbox" name="visible" value="true" <%= editVisible ? "checked" : "" %> />
<span>목록에 공개</span>
</label>
<div class="game-form__actions">
<a class="game-button" href="<%= editMode ? ctx + "/game/" + editGameId : ctx + "/" %>">취소</a>
<button class="game-button game-button--primary" type="submit" id="game-register-submit">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 5v14"/>
<path d="M5 12h14"/>
</svg>
<span id="game-register-submit-label"><%= submitText %></span>
</button>
</div>
</form>
</section>
<aside class="game-preview" aria-label="미리보기">
<p class="game-preview__label">미리보기</p>
<div class="game-preview__media">
<% if (editThumbnailUrl.isBlank()) { %>
<img id="preview-thumb" alt="" hidden />
<% } else { %>
<img id="preview-thumb" src="<%= ctx + editThumbnailUrl %>" alt="" />
<% } %>
<img class="game-preview__fallback" id="preview-fallback" src="<%= ctx %>/images/logo.png" alt="" width="120" height="120" <%= editThumbnailUrl.isBlank() ? "" : "hidden" %> />
</div>
<div class="game-preview__body">
<h2 class="game-preview__name" id="preview-name"><%= editGameName.isBlank() ? "게임 이름" : editGameName %></h2>
<p class="game-preview__creator" id="preview-creator"><%= creatorValue.isBlank() ? "제작자" : creatorValue %></p>
<p class="game-preview__note" id="preview-note"><%= editCreatorNote.isBlank() ? "소개" : editCreatorNote %></p>
</div>
</aside>
</div>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
<script>
(function () {
var ctx = '<%= ctx %>';
var form = document.getElementById('game-register-form');
var editMode = form && form.getAttribute('data-edit-mode') === 'true';
var nameInput = document.getElementById('game-name');
var gitUrlInput = document.getElementById('game-git-url');
var noteInput = document.getElementById('game-creator-note');
var webglInput = document.getElementById('game-webgl-zip');
var thumbnailInput = document.getElementById('game-thumbnail-image');
var webglFileName = document.getElementById('game-webgl-file-name');
var thumbnailFileName = document.getElementById('game-thumbnail-file-name');
var uploadedGameUuid = document.getElementById('game-uploaded-game-uuid');
var uploadedWebglPath = document.getElementById('game-uploaded-webgl-path');
var uploadedThumbnailUrl = document.getElementById('game-uploaded-thumbnail-url');
var visibleInput = form ? form.querySelector('input[name="visible"]') : null;
var submitBtn = document.getElementById('game-register-submit');
var submitLabel = document.getElementById('game-register-submit-label');
var previewName = document.getElementById('preview-name');
var previewNote = document.getElementById('preview-note');
var previewThumb = document.getElementById('preview-thumb');
var previewFallback = document.getElementById('preview-fallback');
var thumbnailPreviewUrl = null;
var submitText = submitLabel ? submitLabel.textContent : '';
function valueOr(input, fallback) {
var value = input && input.value ? input.value.trim() : '';
return value || fallback;
}
function updatePreview() {
previewName.textContent = valueOr(nameInput, '게임 이름');
previewNote.textContent = valueOr(noteInput, '소개');
}
function setThumbnailPreview(file) {
if (thumbnailPreviewUrl) {
URL.revokeObjectURL(thumbnailPreviewUrl);
thumbnailPreviewUrl = null;
}
if (file) {
thumbnailPreviewUrl = URL.createObjectURL(file);
previewThumb.src = thumbnailPreviewUrl;
previewThumb.hidden = false;
if (previewFallback) {
previewFallback.hidden = true;
}
} else {
previewThumb.removeAttribute('src');
previewThumb.hidden = true;
if (previewFallback) {
previewFallback.hidden = false;
}
}
}
function openModal(title, message, confirmText, onConfirm) {
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({
title: title,
message: message,
confirmText: confirmText || '확인',
onConfirm: onConfirm
});
return;
}
alert(message);
if (typeof onConfirm === 'function') {
onConfirm();
}
}
function setSubmitting(submitting) {
if (!submitBtn) return;
submitBtn.disabled = submitting;
if (submitLabel) {
submitLabel.textContent = submitting ? (editMode ? '수정 중...' : '등록 중...') : submitText;
}
}
function uploadFile(url, file, fields) {
var body = new FormData();
body.append('file', file);
if (fields) {
Object.keys(fields).forEach(function (key) {
body.append(key, fields[key]);
});
}
return fetch(url, {
method: 'POST',
headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}) : {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: body
}).catch(function () {
throw new Error('Upload request failed before receiving a server response. Please check the server console and browser Network tab.');
}).then(function (res) {
return res.json().catch(function () {
return { message: '파일을 업로드하지 못했습니다.' };
}).then(function (data) {
if (!res.ok) {
var error = new Error(data && data.message ? data.message : '파일을 업로드하지 못했습니다.');
error.status = res.status;
throw error;
}
return data;
});
});
}
function saveGame() {
var body = new URLSearchParams();
body.set('name', valueOr(nameInput, ''));
body.set('gitUrl', valueOr(gitUrlInput, ''));
body.set('creatorNote', valueOr(noteInput, ''));
body.set('gameUuid', valueOr(uploadedGameUuid, ''));
body.set('webglPath', valueOr(uploadedWebglPath, ''));
body.set('thumbnailUrl', valueOr(uploadedThumbnailUrl, ''));
if (visibleInput && visibleInput.checked) {
body.set('visible', visibleInput.value || 'true');
}
return fetch(form.action, {
method: 'POST',
headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}) : {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: body
}).then(function (res) {
return res.json().catch(function () {
return { message: '게임을 등록하지 못했습니다.' };
}).then(function (data) {
if (!res.ok) {
var error = new Error(data && data.message ? data.message : '게임을 등록하지 못했습니다.');
error.status = res.status;
throw error;
}
return data;
});
});
}
if (nameInput) nameInput.addEventListener('input', updatePreview);
if (noteInput) noteInput.addEventListener('input', updatePreview);
if (webglInput) {
webglInput.addEventListener('change', function () {
var file = webglInput.files && webglInput.files[0];
webglFileName.textContent = file ? file.name : '선택된 파일 없음';
if (uploadedWebglPath) {
uploadedWebglPath.value = '';
}
if (uploadedGameUuid) {
uploadedGameUuid.value = '';
}
});
}
if (thumbnailInput) {
thumbnailInput.addEventListener('change', function () {
var file = thumbnailInput.files && thumbnailInput.files[0];
thumbnailFileName.textContent = file ? file.name : '선택된 파일 없음';
if (uploadedThumbnailUrl) {
uploadedThumbnailUrl.value = '';
}
if (file && file.type && file.type.indexOf('image/') === 0) {
setThumbnailPreview(file);
} else {
setThumbnailPreview(null);
if (file) {
thumbnailInput.value = '';
thumbnailFileName.textContent = '선택된 파일 없음';
openModal('썸네일 선택 실패', '이미지 파일만 선택해 주세요.');
}
}
});
}
if (previewThumb) {
previewThumb.addEventListener('error', function () {
previewThumb.removeAttribute('src');
previewThumb.hidden = true;
if (previewFallback) {
previewFallback.hidden = false;
}
});
}
if (form) {
form.addEventListener('submit', function (ev) {
ev.preventDefault();
if (!form.checkValidity()) {
form.reportValidity();
return;
}
var webglFile = webglInput && webglInput.files ? webglInput.files[0] : null;
var thumbnailFile = thumbnailInput && thumbnailInput.files ? thumbnailInput.files[0] : null;
if (!editMode && !webglFile) {
openModal('업로드 실패', 'WebGL zip 파일을 선택해 주세요.');
return;
}
setSubmitting(true);
(webglFile ? uploadFile(ctx + '/api/game-files/webgl-zip', webglFile) : Promise.resolve({
gameUuid: uploadedGameUuid ? uploadedGameUuid.value : '',
webglPath: uploadedWebglPath ? uploadedWebglPath.value : ''
})).then(function (webglResult) {
if (uploadedGameUuid && webglResult && webglResult.gameUuid) {
uploadedGameUuid.value = webglResult.gameUuid;
}
if (uploadedWebglPath && webglResult && webglResult.webglPath) {
uploadedWebglPath.value = webglResult.webglPath;
}
if (!thumbnailFile) {
return {
webglResult: webglResult,
thumbnailResult: null
};
}
return uploadFile(ctx + '/api/game-files/thumbnail', thumbnailFile, {
gameUuid: webglResult.gameUuid
}).then(function (thumbnailResult) {
return {
webglResult: webglResult,
thumbnailResult: thumbnailResult
};
});
}).then(function (results) {
var thumbnailResult = results.thumbnailResult;
if (uploadedThumbnailUrl && thumbnailResult && thumbnailResult.thumbnailUrl) {
uploadedThumbnailUrl.value = thumbnailResult.thumbnailUrl;
}
return saveGame();
}).then(function (registerResult) {
openModal(editMode ? '게임 수정 완료' : '게임 등록 완료', editMode ? '게임이 수정되었습니다.' : '게임 등록이 완료되었습니다.', '확인', function () {
if (registerResult && registerResult.gameId) {
window.location.href = ctx + '/game/' + registerResult.gameId;
} else if (registerResult && registerResult.location) {
window.location.href = ctx + registerResult.location;
} else {
window.location.href = ctx + '/';
}
});
}).catch(function (err) {
openModal('등록 실패', err.message || '게임을 등록하지 못했습니다.', '확인', function () {
if (err.status === 401) {
window.location.href = ctx + '/login';
}
});
}).finally(function () {
setSubmitting(false);
});
});
}
updatePreview();
})();
</script>
</body>
</html>