Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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="차감">−</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
© 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