Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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
.d-none { display: none !important; }
254
.text-accent { color: var(--accent-color); }
255
256
/* Pagination */
257
.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; }
258
.page-link:hover { color: var(--accent-color); background-color: var(--bg-secondary); border-color: var(--accent-color); z-index: 2; }
259
.page-item.active .page-link { background-color: var(--accent-color); border-color: var(--accent-color); color: white; }
260
.page-item.disabled .page-link { opacity: 0.4; pointer-events: none; background-color: transparent; border: none; }
261
262
/* [반응형] 960px 이하 */
263
@media (max-width: 960px) {
264
html, body { overflow: auto !important; height: auto !important; }
265
.layout-container { flex-direction: column; height: auto !important; }
266
267
.global-header { flex-wrap: wrap; height: auto !important; min-height: 60px; padding: 10px 15px; }
268
269
/* 모바일에서 네비게이션 절대 위치 해제 */
270
.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); }
271
272
.header-brand { order: 1; }
273
.header-controls { order: 2; margin-left: auto; }
274
275
.shelf-column { width: 100%; height: 280px; min-height: 280px; border-right: none; border-bottom: 1px solid var(--border-color); order: 1; flex: none; }
276
.shelf-body { overflow-y: auto; }
277
.tweet-card { padding: 12px 14px; }
278
.shelf-footer { display: block !important; order: 99; width: 100%; background-color: var(--bg-main); }
279
280
.content-column { width: 100%; height: auto !important; flex: none; order: 2; border: none; overflow: visible; }
281
.content-scroll-area { overflow: visible !important; padding: 15px; height: auto !important; }
282
.bottom-pagination { display: block !important; }
283
284
.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; }
285
.info-card { flex: 1; min-width: 200px; margin-bottom: 0; }
286
.info-column .footer { display: none; }
287
.info-column .sign-area {
288
flex: 0 0 100%; /* 가로 폭을 100%로 채워서 줄바꿈 강제 */
289
width: 100%;
290
display: block; /* 블록 요소로 확실하게 처리 */
291
text-align: center;
292
margin-top: 20px !important; /* 상단 여백 확보 */
293
order: 3; /* 순서 명시 (카드들 다음) */
294
}
295
296
/* [수정] 저작권 푸터는 그대로 유지하되 선택자 분리 */
297
.info-column footer.footer {
298
flex: 0 0 100%;
299
width: 100%;
300
text-align: center !important;
301
order: 4; /* 사인 이미지 다음 순서 */
302
}
303
304
.info-column .footer img { display: none; } /* 혹시 모를 잔재 숨김 처리 */
305
306
/* 1. 사인 이미지 영역 (순서 3번째) */
307
.info-column .sign-area {
308
flex: 0 0 100%;
309
width: 100%;
310
display: block;
311
text-align: center;
312
margin-top: 20px;
313
order: 3; /* 카드들 다음 */
314
}
315
316
/* [추가] 2. 저작권 푸터 영역 (순서 4번째 - 맨 마지막) */
317
.info-column footer.footer {
318
flex: 0 0 100%; /* 가로 꽉 차게 */
319
width: 100%;
320
text-align: center !important;
321
order: 4; /* 사인 이미지보다 더 아래 */
322
display: block !important; /* 혹시 사라졌다면 강제로 보이게 */
323
margin-bottom: 20px; /* 바닥 여백 조금 확보 */
324
}
325
}
326
</style>
327
</head>
328
329
<body class="<%= theme === 'dark' ? 'dark-mode' : '' %>">
330
331
<header class="global-header">
332
<div class="header-brand">
333
<a href="/hinana/index">
334
<img src="/image/archive.png" alt="비나래 아카이브" class="header-logo logo-day">
335
<img src="/image/archive1.png" alt="비나래 아카이브" class="header-logo logo-night">
336
</a>
337
</div>
338
339
<nav class="header-nav">
340
<a href="/hinana/index" class="nav-link active">Archive</a>
341
<a href="/hinana/info" class="nav-link">Info</a>
342
<a href="/hinana/blog" class="nav-link">Blog</a>
343
<a href="/hinana/lounge" class="nav-link">Lounge</a>
344
345
<span class="text-secondary opacity-25 mx-1">|</span>
346
347
<% if(username) { %>
348
<a href="/logout?redirect=/hinana/index" class="icon-btn text-danger" title="로그아웃">
349
Logout
350
</a>
351
<% } else { %>
352
<a href="/login?redirect=/hinana/index" class="nav-link fw-bold" style="color:var(--accent-color);">
353
Login
354
</a>
355
<% } %>
356
</nav>
357
358
<div class="header-controls">
359
<a href="/hinana/gallery#brand-assets" class="nav-link" style="font-size:0.9rem;">사이트 맵</a>
360
<form action="/toggle-theme" method="POST" style="margin:0;">
361
<input type="hidden" name="redirect" value="/hinana/index">
362
<button type="submit" class="icon-btn" title="테마 변경">
363
<i class="bi <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'bi-moon-stars-fill' : 'bi-sun-fill' %>"></i>
364
</button>
365
</form>
366
</div>
367
</header>
368
369
<div class="layout-container">
370
371
<div class="shelf-column">
372
<div class="shelf-body custom-scrollbar" style="padding:0;">
373
<% if(posts && posts.length > 0) { %>
374
<% posts.forEach(function(post) { %>
375
<%
376
var isActive = (typeof currentPost !== 'undefined' && currentPost && String(currentPost.id) === String(post.id));
377
var authorName = post.username.replace('(익명)', '').trim();
378
var isAnon = post.username.endsWith('(익명)');
379
var plainPreview = (post.content || '').replace(/<[^>]*>/g, '').replace(/ /g, ' ').replace(/\s+/g, ' ').trim();
380
var likeCount = post.likes ? post.likes.length : 0;
381
var replyCount = post.replies ? post.replies.length : 0;
382
var isLiked = post.likes && post.likes.includes(username);
383
%>
384
<a href="?selectedId=<%= post.id %>&page=<%= currentPage %>&sort=<%= sort || 'new' %>&keyword=<%= encodeURIComponent(keyword || '') %>"
385
class="tweet-card <%= isActive ? 'active' : '' %>">
386
<div class="tweet-avatar-wrap">
387
<div class="tweet-avatar">
388
<% if (!isAnon && typeof userProfileImages !== 'undefined' && userProfileImages[authorName]) { %>
389
<img src="<%= userProfileImages[authorName] %>" alt="">
390
<% } else { %>
391
<%= authorName.substring(0,1).toUpperCase() %>
392
<% } %>
393
</div>
394
</div>
395
<div class="tweet-body">
396
<div class="tweet-header">
397
<span class="tweet-username">
398
<%= authorName %>
399
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
400
<% } else if(!isAnon && typeof verifiedUsers !== 'undefined' && verifiedUsers.includes(authorName)) { %><i class="bi bi-patch-check-fill" style="color:#1d9bf0; font-size:0.8em;"></i>
401
<% } else if(isAnon) { %><i class="bi bi-incognito" style="font-size:0.8em; color:var(--text-secondary);"></i>
402
<% } %>
403
<% if(post.isPrivate) { %><i class="bi bi-lock-fill" style="font-size:0.75em; color:var(--text-secondary);"></i><% } %>
404
</span>
405
<span class="tweet-date">· <%= fmtDate(post.timestamp, true) %></span>
406
</div>
407
<div class="tweet-content-preview"><%= plainPreview.substring(0, 100) %><%= plainPreview.length > 100 ? '...' : '' %></div>
408
<div class="tweet-stats">
409
<span class="tweet-stat <%= isLiked ? 'liked' : '' %>">
410
<i class="bi <%= isLiked ? 'bi-heart-fill' : 'bi-heart' %>"></i> <%= likeCount %>
411
</span>
412
<span class="tweet-stat">
413
<i class="bi bi-chat"></i> <%= replyCount %>
414
</span>
415
</div>
416
</div>
417
</a>
418
<% }); %>
419
<% } else { %>
420
<div class="text-center py-5 small" style="color:var(--text-secondary);">게시글이 없습니다.</div>
421
<% } %>
422
</div>
423
424
<div class="shelf-footer">
425
<%- include('partials/pagination', { currentPage, totalPages, sort, keyword: keyword || '', basePath: basePath || '/hinana/index' }) %>
426
</div>
427
</div>
428
429
<div class="content-column">
430
<div class="content-scroll-area custom-scrollbar">
431
432
<div class="d-flex align-items-center gap-2 mb-3 flex-wrap">
433
<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)">
434
<option value="new" <%= (typeof sort !== 'undefined' && sort === 'new') ? 'selected' : '' %>>최신순</option>
435
<option value="popular" <%= (typeof sort !== 'undefined' && sort === 'popular') ? 'selected' : '' %>>인기순</option>
436
<option value="old" <%= (typeof sort !== 'undefined' && sort === 'old') ? 'selected' : '' %>>오래된순</option>
437
</select>
438
<form action="/hinana/search" method="GET" class="d-inline-flex gap-2 ms-auto">
439
<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);">
440
<button class="btn btn-sm" style="background-color:var(--accent-color); color:white; border:none;">검색</button>
441
</form>
442
</div>
443
444
<% if(typeof trendingTags !== 'undefined' && trendingTags.length > 0) { %>
445
<div class="d-flex align-items-center gap-2 mb-3 flex-wrap">
446
<i class="bi bi-hash" style="color:var(--accent-color); font-size:0.85rem;"></i>
447
<% trendingTags.forEach(function(t) { %>
448
<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;">
449
<%= t.tag %> <span style="color:var(--text-secondary); font-size:0.65rem;"><%= t.count %></span>
450
</a>
451
<% }); %>
452
</div>
453
<% } %>
454
455
<% if(username) { %>
456
<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"
457
style="background-color:var(--bg-secondary); border:1px solid var(--border-color); font-size:0.9rem;">
458
<span><i class="bi bi-bell-fill me-2" style="color:var(--accent-color);"></i>알림을 허용하면 새 답글 소식을 바로 받을 수 있어요.</span>
459
<div class="d-flex gap-2 flex-shrink-0">
460
<button class="btn btn-sm" style="background-color:var(--accent-color); color:white; border:none;" onclick="requestNotifPermission()">허용하기</button>
461
<button class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('notif-banner').remove()">닫기</button>
462
</div>
463
</div>
464
<% } %>
465
466
<% if (!keyword) { %>
467
<div class="content-card mb-4" id="write-section-card">
468
<div class="write-header">
469
<h2><i class="bi bi-pencil-square"></i> New Page</h2>
470
<div class="btn-group btn-group-sm">
471
<% if(username) { %>
472
<button type="button" class="btn btn-outline-secondary active" id="btn-mode-user" onclick="toggleWriteMode('user')">
473
<i class="bi bi-person-fill"></i> 회원
474
</button>
475
<% } %>
476
<% if(typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
477
<button type="button" class="btn btn-outline-secondary <%= !username ? 'active' : '' %>" id="btn-mode-anon" onclick="toggleWriteMode('anon')">
478
<i class="bi bi-incognito"></i> 익명
479
</button>
480
<% } %>
481
</div>
482
</div>
483
484
<div class="write-body">
485
<% if(username) { %>
486
<div id="form-mode-user">
487
<form action="/post" method="POST">
488
<textarea class="write-textarea" id="main-write-input" name="content" rows="3" maxlength="150"
489
placeholder="<%= username %>님, 오늘은 어떤 이야기가 있나요?" required
490
oninput="checkInputLength(this, 'main-user-count')"></textarea>
491
<div class="write-footer">
492
<div class="d-flex align-items-center">
493
<span id="main-user-count" class="text-muted small me-3">0/150</span>
494
<div class="form-check m-0">
495
<input type="checkbox" id="isPrivate" name="isPrivate" class="form-check-input">
496
<label for="isPrivate" class="form-check-label">나만 보기</label>
497
</div>
498
</div>
499
<button type="submit" class="btn btn-sm px-4 rounded-pill fw-bold" style="background:var(--accent-color); color:#fff; border:none;">
500
기록하기
501
</button>
502
</div>
503
</form>
504
</div>
505
<% } else if (typeof isAnonymousPostingEnabled !== 'undefined' && !isAnonymousPostingEnabled) { %>
506
<div class="text-center py-3">
507
<p class="text-secondary mb-2">글을 작성하려면 로그인이 필요합니다.</p>
508
<a href="/login" class="btn btn-primary btn-sm rounded-pill px-4">로그인</a>
509
</div>
510
<% } %>
511
512
<% if(typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
513
<div id="form-mode-anon" class="<%= username ? 'd-none' : '' %>">
514
<form action="/post" method="POST" onsubmit="return validateAnonForm()">
515
<input type="hidden" name="isAnonymous" value="true">
516
<div class="row g-2 mb-2">
517
<div class="col-6">
518
<input type="text" class="form-control form-control-sm" id="anon-username" name="anonymousUsername" placeholder="닉네임 (20자)" maxlength="20" required oninput="checkAnonFormReady()">
519
</div>
520
<div class="col-6">
521
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
522
</div>
523
</div>
524
<textarea class="write-textarea" id="anon-write-input" name="content" rows="3" maxlength="150"
525
placeholder="익명으로 기록됩니다." required
526
oninput="checkInputLength(this, 'main-anon-count'); checkAnonFormReady()"></textarea>
527
<div class="write-footer">
528
<span id="main-anon-count" class="text-muted small me-auto">0/150</span>
529
<button type="submit" id="anon-submit-btn" class="btn btn-sm px-4 rounded-pill" disabled
530
style="background-color: var(--border-color); color: var(--text-secondary); cursor: not-allowed; opacity: 0.6;">
531
<i class="bi bi-send me-1"></i> 익명 게시
532
</button>
533
</div>
534
</form>
535
</div>
536
<% } %>
537
</div>
538
</div>
539
<% } %>
540
541
<% if(posts && posts.length > 0) { %>
542
<% posts.forEach(function(post) { %>
543
<div class="content-card mb-4" id="post-card-<%= post.id %>">
544
<div class="post-header">
545
<div class="d-flex align-items-center gap-3">
546
<div class="post-avatar">
547
<% var authorName = post.username.replace('(익명)', '').trim(); %>
548
<% if (!post.username.endsWith('(익명)') && typeof userProfileImages !== 'undefined' && userProfileImages[authorName]) { %>
549
<img src="<%= userProfileImages[authorName] %>" style="width:100%; height:100%; border-radius:50%; object-fit:cover;">
550
<% } else { %>
551
<%= post.username.substring(0,1).toUpperCase() %>
552
<% } %>
553
</div>
554
<div>
555
<div class="post-author">
556
<%= post.username.replace('(익명)', '') %>
557
<% if(post.username.endsWith('(익명)')) { %><i class="bi bi-incognito ms-1 text-muted small"></i><% } %>
558
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
559
<% } else if(typeof verifiedUsers !== 'undefined' && verifiedUsers.includes(post.username.replace('(익명)',''))) { %><i class="bi bi-patch-check-fill verified-badge" title="인증됨"></i><% } %>
560
</div>
561
<div class="post-date">
562
<%= fmtDate(post.timestamp) %>
563
<% if(post.isPrivate) { %><i class="bi bi-lock-fill ms-1 text-warning"></i><% } %>
564
</div>
565
</div>
566
</div>
567
<div class="d-flex align-items-center">
568
<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>
569
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
570
<form action="/delete" method="POST" data-confirm="삭제하시겠습니까?" class="d-inline">
571
<input type="hidden" name="id" value="<%= post.id %>">
572
<button class="btn btn-link text-secondary p-0"><i class="bi bi-trash"></i></button>
573
</form>
574
<% } else if (post.username.endsWith('(익명)') && typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
575
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
576
<% } %>
577
</div>
578
</div>
579
580
<div class="post-body-container">
581
<div class="post-content-wrapper" id="content-wrapper-<%= post.id %>">
582
<div class="post-content">
583
<% if(post.image) { %>
584
<div class="mb-3"><img src="<%= post.image %>" alt="첨부 이미지" class="img-fluid rounded" style="max-height: 500px; width: auto;"></div>
585
<% } %>
586
<%- post.content
587
.replace(/<script[^>]*>([\S\s]*?)<\/script>/gim, "")
588
.replace(/<a\s+href="([^"]*?)"\s+class="hashtag">([^<]*?)<\/a>/gim, '___HASHTAG___$1___$2___END___')
589
.replace(/<a\s+href="#"\s+class="external-link"\s+data-url="([^"]*?)">([^<]*?)<\/a>/gim, '___EXTLINK___$1___$2___END___')
590
.replace(/<\/p>\s*<p[^>]*>/gim, '\n')
591
.replace(/<br\s*\/?>/gim, '\n')
592
.replace(/<\/div>\s*<div[^>]*>/gim, '\n')
593
.replace(/<\/?\w(?:[^"'>]|"[^"]*"|'[^']*')*>/gim, "")
594
.replace(/___HASHTAG___(.*?)___(.*?)___END___/gim, '<a href="$1" class="hashtag">$2</a>')
595
.replace(/___EXTLINK___(.*?)___(.*?)___END___/gim, '<a href="#" class="external-link" data-url="$1">$2</a>')
596
.replace(/ /g, ' ')
597
.replace(/\n/g, '<br>')
598
%>
599
</div>
600
</div>
601
<button class="btn-show-more" onclick="togglePostContent('<%= post.id %>', this)">더보기</button>
602
</div>
603
604
<div class="post-actions">
605
<form action="/like" method="POST" class="like-form">
606
<input type="hidden" name="postId" value="<%= post.id %>">
607
<% const isLiked = post.likes && post.likes.includes(username); %>
608
<button type="submit" class="action-btn <%= isLiked ? 'liked' : '' %>">
609
<i class="bi <%= isLiked ? 'bi-heart-fill' : 'bi-heart' %>"></i>
610
<span class="like-count">Likes <%= post.likes ? post.likes.length : 0 %></span>
611
</button>
612
</form>
613
<button class="action-btn" onclick="toggleReplyForm('<%= post.id %>')">
614
<i class="bi bi-chat-quote-fill text-accent"></i> <span>Reply</span>
615
</button>
616
</div>
617
618
<% if(username) { %>
619
<div id="reply-form-<%= post.id %>" class="d-none p-3 bg-tertiary border-top">
620
<form action="/reply" method="POST">
621
<input type="hidden" name="postId" value="<%= post.id %>">
622
<input type="hidden" name="redirectUrl" value="<%= typeof basePath !== 'undefined' ? basePath : '/hinana/index' %>?selectedId=<%= post.id %>&page=<%= currentPage %>&sort=<%= sort || 'new' %>&keyword=<%= encodeURIComponent(keyword || '') %>">
623
<div class="d-flex flex-column gap-2">
624
<textarea class="write-textarea" name="content" rows="2" maxlength="150" placeholder="답글을 입력하세요..." required style="resize:none;" oninput="checkInputLength(this, 'reply-count-<%= post.id %>')"></textarea>
625
<div class="d-flex justify-content-between align-items-center">
626
<span id="reply-count-<%= post.id %>" class="text-muted small">0/150</span>
627
<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>
628
</div>
629
</div>
630
</form>
631
</div>
632
<% } else if(typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
633
<div id="reply-form-<%= post.id %>" class="d-none p-3 bg-tertiary border-top">
634
<form action="/reply" method="POST">
635
<input type="hidden" name="postId" value="<%= post.id %>">
636
<input type="hidden" name="isAnonymous" value="true">
637
<input type="hidden" name="redirectUrl" value="<%= typeof basePath !== 'undefined' ? basePath : '/hinana/index' %>?selectedId=<%= post.id %>&page=<%= currentPage %>&sort=<%= sort || 'new' %>&keyword=<%= encodeURIComponent(keyword || '') %>">
638
<div class="row g-2 mb-2">
639
<div class="col-6"><input type="text" class="form-control form-control-sm" name="anonymousUsername" placeholder="닉네임" required></div>
640
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
641
</div>
642
<div class="d-flex flex-column gap-2">
643
<textarea class="write-textarea" name="content" rows="2" maxlength="150" placeholder="익명 답글..." required style="resize:none;" oninput="checkInputLength(this, 'anon-reply-count-<%= post.id %>')"></textarea>
644
<div class="d-flex justify-content-between align-items-center">
645
<span id="anon-reply-count-<%= post.id %>" class="text-muted small">0/150</span>
646
<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>
647
</div>
648
</div>
649
</form>
650
</div>
651
<% } %>
652
653
<% if(post.replies && post.replies.length > 0) { %>
654
<div class="replies-container">
655
<div class="replies-header">
656
<i class="bi bi-chat-dots"></i> Comments (<%= post.replies.length %>)
657
</div>
658
659
<% function renderReplies(replyList, depth) { %>
660
<% replyList.forEach(function(reply, index) { %>
661
<div class="reply-item <%= (depth === 0 && index >= 3) ? 'd-none hidden-reply-' + post.id : '' %>"
662
style="margin-left: <%= Math.min(depth, 3) * 20 %>px; border-left: <%= depth > 0 ? '2px' : '3px' %> solid var(--border-color);">
663
664
<div class="reply-meta">
665
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
666
<% var replyAuthorClean = reply.username.replace('(익명)', '').trim(); %>
667
<% if (!reply.username.endsWith('(익명)') && typeof userProfileImages !== 'undefined' && userProfileImages[replyAuthorClean]) { %>
668
<img src="<%= userProfileImages[replyAuthorClean] %>" style="width:16px; height:16px; border-radius:50%; object-fit:cover; flex-shrink:0;">
669
<% } %>
670
<%= replyAuthorClean %>
671
<% if(reply.username.endsWith('(익명)')) { %><i class="bi bi-incognito ms-1 text-muted"></i><% } %>
672
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
673
<% } else if(typeof verifiedUsers !== 'undefined' && verifiedUsers.includes(replyAuthorClean)) { %><i class="bi bi-patch-check-fill verified-badge" title="인증됨"></i><% } %>
674
</span>
675
676
<span class="text-muted" style="font-size:0.75rem;">
677
<%= fmtDate(reply.timestamp) %>
678
679
<button class="btn p-0 text-secondary border-0 bg-transparent ms-2" onclick="toggleReplyForm('<%= reply.id %>')">
680
<i class="bi bi-chat-quote-fill"></i>
681
</button>
682
683
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
684
<form action="/delete-reply" method="POST" class="d-inline ms-1" data-confirm="삭제하시겠습니까?">
685
<input type="hidden" name="postId" value="<%= post.id %>">
686
<input type="hidden" name="replyId" value="<%= reply.id %>">
687
<button class="btn p-0 text-danger border-0 bg-transparent"><i class="bi bi-x-lg"></i></button>
688
</form>
689
<% } else if(reply.username.endsWith('(익명)') && typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
690
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
691
<% } %>
692
</span>
693
</div>
694
695
<div class="reply-content">
696
<%- (reply.content || '')
697
.replace(/<script[^>]*>([\S\s]*?)<\/script>/gim, "")
698
.replace(/<a\s+href="([^"]*?)"\s+class="hashtag">([^<]*?)<\/a>/gim, '___HASHTAG___$1___$2___END___')
699
.replace(/<a\s+href="#"\s+class="external-link"\s+data-url="([^"]*?)">([^<]*?)<\/a>/gim, '___EXTLINK___$1___$2___END___')
700
.replace(/<\/?\w(?:[^"'>]|"[^"]*"|'[^']*')*>/gim, "")
701
.replace(/___HASHTAG___(.*?)___(.*?)___END___/gim, '<a href="$1" class="hashtag">$2</a>')
702
.replace(/___EXTLINK___(.*?)___(.*?)___END___/gim, '<a href="#" class="external-link" data-url="$1">$2</a>')
703
.replace(/ /g, ' ')
704
.replace(/\n/g, '<br>')
705
%>
706
</div>
707
708
<div id="reply-form-<%= reply.id %>" class="d-none p-2 bg-tertiary rounded mb-2 mt-2">
709
<form action="/reply" method="POST">
710
<input type="hidden" name="postId" value="<%= post.id %>">
711
<input type="hidden" name="parentReplyId" value="<%= reply.id %>">
712
<input type="hidden" name="redirectUrl" value="<%= typeof basePath !== 'undefined' ? basePath : '/hinana/index' %>?selectedId=<%= post.id %>&page=<%= currentPage %>&sort=<%= sort || 'new' %>&keyword=<%= encodeURIComponent(keyword || '') %>">
713
714
<% if(!username && typeof isAnonymousPostingEnabled !== 'undefined' && isAnonymousPostingEnabled) { %>
715
<input type="hidden" name="isAnonymous" value="true">
716
<div class="row g-1 mb-1">
717
<div class="col-6">
718
<input type="text" class="form-control form-control-sm" name="anonymousUsername" placeholder="닉네임 (20자)" maxlength="20" required>
719
</div>
720
<div class="col-6">
721
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
722
</div>
723
</div>
724
<% } %>
725
726
<div class="d-flex flex-column gap-2">
727
<textarea class="write-textarea" name="content" rows="1" maxlength="150"
728
placeholder="답글..." required style="resize:none; font-size:0.9rem; padding:6px;"
729
oninput="checkInputLength(this, 'reply-counter-<%= reply.id %>')"></textarea>
730
731
<div class="d-flex justify-content-between align-items-center">
732
<span id="reply-counter-<%= reply.id %>" class="text-muted small ms-1" style="font-size:0.75rem;">0/150</span>
733
<button class="btn btn-sm btn-accent text-white" style="background:var(--accent-color); border:none;">등록</button>
734
</div>
735
</div>
736
</form>
737
</div>
738
739
<% if(reply.replies && reply.replies.length > 0) { %>
740
<%= renderReplies(reply.replies, depth + 1) %>
741
<% } %>
742
</div>
743
<% }); %>
744
<% } %>
745
746
<%= renderReplies(post.replies, 0) %>
747
748
<% if(post.replies.length > 3) { %>
749
<div class="text-center mt-2">
750
<button class="btn btn-sm btn-link text-decoration-none text-secondary"
751
onclick="showAllReplies('<%= post.id %>', this)">
752
<i class="bi bi-chevron-down"></i> 댓글 <%= post.replies.length - 3 %>개 더 보기
753
</button>
754
</div>
755
<% } %>
756
</div>
757
<% } %>
758
</div>
759
<% }); %>
760
<% } else { %>
761
<div class="h-100 d-flex align-items-center justify-content-center text-muted flex-column py-5">
762
<i class="bi bi-book fs-1 mb-3 opacity-50"></i>
763
<p>작성된 글이 없습니다.</p>
764
</div>
765
<% } %>
766
767
</div>
768
<div class="bottom-pagination">
769
<%- include('partials/pagination', { currentPage, totalPages, sort, keyword: keyword || '', basePath: basePath || '/hinana/index' }) %>
770
</div>
771
</div>
772
773
<div class="info-column">
774
<div class="info-card">
775
<div class="info-card-title">Current User</div>
776
<div class="user-profile mb-3">
777
<% if (typeof currentUserProfileImage !== 'undefined' && currentUserProfileImage) { %>
778
<img src="<%= currentUserProfileImage %>" style="width:40px; height:40px; border-radius:50%; object-fit:cover; border:2px solid var(--border-color);">
779
<% } else { %>
780
<i class="bi bi-person-circle user-avatar-lg"></i>
781
<% } %>
782
<div class="user-info-text">
783
<div class="user-name-lg">
784
<% if(username) { %>
785
<a href="/hinana/userInfo" class="text-decoration-none text-reset hover-underline"><%= username %></a>
786
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
787
<% } else if(typeof verifiedUsers !== 'undefined' && verifiedUsers.includes(username)) { %><i class="bi bi-patch-check-fill verified-badge" title="인증됨"></i><% } %>
788
<% } else { %>
789
Guest
790
<% } %>
791
</div>
792
<div class="mt-2">
793
<% if(username) { %>
794
<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>
795
<% } else { %>
796
<a href="/login" class="btn btn-primary btn-sm btn-logout w-100">Login</a>
797
<% } %>
798
</div>
799
</div>
800
</div>
801
</div>
802
803
<div class="info-card">
804
<div class="info-card-title">Settings</div>
805
<div class="theme-toggle-wrapper">
806
<span class="d-flex align-items-center gap-2 small">
807
<i class="bi <%= theme==='dark'?'bi-moon-stars-fill':'bi-sun-fill' %>"></i>
808
<%= theme==='dark'?'Dark Mode':'Light Mode' %>
809
<form action="/toggle-theme" method="POST" id="theme-form">
810
<label class="switch" style="transform:scale(0.8);">
811
<input type="checkbox" <%= theme==='dark'?'checked':'' %> onchange="document.getElementById('theme-form').submit()">
812
<span class="slider"></span>
813
</label>
814
</form>
815
</span>
816
</div>
817
</div>
818
819
<div class="info-card mt-3">
820
<div class="info-card-title">System Info</div>
821
<ul class="small text-secondary list-unstyled mb-0">
822
<li class="mb-1 d-flex justify-content-between"><span>Version</span></li>
823
<li class="mb-1 d-flex justify-content-between"><span> Ver. 6.5.4.0-Kozeki Ui</li>
824
</ul>
825
</div>
826
827
<div class="sign-area" style="text-align:center; margin-top: 40px;">
828
<img src="/image/sign.png" id="fumika_sign" style="max-width:200px; width:80%; opacity:0.8;" />
829
</div>
830
<footer class="container-fluid text-center footer">
831
<a href="#myPage" title="To Top"><span class="glyphicon glyphicon-chevron-up"></span></a>
832
<p style="margin-bottom: 0rem;" class="copyright">X - @NoctchillHinana</p>
833
<p style="margin-bottom: 0rem;" class="copyright">ⓒ 2024~2026. 비나래 | hinana.moe All rights reserved.</p>
834
</footer>
835
</div>
836
</div>
837
838
<script>
839
document.addEventListener('DOMContentLoaded', () => {
840
// [핵심] 스크롤이 발생하는 진짜 영역 찾기
841
const scrollArea = document.querySelector('.content-scroll-area');
842
843
// 1. 페이지 로드 직후: 기억해둔 위치로 '순간이동' (눈 깜짝할 새 복구)
844
const savedScroll = sessionStorage.getItem('hinanaScrollPos');
845
if (savedScroll && scrollArea) {
846
scrollArea.scrollTop = savedScroll;
847
}
848
849
// 2. 그 다음, 선택된 글이 있다면 그쪽으로 '부드럽게' 이동
850
const urlParams = new URLSearchParams(window.location.search);
851
const selectedId = urlParams.get('selectedId');
852
const hasKeyword = !!(urlParams.get('keyword') || '').trim();
853
854
// 검색 중(keyword 있음)일 때는 scrollIntoView 안 함 - 모바일에서 전체 결과 표시
855
if (selectedId && !hasKeyword) {
856
const targetCard = document.getElementById('post-card-' + selectedId);
857
858
if (targetCard) {
859
// 약간의 딜레이를 주어 스크롤 복구가 확실히 끝난 뒤 움직이게 함
860
setTimeout(() => {
861
targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
862
863
// 강조 효과 (테두리 깜빡임)
864
targetCard.style.transition = "all 0.5s ease";
865
const originalBorder = targetCard.style.border;
866
const originalShadow = targetCard.style.boxShadow;
867
868
targetCard.style.border = "2px solid var(--accent-color)";
869
targetCard.style.boxShadow = "0 0 15px rgba(200, 100, 0, 0.3)";
870
871
setTimeout(() => {
872
targetCard.style.border = originalBorder;
873
targetCard.style.boxShadow = originalShadow;
874
}, 2000);
875
}, 50); // 0.05초 뒤 실행
876
}
877
}
878
879
// 3. 페이지를 떠날 때(클릭/새로고침): 현재 스크롤 위치 저장
880
window.addEventListener('beforeunload', () => {
881
if (scrollArea) {
882
sessionStorage.setItem('hinanaScrollPos', scrollArea.scrollTop);
883
}
884
});
885
886
// --- [기타 기존 기능들 유지] ---
887
888
// 익명 작성 토글
889
const toggleBtn = document.getElementById('toggle-anonymous-btn');
890
const cancelBtn = document.getElementById('cancel-anonymous-btn');
891
const mainPostContainer = document.getElementById('main-post-container');
892
const anonPostContainer = document.getElementById('anonymous-post-container');
893
894
// 서명 이미지 더블 클릭 이벤트
895
const signImage = document.getElementById('fumika_sign');
896
if (signImage) {
897
signImage.addEventListener('dblclick', () => { window.location.href = '/hinana/ai'; });
898
let lastTap = 0;
899
signImage.addEventListener('touchend', (e) => {
900
const currentTime = new Date().getTime();
901
if (currentTime - lastTap < 300 && currentTime - lastTap > 0) {
902
e.preventDefault();
903
window.location.href = '/hinana/ai';
904
}
905
lastTap = currentTime;
906
});
907
}
908
});
909
910
// 좋아요 및 기타 UI 기능
911
$(document).ready(function() {
912
$(document).on('submit', '.like-form', function(e) {
913
e.preventDefault();
914
var form = $(this);
915
var btn = form.find('button');
916
var icon = btn.find('i');
917
var countSpan = btn.find('.like-count');
918
919
$.ajax({
920
type: "POST", url: form.attr('action') || "/like", data: form.serialize(),
921
success: function(res) {
922
if(res.isLiked) { btn.addClass('liked'); icon.removeClass('bi-heart').addClass('bi-heart-fill'); }
923
else { btn.removeClass('liked'); icon.removeClass('bi-heart-fill').addClass('bi-heart'); }
924
countSpan.text('Likes ' + res.likeCount);
925
},
926
error: function(xhr) {
927
if(xhr.status === 401) { showConfirm('로그인이 필요합니다.').then(function(ok){ if(ok) location.href = '/login'; }); }
928
else { showAlert("오류 발생"); }
929
}
930
});
931
});
932
933
// 내용 더보기/접기 처리 (초기화 - <br> 기반 줄 수 분리)
934
const MAX_LINES = 6;
935
const cards = document.querySelectorAll('.content-card');
936
cards.forEach(function(card) {
937
const content = card.querySelector('.post-content');
938
const btn = card.querySelector('.btn-show-more');
939
if (!content || !btn) return;
940
941
const html = content.innerHTML;
942
const parts = html.split(/<br\s*\/?>/i);
943
if (parts.length <= MAX_LINES) {
944
btn.remove();
945
return;
946
}
947
948
const visible = parts.slice(0, MAX_LINES).join('<br>');
949
const hidden = parts.slice(MAX_LINES).join('<br>');
950
content.innerHTML = visible + '<span class="post-content-extra"><br>' + hidden + '</span>';
951
btn.style.display = 'inline-block';
952
});
953
});
954
955
// 유틸리티 함수들
956
function toggleReplyForm(postId) {
957
const formDiv = document.getElementById('reply-form-' + postId);
958
if (formDiv) {
959
if (formDiv.classList.contains('d-none')) {
960
formDiv.classList.remove('d-none');
961
formDiv.querySelector('textarea')?.focus();
962
} else {
963
formDiv.classList.add('d-none');
964
}
965
} else {
966
showConfirm('로그인이 필요합니다.').then(function(ok){ if(ok) location.href = '/login?redirect=/hinana/index'; });
967
}
968
}
969
970
function togglePostContent(postId, btn) {
971
const wrapper = document.getElementById('content-wrapper-' + postId);
972
if (wrapper.classList.contains('expanded')) {
973
wrapper.classList.remove('expanded');
974
btn.innerText = '더보기';
975
} else {
976
wrapper.classList.add('expanded');
977
btn.innerText = '접기';
978
}
979
}
980
981
function showAllReplies(postId, btn) {
982
$('.hidden-reply-' + postId).removeClass('d-none');
983
$(btn).parent().remove();
984
}
985
986
function checkAnonFormReady() {
987
const username = document.getElementById('anon-username');
988
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
989
const content = document.getElementById('anon-write-input');
990
const btn = document.getElementById('anon-submit-btn');
991
if (!btn) return;
992
993
const ready = username && username.value.trim().length > 0
994
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
995
&& content && content.value.trim().length > 0;
996
997
if (ready) {
998
btn.disabled = false;
999
btn.style.backgroundColor = document.body.classList.contains('dark-mode') ? '#b45309' : 'var(--accent-color)';
1000
btn.style.color = 'white';
1001
btn.style.cursor = 'pointer';
1002
btn.style.opacity = '1';
1003
} else {
1004
btn.disabled = true;
1005
btn.style.backgroundColor = 'var(--border-color)';
1006
btn.style.color = 'var(--text-secondary)';
1007
btn.style.cursor = 'not-allowed';
1008
btn.style.opacity = '0.6';
1009
}
1010
}
1011
1012
function validateAnonForm() {
1013
const username = document.getElementById('anon-username');
1014
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1015
const content = document.getElementById('anon-write-input');
1016
return username && username.value.trim().length > 0
1017
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1018
&& content && content.value.trim().length > 0;
1019
}
1020
1021
function checkInputLength(input, counterId) {
1022
const maxLength = 150;
1023
const counter = document.getElementById(counterId);
1024
if (counter) {
1025
counter.innerText = `${input.value.length}/${maxLength}`;
1026
counter.className = 'small ms-2 ' + (input.value.length >= maxLength ? 'text-danger fw-bold' : 'text-muted');
1027
}
1028
}
1029
1030
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1031
const pw = await showPrompt("비밀번호:"); if(!pw) return;
1032
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1033
method:'POST', headers:{'Content-Type':'application/json'},
1034
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1035
}).then(r=>r.json()).then(d=>{ showAlert(d.message).then(()=>{ if(d.success) location.reload(); }); });
1036
}
1037
1038
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1039
const pw = await showPrompt("비밀번호:"); if(!pw) return;
1040
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1041
method:'POST', headers:{'Content-Type':'application/json'},
1042
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1043
}).then(r=>r.json()).then(d=>{ showAlert(d.message).then(()=>{ if(d.success) location.reload(); }); });
1044
}
1045
1046
function toggleWriteMode(mode) {
1047
const userForm = document.getElementById('form-mode-user');
1048
const anonForm = document.getElementById('form-mode-anon');
1049
const btnUser = document.getElementById('btn-mode-user');
1050
const btnAnon = document.getElementById('btn-mode-anon');
1051
if (mode === 'user') {
1052
if(userForm) userForm.classList.remove('d-none');
1053
if(anonForm) anonForm.classList.add('d-none');
1054
if(btnUser) btnUser.classList.add('active');
1055
if(btnAnon) btnAnon.classList.remove('active');
1056
} else {
1057
if(userForm) userForm.classList.add('d-none');
1058
if(anonForm) anonForm.classList.remove('d-none');
1059
if(btnUser) btnUser.classList.remove('active');
1060
if(btnAnon) btnAnon.classList.add('active');
1061
}
1062
}
1063
1064
function changeSortOrder(sortValue) {
1065
const url = new URL(window.location.href);
1066
url.searchParams.set('sort', sortValue);
1067
url.searchParams.set('page', '1');
1068
window.location.href = url.toString();
1069
}
1070
// 외부 링크 경고 (미리보기 카드 클릭 포함)
1071
document.addEventListener('click', function(e) {
1072
const card = e.target.closest('.link-preview-card');
1073
const link = card ? null : e.target.closest('a.external-link');
1074
const target = card || link;
1075
if (!target) return;
1076
e.preventDefault();
1077
const url = target.getAttribute('data-url');
1078
showConfirm('안전하지 않을 수 있는 외부 링크입니다.\n이동하시겠습니까?\n\n' + url).then(function(ok) {
1079
if (ok) window.open(url, '_blank', 'noopener,noreferrer');
1080
});
1081
});
1082
1083
// 링크 미리보기 카드 생성
1084
(function() {
1085
const links = document.querySelectorAll('.post-content a.external-link, .reply-content a.external-link');
1086
const seen = new Set();
1087
links.forEach(function(link) {
1088
const url = link.getAttribute('data-url');
1089
if (!url || seen.has(url)) return;
1090
seen.add(url);
1091
1092
fetch('/api/link-preview?url=' + encodeURIComponent(url))
1093
.then(r => r.json())
1094
.then(data => {
1095
if (data.error || (!data.title && !data.description)) return;
1096
1097
const card = document.createElement('div');
1098
card.className = 'link-preview-card';
1099
card.setAttribute('data-url', url);
1100
1101
const bar = document.createElement('div');
1102
bar.className = 'link-preview-bar';
1103
if (data.color) bar.style.backgroundColor = data.color;
1104
1105
const body = document.createElement('div');
1106
body.className = 'link-preview-body';
1107
1108
if (data.domain) {
1109
const domain = document.createElement('div');
1110
domain.className = 'link-preview-domain';
1111
domain.textContent = data.domain;
1112
body.appendChild(domain);
1113
}
1114
if (data.title) {
1115
const title = document.createElement('div');
1116
title.className = 'link-preview-title';
1117
title.textContent = data.title;
1118
body.appendChild(title);
1119
}
1120
if (data.description) {
1121
const desc = document.createElement('div');
1122
desc.className = 'link-preview-desc';
1123
desc.textContent = data.description;
1124
body.appendChild(desc);
1125
}
1126
if (data.image) {
1127
const img = document.createElement('img');
1128
img.className = 'link-preview-img';
1129
img.src = data.image;
1130
img.alt = '';
1131
img.onerror = function() { this.remove(); };
1132
body.appendChild(img);
1133
}
1134
1135
card.appendChild(bar);
1136
card.appendChild(body);
1137
1138
// 링크 바로 뒤에 삽입
1139
if (link.nextSibling) {
1140
link.parentNode.insertBefore(card, link.nextSibling);
1141
} else {
1142
link.parentNode.appendChild(card);
1143
}
1144
})
1145
.catch(function() {});
1146
});
1147
})();
1148
</script>
1149
1150
<script>
1151
var _PUSH_KEY = '<%= typeof vapidPublicKey !== "undefined" ? vapidPublicKey : "" %>';
1152
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1153
</script>
1154
<script>
1155
let _swReg = null;
1156
1157
// Android WebView 브릿지: FCM 토큰 수신 후 서버에 저장
1158
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1159
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1160
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1161
method: 'POST',
1162
headers: { 'Content-Type': 'application/json' },
1163
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1164
}).catch(() => {});
1165
};
1166
1167
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1168
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1169
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1170
}
1171
1172
function urlBase64ToUint8Array(b64) {
1173
const pad = '='.repeat((4 - b64.length % 4) % 4);
1174
const base64 = (b64 + pad).replace(/-/g, '+').replace(/_/g, '/');
1175
const raw = atob(base64);
1176
return Uint8Array.from(raw, c => c.charCodeAt(0));
1177
}
1178
1179
async function subscribePush(reg) {
1180
const existing = await reg.pushManager.getSubscription();
1181
if (!existing) {
1182
try {
1183
const sub = await reg.pushManager.subscribe({
1184
userVisibleOnly: true,
1185
applicationServerKey: urlBase64ToUint8Array(_PUSH_KEY)
1186
});
1187
await fetch('/api/push/subscribe', {
1188
method: 'POST',
1189
headers: { 'Content-Type': 'application/json' },
1190
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1191
});
1192
} catch (e) { console.warn('Push 구독 실패:', e); }
1193
} else {
1194
fetch('/api/push/subscribe', {
1195
method: 'POST',
1196
headers: { 'Content-Type': 'application/json' },
1197
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
1198
}).catch(() => {});
1199
}
1200
}
1201
1202
// 버튼 클릭 시 권한 요청 (사용자 제스처 필요)
1203
async function requestNotifPermission() {
1204
const banner = document.getElementById('notif-banner');
1205
const perm = await Notification.requestPermission();
1206
if (perm === 'granted') {
1207
if (banner) banner.remove();
1208
if (_swReg) subscribePush(_swReg);
1209
} else {
1210
if (banner) banner.remove();
1211
}
1212
}
1213
1214
(async function() {
1215
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
1216
const reg = await navigator.serviceWorker.register('/sw.js');
1217
await navigator.serviceWorker.ready;
1218
_swReg = reg;
1219
if (!_PUSH_USER || !_PUSH_KEY) return;
1220
1221
if (Notification.permission === 'granted') {
1222
// 이미 허용된 경우 바로 구독
1223
subscribePush(reg);
1224
} else if (Notification.permission === 'default') {
1225
// 아직 결정 안 된 경우 배너 표시
1226
const banner = document.getElementById('notif-banner');
1227
if (banner) banner.classList.remove('d-none');
1228
}
1229
// 'denied'인 경우 아무것도 하지 않음
1230
})();
1231
</script>
1232
</body>
1233
</html>
1234