본문으로 건너뛰기
Public Source Viewer

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

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

Redacted View
view/hinana/userInfo.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">
7 <link rel="manifest" href="/manifest.json">
8 <meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#000000' : '#ffffff' %>">
9 <meta name="apple-mobile-web-app-title" content="비나래 아카이브">
10 <meta property="og:image" content="/image/2.png" />
11 <meta property="og:description" content="비나래 아카이브"/>
12 <meta property="og:url" content="hinana.moe"/>
13 <meta property="og:title" content="비나래 아카이브"/>
14 <link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
15 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
16 <link href="https://fonts.googleapis.com/css?family=Montserrat:400,700" rel="stylesheet" type="text/css">
17 <link href="https://fonts.googleapis.com/css?family=Lato:300,400,700" rel="stylesheet" type="text/css">
18 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
19 <script src="/js/popup.js"></script>
20 <title>내 정보 - 비나래 아카이브</title>
21
22 <style>
23 :root {
24 --font-family: 'Noto Sans KR', sans-serif;
25 --bg-main: #ffffff; --bg-secondary: #f7f9f9; --bg-tertiary: #eff3f4;
26 --text-primary: #0f1419; --text-secondary: #536471;
27 --accent-color: #1d9bf0; --danger-color: #f4212e; --border-color: #eff3f4;
28 --success-color: #00ba7c; --warning-color: #ffd400;
29 --shadow-sm: 0 1px 2px 0 rgba(15, 20, 25, 0.06);
30 --shadow-md: 0 8px 24px rgba(15, 20, 25, 0.08);
31 }
32 body.dark-mode {
33 --bg-main: #000000; --bg-secondary: #16181c; --bg-tertiary: #202327;
34 --text-primary: #e7e9ea; --text-secondary: #71767b;
35 --accent-color: #1d9bf0; --border-color: #2f3336;
36 --success-color: #00ba7c; --warning-color: #ffd400;
37 --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.5);
38 --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.7);
39 }
40
41 html, body { margin: 0; min-height: 100vh; font-family: var(--font-family); background-color: var(--bg-main); color: var(--text-primary); }
42 a { text-decoration: none; color: inherit; }
43
44 .global-header {
45 height: 60px; background-color: rgba(255, 255, 255, 0.9); border-bottom: 1px solid var(--border-color);
46 display: flex; align-items: center; justify-content: space-between; padding: 0 20px;
47 position: sticky; top: 0; z-index: 1000; backdrop-filter: blur(12px);
48 }
49 body.dark-mode .global-header { background-color: rgba(0, 0, 0, 0.86); }
50 .header-brand { display: flex; align-items: center; }
51 .header-logo { height: 28px; width: auto; mix-blend-mode: multiply; }
52 body.dark-mode .header-logo { mix-blend-mode: screen; }
53 .header-nav {
54 position: absolute; left: 50%; transform: translateX(-50%);
55 display: flex; gap: 20px; align-items: center;
56 }
57 .nav-link { font-weight: 600; font-size: 0.95rem; color: var(--text-secondary); cursor: pointer; transition: color 0.2s; }
58 .nav-link:hover { color: var(--accent-color); }
59 .nav-link.active { color: var(--text-primary); }
60 .nav-divider { opacity: 0.3; color: var(--text-secondary); }
61 .header-controls { display: flex; align-items: center; gap: 10px; z-index: 10; position: relative; }
62 .icon-btn { border: none; background: transparent; color: var(--text-secondary); font-size: 1.1rem; cursor: pointer; padding: 4px; transition: color 0.2s; }
63 .icon-btn:hover { color: var(--text-primary); }
64
65 .content-area { max-width: 920px; margin: 34px auto; padding: 0 20px; }
66
67 .info-card {
68 background-color: var(--bg-secondary); border: 1px solid var(--border-color);
69 border-radius: 8px; box-shadow: var(--shadow-sm); padding: 28px; margin-bottom: 18px;
70 }
71 .info-card h5 { font-size: 0.8rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
72 .info-card .value { font-size: 1.4rem; font-weight: 700; color: var(--text-primary); }
73
74 .bookmark-card {
75 background: var(--bg-secondary);
76 border: 1px solid var(--border-color); border-left: 4px solid var(--accent-color);
77 border-radius: 8px; box-shadow: var(--shadow-sm); padding: 24px 28px; margin-bottom: 18px;
78 display: flex; align-items: center; gap: 20px;
79 }
80 .bookmark-icon { font-size: 2rem; color: var(--accent-color); }
81 .bookmark-label { font-size: 0.8rem; font-weight: 700; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; }
82 .bookmark-value { font-size: 1.8rem; font-weight: 800; color: var(--accent-color); }
83 .bookmark-sub { font-size: 0.75rem; color: var(--text-secondary); margin-top: 2px; }
84
85 .section-title {
86 font-size: 1.2rem; font-weight: 700; color: var(--text-primary);
87 margin: 40px 0 15px; padding-bottom: 10px; border-bottom: 1px solid var(--border-color);
88 }
89 .section-title i { color: var(--accent-color); margin-right: 8px; }
90
91 .admin-card {
92 background-color: var(--bg-secondary); border: 1px solid var(--border-color);
93 border-radius: 8px; box-shadow: var(--shadow-sm); padding: 24px 28px; margin-bottom: 15px;
94 }
95 .admin-card h5 { font-size: 0.95rem; font-weight: 700; color: var(--text-primary); margin-bottom: 8px; }
96
97 .table { color: var(--text-primary); background-color: var(--bg-secondary); }
98 .table thead { background-color: var(--bg-tertiary); }
99 .table thead th { color: var(--text-primary); border-color: var(--border-color); font-size: 0.85rem; }
100 .table td { border-color: var(--border-color); vertical-align: middle; color: var(--text-primary); }
101 .table-striped > tbody > tr:nth-of-type(odd) { background-color: var(--bg-main); }
102 .table-striped > tbody > tr:nth-of-type(even) { background-color: var(--bg-secondary); }
103
104 .btn-accent {
105 background-color: var(--accent-color); color: var(--ui-on-accent, #fff); border: none;
106 padding: 8px 20px; border-radius: 8px; font-weight: 600; font-size: 0.85rem; cursor: pointer; transition: opacity 0.2s;
107 }
108 .btn-accent:hover { opacity: 0.85; color: white; }
109
110 .btn-back {
111 border: 1px solid var(--border-color); background: transparent;
112 color: var(--text-secondary); padding: 10px 25px; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s;
113 }
114 .btn-back:hover { border-color: var(--accent-color); color: var(--accent-color); }
115
116 .pagination-bar {
117 display: flex; align-items: center; justify-content: center; gap: 6px;
118 margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-color);
119 }
120 .page-btn {
121 min-width: 34px; height: 34px; border: 1px solid var(--border-color); border-radius: 6px;
122 background: var(--bg-main); color: var(--text-primary); font-size: 0.85rem; font-weight: 600;
123 cursor: pointer; display: flex; align-items: center; justify-content: center;
124 transition: all 0.15s; font-family: inherit; padding: 0 8px;
125 }
126 .page-btn:hover { border-color: var(--accent-color); color: var(--accent-color); }
127 .page-btn.active { background: var(--accent-color); color: var(--ui-on-accent, #fff); border-color: var(--accent-color); }
128 .page-btn:disabled { opacity: 0.4; cursor: default; }
129
130 .badge-on { background-color: var(--success-color); color: #fff; }
131 .badge-off { background-color: var(--danger-color); color: #fff; }
132
133 .security-log-list { display: grid; gap: 10px; }
134 .security-log-row {
135 display: grid; grid-template-columns: 160px 1fr; gap: 14px;
136 padding: 14px; border: 1px solid var(--border-color); border-radius: 8px;
137 background: var(--bg-main);
138 }
139 .log-time { font-size: 0.78rem; color: var(--text-secondary); line-height: 1.5; }
140 .log-main { min-width: 0; }
141 .log-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 5px; }
142 .log-badge {
143 display: inline-flex; align-items: center; border-radius: 999px; padding: 3px 8px;
144 font-size: 0.7rem; font-weight: 800; letter-spacing: 0.02em;
145 background: var(--bg-tertiary); color: var(--text-secondary);
146 }
147 .log-login_success, .log-signup_success { background: rgba(0, 186, 124, 0.14); color: var(--success-color); }
148 .log-login_failure, .log-login_blocked, .log-access_denied { background: rgba(244, 33, 46, 0.12); color: var(--danger-color); }
149 .log-admin_action { background: rgba(29, 155, 240, 0.14); color: var(--accent-color); }
150 .log-feature_use { background: rgba(255, 212, 0, 0.18); color: #997000; }
151 body.dark-mode .log-feature_use { color: var(--warning-color); }
152 .log-action { font-weight: 800; color: var(--text-primary); }
153 .log-meta, .log-detail { font-size: 0.78rem; color: var(--text-secondary); overflow-wrap: anywhere; }
154 .log-empty { color: var(--text-secondary); font-size: 0.9rem; margin: 0; }
155 .signal-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; margin-top: 14px; }
156 .signal-input {
157 width: 100%; min-height: 78px; resize: vertical;
158 border: 1px solid var(--border-color); border-radius: 8px;
159 padding: 10px 12px; background: var(--bg-main); color: var(--text-primary);
160 font-family: inherit; font-size: 0.88rem; line-height: 1.6;
161 }
162 .signal-input:focus { outline: none; border-color: var(--accent-color); }
163 .signal-preview { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; color: var(--text-secondary); font-size: 0.78rem; }
164 .signal-chip {
165 display: inline-flex; align-items: center; border-radius: 999px; padding: 4px 9px;
166 background: var(--bg-tertiary); color: var(--text-secondary); font-weight: 700;
167 }
168
169 body.dark-mode .btn.btn-sm { border-color: currentColor; }
170 body.dark-mode .admin-card h5 { color: var(--text-primary); }
171 body.dark-mode .table { --bs-table-bg: var(--bg-secondary); --bs-table-striped-bg: var(--bg-main); --bs-table-color: var(--text-primary); }
172
173 .footer-area {
174 text-align: center; padding: 40px 20px; color: var(--text-secondary); font-size: 0.75rem; line-height: 1.8;
175 }
176 .footer-area img { width: 160px; opacity: 0.7; mix-blend-mode: multiply; }
177 body.dark-mode .footer-area img { mix-blend-mode: screen; }
178
179 @media (max-width: 768px) {
180 .header-nav { position: static; transform: none; gap: 12px; }
181 .global-header { flex-wrap: wrap; height: auto; padding: 10px 15px; gap: 8px; }
182 .bookmark-card { flex-direction: column; text-align: center; gap: 10px; }
183 .content-area { margin: 22px auto; padding: 0 14px; }
184 .info-card, .admin-card, .bookmark-card { padding: 20px; }
185 .signal-form { grid-template-columns: 1fr; }
186 .security-log-row { grid-template-columns: 1fr; gap: 8px; }
187 }
188 </style>
189 <link rel="stylesheet" href="/css/site-ui.css?v=3">
190 <link rel="stylesheet" href="/css/site-app.css?v=1">
191 </head>
192
193 <body data-ui-page="userInfo" class="ui-shell <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
194 <%- include('partials/site-header', { uiPage: 'userInfo' }) %>
195 <div id="ui-content" tabindex="-1" class="ui-content-anchor"></div>
196
197 <div class="content-area">
198 <div class="info-card">
199 <h5><i class="bi bi-person-circle"></i> 닉네임</h5>
200 <div style="display: flex; align-items: center; gap: 15px;">
201 <% if (typeof currentUserProfileImage !== 'undefined' && currentUserProfileImage) { %>
202 <div style="position:relative; flex-shrink:0;">
203 <img src="<%= currentUserProfileImage %>" style="width:56px; height:56px; border-radius:50%; object-fit:cover; border:2px solid var(--border-color);">
204 <button onclick="deleteMyProfilePic()" title="프로필 사진 삭제" style="position:absolute; top:-4px; right:-4px; width:20px; height:20px; border-radius:50%; border:none; background:var(--danger-color); color:white; font-size:0.6rem; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0;"><i class="bi bi-x"></i></button>
205 </div>
206 <% } else { %>
207 <div style="width:56px; height:56px; border-radius:50%; background:var(--bg-tertiary); display:flex; align-items:center; justify-content:center; font-size:1.5rem; font-weight:bold; color:var(--text-secondary); flex-shrink:0; border:2px solid var(--border-color);"><%= username.substring(0,1).toUpperCase() %></div>
208 <% } %>
209 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
210 </div>
211 <div style="margin-top: 12px;">
212 <a href="/hinana/shop" class="btn btn-sm" style="border: 1px solid var(--accent-color); color: var(--accent-color); font-weight: 600; font-size: 0.8rem;">
213 <i class="bi bi-camera-fill me-1"></i> 프로필 변경
214 </a>
215 </div>
216 </div>
217
218 <div class="bookmark-card" style="flex-direction: column; gap: 12px;">
219 <div style="display: flex; align-items: center; gap: 15px;">
220 <div class="bookmark-icon"><i class="bi bi-bookmark-fill"></i></div>
221 <div>
222 <div class="bookmark-label">책갈피</div>
223 <div class="bookmark-value">총 <%= typeof bookmarks !== 'undefined' ? bookmarks : 0 %>개</div>
224 </div>
225 </div>
226 <div style="border-top: 1px solid var(--border-color); padding-top: 10px; display: flex; flex-direction: column; gap: 6px;">
227 <div class="bookmark-sub" style="display: flex; justify-content: space-between;">
228 <span>SNS 활동으로 획득</span>
229 <!-- <span class="fw-bold" style="color: var(--text-primary);"><%= (typeof bookmarks !== 'undefined' ? bookmarks : 0) - (typeof tetrisBookmarks !== 'undefined' ? tetrisBookmarks : 0) %>개 <span style="font-weight: normal; color: var(--text-secondary);">(하루 최대 10개)</span></span> -->
230 </div>
231 <div class="bookmark-sub" style="display: flex; justify-content: space-between;">
232 <span>테트리스로 획득</span>
233 <!-- <span class="fw-bold" style="color: var(--text-primary);"><%= typeof tetrisBookmarks !== 'undefined' ? tetrisBookmarks : 0 %>개 <span style="font-weight: normal; color: var(--text-secondary);">(하루 최대 10개)</span></span> -->
234 </div>
235 </div>
236 </div>
237
238 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
239 <div class="section-title"><i class="bi bi-gear-fill"></i> 관리자 패널: 계정 관리</div>
240 <div class="admin-card">
241 <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 15px;">
242 <span style="font-size: 0.75rem; color: var(--accent-color); letter-spacing: 3px; font-weight: 700;">USER LIST</span>
243 <span style="font-size: 0.8rem; color: var(--text-secondary);"><%= users.length %>명</span>
244 </div>
245 <div style="margin-bottom: 15px;">
246 <div style="position: relative;">
247 <i class="bi bi-search" style="position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--text-secondary); font-size: 0.85rem;"></i>
248 <input type="text" id="account-search" placeholder="유저 검색..." oninput="searchAccounts()" 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;">
249 </div>
250 </div>
251 <div class="table-responsive">
252 <table class="table table-striped table-borderless mb-0">
253 <thead>
254 <tr>
255 <th>아이디 (닉네임)</th>
256 <th style="white-space: nowrap; text-align: right;">액션</th>
257 </tr>
258 </thead>
259 <tbody id="account-list">
260 <% users.forEach(user => { %>
261 <tr class="account-row" data-username="<%= user.username %>">
262 <td style="display:flex; align-items:center; gap:8px;">
263 <% if (user.profileImage) { %>
264 <img src="<%= user.profileImage %>" style="width:24px; height:24px; border-radius:50%; object-fit:cover; border:1px solid var(--border-color);">
265 <% } %>
266 <%= user.username %>
267 </td>
268 <td style="text-align: right;">
269 <div style="display:flex; gap:6px; align-items:center; flex-wrap:nowrap; justify-content:flex-end;">
270 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
271 <span style="color: var(--text-secondary);">(관리자)</span>
272 <% } else { %>
273 <form action="/hinana/delete-user" method="POST"
274 data-confirm="<%= user.username %> 계정을 정말로 삭제하시겠습니까?">
275 <input type="hidden" name="usernameToDelete" value="<%= user.username %>">
276 <button type="submit" class="btn btn-sm" style="color: var(--danger-color); border: 1px solid var(--danger-color); white-space: nowrap;">
277 <i class="bi bi-trash-fill"></i> 삭제
278 </button>
279 </form>
280 <% } %>
281 <% if (user.profileImage) { %>
282 <button class="btn btn-sm" style="color: var(--danger-color); border: 1px solid var(--danger-color); white-space: nowrap;" onclick="deleteProfilePic('<%= user.username %>')">
283 <i class="bi bi-person-x-fill"></i> 프로필 삭제
284 </button>
285 <% } %>
286 </div>
287 </td>
288 </tr>
289 <% }); %>
290 </tbody>
291 </table>
292 </div>
293 <div class="pagination-bar" id="account-pagination"></div>
294 </div>
295 <% } %>
296
297 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
298 <div class="admin-card" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:15px;">
299 <div>
300 <h5>오늘 올라온 글</h5>
301 <p style="font-size:0.8rem; color:var(--text-secondary); margin:0 0 6px;">오늘(자정 기준) 공개 게시글 수</p>
302 <span id="today-count-val" style="font-size:1.6rem; font-weight:800; color:var(--accent-color);">—</span>
303 <span style="font-size:0.85rem; color:var(--text-secondary); margin-left:4px;">개</span>
304 </div>
305 <button class="btn btn-sm" style="border:1px solid var(--border-color); color:var(--text-secondary);" onclick="refreshTodayCount()">
306 <i class="bi bi-arrow-clockwise"></i> 새로고침
307 </button>
308 </div>
309 <% } %>
310
311 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
312 <div class="admin-card" id="binarae-signal">
313 <div style="display:flex; justify-content:space-between; align-items:flex-start; gap:15px; flex-wrap:wrap;">
314 <div>
315 <h5>오늘의 신호</h5>
316 <p style="font-size:0.8rem; color:var(--text-secondary); margin:0;">info 페이지의 상태 문구를 기분 문장으로 갱신합니다.</p>
317 </div>
318 <span class="signal-chip">
319 <i class="bi bi-activity me-1"></i>
320 <%= (typeof binaraeSignal !== 'undefined' && binaraeSignal.statusText) ? binaraeSignal.statusText : '파릇하게 기록 중' %>
321 </span>
322 </div>
323 <div class="signal-preview">
324 <span class="signal-chip"><%= (typeof binaraeSignal !== 'undefined' && binaraeSignal.terminalCommand) ? binaraeSignal.terminalCommand : 'mood --fresh' %></span>
325 <span class="signal-chip"><%= (typeof binaraeSignal !== 'undefined' && binaraeSignal.terminalText) ? binaraeSignal.terminalText : 'green signal, memory online' %></span>
326 <% if (typeof binaraeSignal !== 'undefined' && binaraeSignal.keywords && binaraeSignal.keywords.length) { %>
327 <% binaraeSignal.keywords.forEach(function(keyword) { %>
328 <span class="signal-chip">#<%= keyword %></span>
329 <% }); %>
330 <% } %>
331 <% if (typeof binaraeSignal !== 'undefined' && binaraeSignal.analyzedBy) { %>
332 <span class="signal-chip"><%= binaraeSignal.analyzedBy === 'openai' ? 'AI 분석' : '로컬 분석' %></span>
333 <% } %>
334 </div>
335 <form class="signal-form" action="/hinana/admin/binarae-signal" method="POST">
336 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
337 <textarea class="signal-input" name="signal_text" maxlength="240" placeholder="예: 오늘은 살짝 졸리지만 차분하게 기록을 정리하고 싶어."><%= (typeof binaraeSignal !== 'undefined' && binaraeSignal.sourceText) ? binaraeSignal.sourceText : '' %></textarea>
338 <button type="submit" class="btn btn-sm" style="color: var(--accent-color); border: 1px solid var(--accent-color); font-weight: 700; min-height:38px;">
339 <i class="bi bi-magic"></i> 분석 저장
340 </button>
341 </form>
342 </div>
343
344 <% } %>
345
346 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
347 <div class="section-title"><i class="bi bi-speedometer2"></i> 관리자 패널: 운영 도구</div>
348 <div class="admin-card" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:15px;">
349 <div>
350 <h5>Voicepeak</h5>
351 <p style="font-size:0.8rem; color:var(--text-secondary); margin:0;">일본어 원문을 Koharu Rikka 음성으로 생성합니다.</p>
352 </div>
353 <a href="/hinana/voicepeak" class="btn btn-sm" style="color: var(--accent-color); border: 1px solid var(--accent-color); font-weight: 600;">
354 <i class="bi bi-soundwave"></i> 열기
355 </a>
356 </div>
357 <div class="admin-card" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:15px;">
358 <div>
359 <h5>통합 모니터링</h5>
360 <p style="font-size:0.8rem; color:var(--text-secondary); margin:0;">기능 토글, API 연동 상태, 데이터 파일 상태를 한 곳에서 확인합니다.</p>
361 </div>
362 <a href="/hinana/monitor" class="btn btn-sm" style="color: var(--accent-color); border: 1px solid var(--accent-color); font-weight: 600;">
363 <i class="bi bi-speedometer2"></i> 열기
364 </a>
365 </div>
366 <div class="admin-card" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:15px;">
367 <div><h5>보안 관제</h5><p style="font-size:0.8rem; color:var(--text-secondary); margin:0;">요청 현황, 스캐너 경로와 IP 차단 상태를 관리합니다.</p></div>
368 <a href="/hinana/security" class="btn btn-sm" style="color:var(--accent-color); border:1px solid var(--accent-color); font-weight:600;"><i class="bi bi-shield-check"></i> 열기</a>
369 </div>
370 <div class="admin-card" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:15px;">
371 <div><h5>백업 센터</h5><p style="font-size:0.8rem; color:var(--text-secondary); margin:0;">자료 유형별 ZIP 내보내기와 검증 후 복원을 실행합니다.</p></div>
372 <a href="/hinana/backups" class="btn btn-sm" style="color:var(--accent-color); border:1px solid var(--accent-color); font-weight:600;"><i class="bi bi-archive"></i> 열기</a>
373 </div>
374 <div class="admin-card" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:15px;">
375 <div><h5>아카이브 통합 검색</h5><p style="font-size:0.8rem; color:var(--text-secondary); margin:0;">블로그, 노트, 일본어 라운지와 음성 기록을 함께 찾습니다.</p></div>
376 <a href="/hinana/archive-search" class="btn btn-sm" style="color:var(--accent-color); border:1px solid var(--accent-color); font-weight:600;"><i class="bi bi-search"></i> 열기</a>
377 </div>
378 <% } %>
379
380 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
381 <div class="section-title"><i class="bi bi-shield-lock-fill"></i> 보안 로그</div>
382 <div class="admin-card">
383 <div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:16px;">
384 <div>
385 <h5 style="margin-bottom:4px;">최근 로그인 및 기능 사용 기록</h5>
386 <p style="font-size:0.8rem; color:var(--text-secondary); margin:0;">비밀번호와 가입 코드는 기록하지 않습니다.</p>
387 </div>
388 <span style="font-size:0.8rem; color:var(--text-secondary); white-space:nowrap;"><%= (typeof securityLogs !== 'undefined' && securityLogs) ? securityLogs.length : 0 %>개 표시</span>
389 </div>
390 <% if (typeof securityLogs !== 'undefined' && securityLogs && securityLogs.length) { %>
391 <div class="security-log-list" id="security-log-list">
392 <% securityLogs.forEach(function(log) { %>
393 <div class="security-log-row security-log-item">
394 <div class="log-time">
395 <i class="bi bi-clock"></i>
396 <%= typeof fmtDate === 'function' ? fmtDate(log.createdAt) : log.createdAt %>
397 </div>
398 <div class="log-main">
399 <div class="log-head">
400 <span class="log-badge log-<%= log.type %>"><%= log.type %></span>
401 <span class="log-action"><%= log.action %></span>
402 </div>
403 <div class="log-meta">
404 사용자: <strong><%= log.actor || '알 수 없음' %></strong>
405 <% if (log.target) { %> · 대상: <strong><%= log.target %></strong><% } %>
406 <% if (log.ip) { %> · IP: <%= log.ip %><% } %>
407 <% if (log.path) { %> · 경로: <%= log.path %><% } %>
408 </div>
409 <% if (log.userAgent) { %>
410 <div class="log-detail">User-Agent: <%= log.userAgent %></div>
411 <% } %>
412 <% if (log.detail) { %>
413 <div class="log-detail"><%= log.detail %></div>
414 <% } %>
415 </div>
416 </div>
417 <% }); %>
418 </div>
419 <div class="pagination-bar" id="security-log-pagination"></div>
420 <% } else { %>
421 <p class="log-empty">아직 기록된 보안 로그가 없습니다.</p>
422 <% } %>
423 </div>
424 <% } %>
425
426 <div class="section-title"><i class="bi bi-sliders"></i> 설정</div>
427
428 <div class="admin-card" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:15px;">
429 <div>
430 <h5>오늘의 글 알림</h5>
431 <p style="font-size:0.8rem; color:var(--text-secondary); margin:0;">매일 오후 6시 기준, 새로 올라온 글이 있으면 앱 알림을 보내드립니다.</p>
432 <% if (typeof dailyPostNotif !== 'undefined' && dailyPostNotif) { %>
433 <span class="badge badge-on mt-2 d-inline-block"><i class="bi bi-check-circle-fill"></i> 활성화</span>
434 <% } else { %>
435 <span class="badge badge-off mt-2 d-inline-block"><i class="bi bi-x-circle-fill"></i> 비활성화</span>
436 <% } %>
437 </div>
438 <form action="/hinana/toggle-daily-post-notif" method="POST">
439 <% if (typeof dailyPostNotif !== 'undefined' && dailyPostNotif) { %>
440 <button type="submit" class="btn btn-sm" style="color: var(--danger-color); border: 1px solid var(--danger-color);">
441 <i class="bi bi-bell-slash-fill"></i> 끄기
442 </button>
443 <% } else { %>
444 <button type="submit" class="btn btn-sm" style="color: #16a34a; border: 1px solid #16a34a;">
445 <i class="bi bi-bell-fill"></i> 켜기
446 </button>
447 <% } %>
448 </form>
449 </div>
450
451 <div style="margin-top: 30px;">
452 <a href="/hinana/index" class="btn-back">
453 <i class="bi bi-arrow-left"></i> 게시판으로 돌아가기
454 </a>
455 </div>
456 </div>
457
458 <div class="footer-area">
459 <img src="/image/sign.png" alt="sign"><br>
460 <strong>비나래 ARCHIVE</strong><br>
461 X - @NoctchillHinana<br>
462 &copy; 2024~2026. 비나래 | hinana.moe
463 </div>
464 <script>
465 function refreshTodayCount() {
466 fetch('/api/today-count')
467 .then(r => r.json())
468 .then(d => {
469 var el = document.getElementById('today-count-val');
470 if (el) el.textContent = d.count ?? '?';
471 })
472 .catch(function() {});
473 }
474 refreshTodayCount();
475 </script>
476 <script>
477 // 계정 관리 검색 + 페이지네이션
478 (function() {
479 var PER_PAGE = 15;
480 var allRows = Array.from(document.querySelectorAll('#account-list .account-row'));
481 if (allRows.length === 0) return;
482 var filteredRows = allRows.slice();
483
484 function searchAccounts() {
485 var query = document.getElementById('account-search').value.trim().toLowerCase();
486 filteredRows = allRows.filter(function(row) {
487 var username = (row.getAttribute('data-username') || '').toLowerCase();
488 return username.indexOf(query) !== -1;
489 });
490 renderPage(1);
491 }
492
493 function renderPage(page) {
494 var totalPages = Math.ceil(filteredRows.length / PER_PAGE) || 1;
495 var start = (page - 1) * PER_PAGE;
496 var end = start + PER_PAGE;
497
498 allRows.forEach(function(row) { row.style.display = 'none'; });
499 filteredRows.forEach(function(row, i) {
500 row.style.display = (i >= start && i < end) ? '' : 'none';
501 });
502
503 var pag = document.getElementById('account-pagination');
504 if (totalPages <= 1) { pag.innerHTML = ''; return; }
505
506 var html = '';
507 html += '<button class="page-btn" onclick="accountPage(' + Math.max(1, page - 1) + ')" ' + (page === 1 ? 'disabled' : '') + '><i class="bi bi-chevron-left"></i></button>';
508 for (var p = 1; p <= totalPages; p++) {
509 html += '<button class="page-btn' + (p === page ? ' active' : '') + '" onclick="accountPage(' + p + ')">' + p + '</button>';
510 }
511 html += '<button class="page-btn" onclick="accountPage(' + Math.min(totalPages, page + 1) + ')" ' + (page === totalPages ? 'disabled' : '') + '><i class="bi bi-chevron-right"></i></button>';
512 pag.innerHTML = html;
513 }
514
515 window.accountPage = renderPage;
516 window.searchAccounts = searchAccounts;
517 renderPage(1);
518 })();
519
520 // 보안 로그 30개 단위 페이지네이션
521 (function() {
522 var PER_PAGE = 30;
523 var rows = Array.from(document.querySelectorAll('#security-log-list .security-log-item'));
524 if (rows.length === 0) return;
525
526 function renderSecurityLogPage(page) {
527 var totalPages = Math.ceil(rows.length / PER_PAGE) || 1;
528 var safePage = Math.min(Math.max(page, 1), totalPages);
529 var start = (safePage - 1) * PER_PAGE;
530 var end = start + PER_PAGE;
531
532 rows.forEach(function(row, i) {
533 row.style.display = (i >= start && i < end) ? '' : 'none';
534 });
535
536 var pag = document.getElementById('security-log-pagination');
537 if (!pag) return;
538 if (totalPages <= 1) {
539 pag.innerHTML = '';
540 return;
541 }
542
543 var html = '';
544 html += '<button class="page-btn" onclick="securityLogPage(' + Math.max(1, safePage - 1) + ')" ' + (safePage === 1 ? 'disabled' : '') + '><i class="bi bi-chevron-left"></i></button>';
545 for (var p = 1; p <= totalPages; p++) {
546 html += '<button class="page-btn' + (p === safePage ? ' active' : '') + '" onclick="securityLogPage(' + p + ')">' + p + '</button>';
547 }
548 html += '<button class="page-btn" onclick="securityLogPage(' + Math.min(totalPages, safePage + 1) + ')" ' + (safePage === totalPages ? 'disabled' : '') + '><i class="bi bi-chevron-right"></i></button>';
549 pag.innerHTML = html;
550 }
551
552 window.securityLogPage = renderSecurityLogPage;
553 renderSecurityLogPage(1);
554 })();
555
556 async function deleteProfilePic(username) {
557 if (!await showConfirm(username + '의 프로필 사진을 삭제하시겠습니까?')) return;
558 try {
559 const res = await fetch('/hinana/admin/delete-profile-pic', {
560 method: 'POST',
561 headers: { 'Content-Type': 'application/json' },
562 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
563 });
564 const data = await res.json();
565 await showAlert(data.message);
566 if (data.success) location.reload();
567 } catch (e) {
568 await showAlert('오류가 발생했습니다.');
569 }
570 }
571
572 async function deleteMyProfilePic() {
573 if (!await showConfirm('프로필 사진을 삭제하시겠습니까?')) return;
574 try {
575 const res = await fetch('/hinana/shop/delete-profile-pic', {
576 method: 'POST',
577 headers: { 'Content-Type': 'application/json' }
578 });
579 const data = await res.json();
580 await showAlert(data.message);
581 if (data.success) location.reload();
582 } catch (e) {
583 await showAlert('오류가 발생했습니다.');
584 }
585 }
586 </script>
587 <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); }</script>
588 </body>
589 </html>
590