Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
view/hinana/aiNotes.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
<title>AI 노트 — 비나래아카이브</title>
8
<link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
9
<link rel="stylesheet" href="/css/hinana.css" type="text/css">
10
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
11
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
12
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.2/dist/purify.min.js"></script>
13
<script src="/js/popup.js"></script>
14
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
15
<style>
16
:root {
17
--font-family: 'Noto Sans KR', sans-serif;
18
--bg-main: #ffffff; --bg-secondary: #f7f9f9; --bg-tertiary: #eff3f4;
19
--text-primary: #0f1419; --text-secondary: #536471;
20
--accent-color: #1d9bf0; --danger-color: #f4212e; --border-color: #eff3f4;
21
}
22
body.dark-mode {
23
--bg-main: #000000; --bg-secondary: #16181c; --bg-tertiary: #202327;
24
--text-primary: #e7e9ea; --text-secondary: #71767b;
25
--border-color: #2f3336;
26
}
27
html, body { min-height: 100%; margin: 0; font-family: var(--font-family); background: var(--bg-main); color: var(--text-primary); }
28
a { color: inherit; text-decoration: none; }
29
.global-header {
30
height: 60px; background: var(--bg-main); border-bottom: 1px solid var(--border-color);
31
display: flex; align-items: center; justify-content: space-between; padding: 0 20px;
32
}
33
.header-logo { height: 28px; width: auto; mix-blend-mode: multiply; }
34
body.dark-mode .header-logo { mix-blend-mode: screen; }
35
.header-actions { display: flex; align-items: center; gap: 10px; color: var(--text-secondary); }
36
.page-shell { max-width: 920px; margin: 0 auto; padding: 28px 20px 48px; }
37
.page-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 22px; }
38
.page-title { margin: 0; font-size: 1.45rem; font-weight: 700; }
39
.note-filters {
40
display: grid; grid-template-columns: minmax(220px, 1fr) 170px 150px 150px auto;
41
gap: 10px; align-items: center; margin-bottom: 18px;
42
}
43
.filter-field {
44
width: 100%; border: 1px solid var(--border-color); border-radius: 8px;
45
background: var(--bg-secondary); color: var(--text-primary); padding: 9px 11px;
46
}
47
.filter-reset {
48
border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-main);
49
color: var(--text-secondary); padding: 9px 12px;
50
}
51
.filter-reset:hover { color: var(--accent-color); border-color: var(--accent-color); }
52
.pagination-bar {
53
display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 20px;
54
}
55
.page-link-btn {
56
min-width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center;
57
border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-main); color: var(--text-secondary);
58
}
59
.page-link-btn.active { background: var(--accent-color); border-color: var(--accent-color); color: #fff; }
60
.page-link-btn.disabled { opacity: 0.45; pointer-events: none; }
61
.note-list { display: flex; flex-direction: column; gap: 12px; }
62
.note-card { border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-secondary); padding: 16px; }
63
.note-card.pinned { border-color: var(--accent-color); }
64
.note-title-input {
65
width: 100%; border: none; background: transparent; color: var(--text-primary);
66
font-weight: 700; font-size: 1rem; padding: 0; margin-bottom: 7px;
67
}
68
.note-title-input:focus { outline: none; }
69
.note-question { color: var(--text-secondary); font-size: 0.82rem; margin-bottom: 9px; }
70
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
71
.tag-chip {
72
border: 1px solid var(--border-color); border-radius: 999px; padding: 2px 8px;
73
color: var(--text-secondary); font-size: 0.75rem; background: var(--bg-main);
74
}
75
.tag-input {
76
border: 1px solid var(--border-color); border-radius: 999px; padding: 3px 9px;
77
background: var(--bg-main); color: var(--text-primary); font-size: 0.75rem; min-width: 120px;
78
}
79
.tag-picker {
80
border: 1px solid var(--border-color); border-radius: 999px; padding: 3px 9px;
81
background: var(--bg-main); color: var(--text-secondary); font-size: 0.75rem;
82
}
83
.note-excerpt-shell { position: relative; margin-bottom: 12px; }
84
.note-excerpt { line-height: 1.65; }
85
.note-excerpt-shell.collapsible:not(.expanded) .note-excerpt {
86
max-height: calc(1.65em * 5);
87
overflow: hidden;
88
}
89
.note-excerpt-fade {
90
display: none; position: absolute; left: 0; right: 0; bottom: 0;
91
height: 58px; border: none; padding: 24px 0 0; color: var(--text-secondary);
92
background: linear-gradient(to bottom, rgba(247,249,249,0), var(--bg-secondary) 72%);
93
}
94
body.dark-mode .note-excerpt-fade {
95
background: linear-gradient(to bottom, rgba(22,24,28,0), var(--bg-secondary) 72%);
96
}
97
.note-excerpt-shell.collapsible:not(.expanded) .note-excerpt-fade { display: block; }
98
.note-collapse-button {
99
display: none; width: 100%; border: none; background: transparent;
100
color: var(--text-secondary); padding: 4px 0 0;
101
}
102
.note-excerpt-shell.collapsible.expanded + .note-collapse-button { display: block; }
103
.note-excerpt p:last-child { margin-bottom: 0; }
104
.note-excerpt p { margin-bottom: 0.6em; }
105
.note-excerpt ul, .note-excerpt ol { padding-left: 1.4em; margin-bottom: 0.6em; }
106
.note-excerpt code { background: var(--bg-main); padding: 1px 5px; border-radius: 4px; }
107
.note-excerpt pre { background: var(--bg-main); padding: 12px; border-radius: 8px; overflow-x: auto; }
108
.note-excerpt pre code { background: none; padding: 0; }
109
.note-excerpt h1,
110
.note-excerpt h2,
111
.note-excerpt h3,
112
.note-excerpt h4,
113
.note-excerpt h5,
114
.note-excerpt h6 {
115
margin: 0.8em 0 0.35em;
116
line-height: 1.35;
117
font-weight: 700;
118
}
119
.note-excerpt h1 { font-size: 1.15rem; }
120
.note-excerpt h2 { font-size: 1.08rem; }
121
.note-excerpt h3,
122
.note-excerpt h4,
123
.note-excerpt h5,
124
.note-excerpt h6 { font-size: 1rem; }
125
.note-editor { display: none; }
126
.line-editor { display: none; flex-direction: column; gap: 8px; margin-bottom: 12px; }
127
.line-row {
128
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: start;
129
border: 1px solid var(--border-color); border-radius: 8px; padding: 8px;
130
background: var(--bg-main);
131
}
132
.line-preview { min-height: 1.4em; white-space: pre-wrap; line-height: 1.5; }
133
.line-actions { display: flex; align-items: center; gap: 2px; }
134
.line-edit, .line-delete {
135
border: none; background: transparent; color: var(--text-secondary); padding: 2px 4px;
136
}
137
.line-delete:hover { color: var(--danger-color); }
138
.line-input {
139
display: none; grid-column: 1 / -1; width: 100%; min-height: 68px; resize: vertical;
140
border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-secondary);
141
color: var(--text-primary); padding: 8px; line-height: 1.5;
142
}
143
.note-meta { display: flex; justify-content: space-between; align-items: center; gap: 10px; color: var(--text-secondary); font-size: 0.78rem; }
144
.note-actions { display: flex; align-items: center; gap: 12px; }
145
.note-edit, .note-save, .note-cancel, .note-delete, .note-pin { border: none; background: transparent; color: var(--text-secondary); padding: 0; }
146
.note-edit:hover, .note-save:hover, .note-pin:hover, .note-pin.active { color: var(--accent-color); }
147
.note-delete:hover { color: var(--danger-color); }
148
.empty-state {
149
border: 1px dashed var(--border-color); border-radius: 8px; color: var(--text-secondary);
150
min-height: 180px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
151
}
152
.page-footer {
153
margin-top: 42px; text-align: center; color: var(--text-secondary);
154
}
155
.footer-sign {
156
max-width: 200px; width: 80%; opacity: 0.8; mix-blend-mode: multiply; margin-bottom: 8px;
157
}
158
body.dark-mode .footer-sign { mix-blend-mode: screen; }
159
.page-footer p { margin-bottom: 0; font-size: 0.8rem; }
160
.toolbar-row { display: flex; justify-content: flex-end; gap: 8px; margin-bottom: 14px; }
161
@media (max-width: 640px) {
162
.page-head { align-items: flex-start; flex-direction: column; }
163
.note-filters { grid-template-columns: 1fr; }
164
.note-meta { align-items: flex-start; flex-direction: column; }
165
}
166
</style>
167
</head>
168
<body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
169
<header class="global-header">
170
<a href="/hinana/index">
171
<img src="/image/<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'archive1.png' : 'archive.png' %>" alt="Hinana Archive" class="header-logo">
172
</a>
173
<div class="header-actions">
174
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
175
<a href="/hinana/personal-notes" class="btn btn-outline-secondary btn-sm"><i class="bi bi-journal"></i> 개인 노트</a>
176
<% } %>
177
<a href="/hinana/ai" class="btn btn-outline-secondary btn-sm"><i class="bi bi-chat-dots"></i> 대화로 돌아가기</a>
178
<form action="/toggle-theme" method="POST" style="margin:0;">
179
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
180
<input type="hidden" name="redirect" value="/hinana/ai/notes">
181
<button type="submit" class="btn btn-outline-secondary btn-sm" title="테마 변경">
182
<i class="bi <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'bi-moon-stars-fill' : 'bi-sun-fill' %>"></i>
183
</button>
184
</form>
185
</div>
186
</header>
187
188
<main class="page-shell">
189
<div class="page-head">
190
<h1 class="page-title">AI 노트</h1>
191
<span class="text-secondary small" id="note-count">
192
<% if (filteredCount === totalNotes) { %>
193
<%= totalNotes %>개 저장됨
194
<% } else { %>
195
<%= filteredCount %>개 표시 / <%= totalNotes %>개 저장됨
196
<% } %>
197
</span>
198
</div>
199
200
<div class="toolbar-row">
201
<a class="filter-reset" href="/hinana/ai/notes/export/md"><i class="bi bi-download"></i> Markdown</a>
202
<a class="filter-reset" href="/hinana/ai/notes/export/txt"><i class="bi bi-download"></i> TXT</a>
203
</div>
204
205
<form class="note-filters" method="GET" action="/hinana/ai/notes">
206
<input type="search" class="filter-field" name="q" value="<%= search %>" placeholder="질문 또는 내용 검색">
207
<input type="date" class="filter-field" name="date" value="<%= date %>">
208
<select class="filter-field" name="tag">
209
<option value="">모든 태그</option>
210
<% availableTags.forEach(function(item) { %>
211
<option value="<%= item %>" <%= item === tag ? 'selected' : '' %>><%= item %></option>
212
<% }); %>
213
</select>
214
<select class="filter-field" name="sort">
215
<option value="new" <%= sort === 'new' ? 'selected' : '' %>>최신순</option>
216
<option value="old" <%= sort === 'old' ? 'selected' : '' %>>오래된순</option>
217
<option value="updated" <%= sort === 'updated' ? 'selected' : '' %>>최근 수정순</option>
218
<option value="pinned" <%= sort === 'pinned' ? 'selected' : '' %>>고정 우선</option>
219
</select>
220
<button type="submit" class="filter-reset">
221
<i class="bi bi-search"></i> 검색
222
</button>
223
</form>
224
<% if (search || date || tag || sort !== 'new') { %>
225
<a href="/hinana/ai/notes" class="filter-reset d-inline-block mb-3">
226
<i class="bi bi-arrow-counterclockwise"></i> 초기화
227
</a>
228
<% } %>
229
230
<div class="note-list" id="note-list">
231
<% if (aiNotes.length === 0) { %>
232
<div class="empty-state">
233
<i class="bi <%= totalNotes === 0 ? 'bi-journal-text' : 'bi-search' %>" style="font-size:1.7rem;"></i>
234
<span><%= totalNotes === 0 ? '저장한 노트가 아직 없어요.' : '조건에 맞는 노트가 없어요.' %></span>
235
</div>
236
<% } else { %>
237
<% aiNotes.forEach(function(note) { %>
238
<article class="note-card <%= note.pinned ? 'pinned' : '' %>" data-note-id="<%= note.id %>" data-created-at="<%= note.createdAt %>">
239
<input class="note-title-input" maxlength="80" value="<%= note.title || '' %>" placeholder="제목 없음">
240
<div class="note-question">Q. <%= note.question %></div>
241
<div class="tag-row">
242
<% (note.tags || []).forEach(function(item) { %>
243
<span class="tag-chip">#<%= item %></span>
244
<% }); %>
245
<input class="tag-input" value="<%= (note.tags || []).join(', ') %>" placeholder="태그, 쉼표 구분">
246
<select class="tag-picker">
247
<option value="">태그 추가</option>
248
<% tagCatalog.forEach(function(item) { %>
249
<option value="<%= item %>"><%= item %></option>
250
<% }); %>
251
</select>
252
</div>
253
<div class="note-excerpt-shell">
254
<div class="note-excerpt"><%= note.excerpt %></div>
255
<button type="button" class="note-excerpt-fade" aria-label="전체 보기">
256
<i class="bi bi-chevron-down"></i>
257
</button>
258
</div>
259
<button type="button" class="note-collapse-button" aria-label="접기">
260
<i class="bi bi-chevron-up"></i>
261
</button>
262
<textarea class="note-editor"><%= note.excerpt %></textarea>
263
<div class="line-editor"></div>
264
<div class="note-meta">
265
<time datetime="<%= note.createdAt %>"><%= fmtDate(note.createdAt) %></time>
266
<div class="note-actions">
267
<button type="button" class="note-pin <%= note.pinned ? 'active' : '' %>" title="고정">
268
<i class="bi bi-pin-angle<%= note.pinned ? '-fill' : '' %>"></i> 고정
269
</button>
270
<% if (note.sourceViewId) { %>
271
<a href="/hinana/ai/view/<%= note.sourceViewId %>"><i class="bi bi-box-arrow-up-right"></i> 원문</a>
272
<% } %>
273
<a href="/hinana/ai/notes/<%= note.id %>/export/md"><i class="bi bi-download"></i> MD</a>
274
<a href="/hinana/ai/notes/<%= note.id %>/export/txt"><i class="bi bi-download"></i> TXT</a>
275
<button type="button" class="note-edit"><i class="bi bi-pencil"></i> 수정</button>
276
<button type="button" class="note-save d-none"><i class="bi bi-check2"></i> 저장</button>
277
<button type="button" class="note-cancel d-none">취소</button>
278
<button type="button" class="note-delete" data-note-id="<%= note.id %>">
279
<i class="bi bi-trash3"></i> 삭제
280
</button>
281
</div>
282
</div>
283
</article>
284
<% }); %>
285
<% } %>
286
</div>
287
288
<% if (totalPages > 1) { %>
289
<nav class="pagination-bar" aria-label="AI 노트 페이지">
290
<a class="page-link-btn <%= currentPage === 1 ? 'disabled' : '' %>" href="/hinana/ai/notes?page=<%= currentPage - 1 %>&q=<%= encodeURIComponent(search) %>&date=<%= encodeURIComponent(date) %>&tag=<%= encodeURIComponent(tag) %>&sort=<%= encodeURIComponent(sort) %>">
291
<i class="bi bi-chevron-left"></i>
292
</a>
293
<% for (let p = 1; p <= totalPages; p++) { %>
294
<a class="page-link-btn <%= p === currentPage ? 'active' : '' %>" href="/hinana/ai/notes?page=<%= p %>&q=<%= encodeURIComponent(search) %>&date=<%= encodeURIComponent(date) %>&tag=<%= encodeURIComponent(tag) %>&sort=<%= encodeURIComponent(sort) %>"><%= p %></a>
295
<% } %>
296
<a class="page-link-btn <%= currentPage === totalPages ? 'disabled' : '' %>" href="/hinana/ai/notes?page=<%= currentPage + 1 %>&q=<%= encodeURIComponent(search) %>&date=<%= encodeURIComponent(date) %>&tag=<%= encodeURIComponent(tag) %>&sort=<%= encodeURIComponent(sort) %>">
297
<i class="bi bi-chevron-right"></i>
298
</a>
299
</nav>
300
<% } %>
301
302
<footer class="page-footer">
303
<img src="/image/sign.png" alt="" class="footer-sign">
304
<p>X - @NoctchillHinana</p>
305
<p>ⓒ 2024~2026. 비나래 | hinana.moe All rights reserved.</p>
306
</footer>
307
</main>
308
309
<script>
310
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
311
const noteList = document.getElementById('note-list');
312
313
function renderMarkdown(element, markdown) {
314
element.innerHTML = DOMPurify.sanitize(marked.parse(markdown, { breaks: true }));
315
}
316
317
document.querySelectorAll('.note-card').forEach(function (card) {
318
const editor = card.querySelector('.note-editor');
319
card.dataset.rawExcerpt = editor.value;
320
renderMarkdown(card.querySelector('.note-excerpt'), editor.value);
321
refreshExcerptCollapse(card);
322
});
323
324
noteList.addEventListener('click', async function (event) {
325
const pinButton = event.target.closest('.note-pin');
326
if (pinButton) {
327
const card = pinButton.closest('.note-card');
328
const pinned = !pinButton.classList.contains('active');
329
const res = await patchNote(card, { pinned });
330
if (!res) return;
331
card.classList.toggle('pinned', pinned);
332
pinButton.classList.toggle('active', pinned);
333
pinButton.innerHTML = '<i class="bi bi-pin-angle' + (pinned ? '-fill' : '') + '"></i> 고정';
334
return;
335
}
336
const expandButton = event.target.closest('.note-excerpt-fade');
337
if (expandButton) {
338
const shell = expandButton.closest('.note-excerpt-shell');
339
shell.classList.add('expanded');
340
return;
341
}
342
const collapseButton = event.target.closest('.note-collapse-button');
343
if (collapseButton) {
344
const shell = collapseButton.previousElementSibling;
345
shell.classList.remove('expanded');
346
return;
347
}
348
const editButton = event.target.closest('.note-edit');
349
if (editButton) {
350
const card = editButton.closest('.note-card');
351
card.querySelector('.note-excerpt').style.display = 'none';
352
card.querySelector('.note-excerpt-shell').classList.remove('collapsible', 'expanded');
353
card.querySelector('.note-collapse-button').style.display = 'none';
354
buildLineEditor(card);
355
card.querySelector('.line-editor').style.display = 'flex';
356
card.querySelector('.note-edit').classList.add('d-none');
357
card.querySelector('.note-save').classList.remove('d-none');
358
card.querySelector('.note-cancel').classList.remove('d-none');
359
return;
360
}
361
362
const lineEditButton = event.target.closest('.line-edit');
363
if (lineEditButton) {
364
const row = lineEditButton.closest('.line-row');
365
const input = row.querySelector('.line-input');
366
input.style.display = 'block';
367
input.focus();
368
return;
369
}
370
371
const lineDeleteButton = event.target.closest('.line-delete');
372
if (lineDeleteButton) {
373
const row = lineDeleteButton.closest('.line-row');
374
const editor = row.closest('.line-editor');
375
row.remove();
376
if (!editor.querySelector('.line-row')) {
377
appendLineRow(editor, '');
378
}
379
return;
380
}
381
382
const cancelButton = event.target.closest('.note-cancel');
383
if (cancelButton) {
384
const card = cancelButton.closest('.note-card');
385
const excerpt = card.querySelector('.note-excerpt');
386
const editor = card.querySelector('.note-editor');
387
editor.value = card.dataset.rawExcerpt || '';
388
editor.style.display = 'none';
389
card.querySelector('.line-editor').style.display = 'none';
390
card.querySelector('.line-editor').innerHTML = '';
391
excerpt.style.display = 'block';
392
refreshExcerptCollapse(card);
393
card.querySelector('.note-edit').classList.remove('d-none');
394
card.querySelector('.note-save').classList.add('d-none');
395
card.querySelector('.note-cancel').classList.add('d-none');
396
return;
397
}
398
399
const saveButton = event.target.closest('.note-save');
400
if (saveButton) {
401
const card = saveButton.closest('.note-card');
402
const editor = card.querySelector('.note-editor');
403
const excerpt = collectLineEditor(card).trim();
404
if (!excerpt) return;
405
406
const ok = await patchNote(card, {
407
excerpt,
408
title: card.querySelector('.note-title-input').value.trim(),
409
tags: readTags(card)
410
});
411
if (!ok) return;
412
413
card.dataset.rawExcerpt = excerpt;
414
renderMarkdown(card.querySelector('.note-excerpt'), excerpt);
415
editor.style.display = 'none';
416
editor.value = excerpt;
417
card.querySelector('.line-editor').style.display = 'none';
418
card.querySelector('.line-editor').innerHTML = '';
419
card.querySelector('.note-excerpt').style.display = 'block';
420
refreshExcerptCollapse(card);
421
card.querySelector('.note-edit').classList.remove('d-none');
422
card.querySelector('.note-save').classList.add('d-none');
423
card.querySelector('.note-cancel').classList.add('d-none');
424
return;
425
}
426
427
const button = event.target.closest('.note-delete');
428
if (!button) return;
429
if (!await showConfirm('정말로 삭제하시겠어요?')) return;
430
431
const noteId = button.dataset.noteId;
432
const res = await fetch('/hinana/ai/notes/' + encodeURIComponent(noteId), {
433
method: 'DELETE',
434
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
435
});
436
const data = await res.json();
437
if (!res.ok || !data.success) return;
438
439
const card = button.closest('.note-card');
440
if (card) card.remove();
441
window.location.reload();
442
});
443
444
noteList.addEventListener('change', async function (event) {
445
const titleInput = event.target.closest('.note-title-input');
446
const tagInput = event.target.closest('.tag-input');
447
const tagPicker = event.target.closest('.tag-picker');
448
if (!titleInput && !tagInput && !tagPicker) return;
449
450
const card = event.target.closest('.note-card');
451
if (tagPicker && tagPicker.value) {
452
const currentTags = readTags(card);
453
if (!currentTags.includes(tagPicker.value)) {
454
currentTags.push(tagPicker.value);
455
card.querySelector('.tag-input').value = currentTags.join(', ');
456
}
457
tagPicker.value = '';
458
}
459
await patchNote(card, {
460
title: card.querySelector('.note-title-input').value.trim(),
461
tags: readTags(card)
462
});
463
});
464
465
function readTags(card) {
466
return card.querySelector('.tag-input').value
467
.split(',')
468
.map(function (item) { return item.trim(); })
469
.filter(Boolean);
470
}
471
472
function buildLineEditor(card) {
473
const container = card.querySelector('.line-editor');
474
const lines = (card.dataset.rawExcerpt || '').split('\n');
475
container.innerHTML = '';
476
lines.forEach(function (line) { appendLineRow(container, line); });
477
}
478
479
function appendLineRow(container, line) {
480
const row = document.createElement('div');
481
row.className = 'line-row';
482
483
const preview = document.createElement('div');
484
preview.className = 'line-preview';
485
preview.textContent = line || ' ';
486
487
const actions = document.createElement('div');
488
actions.className = 'line-actions';
489
490
const editButton = document.createElement('button');
491
editButton.type = 'button';
492
editButton.className = 'line-edit';
493
editButton.innerHTML = '<i class="bi bi-pencil"></i>';
494
editButton.title = '이 줄 수정';
495
496
const deleteButton = document.createElement('button');
497
deleteButton.type = 'button';
498
deleteButton.className = 'line-delete';
499
deleteButton.innerHTML = '<i class="bi bi-trash3"></i>';
500
deleteButton.title = '이 줄 삭제';
501
502
const input = document.createElement('textarea');
503
input.className = 'line-input';
504
input.value = line;
505
input.addEventListener('input', function () {
506
preview.textContent = input.value || ' ';
507
});
508
509
actions.appendChild(editButton);
510
actions.appendChild(deleteButton);
511
row.appendChild(preview);
512
row.appendChild(actions);
513
row.appendChild(input);
514
container.appendChild(row);
515
}
516
517
function collectLineEditor(card) {
518
return Array.from(card.querySelectorAll('.line-input'))
519
.map(function (input) { return input.value; })
520
.join('\n');
521
}
522
523
function renderTags(card, tags) {
524
const row = card.querySelector('.tag-row');
525
row.querySelectorAll('.tag-chip').forEach(function (chip) { chip.remove(); });
526
tags.slice().reverse().forEach(function (tag) {
527
const chip = document.createElement('span');
528
chip.className = 'tag-chip';
529
chip.textContent = '#' + tag;
530
row.prepend(chip);
531
});
532
}
533
534
function refreshExcerptCollapse(card) {
535
const shell = card.querySelector('.note-excerpt-shell');
536
const excerpt = card.querySelector('.note-excerpt');
537
const collapseButton = card.querySelector('.note-collapse-button');
538
shell.classList.remove('collapsible', 'expanded');
539
collapseButton.style.display = '';
540
541
window.requestAnimationFrame(function () {
542
const lineHeight = parseFloat(window.getComputedStyle(excerpt).lineHeight);
543
if (excerpt.scrollHeight > (lineHeight * 5) + 2) {
544
shell.classList.add('collapsible');
545
}
546
});
547
}
548
549
async function patchNote(card, payload) {
550
const res = await fetch('/hinana/ai/notes/' + encodeURIComponent(card.dataset.noteId), {
551
method: 'PATCH',
552
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
553
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
554
});
555
const data = await res.json();
556
if (!res.ok || !data.success) return false;
557
if (payload.tags) renderTags(card, payload.tags);
558
return true;
559
}
560
</script>
561
</body>
562
</html>
563