Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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:#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
</head>
82
<body class="<%= theme === 'dark' ? 'dark-mode' : '' %>">
83
<header class="global-header">
84
<a href="/hinana/index"><img src="/image/<%= theme === 'dark' ? 'archive1.png' : 'archive.png' %>" alt="Hinana Archive" class="header-logo"></a>
85
<div class="header-actions">
86
<a href="/hinana/ai/notes" class="btn btn-outline-secondary btn-sm"><i class="bi bi-stars"></i> AI 노트</a>
87
<form action="/toggle-theme" method="POST" style="margin:0;">
88
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
89
<input type="hidden" name="redirect" value="/hinana/personal-notes">
90
<button type="submit" class="btn btn-outline-secondary btn-sm"><i class="bi <%= theme === 'dark' ? 'bi-moon-stars-fill' : 'bi-sun-fill' %>"></i></button>
91
</form>
92
</div>
93
</header>
94
<main class="page-shell">
95
<div class="page-head">
96
<h1 class="page-title">개인 노트</h1>
97
<span class="text-secondary small"><%= filteredCount === totalNotes ? totalNotes + '개 저장됨' : filteredCount + '개 표시 / ' + totalNotes + '개 저장됨' %></span>
98
</div>
99
<section class="composer">
100
<div class="composer-grid">
101
<input id="new-title" class="field" maxlength="80" placeholder="제목">
102
<textarea id="new-content" class="field" maxlength="10000" placeholder="내용을 적어보세요. 마크다운도 사용할 수 있어요."></textarea>
103
<input id="new-tags" class="field" placeholder="태그, 쉼표 구분">
104
</div>
105
<div class="composer-actions"><button id="create-note" class="action-btn"><i class="bi bi-plus-lg"></i> 새 노트 저장</button></div>
106
</section>
107
<div class="toolbar-row">
108
<a class="action-btn" href="/hinana/personal-notes/export/md"><i class="bi bi-download"></i> Markdown</a>
109
<a class="action-btn" href="/hinana/personal-notes/export/txt"><i class="bi bi-download"></i> TXT</a>
110
</div>
111
<form class="note-filters" method="GET" action="/hinana/personal-notes">
112
<input class="field" name="q" value="<%= search %>" placeholder="제목 또는 내용 검색">
113
<input class="field" type="date" name="date" value="<%= date %>">
114
<select class="field" name="tag"><option value="">모든 태그</option><% availableTags.forEach(function(item){ %><option value="<%= item %>" <%= item===tag?'selected':'' %>><%= item %></option><% }) %></select>
115
<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>
116
<button class="action-btn"><i class="bi bi-search"></i> 검색</button>
117
</form>
118
<div id="note-list" class="note-list">
119
<% if (notes.length === 0) { %><div class="empty-state"><%= totalNotes===0?'저장한 노트가 아직 없어요.':'조건에 맞는 노트가 없어요.' %></div><% } %>
120
<% notes.forEach(function(note){ %>
121
<article class="note-card <%= note.pinned?'pinned':'' %>" data-note-id="<%= note.id %>">
122
<input class="note-title-input" value="<%= note.title %>">
123
<div class="tag-row">
124
<% (note.tags||[]).forEach(function(item){ %><span class="tag-chip">#<%= item %></span><% }) %>
125
<input class="tag-input" value="<%= (note.tags||[]).join(', ') %>">
126
<select class="tag-picker"><option value="">태그 추가</option><% tagCatalog.forEach(function(item){ %><option value="<%= item %>"><%= item %></option><% }) %></select>
127
</div>
128
<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>
129
<button class="note-collapse-button" type="button"><i class="bi bi-chevron-up"></i></button>
130
<textarea class="note-editor"><%= note.content %></textarea>
131
<div class="note-meta">
132
<time><%= fmtDate(note.createdAt) %></time>
133
<div class="note-actions">
134
<button class="note-pin <%= note.pinned?'active':'' %>" type="button"><i class="bi bi-pin-angle<%= note.pinned?'-fill':'' %>"></i> 고정</button>
135
<a href="/hinana/personal-notes/<%= note.id %>/export/md">MD</a>
136
<a href="/hinana/personal-notes/<%= note.id %>/export/txt">TXT</a>
137
<button class="note-edit" type="button">수정</button>
138
<button class="note-save d-none" type="button">저장</button>
139
<button class="note-cancel d-none" type="button">취소</button>
140
<button class="note-delete" type="button">삭제</button>
141
</div>
142
</div>
143
</article>
144
<% }) %>
145
</div>
146
<% 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><% } %>
147
<footer class="page-footer">
148
<img src="/image/sign.png" alt="" class="footer-sign">
149
<p>X - @NoctchillHinana</p>
150
<p>ⓒ 2024~2026. 비나래 | hinana.moe All rights reserved.</p>
151
</footer>
152
</main>
153
<script>
154
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
155
const noteList = document.getElementById('note-list');
156
function renderMarkdown(el, md){ el.innerHTML = DOMPurify.sanitize(marked.parse(md,{breaks:true})); }
157
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'); }); }
158
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); });
159
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
160
noteList.addEventListener('click', async e=>{
161
const card=e.target.closest('.note-card'); if(!card) return;
162
if(e.target.closest('.note-excerpt-fade')) return card.querySelector('.note-excerpt-shell').classList.add('expanded');
163
if(e.target.closest('.note-collapse-button')) return card.querySelector('.note-excerpt-shell').classList.remove('expanded');
164
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; }
165
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; }
166
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; }
167
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; }
168
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
169
});
170
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)}); });
171
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
172
</script>
173
</body>
174
</html>
175