Public Source Viewer

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

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

Redacted View
view/hinana/blogpost.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 ogData !== 'undefined' ? ogData : {
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="article">
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 <script src="/js/popup.js"></script>
38 <title><%= pageMeta.title %> - 비나래 아카이브</title>
39 <style>
40 :root {
41 --font-family: 'Noto Sans KR', sans-serif;
42 --bg-main: #ffffff; --bg-secondary: #f7f9f9; --bg-tertiary: #eff3f4;
43 --text-primary: #0f1419; --text-secondary: #536471;
44 --accent-color: #1d9bf0; --danger-color: #f4212e; --border-color: #eff3f4;
45 --shadow-sm: 0 1px 3px rgba(0,0,0,0.08); --shadow-md: 0 4px 12px rgba(0,0,0,0.10);
46 }
47 body.dark-mode {
48 --bg-main: #000000; --bg-secondary: #16181c; --bg-tertiary: #202327;
49 --text-primary: #e7e9ea; --text-secondary: #71767b;
50 --border-color: #2f3336; --accent-color: #1d9bf0; --danger-color: #f4212e;
51 --shadow-sm: 0 1px 3px rgba(255,255,255,0.04); --shadow-md: 0 4px 12px rgba(0,0,0,0.6);
52 }
53
54 html, body { height: 100%; margin: 0; font-family: var(--font-family); background-color: var(--bg-main); color: var(--text-primary); overflow: hidden; }
55 a { text-decoration: none; color: inherit; }
56
57 /* 헤더 */
58 .global-header {
59 height: 60px;
60 background-color: rgba(255,255,255,0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
61 border-bottom: 1px solid var(--border-color);
62 display: flex; align-items: center; justify-content: space-between; padding: 0 20px; z-index: 100; position: sticky; top: 0;
63 }
64 body.dark-mode .global-header { background-color: rgba(0,0,0,0.85); }
65 .header-brand { display: flex; align-items: center; gap: 15px; }
66 .header-logo { height: 28px; width: auto; }
67
68 /* 메뉴바 중앙 정렬 강제 */
69 .header-nav {
70 position: absolute; left: 50%; transform: translateX(-50%);
71 display: flex; gap: 20px; align-items: center; z-index: 5;
72 }
73 .nav-link { font-weight: 600; font-size: 0.95rem; color: var(--text-secondary); }
74 .nav-link:hover { color: var(--accent-color); }
75 .nav-link.active { color: var(--text-primary); }
76
77 .header-controls { display: flex; align-items: center; gap: 10px; z-index: 10; position: relative; background-color: var(--bg-tertiary); }
78 .icon-btn { border: none; background: transparent; color: var(--text-secondary); font-size: 1.1rem; cursor: pointer; padding: 4px; }
79 .icon-btn:hover { color: var(--text-primary); }
80
81 /* 레이아웃 */
82 .layout-container { display: flex; height: calc(100vh - 60px); }
83
84 .shelf-column {
85 width: 300px; min-width: 300px; background-color: var(--bg-secondary);
86 border-right: 1px solid var(--border-color); display: flex; flex-direction: column;
87 justify-content: center; align-items: center;
88 }
89
90 .content-column {
91 flex: 1; display: flex; flex-direction: column;
92 background-color: var(--bg-main); position: relative; overflow: hidden;
93 }
94 .content-scroll-area {
95 flex: 1; overflow-y: auto; padding: 40px;
96 display: flex; justify-content: center;
97 }
98 .content-card {
99 background-color: var(--bg-secondary); border-radius: 12px;
100 box-shadow: var(--shadow-sm); border: 1px solid var(--border-color);
101 overflow: hidden; width: 100%; max-width: 800px; height: fit-content; margin-bottom: 40px;
102 }
103
104 .post-header {
105 padding: 25px; background-color: var(--bg-tertiary); border-bottom: 1px solid var(--border-color);
106 }
107 .post-title { font-size: 1.5rem; font-weight: 700; color: var(--accent-color); margin-bottom: 10px; line-height: 1.3; }
108 .post-meta { font-size: 0.85rem; color: var(--text-secondary); display: flex; align-items: center; gap: 15px; }
109 .badge-admin { background-color: var(--accent-color); color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; margin-left: 5px; }
110
111 .post-content {
112 padding: 40px; font-size: 1.1rem; line-height: 1.8; color: var(--text-primary);
113 min-height: 200px; white-space: normal; word-break: break-all;
114 }
115 .post-content img { max-width: 100%; height: auto; border-radius: 8px; margin: 10px 0; display: block; }
116
117 .post-actions {
118 padding: 15px 25px; background-color: var(--bg-main); border-top: 1px solid var(--border-color);
119 display: flex; justify-content: space-between; align-items: center;
120 }
121 .action-btn {
122 display: flex; align-items: center; gap: 6px; padding: 8px 15px;
123 border-radius: 6px; border: 1px solid var(--border-color);
124 background: var(--bg-secondary); color: var(--text-secondary);
125 font-size: 0.9rem; cursor: pointer; transition: all 0.2s;
126 }
127 .action-btn:hover { background-color: var(--border-color); color: var(--text-primary); }
128 .action-btn.liked { color: var(--danger-color); border-color: var(--danger-color); background-color: rgba(220, 38, 38, 0.1); }
129
130 .replies-container { padding: 25px; background-color: var(--bg-main); }
131 .reply-item { margin-bottom: 20px; padding-left: 15px; border-left: 3px solid var(--border-color); }
132 .reply-meta { display: flex; justify-content: space-between; font-size: 0.85rem; margin-bottom: 6px; }
133 .reply-author { font-weight: 600; display: flex; align-items: center; gap: 5px; }
134 .reply-content { font-size: 0.95rem; line-height: 1.6; }
135
136 .info-column {
137 width: 260px; min-width: 260px; background-color: var(--bg-secondary);
138 border-left: 1px solid var(--border-color); padding: 20px;
139 display: flex; flex-direction: column; gap: 20px;
140 }
141 .info-card {
142 background-color: var(--bg-main); border-radius: 12px; padding: 20px;
143 border: 1px solid var(--border-color); box-shadow: var(--shadow-sm);
144 }
145 .info-card-title { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-secondary); margin-bottom: 10px; }
146
147 /* 토글 스위치 */
148 .theme-toggle-wrapper { display: flex; align-items: center; justify-content: space-between; }
149 .switch { position: relative; display: inline-block; width: 40px; height: 22px; vertical-align: middle; }
150 .switch input { opacity: 0; width: 0; height: 0; }
151 .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 34px; }
152 .slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
153 input:checked + .slider { background-color: var(--accent-color); }
154 input:checked + .slider:before { transform: translateX(18px); }
155
156 /* 반응형 */
157 @media (max-width: 960px) {
158 html, body { overflow: auto !important; height: auto !important; }
159 .layout-container { flex-direction: column; height: auto !important; }
160
161 .shelf-column { display: none; }
162
163 .content-column { width: 100%; height: auto !important; border: none; order: 1; overflow: visible; }
164 .content-scroll-area { padding: 15px; height: auto !important; overflow: visible; }
165 .post-content { padding: 20px; font-size: 1rem; }
166
167 .global-header { flex-wrap: wrap; height: auto; padding: 10px 20px; }
168 .header-nav {
169 position: static; transform: none; width: 100%;
170 justify-content: center; margin-top: 10px; padding-top: 10px;
171 border-top: 1px solid rgba(0,0,0,0.05); order: 3;
172 }
173 .header-brand { flex: 1; order: 1; }
174 .header-controls { flex: auto; justify-content: flex-end; background-color: transparent; order: 2; }
175
176 .info-column {
177 display: flex; width: 100%; height: auto;
178 border-left: none; border-top: 1px solid var(--border-color);
179 order: 2; padding: 20px; flex-direction: row; flex-wrap: wrap;
180 }
181 .info-card { flex: 1; min-width: 200px; margin-bottom: 0; }
182 .footer-info { display: none; }
183 }
184 .d-none { display: none !important; }
185 footer, .footer, .footer-area, .footer-info {
186 background-color: transparent !important;
187 background: transparent !important;
188 box-shadow: none !important; /* 그림자 제거 */
189 color: var(--text-secondary) !important; /* 글자색 테마 맞춤 */
190 }
191 html, body {
192 height: auto;
193 overflow: auto;
194 background-color: var(--bg-main);
195 }
196 .layout-container {
197 display: block;
198 height: auto;
199 }
200 .shelf-column,
201 .info-column {
202 display: none;
203 }
204 .content-column {
205 display: block;
206 width: 100%;
207 overflow: visible;
208 background-color: var(--bg-main);
209 }
210 .content-scroll-area {
211 display: block;
212 max-width: 900px;
213 margin: 0 auto;
214 padding: 38px 20px 0;
215 overflow: visible;
216 }
217 .content-card {
218 max-width: none;
219 border-radius: 8px;
220 box-shadow: none;
221 margin-bottom: 0;
222 }
223 .header-controls {
224 background: transparent;
225 }
226 .post-header {
227 background-color: var(--bg-secondary);
228 }
229 .post-content {
230 background-color: var(--bg-main);
231 word-break: keep-all;
232 overflow-wrap: anywhere;
233 }
234 .post-actions,
235 .replies-container {
236 background-color: var(--bg-main);
237 }
238 .post-back-link {
239 display: inline-flex;
240 align-items: center;
241 gap: 6px;
242 margin-bottom: 16px;
243 color: var(--text-secondary);
244 font-weight: 700;
245 font-size: 0.9rem;
246 }
247 .post-back-link:hover {
248 color: var(--accent-color);
249 }
250 .footer-area {
251 max-width: 900px;
252 margin: 48px auto 0;
253 padding: 28px 20px 40px;
254 color: var(--text-secondary);
255 font-size: 0.78rem;
256 border-top: 1px solid var(--border-color);
257 display: flex;
258 align-items: center;
259 justify-content: space-between;
260 gap: 20px;
261 }
262 .footer-brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
263 .footer-logo {
264 width: 44px; height: 44px; object-fit: contain;
265 opacity: 0.72; mix-blend-mode: multiply;
266 flex: 0 0 auto;
267 }
268 body.dark-mode .footer-logo { mix-blend-mode: screen; opacity: 0.86; }
269 .footer-title { font-weight: 800; color: var(--text-primary); font-size: 0.9rem; }
270 .footer-sub { margin-top: 2px; }
271 .footer-links { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
272 .footer-links a { color: var(--text-secondary); font-weight: 700; }
273 .footer-links a:hover { color: var(--accent-color); }
274 .footer-copy { width: 100%; text-align: right; font-size: 0.72rem; }
275 .footer-version {
276 display: inline-flex; align-items: center; min-height: 20px;
277 padding: 0 8px; margin-right: 8px; border: 1px solid var(--border-color);
278 border-radius: 999px; color: var(--accent-color); font-weight: 800;
279 background: var(--bg-secondary);
280 }
281 @media (max-width: 768px) {
282 .content-scroll-area { padding: 20px 12px 0; }
283 .post-meta { flex-wrap: wrap; }
284 .post-actions { gap: 12px; align-items: flex-start; flex-direction: column; }
285 .footer-area { flex-direction: column; align-items: flex-start; margin-top: 36px; }
286 .footer-links { justify-content: flex-start; }
287 .footer-copy { text-align: left; }
288 }
289 </style>
290 </head>
291
292 <body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
293
294 <header class="global-header">
295 <div class="header-brand">
296 <a href="/hinana/index">
297 <img src="/image/<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'archive1.png' : 'archive.png' %>"
298 alt="Hinana Archive" class="header-logo">
299 </a>
300 </div>
301
302 <nav class="header-nav">
303 <a href="/hinana/index" class="nav-link">Archive</a>
304 <a href="/hinana/info" class="nav-link">Info</a>
305 <a href="/hinana/blog" class="nav-link active">Blog</a>
306 <a href="/hinana/lounge" class="nav-link">Lounge</a>
307 <span class="nav-divider">|</span>
308
309 <% if(username) { %>
310 <a href="/logout?redirect=/hinana/post/<%= post.id %>" class="nav-link text-danger fw-bold">Logout</a>
311 <% } else { %>
312 <a href="/login?redirect=/hinana/post/<%= post.id %>" class="nav-link login-link fw-bold">Login</a>
313 <% } %>
314 </nav>
315
316 <div class="header-controls">
317 <a href="/hinana/gallery#brand-assets" class="nav-link" style="font-size:0.9rem;">사이트 맵</a>
318 <form action="/toggle-theme" method="POST" class="d-inline">
319 <button type="submit" class="icon-btn" title="테마 변경">
320 <i class="bi <%= (typeof theme !== 'undefined' && theme==='dark') ? 'bi-moon-stars-fill':'bi-sun-fill' %>"></i>
321 </button>
322 </form>
323 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
324 <a href="/hinana/write" class="icon-btn text-accent"><i class="bi bi-pencil-square"></i></a>
325 <% } %>
326 </div>
327 </header>
328
329 <div class="layout-container">
330
331 <div class="shelf-column">
332 <div class="text-center">
333 <a href="/hinana/blog" class="btn btn-outline-secondary btn-sm">
334 <i class="bi bi-arrow-left"></i> 블로그 목록
335 </a>
336 </div>
337 </div>
338
339 <div class="content-column">
340 <div class="content-scroll-area custom-scrollbar">
341 <a href="/hinana/blog" class="post-back-link">
342 <i class="bi bi-arrow-left"></i> 블로그 목록
343 </a>
344
345 <div class="content-card">
346 <div class="post-header">
347 <div class="post-title"><%= post.title %></div>
348 <div class="post-meta">
349 <span>
350 <% if (typeof userProfileImages !== 'undefined' && userProfileImages[post.author]) { %>
351 <img src="<%= userProfileImages[post.author] %>" style="width:18px; height:18px; border-radius:50%; object-fit:cover; vertical-align:middle;" class="me-1">
352 <% } else { %>
353 <i class="bi bi-person-circle me-1"></i>
354 <% } %>
355 <%= post.author %>
356 </span>
357 <span><i class="bi bi-calendar3 me-1"></i> <%= fmtDate(post.createdAt, true) %></span>
358 <% if(post.author === '비나래') { %><span class="badge-admin">ADMIN</span><% } %>
359 </div>
360 </div>
361
362 <div class="post-content">
363 <%- post.content %>
364 </div>
365
366 <div class="post-actions">
367 <div>
368 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
369 <a href="/hinana/edit-post/<%= post.id %>" class="btn btn-sm btn-outline-warning me-1">
370 <i class="bi bi-pencil-fill"></i> 수정
371 </a>
372 <form action="/hinana/delete-post" method="POST" class="d-inline" data-confirm="정말 삭제하시겠습니까?">
373 <input type="hidden" name="postId" value="<%= post.id %>">
374 <button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash-fill"></i> 삭제</button>
375 </form>
376 <% } else if(post.author && post.author.endsWith('(익명)') && isAnonymousPostingEnabled) { %>
377 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
378 <% } %>
379 </div>
380
381 <div class="d-flex gap-2">
382 <form action="/like" method="POST" class="like-form">
383 <input type="hidden" name="postId" value="<%= post.id %>">
384 <% const isLiked = post.likes && post.likes.includes(username); %>
385 <button type="submit" class="action-btn <%= isLiked ? 'liked' : '' %>">
386 <i class="bi <%= isLiked ? 'bi-heart-fill' : 'bi-heart' %>"></i>
387 <span class="like-count"><%= post.likes ? post.likes.length : 0 %></span>
388 </button>
389 </form>
390 <button class="action-btn" onclick="toggleReplyForm()">
391 <i class="bi bi-chat-quote-fill"></i> Reply
392 </button>
393 </div>
394 </div>
395
396 <div id="reply-form" class="d-none p-3 bg-tertiary border-top">
397 <% if(username) { %>
398 <form action="/hinana/reply/<%= post.id %>" method="POST">
399 <input type="hidden" name="postId" value="<%= post.id %>">
400 <input type="hidden" name="redirectUrl" value="/hinana/post/<%= post.id %>">
401
402 <div class="d-flex flex-column gap-2">
403 <textarea class="write-textarea" name="content" rows="2" maxlength="150"
404 placeholder="답글 작성..." required style="resize:none; width:100%; border-radius:6px; border:1px solid var(--border-color); padding:8px;"
405 oninput="checkInputLength(this, 'main-reply-count')"></textarea>
406
407 <div class="d-flex justify-content-between align-items-center">
408 <span id="main-reply-count" class="text-muted small">0/150</span>
409 <button class="btn btn-sm btn-secondary" style="background:var(--accent-color); border:none; color:white;">전송</button>
410 </div>
411 </div>
412 </form>
413 <% } else if(typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
414 <form action="/hinana/reply/<%= post.id %>" method="POST">
415 <input type="hidden" name="postId" value="<%= post.id %>">
416 <input type="hidden" name="redirectUrl" value="/hinana/post/<%= post.id %>">
417 <input type="hidden" name="isAnonymous" value="true">
418 <div class="row g-2 mb-2">
419 <div class="col-6">
420 <input type="text" class="form-control form-control-sm" name="anonymousUsername" placeholder="닉네임 (최대 20자)"
421 maxlength="20" required>
422 </div>
423 <div class="col-6">
424 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
425 maxlength="6" required>
426 </div>
427 </div>
428 <div class="d-flex flex-column gap-2">
429 <textarea class="write-textarea" name="content" rows="2" maxlength="150"
430 placeholder="익명 답글..." required style="resize:none; width:100%; border-radius:6px; border:1px solid var(--border-color); padding:8px;"
431 oninput="checkInputLength(this, 'main-anon-reply-count')"></textarea>
432
433 <div class="d-flex justify-content-between align-items-center">
434 <span id="main-anon-reply-count" class="text-muted small">0/150</span>
435 <button class="btn btn-sm btn-secondary" style="background:var(--accent-color); border:none; color:white;">전송</button>
436 </div>
437 </div>
438 </form>
439 <% } else { %>
440 <div class="text-center py-2">
441 <a href="/login?redirect=/hinana/post/<%= post.id %>" class="btn btn-sm btn-primary">로그인하여 답글 달기</a>
442 </div>
443 <% } %>
444 </div>
445
446 <% if(replies && replies.length > 0) { %>
447 <div class="replies-container">
448 <div class="replies-header mb-3 fw-bold text-secondary">
449 <i class="bi bi-chat-dots"></i> Comments (<%= replies.length %>)
450 </div>
451
452 <% function renderReplies(replyList, depth) { %>
453 <% replyList.forEach(function(reply) { %>
454 <div class="reply-item" style="margin-left: <%= depth * 20 %>px;">
455 <div class="reply-meta">
456 <div class="reply-author">
457 <% var replyAuthorName = reply.username.replace('(익명)', '').trim(); %>
458 <% if (!reply.username.endsWith('(익명)') && typeof userProfileImages !== 'undefined' && userProfileImages[replyAuthorName]) { %>
459 <img src="<%= userProfileImages[replyAuthorName] %>" style="width:20px; height:20px; border-radius:50%; object-fit:cover; vertical-align:middle;">
460 <% } else { %>
461 <i class="bi bi-person-circle text-muted"></i>
462 <% } %>
463 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
464 <%= replyAuthorName %>
465 </span>
466 <% if(reply.username.endsWith('(익명)')) { %>
467 <i class="bi bi-incognito ms-1 text-muted" style="font-size:0.9em;"></i>
468 <% } %>
469 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
470 <span class="badge-admin ms-1" style="font-size:0.6rem;">ADMIN</span>
471 <% } %>
472 </div>
473
474 <div class="text-end">
475 <span class="text-muted small">
476 <%= fmtDate(reply.createdAt || reply.timestamp || Date.now()) %>
477 </span>
478
479 <div class="mt-1">
480 <button class="btn p-0 text-secondary border-0 bg-transparent me-2" style="font-size:0.75rem;" onclick="toggleReplyForm('<%= reply.id %>')">
481 <i class="bi bi-chat-quote-fill"></i> 답글
482 </button>
483
484 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
485 <form action="/hinana/delete-blog-reply" method="POST" class="d-inline" data-confirm="삭제하시겠습니까?">
486 <input type="hidden" name="postId" value="<%= post.id %>">
487 <input type="hidden" name="replyId" value="<%= reply.id %>">
488 <button class="btn p-0 text-danger border-0 bg-transparent" style="font-size:0.75rem;"><i class="bi bi-trash"></i></button>
489 </form>
490 <% } else if(reply.username.endsWith('(익명)') && typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
491 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
492 <% } %>
493 </div>
494 </div>
495 </div>
496
497 <div class="reply-content">
498 <%- reply.content
499 .replace(/<script[^>]*>([\S\s]*?)<\/script>/gim, "") // 1. 스크립트 태그 및 내부 내용 완전 삭제
500 .replace(/<\/?\w(?:[^"'>]|"[^"]*"|'[^']*')*>/gim, "") // 2. 기타 HTML 태그(img, a 등) 제거
501 .replace(/&nbsp;/g, ' ')
502 .replace(/\n/g, '<br>') // 3. 줄바꿈 처리
503 %>
504 </div>
505
506 <div id="reply-form-<%= reply.id %>" class="d-none p-2 bg-tertiary rounded mb-3 mt-2">
507 <% if(username) { %>
508 <form action="/hinana/reply/<%= post.id %>" method="POST">
509 <input type="hidden" name="postId" value="<%= post.id %>">
510 <input type="hidden" name="parentReplyId" value="<%= reply.id %>">
511 <input type="hidden" name="redirectUrl" value="/hinana/post/<%= post.id %>">
512
513 <div class="d-flex flex-column gap-2">
514 <textarea class="write-textarea" name="content" rows="1" maxlength="150"
515 placeholder="답글..." required style="resize:none; font-size:0.9rem; padding:6px;"
516 oninput="checkInputLength(this, 'reply-count-<%= reply.id %>')"></textarea>
517
518 <div class="d-flex justify-content-between align-items-center">
519 <span id="reply-count-<%= reply.id %>" class="text-muted small">0/150</span>
520 <button class="btn btn-sm btn-accent text-white" style="background:var(--accent-color); border:none;">등록</button>
521 </div>
522 </div>
523 </form>
524 <% } else if(typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
525 <form action="/hinana/reply/<%= post.id %>" method="POST">
526 <input type="hidden" name="postId" value="<%= post.id %>">
527 <input type="hidden" name="parentReplyId" value="<%= reply.id %>">
528 <input type="hidden" name="redirectUrl" value="/hinana/post/<%= post.id %>">
529 <input type="hidden" name="isAnonymous" value="true">
530 <div class="row g-1 mb-1">
531 <div class="col-6"><input type="text" class="form-control form-control-sm" name="anonymousUsername" placeholder="닉네임" maxlength="20" required></div>
532 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
533 </div>
534 <div class="d-flex flex-column gap-2">
535 <textarea class="write-textarea" name="content" rows="1" maxlength="150"
536 placeholder="익명 답글..." required style="resize:none; font-size:0.9rem; padding:6px;"
537 oninput="checkInputLength(this, 'anon-reply-count-<%= reply.id %>')"></textarea>
538
539 <div class="d-flex justify-content-between align-items-center">
540 <span id="anon-reply-count-<%= reply.id %>" class="text-muted small">0/150</span>
541 <button class="btn btn-sm btn-accent text-white" style="background:var(--accent-color); border:none;">등록</button>
542 </div>
543 </div>
544 </form>
545 <% } %>
546 </div>
547
548 <% if(reply.replies && reply.replies.length > 0) { %>
549 <%= renderReplies(reply.replies, depth + 1) %>
550 <% } %>
551 </div>
552 <% }); %>
553 <% } %>
554
555 <%= renderReplies(replies, 0) %>
556 </div>
557 <% } %>
558 </div>
559
560 </div>
561 </div>
562
563 <div class="info-column">
564 <div class="info-card">
565 <div class="info-card-title">Current User</div>
566 <div class="d-flex align-items-center gap-2">
567 <% if (typeof currentUserProfileImage !== 'undefined' && currentUserProfileImage) { %>
568 <img src="<%= currentUserProfileImage %>" style="width:32px; height:32px; border-radius:50%; object-fit:cover;">
569 <% } else { %>
570 <i class="bi bi-person-circle fs-4 text-secondary"></i>
571 <% } %>
572 <div class="fw-bold"><% if(username) { %><a href="/hinana/userInfo" style="color: inherit;"><%= username %></a><% } else { %>Guest<% } %></div>
573 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
574 </div>
575 <div class="mt-3">
576 <% if(username) { %>
577 <a href="/logout?redirect=/hinana/post/<%= post.id %>" class="btn btn-outline-secondary btn-sm w-100">Logout</a>
578 <% } else { %>
579 <a href="/login?redirect=/hinana/post/<%= post.id %>" class="btn btn-primary btn-sm w-100">Login</a>
580 <% } %>
581 </div>
582
583 </div>
584
585 <div class="info-card mt-3">
586 <div class="info-card-title">Settings</div>
587 <div class="theme-toggle-wrapper">
588 <span class="d-flex align-items-center gap-2 small text-secondary">
589 <i class="bi <%= (typeof theme !== 'undefined' && theme==='dark') ? 'bi-moon-stars-fill':'bi-sun-fill' %>"></i>
590
591 <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'Dark Mode' : 'Light Mode' %>
592 </span>
593
594 <form action="/toggle-theme" method="POST" id="theme-form">
595 <label class="switch" style="transform:scale(0.8);">
596 <input type="checkbox" <%= (typeof theme !== 'undefined' && theme==='dark') ? 'checked':'' %> onchange="document.getElementById('theme-form').submit()">
597 <span class="slider"></span>
598 </label>
599 </form>
600 </div>
601 </div>
602
603 <% if(typeof currentPost !== 'undefined' && currentPost) { %>
604 <div class="info-card">
605 <div class="info-card-title">Page Info</div>
606 <ul class="small text-secondary list-unstyled mb-0">
607 <li class="mb-1 d-flex justify-content-between"><span>Version</span></li>
608 <div class="info-card mt-3">
609 <div class="info-card-title">System Info</div> <ul class="small text-secondary list-unstyled mb-0">
610 <li class="mb-1 d-flex justify-content-between"><span>Version</span></li>
611 <li class="mb-1 d-flex justify-content-between"><span> Ver. 8.0.3.2-Utsumi Aoba</span></li>
612 </ul>
613 </div>
614 </div>
615 <% } %>
616
617 <div style="text-align:left;" class="footer">
618 <img src="/image/sign.png" id="fumika_sign" style="max-width:250px; max-height:initial; width:100%; height:100%;" />
619 </div>
620 <footer class="container-fluid text-center footer">
621 <a href="#myPage" title="To Top">
622 <span class="glyphicon glyphicon-chevron-up"></span>
623 </a>
624 <p style="margin-bottom: 0rem;" class="copyright">X - @NoctchillHinana</p>
625 <p style="margin-bottom: 0rem;" class="copyright">ⓒ 2024~2026. 비나래 | hinana.moe All rights reserved.</p>
626 </footer>
627 </div>
628
629 </div>
630
631 <footer class="footer-area">
632 <div class="footer-brand">
633 <img src="/image/sign.png" alt="비나래" class="footer-logo">
634 <div>
635 <div class="footer-title">비나래 아카이브 도서관</div>
636 <div class="footer-sub">Archive My Memory</div>
637 </div>
638 </div>
639 <div>
640 <div class="footer-links">
641 <a href="/hinana/index">Archive</a>
642 <a href="/hinana/blog">Blog</a>
643 <a href="/hinana/lounge">Lounge</a>
644 <a href="/hinana/developer">Developer</a>
645 <a href="https://x.com/NoctchillHinana">X</a>
646 </div>
647 <div class="footer-copy"><span class="footer-version">Ver. 8.0.3.2-Utsumi Aoba</span>ⓒ 2024~2026. 비나래 | hinana.moe</div>
648 </div>
649 </footer>
650
651 <script>
652 // 글자 수 체크 함수
653 function checkInputLength(input, counterId) {
654 const maxLength = 150;
655 const currentLength = input.value.length;
656 const counter = document.getElementById(counterId);
657
658 if (counter) {
659 counter.innerText = `${currentLength}/${maxLength}`;
660
661 counter.className = 'small ' + (
662 currentLength >= maxLength ? 'text-danger fw-bold' :
663 currentLength >= 130 ? 'text-warning fw-bold' :
664 'text-muted'
665 );
666 }
667 }
668
669 $(document).ready(function() {
670 $('.like-form').on('submit', function(e) {
671 e.preventDefault();
672 var form = $(this);
673 var btn = form.find('button');
674 var span = btn.find('.like-count');
675 var icon = btn.find('i');
676 $.ajax({
677 type: 'POST', url: '/like', data: form.serialize(),
678 success: function(res) {
679 if(res.isLiked) {
680 btn.addClass('liked'); icon.removeClass('bi-heart').addClass('bi-heart-fill');
681 } else {
682 btn.removeClass('liked'); icon.removeClass('bi-heart-fill').addClass('bi-heart');
683 }
684 span.text(res.likeCount);
685 },
686 error: function(xhr) {
687 if(xhr.status === 401) {
688 showConfirm('로그인이 필요합니다. 이동할까요?').then(function(ok){ if(ok) location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname); });
689 } else { showAlert('오류 발생'); }
690 }
691 });
692 });
693 });
694
695 function toggleReplyForm(id) {
696 var targetId = id ? 'reply-form-' + id : 'reply-form';
697 var form = document.getElementById(targetId);
698 if (form) {
699 if(form.classList.contains('d-none')) {
700 form.classList.remove('d-none');
701 form.querySelector('textarea')?.focus();
702 } else {
703 form.classList.add('d-none');
704 }
705 } else {
706 showConfirm('로그인이 필요합니다. 이동할까요?').then(function(ok){ if(ok) location.href='/login?redirect=' + encodeURIComponent(window.location.pathname); });
707 }
708 }
709
710 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
711 const pw = await showPrompt("비밀번호:"); if(!pw) return;
712 fetch('/hinana/delete-post', {
713 // 실제 비밀번호 삭제 로직 필요 (예시)
714 }).then(() => location.href='/hinana/blog');
715 }
716
717 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
718 const pw = await showPrompt("비밀번호:"); if(!pw) return;
719 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
720 method:'POST', headers:{'Content-Type':'application/json'},
721 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
722 }).then(r=>r.json()).then(d=>{ showAlert(d.message).then(()=>{ if(d.success) location.reload(); }); });
723 }
724 </script>
725 <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); }</script>
726 </body>
727 </html>
728