본문으로 건너뛰기
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: var(--ui-on-accent, #fff); 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 <link rel="stylesheet" href="/css/site-ui.css?v=3">
168 <link rel="stylesheet" href="/css/site-app.css?v=1">
169 </head>
170
171 <body data-ui-page="admin" class="ui-shell <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
172 <%- include('partials/site-header', { uiPage: 'admin' }) %>
173
174 <div id="ui-content" tabindex="-1" role="main" class="layout-container">
175 <div class="content-column">
176 <div class="admin-hero">
177 <span style="color: var(--accent-color); letter-spacing: 5px; font-weight: bold; font-size: 0.8rem;">ADMINISTRATION</span>
178 <h2 class="info-title">책갈피 관리</h2>
179 <div style="width: 60px; height: 1px; background: var(--accent-color); margin: 0 auto;"></div>
180 <p class="text-secondary small mt-3">유저들의 책갈피를 조회하고 지급 또는 차감할 수 있습니다.</p>
181 </div>
182
183 <div class="admin-card">
184 <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;">
185 <span style="font-size: 0.75rem; color: var(--accent-color); letter-spacing: 3px; font-weight: 700;">USER LIST</span>
186 <span style="font-size: 0.8rem; color: var(--text-secondary);"><%= usersList.length %>명</span>
187 </div>
188
189 <!-- 일괄 지급/차감 -->
190 <div class="bulk-bar">
191 <span class="bulk-label"><i class="bi bi-people-fill me-1"></i> 전체 유저 일괄</span>
192 <input type="number" id="bulk-amount" class="amount-input" value="1" min="1" max="9999">
193 <button class="btn-bulk btn-bulk-plus" onclick="bulkAdjust(1)"><i class="bi bi-plus-lg"></i> 일괄 지급</button>
194 <button class="btn-bulk btn-bulk-minus" onclick="bulkAdjust(-1)"><i class="bi bi-dash-lg"></i> 일괄 차감</button>
195 </div>
196
197 <!-- 검색 -->
198 <div style="margin-bottom: 15px;">
199 <div style="position: relative;">
200 <i class="bi bi-search" style="position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--text-secondary); font-size: 0.85rem;"></i>
201 <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;">
202 </div>
203 </div>
204
205 <!-- 유저 목록 (JS로 페이지네이션) -->
206 <div id="user-list">
207 <% usersList.forEach(function(u, idx) { %>
208 <div class="user-row" data-username="<%= u.username %>" data-idx="<%= idx %>">
209 <div class="user-name">
210 <i class="bi bi-person-fill" style="color: var(--text-secondary);"></i>
211 <%= u.username %>
212 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
213 <i class="bi bi-patch-check-fill verified-badge-admin" title="관리자"></i>
214 <% } else if (verifiedUsers && verifiedUsers.includes(u.username)) { %>
215 <i class="bi bi-patch-check-fill verified-badge" title="인증됨"></i>
216 <% } %>
217 </div>
218 <div class="user-bookmarks">
219 <i class="bi bi-bookmark-fill" style="font-size: 0.8rem;"></i>
220 <span class="bookmark-count"><%= u.bookmarks %></span>
221 </div>
222 <div class="user-controls">
223 <input type="number" class="amount-input" value="1" min="1" max="9999">
224 <button class="btn-adjust btn-plus" onclick="adjustBookmark('<%= u.username %>', this, 1)" title="지급">+</button>
225 <button class="btn-adjust btn-minus" onclick="adjustBookmark('<%= u.username %>', this, -1)" title="차감">&minus;</button>
226 <% if (u.profileImage) { %>
227 <button class="btn-adjust btn-minus" onclick="deleteProfilePic('<%= u.username %>')" title="프로필 삭제" style="margin-left:4px;">
228 <i class="bi bi-person-x-fill"></i>
229 </button>
230 <% } %>
231 </div>
232 </div>
233 <% }); %>
234 </div>
235
236 <!-- 페이지네이션 -->
237 <div class="pagination-bar" id="pagination"></div>
238 </div>
239 </div>
240
241 <div class="info-column">
242 <div class="info-card p-4 mb-4 text-center">
243 <div style="font-size: 0.65rem; color: var(--accent-color); letter-spacing: 3px; font-weight: 700; margin-bottom: 20px;">PASSENGER INFO</div>
244 <div class="fs-4 fw-bold mb-3">
245 <a href="/hinana/userInfo" style="color: inherit;"><%= username %></a>
246 <i class="bi bi-patch-check-fill verified-badge-admin" title="관리자 인증"></i>
247 </div>
248 <div>
249 <a href="/logout?redirect=/hinana/admin" class="btn btn-outline-dark btn-sm w-100 py-2">SIGN OUT</a>
250 </div>
251 </div>
252
253 <div class="info-card p-4 mb-4">
254 <div style="font-size: 0.65rem; color: var(--accent-color); letter-spacing: 3px; font-weight: 700; margin-bottom: 15px;">ADMIN MENU</div>
255 <ul class="small list-unstyled mb-0" style="color: var(--text-secondary);">
256 <li class="mb-2">
257 <a href="/hinana/admin" style="color: var(--accent-color); font-weight: 600;">
258 <i class="bi bi-bookmark-star-fill me-1"></i> 책갈피 관리
259 </a>
260 </li>
261 <li class="mb-2">
262 <a href="/hinana/monitor" style="color: inherit;">
263 <i class="bi bi-speedometer2 me-1"></i> Monitor
264 </a>
265 </li>
266 <li class="mb-2"><a href="/hinana/security" style="color:inherit;"><i class="bi bi-shield-check me-1"></i> 보안 관제</a></li>
267 <li class="mb-2"><a href="/hinana/backups" style="color:inherit;"><i class="bi bi-archive me-1"></i> 백업 센터</a></li>
268 <li class="mb-2"><a href="/hinana/archive-search" style="color:inherit;"><i class="bi bi-search me-1"></i> 통합 검색</a></li>
269 <li class="mb-2">
270 <a href="/hinana/shop" style="color: inherit;">
271 <i class="bi bi-shop me-1"></i> 교환소
272 </a>
273 </li>
274 <li>
275 <a href="/hinana/userInfo" style="color: inherit;">
276 <i class="bi bi-people-fill me-1"></i> 계정 관리
277 </a>
278 </li>
279 </ul>
280 </div>
281
282 <div class="info-card p-4 mb-4">
283 <div style="font-size: 0.65rem; color: var(--accent-color); letter-spacing: 3px; font-weight: 700; margin-bottom: 15px;">SYSTEM INFO</div>
284 <ul class="small text-secondary list-unstyled mb-0">
285 <li class="mb-1 d-flex justify-content-between">
286 <span>Version</span>
287 <span class="text-end">Ver. 9.2.1.0 - Iochi Mari</span>
288 </li>
289 </ul>
290 </div>
291
292 <div class="mt-auto text-center pt-5">
293 <img src="/image/sign.png" style="width: 160px; opacity: 0.7; mix-blend-mode: multiply;">
294 <div class="mt-ㅋ pt-4 border-top" style="font-size: 0.7rem; color: var(--text-secondary); line-height: 1.8;">
295 <strong>비나래 라운지</strong><br>
296 X - @NoctchillHinana<br>
297 &copy; 2024~2026. 비나래 | hinana.moe
298 </div>
299 </div>
300 </div>
301 </div>
302
303 <script>
304 // 검색 + 페이지네이션
305 var PER_PAGE = 15;
306 var currentPage = 1;
307 var allRows = Array.from(document.querySelectorAll('#user-list .user-row'));
308 var filteredRows = allRows.slice();
309
310 function searchUsers() {
311 var query = document.getElementById('user-search').value.trim().toLowerCase();
312 filteredRows = allRows.filter(function(row) {
313 var username = (row.getAttribute('data-username') || '').toLowerCase();
314 return username.indexOf(query) !== -1;
315 });
316 renderPage(1);
317 }
318
319 function renderPage(page) {
320 currentPage = page;
321 var totalPages = Math.ceil(filteredRows.length / PER_PAGE) || 1;
322 var start = (page - 1) * PER_PAGE;
323 var end = start + PER_PAGE;
324
325 allRows.forEach(function(row) { row.style.display = 'none'; });
326 filteredRows.forEach(function(row, i) {
327 row.style.display = (i >= start && i < end) ? '' : 'none';
328 });
329
330 var pag = document.getElementById('pagination');
331 if (totalPages <= 1) { pag.innerHTML = ''; return; }
332
333 var html = '';
334 html += '<button class="page-btn" onclick="renderPage(' + Math.max(1, page - 1) + ')" ' + (page === 1 ? 'disabled' : '') + '><i class="bi bi-chevron-left"></i></button>';
335 for (var p = 1; p <= totalPages; p++) {
336 html += '<button class="page-btn' + (p === page ? ' active' : '') + '" onclick="renderPage(' + p + ')">' + p + '</button>';
337 }
338 html += '<button class="page-btn" onclick="renderPage(' + Math.min(totalPages, page + 1) + ')" ' + (page === totalPages ? 'disabled' : '') + '><i class="bi bi-chevron-right"></i></button>';
339 pag.innerHTML = html;
340 }
341
342 renderPage(1);
343
344 // 개별 조정
345 async function adjustBookmark(username, btnEl, direction) {
346 var row = btnEl.closest('.user-row');
347 var input = row.querySelector('.amount-input');
348 var amount = parseInt(input.value) || 0;
349 if (amount <= 0) {
350 await showAlert('1 이상의 수를 입력해주세요.');
351 return;
352 }
353
354 var finalAmount = amount * direction;
355 var action = direction > 0 ? '지급' : '차감';
356
357 if (!await showConfirm(username + '에게 책갈피 ' + amount + '개를 ' + action + '하시겠습니까?')) return;
358
359 try {
360 var res = await fetch('/hinana/admin/bookmark', {
361 method: 'POST',
362 headers: { 'Content-Type': 'application/json' },
363 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
364 });
365 var data = await res.json();
366 if (data.success) {
367 row.querySelector('.bookmark-count').textContent = data.newTotal;
368 }
369 await showAlert(data.message);
370 } catch (e) {
371 await showAlert('오류가 발생했습니다.');
372 }
373 }
374
375 // 일괄 조정
376 async function bulkAdjust(direction) {
377 var amount = parseInt(document.getElementById('bulk-amount').value) || 0;
378 if (amount <= 0) {
379 await showAlert('1 이상의 수를 입력해주세요.');
380 return;
381 }
382
383 var finalAmount = amount * direction;
384 var action = direction > 0 ? '지급' : '차감';
385
386 if (!await showConfirm('전체 유저에게 책갈피 ' + amount + '개를 ' + action + '하시겠습니까?')) return;
387
388 try {
389 var res = await fetch('/hinana/admin/bookmark-bulk', {
390 method: 'POST',
391 headers: { 'Content-Type': 'application/json' },
392 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
393 });
394 var data = await res.json();
395 if (data.success && data.results) {
396 data.results.forEach(function(r) {
397 var row = document.querySelector('.user-row[data-username="' + r.username + '"]');
398 if (row) row.querySelector('.bookmark-count').textContent = r.newTotal;
399 });
400 }
401 await showAlert(data.message);
402 } catch (e) {
403 await showAlert('오류가 발생했습니다.');
404 }
405 }
406
407 async function deleteProfilePic(username) {
408 if (!await showConfirm(username + '의 프로필 사진을 삭제하시겠습니까?')) return;
409 try {
410 const res = await fetch('/hinana/admin/delete-profile-pic', {
411 method: 'POST',
412 headers: { 'Content-Type': 'application/json' },
413 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
414 });
415 const data = await res.json();
416 await showAlert(data.message);
417 if (data.success) location.reload();
418 } catch (e) {
419 await showAlert('오류가 발생했습니다.');
420 }
421 }
422 </script>
423 </body>
424 </html>
425