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
163 .pagination { margin-top: 40px; }
164 .page-link { color: var(--text-secondary); background-color: var(--bg-secondary); border-color: var(--border-color); }
165 .page-item.active .page-link { background-color: var(--accent-color); border-color: var(--accent-color); color: white; }
166
167 .footer-area {
168 max-width: 900px;
169 margin: 48px auto 0;
170 padding: 28px 20px 40px;
171 color: var(--text-secondary);
172 font-size: 0.78rem;
173 border-top: 1px solid var(--border-color);
174 display: flex;
175 align-items: center;
176 justify-content: space-between;
177 gap: 20px;
178 }
179 .footer-brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
180 .footer-logo {
181 width: 44px; height: 44px; object-fit: contain;
182 opacity: 0.72; mix-blend-mode: multiply;
183 flex: 0 0 auto;
184 }
185 body.dark-mode .footer-logo { mix-blend-mode: screen; opacity: 0.86; }
186 .footer-title { font-weight: 800; color: var(--text-primary); font-size: 0.9rem; }
187 .footer-sub { margin-top: 2px; }
188 .footer-links { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
189 .footer-links a { color: var(--text-secondary); font-weight: 700; }
190 .footer-links a:hover { color: var(--accent-color); }
191 .footer-copy { width: 100%; text-align: right; font-size: 0.72rem; }
192
193 /* 반응형 */
194 @media (max-width: 768px) {
195 .global-header { flex-wrap: wrap; height: auto; padding: 10px 20px; }
196 .header-nav {
197 position: static; transform: none; width: 100%; order: 3; justify-content: center;
198 margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(0,0,0,0.05);
199 }
200 .header-left { flex: 1; order: 1; }
201 .header-controls { flex: auto; justify-content: flex-end; background-color: transparent; order: 2; }
202
203 .blog-container { padding-top: 20px; }
204 .blog-item { flex-direction: column; height: auto; }
205 .blog-thumb { width: 100%; height: 160px; }
206 .footer-area { flex-direction: column; align-items: flex-start; margin-top: 36px; }
207 .footer-links { justify-content: flex-start; }
208 .footer-copy { text-align: left; }
209 }
210 .d-none { display: none !important; }
211 footer, .footer, .footer-info {
212 background-color: transparent !important; /* 배경 투명 강제 */
213 background: transparent !important;
214 box-shadow: none !important; /* 그림자 제거 */
215 color: var(--text-secondary) !important; /* 글자색 테마 맞춤 */
216 }
217
218 .blog-info {
219 flex: 1;
220 padding: 20px;
221 display: flex;
222 flex-direction: column;
223 justify-content: space-between;
224
225 /* ★핵심: Flex 자식 요소가 부모 영역을 넘어가지 않도록 최소 너비 0 설정 */
226 min-width: 0;
227 }
228
229 .blog-title {
230 font-size: 1.2rem;
231 font-weight: 700;
232 color: var(--accent-color);
233 margin-bottom: 8px;
234
235 /* ★핵심: 한 줄 말줄임 처리 */
236 white-space: nowrap;
237 overflow: hidden;
238 text-overflow: ellipsis;
239 }
240
241 .blog-preview {
242 font-size: 0.9rem;
243 color: var(--text-primary);
244 line-height: 1.6;
245
246 /* ★핵심: 여러 줄 말줄임 및 강제 줄바꿈 */
247 display: -webkit-box;
248 -webkit-line-clamp: 3; /* 3줄까지만 표시 */
249 -webkit-box-orient: vertical;
250 overflow: hidden;
251
252 /* 긴 단어 강제 줄바꿈 (이게 없으면 뚫고 나감) */
253 word-break: break-all;
254 overflow-wrap: break-word;
255 }
256 </style>
257 </head>
258
259 <body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
260
261 <header class="global-header">
262 <div class="header-left">
263 <a href="/hinana/index">
264 <img src="/image/<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'archive1.png' : 'archive.png' %>"
265 alt="Hinana Archive" class="header-logo">
266 </a>
267 </div>
268
269 <nav class="header-nav">
270 <a href="/hinana/index" class="nav-link">Archive</a>
271 <a href="/hinana/info" class="nav-link">Info</a>
272 <a href="/hinana/blog" class="nav-link active">Blog</a>
273 <a href="/hinana/lounge" class="nav-link">Lounge</a>
274
275 <span class="nav-divider">|</span>
276
277 <% if(username) { %>
278 <a href="/logout?redirect=/hinana/blog" class="nav-link text-danger">Logout</a>
279 <% } else { %>
280 <a href="/login?redirect=/hinana/blog" class="nav-link login-link">Login</a>
281 <% } %>
282 </nav>
283
284 <div class="header-controls">
285 <a href="/hinana/gallery#brand-assets" class="nav-link" style="font-size:0.9rem;">사이트 맵</a>
286 <form action="/toggle-theme" method="POST" style="margin:0;">
287 <input type="hidden" name="redirect" value="/hinana/blog">
288 <button type="submit" class="icon-btn" title="테마 변경">
289 <i class="bi <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'bi-moon-stars-fill' : 'bi-sun-fill' %>"></i>
290 </button>
291 </form>
292 </div>
293 </header>
294
295 <div class="blog-container">
296
297 <div class="blog-header-title">
298 <div style="display:flex; flex-direction:column; gap:4px;">
299 <img src="/image/<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'library1.png' : 'library.png' %>" style="height:32px; align-self:flex-start;">
300 <div style="display:flex; align-items:center; gap:8px;">
301 <small>Archive My Memory</small>
302 <form action="/toggle-theme" method="POST" class="d-inline">
303 <button type="submit" class="icon-btn">
304 <i class="bi <%= (typeof theme !== 'undefined' && theme==='dark') ? 'bi-moon-stars-fill':'bi-sun-fill' %>"></i>
305 </button>
306 </form>
307 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
308 <a href="/hinana/write" class="icon-btn text-accent"><i class="bi bi-pencil-square"></i></a>
309 <% } %>
310 </div>
311 </div>
312 <form action="/hinana/blog" method="GET" style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
313 <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);">
314 <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);">
315 <option value="new" <%= (typeof sort !== 'undefined' && sort === 'new') || typeof sort === 'undefined' ? 'selected' : '' %>>최신순</option>
316 <option value="old" <%= (typeof sort !== 'undefined' && sort === 'old') ? 'selected' : '' %>>오래된순</option>
317 </select>
318 <button class="btn btn-sm" style="background-color:var(--accent-color); color:white; border:none;">검색</button>
319 </form>
320 </div>
321
322 <div class="blog-list">
323 <% if(posts && posts.length > 0) { %>
324 <% posts.forEach(function(post) { %>
325 <%
326 // [안전한 스크립트 처리]
327 let thumbnailSrc = post.image || '';
328 let hasThumbnail = Boolean(thumbnailSrc);
329 let plainText = '';
330
331 if (post.content) {
332 // 이미지 추출
333 const imgMatch = post.content.match(/<img[^>]+src="([^">]+)"/);
334 if (!thumbnailSrc && imgMatch) {
335 thumbnailSrc = imgMatch[1];
336 hasThumbnail = true;
337 }
338
339 // 텍스트만 추출 (태그 제거)
340 plainText = post.content
341 .replace(/<[^>]*>/g, '')
342 .replace(/&nbsp;/g, ' ')
343 .substring(0, 150) + '...';
344 }
345 %>
346
347 <div class="blog-item" onclick="location.href='/hinana/post/<%= post.id %>'">
348 <div class="blog-thumb">
349 <% if (hasThumbnail) { %>
350 <img src="<%= thumbnailSrc %>" alt="Thumbnail">
351 <% } else { %>
352 <div class="blog-thumb-empty" aria-hidden="true">
353 <div class="thumb-mark"><i class="bi bi-journal-bookmark-fill"></i></div>
354 <div class="thumb-label">Library Note</div>
355 <div class="thumb-title"><%= post.title %></div>
356 </div>
357 <% } %>
358 </div>
359 <div class="blog-info">
360 <div>
361 <div class="blog-title"><%= post.title %></div>
362 <div class="blog-preview"><%= plainText %></div>
363 </div>
364 <div class="blog-meta">
365 <span>
366 <% if (typeof userProfileImages !== 'undefined' && userProfileImages[post.author]) { %>
367 <img src="<%= userProfileImages[post.author] %>" style="width:18px; height:18px; border-radius:50%; object-fit:cover; vertical-align:middle;" class="me-1">
368 <% } else { %>
369 <i class="bi bi-person-circle me-1"></i>
370 <% } %>
371 <%= post.author %>
372 </span>
373 <span><i class="bi bi-calendar3 me-1"></i> <%= fmtDate(post.createdAt, true) %></span>
374 <span class="ms-auto"><i class="bi bi-chat-dots"></i> <%= post.replyCount %></span>
375 </div>
376 </div>
377 </div>
378 <% }); %>
379 <% } else { %>
380 <div class="text-center py-5 text-secondary">등록된 글이 없습니다.</div>
381 <% } %>
382 </div>
383
384 <nav aria-label="Page navigation">
385 <ul class="pagination justify-content-center">
386 <li class="page-item <%= currentPage <= 1 ? 'disabled' : '' %>">
387 <a class="page-link" href="?page=<%= currentPage - 1 %>&sort=<%= sort || 'new' %>&keyword=<%= keyword || '' %>">&laquo;</a>
388 </li>
389 <% for(let i = 1; i <= totalPages; i++) { %>
390 <li class="page-item <%= currentPage == i ? 'active' : '' %>">
391 <a class="page-link" href="?page=<%= i %>&sort=<%= sort || 'new' %>&keyword=<%= keyword || '' %>"><%= i %></a>
392 </li>
393 <% } %>
394 <li class="page-item <%= currentPage >= totalPages ? 'disabled' : '' %>">
395 <a class="page-link" href="?page=<%= currentPage + 1 %>&sort=<%= sort || 'new' %>&keyword=<%= keyword || '' %>">&raquo;</a>
396 </li>
397 </ul>
398 </nav>
399 </div>
400
401 <footer class="footer-area">
402 <div class="footer-brand">
403 <img src="/image/sign.png" alt="비나래" class="footer-logo">
404 <div>
405 <div class="footer-title">비나래 아카이브 도서관</div>
406 <div class="footer-sub">Archive My Memory</div>
407 </div>
408 </div>
409 <div>
410 <div class="footer-links">
411 <a href="/hinana/index">Archive</a>
412 <a href="/hinana/blog">Blog</a>
413 <a href="/hinana/lounge">Lounge</a>
414 <a href="/hinana/developer">Developer</a>
415 <a href="https://x.com/NoctchillHinana">X</a>
416 </div>
417 <div class="footer-copy">ⓒ 2024~2026. 비나래 | hinana.moe</div>
418 </div>
419 </footer>
420
421 <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); }</script>
422 </body>
423 </html>
424