Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
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