Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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: var(--ui-on-accent, #fff); 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
<link rel="stylesheet" href="/css/site-ui.css?v=3">
291
<link rel="stylesheet" href="/css/site-app.css?v=1">
292
</head>
293
294
<body data-ui-page="blogpost" class="ui-shell <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
295
296
<%- include('partials/site-header', { uiPage: 'blogpost' }) %>
297
298
<div id="ui-content" tabindex="-1" role="main" class="layout-container">
299
300
<div class="shelf-column">
301
<div class="text-center">
302
<a href="/hinana/blog" class="btn btn-outline-secondary btn-sm">
303
<i class="bi bi-arrow-left"></i> 블로그 목록
304
</a>
305
</div>
306
</div>
307
308
<div class="content-column">
309
<div class="content-scroll-area custom-scrollbar">
310
<a href="/hinana/blog" class="post-back-link">
311
<i class="bi bi-arrow-left"></i> 블로그 목록
312
</a>
313
314
<div class="content-card">
315
<div class="post-header">
316
<div class="post-title"><%= post.title %></div>
317
<div class="post-meta">
318
<span>
319
<% if (typeof userProfileImages !== 'undefined' && userProfileImages[post.author]) { %>
320
<img src="<%= userProfileImages[post.author] %>" style="width:18px; height:18px; border-radius:50%; object-fit:cover; vertical-align:middle;" class="me-1">
321
<% } else { %>
322
<i class="bi bi-person-circle me-1"></i>
323
<% } %>
324
<%= post.author %>
325
</span>
326
<span><i class="bi bi-calendar3 me-1"></i> <%= fmtDate(post.createdAt, true) %></span>
327
<% if(post.author === '비나래') { %><span class="badge-admin">ADMIN</span><% } %>
328
</div>
329
</div>
330
331
<div class="post-content">
332
<%- post.content %>
333
</div>
334
335
<div class="post-actions">
336
<div>
337
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
338
<a href="/hinana/edit-post/<%= post.id %>" class="btn btn-sm btn-outline-warning me-1">
339
<i class="bi bi-pencil-fill"></i> 수정
340
</a>
341
<form action="/hinana/delete-post" method="POST" class="d-inline" data-confirm="정말 삭제하시겠습니까?">
342
<input type="hidden" name="postId" value="<%= post.id %>">
343
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash-fill"></i> 삭제</button>
344
</form>
345
<% } else if(post.author && post.author.endsWith('(익명)') && isAnonymousPostingEnabled) { %>
346
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
347
<% } %>
348
</div>
349
350
<div class="d-flex gap-2">
351
<form action="/like" method="POST" class="like-form">
352
<input type="hidden" name="postId" value="<%= post.id %>">
353
<% const isLiked = post.likes && post.likes.includes(username); %>
354
<button type="submit" class="action-btn <%= isLiked ? 'liked' : '' %>">
355
<i class="bi <%= isLiked ? 'bi-heart-fill' : 'bi-heart' %>"></i>
356
<span class="like-count"><%= post.likes ? post.likes.length : 0 %></span>
357
</button>
358
</form>
359
<button class="action-btn" onclick="toggleReplyForm()">
360
<i class="bi bi-chat-quote-fill"></i> Reply
361
</button>
362
</div>
363
</div>
364
365
<div id="reply-form" class="d-none p-3 bg-tertiary border-top">
366
<% if(username) { %>
367
<form action="/hinana/reply/<%= post.id %>" method="POST">
368
<input type="hidden" name="postId" value="<%= post.id %>">
369
<input type="hidden" name="redirectUrl" value="/hinana/post/<%= post.id %>">
370
371
<div class="d-flex flex-column gap-2">
372
<textarea class="write-textarea" name="content" rows="2" maxlength="150"
373
placeholder="답글 작성..." required style="resize:none; width:100%; border-radius:6px; border:1px solid var(--border-color); padding:8px;"
374
oninput="checkInputLength(this, 'main-reply-count')"></textarea>
375
376
<div class="d-flex justify-content-between align-items-center">
377
<span id="main-reply-count" class="text-muted small">0/150</span>
378
<button class="ui-accent-control btn btn-sm btn-secondary" style="background:var(--accent-color); border:none; color:var(--ui-on-accent, #fff);">전송</button>
379
</div>
380
</div>
381
</form>
382
<% } else if(typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
383
<form action="/hinana/reply/<%= post.id %>" method="POST">
384
<input type="hidden" name="postId" value="<%= post.id %>">
385
<input type="hidden" name="redirectUrl" value="/hinana/post/<%= post.id %>">
386
<input type="hidden" name="isAnonymous" value="true">
387
<div class="row g-2 mb-2">
388
<div class="col-6">
389
<input type="text" class="form-control form-control-sm" name="anonymousUsername" placeholder="닉네임 (최대 20자)"
390
maxlength="20" required>
391
</div>
392
<div class="col-6">
393
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
394
maxlength="6" required>
395
</div>
396
</div>
397
<div class="d-flex flex-column gap-2">
398
<textarea class="write-textarea" name="content" rows="2" maxlength="150"
399
placeholder="익명 답글..." required style="resize:none; width:100%; border-radius:6px; border:1px solid var(--border-color); padding:8px;"
400
oninput="checkInputLength(this, 'main-anon-reply-count')"></textarea>
401
402
<div class="d-flex justify-content-between align-items-center">
403
<span id="main-anon-reply-count" class="text-muted small">0/150</span>
404
<button class="ui-accent-control btn btn-sm btn-secondary" style="background:var(--accent-color); border:none; color:var(--ui-on-accent, #fff);">전송</button>
405
</div>
406
</div>
407
</form>
408
<% } else { %>
409
<div class="text-center py-2">
410
<a href="/login?redirect=/hinana/post/<%= post.id %>" class="btn btn-sm btn-primary">로그인하여 답글 달기</a>
411
</div>
412
<% } %>
413
</div>
414
415
<% if(replies && replies.length > 0) { %>
416
<div class="replies-container">
417
<div class="replies-header mb-3 fw-bold text-secondary">
418
<i class="bi bi-chat-dots"></i> Comments (<%= replies.length %>)
419
</div>
420
421
<% function renderReplies(replyList, depth) { %>
422
<% replyList.forEach(function(reply) { %>
423
<div class="reply-item" style="margin-left: <%= depth * 20 %>px;">
424
<div class="reply-meta">
425
<div class="reply-author">
426
<% var replyAuthorName = reply.username.replace('(익명)', '').trim(); %>
427
<% if (!reply.username.endsWith('(익명)') && typeof userProfileImages !== 'undefined' && userProfileImages[replyAuthorName]) { %>
428
<img src="<%= userProfileImages[replyAuthorName] %>" style="width:20px; height:20px; border-radius:50%; object-fit:cover; vertical-align:middle;">
429
<% } else { %>
430
<i class="bi bi-person-circle text-muted"></i>
431
<% } %>
432
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
433
<%= replyAuthorName %>
434
</span>
435
<% if(reply.username.endsWith('(익명)')) { %>
436
<i class="bi bi-incognito ms-1 text-muted" style="font-size:0.9em;"></i>
437
<% } %>
438
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
439
<span class="badge-admin ms-1" style="font-size:0.6rem;">ADMIN</span>
440
<% } %>
441
</div>
442
443
<div class="text-end">
444
<span class="text-muted small">
445
<%= fmtDate(reply.createdAt || reply.timestamp || Date.now()) %>
446
</span>
447
448
<div class="mt-1">
449
<button class="btn p-0 text-secondary border-0 bg-transparent me-2" style="font-size:0.75rem;" onclick="toggleReplyForm('<%= reply.id %>')">
450
<i class="bi bi-chat-quote-fill"></i> 답글
451
</button>
452
453
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
454
<form action="/hinana/delete-blog-reply" method="POST" class="d-inline" data-confirm="삭제하시겠습니까?">
455
<input type="hidden" name="postId" value="<%= post.id %>">
456
<input type="hidden" name="replyId" value="<%= reply.id %>">
457
<button class="btn p-0 text-danger border-0 bg-transparent" style="font-size:0.75rem;"><i class="bi bi-trash"></i></button>
458
</form>
459
<% } else if(reply.username.endsWith('(익명)') && typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
460
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
461
<% } %>
462
</div>
463
</div>
464
</div>
465
466
<div class="reply-content">
467
<%- reply.content
468
.replace(/<script[^>]*>([\S\s]*?)<\/script>/gim, "") // 1. 스크립트 태그 및 내부 내용 완전 삭제
469
.replace(/<\/?\w(?:[^"'>]|"[^"]*"|'[^']*')*>/gim, "") // 2. 기타 HTML 태그(img, a 등) 제거
470
.replace(/ /g, ' ')
471
.replace(/\n/g, '<br>') // 3. 줄바꿈 처리
472
%>
473
</div>
474
475
<div id="reply-form-<%= reply.id %>" class="d-none p-2 bg-tertiary rounded mb-3 mt-2">
476
<% if(username) { %>
477
<form action="/hinana/reply/<%= post.id %>" method="POST">
478
<input type="hidden" name="postId" value="<%= post.id %>">
479
<input type="hidden" name="parentReplyId" value="<%= reply.id %>">
480
<input type="hidden" name="redirectUrl" value="/hinana/post/<%= post.id %>">
481
482
<div class="d-flex flex-column gap-2">
483
<textarea class="write-textarea" name="content" rows="1" maxlength="150"
484
placeholder="답글..." required style="resize:none; font-size:0.9rem; padding:6px;"
485
oninput="checkInputLength(this, 'reply-count-<%= reply.id %>')"></textarea>
486
487
<div class="d-flex justify-content-between align-items-center">
488
<span id="reply-count-<%= reply.id %>" class="text-muted small">0/150</span>
489
<button class="btn btn-sm btn-accent text-white" style="background:var(--accent-color); border:none;">등록</button>
490
</div>
491
</div>
492
</form>
493
<% } else if(typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
494
<form action="/hinana/reply/<%= post.id %>" method="POST">
495
<input type="hidden" name="postId" value="<%= post.id %>">
496
<input type="hidden" name="parentReplyId" value="<%= reply.id %>">
497
<input type="hidden" name="redirectUrl" value="/hinana/post/<%= post.id %>">
498
<input type="hidden" name="isAnonymous" value="true">
499
<div class="row g-1 mb-1">
500
<div class="col-6"><input type="text" class="form-control form-control-sm" name="anonymousUsername" placeholder="닉네임" maxlength="20" required></div>
501
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
502
</div>
503
<div class="d-flex flex-column gap-2">
504
<textarea class="write-textarea" name="content" rows="1" maxlength="150"
505
placeholder="익명 답글..." required style="resize:none; font-size:0.9rem; padding:6px;"
506
oninput="checkInputLength(this, 'anon-reply-count-<%= reply.id %>')"></textarea>
507
508
<div class="d-flex justify-content-between align-items-center">
509
<span id="anon-reply-count-<%= reply.id %>" class="text-muted small">0/150</span>
510
<button class="btn btn-sm btn-accent text-white" style="background:var(--accent-color); border:none;">등록</button>
511
</div>
512
</div>
513
</form>
514
<% } %>
515
</div>
516
517
<% if(reply.replies && reply.replies.length > 0) { %>
518
<%= renderReplies(reply.replies, depth + 1) %>
519
<% } %>
520
</div>
521
<% }); %>
522
<% } %>
523
524
<%= renderReplies(replies, 0) %>
525
</div>
526
<% } %>
527
</div>
528
529
</div>
530
</div>
531
532
<div class="info-column">
533
<div class="info-card">
534
<div class="info-card-title">Current User</div>
535
<div class="d-flex align-items-center gap-2">
536
<% if (typeof currentUserProfileImage !== 'undefined' && currentUserProfileImage) { %>
537
<img src="<%= currentUserProfileImage %>" style="width:32px; height:32px; border-radius:50%; object-fit:cover;">
538
<% } else { %>
539
<i class="bi bi-person-circle fs-4 text-secondary"></i>
540
<% } %>
541
<div class="fw-bold"><% if(username) { %><a href="/hinana/userInfo" style="color: inherit;"><%= username %></a><% } else { %>Guest<% } %></div>
542
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
543
</div>
544
<div class="mt-3">
545
<% if(username) { %>
546
<a href="/logout?redirect=/hinana/post/<%= post.id %>" class="btn btn-outline-secondary btn-sm w-100">Logout</a>
547
<% } else { %>
548
<a href="/login?redirect=/hinana/post/<%= post.id %>" class="btn btn-primary btn-sm w-100">Login</a>
549
<% } %>
550
</div>
551
552
</div>
553
554
<div class="info-card mt-3">
555
<div class="info-card-title">Settings</div>
556
<div class="theme-toggle-wrapper">
557
<span class="d-flex align-items-center gap-2 small text-secondary">
558
<i class="bi <%= (typeof theme !== 'undefined' && theme==='dark') ? 'bi-moon-stars-fill':'bi-sun-fill' %>"></i>
559
560
<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'Dark Mode' : 'Light Mode' %>
561
</span>
562
563
<form action="/toggle-theme" method="POST" id="theme-form">
564
<label class="switch" style="transform:scale(0.8);">
565
<input type="checkbox" <%= (typeof theme !== 'undefined' && theme==='dark') ? 'checked':'' %> onchange="document.getElementById('theme-form').submit()">
566
<span class="slider"></span>
567
</label>
568
</form>
569
</div>
570
</div>
571
572
<% if(typeof currentPost !== 'undefined' && currentPost) { %>
573
<div class="info-card">
574
<div class="info-card-title">Page Info</div>
575
<ul class="small text-secondary list-unstyled mb-0">
576
<li class="mb-1 d-flex justify-content-between"><span>Version</span></li>
577
<div class="info-card mt-3">
578
<div class="info-card-title">System Info</div> <ul class="small text-secondary list-unstyled mb-0">
579
<li class="mb-1 d-flex justify-content-between"><span>Version</span></li>
580
<li class="mb-1 d-flex justify-content-between"><span> Ver. 9.2.1.0 - Iochi Mari</span></li>
581
</ul>
582
</div>
583
</div>
584
<% } %>
585
586
<div style="text-align:left;" class="footer">
587
<img src="/image/sign.png" id="fumika_sign" style="max-width:250px; max-height:initial; width:100%; height:100%;" />
588
</div>
589
<footer class="container-fluid text-center footer">
590
<a href="#myPage" title="To Top">
591
<span class="glyphicon glyphicon-chevron-up"></span>
592
</a>
593
<p style="margin-bottom: 0rem;" class="copyright">X - @NoctchillHinana</p>
594
<p style="margin-bottom: 0rem;" class="copyright">ⓒ 2024~2026. 비나래 | hinana.moe All rights reserved.</p>
595
</footer>
596
</div>
597
598
</div>
599
600
<footer class="footer-area">
601
<div class="footer-brand">
602
<img src="/image/sign.png" alt="비나래" class="footer-logo">
603
<div>
604
<div class="footer-title">비나래 아카이브 도서관</div>
605
<div class="footer-sub">Archive My Memory</div>
606
</div>
607
</div>
608
<div>
609
<div class="footer-links">
610
<a href="/hinana/index">Archive</a>
611
<a href="/hinana/blog">Blog</a>
612
<a href="/hinana/lounge">Lounge</a>
613
<a href="/hinana/developer">Developer</a>
614
<a href="https://x.com/NoctchillHinana">X</a>
615
</div>
616
<div class="footer-copy"><span class="footer-version">Ver. 9.2.1.0 - Iochi Mari</span>ⓒ 2024~2026. 비나래 | hinana.moe</div>
617
</div>
618
</footer>
619
620
<script>
621
// 글자 수 체크 함수
622
function checkInputLength(input, counterId) {
623
const maxLength = 150;
624
const currentLength = input.value.length;
625
const counter = document.getElementById(counterId);
626
627
if (counter) {
628
counter.innerText = `${currentLength}/${maxLength}`;
629
630
counter.className = 'small ' + (
631
currentLength >= maxLength ? 'text-danger fw-bold' :
632
currentLength >= 130 ? 'text-warning fw-bold' :
633
'text-muted'
634
);
635
}
636
}
637
638
$(document).ready(function() {
639
$('.like-form').on('submit', function(e) {
640
e.preventDefault();
641
var form = $(this);
642
var btn = form.find('button');
643
var span = btn.find('.like-count');
644
var icon = btn.find('i');
645
$.ajax({
646
type: 'POST', url: '/like', data: form.serialize(),
647
success: function(res) {
648
if(res.isLiked) {
649
btn.addClass('liked'); icon.removeClass('bi-heart').addClass('bi-heart-fill');
650
} else {
651
btn.removeClass('liked'); icon.removeClass('bi-heart-fill').addClass('bi-heart');
652
}
653
span.text(res.likeCount);
654
},
655
error: function(xhr) {
656
if(xhr.status === 401) {
657
showConfirm('로그인이 필요합니다. 이동할까요?').then(function(ok){ if(ok) location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname); });
658
} else { showAlert('오류 발생'); }
659
}
660
});
661
});
662
});
663
664
function toggleReplyForm(id) {
665
var targetId = id ? 'reply-form-' + id : 'reply-form';
666
var form = document.getElementById(targetId);
667
if (form) {
668
if(form.classList.contains('d-none')) {
669
form.classList.remove('d-none');
670
form.querySelector('textarea')?.focus();
671
} else {
672
form.classList.add('d-none');
673
}
674
} else {
675
showConfirm('로그인이 필요합니다. 이동할까요?').then(function(ok){ if(ok) location.href='/login?redirect=' + encodeURIComponent(window.location.pathname); });
676
}
677
}
678
679
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
680
const pw = await showPrompt("비밀번호:"); if(!pw) return;
681
fetch('/hinana/delete-post', {
682
// 실제 비밀번호 삭제 로직 필요 (예시)
683
}).then(() => location.href='/hinana/blog');
684
}
685
686
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
687
const pw = await showPrompt("비밀번호:"); if(!pw) return;
688
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
689
method:'POST', headers:{'Content-Type':'application/json'},
690
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
691
}).then(r=>r.json()).then(d=>{ showAlert(d.message).then(()=>{ if(d.success) location.reload(); }); });
692
}
693
</script>
694
<script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); }</script>
695
</body>
696
</html>
697