Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
view/hinana/plaza.ejs
공개 가능
1
<!DOCTYPE html>
2
<html lang="ko" xmlns="http://www.w3.org/1999/xhtml">
3
<head>
4
<meta charset="utf-8" />
5
<meta name="color-scheme" content="light dark">
6
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1.0">
7
<meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#0f141e' : '#f8f7f5' %>">
8
<meta name="apple-mobile-web-app-title" content="비나래 라운지">
9
<meta property="og:image" content="/image/train_hinana.png" />
10
<meta property="og:description" content="비나래 라운지 | 광장"/>
11
<meta property="og:url" content="hinana.moe/hinana/lounge/plaza"/>
12
<meta property="og:title" content="비나래 라운지"/>
13
<title>비나래 라운지 | 광장</title>
14
15
<link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
16
<script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
17
<link href="https://fonts.googleapis.com/css?family=Montserrat:400,700" rel="stylesheet" type="text/css">
18
<link href="https://fonts.googleapis.com/css?family=Lato:300,400,700" rel="stylesheet" type="text/css">
19
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
20
<script src="/js/popup.js"></script>
21
22
<style>
23
:root {
24
--font-family: 'Noto Sans KR', sans-serif;
25
--bg-main: #f8f7f5; --bg-secondary: #ffffff; --bg-tertiary: #1a2238;
26
--text-primary: #1a2238; --text-secondary: #5e6676;
27
--accent-color: #c5a059; --border-color: #e5e1da;
28
--shadow-md: 0 10px 40px -10px rgba(26, 34, 56, 0.12);
29
--shadow-sm: 0 2px 8px rgba(0,0,0,0.05);
30
}
31
32
body.dark-mode {
33
--bg-main: #0f141e; --bg-secondary: #1a2238; --bg-tertiary: #0a0e17;
34
--text-primary: #e7e5e4; --text-secondary: #a8a29e;
35
--accent-color: #d4b47a; --border-color: #2e3a59;
36
}
37
38
/* 기본 설정 */
39
html, body {
40
height: auto !important; min-height: 100%; margin: 0; padding: 0;
41
font-family: var(--font-family); background-color: var(--bg-main); color: var(--text-primary);
42
overflow-x: hidden; overflow-y: auto; width: 100%;
43
}
44
a { text-decoration: none; color: inherit; }
45
* { box-sizing: border-box; } /* [중요] 패딩이 너비에 포함되도록 강제 */
46
47
/* [Header] */
48
.global-header {
49
height: 60px; background-color: var(--bg-tertiary); border-bottom: 1px solid var(--border-color);
50
display: flex; align-items: center; justify-content: space-between; padding: 0 40px;
51
position: sticky; top: 0; z-index: 1000; color: white; flex-wrap: wrap;
52
}
53
.header-logo { height: 32px; filter: none !important; -webkit-filter: none !important; mix-blend-mode: normal !important; }
54
.header-brand { display: flex; align-items: center; flex: 0 0 auto; }
55
.header-nav { display: flex; gap: 20px; align-items: center; transition: all 0.3s ease; }
56
57
/* [Layout] */
58
.layout-container {
59
display: flex;
60
min-height: calc(100vh - 60px);
61
background-color: var(--bg-main);
62
width: 100%; max-width: 100vw; overflow-x: hidden;
63
}
64
65
/* [Left Content Column] */
66
.content-column {
67
flex: 1; padding: 60px 40px;
68
display: flex; flex-direction: column; align-items: center;
69
width: 100%;
70
/* [중요] 화면이 좁아질 때 컨텐츠 영역이 줄어들 수 있게 허용 */
71
min-width: 0;
72
}
73
74
.plaza-hero { text-align: center; margin-bottom: 40px; }
75
.info-title { font-size: 2.5rem; font-weight: 700; color: var(--text-primary); margin: 10px 0; letter-spacing: -1px; }
76
77
.input-card {
78
width: 100%; max-width: 800px; background-color: var(--bg-secondary);
79
padding: 25px; border-radius: 12px; border: 1px solid var(--border-color);
80
border-top: 4px solid var(--accent-color) !important; box-shadow: var(--shadow-md);
81
margin-bottom: 50px;
82
}
83
.plaza-textarea {
84
width: 100%; height: 100px; border: 1px solid var(--border-color);
85
background: var(--bg-main); color: var(--text-primary);
86
padding: 15px; border-radius: 8px; resize: none; outline: none; transition: border-color 0.2s;
87
}
88
.plaza-textarea:focus { border-color: var(--accent-color); }
89
90
.plaza-grid {
91
width: 100%; max-width: 1000px;
92
display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
93
gap: 20px;
94
}
95
.message-card {
96
background: var(--bg-secondary); border: 1px solid var(--border-color);
97
padding: 20px; border-radius: 12px; position: relative;
98
transition: transform 0.2s, box-shadow 0.2s; box-shadow: var(--shadow-sm);
99
}
100
.message-card:hover { transform: translateY(-5px); border-color: var(--accent-color); box-shadow: var(--shadow-md); }
101
.msg-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
102
.msg-user { font-weight: 700; color: var(--accent-color); font-size: 0.95rem; }
103
.msg-timer { font-size: 0.65rem; color: #dc3545; font-weight: bold; border: 1px solid #dc3545; padding: 3px 8px; border-radius: 20px; display: flex; align-items: center; gap: 4px; }
104
.msg-content { font-size: 0.95rem; line-height: 1.6; white-space: pre-wrap; color: var(--text-primary); }
105
106
/* [Right Sidebar - 완전 고정] */
107
.info-column {
108
/* [해결 1] flex-shrink: 0 을 추가하여 절대 찌그러지거나 밀리지 않게 함 */
109
flex: 0 0 300px;
110
width: 300px;
111
background-color: var(--bg-secondary);
112
border-left: 1px solid var(--border-color);
113
padding: 30px;
114
display: flex; flex-direction: column;
115
}
116
.info-card { background-color: var(--bg-main); border: 1px solid var(--border-color); border-radius: 8px; margin-bottom: 20px; }
117
118
/* [Responsive - 해결 2] 분기점을 1200px로 상향 조정 */
119
@media (max-width: 1200px) {
120
.global-header { padding: 10px 20px; }
121
122
/* 레이아웃을 세로 모드로 전환 */
123
.layout-container { flex-direction: column; align-items: center; }
124
125
.content-column { width: 100%; padding: 40px 20px; }
126
127
/* 우측 패널을 하단으로 이동하고 너비 100%로 확장 */
128
.info-column {
129
width: 100%;
130
flex: auto; /* 고정 해제 */
131
border-left: none; border-top: 1px solid var(--border-color);
132
padding: 40px 20px;
133
}
134
.info-column > div:last-child { margin-bottom: 60px; }
135
}
136
137
@media (max-width: 991px) {
138
.global-header { height: auto; min-height: 70px; }
139
.header-nav { order: 2; gap: 15px !important; }
140
.header-controls {
141
width: 100%; order: 3; display: flex; justify-content: flex-end;
142
margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1);
143
}
144
.header-brand { order: 1; }
145
}
146
147
/* 기타 스타일 (삭제 버튼, 페이지네이션) */
148
.btn-delete { background: none; border: none; color: var(--text-secondary); font-size: 0.8rem; padding: 2px 5px; cursor: pointer; transition: color 0.2s; display: flex; align-items: center; }
149
.btn-delete:hover { color: #dc3545; }
150
.pagination-container { margin-top: 40px; display: flex; justify-content: center; gap: 8px; }
151
.page-link-custom { display: flex; align-items: center; justify-content: center; min-width: 36px; height: 36px; padding: 0 10px; border: 1px solid var(--border-color); background-color: var(--bg-secondary); color: var(--text-primary); border-radius: 8px; font-size: 0.9rem; font-weight: 600; transition: all 0.2s; text-decoration: none; }
152
.page-link-custom:hover { border-color: var(--accent-color); color: var(--accent-color); transform: translateY(-2px); box-shadow: var(--shadow-sm); }
153
.page-link-custom.active { background-color: var(--accent-color); color: #fff; border-color: var(--accent-color); }
154
.page-link-custom.disabled { opacity: 0.5; pointer-events: none; background-color: var(--bg-main); }
155
.verified-badge { color: #1d9bf0; font-size: 0.85em; margin-left: 2px; }
156
.verified-badge-admin { color: var(--accent-color); font-size: 0.85em; margin-left: 2px; }
157
</style>
158
</head>
159
160
<body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
161
<header class="global-header">
162
<div class="header-brand">
163
<a href="/">
164
<img src="/image/lounge1.png" alt="Logo" class="header-logo">
165
</a>
166
</div>
167
<nav class="header-nav d-flex gap-4">
168
<a href="/hinana/index" class="nav-link text-white-50 small fw-bold">Archive</a>
169
<a href="/hinana/info" class="nav-link text-white-50 small fw-bold">Info</a>
170
<a href="/hinana/lounge" class="nav-link text-white fw-bold">Lounge</a>
171
</nav>
172
<div class="header-controls" style="display:flex; align-items:center; gap:12px;">
173
<a href="/hinana/gallery#brand-assets" class="text-white-50 small fw-bold" style="text-decoration:none;">사이트 맵</a>
174
<form action="/toggle-theme" method="POST" style="margin:0;">
175
<button type="submit" class="btn text-white p-1"><i class="bi bi-moon-stars"></i></button>
176
</form>
177
</div>
178
</header>
179
180
<div class="layout-container">
181
<div class="content-column">
182
<div class="plaza-hero">
183
<span style="color: var(--accent-color); letter-spacing: 5px; font-weight: bold; font-size: 0.8rem;">TEMPORARY SQUARE</span>
184
<h2 class="info-title">비나래 광장</h2>
185
<div style="width: 60px; height: 1px; background: var(--accent-color); margin: 0 auto;"></div>
186
<p class="text-secondary small mt-3">이곳의 이야기는 24시간 후 바람처럼 사라집니다.</p><p class="text-secondary small">그래도 히나나가 들렀다 가기엔 충분한 시간이죠.</p>
187
</div>
188
189
<div class="input-card">
190
<form action="/hinana/plaza/post" method="POST">
191
<textarea
192
name="content"
193
class="plaza-textarea"
194
placeholder="광장에 남길 메시지를 적어주세요... (최대 300자)"
195
maxlength="300"
196
oninput="updateCharCount(this)"
197
></textarea>
198
199
<div class="text-end mt-1 mb-2" style="font-size: 0.75rem; color: var(--text-secondary);">
200
<span id="current-count">0</span> / 300
201
</div>
202
203
<div class="d-flex justify-content-between align-items-center mt-2">
204
<div class="d-flex gap-2 align-items-center">
205
<% if(!username) { %>
206
<input type="text" name="anonymousUsername" class="form-control form-control-sm" placeholder="닉네임" style="width: 120px;">
207
<input type="hidden" name="isAnonymous" value="true">
208
<% } else { %>
209
<span class="badge bg-secondary"><%= username %></span>
210
<% } %>
211
</div>
212
<button type="submit" class="btn btn-dark btn-sm px-4" style="background: var(--accent-color); border: none;">광장 한 편으로</button>
213
</div>
214
</form>
215
</div>
216
217
<div class="plaza-grid">
218
<% messages.forEach(msg => {
219
/* [수정] 남은 시간 실시간 계산 로직 추가 */
220
const now = new Date();
221
const posted = new Date(msg.timestamp);
222
const elapsed = now - posted; // 흐른 시간 (ms)
223
const lifeTime = 24 * 60 * 60 * 1000; // 수명 24시간 (ms)
224
const remaining = lifeTime - elapsed; // 남은 시간 (ms)
225
226
// 음수 방지 (삭제 직전 0으로 표시)
227
const safeRemaining = remaining > 0 ? remaining : 0;
228
229
const rHours = Math.floor(safeRemaining / (1000 * 60 * 60));
230
const rMinutes = Math.floor((safeRemaining % (1000 * 60 * 60)) / (1000 * 60));
231
%>
232
<div class="message-card">
233
<div class="msg-header">
234
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
235
236
<div class="d-flex align-items-center gap-2">
237
<span class="msg-timer" title="남은 시간">
238
<i class="bi bi-hourglass-split"></i>
239
<%= rHours %>H <%= rMinutes %>M
240
</span>
241
242
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
243
<form action="/hinana/plaza/delete" method="POST" data-confirm="정말 이 기록을 지우시겠습니까?" style="margin:0;">
244
<input type="hidden" name="id" value="<%= msg.id %>">
245
<button type="submit" class="btn-delete" title="삭제">
246
<i class="bi bi-trash3-fill"></i>
247
</button>
248
</form>
249
<% } %>
250
</div>
251
</div>
252
253
<div class="msg-content"><%= msg.content %></div>
254
255
<div class="mt-3 pt-2 border-top" style="font-size: 0.7rem; color: var(--text-secondary); border-color: var(--border-color) !important;">
256
<%= fmtDate(msg.timestamp) %>
257
</div>
258
</div>
259
<% }) %>
260
</div>
261
<% if (totalPages > 1) { %>
262
<div class="pagination-container">
263
<% if (currentPage > 1) { %>
264
<a href="?page=<%= currentPage - 1 %>" class="page-link-custom">
265
<i class="bi bi-chevron-left"></i>
266
</a>
267
<% } else { %>
268
<span class="page-link-custom disabled"><i class="bi bi-chevron-left"></i></span>
269
<% } %>
270
271
<%
272
let startPage = Math.max(1, currentPage - 2);
273
let endPage = Math.min(totalPages, startPage + 4);
274
if (endPage - startPage < 4) {
275
startPage = Math.max(1, endPage - 4);
276
}
277
%>
278
279
<% for(let i = startPage; i <= endPage; i++) { %>
280
<a href="?page=<%= i %>" class="page-link-custom <%= currentPage === i ? 'active' : '' %>">
281
<%= i %>
282
</a>
283
<% } %>
284
285
<% if (currentPage < totalPages) { %>
286
<a href="?page=<%= currentPage + 1 %>" class="page-link-custom">
287
<i class="bi bi-chevron-right"></i>
288
</a>
289
<% } else { %>
290
<span class="page-link-custom disabled"><i class="bi bi-chevron-right"></i></span>
291
<% } %>
292
</div>
293
<% } %>
294
</div>
295
296
<div class="info-column">
297
<div class="info-card p-4 mb-4 text-center">
298
<div style="font-size: 0.65rem; color: var(--accent-color); letter-spacing: 3px; font-weight: 700; margin-bottom: 20px;">PASSENGER INFO</div>
299
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
300
<div>
301
<% if(username) { %>
302
<a href="/logout?redirect=/hinana/plaza" class="btn btn-outline-dark btn-sm w-100 py-2">SIGN OUT</a>
303
<% } else { %>
304
<a href="/login?redirect=/hinana/plaza" class="btn btn-dark btn-sm w-100 py-2">SIGN IN</a>
305
<% } %>
306
</div>
307
</div>
308
309
<div class="info-card p-4 mb-4">
310
<div style="font-size: 0.65rem; color: var(--accent-color); letter-spacing: 3px; font-weight: 700; margin-bottom: 15px;">SYSTEM INFO</div>
311
<ul class="small text-secondary list-unstyled mb-0">
312
<li class="mb-1 d-flex justify-content-between">
313
<span>Version</span>
314
<span class="text-end">Ver. 6.5.4.0-Kozeki Ui</span>
315
</li>
316
</ul>
317
</div>
318
319
<div class="mt-auto text-center pt-5">
320
<img src="/image/sign.png" style="width: 160px; opacity: 0.7; mix-blend-mode: multiply;">
321
<div class="mt-4 pt-4 border-top" style="font-size: 0.7rem; color: var(--text-secondary); line-height: 1.8;">
322
<strong>비나래 라운지</strong><br>
323
X - @NoctchillHinana<br>
324
ⓒ 2024~2026. 비나래 | hinana.moe
325
</div>
326
</div>
327
</div>
328
</div>
329
<script>
330
function updateCharCount(textarea) {
331
const currentLength = textarea.value.length;
332
const counter = document.getElementById('current-count');
333
334
// 글자 수 업데이트
335
counter.innerText = currentLength;
336
337
// (선택사항) 300자 꽉 차면 빨간색으로 경고
338
if (currentLength >= 300) {
339
counter.style.color = '#dc3545'; // 빨간색
340
counter.style.fontWeight = 'bold';
341
} else {
342
counter.style.color = ''; // 기본색 복귀
343
counter.style.fontWeight = 'normal';
344
}
345
}
346
</script>
347
</body>
348
</html>
349