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: 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="차감">−</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
© 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