Public Source Viewer

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

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

Redacted View
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