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
.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(/ /g, ' ')
349
.replace(/&/g, '&')
350
.replace(/</g, '<')
351
.replace(/>/g, '>')
352
.replace(/"/g, '"')
353
.replace(/'/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(/ /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 : '') %>">«</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 : '') %>">»</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