Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
view/hinana/discordArchive.ejs
공개 가능
1
<!DOCTYPE html>
2
<html lang="ko">
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="<%= theme === 'dark' ? '#0e1218' : '#f7f9fb' %>">
9
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
10
<title>Discord AI Chat 아카이브 - 비나래 아카이브</title>
11
<link rel="stylesheet" href="/vendors/bootstrap/css/bootstrap.min.css">
12
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
13
<script src="/js/popup.js"></script>
14
<style>
15
:root{--font:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans KR",sans-serif;--bg:#f7f9fb;--paper:#fff;--panel:rgba(255,255,255,.88);--text:#18202b;--muted:#687586;--accent:#c5a059;--accent2:#d45f86;--line:rgba(24,32,43,.12);--header:rgba(247,249,251,.86);--danger:#c43737;--shadow:0 20px 58px rgba(27,39,56,.1)}
16
body.dark-mode{--bg:#0e1218;--paper:#151b24;--panel:rgba(20,27,37,.9);--text:#eef4fb;--muted:#a3afbd;--accent:#d4b47a;--accent2:#ee89aa;--line:rgba(238,244,251,.13);--header:rgba(14,18,24,.88);--danger:#ff7777;--shadow:0 22px 64px rgba(0,0,0,.4)}
17
*{box-sizing:border-box}html,body{margin:0;min-height:100%;font-family:var(--font);background:var(--bg);color:var(--text)}body{padding-top:62px}a{color:inherit;text-decoration:none}button,input,select{font:inherit}
18
.global-header{position:fixed;inset:0 0 auto;z-index:3000;min-height:62px;padding:0 22px;display:flex;align-items:center;justify-content:space-between;gap:18px;background:var(--header);border-bottom:1px solid var(--line);backdrop-filter:blur(18px);box-shadow:0 10px 30px rgba(27,39,56,.07)}
19
.header-logo{height:30px;width:auto;mix-blend-mode:multiply}.dark-mode .header-logo{mix-blend-mode:screen}.header-nav{position:absolute;left:50%;transform:translateX(-50%);display:flex;gap:16px}.nav-link{padding:8px 10px;border-radius:7px;color:var(--muted);font-size:.9rem;font-weight:800}.nav-link:hover,.nav-link.active{color:var(--text);background:color-mix(in srgb,var(--paper) 70%,transparent)}
20
.header-controls{display:flex;align-items:center;gap:8px}.icon-btn{width:36px;height:36px;display:inline-grid;place-items:center;border:1px solid var(--line);border-radius:50%;background:var(--panel);color:var(--muted)}.account-link{padding:8px 11px;border:1px solid var(--line);border-radius:7px;font-size:.8rem;font-weight:800;color:var(--muted)}
21
.shell{width:min(1180px,calc(100% - 36px));margin:0 auto;padding:42px 0 70px}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;padding-bottom:22px;border-bottom:1px solid var(--line)}.eyebrow{font-size:.7rem;font-weight:900;color:var(--accent);text-transform:uppercase}.page-head h1{font-size:2rem;margin:5px 0 7px;font-weight:950}.page-head p{margin:0;color:var(--muted);font-size:.9rem}
22
.total-state{display:flex;align-items:center;gap:10px;padding:10px 13px;border:1px solid var(--line);background:var(--panel);border-radius:7px;font-size:.8rem}.total-state i{color:#5865f2;font-size:1.1rem}.total-state strong{display:block;color:var(--text)}
23
.workspace{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:20px;margin-top:22px;align-items:start}.list-area{min-width:0}.side-panel{background:var(--paper);border:1px solid var(--line);border-radius:7px;padding:17px;margin-bottom:12px}.panel-label{display:block;font-size:.69rem;font-weight:900;color:var(--accent);text-transform:uppercase;margin-bottom:8px}.side-panel h2{font-size:.95rem;font-weight:900;margin:0 0 8px}.side-panel p{font-size:.78rem;color:var(--muted);line-height:1.55;margin:0}
24
.filter-bar{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;padding:15px;background:var(--paper);border:1px solid var(--line);border-radius:7px;margin-bottom:16px;box-shadow:var(--shadow)}
25
.filter-field{display:flex;flex-direction:column;gap:5px;min-width:0;flex:1 1 160px}.filter-field label{font-size:.69rem;font-weight:900;color:var(--accent);text-transform:uppercase}.filter-field select{width:100%;border:1px solid var(--line);border-radius:5px;background:var(--bg);color:var(--text);padding:9px 11px;font-size:.85rem}.filter-field select:focus{outline:2px solid color-mix(in srgb,var(--accent) 30%,transparent);border-color:var(--accent)}
26
.filter-apply{min-height:40px;border:0;border-radius:5px;padding:0 16px;background:var(--accent);color:var(--ui-on-accent, #fff);font-weight:900;white-space:nowrap}.filter-reset{min-height:40px;display:inline-flex;align-items:center;padding:0 13px;border:1px solid var(--line);border-radius:5px;color:var(--muted);font-size:.82rem;font-weight:800}.filter-reset:hover{color:var(--text)}
27
.entry{background:var(--paper);border:1px solid var(--line);border-radius:7px;margin-bottom:12px;overflow:hidden}
28
.entry-head{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;padding:12px 16px;border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--accent) 4%,transparent);font-size:.78rem}
29
.entry-user{font-weight:900;color:var(--text)}.entry-guild{display:inline-flex;align-items:center;gap:5px;color:#5865f2;font-weight:800}.entry-meta{color:var(--muted);margin-left:auto;display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center}
30
.badge-slot{padding:2px 8px;border:1px solid var(--line);border-radius:99px;font-size:.68rem;font-weight:800;color:var(--muted)}
31
.badge-hidden{padding:2px 8px;border:1px solid color-mix(in srgb,var(--danger) 45%,var(--line));border-radius:99px;font-size:.68rem;font-weight:800;color:var(--danger);background:color-mix(in srgb,var(--danger) 7%,transparent)}
32
.entry.hidden-entry{opacity:.6;border-style:dashed}
33
.entry-actions{display:flex;gap:6px;margin-left:10px}.action-btn{border:1px solid var(--line);background:var(--bg);color:var(--muted);border-radius:5px;padding:4px 10px;font-size:.72rem;font-weight:800;white-space:nowrap}.action-btn:hover{color:var(--text);border-color:var(--accent)}.action-btn.danger:hover{color:var(--danger);border-color:var(--danger)}
34
.entry-body{padding:14px 16px}.qa-label{font-size:.66rem;font-weight:900;color:var(--accent);text-transform:uppercase;margin-bottom:4px}.qa-label.answer{color:var(--accent2)}
35
.qa-text{white-space:pre-wrap;font-size:.86rem;line-height:1.65;word-break:break-word}
36
.qa-question{margin-bottom:12px;padding-bottom:12px;border-bottom:1px dashed var(--line)}
37
.qa-answer .qa-text{max-height:170px;overflow:hidden;position:relative}
38
.qa-answer.open .qa-text{max-height:none}
39
.qa-answer .qa-text.clamped::after{content:'';position:absolute;inset:auto 0 0;height:56px;background:linear-gradient(transparent,var(--paper))}
40
.qa-answer.open .qa-text.clamped::after{display:none}
41
.expand-btn{margin-top:8px;border:1px solid var(--line);background:var(--bg);color:var(--muted);border-radius:5px;padding:5px 12px;font-size:.75rem;font-weight:800}.expand-btn:hover{color:var(--text);border-color:var(--accent)}
42
.empty{padding:60px 20px;text-align:center;background:var(--paper);border:1px dashed var(--line);border-radius:7px;color:var(--muted)}.empty i{font-size:2rem;display:block;margin-bottom:12px;color:var(--accent)}
43
.pagination-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px;margin-top:22px}
44
.page-link2{min-width:36px;height:36px;display:inline-grid;place-items:center;padding:0 10px;border:1px solid var(--line);border-radius:5px;background:var(--paper);color:var(--muted);font-size:.82rem;font-weight:800}
45
.page-link2:hover{color:var(--text);border-color:var(--accent)}.page-link2.current{background:var(--accent);border-color:var(--accent);color:var(--ui-on-accent, #fff);pointer-events:none}.page-link2.disabled{opacity:.4;pointer-events:none}
46
.page-info{width:100%;text-align:center;margin-top:8px;color:var(--muted);font-size:.76rem}
47
.side-links{display:grid;gap:1px}.side-links a{display:flex;justify-content:space-between;align-items:center;padding:9px 0;border-bottom:1px solid var(--line);font-size:.8rem;color:var(--muted)}.side-links a:last-child{border:0}.side-links a:hover{color:var(--text)}
48
.stat-row{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--line);font-size:.8rem;color:var(--muted)}.stat-row:last-child{border:0}.stat-row strong{color:var(--text)}
49
.site-footer{width:min(1180px,calc(100% - 36px));margin:0 auto 22px;padding:25px 0 4px;border-top:1px solid var(--line);display:flex;align-items:flex-end;justify-content:space-between;gap:24px;color:var(--muted);font-size:.76rem}.footer-brand{display:flex;align-items:center;gap:13px}.footer-logo{width:90px;mix-blend-mode:multiply}.dark-mode .footer-logo{mix-blend-mode:screen}.footer-title{font-weight:950;color:var(--text);font-size:.9rem}.footer-links{display:flex;justify-content:flex-end;gap:14px;margin-bottom:7px}.footer-links a:hover{color:var(--text)}.footer-copy{text-align:right}.footer-version{margin-right:10px}
50
@media(max-width:850px){body{padding-top:108px}.global-header{min-height:108px;align-items:flex-start;padding:11px 16px}.header-nav{top:60px;width:100%;justify-content:center;border-top:1px solid var(--line);padding-top:7px}.workspace{grid-template-columns:1fr}.sidebar{display:grid;grid-template-columns:1fr 1fr;gap:10px}.side-panel{margin:0}.page-head{align-items:flex-start}.site-footer{align-items:flex-start}}
51
@media(max-width:600px){.shell,.site-footer{width:min(100% - 24px,1180px)}.shell{padding-top:28px}.page-head{display:block}.total-state{margin-top:15px}.page-head h1{font-size:1.55rem}.filter-bar{display:block}.filter-field{margin-bottom:10px}.filter-apply,.filter-reset{width:100%;justify-content:center;margin-top:6px}.entry-meta{margin-left:0}.form-foot,.site-footer{display:block}.sidebar{grid-template-columns:1fr}.footer-links{justify-content:flex-start;margin-top:18px}.footer-copy{text-align:left}.account-link{display:none}}
52
</style>
53
<link rel="stylesheet" href="/css/site-ui.css?v=3">
54
<link rel="stylesheet" href="/css/site-app.css?v=1">
55
</head>
56
<body data-ui-page="discordArchive" class="ui-shell <%= theme === 'dark' ? 'dark-mode' : '' %>">
57
<%- include('partials/site-header', { uiPage: 'discordArchive' }) %>
58
59
<%
60
// 페이징 링크에 현재 필터를 유지하기 위한 쿼리스트링 생성기
61
function buildQuery(page) {
62
const params = new URLSearchParams();
63
if (selectedGuild) params.set('guild', selectedGuild);
64
if (selectedUser) params.set('user', selectedUser);
65
params.set('pageSize', String(result.pageSize));
66
params.set('page', String(page));
67
return '/hinana/discord-archive?' + params.toString();
68
}
69
%>
70
71
<main id="ui-content" tabindex="-1" class="shell">
72
<section class="page-head">
73
<div><div class="eyebrow">Discord AI Chat Archive</div><h1>대화 기록 아카이브</h1><p>/히나나 명령어의 전체 대화 기록을 서버별·사용자별로 열람합니다.</p></div>
74
<div class="total-state"><i class="bi bi-discord"></i><div><strong><%= result.total.toLocaleString() %>건</strong><span>필터 결과 기준</span></div></div>
75
</section>
76
77
<div class="workspace">
78
<section class="list-area">
79
<form class="filter-bar" method="GET" action="/hinana/discord-archive">
80
<div class="filter-field">
81
<label for="guildFilter">서버</label>
82
<select id="guildFilter" name="guild">
83
<option value="">전체 서버</option>
84
<% filters.guilds.forEach(function(g){ %>
85
<option value="<%= g.id %>" <%= selectedGuild === g.id ? 'selected' : '' %>><%= g.name %> (<%= g.count %>건)</option>
86
<% }); %>
87
</select>
88
</div>
89
<div class="filter-field">
90
<label for="userFilter">사용자</label>
91
<select id="userFilter" name="user">
92
<option value="">전체 사용자</option>
93
<% filters.users.forEach(function(u){ %>
94
<option value="<%= u.id %>" <%= selectedUser === u.id ? 'selected' : '' %>><%= u.name %> (<%= u.count %>건)</option>
95
<% }); %>
96
</select>
97
</div>
98
<div class="filter-field" style="flex:0 1 130px">
99
<label for="pageSizeFilter">표시 건수</label>
100
<select id="pageSizeFilter" name="pageSize">
101
<% pageSizes.forEach(function(size){ %>
102
<option value="<%= size %>" <%= result.pageSize === size ? 'selected' : '' %>><%= size %>건씩</option>
103
<% }); %>
104
</select>
105
</div>
106
<button class="filter-apply" type="submit"><i class="bi bi-funnel-fill me-1"></i>적용</button>
107
<a class="filter-reset" href="/hinana/discord-archive">초기화</a>
108
</form>
109
110
<% if (result.entries.length === 0) { %>
111
<div class="empty"><i class="bi bi-chat-square-dots"></i>조건에 맞는 대화 기록이 없습니다.<br>봇이 새 대화를 저장하면 이곳에 표시됩니다.</div>
112
<% } else { %>
113
<% result.entries.forEach(function(entry){ %>
114
<article class="entry <%= entry.isHidden ? 'hidden-entry' : '' %>">
115
<div class="entry-head">
116
<span class="entry-user"><i class="bi bi-person-fill me-1"></i><%= entry.discordUsername %></span>
117
<span class="entry-guild"><i class="bi bi-<%= entry.guildId ? 'server' : 'envelope-fill' %>"></i><%= entry.guildName || 'DM' %></span>
118
<span class="badge-slot"><%= entry.personaName || ('슬롯 ' + entry.personaSlot) %></span>
119
<% if (entry.isHidden) { %><span class="badge-hidden"><i class="bi bi-eye-slash-fill me-1"></i>숨김</span><% } %>
120
<span class="entry-meta">
121
<span><i class="bi bi-cpu me-1"></i><%= entry.model %></span>
122
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
123
<span><i class="bi bi-clock me-1"></i><%= new Date(entry.createdAt).toLocaleString('ko-KR') %></span>
124
</span>
125
<% if (isAdmin) { %>
126
<span class="entry-actions">
127
<button type="button" class="action-btn hide-btn" data-id="<%= entry.id %>" data-hidden="<%= entry.isHidden ? '1' : '0' %>"><i class="bi bi-eye<%= entry.isHidden ? '' : '-slash' %> me-1"></i><%= entry.isHidden ? '숨김 해제' : '숨김' %></button>
128
<button type="button" class="action-btn danger delete-btn" data-id="<%= entry.id %>"><i class="bi bi-trash me-1"></i>삭제</button>
129
</span>
130
<% } %>
131
</div>
132
<div class="entry-body">
133
<div class="qa-question"><div class="qa-label">Question</div><div class="qa-text"><%= entry.question %></div></div>
134
<div class="qa-answer"><div class="qa-label answer">Answer</div><div class="qa-text"><%= entry.answer %></div><button type="button" class="expand-btn" hidden><i class="bi bi-chevron-down me-1"></i>전체 보기</button></div>
135
</div>
136
</article>
137
<% }); %>
138
139
<nav class="pagination-bar" aria-label="페이지 이동">
140
<a class="page-link2 <%= result.page <= 1 ? 'disabled' : '' %>" href="<%= buildQuery(1) %>"><i class="bi bi-chevron-double-left"></i></a>
141
<a class="page-link2 <%= result.page <= 1 ? 'disabled' : '' %>" href="<%= buildQuery(result.page - 1) %>"><i class="bi bi-chevron-left"></i></a>
142
<%
143
const windowSize = 5;
144
let startPage = Math.max(1, result.page - Math.floor(windowSize / 2));
145
let endPage = Math.min(result.totalPages, startPage + windowSize - 1);
146
startPage = Math.max(1, endPage - windowSize + 1);
147
for (let p = startPage; p <= endPage; p++) { %>
148
<a class="page-link2 <%= p === result.page ? 'current' : '' %>" href="<%= buildQuery(p) %>"><%= p %></a>
149
<% } %>
150
<a class="page-link2 <%= result.page >= result.totalPages ? 'disabled' : '' %>" href="<%= buildQuery(result.page + 1) %>"><i class="bi bi-chevron-right"></i></a>
151
<a class="page-link2 <%= result.page >= result.totalPages ? 'disabled' : '' %>" href="<%= buildQuery(result.totalPages) %>"><i class="bi bi-chevron-double-right"></i></a>
152
<div class="page-info"><%= result.page %> / <%= result.totalPages %> 페이지 · 총 <%= result.total.toLocaleString() %>건</div>
153
</nav>
154
<% } %>
155
</section>
156
157
<aside class="sidebar">
158
<section class="side-panel">
159
<span class="panel-label">Statistics</span>
160
<h2>아카이브 현황</h2>
161
<div class="stat-row"><span>필터 결과</span><strong><%= result.total.toLocaleString() %>건</strong></div>
162
<div class="stat-row"><span>서버 수</span><strong><%= filters.guilds.length %>개</strong></div>
163
<div class="stat-row"><span>사용자 수</span><strong><%= filters.users.length %>명</strong></div>
164
</section>
165
<% if (isAdmin) { %>
166
<section class="side-panel">
167
<span class="panel-label">Admin</span>
168
<h2><%= username %></h2>
169
<div class="side-links">
170
<a href="/hinana/admin"><span><i class="bi bi-gear me-1"></i> 관리자 페이지</span><i class="bi bi-chevron-right"></i></a>
171
<a href="/hinana/security"><span><i class="bi bi-shield-lock me-1"></i> 보안 대시보드</span><i class="bi bi-chevron-right"></i></a>
172
<a href="/hinana/backups"><span><i class="bi bi-archive me-1"></i> 백업 관리</span><i class="bi bi-chevron-right"></i></a>
173
</div>
174
</section>
175
<% } else { %>
176
<section class="side-panel">
177
<span class="panel-label">Account</span>
178
<h2><%= username || '게스트' %></h2>
179
<div class="side-links">
180
<% if (username) { %>
181
<a href="/hinana/userInfo"><span><i class="bi bi-person me-1"></i> 계정 정보</span><i class="bi bi-chevron-right"></i></a>
182
<% } else { %>
183
<a href="/login?redirect=<%= encodeURIComponent('/hinana/discord-archive') %>"><span><i class="bi bi-box-arrow-in-right me-1"></i> 로그인</span><i class="bi bi-chevron-right"></i></a>
184
<% } %>
185
<a href="/hinana/lounge"><span><i class="bi bi-grid me-1"></i> 라운지</span><i class="bi bi-chevron-right"></i></a>
186
<a href="/hinana/persona"><span><i class="bi bi-person-badge me-1"></i> 페르소나 설정</span><i class="bi bi-chevron-right"></i></a>
187
</div>
188
</section>
189
<% } %>
190
<section class="side-panel">
191
<span class="panel-label">Info</span>
192
<h2>기록 안내</h2>
193
<p>/히나나 명령어가 성공적으로 응답한 대화만 저장됩니다. DM 대화는 서버 필터에서 'DM'으로 표시됩니다.</p>
194
</section>
195
</aside>
196
</div>
197
</main>
198
199
<footer class="site-footer"><div class="footer-brand"><img src="/image/sign.png" alt="비나래" class="footer-logo"><div><div class="footer-title">비나래 라운지</div><div>Archive My Lounge</div></div></div><div><div class="footer-links"><a href="/hinana/index">Archive</a><a href="/hinana/lounge">Lounge</a><a href="/hinana/gallery">Gallery</a><a href="/hinana/plaza">Plaza</a><a href="https://x.com/NoctchillHinana" target="_blank" rel="noreferrer">X</a></div><div class="footer-copy"><span class="footer-version">Ver. 9.2.1.0 - Iochi Mari</span>© 2024~2026. 비나래 | hinana.moe</div></div></footer>
200
201
<script>
202
// 답변이 길어 잘린 경우에만 '전체 보기' 버튼 표시
203
document.querySelectorAll('.qa-answer').forEach(function(box){
204
const text = box.querySelector('.qa-text');
205
const btn = box.querySelector('.expand-btn');
206
if (!text || !btn) return;
207
if (text.scrollHeight > text.clientHeight + 4) {
208
text.classList.add('clamped');
209
btn.hidden = false;
210
btn.addEventListener('click', function(){
211
const open = box.classList.toggle('open');
212
btn.innerHTML = open
213
? '<i class="bi bi-chevron-up me-1"></i>접기'
214
: '<i class="bi bi-chevron-down me-1"></i>전체 보기';
215
});
216
}
217
});
218
// 관리자 전용: 대화 숨김/삭제
219
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
220
async function adminApi(url, body) {
221
const res = await fetch(url, {
222
method: 'POST',
223
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
224
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
225
});
226
const data = await res.json().catch(function(){ return {}; });
227
if (!res.ok || !data.success) throw new Error(data.message || '요청에 실패했습니다.');
228
return data;
229
}
230
document.querySelectorAll('.hide-btn').forEach(function(btn){
231
btn.addEventListener('click', async function(){
232
const isHidden = btn.dataset.hidden === '1';
233
try {
234
await adminApi('/api/admin/discord-archive/hide', { id: btn.dataset.id, hidden: !isHidden });
235
location.reload();
236
} catch (err) { showAlert(err.message); }
237
});
238
});
239
document.querySelectorAll('.delete-btn').forEach(function(btn){
240
btn.addEventListener('click', async function(){
241
if (!confirm('이 대화를 완전히 삭제할까요? 되돌릴 수 없습니다.')) return;
242
try {
243
await adminApi('/api/admin/discord-archive/delete', { id: btn.dataset.id });
244
location.reload();
245
} catch (err) { showAlert(err.message); }
246
});
247
});
248
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');
249
</script>
250
</body>
251
</html>
252