Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
view/hinana/aiView.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
<meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#000000' : '#ffffff' %>">
8
<meta property="og:image" content="/image/2.png" />
9
<meta property="og:title" content="히나나 AI 답변" />
10
<meta property="og:description" content="<%= answer.substring(0, 100).replace(/[\r\n]/g, ' ') %>..." />
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" rel="stylesheet" type="text/css">
14
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
15
<link rel="stylesheet" href="/css/hinana.css" type="text/css">
16
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
17
<script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
18
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
19
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.2/dist/purify.min.js"></script>
20
<script src="/js/popup.js"></script>
21
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
22
<title>히나나 AI 답변</title>
23
<style>
24
:root {
25
--font-family: 'Noto Sans KR', sans-serif;
26
--bg-main: #ffffff; --bg-secondary: #f7f9f9; --bg-tertiary: #eff3f4;
27
--text-primary: #0f1419; --text-secondary: #536471;
28
--accent-color: #1d9bf0; --danger-color: #f4212e; --border-color: #eff3f4;
29
--chat-bg-ai: #ffffff; --chat-bg-user: #1d9bf0;
30
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08); --shadow-md: 0 4px 12px rgba(0,0,0,0.10);
31
}
32
body.dark-mode {
33
--bg-main: #000000; --bg-secondary: #16181c; --bg-tertiary: #202327;
34
--text-primary: #e7e9ea; --text-secondary: #71767b;
35
--border-color: #2f3336; --accent-color: #1d9bf0; --danger-color: #f4212e;
36
--chat-bg-ai: #16181c; --chat-bg-user: #1d9bf0;
37
--shadow-sm: 0 1px 3px rgba(255,255,255,0.04); --shadow-md: 0 4px 12px rgba(0,0,0,0.6);
38
}
39
40
html, body { height: 100%; margin: 0; font-family: var(--font-family); background-color: var(--bg-main); color: var(--text-primary); overflow: hidden; }
41
a { text-decoration: none; color: inherit; }
42
43
.global-header {
44
height: 60px;
45
background-color: rgba(255,255,255,0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
46
border-bottom: 1px solid var(--border-color);
47
display: flex; align-items: center; justify-content: space-between; padding: 0 20px; z-index: 100; position: sticky; top: 0;
48
}
49
body.dark-mode .global-header { background-color: rgba(0,0,0,0.85); }
50
.header-brand { display: flex; align-items: center; gap: 15px; }
51
.header-logo { height: 28px; width: auto; }
52
body:not(.dark-mode) .logo-night { display: none; }
53
body.dark-mode .logo-day { display: none; }
54
.header-nav {
55
position: absolute; left: 50%; transform: translateX(-50%);
56
display: flex; gap: 20px; align-items: center; z-index: 5;
57
}
58
.nav-link { font-weight: 600; font-size: 0.95rem; color: var(--text-secondary); }
59
.nav-link:hover { color: var(--accent-color); }
60
.nav-link.active { color: var(--text-primary); }
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; }
63
.icon-btn:hover { color: var(--text-primary); }
64
65
.layout-container { display: flex; height: calc(100vh - 60px); }
66
67
.shelf-column {
68
width: 300px; min-width: 300px; background-color: var(--bg-secondary);
69
border-right: 1px solid var(--border-color); display: flex; flex-direction: column;
70
justify-content: center; align-items: center; gap: 12px;
71
}
72
.shelf-column .shelf-label {
73
font-size: 0.75rem; font-weight: 700; text-transform: uppercase;
74
color: var(--text-secondary); letter-spacing: 0.08em;
75
}
76
77
.content-column {
78
flex: 1; display: flex; flex-direction: column;
79
background-color: var(--bg-main); position: relative; overflow: hidden;
80
}
81
.content-scroll-area {
82
flex: 1; overflow-y: auto; padding: 40px;
83
display: flex; justify-content: center;
84
}
85
.content-card {
86
background-color: var(--bg-secondary); border-radius: 12px;
87
box-shadow: var(--shadow-sm); border: 1px solid var(--border-color);
88
overflow: hidden; width: 100%; max-width: 760px; height: fit-content; margin-bottom: 40px;
89
}
90
91
/* 카드 헤더 */
92
.qa-header {
93
padding: 20px 25px; background-color: var(--bg-tertiary);
94
border-bottom: 1px solid var(--border-color);
95
display: flex; align-items: center; justify-content: space-between;
96
}
97
.qa-header-title {
98
display: flex; align-items: center; gap: 8px;
99
font-size: 0.85rem; font-weight: 700; color: var(--accent-color);
100
}
101
.qa-header-date { font-size: 0.78rem; color: var(--text-secondary); }
102
103
/* Q&A 버블 영역 */
104
.qa-body { padding: 28px; display: flex; flex-direction: column; gap: 20px; }
105
106
.bubble { border-radius: 16px; padding: 16px 20px; line-height: 1.7; word-break: break-word; font-size: 0.97rem; }
107
.bubble-label { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; margin-bottom: 8px; }
108
109
.bubble-q-wrap { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
110
.bubble-outer-label { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; color: var(--text-secondary); }
111
.bubble-q {
112
background-color: var(--chat-bg-user); color: #fff;
113
border-bottom-right-radius: 4px; max-width: 88%; white-space: pre-wrap;
114
}
115
116
.bubble-a {
117
background-color: var(--chat-bg-ai); border: 1px solid var(--border-color);
118
border-bottom-left-radius: 4px;
119
}
120
body.dark-mode .bubble-a { color: var(--text-primary); }
121
.bubble-a .bubble-label { color: var(--accent-color); }
122
.bubble-a-content p:last-child { margin-bottom: 0; }
123
.bubble-a-content p { margin-bottom: 0.6em; }
124
.bubble-a-content ul, .bubble-a-content ol { padding-left: 1.4em; margin-bottom: 0.6em; }
125
.bubble-a-content code { background-color: var(--bg-tertiary); padding: 1px 5px; border-radius: 4px; font-size: 0.9em; }
126
.bubble-a-content pre { background-color: var(--bg-tertiary); padding: 12px; border-radius: 8px; overflow-x: auto; }
127
.bubble-a-content pre code { background: none; padding: 0; }
128
.bubble-a-content h1, .bubble-a-content h2, .bubble-a-content h3,
129
.bubble-a-content h4, .bubble-a-content h5, .bubble-a-content h6 {
130
font-size: 1em; font-weight: 700; margin: 0.8em 0 0.4em;
131
}
132
133
/* 카드 푸터 */
134
.qa-footer {
135
padding: 14px 25px; background-color: var(--bg-main);
136
border-top: 1px solid var(--border-color);
137
display: flex; align-items: center; justify-content: space-between;
138
}
139
.qa-footer-brand { font-size: 0.78rem; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
140
.qa-footer-link { font-size: 0.78rem; color: var(--accent-color); display: flex; align-items: center; gap: 5px; }
141
.qa-footer-link:hover { text-decoration: underline; }
142
143
.selection-note-menu {
144
position: fixed; z-index: 21000; display: none; align-items: center; gap: 5px;
145
border: 1px solid var(--border-color); border-radius: 999px; padding: 5px;
146
background-color: var(--bg-main); box-shadow: var(--shadow-md);
147
}
148
.selection-note-menu.visible { display: flex; }
149
.selection-note-button {
150
border: none; border-radius: 999px; padding: 7px 11px; background: transparent;
151
color: var(--text-primary); font-size: 0.78rem; font-weight: 600;
152
}
153
.selection-note-button:hover { color: var(--accent-color); border-color: var(--accent-color); }
154
155
/* 반응형 */
156
@media (max-width: 960px) {
157
.selection-note-menu {
158
left: 12px !important; right: 12px !important; bottom: 16px !important; top: auto !important;
159
border-radius: 14px; justify-content: stretch; gap: 4px;
160
}
161
.selection-note-button { flex: 1; padding: 11px 10px; }
162
html, body { overflow: auto !important; height: auto !important; }
163
.layout-container { flex-direction: column; height: auto !important; }
164
.shelf-column { display: none; }
165
.content-column { width: 100%; height: auto !important; overflow: visible; }
166
.content-scroll-area { padding: 16px; height: auto !important; overflow: visible; }
167
.global-header { flex-wrap: wrap; height: auto; padding: 10px 20px; }
168
.header-nav {
169
position: static; transform: none; width: 100%;
170
justify-content: center; margin-top: 10px; padding-top: 10px;
171
border-top: 1px solid rgba(0,0,0,0.05); order: 3;
172
}
173
.header-brand { flex: 1; order: 1; }
174
.header-controls { flex: auto; justify-content: flex-end; background-color: transparent; order: 2; }
175
.bubble-q { max-width: 96%; }
176
.qa-body { padding: 16px; }
177
}
178
</style>
179
</head>
180
181
<body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
182
183
<header class="global-header">
184
<div class="header-brand">
185
<a href="/hinana/index">
186
<img src="/image/<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'archive1.png' : 'archive.png' %>"
187
alt="Hinana Archive" class="header-logo">
188
</a>
189
</div>
190
191
<nav class="header-nav">
192
<a href="/hinana/index" class="nav-link">Archive</a>
193
<a href="/hinana/info" class="nav-link">Info</a>
194
<a href="/hinana/blog" class="nav-link">Blog</a>
195
<a href="/hinana/lounge" class="nav-link">Lounge</a>
196
<span class="nav-divider">|</span>
197
<a href="/hinana/ai" class="nav-link active"><i class="bi bi-stars"></i> AI</a>
198
</nav>
199
200
<div class="header-controls">
201
<form action="/toggle-theme" method="POST" class="d-inline">
202
<input type="hidden" name="redirect" value="/hinana/ai/view/<%= typeof viewId !== 'undefined' ? viewId : '' %>">
203
<button type="submit" class="icon-btn" title="테마 변경">
204
<i class="bi <%= (typeof theme !== 'undefined' && theme==='dark') ? 'bi-moon-stars-fill' : 'bi-sun-fill' %>"></i>
205
</button>
206
</form>
207
</div>
208
</header>
209
210
<div class="layout-container">
211
212
<div class="shelf-column">
213
<span class="shelf-label"><i class="bi bi-stars me-1"></i> 히나나 AI</span>
214
<a href="/hinana/ai" class="btn btn-outline-secondary btn-sm">
215
<i class="bi bi-chat-dots"></i> AI 채팅으로
216
</a>
217
</div>
218
219
<div class="content-column">
220
<div class="content-scroll-area custom-scrollbar">
221
<div class="content-card">
222
223
<div class="qa-header">
224
<div class="qa-header-title">
225
<i class="bi bi-stars"></i> 히나나 AI 답변
226
</div>
227
<span class="qa-header-date">
228
<%= new Date(createdAt).toLocaleDateString('ko-KR', { year:'numeric', month:'long', day:'numeric' }) %>
229
</span>
230
</div>
231
232
<div class="qa-body">
233
<div class="bubble-q-wrap">
234
<span class="bubble-outer-label">질문</span>
235
<div class="bubble bubble-q"><%= question %></div>
236
</div>
237
<div class="bubble bubble-a">
238
<div class="bubble-label"><i class="bi bi-stars"></i> 히나나의 답변</div>
239
<div class="bubble-a-content" id="answer-content"></div>
240
</div>
241
</div>
242
243
<div class="qa-footer">
244
<span class="qa-footer-brand">
245
<i class="bi bi-stars"></i> hinana.moe AI Chat · Powered by gpt-5.5
246
</span>
247
<div style="display:flex; align-items:center; gap:14px;">
248
<a href="/hinana/ai/notes" class="qa-footer-link">
249
<i class="bi bi-journal-text"></i> AI 노트 보기
250
</a>
251
<a href="/hinana/ai" class="qa-footer-link">
252
<i class="bi bi-chat-dots"></i> 직접 물어보기
253
</a>
254
</div>
255
</div>
256
257
</div>
258
</div>
259
</div>
260
261
<div class="selection-note-menu" id="selection-note-menu">
262
<button type="button" class="selection-note-button" id="selection-note-button">
263
<i class="bi bi-journal-plus"></i> 노트 저장
264
</button>
265
<button type="button" class="selection-note-button" id="full-note-button">
266
<i class="bi bi-journal-text"></i> 전체를 노트로 저장
267
</button>
268
</div>
269
270
</div>
271
272
<script>
273
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
274
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
275
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
276
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
277
const answerContent = document.getElementById('answer-content');
278
const selectionNoteMenu = document.getElementById('selection-note-menu');
279
const selectionNoteButton = document.getElementById('selection-note-button');
280
const fullNoteButton = document.getElementById('full-note-button');
281
let selectedNotePayload = null;
282
const html = DOMPurify.sanitize(marked.parse(raw, { breaks: true }));
283
answerContent.innerHTML = html;
284
285
async function saveNote(notePayload) {
286
if (!notePayload) return;
287
288
selectionNoteButton.disabled = true;
289
fullNoteButton.disabled = true;
290
try {
291
const res = await fetch('/hinana/ai/notes', {
292
method: 'POST',
293
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
294
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
295
});
296
const data = await res.json();
297
if (!res.ok || !data.success) {
298
await showAlert(data.message || '노트 저장에 실패했어요.');
299
return;
300
}
301
302
if (data.requiresAppendConfirm) {
303
const shouldAppend = await showConfirm('기존 노트에 내용을 추가하시겠어요?');
304
if (!shouldAppend) {
305
hideSelectionNoteButton();
306
return;
307
}
308
309
const appendRes = await fetch('/hinana/ai/notes', {
310
method: 'POST',
311
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
312
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
313
});
314
const appendData = await appendRes.json();
315
if (!appendRes.ok || !appendData.success) {
316
await showAlert(appendData.message || '노트 저장에 실패했어요.');
317
return;
318
}
319
}
320
321
hideSelectionNoteButton();
322
window.getSelection()?.removeAllRanges();
323
await showAlert('노트를 저장했어요.');
324
} catch (error) {
325
console.error(error);
326
await showAlert('노트 저장 중 오류가 발생했어요.');
327
} finally {
328
selectionNoteButton.disabled = false;
329
fullNoteButton.disabled = false;
330
}
331
}
332
333
function hideSelectionNoteButton() {
334
selectedNotePayload = null;
335
selectionNoteMenu.classList.remove('visible');
336
}
337
338
function normalizeVisibleText(value) {
339
return String(value || '').replace(/\s+/g, ' ').trim();
340
}
341
342
function markdownToVisibleText(markdown) {
343
const probe = document.createElement('div');
344
probe.innerHTML = DOMPurify.sanitize(marked.parse(markdown, { breaks: true }));
345
return normalizeVisibleText(probe.textContent || '');
346
}
347
348
function recoverMarkdownExcerpt(rawMarkdown, selectedText) {
349
const selected = normalizeVisibleText(selectedText);
350
if (!selected) return '';
351
352
const lines = String(rawMarkdown || '').replace(/\r\n?/g, '\n').split('\n');
353
const visibleLines = lines.map(function (line) {
354
return markdownToVisibleText(line);
355
});
356
357
for (let start = 0; start < lines.length; start += 1) {
358
let visible = '';
359
for (let end = start; end < lines.length; end += 1) {
360
visible = normalizeVisibleText(visible + ' ' + visibleLines[end]);
361
if (visible === selected) {
362
return lines.slice(start, end + 1).join('\n').trim();
363
}
364
if (visible.length > selected.length && !visible.includes(selected)) {
365
break;
366
}
367
}
368
}
369
370
const lineIndex = visibleLines.findIndex(function (line) {
371
return line.includes(selected);
372
});
373
return lineIndex >= 0 ? lines[lineIndex].trim() : selectedText.replace(/\r\n?/g, '\n').trim();
374
}
375
376
function showSelectionNoteButton() {
377
const selection = window.getSelection();
378
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
379
hideSelectionNoteButton();
380
return;
381
}
382
383
const selectedText = selection.toString().replace(/\r\n?/g, '\n').trim();
384
if (!selectedText) {
385
hideSelectionNoteButton();
386
return;
387
}
388
389
const range = selection.getRangeAt(0);
390
const commonNode = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
391
? range.commonAncestorContainer
392
: range.commonAncestorContainer.parentElement;
393
if (!commonNode || !answerContent.contains(commonNode)) {
394
hideSelectionNoteButton();
395
return;
396
}
397
398
const rect = range.getBoundingClientRect();
399
if (!rect.width && !rect.height) {
400
hideSelectionNoteButton();
401
return;
402
}
403
404
const excerpt = recoverMarkdownExcerpt(raw, selectedText);
405
selectedNotePayload = { question, excerpt, fullAnswer: raw, sourceViewId };
406
if (!window.matchMedia('(max-width: 960px)').matches) {
407
selectionNoteMenu.style.left = Math.min(window.innerWidth - 260, Math.max(12, rect.left + (rect.width / 2) - 120)) + 'px';
408
selectionNoteMenu.style.top = Math.max(12, rect.top - 48) + 'px';
409
}
410
selectionNoteMenu.classList.add('visible');
411
}
412
413
selectionNoteButton.addEventListener('click', function () {
414
if (!selectedNotePayload) return;
415
saveNote({ question, excerpt: selectedNotePayload.excerpt, sourceViewId });
416
});
417
fullNoteButton.addEventListener('click', function () {
418
if (!selectedNotePayload) return;
419
saveNote({ question, excerpt: selectedNotePayload.fullAnswer, sourceViewId });
420
});
421
answerContent.addEventListener('mouseup', function () {
422
window.setTimeout(showSelectionNoteButton, 0);
423
});
424
answerContent.addEventListener('touchend', function () {
425
window.setTimeout(showSelectionNoteButton, 220);
426
});
427
selectionNoteMenu.addEventListener('pointerdown', function (event) {
428
event.stopPropagation();
429
});
430
document.addEventListener('mousedown', function (event) {
431
if (!selectionNoteMenu.contains(event.target)) hideSelectionNoteButton();
432
});
433
window.addEventListener('resize', hideSelectionNoteButton);
434
document.querySelector('.content-scroll-area').addEventListener('scroll', hideSelectionNoteButton);
435
</script>
436
</body>
437
</html>
438