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: white; }
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 </head>
266
267 <body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
268
269 <header class="global-header">
270 <div class="header-left">
271 <a href="/hinana/index">
272 <img src="/image/<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'archive1.png' : 'archive.png' %>"
273 alt="Hinana Archive" class="header-logo">
274 </a>
275 </div>
276
277 <nav class="header-nav">
278 <a href="/hinana/index" class="nav-link">Archive</a>
279 <a href="/hinana/info" class="nav-link">Info</a>
280 <a href="/hinana/blog" class="nav-link active">Blog</a>
281 <a href="/hinana/lounge" class="nav-link">Lounge</a>
282
283 <span class="nav-divider">|</span>
284
285 <% if(username) { %>
286 <a href="/logout?redirect=/hinana/blog" class="nav-link text-danger">Logout</a>
287 <% } else { %>
288 <a href="/login?redirect=/hinana/blog" class="nav-link login-link">Login</a>
289 <% } %>
290 </nav>
291
292 <div class="header-controls">
293 <a href="/hinana/gallery#brand-assets" class="nav-link" style="font-size:0.9rem;">사이트 맵</a>
294 <form action="/toggle-theme" method="POST" style="margin:0;">
295 <input type="hidden" name="redirect" value="/hinana/blog">
296 <button type="submit" class="icon-btn" title="테마 변경">
297 <i class="bi <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'bi-moon-stars-fill' : 'bi-sun-fill' %>"></i>
298 </button>
299 </form>
300 </div>
301 </header>
302
303 <div class="blog-container">
304
305 <div class="blog-header-title">
306 <div style="display:flex; flex-direction:column; gap:4px;">
307 <img src="/image/<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'library1.png' : 'library.png' %>" style="height:32px; align-self:flex-start;">
308 <div style="display:flex; align-items:center; gap:8px;">
309 <small>Archive My Memory</small>
310 <form action="/toggle-theme" method="POST" class="d-inline">
311 <button type="submit" class="icon-btn">
312 <i class="bi <%= (typeof theme !== 'undefined' && theme==='dark') ? 'bi-moon-stars-fill':'bi-sun-fill' %>"></i>
313 </button>
314 </form>
315 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
316 <a href="/hinana/write" class="icon-btn text-accent"><i class="bi bi-pencil-square"></i></a>
317 <% } %>
318 </div>
319 </div>
320 <form action="/hinana/blog" method="GET" style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
321 <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);">
322 <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);">
323 <option value="new" <%= (typeof sort !== 'undefined' && sort === 'new') || typeof sort === 'undefined' ? 'selected' : '' %>>최신순</option>
324 <option value="old" <%= (typeof sort !== 'undefined' && sort === 'old') ? 'selected' : '' %>>오래된순</option>
325 </select>
326 <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);">
327 <option value="" <%= !(typeof selectedTag !== 'undefined' && selectedTag) ? 'selected' : '' %>>전체</option>
328 <% if (typeof availableTags !== 'undefined' && availableTags.length) { %>
329 <% availableTags.forEach(function(tag) { %>
330 <option value="<%= tag %>" <%= (typeof selectedTag !== 'undefined' && selectedTag === tag) ? 'selected' : '' %>><%= tag %></option>
331 <% }) %>
332 <% } %>
333 </select>
334 <button class="btn btn-sm" style="background-color:var(--accent-color); color:white; border:none;">검색</button>
335 </form>
336 </div>
337
338 <div class="blog-list">
339 <% if(posts && posts.length > 0) { %>
340 <% posts.forEach(function(post) { %>
341 <%
342 // [안전한 스크립트 처리]
343 let thumbnailSrc = post.image || '';
344 let hasThumbnail = Boolean(thumbnailSrc);
345 let plainText = '';
346 const decodeHtmlText = function(value) {
347 return String(value || '')
348 .replace(/&nbsp;/g, ' ')
349 .replace(/&amp;/g, '&')
350 .replace(/&lt;/g, '<')
351 .replace(/&gt;/g, '>')
352 .replace(/&quot;/g, '"')
353 .replace(/&#39;/g, "'");
354 };
355
356 if (post.content) {
357 // 이미지 추출
358 const imgMatch = post.content.match(/<img[^>]+src="([^">]+)"/);
359 if (!thumbnailSrc && imgMatch) {
360 thumbnailSrc = imgMatch[1];
361 hasThumbnail = true;
362 }
363
364 // 텍스트만 추출 (태그 제거)
365 plainText = post.content
366 .replace(/<[^>]*>/g, '')
367 .replace(/&nbsp;/g, ' ')
368 .substring(0, 150);
369 plainText = decodeHtmlText(plainText) + '...';
370 }
371 %>
372
373 <div class="blog-item" onclick="location.href='/hinana/post/<%= post.id %>'">
374 <div class="blog-thumb">
375 <% if (hasThumbnail) { %>
376 <img src="<%= thumbnailSrc %>" alt="Thumbnail">
377 <% } else { %>
378 <div class="blog-thumb-empty" aria-hidden="true">
379 <div class="thumb-mark"><i class="bi bi-journal-bookmark-fill"></i></div>
380 <div class="thumb-label">Library Note</div>
381 <div class="thumb-title"><%= post.title %></div>
382 </div>
383 <% } %>
384 </div>
385 <div class="blog-info">
386 <div>
387 <div class="blog-title"><%= post.title %></div>
388 <div class="blog-preview"><%= plainText %></div>
389 <% if (post.tags && post.tags.length) { %>
390 <div class="blog-tags">
391 <% post.tags.forEach(function(tag) { %>
392 <a class="blog-tag-chip" href="/hinana/blog?tag=<%= encodeURIComponent(tag) %>&sort=<%= sort || 'new' %>" onclick="event.stopPropagation()">#<%= tag %></a>
393 <% }) %>
394 </div>
395 <% } %>
396 </div>
397 <div class="blog-meta">
398 <span>
399 <% if (typeof userProfileImages !== 'undefined' && userProfileImages[post.author]) { %>
400 <img src="<%= userProfileImages[post.author] %>" style="width:18px; height:18px; border-radius:50%; object-fit:cover; vertical-align:middle;" class="me-1">
401 <% } else { %>
402 <i class="bi bi-person-circle me-1"></i>
403 <% } %>
404 <%= post.author %>
405 </span>
406 <span><i class="bi bi-calendar3 me-1"></i> <%= fmtDate(post.createdAt, true) %></span>
407 <span class="ms-auto"><i class="bi bi-chat-dots"></i> <%= post.replyCount %></span>
408 </div>
409 </div>
410 </div>
411 <% }); %>
412 <% } else { %>
413 <div class="text-center py-5 text-secondary">등록된 글이 없습니다.</div>
414 <% } %>
415 </div>
416
417 <nav aria-label="Page navigation">
418 <ul class="pagination justify-content-center">
419 <li class="page-item <%= currentPage <= 1 ? 'disabled' : '' %>">
420 <a class="page-link" href="?page=<%= currentPage - 1 %>&sort=<%= sort || 'new' %>&keyword=<%= encodeURIComponent(keyword || '') %>&tag=<%= encodeURIComponent(typeof selectedTag !== 'undefined' ? selectedTag : '') %>">&laquo;</a>
421 </li>
422 <% for(let i = 1; i <= totalPages; i++) { %>
423 <li class="page-item <%= currentPage == i ? 'active' : '' %>">
424 <a class="page-link" href="?page=<%= i %>&sort=<%= sort || 'new' %>&keyword=<%= encodeURIComponent(keyword || '') %>&tag=<%= encodeURIComponent(typeof selectedTag !== 'undefined' ? selectedTag : '') %>"><%= i %></a>
425 </li>
426 <% } %>
427 <li class="page-item <%= currentPage >= totalPages ? 'disabled' : '' %>">
428 <a class="page-link" href="?page=<%= currentPage + 1 %>&sort=<%= sort || 'new' %>&keyword=<%= encodeURIComponent(keyword || '') %>&tag=<%= encodeURIComponent(typeof selectedTag !== 'undefined' ? selectedTag : '') %>">&raquo;</a>
429 </li>
430 </ul>
431 </nav>
432 </div>
433
434 <footer class="footer-area">
435 <div class="footer-brand">
436 <img src="/image/sign.png" alt="비나래" class="footer-logo">
437 <div>
438 <div class="footer-title">비나래 아카이브 도서관</div>
439 <div class="footer-sub">Archive My Memory</div>
440 </div>
441 </div>
442 <div>
443 <div class="footer-links">
444 <a href="/hinana/index">Archive</a>
445 <a href="/hinana/blog">Blog</a>
446 <a href="/hinana/lounge">Lounge</a>
447 <a href="/hinana/developer">Developer</a>
448 <a href="https://x.com/NoctchillHinana">X</a>
449 </div>
450 <div class="footer-copy"><span class="footer-version">Ver. 8.0.3.2-Utsumi Aoba</span>ⓒ 2024~2026. 비나래 | hinana.moe</div>
451 </div>
452 </footer>
453
454 <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); }</script>
455 </body>
456 </html>
457