본문으로 건너뛰기
Public Source Viewer

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

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

Redacted View
view/hinana/blog.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 <link rel="manifest" href="/manifest.json">
8 <meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#000000' : '#ffffff' %>">
9 <meta name="apple-mobile-web-app-title" content="비나래 아카이브">
10 <%
11 const pageMeta = typeof metaData !== 'undefined' ? metaData : {
12 title: '비나래 도서관',
13 description: '비나래가 기록한 이야기와 아카이브 글을 모아 보는 도서관입니다.',
14 url: 'https://hinana.moe/hinana/blog',
15 image: 'https://hinana.moe/image/title.png'
16 };
17 %>
18 <meta name="description" content="<%= pageMeta.description %>">
19 <meta property="og:type" content="website">
20 <meta property="og:site_name" content="비나래 아카이브">
21 <meta property="og:title" content="<%= pageMeta.title %>">
22 <meta property="og:description" content="<%= pageMeta.description %>">
23 <meta property="og:url" content="<%= pageMeta.url %>">
24 <meta property="og:image" content="<%= pageMeta.image %>">
25 <meta name="twitter:card" content="summary_large_image">
26 <meta name="twitter:title" content="<%= pageMeta.title %>">
27 <meta name="twitter:description" content="<%= pageMeta.description %>">
28 <meta name="twitter:image" content="<%= pageMeta.image %>">
29 <link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
30 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
31 <link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet" type="text/css">
32 <link href="https://fonts.googleapis.com/css?family=Lato" rel="stylesheet" type="text/css">
33 <link rel="stylesheet" href="/css/hinana.css" type="text/css">
34 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
35 <script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
36 <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.3/umd/popper.min.js" integrity="sha384-vFJXuSJphROIrBnz7yo7oB41mKfc8JzQZiCq4NCceLEaO4IHwicKwpJf9c9IpFgh" crossorigin="anonymous"></script>
37 <title><%= pageMeta.title %> - 비나래 아카이브</title>
38 <style>
39 /* [테마 변수 - Index와 동일] */
40 :root {
41 --font-family: 'Noto Sans KR', sans-serif;
42 --bg-main: #ffffff;
43 --bg-secondary: #f7f9f9;
44 --bg-tertiary: #eff3f4;
45 --text-primary: #0f1419;
46 --text-secondary: #536471;
47 --accent-color: #1d9bf0;
48 --danger-color: #f4212e;
49 --border-color: #eff3f4;
50 --shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
51 --shadow-md: 0 4px 12px rgba(0,0,0,0.10);
52 }
53 body.dark-mode {
54 --bg-main: #000000;
55 --bg-secondary: #16181c;
56 --bg-tertiary: #202327;
57 --text-primary: #e7e9ea;
58 --text-secondary: #71767b;
59 --border-color: #2f3336;
60 --accent-color: #1d9bf0;
61 --danger-color: #f4212e;
62 --shadow-sm: 0 1px 3px rgba(255,255,255,0.04);
63 --shadow-md: 0 4px 12px rgba(0,0,0,0.6);
64 }
65
66 html, body { height: 100%; margin: 0; font-family: var(--font-family); background-color: var(--bg-main); color: var(--text-primary); overflow-y: auto; }
67 a { text-decoration: none; color: inherit; }
68 ul { list-style: none; padding: 0; margin: 0; }
69
70 /* [헤더 스타일] */
71 .global-header {
72 height: 60px;
73 background-color: rgba(255,255,255,0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
74 border-bottom: 1px solid var(--border-color);
75 display: flex; align-items: center; justify-content: space-between; padding: 0 20px;
76 position: sticky; top: 0; z-index: 1000;
77 }
78 body.dark-mode .global-header { background-color: rgba(0,0,0,0.85); }
79 .header-left { display: flex; align-items: center; }
80 .header-logo { height: 28px; width: auto; }
81
82 .header-nav {
83 position: absolute; left: 50%; transform: translateX(-50%);
84 display: flex; gap: 20px; align-items: center; z-index: 5;
85 }
86 .nav-link { font-weight: 600; font-size: 0.95rem; color: var(--text-secondary); cursor: pointer; white-space: nowrap; }
87 .nav-link:hover { color: var(--accent-color); }
88 .nav-link.active { color: var(--text-primary); }
89 .nav-divider { opacity: 0.3; font-weight: normal; }
90 .login-link { color: var(--accent-color); }
91
92 .header-controls { display: flex; align-items: center; gap: 10px; z-index: 10; position: relative; }
93 .icon-btn { border: none; background: transparent; color: var(--text-secondary); font-size: 1.1rem; cursor: pointer; padding: 4px; }
94 .icon-btn:hover { color: var(--text-primary); }
95
96 /* [블로그 컨테이너] */
97 .blog-container { max-width: 900px; margin: 0 auto; padding: 40px 20px; min-height: 80vh; }
98
99 .blog-header-title {
100 display: flex; justify-content: space-between; align-items: flex-end; flex-wrap: wrap;
101 border-bottom: 2px solid var(--border-color); padding-bottom: 15px; margin-bottom: 30px;
102 gap: 15px;
103 }
104 .blog-header-title h2 { font-weight: 700; margin: 0; color: var(--text-primary); display: flex; align-items: center; gap: 10px; }
105
106 /* [리스트 아이템] */
107 .blog-item {
108 display: flex; background-color: var(--bg-secondary); border: 1px solid var(--border-color);
109 border-radius: 8px; overflow: hidden; margin-bottom: 20px; height: 180px;
110 cursor: pointer; transition: all 0.2s;
111 }
112 .blog-item:hover { transform: translateY(-3px); box-shadow: var(--shadow-sm); border-color: var(--accent-color); }
113
114 /* 썸네일 */
115 .blog-thumb { width: 220px; min-width: 220px; background-color: var(--bg-tertiary); overflow: hidden; display: flex; align-items: center; justify-content: center; position: relative; }
116 .blog-thumb img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s; }
117 .blog-item:hover .blog-thumb img { transform: scale(1.05); }
118 .blog-thumb-empty {
119 width: 100%; height: 100%; padding: 22px;
120 display: flex; flex-direction: column; align-items: center; justify-content: center;
121 background:
122 linear-gradient(135deg, rgba(29,155,240,0.12), transparent 42%),
123 radial-gradient(circle at 78% 22%, rgba(0,186,124,0.14), transparent 34%),
124 var(--bg-tertiary);
125 color: var(--text-primary);
126 text-align: center;
127 }
128 .blog-thumb-empty::before {
129 content: "";
130 position: absolute; inset: 14px;
131 border: 1px solid color-mix(in srgb, var(--accent-color) 26%, transparent);
132 border-radius: 6px;
133 pointer-events: none;
134 }
135 .thumb-mark {
136 width: 54px; height: 54px; border-radius: 50%;
137 display: flex; align-items: center; justify-content: center;
138 background: var(--bg-main); color: var(--accent-color);
139 border: 1px solid var(--border-color);
140 box-shadow: var(--shadow-sm);
141 font-size: 1.5rem;
142 margin-bottom: 12px;
143 }
144 .thumb-label { font-size: 0.72rem; font-weight: 800; color: var(--accent-color); letter-spacing: 0.16em; text-transform: uppercase; }
145 .thumb-title {
146 width: 100%; max-width: 150px; margin-top: 6px;
147 color: var(--text-secondary); font-size: 0.78rem; font-weight: 700;
148 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
149 }
150 body.dark-mode .blog-thumb-empty {
151 background:
152 linear-gradient(135deg, rgba(29,155,240,0.18), transparent 42%),
153 radial-gradient(circle at 78% 22%, rgba(0,186,124,0.16), transparent 34%),
154 var(--bg-tertiary);
155 }
156
157 /* 내용 */
158 .blog-info { flex: 1; padding: 20px; display: flex; flex-direction: column; justify-content: space-between; }
159 .blog-title { font-size: 1.2rem; font-weight: 700; color: var(--accent-color); margin-bottom: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
160 .blog-preview { font-size: 0.9rem; color: var(--text-primary); line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
161 .blog-meta { font-size: 0.8rem; color: var(--text-secondary); display: flex; gap: 15px; border-top: 1px solid var(--border-color); padding-top: 10px; }
162 .blog-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
163 .blog-tag-chip { display: inline-flex; align-items: center; border-radius: 999px; padding: 3px 10px; background: rgba(29, 155, 240, 0.12); color: var(--accent-color); font-size: 0.75rem; font-weight: 700; text-decoration: none; }
164
165 .pagination { margin-top: 40px; }
166 .page-link { color: var(--text-secondary); background-color: var(--bg-secondary); border-color: var(--border-color); }
167 .page-item.active .page-link { background-color: var(--accent-color); border-color: var(--accent-color); color: var(--ui-on-accent, #fff); }
168
169 .footer-area {
170 max-width: 900px;
171 margin: 48px auto 0;
172 padding: 28px 20px 40px;
173 color: var(--text-secondary);
174 font-size: 0.78rem;
175 border-top: 1px solid var(--border-color);
176 display: flex;
177 align-items: center;
178 justify-content: space-between;
179 gap: 20px;
180 }
181 .footer-brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
182 .footer-logo {
183 width: 44px; height: 44px; object-fit: contain;
184 opacity: 0.72; mix-blend-mode: multiply;
185 flex: 0 0 auto;
186 }
187 body.dark-mode .footer-logo { mix-blend-mode: screen; opacity: 0.86; }
188 .footer-title { font-weight: 800; color: var(--text-primary); font-size: 0.9rem; }
189 .footer-sub { margin-top: 2px; }
190 .footer-links { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
191 .footer-links a { color: var(--text-secondary); font-weight: 700; }
192 .footer-links a:hover { color: var(--accent-color); }
193 .footer-copy { width: 100%; text-align: right; font-size: 0.72rem; }
194 .footer-version {
195 display: inline-flex; align-items: center; min-height: 20px;
196 padding: 0 8px; margin-right: 8px; border: 1px solid var(--border-color);
197 border-radius: 999px; color: var(--accent-color); font-weight: 800;
198 background: var(--bg-secondary);
199 }
200
201 /* 반응형 */
202 @media (max-width: 768px) {
203 .global-header { flex-wrap: wrap; height: auto; padding: 10px 20px; }
204 .header-nav {
205 position: static; transform: none; width: 100%; order: 3; justify-content: center;
206 margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(0,0,0,0.05);
207 }
208 .header-left { flex: 1; order: 1; }
209 .header-controls { flex: auto; justify-content: flex-end; background-color: transparent; order: 2; }
210
211 .blog-container { padding-top: 20px; }
212 .blog-item { flex-direction: column; height: auto; }
213 .blog-thumb { width: 100%; height: 160px; }
214 .footer-area { flex-direction: column; align-items: flex-start; margin-top: 36px; }
215 .footer-links { justify-content: flex-start; }
216 .footer-copy { text-align: left; }
217 }
218 .d-none { display: none !important; }
219 footer, .footer, .footer-info {
220 background-color: transparent !important; /* 배경 투명 강제 */
221 background: transparent !important;
222 box-shadow: none !important; /* 그림자 제거 */
223 color: var(--text-secondary) !important; /* 글자색 테마 맞춤 */
224 }
225
226 .blog-info {
227 flex: 1;
228 padding: 20px;
229 display: flex;
230 flex-direction: column;
231 justify-content: space-between;
232
233 /* ★핵심: Flex 자식 요소가 부모 영역을 넘어가지 않도록 최소 너비 0 설정 */
234 min-width: 0;
235 }
236
237 .blog-title {
238 font-size: 1.2rem;
239 font-weight: 700;
240 color: var(--accent-color);
241 margin-bottom: 8px;
242
243 /* ★핵심: 한 줄 말줄임 처리 */
244 white-space: nowrap;
245 overflow: hidden;
246 text-overflow: ellipsis;
247 }
248
249 .blog-preview {
250 font-size: 0.9rem;
251 color: var(--text-primary);
252 line-height: 1.6;
253
254 /* ★핵심: 여러 줄 말줄임 및 강제 줄바꿈 */
255 display: -webkit-box;
256 -webkit-line-clamp: 3; /* 3줄까지만 표시 */
257 -webkit-box-orient: vertical;
258 overflow: hidden;
259
260 /* 긴 단어 강제 줄바꿈 (이게 없으면 뚫고 나감) */
261 word-break: break-all;
262 overflow-wrap: break-word;
263 }
264 </style>
265 <link rel="stylesheet" href="/css/site-ui.css?v=3">
266 <link rel="stylesheet" href="/css/site-app.css?v=1">
267 </head>
268
269 <body data-ui-page="blog" class="ui-shell <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
270
271 <%- include('partials/site-header', { uiPage: 'blog' }) %>
272 <div id="ui-content" tabindex="-1" class="ui-content-anchor"></div>
273
274 <div class="blog-container">
275
276 <div class="blog-header-title">
277 <div style="display:flex; flex-direction:column; gap:4px;">
278 <img src="/image/<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'library1.png' : 'library.png' %>" style="height:32px; align-self:flex-start;">
279 <div style="display:flex; align-items:center; gap:8px;">
280 <small>Archive My Memory</small>
281 <form action="/toggle-theme" method="POST" class="d-inline">
282 <button type="submit" class="icon-btn">
283 <i class="bi <%= (typeof theme !== 'undefined' && theme==='dark') ? 'bi-moon-stars-fill':'bi-sun-fill' %>"></i>
284 </button>
285 </form>
286 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
287 <a href="/hinana/write" class="icon-btn text-accent"><i class="bi bi-pencil-square"></i></a>
288 <% } %>
289 </div>
290 </div>
291 <form action="/hinana/blog" method="GET" style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
292 <input type="text" name="keyword" value="<%= keyword || '' %>" class="form-control form-control-sm" placeholder="제목/내용 검색..." style="width:180px; background-color:var(--bg-secondary); color:var(--text-primary); border-color:var(--border-color);">
293 <select name="sort" class="form-select form-select-sm" onchange="this.form.submit()" style="width:auto; background-color:var(--bg-secondary); color:var(--text-primary); border-color:var(--border-color);">
294 <option value="new" <%= (typeof sort !== 'undefined' && sort === 'new') || typeof sort === 'undefined' ? 'selected' : '' %>>최신순</option>
295 <option value="old" <%= (typeof sort !== 'undefined' && sort === 'old') ? 'selected' : '' %>>오래된순</option>
296 </select>
297 <select name="tag" class="form-select form-select-sm" onchange="this.form.submit()" style="width:auto; background-color:var(--bg-secondary); color:var(--text-primary); border-color:var(--border-color);">
298 <option value="" <%= !(typeof selectedTag !== 'undefined' && selectedTag) ? 'selected' : '' %>>전체</option>
299 <% if (typeof availableTags !== 'undefined' && availableTags.length) { %>
300 <% availableTags.forEach(function(tag) { %>
301 <option value="<%= tag %>" <%= (typeof selectedTag !== 'undefined' && selectedTag === tag) ? 'selected' : '' %>><%= tag %></option>
302 <% }) %>
303 <% } %>
304 </select>
305 <button class="ui-accent-control btn btn-sm" style="background-color:var(--accent-color); color:var(--ui-on-accent, #fff); border:none;">검색</button>
306 </form>
307 </div>
308
309 <div class="blog-list">
310 <% if(posts && posts.length > 0) { %>
311 <% posts.forEach(function(post) { %>
312 <%
313 // [안전한 스크립트 처리]
314 let thumbnailSrc = post.image || '';
315 let hasThumbnail = Boolean(thumbnailSrc);
316 let plainText = '';
317 const decodeHtmlText = function(value) {
318 return String(value || '')
319 .replace(/&nbsp;/g, ' ')
320 .replace(/&amp;/g, '&')
321 .replace(/&lt;/g, '<')
322 .replace(/&gt;/g, '>')
323 .replace(/&quot;/g, '"')
324 .replace(/&#39;/g, "'");
325 };
326
327 if (post.content) {
328 // 이미지 추출
329 const imgMatch = post.content.match(/<img[^>]+src="([^">]+)"/);
330 if (!thumbnailSrc && imgMatch) {
331 thumbnailSrc = imgMatch[1];
332 hasThumbnail = true;
333 }
334
335 // 텍스트만 추출 (태그 제거)
336 plainText = post.content
337 .replace(/<[^>]*>/g, '')
338 .replace(/&nbsp;/g, ' ')
339 .substring(0, 150);
340 plainText = decodeHtmlText(plainText) + '...';
341 }
342 %>
343
344 <div class="blog-item" onclick="location.href='/hinana/post/<%= post.id %>'">
345 <div class="blog-thumb">
346 <% if (hasThumbnail) { %>
347 <img src="<%= thumbnailSrc %>" alt="Thumbnail">
348 <% } else { %>
349 <div class="blog-thumb-empty" aria-hidden="true">
350 <div class="thumb-mark"><i class="bi bi-journal-bookmark-fill"></i></div>
351 <div class="thumb-label">Library Note</div>
352 <div class="thumb-title"><%= post.title %></div>
353 </div>
354 <% } %>
355 </div>
356 <div class="blog-info">
357 <div>
358 <div class="blog-title"><%= post.title %></div>
359 <div class="blog-preview"><%= plainText %></div>
360 <% if (post.tags && post.tags.length) { %>
361 <div class="blog-tags">
362 <% post.tags.forEach(function(tag) { %>
363 <a class="blog-tag-chip" href="/hinana/blog?tag=<%= encodeURIComponent(tag) %>&sort=<%= sort || 'new' %>" onclick="event.stopPropagation()">#<%= tag %></a>
364 <% }) %>
365 </div>
366 <% } %>
367 </div>
368 <div class="blog-meta">
369 <span>
370 <% if (typeof userProfileImages !== 'undefined' && userProfileImages[post.author]) { %>
371 <img src="<%= userProfileImages[post.author] %>" style="width:18px; height:18px; border-radius:50%; object-fit:cover; vertical-align:middle;" class="me-1">
372 <% } else { %>
373 <i class="bi bi-person-circle me-1"></i>
374 <% } %>
375 <%= post.author %>
376 </span>
377 <span><i class="bi bi-calendar3 me-1"></i> <%= fmtDate(post.createdAt, true) %></span>
378 <span class="ms-auto"><i class="bi bi-chat-dots"></i> <%= post.replyCount %></span>
379 </div>
380 </div>
381 </div>
382 <% }); %>
383 <% } else { %>
384 <div class="text-center py-5 text-secondary">등록된 글이 없습니다.</div>
385 <% } %>
386 </div>
387
388 <nav aria-label="Page navigation">
389 <ul class="pagination justify-content-center">
390 <li class="page-item <%= currentPage <= 1 ? 'disabled' : '' %>">
391 <a class="page-link" href="?page=<%= currentPage - 1 %>&sort=<%= sort || 'new' %>&keyword=<%= encodeURIComponent(keyword || '') %>&tag=<%= encodeURIComponent(typeof selectedTag !== 'undefined' ? selectedTag : '') %>">&laquo;</a>
392 </li>
393 <% for(let i = 1; i <= totalPages; i++) { %>
394 <li class="page-item <%= currentPage == i ? 'active' : '' %>">
395 <a class="page-link" href="?page=<%= i %>&sort=<%= sort || 'new' %>&keyword=<%= encodeURIComponent(keyword || '') %>&tag=<%= encodeURIComponent(typeof selectedTag !== 'undefined' ? selectedTag : '') %>"><%= i %></a>
396 </li>
397 <% } %>
398 <li class="page-item <%= currentPage >= totalPages ? 'disabled' : '' %>">
399 <a class="page-link" href="?page=<%= currentPage + 1 %>&sort=<%= sort || 'new' %>&keyword=<%= encodeURIComponent(keyword || '') %>&tag=<%= encodeURIComponent(typeof selectedTag !== 'undefined' ? selectedTag : '') %>">&raquo;</a>
400 </li>
401 </ul>
402 </nav>
403 </div>
404
405 <footer class="footer-area">
406 <div class="footer-brand">
407 <img src="/image/sign.png" alt="비나래" class="footer-logo">
408 <div>
409 <div class="footer-title">비나래 아카이브 도서관</div>
410 <div class="footer-sub">Archive My Memory</div>
411 </div>
412 </div>
413 <div>
414 <div class="footer-links">
415 <a href="/hinana/index">Archive</a>
416 <a href="/hinana/blog">Blog</a>
417 <a href="/hinana/lounge">Lounge</a>
418 <a href="/hinana/developer">Developer</a>
419 <a href="https://x.com/NoctchillHinana">X</a>
420 </div>
421 <div class="footer-copy"><span class="footer-version">Ver. 9.2.1.0 - Iochi Mari</span>ⓒ 2024~2026. 비나래 | hinana.moe</div>
422 </div>
423 </footer>
424
425 <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); }</script>
426 </body>
427 </html>
428