Public Source Viewer

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

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

Redacted View
view/hinana/post.ejs
공개 가능
1 <!DOCTYPE html>
2 <html lang="ko">
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 <title>게시글 상세 - Hinana.moe</title>
9 <meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#000000' : '#ffffff' %>">
10 <meta name="apple-mobile-web-app-title" content="비나래 아카이브">
11 <meta property="og:image" content="/image/2.png" />
12 <meta property="og:description" content="morikubo"/>
13 <meta property="og:url" content="hinana.moe"/>
14 <meta property="og:title" content="비나래 아카이브"/>
15 <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
16 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
17 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
18 <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
19 <script src="/js/popup.js"></script>
20
21 <style>
22 :root {
23 --font-family: 'Noto Sans KR', sans-serif;
24 --bg-main: #ffffff; --bg-secondary: #f7f9f9; --bg-tertiary: #eff3f4;
25 --text-primary: #0f1419; --text-secondary: #536471;
26 --accent-color: #1d9bf0; --danger-color: #f4212e; --border-color: #eff3f4;
27 --shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
28 }
29 body.dark-mode {
30 --bg-main: #000000; --bg-secondary: #16181c; --bg-tertiary: #202327;
31 --text-primary: #e7e9ea; --text-secondary: #71767b;
32 --border-color: #2f3336; --accent-color: #1d9bf0; --danger-color: #f4212e;
33 --shadow-sm: 0 1px 3px rgba(255,255,255,0.04);
34 }
35
36 html, body { height: 100%; margin: 0; font-family: var(--font-family); background-color: var(--bg-main); color: var(--text-primary); overflow: hidden; }
37 a { text-decoration: none; color: inherit; }
38
39 /* 헤더 */
40 .global-header {
41 height: 60px;
42 background-color: rgba(255,255,255,0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
43 border-bottom: 1px solid var(--border-color);
44 display: flex; align-items: center; justify-content: space-between; padding: 0 20px;
45 position: sticky; top: 0; z-index: 1000;
46 }
47 body.dark-mode .global-header { background-color: rgba(0,0,0,0.85); }
48 .header-brand { display: flex; align-items: center; }
49 .header-logo { height: 28px; width: auto; }
50
51 .header-nav {
52 position: absolute; left: 50%; transform: translateX(-50%);
53 display: flex; gap: 20px; align-items: center; z-index: 5;
54 }
55 .nav-link { font-weight: 600; font-size: 0.95rem; color: var(--text-secondary); }
56 .nav-link:hover { color: var(--accent-color); }
57 .nav-link.active { color: var(--text-primary); }
58 .nav-divider { opacity: 0.3; color: var(--text-secondary); }
59 .login-link { color: var(--accent-color); font-weight: bold; }
60
61 .header-controls { display: flex; align-items: center; gap: 10px; z-index: 10; position: relative; background-color: var(--bg-tertiary); }
62
63 .layout-container { display: flex; height: calc(100vh - 60px); }
64
65 /* [좌측] 뒤로가기 안내 */
66 .shelf-column {
67 width: 300px; min-width: 300px; background-color: var(--bg-secondary);
68 border-right: 1px solid var(--border-color); display: flex; flex-direction: column;
69 justify-content: center; align-items: center; color: var(--text-secondary); font-size: 0.9rem;
70 }
71
72 /* [중앙] 본문 */
73 .content-column {
74 flex: 1; display: flex; flex-direction: column;
75 background-color: var(--bg-main); position: relative; overflow: hidden;
76 min-height: 0;
77 }
78 .content-scroll-area {
79 flex: 1; overflow-y: auto; padding: 30px;
80 min-height: 0;
81 }
82 .content-card {
83 background-color: var(--bg-secondary); border-radius: 12px; box-shadow: var(--shadow-sm);
84 border: 1px solid var(--border-color);
85 width: 100%; max-width: 800px; margin: 0 auto 30px auto;
86 }
87
88 .post-header {
89 padding: 20px; background-color: var(--bg-tertiary); border-bottom: 1px solid var(--border-color);
90 display: flex; justify-content: space-between; align-items: center;
91 }
92 .post-avatar {
93 width: 48px; height: 48px; background-color: var(--bg-secondary);
94 border: 2px solid var(--border-color); border-radius: 50%;
95 display: flex; align-items: center; justify-content: center; font-size: 1.2rem; font-weight: bold; color: var(--text-secondary);
96 }
97 .post-meta { flex: 1; margin-left: 15px; }
98 .post-author { font-size: 1.1rem; font-weight: 700; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; }
99 .badge-admin { background-color: var(--accent-color); color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; }
100 .post-date { font-size: 0.85rem; color: var(--text-secondary); }
101
102 .post-content {
103 padding: 30px; font-size: 1.1rem; line-height: 1.8; color: var(--text-primary);
104 white-space: normal; word-break: break-all; text-align: left;
105 }
106
107 .post-actions {
108 padding: 15px 20px; background-color: var(--bg-main); border-top: 1px solid var(--border-color);
109 display: flex; justify-content: flex-end; gap: 10px;
110 }
111 .action-btn {
112 display: flex; align-items: center; gap: 6px; padding: 8px 12px;
113 border-radius: 6px; border: 1px solid var(--border-color);
114 background: var(--bg-secondary); color: var(--text-secondary);
115 font-size: 0.9rem; cursor: pointer; transition: all 0.2s;
116 }
117 .action-btn:hover { background-color: var(--border-color); color: var(--text-primary); }
118 .action-btn.liked { color: var(--danger-color); border-color: var(--danger-color); background-color: rgba(220, 38, 38, 0.1); }
119
120 .replies-container { padding: 20px; background-color: var(--bg-main); }
121 .reply-item { margin-bottom: 15px; padding-left: 15px; border-left: 3px solid var(--border-color); }
122 .reply-meta { display: flex; justify-content: space-between; font-size: 0.85rem; margin-bottom: 5px; }
123 .reply-author { font-weight: 600; display: flex; align-items: center; gap: 4px; }
124 .reply-content { font-size: 0.95rem; line-height: 1.5; }
125
126 /* [우측] 정보창 */
127 .info-column {
128 width: 260px; min-width: 260px; background-color: var(--bg-secondary);
129 border-left: 1px solid var(--border-color); padding: 20px;
130 display: flex; flex-direction: column; gap: 20px;
131 }
132 .info-card {
133 background-color: var(--bg-main); border-radius: 12px; padding: 20px;
134 border: 1px solid var(--border-color); box-shadow: var(--shadow-sm);
135 }
136 .info-card-title { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-secondary); margin-bottom: 10px; }
137
138 /* 테마 토글 */
139 .theme-toggle-wrapper { display: flex; align-items: center; justify-content: space-between; }
140 .switch { position: relative; display: inline-block; width: 44px; height: 24px; }
141 .switch input { opacity: 0; width: 0; height: 0; }
142 .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--text-secondary); transition: .4s; border-radius: 24px; }
143 .slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
144 input:checked + .slider { background-color: var(--accent-color); }
145 input:checked + .slider:before { transform: translateX(20px); }
146
147 /* [반응형 수정 - index.ejs와 동일하게 우측 패널 하단 이동] */
148 @media (max-width: 960px) {
149 html, body { overflow: auto !important; height: auto !important; }
150 .layout-container { flex-direction: column; height: auto !important; }
151
152 .global-header { flex-wrap: wrap; height: auto; padding: 10px 20px; }
153 .header-nav { position: static; transform: none; width: 100%; justify-content: center; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(0,0,0,0.05); order: 3; }
154 .header-brand { flex: 1; order: 1; }
155 .header-controls { flex: auto; justify-content: flex-end; background-color: transparent; order: 2; }
156
157 /* 좌측은 숨김 (목록으로 가기 버튼이 있으므로) */
158 .shelf-column { display: none; }
159
160 /* 중앙 본문 */
161 .content-column { width: 100%; height: auto !important; border: none; overflow: visible; order: 1; }
162 .content-scroll-area { padding: 15px; height: auto !important; overflow: visible; }
163
164 /* 우측 정보창 -> 하단으로 이동 */
165 .info-column {
166 display: flex; /* 보이게 설정 */
167 width: 100%; height: auto;
168 border-left: none; border-top: 1px solid var(--border-color);
169 order: 2; /* 본문 아래 */
170 padding: 20px; flex-direction: row; flex-wrap: wrap;
171 }
172 .info-card { flex: 1; min-width: 200px; margin-bottom: 0; }
173 .footer-info { display: none; }
174 }
175
176 .d-none { display: none !important; }
177
178 /* 인증마크 */
179 .verified-badge { color: #1d9bf0; font-size: 0.85em; margin-left: 2px; }
180 .verified-badge-admin { color: var(--accent-color); font-size: 0.85em; margin-left: 2px; }
181
182 /* 해시태그 */
183 .post-content a.hashtag, .reply-content a.hashtag { color: #2196F3 !important; text-decoration: none; cursor: pointer; }
184 .post-content a.hashtag:hover, .reply-content a.hashtag:hover { color: #1976D2 !important; text-decoration: underline; }
185 .post-content a.external-link, .reply-content a.external-link { color: #2196F3 !important; text-decoration: none; cursor: pointer; }
186 .post-content a.external-link:hover, .reply-content a.external-link:hover { color: #1976D2 !important; text-decoration: underline; }
187
188 /* 링크 미리보기 카드 */
189 .link-preview-card {
190 display: flex; max-width: 400px; margin: 8px 0 4px; border-radius: 8px;
191 overflow: hidden; border: 1px solid var(--border-color);
192 background-color: var(--bg-secondary); cursor: pointer;
193 }
194 .link-preview-bar { width: 4px; flex-shrink: 0; background-color: var(--accent-color); }
195 .link-preview-body { padding: 10px 12px; flex: 1; min-width: 0; }
196 .link-preview-domain { font-size: 0.7rem; color: var(--text-secondary); margin-bottom: 3px; }
197 .link-preview-title { font-size: 0.85rem; font-weight: 700; color: #2196F3; margin-bottom: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
198 .link-preview-desc {
199 font-size: 0.78rem; color: var(--text-secondary); line-height: 1.4;
200 display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
201 }
202 .link-preview-img { margin-top: 8px; border-radius: 4px; max-width: 100%; max-height: 200px; object-fit: cover; }
203 </style>
204 </head>
205
206 <body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
207
208 <header class="global-header">
209 <div class="header-brand">
210 <a href="/hinana/index">
211 <img src="/image/<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'archive1.png' : 'archive.png' %>"
212 alt="Hinana Archive" class="header-logo">
213 </a>
214 </div>
215 <nav class="header-nav">
216 <a href="/hinana/index" class="nav-link active">Archive</a>
217 <a href="/hinana/info" class="nav-link">Info</a>
218 <a href="/hinana/blog" class="nav-link">Blog</a>
219 <a href="/hinana/lounge" class="nav-link">Lounge</a>
220
221 <span class="nav-divider">|</span>
222
223 <% if(username) { %>
224 <a href="/logout?redirect=/hinana/index" class="nav-link text-danger fw-bold">Logout</a>
225 <% } else { %>
226 <a href="/login?redirect=/hinana/index" class="nav-link login-link">Login</a>
227 <% } %>
228 </nav>
229 <div class="header-controls">
230 <a href="/hinana/gallery#brand-assets" class="nav-link" style="font-size:0.9rem;">사이트 맵</a>
231 </div>
232 </header>
233
234 <div class="layout-container">
235
236 <div class="shelf-column">
237 <div class="text-center">
238 <a href="/hinana/index" class="btn btn-outline-secondary btn-sm">
239 <i class="bi bi-arrow-left"></i> 목록으로
240 </a>
241 </div>
242 </div>
243
244 <div class="content-column">
245 <div class="content-scroll-area custom-scrollbar">
246
247 <div class="content-card">
248 <div class="post-header">
249 <div class="d-flex align-items-center gap-3">
250 <div class="post-avatar">
251 <% var authorName = post.username.replace('(익명)', '').trim(); %>
252 <% if (!post.username.endsWith('(익명)') && typeof userProfileImages !== 'undefined' && userProfileImages[authorName]) { %>
253 <img src="<%= userProfileImages[authorName] %>" style="width:100%; height:100%; border-radius:50%; object-fit:cover;">
254 <% } else { %>
255 <%= post.username.substring(0,1).toUpperCase() %>
256 <% } %>
257 </div>
258 <div class="post-meta">
259 <div class="post-author">
260 <%= post.username.replace('(익명)', '').trim() %>
261 <% if(post.username.endsWith('(익명)')) { %>
262 <i class="bi bi-incognito ms-1 text-muted" title="익명"></i>
263 <% } %>
264 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
265 <% } else if(typeof verifiedUsers !== 'undefined' && verifiedUsers.includes(post.username.replace('(익명)',''))) { %><i class="bi bi-patch-check-fill verified-badge" title="인증됨"></i><% } %>
266 </div>
267 <div class="post-date">
268 <%= fmtDate(post.timestamp) %>
269 <% if(post.isPrivate) { %><i class="bi bi-lock-fill ms-1 text-warning"></i><% } %>
270 </div>
271 </div>
272 </div>
273
274 <div>
275 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
276 <form action="/delete" method="POST" data-confirm="삭제하시겠습니까?">
277 <input type="hidden" name="id" value="<%= post.id %>">
278 <button class="btn btn-link text-secondary p-0"><i class="bi bi-trash"></i></button>
279 </form>
280 <% } else if (post.username.endsWith('(익명)') && typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
281 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
282 <% } %>
283 </div>
284 </div>
285
286 <div class="post-content">
287 <% if(post.image) { %>
288 <div class="mb-3">
289 <img src="<%= post.image %>" alt="Attached" class="img-fluid rounded" style="max-width: 100%;">
290 </div>
291 <% } %>
292 <%- post.content
293 .replace(/<script[^>]*>([\S\s]*?)<\/script>/gim, "")
294 .replace(/<a\s+href="([^"]*?)"\s+class="hashtag">([^<]*?)<\/a>/gim, '___HASHTAG___$1___$2___END___')
295 .replace(/<a\s+href="#"\s+class="external-link"\s+data-url="([^"]*?)">([^<]*?)<\/a>/gim, '___EXTLINK___$1___$2___END___')
296 .replace(/<\/p>\s*<p[^>]*>/gim, '\n')
297 .replace(/<br\s*\/?>/gim, '\n')
298 .replace(/<\/div>\s*<div[^>]*>/gim, '\n')
299 .replace(/<\/?\w(?:[^"'>]|"[^"]*"|'[^']*')*>/gim, "")
300 .replace(/___HASHTAG___(.*?)___(.*?)___END___/gim, '<a href="$1" class="hashtag">$2</a>')
301 .replace(/___EXTLINK___(.*?)___(.*?)___END___/gim, '<a href="#" class="external-link" data-url="$1">$2</a>')
302 .replace(/&nbsp;/g, ' ')
303 .replace(/\n/g, '<br>')
304 %>
305 </div>
306
307 <div class="post-actions">
308 <form action="/like" method="POST" class="like-form">
309 <input type="hidden" name="postId" value="<%= post.id %>">
310 <% const isLiked = post.likes && post.likes.includes(username); %>
311 <button type="submit" class="action-btn <%= isLiked ? 'liked' : '' %>">
312 <i class="bi <%= isLiked ? 'bi-heart-fill' : 'bi-heart' %>"></i>
313 <span class="like-count">Likes <%= post.likes ? post.likes.length : 0 %></span>
314 </button>
315 </form>
316 <button class="action-btn" onclick="toggleReplyForm()">
317 <i class="bi bi-chat-quote-fill"></i> Reply
318 </button>
319 </div>
320
321 <div id="reply-form" class="d-none p-3 bg-tertiary border-top">
322 <% if(username) { %>
323 <form action="/reply" method="POST">
324 <input type="hidden" name="postId" value="<%= post.id %>">
325 <input type="hidden" name="redirectUrl" value="/post/<%= post.id %>">
326 <div class="d-flex gap-2">
327 <textarea class="write-textarea" name="content" rows="2" placeholder="답글 작성..." required style="resize:none; width:100%; border-radius:6px; border:1px solid var(--border-color); padding:8px;"></textarea>
328 <button class="btn btn-sm btn-secondary" style="background:var(--accent-color); border:none; color:white;">전송</button>
329 </div>
330 </form>
331 <% } else if(typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
332 <form action="/reply" method="POST">
333 <input type="hidden" name="postId" value="<%= post.id %>">
334 <input type="hidden" name="redirectUrl" value="/post/<%= post.id %>">
335 <input type="hidden" name="isAnonymous" value="true">
336 <div class="row g-2 mb-2">
337 <div class="col-6"><input type="text" class="form-control form-control-sm" name="anonymousUsername" placeholder="닉네임" required></div>
338 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
339 </div>
340 <div class="d-flex gap-2">
341 <textarea class="write-textarea" name="content" rows="2" placeholder="익명 답글..." required style="resize:none; width:100%; border-radius:6px; border:1px solid var(--border-color); padding:8px;"></textarea>
342 <button class="btn btn-sm btn-secondary" style="background:var(--accent-color); border:none; color:white;">전송</button>
343 </div>
344 </form>
345 <% } else { %>
346 <div class="text-center py-2">
347 <a href="/login?redirect=/post/<%= post.id %>" class="btn btn-sm btn-primary">로그인하여 답글 달기</a>
348 </div>
349 <% } %>
350 </div>
351
352 <% if(replies && replies.length > 0) { %>
353 <div class="replies-container">
354 <div class="replies-header mb-3 fw-bold text-secondary">
355 <i class="bi bi-chat-dots"></i> Comments (<%= replies.length %>)
356 </div>
357
358 <% function renderReplies(replyList, depth) { %>
359 <% replyList.forEach(function(reply) { %>
360 <div class="reply-item" style="margin-left: <%= depth * 20 %>px;">
361 <div class="reply-meta">
362 <div class="reply-author">
363 <% var replyUser = reply.username.replace('(익명)', '').trim(); %>
364 <% if(!reply.username.endsWith('(익명)') && typeof userProfileImages !== 'undefined' && userProfileImages[replyUser]) { %>
365 <img src="<%= userProfileImages[replyUser] %>" style="width:20px; height:20px; border-radius:50%; object-fit:cover; border:1px solid var(--border-color); flex-shrink:0;">
366 <% } %>
367 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
368 <%= reply.username.replace('(익명)', '').trim() %>
369 </span>
370 <% if(reply.username.endsWith('(익명)')) { %>
371 <i class="bi bi-incognito ms-1 text-muted" style="font-size:0.9em;"></i>
372 <% } %>
373 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
374 <% } else if(typeof verifiedUsers !== 'undefined' && verifiedUsers.includes(reply.username.replace('(익명)','').trim())) { %><i class="bi bi-patch-check-fill verified-badge" title="인증됨"></i><% } %>
375 </div>
376
377 <div class="text-end">
378 <span class="text-muted small">
379 <%= fmtDate(reply.timestamp) %>
380 </span>
381
382 <div class="mt-1">
383 <button class="btn p-0 text-secondary border-0 bg-transparent me-2" style="font-size:0.75rem;" onclick="toggleReplyForm('<%= reply.id %>')">
384 <i class="bi bi-chat-quote-fill"></i> 답글
385 </button>
386
387 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
388 <form action="/delete-reply" method="POST" class="d-inline" data-confirm="삭제하시겠습니까?">
389 <input type="hidden" name="postId" value="<%= post.id %>">
390 <input type="hidden" name="replyId" value="<%= reply.id %>">
391 <button class="btn p-0 text-danger border-0 bg-transparent" style="font-size:0.75rem;"><i class="bi bi-trash"></i></button>
392 </form>
393 <% } else if(reply.username.endsWith('(익명)') && typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
394 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
395 <% } %>
396 </div>
397 </div>
398 </div>
399
400 <div class="reply-content">
401 <%- (reply.content || '')
402 .replace(/<script[^>]*>([\S\s]*?)<\/script>/gim, "")
403 .replace(/<a\s+href="([^"]*?)"\s+class="hashtag">([^<]*?)<\/a>/gim, '___HASHTAG___$1___$2___END___')
404 .replace(/<a\s+href="#"\s+class="external-link"\s+data-url="([^"]*?)">([^<]*?)<\/a>/gim, '___EXTLINK___$1___$2___END___')
405 .replace(/<\/?\w(?:[^"'>]|"[^"]*"|'[^']*')*>/gim, "")
406 .replace(/___HASHTAG___(.*?)___(.*?)___END___/gim, '<a href="$1" class="hashtag">$2</a>')
407 .replace(/___EXTLINK___(.*?)___(.*?)___END___/gim, '<a href="#" class="external-link" data-url="$1">$2</a>')
408 .replace(/&nbsp;/g, ' ')
409 .replace(/\n/g, '<br>')
410 %>
411 </div>
412
413 <div id="reply-form-<%= reply.id %>" class="d-none p-2 bg-tertiary rounded mb-3 mt-2">
414 <% if(username) { %>
415 <form action="/reply" method="POST">
416 <input type="hidden" name="postId" value="<%= post.id %>">
417 <input type="hidden" name="parentReplyId" value="<%= reply.id %>">
418 <input type="hidden" name="redirectUrl" value="/post/<%= post.id %>">
419 <div class="d-flex gap-2">
420 <textarea class="write-textarea" name="content" rows="1" placeholder="답글..." required style="resize:none; font-size:0.9rem; padding:6px;"></textarea>
421 <button class="btn btn-sm btn-accent text-white" style="background:var(--accent-color); border:none;">등록</button>
422 </div>
423 </form>
424 <% } else if(typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
425 <form action="/reply" method="POST">
426 <input type="hidden" name="postId" value="<%= post.id %>">
427 <input type="hidden" name="parentReplyId" value="<%= reply.id %>">
428 <input type="hidden" name="redirectUrl" value="/post/<%= post.id %>">
429 <input type="hidden" name="isAnonymous" value="true">
430 <div class="row g-1 mb-1">
431 <div class="col-6"><input type="text" class="form-control form-control-sm" name="anonymousUsername" placeholder="닉네임" required></div>
432 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
433 </div>
434 <div class="d-flex gap-2">
435 <textarea class="write-textarea" name="content" rows="1" placeholder="익명 답글..." required style="resize:none; font-size:0.9rem; padding:6px;"></textarea>
436 <button class="btn btn-sm btn-accent text-white" style="background:var(--accent-color); border:none;">등록</button>
437 </div>
438 </form>
439 <% } %>
440 </div>
441
442 <% if(reply.replies && reply.replies.length > 0) { %>
443 <%= renderReplies(reply.replies, depth + 1) %>
444 <% } %>
445 </div>
446 <% }); %>
447 <% } %>
448
449 <%= renderReplies(replies, 0) %>
450 </div>
451 <% } %>
452 </div>
453 </div>
454 </div>
455
456 <div class="info-column">
457 <div class="info-card">
458 <div class="info-card-title">Current User</div>
459 <div class="d-flex align-items-center gap-2">
460 <i class="bi bi-person-circle fs-4 text-secondary"></i>
461 <div class="fw-bold"><% if(username) { %><a href="/hinana/userInfo" style="color: inherit;"><%= username %></a><% } else { %>Guest<% } %>
462 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
463 <% } else if(typeof verifiedUsers !== 'undefined' && verifiedUsers.includes(username)) { %><i class="bi bi-patch-check-fill verified-badge" title="인증됨"></i><% } %></div>
464 </div>
465 <div class="mt-3">
466 <% if(username) { %>
467 <a href="/logout?redirect=/post/<%= post.id %>" class="btn btn-outline-secondary btn-sm w-100">Logout</a>
468 <% } else { %>
469 <a href="/login?redirect=/post/<%= post.id %>" class="btn btn-primary btn-sm w-100">Login</a>
470 <% } %>
471 </div>
472
473 </div>
474
475 <div class="info-card">
476 <div class="info-card-title">Settings</div>
477 <div class="theme-toggle-wrapper">
478 <span class="d-flex align-items-center gap-2 small">
479 <i class="bi <%= theme==='dark'?'bi-moon-stars-fill':'bi-sun-fill' %>"></i>
480 <%= theme==='dark'?'Dark Mode':'Light Mode' %>
481 </span>
482 <form action="/toggle-theme" method="POST" id="theme-form">
483 <label class="switch" style="transform:scale(0.8);">
484 <input type="checkbox" <%= theme==='dark'?'checked':'' %> onchange="document.getElementById('theme-form').submit()">
485 <span class="slider"></span>
486 </label>
487 </form>
488 </div>
489 </div>
490 <% if(post) { %>
491 <div class="info-card">
492 <div class="info-card-title">System Info</div>
493 <ul class="small text-secondary list-unstyled mb-0">
494 <li class="mb-1 d-flex justify-content-between"><span>Version</span></li>
495 <li class="mb-1 d-flex justify-content-between">
496 <span>Ver. 6.5.4.0-Kozeki Ui</span>
497 </li>
498 </ul>
499 </div>
500 <% } %>
501
502 <div style="text-align:left;" class="footer">
503 <img src="/image/sign.png" id="fumika_sign" style="max-width:250px; max-height:initial; width:100%; height:100%;" />
504 </div>
505 <footer class="container-fluid text-center footer">
506 <a href="#myPage" title="To Top">
507 <span class="glyphicon glyphicon-chevron-up"></span>
508 </a>
509 <p style="margin-bottom: 0rem;" class="copyright">X - @NoctchillHinana</p>
510 <p style="margin-bottom: 0rem;" class="copyright">ⓒ 2024~2026. 비나래 | hinana.moe All rights reserved.</p>
511 </footer>
512 </div>
513 </div>
514
515
516 <script>
517 // 좋아요 AJAX
518 $(document).ready(function() {
519 $('.like-form').on('submit', function(e) {
520 e.preventDefault();
521 var form = $(this);
522 var btn = form.find('button');
523 var span = btn.find('.like-count');
524 var icon = btn.find('i');
525
526 $.ajax({
527 type: 'POST', url: '/like', data: form.serialize(),
528 success: function(res) {
529 if(res.isLiked) {
530 btn.addClass('liked'); icon.removeClass('bi-heart').addClass('bi-heart-fill');
531 } else {
532 btn.removeClass('liked'); icon.removeClass('bi-heart-fill').addClass('bi-heart');
533 }
534 span.text('Likes ' + res.likeCount);
535 },
536 error: function(xhr) {
537 if(xhr.status === 401) {
538 showConfirm('로그인이 필요합니다. 이동할까요?').then(function(ok){ if(ok) location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname); });
539 } else {
540 showAlert('오류 발생');
541 }
542 }
543 });
544 });
545 });
546
547 // 답글 폼 토글
548 function toggleReplyForm(id) {
549 var targetId = id ? 'reply-form-' + id : 'reply-form';
550 var form = document.getElementById(targetId);
551 if (form) {
552 if(form.classList.contains('d-none')) {
553 form.classList.remove('d-none');
554 form.querySelector('textarea')?.focus();
555 } else {
556 form.classList.add('d-none');
557 }
558 } else {
559 showConfirm('로그인이 필요합니다. 이동할까요?').then(function(ok){ if(ok) location.href='/login?redirect=' + encodeURIComponent(window.location.pathname); });
560 }
561 }
562
563 // 삭제 함수들
564 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
565 const pw = await showPrompt("비밀번호:"); if(!pw) return;
566 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
567 method:'POST', headers:{'Content-Type':'application/json'},
568 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
569 }).then(r=>r.json()).then(d=>{ showAlert(d.message).then(()=>{ if(d.success) location.href='/hinana/index'; }); });
570 }
571
572 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
573 const pw = await showPrompt("비밀번호:"); if(!pw) return;
574 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
575 method:'POST', headers:{'Content-Type':'application/json'},
576 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
577 }).then(r=>r.json()).then(d=>{ showAlert(d.message).then(()=>{ if(d.success) location.reload(); }); });
578 }
579
580 // 외부 링크 경고
581 document.addEventListener('click', function(e) {
582 const card = e.target.closest('.link-preview-card');
583 const link = card ? null : e.target.closest('a.external-link');
584 const target = card || link;
585 if (!target) return;
586 e.preventDefault();
587 const url = target.getAttribute('data-url');
588 showConfirm('안전하지 않을 수 있는 외부 링크입니다.\n이동하시겠습니까?\n\n' + url).then(function(ok) {
589 if (ok) window.open(url, '_blank', 'noopener,noreferrer');
590 });
591 });
592
593 // 링크 미리보기 카드 생성
594 (function() {
595 const links = document.querySelectorAll('.post-content a.external-link, .reply-content a.external-link');
596 const seen = new Set();
597 links.forEach(function(link) {
598 const url = link.getAttribute('data-url');
599 if (!url || seen.has(url)) return;
600 seen.add(url);
601 fetch('/api/link-preview?url=' + encodeURIComponent(url))
602 .then(r => r.json())
603 .then(data => {
604 if (data.error || (!data.title && !data.description)) return;
605 const card = document.createElement('div');
606 card.className = 'link-preview-card';
607 card.setAttribute('data-url', url);
608 const bar = document.createElement('div');
609 bar.className = 'link-preview-bar';
610 if (data.color) bar.style.backgroundColor = data.color;
611 const body = document.createElement('div');
612 body.className = 'link-preview-body';
613 if (data.domain) { const d = document.createElement('div'); d.className = 'link-preview-domain'; d.textContent = data.domain; body.appendChild(d); }
614 if (data.title) { const t = document.createElement('div'); t.className = 'link-preview-title'; t.textContent = data.title; body.appendChild(t); }
615 if (data.description) { const dc = document.createElement('div'); dc.className = 'link-preview-desc'; dc.textContent = data.description; body.appendChild(dc); }
616 if (data.image) { const img = document.createElement('img'); img.className = 'link-preview-img'; img.src = data.image; img.alt = ''; img.onerror = function() { this.remove(); }; body.appendChild(img); }
617 card.appendChild(bar);
618 card.appendChild(body);
619 if (link.nextSibling) { link.parentNode.insertBefore(card, link.nextSibling); } else { link.parentNode.appendChild(card); }
620 })
621 .catch(function() {});
622 });
623 })();
624 </script>
625 <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); }</script>
626 </body>
627 </html>
628