Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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(/ /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 || '' %>">«</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 || '' %>">»</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