Public Source Viewer

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

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

Redacted View
view/hinana/index.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 <meta property="og:image" content="/image/2.png" />
11 <meta property="og:description" content="morikubo"/>
12 <meta property="og:url" content="hinana.moe"/>
13 <meta property="og:title" content="비나래 아카이브"/>
14 <link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
15 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
16 <link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet" type="text/css">
17 <link href="https://fonts.googleapis.com/css?family=Lato" rel="stylesheet" type="text/css">
18 <link rel="stylesheet" href="/css/hinana.css" type="text/css">
19 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
20 <script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
21 <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.3/umd/popper.min.js" integrity="sha384-vFJXuSJphROIrBnz7yo7oB41mKfc8JzQZiCq4NCceLEaO4IHwicKwpJf9c9IpFgh" crossorigin="anonymous"></script>
22 <script src="/js/popup.js"></script>
23 <title>비나래 아카이브</title>
24
25 <style>
26 :root {
27 --font-family: 'Noto Sans KR', sans-serif;
28 --bg-main: #ffffff;
29 --bg-secondary: #f7f9f9;
30 --bg-tertiary: #eff3f4;
31 --text-primary: #0f1419;
32 --text-secondary: #536471;
33 --accent-color: #1d9bf0;
34 --danger-color: #f4212e;
35 --border-color: #eff3f4;
36 --shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
37 --shadow-md: 0 4px 12px rgba(0,0,0,0.10);
38 }
39
40 body.dark-mode {
41 --bg-main: #000000;
42 --bg-secondary: #16181c;
43 --bg-tertiary: #202327;
44 --text-primary: #e7e9ea;
45 --text-secondary: #71767b;
46 --accent-color: #1d9bf0;
47 --danger-color: #f4212e;
48 --border-color: #2f3336;
49 --shadow-sm: 0 1px 3px rgba(255,255,255,0.04);
50 --shadow-md: 0 4px 12px rgba(0,0,0,0.6);
51 }
52
53 .verified-badge { color: #1d9bf0; font-size: 0.85em; margin-left: 2px; }
54 .verified-badge-admin { color: var(--accent-color); font-size: 0.85em; margin-left: 2px; }
55
56 /* 기본 초기화 */
57 html, body {
58 height: 100%;
59 margin: 0;
60 font-family: var(--font-family);
61 background-color: var(--bg-main);
62 color: var(--text-primary);
63 -webkit-overflow-scrolling: touch;
64 }
65
66 a { text-decoration: none; color: inherit; }
67 ul { list-style: none; padding: 0; margin: 0; }
68 .btn:focus { box-shadow: none; }
69
70 /* [헤더 스타일] */
71 .global-header {
72 height: 60px;
73 background-color: rgba(255,255,255,0.85);
74 backdrop-filter: blur(12px);
75 -webkit-backdrop-filter: blur(12px);
76 border-bottom: 1px solid var(--border-color);
77 display: flex;
78 align-items: center;
79 justify-content: space-between;
80 padding: 0 20px;
81 position: sticky; top: 0; z-index: 1000;
82 }
83 body.dark-mode .global-header {
84 background-color: rgba(0,0,0,0.85);
85 }
86
87 /* 로고 영역 (커서 깜빡임 방지) */
88 .header-brand, .header-brand a {
89 position: relative; z-index: 20; display: flex; align-items: center;
90 user-select: none; -webkit-user-select: none;
91 caret-color: transparent; outline: none; cursor: pointer;
92 }
93 .header-logo { height: 28px; width: auto; }
94 body:not(.dark-mode) .logo-night { display: none; }
95 body.dark-mode .logo-day { display: none; }
96
97 /* 중앙 네비게이션 (절대 위치 중앙 정렬 + 클릭 통과 처리) */
98 .header-nav {
99 position: absolute; left: 50%; transform: translateX(-50%); top: 0; height: 100%;
100 display: flex; align-items: center; gap: 20px; justify-content: center;
101 pointer-events: none; /* 투명 영역 클릭 통과 */
102 z-index: 10;
103 }
104 /* 내부 버튼은 클릭 가능하게 복구 */
105 .header-nav a, .header-nav .nav-link, .header-nav .icon-btn { pointer-events: auto; }
106
107 .nav-link { font-weight: 600; font-size: 0.95rem; color: var(--text-secondary); transition: color 0.2s; position: relative; }
108 .nav-link:hover { color: var(--accent-color); text-decoration: none; }
109 .nav-link.active { color: var(--text-primary); }
110
111 .header-controls { position: relative; z-index: 20; display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
112 .icon-btn { border: none; background: transparent; color: var(--text-secondary); font-size: 1.1rem; cursor: pointer; padding: 4px; transition: color 0.2s; }
113 .icon-btn:hover { color: var(--text-primary); }
114
115 /* [해시태그] */
116 .post-content a.hashtag, .reply-content a.hashtag { color: #2196F3 !important; text-decoration: none; cursor: pointer; }
117 .post-content a.hashtag:hover, .reply-content a.hashtag:hover { color: #1976D2 !important; text-decoration: underline; }
118 .post-content a.external-link, .reply-content a.external-link { color: #2196F3 !important; text-decoration: none; cursor: pointer; }
119 .post-content a.external-link:hover, .reply-content a.external-link:hover { color: #1976D2 !important; text-decoration: underline; }
120
121 /* 링크 미리보기 카드 (Discord 스타일) */
122 .link-preview-card {
123 display: flex; max-width: 400px; margin: 8px 0 4px; border-radius: 8px;
124 overflow: hidden; border: 1px solid var(--border-color);
125 background-color: var(--bg-secondary); cursor: pointer;
126 }
127 .link-preview-bar { width: 4px; flex-shrink: 0; background-color: var(--accent-color); }
128 .link-preview-body { padding: 10px 12px; flex: 1; min-width: 0; }
129 .link-preview-domain { font-size: 0.7rem; color: var(--text-secondary); margin-bottom: 3px; }
130 .link-preview-title { font-size: 0.85rem; font-weight: 700; color: #2196F3; margin-bottom: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
131 .link-preview-desc {
132 font-size: 0.78rem; color: var(--text-secondary); line-height: 1.4;
133 display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
134 }
135 .link-preview-img { margin-top: 8px; border-radius: 4px; max-width: 100%; max-height: 200px; object-fit: cover; }
136
137 /* [검색창 다크모드 대응] */
138 #global-search-bar { background-color: var(--bg-tertiary); border-bottom-color: var(--border-color) !important; }
139 #global-search-bar input.form-control { background-color: var(--bg-main); color: var(--text-primary); border-color: var(--border-color); }
140 #global-search-bar input.form-control:focus { border-color: var(--accent-color); box-shadow: 0 0 0 0.2rem rgba(180, 83, 9, 0.25); }
141 #global-search-bar input::placeholder { color: var(--text-secondary); }
142
143 .layout-container { display: flex; height: calc(100vh - 60px); }
144
145 /* 좌측 피드 패널 */
146 .shelf-column { width: 320px; min-width: 320px; background-color: var(--bg-main); border-right: 1px solid var(--border-color); display: flex; flex-direction: column; z-index: 10; }
147 .shelf-body { flex: 1; overflow-y: auto; }
148 .shelf-footer { padding: 12px 16px; border-top: 1px solid var(--border-color); text-align: center; background-color: var(--bg-main); }
149
150 /* 페이지네이션 */
151 .pagination-bar { display: flex; align-items: center; justify-content: center; gap: 4px; flex-wrap: wrap; }
152 .page-btn { display: inline-flex; align-items: center; justify-content: center; min-width: 32px; height: 32px; padding: 0 6px; border-radius: 6px; font-size: 0.85rem; font-weight: 600; color: var(--text-secondary); text-decoration: none; transition: all 0.15s; }
153 .page-btn:hover { background-color: var(--bg-tertiary); color: var(--text-primary); }
154 .page-btn.active { background-color: var(--accent-color); color: white; pointer-events: none; }
155 .page-ellipsis { color: var(--text-secondary); font-size: 0.85rem; padding: 0 4px; }
156 .bottom-pagination { display: none; padding: 15px; text-align: center; border-top: 1px solid var(--border-color); }
157
158 /* 트윗 카드 */
159 .tweet-card {
160 display: flex; gap: 12px; padding: 14px 16px;
161 border-bottom: 1px solid var(--border-color);
162 cursor: pointer; transition: background 0.15s;
163 text-decoration: none; color: inherit;
164 }
165 .tweet-card:hover { background: var(--bg-secondary); }
166 .tweet-card.active { background: var(--bg-tertiary); border-left: 3px solid var(--accent-color); }
167 .tweet-avatar-wrap { flex-shrink: 0; }
168 .tweet-avatar {
169 width: 40px; height: 40px; border-radius: 50%;
170 background: var(--accent-color); color: #fff;
171 display: flex; align-items: center; justify-content: center;
172 font-size: 1rem; font-weight: 700; overflow: hidden;
173 }
174 .tweet-avatar img { width: 100%; height: 100%; object-fit: cover; }
175 .tweet-body { flex: 1; min-width: 0; }
176 .tweet-header { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 3px; }
177 .tweet-username { font-weight: 700; font-size: 0.9rem; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 130px; }
178 .tweet-date { font-size: 0.78rem; color: var(--text-secondary); flex-shrink: 0; }
179 .tweet-content-preview {
180 font-size: 0.88rem; color: var(--text-primary); line-height: 1.45;
181 display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical;
182 overflow: hidden; margin-bottom: 6px;
183 }
184 .tweet-stats { display: flex; gap: 14px; font-size: 0.78rem; color: var(--text-secondary); }
185 .tweet-stat { display: flex; align-items: center; gap: 4px; }
186 .tweet-stat.liked { color: var(--danger-color); }
187
188 /* 중앙 본문 */
189 .content-column { flex: 1; display: flex; flex-direction: column; background-color: var(--bg-main); position: relative; overflow: hidden; }
190 .content-scroll-area { flex: 1; overflow-y: auto; padding: 30px; display: flex; flex-direction: column; gap: 30px; }
191 .content-card { background-color: var(--bg-secondary); border-radius: 12px; box-shadow: var(--shadow-sm); border: 1px solid var(--border-color); overflow: visible; margin-bottom: 20px; }
192
193 .write-header, .post-header { padding: 15px 20px; background-color: var(--bg-tertiary); border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
194 .write-header h2 { font-size: 1.1rem; font-weight: 700; margin: 0; display: flex; align-items: center; gap: 8px; }
195 .write-body { padding: 20px; }
196 .write-textarea { width: 100%; border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; background-color: var(--bg-main); color: var(--text-primary); resize: none; transition: border-color 0.2s; }
197 .write-textarea:focus { outline: none; border-color: var(--accent-color); }
198 .form-control { background-color: var(--bg-main); color: var(--text-primary); border-color: var(--border-color); }
199 .form-control::placeholder { color: var(--text-secondary); }
200 .write-footer { margin-top: 12px; display: flex; justify-content: space-between; align-items: center; color: var(--text-secondary); font-size: 0.875rem; }
201
202 .post-avatar { width: 48px; height: 48px; background-color: var(--bg-secondary); border: 2px solid var(--border-color); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; font-weight: bold; color: var(--text-secondary); }
203 .post-author { font-size: 1.1rem; font-weight: 700; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; }
204 .badge-admin { background-color: var(--accent-color); color: white; font-size: 0.7rem; padding: 2px 6px; border-radius: 4px; vertical-align: middle; }
205 .post-date { font-size: 0.85rem; color: var(--text-secondary); }
206
207 .post-content { padding: 30px; font-size: 1.1rem; line-height: 1.7; color: var(--text-primary); text-align: left; white-space: normal; word-break: break-all; overflow-wrap: break-word; }
208 .post-content-wrapper { position: relative; overflow: hidden; }
209 .post-content-extra { display: none; }
210 .post-content-wrapper.expanded .post-content-extra { display: inline; }
211
212 .btn-show-more { background: none; border: none; color: #2563eb; font-size: 0.85rem; font-weight: 600; padding: 4px 30px 8px; cursor: pointer; display: none; }
213 .btn-show-more:hover { text-decoration: underline; }
214
215 .post-actions { padding: 15px 20px; border-top: 1px solid var(--border-color); background-color: var(--bg-main); display: flex; justify-content: flex-end; gap: 10px; }
216 .action-btn { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border-radius: 6px; font-size: 0.9rem; font-weight: 500; color: var(--text-secondary); border: 1px solid var(--border-color); background: var(--bg-secondary); cursor: pointer; transition: all 0.2s; }
217 .action-btn:hover { background-color: var(--border-color); color: var(--text-primary); }
218 .action-btn.liked { color: var(--danger-color); border-color: var(--danger-color); background-color: rgb(239 68 68 / 0.1); }
219
220 .replies-container { padding: 20px; background-color: var(--bg-main); }
221 .replies-header { font-size: 1rem; font-weight: 700; margin-bottom: 15px; display: flex; align-items: center; gap: 8px; }
222 .reply-item { margin-bottom: 12px; padding-left: 12px; border-left: 3px solid var(--border-color); }
223 .reply-meta { display: flex; justify-content: space-between; font-size: 0.85rem; margin-bottom: 4px; }
224 .reply-author { font-weight: 600; }
225 .reply-content { font-size: 0.95rem; line-height: 1.5; }
226
227 /* 우측 패널 */
228 .info-column { width: 260px; min-width: 260px; background-color: var(--bg-secondary); border-left: 1px solid var(--border-color); padding: 20px; display: flex; flex-direction: column; gap: 20px; overflow-y: auto; }
229 .info-card { background-color: var(--bg-main); border-radius: 12px; padding: 20px; border: 1px solid var(--border-color); box-shadow: var(--shadow-sm); }
230 .info-card-title { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-secondary); margin-bottom: 12px; letter-spacing: 0.05em; }
231 .user-profile { display: flex; align-items: center; gap: 12px; }
232 .user-avatar-lg { width: 40px; height: 40px; font-size: 1.5rem; color: var(--text-secondary); }
233 .user-info-text { flex: 1; }
234 .user-name-lg { font-weight: 700; font-size: 1.1rem; }
235
236 /* 스위치 스타일 */
237 .switch { position: relative; display: inline-block; width: 44px; height: 24px; }
238 .switch input { opacity: 0; width: 0; height: 0; }
239 .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--text-secondary); transition: .4s; border-radius: 24px; }
240 .slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
241 input:checked + .slider { background-color: var(--accent-color); }
242 input:checked + .slider:before { transform: translateX(20px); }
243
244 /* Footer 스타일 (배경 투명화) */
245 .info-column .footer, .info-column footer, footer.container-fluid {
246 background-color: transparent !important; background: none !important;
247 border: none !important; box-shadow: none !important;
248 color: var(--text-secondary) !important; margin-top: auto;
249 }
250 .info-column footer a { color: var(--text-secondary) !important; }
251 .info-column footer a:hover { color: var(--accent-color) !important; }
252
253 .archive-footer {
254 flex: 0 0 auto;
255 width: 100%;
256 margin-top: 0;
257 padding-top: 22px;
258 border-top: 1px solid var(--border-color) !important;
259 color: var(--text-secondary) !important;
260 display: flex;
261 flex-direction: column;
262 align-items: center;
263 justify-content: center;
264 gap: 12px;
265 font-size: 0.78rem;
266 text-align: center;
267 }
268 .archive-footer-brand { display: flex; align-items: center; justify-content: center; gap: 12px; min-width: 0; }
269 .archive-footer-logo { width: 44px; height: 44px; object-fit: contain; opacity: 0.76; mix-blend-mode: multiply; }
270 body.dark-mode .archive-footer-logo { mix-blend-mode: screen; opacity: 0.9; }
271 .archive-footer-title { color: var(--text-primary); font-weight: 800; font-size: 0.92rem; white-space: nowrap; }
272 .archive-footer-subtitle { margin-top: 2px; color: var(--text-secondary); white-space: nowrap; }
273 .archive-footer-links { display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap; }
274 .archive-footer-links a { color: var(--text-secondary) !important; font-weight: 700; }
275 .archive-footer-links a:hover { color: var(--accent-color) !important; }
276 .archive-footer-copy {
277 margin-top: 5px;
278 text-align: center;
279 font-size: 0.72rem;
280 line-height: 1.55;
281 display: flex;
282 flex-direction: column;
283 align-items: center;
284 gap: 5px;
285 }
286 .archive-footer-version {
287 display: inline-flex;
288 align-items: center;
289 min-height: 20px;
290 padding: 0 8px;
291 margin-right: 0;
292 border: 1px solid var(--border-color);
293 border-radius: 999px;
294 color: var(--accent-color);
295 font-weight: 800;
296 background: var(--bg-secondary);
297 }
298
299 .d-none { display: none !important; }
300 .text-accent { color: var(--accent-color); }
301
302 /* Pagination */
303 .page-link { color: var(--text-secondary); background-color: var(--bg-main); border: 1px solid var(--border-color); border-radius: 4px; min-width: 28px; height: 28px; padding: 0 6px; display: flex; align-items: center; justify-content: center; font-size: 0.8rem; line-height: 1; }
304 .page-link:hover { color: var(--accent-color); background-color: var(--bg-secondary); border-color: var(--accent-color); z-index: 2; }
305 .page-item.active .page-link { background-color: var(--accent-color); border-color: var(--accent-color); color: white; }
306 .page-item.disabled .page-link { opacity: 0.4; pointer-events: none; background-color: transparent; border: none; }
307
308 /* [반응형] 960px 이하 */
309 @media (max-width: 960px) {
310 html, body { overflow: auto !important; height: auto !important; }
311 .layout-container { flex-direction: column; height: auto !important; }
312
313 .global-header { flex-wrap: wrap; height: auto !important; min-height: 60px; padding: 10px 15px; }
314
315 /* 모바일에서 네비게이션 절대 위치 해제 */
316 .header-nav { position: static !important; transform: none !important; height: auto !important; pointer-events: auto !important; width: 100%; order: 3; justify-content: center; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(0,0,0,0.05); }
317
318 .header-brand { order: 1; }
319 .header-controls { order: 2; margin-left: auto; }
320
321 .shelf-column { width: 100%; height: 280px; min-height: 280px; border-right: none; border-bottom: 1px solid var(--border-color); order: 1; flex: none; }
322 .shelf-body { overflow-y: auto; }
323 .tweet-card { padding: 12px 14px; }
324 .shelf-footer { display: block !important; order: 99; width: 100%; background-color: var(--bg-main); }
325
326 .content-column { width: 100%; height: auto !important; flex: none; order: 2; border: none; overflow: visible; }
327 .content-scroll-area { overflow: visible !important; padding: 15px; height: auto !important; }
328 .bottom-pagination { display: block !important; }
329
330 .info-column { width: 100%; height: auto; border-left: none; border-top: 1px solid var(--border-color); order: 3; padding: 20px; flex-direction: row; flex-wrap: wrap; flex: none; }
331 .info-card { flex: 1; min-width: 200px; margin-bottom: 0; }
332 .info-column .footer { display: none; }
333 .info-column .sign-area {
334 flex: 0 0 100%; /* 가로 폭을 100%로 채워서 줄바꿈 강제 */
335 width: 100%;
336 display: block; /* 블록 요소로 확실하게 처리 */
337 text-align: center;
338 margin-top: 20px !important; /* 상단 여백 확보 */
339 order: 3; /* 순서 명시 (카드들 다음) */
340 }
341
342 /* [수정] 저작권 푸터는 그대로 유지하되 선택자 분리 */
343 .info-column footer.footer {
344 flex: 0 0 100%;
345 width: 100%;
346 text-align: center !important;
347 order: 4; /* 사인 이미지 다음 순서 */
348 }
349
350 .info-column .footer img { display: none; } /* 혹시 모를 잔재 숨김 처리 */
351
352 /* 1. 사인 이미지 영역 (순서 3번째) */
353 .info-column .sign-area {
354 flex: 0 0 100%;
355 width: 100%;
356 display: block;
357 text-align: center;
358 margin-top: 20px;
359 order: 3; /* 카드들 다음 */
360 }
361
362 /* [추가] 2. 저작권 푸터 영역 (순서 4번째 - 맨 마지막) */
363 .info-column footer.footer {
364 flex: 0 0 100%; /* 가로 꽉 차게 */
365 width: 100%;
366 text-align: center !important;
367 order: 4; /* 사인 이미지보다 더 아래 */
368 display: block !important; /* 혹시 사라졌다면 강제로 보이게 */
369 margin-bottom: 20px; /* 바닥 여백 조금 확보 */
370 }
371 }
372 </style>
373 </head>
374
375 <body class="<%= theme === 'dark' ? 'dark-mode' : '' %>">
376
377 <header class="global-header">
378 <div class="header-brand">
379 <a href="/hinana/index">
380 <img src="/image/archive.png" alt="비나래 아카이브" class="header-logo logo-day">
381 <img src="/image/archive1.png" alt="비나래 아카이브" class="header-logo logo-night">
382 </a>
383 </div>
384
385 <nav class="header-nav">
386 <a href="/hinana/index" class="nav-link active">Archive</a>
387 <a href="/hinana/info" class="nav-link">Info</a>
388 <a href="/hinana/blog" class="nav-link">Blog</a>
389 <a href="/hinana/lounge" class="nav-link">Lounge</a>
390
391 <span class="text-secondary opacity-25 mx-1">|</span>
392
393 <% if(username) { %>
394 <a href="/logout?redirect=/hinana/index" class="icon-btn text-danger" title="로그아웃">
395 Logout
396 </a>
397 <% } else { %>
398 <a href="/login?redirect=/hinana/index" class="nav-link fw-bold" style="color:var(--accent-color);">
399 Login
400 </a>
401 <% } %>
402 </nav>
403
404 <div class="header-controls">
405 <a href="/hinana/gallery#brand-assets" class="nav-link" style="font-size:0.9rem;">사이트 맵</a>
406 <form action="/toggle-theme" method="POST" style="margin:0;">
407 <input type="hidden" name="redirect" value="/hinana/index">
408 <button type="submit" class="icon-btn" title="테마 변경">
409 <i class="bi <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'bi-moon-stars-fill' : 'bi-sun-fill' %>"></i>
410 </button>
411 </form>
412 </div>
413 </header>
414
415 <div class="layout-container">
416
417 <div class="shelf-column">
418 <div class="shelf-body custom-scrollbar" style="padding:0;">
419 <% if(posts && posts.length > 0) { %>
420 <% posts.forEach(function(post) { %>
421 <%
422 var isActive = (typeof currentPost !== 'undefined' && currentPost && String(currentPost.id) === String(post.id));
423 var authorName = post.username.replace('(익명)', '').trim();
424 var isAnon = post.username.endsWith('(익명)');
425 var plainPreview = (post.content || '').replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();
426 var likeCount = post.likes ? post.likes.length : 0;
427 var replyCount = post.replies ? post.replies.length : 0;
428 var isLiked = post.likes && post.likes.includes(username);
429 %>
430 <a href="?selectedId=<%= post.id %>&page=<%= currentPage %>&sort=<%= sort || 'new' %>&keyword=<%= encodeURIComponent(keyword || '') %>"
431 class="tweet-card <%= isActive ? 'active' : '' %>">
432 <div class="tweet-avatar-wrap">
433 <div class="tweet-avatar">
434 <% if (!isAnon && typeof userProfileImages !== 'undefined' && userProfileImages[authorName]) { %>
435 <img src="<%= userProfileImages[authorName] %>" alt="">
436 <% } else { %>
437 <%= authorName.substring(0,1).toUpperCase() %>
438 <% } %>
439 </div>
440 </div>
441 <div class="tweet-body">
442 <div class="tweet-header">
443 <span class="tweet-username">
444 <%= authorName %>
445 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
446 <% } else if(!isAnon && typeof verifiedUsers !== 'undefined' && verifiedUsers.includes(authorName)) { %><i class="bi bi-patch-check-fill" style="color:#1d9bf0; font-size:0.8em;"></i>
447 <% } else if(isAnon) { %><i class="bi bi-incognito" style="font-size:0.8em; color:var(--text-secondary);"></i>
448 <% } %>
449 <% if(post.isPrivate) { %><i class="bi bi-lock-fill" style="font-size:0.75em; color:var(--text-secondary);"></i><% } %>
450 </span>
451 <span class="tweet-date">· <%= fmtDate(post.timestamp, true) %></span>
452 </div>
453 <div class="tweet-content-preview"><%= plainPreview.substring(0, 100) %><%= plainPreview.length > 100 ? '...' : '' %></div>
454 <div class="tweet-stats">
455 <span class="tweet-stat <%= isLiked ? 'liked' : '' %>">
456 <i class="bi <%= isLiked ? 'bi-heart-fill' : 'bi-heart' %>"></i> <%= likeCount %>
457 </span>
458 <span class="tweet-stat">
459 <i class="bi bi-chat"></i> <%= replyCount %>
460 </span>
461 </div>
462 </div>
463 </a>
464 <% }); %>
465 <% } else { %>
466 <div class="text-center py-5 small" style="color:var(--text-secondary);">게시글이 없습니다.</div>
467 <% } %>
468 </div>
469
470 <div class="shelf-footer">
471 <%- include('partials/pagination', { currentPage, totalPages, sort, keyword: keyword || '', basePath: basePath || '/hinana/index' }) %>
472 </div>
473 </div>
474
475 <div class="content-column">
476 <div class="content-scroll-area custom-scrollbar">
477
478 <div class="d-flex align-items-center gap-2 mb-3 flex-wrap">
479 <select class="form-select form-select-sm" style="width:auto; cursor:pointer; background-color:var(--bg-secondary); color:var(--text-primary); border-color:var(--border-color);" onchange="changeSortOrder(this.value)">
480 <option value="new" <%= (typeof sort !== 'undefined' && sort === 'new') ? 'selected' : '' %>>최신순</option>
481 <option value="popular" <%= (typeof sort !== 'undefined' && sort === 'popular') ? 'selected' : '' %>>인기순</option>
482 <option value="old" <%= (typeof sort !== 'undefined' && sort === 'old') ? 'selected' : '' %>>오래된순</option>
483 </select>
484 <form action="/hinana/search" method="GET" class="d-inline-flex gap-2 ms-auto">
485 <input type="text" name="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);">
486 <button class="btn btn-sm" style="background-color:var(--accent-color); color:white; border:none;">검색</button>
487 </form>
488 </div>
489
490 <% if(typeof trendingTags !== 'undefined' && trendingTags.length > 0) { %>
491 <div class="d-flex align-items-center gap-2 mb-3 flex-wrap">
492 <i class="bi bi-hash" style="color:var(--accent-color); font-size:0.85rem;"></i>
493 <% trendingTags.forEach(function(t) { %>
494 <a href="/hinana/search?keyword=<%= encodeURIComponent(t.tag) %>" class="badge rounded-pill" style="background-color:var(--bg-secondary); color:var(--text-primary); border:1px solid var(--border-color); font-weight:500; font-size:0.75rem; text-decoration:none; padding:5px 10px;">
495 <%= t.tag %> <span style="color:var(--text-secondary); font-size:0.65rem;"><%= t.count %></span>
496 </a>
497 <% }); %>
498 </div>
499 <% } %>
500
501 <% if(username) { %>
502 <div id="notif-banner" class="d-none mb-3 px-4 py-3 rounded-3 d-flex align-items-center justify-content-between gap-3"
503 style="background-color:var(--bg-secondary); border:1px solid var(--border-color); font-size:0.9rem;">
504 <span><i class="bi bi-bell-fill me-2" style="color:var(--accent-color);"></i>알림을 허용하면 새 답글 소식을 바로 받을 수 있어요.</span>
505 <div class="d-flex gap-2 flex-shrink-0">
506 <button class="btn btn-sm" style="background-color:var(--accent-color); color:white; border:none;" onclick="requestNotifPermission()">허용하기</button>
507 <button class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('notif-banner').remove()">닫기</button>
508 </div>
509 </div>
510 <% } %>
511
512 <% if (!keyword) { %>
513 <div class="content-card mb-4" id="write-section-card">
514 <div class="write-header">
515 <h2><i class="bi bi-pencil-square"></i> New Page</h2>
516 <div class="btn-group btn-group-sm">
517 <% if(username) { %>
518 <button type="button" class="btn btn-outline-secondary active" id="btn-mode-user" onclick="toggleWriteMode('user')">
519 <i class="bi bi-person-fill"></i> 회원
520 </button>
521 <% } %>
522 <% if(typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
523 <button type="button" class="btn btn-outline-secondary <%= !username ? 'active' : '' %>" id="btn-mode-anon" onclick="toggleWriteMode('anon')">
524 <i class="bi bi-incognito"></i> 익명
525 </button>
526 <% } %>
527 </div>
528 </div>
529
530 <div class="write-body">
531 <% if(username) { %>
532 <div id="form-mode-user">
533 <form action="/post" method="POST">
534 <textarea class="write-textarea" id="main-write-input" name="content" rows="3" maxlength="150"
535 placeholder="<%= username %>님, 오늘은 어떤 이야기가 있나요?" required
536 oninput="checkInputLength(this, 'main-user-count')"></textarea>
537 <div class="write-footer">
538 <div class="d-flex align-items-center">
539 <span id="main-user-count" class="text-muted small me-3">0/150</span>
540 <div class="form-check m-0">
541 <input type="checkbox" id="isPrivate" name="isPrivate" class="form-check-input">
542 <label for="isPrivate" class="form-check-label">나만 보기</label>
543 </div>
544 </div>
545 <button type="submit" class="btn btn-sm px-4 rounded-pill fw-bold" style="background:var(--accent-color); color:#fff; border:none;">
546 기록하기
547 </button>
548 </div>
549 </form>
550 </div>
551 <% } else if (typeof isAnonymousPostingEnabled !== 'undefined' && !isAnonymousPostingEnabled) { %>
552 <div class="text-center py-3">
553 <p class="text-secondary mb-2">글을 작성하려면 로그인이 필요합니다.</p>
554 <a href="/login" class="btn btn-primary btn-sm rounded-pill px-4">로그인</a>
555 </div>
556 <% } %>
557
558 <% if(typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
559 <div id="form-mode-anon" class="<%= username ? 'd-none' : '' %>">
560 <form action="/post" method="POST" onsubmit="return validateAnonForm()">
561 <input type="hidden" name="isAnonymous" value="true">
562 <div class="row g-2 mb-2">
563 <div class="col-6">
564 <input type="text" class="form-control form-control-sm" id="anon-username" name="anonymousUsername" placeholder="닉네임 (20자)" maxlength="20" required oninput="checkAnonFormReady()">
565 </div>
566 <div class="col-6">
567 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
568 </div>
569 </div>
570 <textarea class="write-textarea" id="anon-write-input" name="content" rows="3" maxlength="150"
571 placeholder="익명으로 기록됩니다." required
572 oninput="checkInputLength(this, 'main-anon-count'); checkAnonFormReady()"></textarea>
573 <div class="write-footer">
574 <span id="main-anon-count" class="text-muted small me-auto">0/150</span>
575 <button type="submit" id="anon-submit-btn" class="btn btn-sm px-4 rounded-pill" disabled
576 style="background-color: var(--border-color); color: var(--text-secondary); cursor: not-allowed; opacity: 0.6;">
577 <i class="bi bi-send me-1"></i> 익명 게시
578 </button>
579 </div>
580 </form>
581 </div>
582 <% } %>
583 </div>
584 </div>
585 <% } %>
586
587 <% if(posts && posts.length > 0) { %>
588 <% posts.forEach(function(post) { %>
589 <div class="content-card mb-4" id="post-card-<%= post.id %>">
590 <div class="post-header">
591 <div class="d-flex align-items-center gap-3">
592 <div class="post-avatar">
593 <% var authorName = post.username.replace('(익명)', '').trim(); %>
594 <% if (!post.username.endsWith('(익명)') && typeof userProfileImages !== 'undefined' && userProfileImages[authorName]) { %>
595 <img src="<%= userProfileImages[authorName] %>" style="width:100%; height:100%; border-radius:50%; object-fit:cover;">
596 <% } else { %>
597 <%= post.username.substring(0,1).toUpperCase() %>
598 <% } %>
599 </div>
600 <div>
601 <div class="post-author">
602 <%= post.username.replace('(익명)', '') %>
603 <% if(post.username.endsWith('(익명)')) { %><i class="bi bi-incognito ms-1 text-muted small"></i><% } %>
604 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
605 <% } else if(typeof verifiedUsers !== 'undefined' && verifiedUsers.includes(post.username.replace('(익명)',''))) { %><i class="bi bi-patch-check-fill verified-badge" title="인증됨"></i><% } %>
606 </div>
607 <div class="post-date">
608 <%= fmtDate(post.timestamp) %>
609 <% if(post.isPrivate) { %><i class="bi bi-lock-fill ms-1 text-warning"></i><% } %>
610 </div>
611 </div>
612 </div>
613 <div class="d-flex align-items-center">
614 <a href="/post/<%= post.id %>" class="btn btn-link text-secondary p-0 me-2" title="상세 페이지"><i class="bi bi-link-45deg" style="font-size: 1.3rem;"></i></a>
615 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
616 <form action="/delete" method="POST" data-confirm="삭제하시겠습니까?" class="d-inline">
617 <input type="hidden" name="id" value="<%= post.id %>">
618 <button class="btn btn-link text-secondary p-0"><i class="bi bi-trash"></i></button>
619 </form>
620 <% } else if (post.username.endsWith('(익명)') && typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
621 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
622 <% } %>
623 </div>
624 </div>
625
626 <div class="post-body-container">
627 <div class="post-content-wrapper" id="content-wrapper-<%= post.id %>">
628 <div class="post-content">
629 <% if(post.image) { %>
630 <div class="mb-3"><img src="<%= post.image %>" alt="첨부 이미지" class="img-fluid rounded" style="max-height: 500px; width: auto;"></div>
631 <% } %>
632 <%- post.content
633 .replace(/<script[^>]*>([\S\s]*?)<\/script>/gim, "")
634 .replace(/<a\s+href="([^"]*?)"\s+class="hashtag">([^<]*?)<\/a>/gim, '___HASHTAG___$1___$2___END___')
635 .replace(/<a\s+href="#"\s+class="external-link"\s+data-url="([^"]*?)">([^<]*?)<\/a>/gim, '___EXTLINK___$1___$2___END___')
636 .replace(/<\/p>\s*<p[^>]*>/gim, '\n')
637 .replace(/<br\s*\/?>/gim, '\n')
638 .replace(/<\/div>\s*<div[^>]*>/gim, '\n')
639 .replace(/<\/?\w(?:[^"'>]|"[^"]*"|'[^']*')*>/gim, "")
640 .replace(/___HASHTAG___(.*?)___(.*?)___END___/gim, '<a href="$1" class="hashtag">$2</a>')
641 .replace(/___EXTLINK___(.*?)___(.*?)___END___/gim, '<a href="#" class="external-link" data-url="$1">$2</a>')
642 .replace(/&nbsp;/g, ' ')
643 .replace(/\n/g, '<br>')
644 %>
645 </div>
646 </div>
647 <button class="btn-show-more" onclick="togglePostContent('<%= post.id %>', this)">더보기</button>
648 </div>
649
650 <div class="post-actions">
651 <form action="/like" method="POST" class="like-form">
652 <input type="hidden" name="postId" value="<%= post.id %>">
653 <% const isLiked = post.likes && post.likes.includes(username); %>
654 <button type="submit" class="action-btn <%= isLiked ? 'liked' : '' %>">
655 <i class="bi <%= isLiked ? 'bi-heart-fill' : 'bi-heart' %>"></i>
656 <span class="like-count">Likes <%= post.likes ? post.likes.length : 0 %></span>
657 </button>
658 </form>
659 <button class="action-btn" onclick="toggleReplyForm('<%= post.id %>')">
660 <i class="bi bi-chat-quote-fill text-accent"></i> <span>Reply</span>
661 </button>
662 </div>
663
664 <% if(username) { %>
665 <div id="reply-form-<%= post.id %>" class="d-none p-3 bg-tertiary border-top">
666 <form action="/reply" method="POST">
667 <input type="hidden" name="postId" value="<%= post.id %>">
668 <input type="hidden" name="redirectUrl" value="<%= typeof basePath !== 'undefined' ? basePath : '/hinana/index' %>?selectedId=<%= post.id %>&page=<%= currentPage %>&sort=<%= sort || 'new' %>&keyword=<%= encodeURIComponent(keyword || '') %>">
669 <div class="d-flex flex-column gap-2">
670 <textarea class="write-textarea" name="content" rows="2" maxlength="150" placeholder="답글을 입력하세요..." required style="resize:none;" oninput="checkInputLength(this, 'reply-count-<%= post.id %>')"></textarea>
671 <div class="d-flex justify-content-between align-items-center">
672 <span id="reply-count-<%= post.id %>" class="text-muted small">0/150</span>
673 <button class="btn btn-sm px-3 rounded-pill fw-bold" style="background:var(--accent-color); color:#fff; border:none;"><i class="bi bi-send"></i></button>
674 </div>
675 </div>
676 </form>
677 </div>
678 <% } else if(typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
679 <div id="reply-form-<%= post.id %>" class="d-none p-3 bg-tertiary border-top">
680 <form action="/reply" method="POST">
681 <input type="hidden" name="postId" value="<%= post.id %>">
682 <input type="hidden" name="isAnonymous" value="true">
683 <input type="hidden" name="redirectUrl" value="<%= typeof basePath !== 'undefined' ? basePath : '/hinana/index' %>?selectedId=<%= post.id %>&page=<%= currentPage %>&sort=<%= sort || 'new' %>&keyword=<%= encodeURIComponent(keyword || '') %>">
684 <div class="row g-2 mb-2">
685 <div class="col-6"><input type="text" class="form-control form-control-sm" name="anonymousUsername" placeholder="닉네임" required></div>
686 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
687 </div>
688 <div class="d-flex flex-column gap-2">
689 <textarea class="write-textarea" name="content" rows="2" maxlength="150" placeholder="익명 답글..." required style="resize:none;" oninput="checkInputLength(this, 'anon-reply-count-<%= post.id %>')"></textarea>
690 <div class="d-flex justify-content-between align-items-center">
691 <span id="anon-reply-count-<%= post.id %>" class="text-muted small">0/150</span>
692 <button class="btn btn-sm px-3 rounded-pill fw-bold" style="background:var(--accent-color); color:#fff; border:none;"><i class="bi bi-send"></i></button>
693 </div>
694 </div>
695 </form>
696 </div>
697 <% } %>
698
699 <% if(post.replies && post.replies.length > 0) { %>
700 <div class="replies-container">
701 <div class="replies-header">
702 <i class="bi bi-chat-dots"></i> Comments (<%= post.replies.length %>)
703 </div>
704
705 <% function renderReplies(replyList, depth) { %>
706 <% replyList.forEach(function(reply, index) { %>
707 <div class="reply-item <%= (depth === 0 && index >= 3) ? 'd-none hidden-reply-' + post.id : '' %>"
708 style="margin-left: <%= Math.min(depth, 3) * 20 %>px; border-left: <%= depth > 0 ? '2px' : '3px' %> solid var(--border-color);">
709
710 <div class="reply-meta">
711 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
712 <% var replyAuthorClean = reply.username.replace('(익명)', '').trim(); %>
713 <% if (!reply.username.endsWith('(익명)') && typeof userProfileImages !== 'undefined' && userProfileImages[replyAuthorClean]) { %>
714 <img src="<%= userProfileImages[replyAuthorClean] %>" style="width:16px; height:16px; border-radius:50%; object-fit:cover; flex-shrink:0;">
715 <% } %>
716 <%= replyAuthorClean %>
717 <% if(reply.username.endsWith('(익명)')) { %><i class="bi bi-incognito ms-1 text-muted"></i><% } %>
718 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
719 <% } else if(typeof verifiedUsers !== 'undefined' && verifiedUsers.includes(replyAuthorClean)) { %><i class="bi bi-patch-check-fill verified-badge" title="인증됨"></i><% } %>
720 </span>
721
722 <span class="text-muted" style="font-size:0.75rem;">
723 <%= fmtDate(reply.timestamp) %>
724
725 <button class="btn p-0 text-secondary border-0 bg-transparent ms-2" onclick="toggleReplyForm('<%= reply.id %>')">
726 <i class="bi bi-chat-quote-fill"></i>
727 </button>
728
729 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
730 <form action="/delete-reply" method="POST" class="d-inline ms-1" data-confirm="삭제하시겠습니까?">
731 <input type="hidden" name="postId" value="<%= post.id %>">
732 <input type="hidden" name="replyId" value="<%= reply.id %>">
733 <button class="btn p-0 text-danger border-0 bg-transparent"><i class="bi bi-x-lg"></i></button>
734 </form>
735 <% } else if(reply.username.endsWith('(익명)') && typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
736 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
737 <% } %>
738 </span>
739 </div>
740
741 <div class="reply-content">
742 <%- (reply.content || '')
743 .replace(/<script[^>]*>([\S\s]*?)<\/script>/gim, "")
744 .replace(/<a\s+href="([^"]*?)"\s+class="hashtag">([^<]*?)<\/a>/gim, '___HASHTAG___$1___$2___END___')
745 .replace(/<a\s+href="#"\s+class="external-link"\s+data-url="([^"]*?)">([^<]*?)<\/a>/gim, '___EXTLINK___$1___$2___END___')
746 .replace(/<\/?\w(?:[^"'>]|"[^"]*"|'[^']*')*>/gim, "")
747 .replace(/___HASHTAG___(.*?)___(.*?)___END___/gim, '<a href="$1" class="hashtag">$2</a>')
748 .replace(/___EXTLINK___(.*?)___(.*?)___END___/gim, '<a href="#" class="external-link" data-url="$1">$2</a>')
749 .replace(/&nbsp;/g, ' ')
750 .replace(/\n/g, '<br>')
751 %>
752 </div>
753
754 <div id="reply-form-<%= reply.id %>" class="d-none p-2 bg-tertiary rounded mb-2 mt-2">
755 <form action="/reply" method="POST">
756 <input type="hidden" name="postId" value="<%= post.id %>">
757 <input type="hidden" name="parentReplyId" value="<%= reply.id %>">
758 <input type="hidden" name="redirectUrl" value="<%= typeof basePath !== 'undefined' ? basePath : '/hinana/index' %>?selectedId=<%= post.id %>&page=<%= currentPage %>&sort=<%= sort || 'new' %>&keyword=<%= encodeURIComponent(keyword || '') %>">
759
760 <% if(!username && typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
761 <input type="hidden" name="isAnonymous" value="true">
762 <div class="row g-1 mb-1">
763 <div class="col-6">
764 <input type="text" class="form-control form-control-sm" name="anonymousUsername" placeholder="닉네임 (20자)" maxlength="20" required>
765 </div>
766 <div class="col-6">
767 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
768 </div>
769 </div>
770 <% } %>
771
772 <div class="d-flex flex-column gap-2">
773 <textarea class="write-textarea" name="content" rows="1" maxlength="150"
774 placeholder="답글..." required style="resize:none; font-size:0.9rem; padding:6px;"
775 oninput="checkInputLength(this, 'reply-counter-<%= reply.id %>')"></textarea>
776
777 <div class="d-flex justify-content-between align-items-center">
778 <span id="reply-counter-<%= reply.id %>" class="text-muted small ms-1" style="font-size:0.75rem;">0/150</span>
779 <button class="btn btn-sm btn-accent text-white" style="background:var(--accent-color); border:none;">등록</button>
780 </div>
781 </div>
782 </form>
783 </div>
784
785 <% if(reply.replies && reply.replies.length > 0) { %>
786 <%= renderReplies(reply.replies, depth + 1) %>
787 <% } %>
788 </div>
789 <% }); %>
790 <% } %>
791
792 <%= renderReplies(post.replies, 0) %>
793
794 <% if(post.replies.length > 3) { %>
795 <div class="text-center mt-2">
796 <button class="btn btn-sm btn-link text-decoration-none text-secondary"
797 onclick="showAllReplies('<%= post.id %>', this)">
798 <i class="bi bi-chevron-down"></i> 댓글 <%= post.replies.length - 3 %>개 더 보기
799 </button>
800 </div>
801 <% } %>
802 </div>
803 <% } %>
804 </div>
805 <% }); %>
806 <% } else { %>
807 <div class="h-100 d-flex align-items-center justify-content-center text-muted flex-column py-5">
808 <i class="bi bi-book fs-1 mb-3 opacity-50"></i>
809 <p>작성된 글이 없습니다.</p>
810 </div>
811 <% } %>
812
813 </div>
814 <div class="bottom-pagination">
815 <%- include('partials/pagination', { currentPage, totalPages, sort, keyword: keyword || '', basePath: basePath || '/hinana/index' }) %>
816 </div>
817 </div>
818
819 <div class="info-column">
820 <div class="info-card">
821 <div class="info-card-title">Current User</div>
822 <div class="user-profile mb-3">
823 <% if (typeof currentUserProfileImage !== 'undefined' && currentUserProfileImage) { %>
824 <img src="<%= currentUserProfileImage %>" style="width:40px; height:40px; border-radius:50%; object-fit:cover; border:2px solid var(--border-color);">
825 <% } else { %>
826 <i class="bi bi-person-circle user-avatar-lg"></i>
827 <% } %>
828 <div class="user-info-text">
829 <div class="user-name-lg">
830 <% if(username) { %>
831 <a href="/hinana/userInfo" class="text-decoration-none text-reset hover-underline"><%= username %></a>
832 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
833 <% } else if(typeof verifiedUsers !== 'undefined' && verifiedUsers.includes(username)) { %><i class="bi bi-patch-check-fill verified-badge" title="인증됨"></i><% } %>
834 <% } else { %>
835 Guest
836 <% } %>
837 </div>
838 <div class="mt-2">
839 <% if(username) { %>
840 <a href="/logout?redirect=/hinana/index" class="btn btn-outline-secondary btn-sm btn-logout w-100"><i class="bi bi-box-arrow-right"></i> Logout</a>
841 <% } else { %>
842 <a href="/login" class="btn btn-primary btn-sm btn-logout w-100">Login</a>
843 <% } %>
844 </div>
845 </div>
846 </div>
847 </div>
848
849 <div class="info-card">
850 <div class="info-card-title">Settings</div>
851 <div class="theme-toggle-wrapper">
852 <span class="d-flex align-items-center gap-2 small">
853 <i class="bi <%= theme==='dark'?'bi-moon-stars-fill':'bi-sun-fill' %>"></i>
854 <%= theme==='dark'?'Dark Mode':'Light Mode' %>
855 <form action="/toggle-theme" method="POST" id="theme-form">
856 <input type="hidden" name="redirect" value="/hinana/index">
857 <label class="switch" style="transform:scale(0.8);">
858 <input type="checkbox" <%= theme==='dark'?'checked':'' %> onchange="document.getElementById('theme-form').submit()">
859 <span class="slider"></span>
860 </label>
861 </form>
862 </span>
863 </div>
864 </div>
865
866 <footer class="archive-footer">
867 <div class="archive-footer-brand">
868 <img src="/image/sign.png" alt="비나래" class="archive-footer-logo">
869 <div>
870 <div class="archive-footer-title">비나래 아카이브</div>
871 <div class="archive-footer-subtitle">Archive My Memory</div>
872 </div>
873 </div>
874 <div>
875 <div class="archive-footer-links">
876 <a href="/hinana/index">Archive</a>
877 <a href="/hinana/info">Info</a>
878 <a href="/hinana/blog">Blog</a>
879 <a href="/hinana/lounge">Lounge</a>
880 <a href="https://x.com/NoctchillHinana" target="_blank" rel="noreferrer">X</a>
881 </div>
882 <div class="archive-footer-copy"><span class="archive-footer-version">Ver. 8.0.3.2-Utsumi Aoba</span>&copy; 2024~2026. 비나래 | hinana.moe</div>
883 </div>
884 </footer>
885 </div>
886 </div>
887
888 <script>
889 document.addEventListener('DOMContentLoaded', () => {
890 // [핵심] 스크롤이 발생하는 진짜 영역 찾기
891 const scrollArea = document.querySelector('.content-scroll-area');
892
893 // 1. 페이지 로드 직후: 기억해둔 위치로 '순간이동' (눈 깜짝할 새 복구)
894 const savedScroll = sessionStorage.getItem('hinanaScrollPos');
895 if (savedScroll && scrollArea) {
896 scrollArea.scrollTop = savedScroll;
897 }
898
899 // 2. 그 다음, 선택된 글이 있다면 그쪽으로 '부드럽게' 이동
900 const urlParams = new URLSearchParams(window.location.search);
901 const selectedId = urlParams.get('selectedId');
902 const hasKeyword = !!(urlParams.get('keyword') || '').trim();
903
904 // 검색 중(keyword 있음)일 때는 scrollIntoView 안 함 - 모바일에서 전체 결과 표시
905 if (selectedId && !hasKeyword) {
906 const targetCard = document.getElementById('post-card-' + selectedId);
907
908 if (targetCard) {
909 // 약간의 딜레이를 주어 스크롤 복구가 확실히 끝난 뒤 움직이게 함
910 setTimeout(() => {
911 targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
912
913 // 강조 효과 (테두리 깜빡임)
914 targetCard.style.transition = "all 0.5s ease";
915 const originalBorder = targetCard.style.border;
916 const originalShadow = targetCard.style.boxShadow;
917
918 targetCard.style.border = "2px solid var(--accent-color)";
919 targetCard.style.boxShadow = "0 0 15px rgba(200, 100, 0, 0.3)";
920
921 setTimeout(() => {
922 targetCard.style.border = originalBorder;
923 targetCard.style.boxShadow = originalShadow;
924 }, 2000);
925 }, 50); // 0.05초 뒤 실행
926 }
927 }
928
929 // 3. 페이지를 떠날 때(클릭/새로고침): 현재 스크롤 위치 저장
930 window.addEventListener('beforeunload', () => {
931 if (scrollArea) {
932 sessionStorage.setItem('hinanaScrollPos', scrollArea.scrollTop);
933 }
934 });
935
936 // --- [기타 기존 기능들 유지] ---
937
938 // 익명 작성 토글
939 const toggleBtn = document.getElementById('toggle-anonymous-btn');
940 const cancelBtn = document.getElementById('cancel-anonymous-btn');
941 const mainPostContainer = document.getElementById('main-post-container');
942 const anonPostContainer = document.getElementById('anonymous-post-container');
943
944 // 서명 이미지 더블 클릭 이벤트
945 const signImage = document.getElementById('fumika_sign');
946 if (signImage) {
947 signImage.addEventListener('dblclick', () => { window.location.href = '/hinana/ai'; });
948 let lastTap = 0;
949 signImage.addEventListener('touchend', (e) => {
950 const currentTime = new Date().getTime();
951 if (currentTime - lastTap < 300 && currentTime - lastTap > 0) {
952 e.preventDefault();
953 window.location.href = '/hinana/ai';
954 }
955 lastTap = currentTime;
956 });
957 }
958 });
959
960 // 좋아요 및 기타 UI 기능
961 $(document).ready(function() {
962 $(document).on('submit', '.like-form', function(e) {
963 e.preventDefault();
964 var form = $(this);
965 var btn = form.find('button');
966 var icon = btn.find('i');
967 var countSpan = btn.find('.like-count');
968
969 $.ajax({
970 type: "POST", url: form.attr('action') || "/like", data: form.serialize(),
971 success: function(res) {
972 if(res.isLiked) { btn.addClass('liked'); icon.removeClass('bi-heart').addClass('bi-heart-fill'); }
973 else { btn.removeClass('liked'); icon.removeClass('bi-heart-fill').addClass('bi-heart'); }
974 countSpan.text('Likes ' + res.likeCount);
975 },
976 error: function(xhr) {
977 if(xhr.status === 401) { showConfirm('로그인이 필요합니다.').then(function(ok){ if(ok) location.href = '/login'; }); }
978 else { showAlert("오류 발생"); }
979 }
980 });
981 });
982
983 // 내용 더보기/접기 처리 (초기화 - <br> 기반 줄 수 분리)
984 const MAX_LINES = 6;
985 const cards = document.querySelectorAll('.content-card');
986 cards.forEach(function(card) {
987 const content = card.querySelector('.post-content');
988 const btn = card.querySelector('.btn-show-more');
989 if (!content || !btn) return;
990
991 const html = content.innerHTML;
992 const parts = html.split(/<br\s*\/?>/i);
993 if (parts.length <= MAX_LINES) {
994 btn.remove();
995 return;
996 }
997
998 const visible = parts.slice(0, MAX_LINES).join('<br>');
999 const hidden = parts.slice(MAX_LINES).join('<br>');
1000 content.innerHTML = visible + '<span class="post-content-extra"><br>' + hidden + '</span>';
1001 btn.style.display = 'inline-block';
1002 });
1003 });
1004
1005 // 유틸리티 함수들
1006 function toggleReplyForm(postId) {
1007 const formDiv = document.getElementById('reply-form-' + postId);
1008 if (formDiv) {
1009 if (formDiv.classList.contains('d-none')) {
1010 formDiv.classList.remove('d-none');
1011 formDiv.querySelector('textarea')?.focus();
1012 } else {
1013 formDiv.classList.add('d-none');
1014 }
1015 } else {
1016 showConfirm('로그인이 필요합니다.').then(function(ok){ if(ok) location.href = '/login?redirect=/hinana/index'; });
1017 }
1018 }
1019
1020 function togglePostContent(postId, btn) {
1021 const wrapper = document.getElementById('content-wrapper-' + postId);
1022 if (wrapper.classList.contains('expanded')) {
1023 wrapper.classList.remove('expanded');
1024 btn.innerText = '더보기';
1025 } else {
1026 wrapper.classList.add('expanded');
1027 btn.innerText = '접기';
1028 }
1029 }
1030
1031 function showAllReplies(postId, btn) {
1032 $('.hidden-reply-' + postId).removeClass('d-none');
1033 $(btn).parent().remove();
1034 }
1035
1036 function checkAnonFormReady() {
1037 const username = document.getElementById('anon-username');
1038 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1039 const content = document.getElementById('anon-write-input');
1040 const btn = document.getElementById('anon-submit-btn');
1041 if (!btn) return;
1042
1043 const ready = username && username.value.trim().length > 0
1044 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1045 && content && content.value.trim().length > 0;
1046
1047 if (ready) {
1048 btn.disabled = false;
1049 btn.style.backgroundColor = document.body.classList.contains('dark-mode') ? '#b45309' : 'var(--accent-color)';
1050 btn.style.color = 'white';
1051 btn.style.cursor = 'pointer';
1052 btn.style.opacity = '1';
1053 } else {
1054 btn.disabled = true;
1055 btn.style.backgroundColor = 'var(--border-color)';
1056 btn.style.color = 'var(--text-secondary)';
1057 btn.style.cursor = 'not-allowed';
1058 btn.style.opacity = '0.6';
1059 }
1060 }
1061
1062 function validateAnonForm() {
1063 const username = document.getElementById('anon-username');
1064 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1065 const content = document.getElementById('anon-write-input');
1066 return username && username.value.trim().length > 0
1067 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1068 && content && content.value.trim().length > 0;
1069 }
1070
1071 function checkInputLength(input, counterId) {
1072 const maxLength = 150;
1073 const counter = document.getElementById(counterId);
1074 if (counter) {
1075 counter.innerText = `${input.value.length}/${maxLength}`;
1076 counter.className = 'small ms-2 ' + (input.value.length >= maxLength ? 'text-danger fw-bold' : 'text-muted');
1077 }
1078 }
1079
1080 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1081 const pw = await showPrompt("비밀번호:"); if(!pw) return;
1082 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1083 method:'POST', headers:{'Content-Type':'application/json'},
1084 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1085 }).then(r=>r.json()).then(d=>{ showAlert(d.message).then(()=>{ if(d.success) location.reload(); }); });
1086 }
1087
1088 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1089 const pw = await showPrompt("비밀번호:"); if(!pw) return;
1090 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1091 method:'POST', headers:{'Content-Type':'application/json'},
1092 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1093 }).then(r=>r.json()).then(d=>{ showAlert(d.message).then(()=>{ if(d.success) location.reload(); }); });
1094 }
1095
1096 function toggleWriteMode(mode) {
1097 const userForm = document.getElementById('form-mode-user');
1098 const anonForm = document.getElementById('form-mode-anon');
1099 const btnUser = document.getElementById('btn-mode-user');
1100 const btnAnon = document.getElementById('btn-mode-anon');
1101 if (mode === 'user') {
1102 if(userForm) userForm.classList.remove('d-none');
1103 if(anonForm) anonForm.classList.add('d-none');
1104 if(btnUser) btnUser.classList.add('active');
1105 if(btnAnon) btnAnon.classList.remove('active');
1106 } else {
1107 if(userForm) userForm.classList.add('d-none');
1108 if(anonForm) anonForm.classList.remove('d-none');
1109 if(btnUser) btnUser.classList.remove('active');
1110 if(btnAnon) btnAnon.classList.add('active');
1111 }
1112 }
1113
1114 function changeSortOrder(sortValue) {
1115 const url = new URL(window.location.href);
1116 url.searchParams.set('sort', sortValue);
1117 url.searchParams.set('page', '1');
1118 window.location.href = url.toString();
1119 }
1120 // 외부 링크 경고 (미리보기 카드 클릭 포함)
1121 document.addEventListener('click', function(e) {
1122 const card = e.target.closest('.link-preview-card');
1123 const link = card ? null : e.target.closest('a.external-link');
1124 const target = card || link;
1125 if (!target) return;
1126 e.preventDefault();
1127 const url = target.getAttribute('data-url');
1128 showConfirm('안전하지 않을 수 있는 외부 링크입니다.\n이동하시겠습니까?\n\n' + url).then(function(ok) {
1129 if (ok) window.open(url, '_blank', 'noopener,noreferrer');
1130 });
1131 });
1132
1133 // 링크 미리보기 카드 생성
1134 (function() {
1135 const links = document.querySelectorAll('.post-content a.external-link, .reply-content a.external-link');
1136 const seen = new Set();
1137 links.forEach(function(link) {
1138 const url = link.getAttribute('data-url');
1139 if (!url || seen.has(url)) return;
1140 seen.add(url);
1141
1142 fetch('/api/link-preview?url=' + encodeURIComponent(url))
1143 .then(r => r.json())
1144 .then(data => {
1145 if (data.error || (!data.title && !data.description)) return;
1146
1147 const card = document.createElement('div');
1148 card.className = 'link-preview-card';
1149 card.setAttribute('data-url', url);
1150
1151 const bar = document.createElement('div');
1152 bar.className = 'link-preview-bar';
1153 if (data.color) bar.style.backgroundColor = data.color;
1154
1155 const body = document.createElement('div');
1156 body.className = 'link-preview-body';
1157
1158 if (data.domain) {
1159 const domain = document.createElement('div');
1160 domain.className = 'link-preview-domain';
1161 domain.textContent = data.domain;
1162 body.appendChild(domain);
1163 }
1164 if (data.title) {
1165 const title = document.createElement('div');
1166 title.className = 'link-preview-title';
1167 title.textContent = data.title;
1168 body.appendChild(title);
1169 }
1170 if (data.description) {
1171 const desc = document.createElement('div');
1172 desc.className = 'link-preview-desc';
1173 desc.textContent = data.description;
1174 body.appendChild(desc);
1175 }
1176 if (data.image) {
1177 const img = document.createElement('img');
1178 img.className = 'link-preview-img';
1179 img.src = data.image;
1180 img.alt = '';
1181 img.onerror = function() { this.remove(); };
1182 body.appendChild(img);
1183 }
1184
1185 card.appendChild(bar);
1186 card.appendChild(body);
1187
1188 // 링크 바로 뒤에 삽입
1189 if (link.nextSibling) {
1190 link.parentNode.insertBefore(card, link.nextSibling);
1191 } else {
1192 link.parentNode.appendChild(card);
1193 }
1194 })
1195 .catch(function() {});
1196 });
1197 })();
1198 </script>
1199
1200 <script>
1201 var _PUSH_KEY = '<%= typeof vapidPublicKey !== "undefined" ? vapidPublicKey : "" %>';
1202 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1203 </script>
1204 <script>
1205 let _swReg = null;
1206
1207 // Android WebView 브릿지: FCM 토큰 수신 후 서버에 저장
1208 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1209 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1210 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1211 method: 'POST',
1212 headers: { 'Content-Type': 'application/json' },
1213 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1214 }).catch(() => {});
1215 };
1216
1217 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1218 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1219 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1220 }
1221
1222 function urlBase64ToUint8Array(b64) {
1223 const pad = '='.repeat((4 - b64.length % 4) % 4);
1224 const base64 = (b64 + pad).replace(/-/g, '+').replace(/_/g, '/');
1225 const raw = atob(base64);
1226 return Uint8Array.from(raw, c => c.charCodeAt(0));
1227 }
1228
1229 async function subscribePush(reg) {
1230 const existing = await reg.pushManager.getSubscription();
1231 if (!existing) {
1232 try {
1233 const sub = await reg.pushManager.subscribe({
1234 userVisibleOnly: true,
1235 applicationServerKey: urlBase64ToUint8Array(_PUSH_KEY)
1236 });
1237 await fetch('/api/push/subscribe', {
1238 method: 'POST',
1239 headers: { 'Content-Type': 'application/json' },
1240 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1241 });
1242 } catch (e) { console.warn('Push 구독 실패:', e); }
1243 } else {
1244 fetch('/api/push/subscribe', {
1245 method: 'POST',
1246 headers: { 'Content-Type': 'application/json' },
1247 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1248 }).catch(() => {});
1249 }
1250 }
1251
1252 // 버튼 클릭 시 권한 요청 (사용자 제스처 필요)
1253 async function requestNotifPermission() {
1254 const banner = document.getElementById('notif-banner');
1255 const perm = await Notification.requestPermission();
1256 if (perm === 'granted') {
1257 if (banner) banner.remove();
1258 if (_swReg) subscribePush(_swReg);
1259 } else {
1260 if (banner) banner.remove();
1261 }
1262 }
1263
1264 (async function() {
1265 if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
1266 const reg = await navigator.serviceWorker.register('/sw.js');
1267 await navigator.serviceWorker.ready;
1268 _swReg = reg;
1269 if (!_PUSH_USER || !_PUSH_KEY) return;
1270
1271 if (Notification.permission === 'granted') {
1272 // 이미 허용된 경우 바로 구독
1273 subscribePush(reg);
1274 } else if (Notification.permission === 'default') {
1275 // 아직 결정 안 된 경우 배너 표시
1276 const banner = document.getElementById('notif-banner');
1277 if (banner) banner.classList.remove('d-none');
1278 }
1279 // 'denied'인 경우 아무것도 하지 않음
1280 })();
1281 </script>
1282 </body>
1283 </html>
1284