Public Source Viewer

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

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

Redacted View
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:#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:#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 </head>
54 <body class="<%= theme === 'dark' ? 'dark-mode' : '' %>">
55 <header class="global-header">
56 <a href="/hinana/lounge"><img src="/image/<%= theme === 'dark' ? 'lounge1.png' : 'lounge.png' %>" alt="비나래 라운지" class="header-logo"></a>
57 <nav class="header-nav" aria-label="주요 메뉴"><a href="/hinana/index" class="nav-link">Archive</a><a href="/hinana/lounge" class="nav-link active">Lounge</a><a href="/hinana/gallery" class="nav-link">Gallery</a><a href="/hinana/plaza" class="nav-link">Plaza</a></nav>
58 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
59 </header>
60
61 <%
62 // 페이징 링크에 현재 필터를 유지하기 위한 쿼리스트링 생성기
63 function buildQuery(page) {
64 const params = new URLSearchParams();
65 if (selectedGuild) params.set('guild', selectedGuild);
66 if (selectedUser) params.set('user', selectedUser);
67 params.set('pageSize', String(result.pageSize));
68 params.set('page', String(page));
69 return '/hinana/discord-archive?' + params.toString();
70 }
71 %>
72
73 <main class="shell">
74 <section class="page-head">
75 <div><div class="eyebrow">Discord AI Chat Archive</div><h1>대화 기록 아카이브</h1><p>/히나나 명령어의 전체 대화 기록을 서버별·사용자별로 열람합니다.</p></div>
76 <div class="total-state"><i class="bi bi-discord"></i><div><strong><%= result.total.toLocaleString() %>건</strong><span>필터 결과 기준</span></div></div>
77 </section>
78
79 <div class="workspace">
80 <section class="list-area">
81 <form class="filter-bar" method="GET" action="/hinana/discord-archive">
82 <div class="filter-field">
83 <label for="guildFilter">서버</label>
84 <select id="guildFilter" name="guild">
85 <option value="">전체 서버</option>
86 <% filters.guilds.forEach(function(g){ %>
87 <option value="<%= g.id %>" <%= selectedGuild === g.id ? 'selected' : '' %>><%= g.name %> (<%= g.count %>건)</option>
88 <% }); %>
89 </select>
90 </div>
91 <div class="filter-field">
92 <label for="userFilter">사용자</label>
93 <select id="userFilter" name="user">
94 <option value="">전체 사용자</option>
95 <% filters.users.forEach(function(u){ %>
96 <option value="<%= u.id %>" <%= selectedUser === u.id ? 'selected' : '' %>><%= u.name %> (<%= u.count %>건)</option>
97 <% }); %>
98 </select>
99 </div>
100 <div class="filter-field" style="flex:0 1 130px">
101 <label for="pageSizeFilter">표시 건수</label>
102 <select id="pageSizeFilter" name="pageSize">
103 <% pageSizes.forEach(function(size){ %>
104 <option value="<%= size %>" <%= result.pageSize === size ? 'selected' : '' %>><%= size %>건씩</option>
105 <% }); %>
106 </select>
107 </div>
108 <button class="filter-apply" type="submit"><i class="bi bi-funnel-fill me-1"></i>적용</button>
109 <a class="filter-reset" href="/hinana/discord-archive">초기화</a>
110 </form>
111
112 <% if (result.entries.length === 0) { %>
113 <div class="empty"><i class="bi bi-chat-square-dots"></i>조건에 맞는 대화 기록이 없습니다.<br>봇이 새 대화를 저장하면 이곳에 표시됩니다.</div>
114 <% } else { %>
115 <% result.entries.forEach(function(entry){ %>
116 <article class="entry <%= entry.isHidden ? 'hidden-entry' : '' %>">
117 <div class="entry-head">
118 <span class="entry-user"><i class="bi bi-person-fill me-1"></i><%= entry.discordUsername %></span>
119 <span class="entry-guild"><i class="bi bi-<%= entry.guildId ? 'server' : 'envelope-fill' %>"></i><%= entry.guildName || 'DM' %></span>
120 <span class="badge-slot"><%= entry.personaName || ('슬롯 ' + entry.personaSlot) %></span>
121 <% if (entry.isHidden) { %><span class="badge-hidden"><i class="bi bi-eye-slash-fill me-1"></i>숨김</span><% } %>
122 <span class="entry-meta">
123 <span><i class="bi bi-cpu me-1"></i><%= entry.model %></span>
124 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
125 <span><i class="bi bi-clock me-1"></i><%= new Date(entry.createdAt).toLocaleString('ko-KR') %></span>
126 </span>
127 <% if (isAdmin) { %>
128 <span class="entry-actions">
129 <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>
130 <button type="button" class="action-btn danger delete-btn" data-id="<%= entry.id %>"><i class="bi bi-trash me-1"></i>삭제</button>
131 </span>
132 <% } %>
133 </div>
134 <div class="entry-body">
135 <div class="qa-question"><div class="qa-label">Question</div><div class="qa-text"><%= entry.question %></div></div>
136 <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>
137 </div>
138 </article>
139 <% }); %>
140
141 <nav class="pagination-bar" aria-label="페이지 이동">
142 <a class="page-link2 <%= result.page <= 1 ? 'disabled' : '' %>" href="<%= buildQuery(1) %>"><i class="bi bi-chevron-double-left"></i></a>
143 <a class="page-link2 <%= result.page <= 1 ? 'disabled' : '' %>" href="<%= buildQuery(result.page - 1) %>"><i class="bi bi-chevron-left"></i></a>
144 <%
145 const windowSize = 5;
146 let startPage = Math.max(1, result.page - Math.floor(windowSize / 2));
147 let endPage = Math.min(result.totalPages, startPage + windowSize - 1);
148 startPage = Math.max(1, endPage - windowSize + 1);
149 for (let p = startPage; p <= endPage; p++) { %>
150 <a class="page-link2 <%= p === result.page ? 'current' : '' %>" href="<%= buildQuery(p) %>"><%= p %></a>
151 <% } %>
152 <a class="page-link2 <%= result.page >= result.totalPages ? 'disabled' : '' %>" href="<%= buildQuery(result.page + 1) %>"><i class="bi bi-chevron-right"></i></a>
153 <a class="page-link2 <%= result.page >= result.totalPages ? 'disabled' : '' %>" href="<%= buildQuery(result.totalPages) %>"><i class="bi bi-chevron-double-right"></i></a>
154 <div class="page-info"><%= result.page %> / <%= result.totalPages %> 페이지 · 총 <%= result.total.toLocaleString() %>건</div>
155 </nav>
156 <% } %>
157 </section>
158
159 <aside class="sidebar">
160 <section class="side-panel">
161 <span class="panel-label">Statistics</span>
162 <h2>아카이브 현황</h2>
163 <div class="stat-row"><span>필터 결과</span><strong><%= result.total.toLocaleString() %>건</strong></div>
164 <div class="stat-row"><span>서버 수</span><strong><%= filters.guilds.length %>개</strong></div>
165 <div class="stat-row"><span>사용자 수</span><strong><%= filters.users.length %>명</strong></div>
166 </section>
167 <% if (isAdmin) { %>
168 <section class="side-panel">
169 <span class="panel-label">Admin</span>
170 <h2><%= username %></h2>
171 <div class="side-links">
172 <a href="/hinana/admin"><span><i class="bi bi-gear me-1"></i> 관리자 페이지</span><i class="bi bi-chevron-right"></i></a>
173 <a href="/hinana/security"><span><i class="bi bi-shield-lock me-1"></i> 보안 대시보드</span><i class="bi bi-chevron-right"></i></a>
174 <a href="/hinana/backups"><span><i class="bi bi-archive me-1"></i> 백업 관리</span><i class="bi bi-chevron-right"></i></a>
175 </div>
176 </section>
177 <% } else { %>
178 <section class="side-panel">
179 <span class="panel-label">Account</span>
180 <h2><%= username || '게스트' %></h2>
181 <div class="side-links">
182 <% if (username) { %>
183 <a href="/hinana/userInfo"><span><i class="bi bi-person me-1"></i> 계정 정보</span><i class="bi bi-chevron-right"></i></a>
184 <% } else { %>
185 <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>
186 <% } %>
187 <a href="/hinana/lounge"><span><i class="bi bi-grid me-1"></i> 라운지</span><i class="bi bi-chevron-right"></i></a>
188 <a href="/hinana/persona"><span><i class="bi bi-person-badge me-1"></i> 페르소나 설정</span><i class="bi bi-chevron-right"></i></a>
189 </div>
190 </section>
191 <% } %>
192 <section class="side-panel">
193 <span class="panel-label">Info</span>
194 <h2>기록 안내</h2>
195 <p>/히나나 명령어가 성공적으로 응답한 대화만 저장됩니다. DM 대화는 서버 필터에서 'DM'으로 표시됩니다.</p>
196 </section>
197 </aside>
198 </div>
199 </main>
200
201 <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. 8.0.3.2-Utsumi Aoba</span>&copy; 2024~2026. 비나래 | hinana.moe</div></div></footer>
202
203 <script>
204 // 답변이 길어 잘린 경우에만 '전체 보기' 버튼 표시
205 document.querySelectorAll('.qa-answer').forEach(function(box){
206 const text = box.querySelector('.qa-text');
207 const btn = box.querySelector('.expand-btn');
208 if (!text || !btn) return;
209 if (text.scrollHeight > text.clientHeight + 4) {
210 text.classList.add('clamped');
211 btn.hidden = false;
212 btn.addEventListener('click', function(){
213 const open = box.classList.toggle('open');
214 btn.innerHTML = open
215 ? '<i class="bi bi-chevron-up me-1"></i>접기'
216 : '<i class="bi bi-chevron-down me-1"></i>전체 보기';
217 });
218 }
219 });
220 // 관리자 전용: 대화 숨김/삭제
221 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
222 async function adminApi(url, body) {
223 const res = await fetch(url, {
224 method: 'POST',
225 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
226 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
227 });
228 const data = await res.json().catch(function(){ return {}; });
229 if (!res.ok || !data.success) throw new Error(data.message || '요청에 실패했습니다.');
230 return data;
231 }
232 document.querySelectorAll('.hide-btn').forEach(function(btn){
233 btn.addEventListener('click', async function(){
234 const isHidden = btn.dataset.hidden === '1';
235 try {
236 await adminApi('/api/admin/discord-archive/hide', { id: btn.dataset.id, hidden: !isHidden });
237 location.reload();
238 } catch (err) { showAlert(err.message); }
239 });
240 });
241 document.querySelectorAll('.delete-btn').forEach(function(btn){
242 btn.addEventListener('click', async function(){
243 if (!confirm('이 대화를 완전히 삭제할까요? 되돌릴 수 없습니다.')) return;
244 try {
245 await adminApi('/api/admin/discord-archive/delete', { id: btn.dataset.id });
246 location.reload();
247 } catch (err) { showAlert(err.message); }
248 });
249 });
250 if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');
251 </script>
252 </body>
253 </html>
254