본문으로 건너뛰기
Public Source Viewer

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

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

Redacted View
view/hinana/personalNotes.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>개인 노트 — 비나래아카이브</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; --border-color: #2f3336;
25 }
26 html, body { min-height: 100%; margin: 0; font-family: var(--font-family); background: var(--bg-main); color: var(--text-primary); }
27 a { color: inherit; text-decoration: none; }
28 .global-header { height: 60px; background: var(--bg-main); border-bottom: 1px solid var(--border-color); display:flex; align-items:center; justify-content:space-between; padding:0 20px; }
29 .header-logo { height: 28px; width:auto; mix-blend-mode:multiply; }
30 body.dark-mode .header-logo { mix-blend-mode:screen; }
31 .header-actions { display:flex; align-items:center; gap:10px; }
32 .page-shell { max-width: 920px; margin:0 auto; padding:28px 20px 48px; }
33 .page-head { display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px; }
34 .page-title { margin:0; font-size:1.45rem; font-weight:700; }
35 .composer { border:1px solid var(--border-color); border-radius:8px; background:var(--bg-secondary); padding:16px; margin-bottom:18px; }
36 .composer-grid { display:grid; gap:10px; }
37 .field { width:100%; border:1px solid var(--border-color); border-radius:8px; background:var(--bg-main); color:var(--text-primary); padding:10px 11px; }
38 textarea.field { min-height:130px; resize:vertical; line-height:1.6; }
39 .composer-actions, .toolbar-row { display:flex; justify-content:flex-end; gap:8px; margin-top:10px; }
40 .action-btn { border:1px solid var(--border-color); border-radius:8px; background:var(--bg-main); color:var(--text-secondary); padding:9px 12px; }
41 .action-btn:hover { color:var(--accent-color); border-color:var(--accent-color); }
42 .note-filters { display:grid; grid-template-columns:minmax(220px,1fr) 170px 150px 150px auto; gap:10px; margin:18px 0; }
43 .note-list { display:flex; flex-direction:column; gap:12px; }
44 .note-card { border:1px solid var(--border-color); border-radius:8px; background:var(--bg-secondary); padding:16px; }
45 .note-card.pinned { border-color:var(--accent-color); }
46 .note-title-input { width:100%; border:none; background:transparent; color:var(--text-primary); font-weight:700; font-size:1rem; padding:0; margin-bottom:8px; }
47 .note-title-input:focus { outline:none; }
48 .tag-row { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:10px; }
49 .tag-chip, .tag-picker, .tag-input { border:1px solid var(--border-color); border-radius:999px; padding:3px 9px; background:var(--bg-main); font-size:.75rem; }
50 .tag-chip { color:var(--text-secondary); }
51 .tag-input { color:var(--text-primary); min-width:120px; }
52 .tag-picker { color:var(--text-secondary); }
53 .note-excerpt-shell { position:relative; margin-bottom:12px; }
54 .note-excerpt { line-height:1.65; }
55 .note-excerpt-shell.collapsible:not(.expanded) .note-excerpt { max-height:calc(1.65em * 5); overflow:hidden; }
56 .note-excerpt-fade { display:none; position:absolute; left:0; right:0; bottom:0; height:58px; border:none; padding-top:24px; color:var(--text-secondary); background:linear-gradient(to bottom, rgba(247,249,249,0), var(--bg-secondary) 72%); }
57 body.dark-mode .note-excerpt-fade { background:linear-gradient(to bottom, rgba(22,24,28,0), var(--bg-secondary) 72%); }
58 .note-excerpt-shell.collapsible:not(.expanded) .note-excerpt-fade { display:block; }
59 .note-collapse-button { display:none; width:100%; border:none; background:transparent; color:var(--text-secondary); }
60 .note-excerpt-shell.collapsible.expanded + .note-collapse-button { display:block; }
61 .note-excerpt p:last-child { margin-bottom:0; }
62 .note-excerpt p { margin-bottom:.6em; }
63 .note-excerpt h1 { font-size:1.15rem; } .note-excerpt h2 { font-size:1.08rem; } .note-excerpt h3, .note-excerpt h4, .note-excerpt h5, .note-excerpt h6 { font-size:1rem; }
64 .note-editor { display:none; width:100%; min-height:120px; resize:vertical; border:1px solid var(--border-color); border-radius:8px; background:var(--bg-main); color:var(--text-primary); padding:10px; }
65 .note-meta { display:flex; justify-content:space-between; gap:10px; align-items:center; color:var(--text-secondary); font-size:.78rem; }
66 .note-actions { display:flex; gap:12px; align-items:center; }
67 .note-edit, .note-save, .note-cancel, .note-delete, .note-pin { border:none; background:transparent; color:var(--text-secondary); padding:0; }
68 .note-pin.active, .note-pin:hover, .note-edit:hover, .note-save:hover { color:var(--accent-color); }
69 .note-delete:hover { color:var(--danger-color); }
70 .empty-state { border:1px dashed var(--border-color); border-radius:8px; color:var(--text-secondary); min-height:180px; display:flex; align-items:center; justify-content:center; }
71 .pagination-bar { display:flex; justify-content:center; gap:8px; margin-top:20px; }
72 .page-link-btn { min-width:36px; height:36px; display:inline-flex; align-items:center; justify-content:center; border:1px solid var(--border-color); border-radius:8px; }
73 .page-link-btn.active { background:var(--accent-color); border-color:var(--accent-color); color:var(--ui-on-accent, #fff); }
74 .page-link-btn.disabled { opacity:.45; pointer-events:none; }
75 .page-footer { margin-top:42px; text-align:center; color:var(--text-secondary); }
76 .footer-sign { max-width:200px; width:80%; opacity:.8; mix-blend-mode:multiply; margin-bottom:8px; }
77 body.dark-mode .footer-sign { mix-blend-mode:screen; }
78 .page-footer p { margin-bottom:0; font-size:.8rem; }
79 @media (max-width:640px) { .page-head { align-items:flex-start; flex-direction:column; } .note-filters { grid-template-columns:1fr; } .note-meta { align-items:flex-start; flex-direction:column; } }
80 </style>
81 <link rel="stylesheet" href="/css/site-ui.css?v=3">
82 <link rel="stylesheet" href="/css/site-app.css?v=1">
83 </head>
84 <body data-ui-page="personalNotes" class="ui-shell <%= theme === 'dark' ? 'dark-mode' : '' %>">
85 <%- include('partials/site-header', { uiPage: 'personalNotes' }) %>
86 <main id="ui-content" tabindex="-1" class="page-shell">
87 <div class="page-head">
88 <h1 class="page-title">개인 노트</h1>
89 <span class="text-secondary small"><%= filteredCount === totalNotes ? totalNotes + '개 저장됨' : filteredCount + '개 표시 / ' + totalNotes + '개 저장됨' %></span>
90 </div>
91 <section class="composer">
92 <div class="composer-grid">
93 <input id="new-title" class="field" maxlength="80" placeholder="제목">
94 <textarea id="new-content" class="field" maxlength="10000" placeholder="내용을 적어보세요. 마크다운도 사용할 수 있어요."></textarea>
95 <input id="new-tags" class="field" placeholder="태그, 쉼표 구분">
96 </div>
97 <div class="composer-actions"><button id="create-note" class="action-btn"><i class="bi bi-plus-lg"></i> 새 노트 저장</button></div>
98 </section>
99 <div class="toolbar-row">
100 <a class="action-btn" href="/hinana/personal-notes/export/md"><i class="bi bi-download"></i> Markdown</a>
101 <a class="action-btn" href="/hinana/personal-notes/export/txt"><i class="bi bi-download"></i> TXT</a>
102 </div>
103 <form class="note-filters" method="GET" action="/hinana/personal-notes">
104 <input class="field" name="q" value="<%= search %>" placeholder="제목 또는 내용 검색">
105 <input class="field" type="date" name="date" value="<%= date %>">
106 <select class="field" name="tag"><option value="">모든 태그</option><% availableTags.forEach(function(item){ %><option value="<%= item %>" <%= item===tag?'selected':'' %>><%= item %></option><% }) %></select>
107 <select class="field" name="sort"><option value="new" <%= sort==='new'?'selected':'' %>>최신순</option><option value="old" <%= sort==='old'?'selected':'' %>>오래된순</option><option value="updated" <%= sort==='updated'?'selected':'' %>>최근 수정순</option><option value="pinned" <%= sort==='pinned'?'selected':'' %>>고정 우선</option></select>
108 <button class="action-btn"><i class="bi bi-search"></i> 검색</button>
109 </form>
110 <div id="note-list" class="note-list">
111 <% if (notes.length === 0) { %><div class="empty-state"><%= totalNotes===0?'저장한 노트가 아직 없어요.':'조건에 맞는 노트가 없어요.' %></div><% } %>
112 <% notes.forEach(function(note){ %>
113 <article class="note-card <%= note.pinned?'pinned':'' %>" data-note-id="<%= note.id %>">
114 <input class="note-title-input" value="<%= note.title %>">
115 <div class="tag-row">
116 <% (note.tags||[]).forEach(function(item){ %><span class="tag-chip">#<%= item %></span><% }) %>
117 <input class="tag-input" value="<%= (note.tags||[]).join(', ') %>">
118 <select class="tag-picker"><option value="">태그 추가</option><% tagCatalog.forEach(function(item){ %><option value="<%= item %>"><%= item %></option><% }) %></select>
119 </div>
120 <div class="note-excerpt-shell"><div class="note-excerpt"><%= note.content %></div><button class="note-excerpt-fade" type="button"><i class="bi bi-chevron-down"></i></button></div>
121 <button class="note-collapse-button" type="button"><i class="bi bi-chevron-up"></i></button>
122 <textarea class="note-editor"><%= note.content %></textarea>
123 <div class="note-meta">
124 <time><%= fmtDate(note.createdAt) %></time>
125 <div class="note-actions">
126 <button class="note-pin <%= note.pinned?'active':'' %>" type="button"><i class="bi bi-pin-angle<%= note.pinned?'-fill':'' %>"></i> 고정</button>
127 <a href="/hinana/personal-notes/<%= note.id %>/export/md">MD</a>
128 <a href="/hinana/personal-notes/<%= note.id %>/export/txt">TXT</a>
129 <button class="note-edit" type="button">수정</button>
130 <button class="note-save d-none" type="button">저장</button>
131 <button class="note-cancel d-none" type="button">취소</button>
132 <button class="note-delete" type="button">삭제</button>
133 </div>
134 </div>
135 </article>
136 <% }) %>
137 </div>
138 <% if (totalPages > 1) { %><nav class="pagination-bar"><% for(let p=1;p<=totalPages;p++){ %><a class="page-link-btn <%= p===currentPage?'active':'' %>" href="/hinana/personal-notes?page=<%= p %>&q=<%= encodeURIComponent(search) %>&date=<%= encodeURIComponent(date) %>&tag=<%= encodeURIComponent(tag) %>&sort=<%= encodeURIComponent(sort) %>"><%= p %></a><% } %></nav><% } %>
139 <footer class="page-footer">
140 <img src="/image/sign.png" alt="" class="footer-sign">
141 <p>X - @NoctchillHinana</p>
142 <p>ⓒ 2024~2026. 비나래 | hinana.moe All rights reserved.</p>
143 </footer>
144 </main>
145 <script>
146 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
147 const noteList = document.getElementById('note-list');
148 function renderMarkdown(el, md){ el.innerHTML = DOMPurify.sanitize(marked.parse(md,{breaks:true})); }
149 function refresh(card){ const ex=card.querySelector('.note-excerpt'), sh=card.querySelector('.note-excerpt-shell'); sh.classList.remove('collapsible','expanded'); requestAnimationFrame(()=>{ const lh=parseFloat(getComputedStyle(ex).lineHeight); if(ex.scrollHeight>(lh*5)+2) sh.classList.add('collapsible'); }); }
150 document.querySelectorAll('.note-card').forEach(card=>{ const ed=card.querySelector('.note-editor'); card.dataset.raw=ed.value; renderMarkdown(card.querySelector('.note-excerpt'),ed.value); refresh(card); });
151 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
152 noteList.addEventListener('click', async e=>{
153 const card=e.target.closest('.note-card'); if(!card) return;
154 if(e.target.closest('.note-excerpt-fade')) return card.querySelector('.note-excerpt-shell').classList.add('expanded');
155 if(e.target.closest('.note-collapse-button')) return card.querySelector('.note-excerpt-shell').classList.remove('expanded');
156 if(e.target.closest('.note-pin')){ const b=e.target.closest('.note-pin'), pinned=!b.classList.contains('active'); if(await patch(card,{pinned})){ b.classList.toggle('active',pinned); card.classList.toggle('pinned',pinned); } return; }
157 if(e.target.closest('.note-edit')){ card.querySelector('.note-excerpt').style.display='none'; card.querySelector('.note-editor').style.display='block'; card.querySelector('.note-edit').classList.add('d-none'); card.querySelector('.note-save').classList.remove('d-none'); card.querySelector('.note-cancel').classList.remove('d-none'); return; }
158 if(e.target.closest('.note-cancel')){ card.querySelector('.note-editor').value=card.dataset.raw; card.querySelector('.note-editor').style.display='none'; card.querySelector('.note-excerpt').style.display='block'; card.querySelector('.note-edit').classList.remove('d-none'); card.querySelector('.note-save').classList.add('d-none'); card.querySelector('.note-cancel').classList.add('d-none'); return; }
159 if(e.target.closest('.note-save')){ const content=card.querySelector('.note-editor').value.trim(); if(!content) return; if(await patch(card,{title:card.querySelector('.note-title-input').value.trim(),content,tags:card.querySelector('.tag-input').value.split(',').map(v=>v.trim()).filter(Boolean)})){ card.dataset.raw=content; renderMarkdown(card.querySelector('.note-excerpt'),content); card.querySelector('.note-editor').style.display='none'; card.querySelector('.note-excerpt').style.display='block'; card.querySelector('.note-edit').classList.remove('d-none'); card.querySelector('.note-save').classList.add('d-none'); card.querySelector('.note-cancel').classList.add('d-none'); refresh(card); } return; }
160 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
161 });
162 noteList.addEventListener('change', async e=>{ const card=e.target.closest('.note-card'); if(!card) return; if(e.target.matches('.tag-picker')&&e.target.value){ const input=card.querySelector('.tag-input'); const tags=input.value.split(',').map(v=>v.trim()).filter(Boolean); if(!tags.includes(e.target.value)){tags.push(e.target.value); input.value=tags.join(', ');} e.target.value=''; } if(e.target.matches('.tag-input,.tag-picker,.note-title-input')) await patch(card,{title:card.querySelector('.note-title-input').value.trim(),tags:card.querySelector('.tag-input').value.split(',').map(v=>v.trim()).filter(Boolean)}); });
163 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
164 </script>
165 </body>
166 </html>
167