Public Source Viewer

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

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

Redacted View
view/hinana/admin.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 <title>비나래 라운지 - 책갈피 관리</title>
10
11 <link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
12 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
13 <link href="https://fonts.googleapis.com/css?family=Montserrat:400,700" rel="stylesheet" type="text/css">
14 <link href="https://fonts.googleapis.com/css?family=Lato:300,400,700" rel="stylesheet" type="text/css">
15 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
16 <script src="/js/popup.js"></script>
17
18 <style>
19 :root {
20 --font-family: 'Noto Sans KR', sans-serif;
21 --bg-main: #f8f7f5; --bg-secondary: #ffffff; --bg-tertiary: #1a2238;
22 --text-primary: #1a2238; --text-secondary: #5e6676;
23 --accent-color: #c5a059; --border-color: #e5e1da;
24 --shadow-md: 0 10px 40px -10px rgba(26, 34, 56, 0.12);
25 --shadow-sm: 0 2px 8px rgba(0,0,0,0.05);
26 --danger-color: #dc2626;
27 }
28
29 body.dark-mode {
30 --bg-main: #0f141e; --bg-secondary: #1a2238; --bg-tertiary: #0a0e17;
31 --text-primary: #e7e5e4; --text-secondary: #a8a29e;
32 --accent-color: #d4b47a; --border-color: #2e3a59;
33 }
34
35 html, body {
36 height: auto !important; min-height: 100%; margin: 0; padding: 0;
37 font-family: var(--font-family); background-color: var(--bg-main); color: var(--text-primary);
38 overflow-x: hidden; overflow-y: auto; width: 100%;
39 }
40 a { text-decoration: none; color: inherit; }
41 * { box-sizing: border-box; }
42
43 .global-header {
44 height: auto; min-height: 70px; background-color: var(--bg-tertiary); border-bottom: 1px solid var(--border-color);
45 display: flex; align-items: center; justify-content: space-between; padding: 10px 40px;
46 position: sticky; top: 0; z-index: 1000; color: white; flex-wrap: wrap;
47 }
48 .header-logo { height: 32px; filter: none !important; -webkit-filter: none !important; mix-blend-mode: normal !important; }
49 .header-brand { display: flex; align-items: center; flex: 0 0 auto; }
50 .header-nav { display: flex; gap: 20px; align-items: center; }
51
52 .layout-container {
53 display: flex; min-height: calc(100vh - 70px);
54 background-color: var(--bg-main);
55 width: 100%; max-width: 100vw; overflow-x: hidden;
56 }
57
58 .content-column {
59 flex: 1; padding: 60px 40px;
60 display: flex; flex-direction: column; align-items: center;
61 width: 100%; min-width: 0;
62 }
63
64 .admin-hero { text-align: center; margin-bottom: 40px; }
65 .info-title { font-size: 2.5rem; font-weight: 700; color: var(--text-primary); margin: 10px 0; letter-spacing: -1px; }
66
67 .admin-card {
68 width: 100%; max-width: 900px; background-color: var(--bg-secondary);
69 padding: 30px; border-radius: 4px; border: 1px solid var(--border-color);
70 border-top: 5px solid var(--accent-color) !important; box-shadow: var(--shadow-md);
71 }
72
73 .user-row {
74 display: flex; align-items: center; gap: 12px;
75 padding: 14px 18px; border: 1px solid var(--border-color); border-radius: 8px;
76 background-color: var(--bg-main); margin-bottom: 10px; transition: all 0.2s;
77 }
78 .user-row:last-child { margin-bottom: 0; }
79 .user-row:hover { border-color: var(--accent-color); box-shadow: var(--shadow-sm); }
80
81 .user-name {
82 flex: 1; font-weight: 600; font-size: 0.95rem; color: var(--text-primary);
83 display: flex; align-items: center; gap: 6px;
84 }
85 .user-bookmarks {
86 font-weight: 700; color: var(--accent-color); font-size: 1rem;
87 min-width: 80px; text-align: center;
88 }
89 .user-controls {
90 display: flex; align-items: center; gap: 6px;
91 }
92 .amount-input {
93 width: 70px; padding: 6px 8px; border: 1px solid var(--border-color);
94 border-radius: 6px; background: var(--bg-secondary); color: var(--text-primary);
95 font-size: 0.85rem; text-align: center; font-family: inherit;
96 }
97 .amount-input:focus { outline: none; border-color: var(--accent-color); }
98
99 .btn-adjust {
100 width: 34px; height: 34px; border: none; border-radius: 6px;
101 font-size: 1rem; font-weight: 700; cursor: pointer; transition: opacity 0.15s;
102 display: flex; align-items: center; justify-content: center;
103 }
104 .btn-adjust:hover { opacity: 0.8; }
105 .btn-plus { background-color: #16a34a; color: white; }
106 .btn-minus { background-color: var(--danger-color); color: white; }
107
108 .verified-badge { color: #1d9bf0; font-size: 0.85em; }
109 .verified-badge-admin { color: var(--accent-color); font-size: 0.85em; }
110
111 .info-column {
112 flex: 0 0 300px; width: 300px;
113 background-color: var(--bg-secondary); border-left: 1px solid var(--border-color);
114 padding: 30px; 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 .pagination-bar {
119 display: flex; align-items: center; justify-content: center; gap: 6px;
120 margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-color);
121 }
122 .page-btn {
123 min-width: 34px; height: 34px; border: 1px solid var(--border-color); border-radius: 6px;
124 background: var(--bg-main); color: var(--text-primary); font-size: 0.85rem; font-weight: 600;
125 cursor: pointer; display: flex; align-items: center; justify-content: center;
126 transition: all 0.15s; font-family: inherit; padding: 0 8px;
127 }
128 .page-btn:hover { border-color: var(--accent-color); color: var(--accent-color); }
129 .page-btn.active { background: var(--accent-color); color: white; border-color: var(--accent-color); }
130 .page-btn:disabled { opacity: 0.4; cursor: default; }
131
132 .bulk-bar {
133 display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
134 padding: 16px 18px; border: 1px solid var(--border-color); border-radius: 8px;
135 background-color: var(--bg-main); margin-bottom: 20px;
136 }
137 .bulk-label { font-size: 0.8rem; font-weight: 700; color: var(--text-secondary); white-space: nowrap; }
138 .btn-bulk {
139 padding: 7px 16px; border: none; border-radius: 6px;
140 font-size: 0.8rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s;
141 font-family: inherit; display: flex; align-items: center; gap: 5px;
142 }
143 .btn-bulk:hover { opacity: 0.8; }
144 .btn-bulk-plus { background-color: #16a34a; color: white; }
145 .btn-bulk-minus { background-color: var(--danger-color); color: white; }
146
147 @media (max-width: 1200px) {
148 .global-header { padding: 10px 20px; }
149 .layout-container { flex-direction: column; align-items: center; }
150 .content-column { width: 100%; padding: 40px 20px; }
151 .info-column { width: 100%; flex: auto; border-left: none; border-top: 1px solid var(--border-color); padding: 40px 20px; }
152 }
153 @media (max-width: 991px) {
154 .global-header { height: auto; min-height: 70px; }
155 .header-nav { order: 2; gap: 15px !important; }
156 .header-controls {
157 width: 100%; order: 3; display: flex; justify-content: flex-end;
158 margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1);
159 }
160 .header-brand { order: 1; }
161 }
162 @media (max-width: 768px) {
163 .user-row { flex-wrap: wrap; }
164 .user-controls { width: 100%; justify-content: flex-end; margin-top: 8px; }
165 }
166 </style>
167 </head>
168
169 <body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
170 <header class="global-header">
171 <div class="header-brand">
172 <a href="/hinana/lounge">
173 <img src="/image/lounge1.png" alt="Logo" class="header-logo">
174 </a>
175 </div>
176 <nav class="header-nav d-flex gap-4">
177 <a href="/hinana/index" class="nav-link text-white-50 small fw-bold">Archive</a>
178 <a href="/hinana/info" class="nav-link text-white-50 small fw-bold">Info</a>
179 <a href="/hinana/lounge" class="nav-link text-white fw-bold">Lounge</a>
180 </nav>
181 <div class="header-controls" style="display:flex; align-items:center; gap:12px;">
182 <a href="/hinana/gallery#brand-assets" class="text-white-50 small fw-bold" style="text-decoration:none;">사이트 맵</a>
183 <form action="/toggle-theme" method="POST" style="margin:0;">
184 <button type="submit" class="btn text-white p-1"><i class="bi bi-moon-stars"></i></button>
185 </form>
186 </div>
187 </header>
188
189 <div class="layout-container">
190 <div class="content-column">
191 <div class="admin-hero">
192 <span style="color: var(--accent-color); letter-spacing: 5px; font-weight: bold; font-size: 0.8rem;">ADMINISTRATION</span>
193 <h2 class="info-title">책갈피 관리</h2>
194 <div style="width: 60px; height: 1px; background: var(--accent-color); margin: 0 auto;"></div>
195 <p class="text-secondary small mt-3">유저들의 책갈피를 조회하고 지급 또는 차감할 수 있습니다.</p>
196 </div>
197
198 <div class="admin-card">
199 <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;">
200 <span style="font-size: 0.75rem; color: var(--accent-color); letter-spacing: 3px; font-weight: 700;">USER LIST</span>
201 <span style="font-size: 0.8rem; color: var(--text-secondary);"><%= usersList.length %>명</span>
202 </div>
203
204 <!-- 일괄 지급/차감 -->
205 <div class="bulk-bar">
206 <span class="bulk-label"><i class="bi bi-people-fill me-1"></i> 전체 유저 일괄</span>
207 <input type="number" id="bulk-amount" class="amount-input" value="1" min="1" max="9999">
208 <button class="btn-bulk btn-bulk-plus" onclick="bulkAdjust(1)"><i class="bi bi-plus-lg"></i> 일괄 지급</button>
209 <button class="btn-bulk btn-bulk-minus" onclick="bulkAdjust(-1)"><i class="bi bi-dash-lg"></i> 일괄 차감</button>
210 </div>
211
212 <!-- 검색 -->
213 <div style="margin-bottom: 15px;">
214 <div style="position: relative;">
215 <i class="bi bi-search" style="position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--text-secondary); font-size: 0.85rem;"></i>
216 <input type="text" id="user-search" placeholder="유저 검색..." oninput="searchUsers()" style="width: 100%; padding: 10px 12px 10px 36px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-main); color: var(--text-primary); font-size: 0.85rem; font-family: inherit; box-sizing: border-box;">
217 </div>
218 </div>
219
220 <!-- 유저 목록 (JS로 페이지네이션) -->
221 <div id="user-list">
222 <% usersList.forEach(function(u, idx) { %>
223 <div class="user-row" data-username="<%= u.username %>" data-idx="<%= idx %>">
224 <div class="user-name">
225 <i class="bi bi-person-fill" style="color: var(--text-secondary);"></i>
226 <%= u.username %>
227 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
228 <i class="bi bi-patch-check-fill verified-badge-admin" title="관리자"></i>
229 <% } else if (verifiedUsers && verifiedUsers.includes(u.username)) { %>
230 <i class="bi bi-patch-check-fill verified-badge" title="인증됨"></i>
231 <% } %>
232 </div>
233 <div class="user-bookmarks">
234 <i class="bi bi-bookmark-fill" style="font-size: 0.8rem;"></i>
235 <span class="bookmark-count"><%= u.bookmarks %></span>
236 </div>
237 <div class="user-controls">
238 <input type="number" class="amount-input" value="1" min="1" max="9999">
239 <button class="btn-adjust btn-plus" onclick="adjustBookmark('<%= u.username %>', this, 1)" title="지급">+</button>
240 <button class="btn-adjust btn-minus" onclick="adjustBookmark('<%= u.username %>', this, -1)" title="차감">&minus;</button>
241 <% if (u.profileImage) { %>
242 <button class="btn-adjust btn-minus" onclick="deleteProfilePic('<%= u.username %>')" title="프로필 삭제" style="margin-left:4px;">
243 <i class="bi bi-person-x-fill"></i>
244 </button>
245 <% } %>
246 </div>
247 </div>
248 <% }); %>
249 </div>
250
251 <!-- 페이지네이션 -->
252 <div class="pagination-bar" id="pagination"></div>
253 </div>
254 </div>
255
256 <div class="info-column">
257 <div class="info-card p-4 mb-4 text-center">
258 <div style="font-size: 0.65rem; color: var(--accent-color); letter-spacing: 3px; font-weight: 700; margin-bottom: 20px;">PASSENGER INFO</div>
259 <div class="fs-4 fw-bold mb-3">
260 <a href="/hinana/userInfo" style="color: inherit;"><%= username %></a>
261 <i class="bi bi-patch-check-fill verified-badge-admin" title="관리자 인증"></i>
262 </div>
263 <div>
264 <a href="/logout?redirect=/hinana/admin" class="btn btn-outline-dark btn-sm w-100 py-2">SIGN OUT</a>
265 </div>
266 </div>
267
268 <div class="info-card p-4 mb-4">
269 <div style="font-size: 0.65rem; color: var(--accent-color); letter-spacing: 3px; font-weight: 700; margin-bottom: 15px;">ADMIN MENU</div>
270 <ul class="small list-unstyled mb-0" style="color: var(--text-secondary);">
271 <li class="mb-2">
272 <a href="/hinana/admin" style="color: var(--accent-color); font-weight: 600;">
273 <i class="bi bi-bookmark-star-fill me-1"></i> 책갈피 관리
274 </a>
275 </li>
276 <li class="mb-2">
277 <a href="/hinana/monitor" style="color: inherit;">
278 <i class="bi bi-speedometer2 me-1"></i> Monitor
279 </a>
280 </li>
281 <li class="mb-2">
282 <a href="/hinana/shop" style="color: inherit;">
283 <i class="bi bi-shop me-1"></i> 교환소
284 </a>
285 </li>
286 <li>
287 <a href="/hinana/userInfo" style="color: inherit;">
288 <i class="bi bi-people-fill me-1"></i> 계정 관리
289 </a>
290 </li>
291 </ul>
292 </div>
293
294 <div class="info-card p-4 mb-4">
295 <div style="font-size: 0.65rem; color: var(--accent-color); letter-spacing: 3px; font-weight: 700; margin-bottom: 15px;">SYSTEM INFO</div>
296 <ul class="small text-secondary list-unstyled mb-0">
297 <li class="mb-1 d-flex justify-content-between">
298 <span>Version</span>
299 <span class="text-end">Ver. 6.5.4.0-Kozeki Ui</span>
300 </li>
301 </ul>
302 </div>
303
304 <div class="mt-auto text-center pt-5">
305 <img src="/image/sign.png" style="width: 160px; opacity: 0.7; mix-blend-mode: multiply;">
306 <div class="mt-ㅋ pt-4 border-top" style="font-size: 0.7rem; color: var(--text-secondary); line-height: 1.8;">
307 <strong>비나래 라운지</strong><br>
308 X - @NoctchillHinana<br>
309 &copy; 2024~2026. 비나래 | hinana.moe
310 </div>
311 </div>
312 </div>
313 </div>
314
315 <script>
316 // 검색 + 페이지네이션
317 var PER_PAGE = 15;
318 var currentPage = 1;
319 var allRows = Array.from(document.querySelectorAll('#user-list .user-row'));
320 var filteredRows = allRows.slice();
321
322 function searchUsers() {
323 var query = document.getElementById('user-search').value.trim().toLowerCase();
324 filteredRows = allRows.filter(function(row) {
325 var username = (row.getAttribute('data-username') || '').toLowerCase();
326 return username.indexOf(query) !== -1;
327 });
328 renderPage(1);
329 }
330
331 function renderPage(page) {
332 currentPage = page;
333 var totalPages = Math.ceil(filteredRows.length / PER_PAGE) || 1;
334 var start = (page - 1) * PER_PAGE;
335 var end = start + PER_PAGE;
336
337 allRows.forEach(function(row) { row.style.display = 'none'; });
338 filteredRows.forEach(function(row, i) {
339 row.style.display = (i >= start && i < end) ? '' : 'none';
340 });
341
342 var pag = document.getElementById('pagination');
343 if (totalPages <= 1) { pag.innerHTML = ''; return; }
344
345 var html = '';
346 html += '<button class="page-btn" onclick="renderPage(' + Math.max(1, page - 1) + ')" ' + (page === 1 ? 'disabled' : '') + '><i class="bi bi-chevron-left"></i></button>';
347 for (var p = 1; p <= totalPages; p++) {
348 html += '<button class="page-btn' + (p === page ? ' active' : '') + '" onclick="renderPage(' + p + ')">' + p + '</button>';
349 }
350 html += '<button class="page-btn" onclick="renderPage(' + Math.min(totalPages, page + 1) + ')" ' + (page === totalPages ? 'disabled' : '') + '><i class="bi bi-chevron-right"></i></button>';
351 pag.innerHTML = html;
352 }
353
354 renderPage(1);
355
356 // 개별 조정
357 async function adjustBookmark(username, btnEl, direction) {
358 var row = btnEl.closest('.user-row');
359 var input = row.querySelector('.amount-input');
360 var amount = parseInt(input.value) || 0;
361 if (amount <= 0) {
362 await showAlert('1 이상의 수를 입력해주세요.');
363 return;
364 }
365
366 var finalAmount = amount * direction;
367 var action = direction > 0 ? '지급' : '차감';
368
369 if (!await showConfirm(username + '에게 책갈피 ' + amount + '개를 ' + action + '하시겠습니까?')) return;
370
371 try {
372 var res = await fetch('/hinana/admin/bookmark', {
373 method: 'POST',
374 headers: { 'Content-Type': 'application/json' },
375 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
376 });
377 var data = await res.json();
378 if (data.success) {
379 row.querySelector('.bookmark-count').textContent = data.newTotal;
380 }
381 await showAlert(data.message);
382 } catch (e) {
383 await showAlert('오류가 발생했습니다.');
384 }
385 }
386
387 // 일괄 조정
388 async function bulkAdjust(direction) {
389 var amount = parseInt(document.getElementById('bulk-amount').value) || 0;
390 if (amount <= 0) {
391 await showAlert('1 이상의 수를 입력해주세요.');
392 return;
393 }
394
395 var finalAmount = amount * direction;
396 var action = direction > 0 ? '지급' : '차감';
397
398 if (!await showConfirm('전체 유저에게 책갈피 ' + amount + '개를 ' + action + '하시겠습니까?')) return;
399
400 try {
401 var res = await fetch('/hinana/admin/bookmark-bulk', {
402 method: 'POST',
403 headers: { 'Content-Type': 'application/json' },
404 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
405 });
406 var data = await res.json();
407 if (data.success && data.results) {
408 data.results.forEach(function(r) {
409 var row = document.querySelector('.user-row[data-username="' + r.username + '"]');
410 if (row) row.querySelector('.bookmark-count').textContent = r.newTotal;
411 });
412 }
413 await showAlert(data.message);
414 } catch (e) {
415 await showAlert('오류가 발생했습니다.');
416 }
417 }
418
419 async function deleteProfilePic(username) {
420 if (!await showConfirm(username + '의 프로필 사진을 삭제하시겠습니까?')) return;
421 try {
422 const res = await fetch('/hinana/admin/delete-profile-pic', {
423 method: 'POST',
424 headers: { 'Content-Type': 'application/json' },
425 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
426 });
427 const data = await res.json();
428 await showAlert(data.message);
429 if (data.success) location.reload();
430 } catch (e) {
431 await showAlert('오류가 발생했습니다.');
432 }
433 }
434 </script>
435 </body>
436 </html>
437