Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
view/hinana/persona.ejs
공개 가능
1
<!DOCTYPE html>
2
<html lang="ko">
3
<head>
4
<meta charset="utf-8" />
5
<meta name="color-scheme" content="light dark">
6
<meta name="viewport" content="width=device-width, initial-scale=1">
7
<link rel="manifest" href="/manifest.json">
8
<meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#0f141e' : '#f8f7f5' %>">
9
<title>페르소나 설정 — 비나래 아카이브</title>
10
<link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
11
<script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
12
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
13
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
14
<script src="https://code.jquery.com/jquery-3.3.1.min.js" crossorigin="anonymous"></script>
15
<script src="/js/popup.js"></script>
16
17
<style>
18
:root {
19
--font-family: 'Noto Sans KR', sans-serif;
20
--bg-main: #f8f7f5;
21
--bg-secondary: #ffffff;
22
--bg-tertiary: #1a2238;
23
--text-primary: #1a2238;
24
--text-secondary: #5e6676;
25
--accent-color: #c5a059;
26
--border-color: #e5e1da;
27
--shadow-md: 0 10px 40px -10px rgba(26, 34, 56, 0.12);
28
--shadow-sm: 0 2px 8px rgba(0,0,0,0.05);
29
--danger-color: #dc2626;
30
}
31
body.dark-mode {
32
--bg-main: #0f141e;
33
--bg-secondary: #1a2238;
34
--bg-tertiary: #0a0e17;
35
--text-primary: #e7e5e4;
36
--text-secondary: #a8a29e;
37
--accent-color: #d4b47a;
38
--border-color: #2e3a59;
39
}
40
41
html, body {
42
height: auto !important; min-height: 100%; margin: 0; padding: 0;
43
font-family: var(--font-family);
44
background-color: var(--bg-main);
45
color: var(--text-primary);
46
overflow-x: hidden;
47
}
48
a { text-decoration: none; color: inherit; }
49
50
/* Header */
51
.global-header {
52
height: 60px; background-color: var(--bg-tertiary);
53
display: flex; align-items: center; justify-content: space-between;
54
padding: 0 40px; position: sticky; top: 0; z-index: 1000;
55
border-bottom: 1px solid rgba(255,255,255,0.08);
56
}
57
.header-logo { height: 32px; }
58
body:not(.dark-mode) .logo-night { display: none; }
59
body.dark-mode .logo-day { display: none; }
60
.header-controls { display: flex; align-items: center; gap: 12px; }
61
.header-controls a { color: rgba(255,255,255,0.5); font-size: 0.85rem; font-weight: 700; text-decoration: none; }
62
.header-controls a:hover { color: white; }
63
.header-controls button { color: white; background: none; border: none; padding: 4px; cursor: pointer; font-size: 1rem; }
64
65
/* Layout */
66
.layout-container {
67
display: flex;
68
min-height: calc(100vh - 60px);
69
width: 100%; max-width: 100vw; overflow-x: hidden;
70
}
71
72
/* Content column */
73
.content-column {
74
flex: 1; padding: 60px 40px 80px;
75
min-width: 0;
76
display: flex; flex-direction: column; align-items: center;
77
}
78
.content-column > * {
79
width: 100%; max-width: 720px;
80
}
81
82
/* Right sidebar */
83
.info-column {
84
flex: 0 0 300px;
85
width: 300px;
86
background-color: var(--bg-secondary);
87
border-left: 1px solid var(--border-color);
88
padding: 40px 0;
89
display: flex; flex-direction: column; align-items: center;
90
}
91
.info-card {
92
width: 250px;
93
background-color: var(--bg-main);
94
border: 1px solid var(--border-color);
95
border-radius: 12px;
96
padding: 20px;
97
margin-bottom: 16px;
98
box-shadow: var(--shadow-sm);
99
}
100
.info-card-label {
101
font-size: 0.65rem; color: var(--accent-color);
102
letter-spacing: 3px; font-weight: 700; margin-bottom: 12px;
103
}
104
105
/* Hero */
106
.page-hero { text-align: center; margin-bottom: 48px; }
107
.page-hero .discord-badge {
108
display: inline-flex; align-items: center; gap: 8px;
109
background: #5865F2; color: white; font-size: 0.8rem;
110
padding: 5px 14px; border-radius: 20px; margin-bottom: 20px;
111
}
112
.page-hero h1 {
113
font-size: 2.2rem; font-weight: 700; letter-spacing: -1px;
114
color: var(--text-primary); margin: 0 0 12px;
115
}
116
.page-hero p { color: var(--text-secondary); font-size: 0.95rem; margin: 0; }
117
118
/* Card */
119
.luxury-card {
120
max-width: 720px;
121
background: var(--bg-secondary);
122
border: 1px solid var(--border-color);
123
border-top: 4px solid var(--accent-color);
124
border-radius: 4px;
125
box-shadow: var(--shadow-md);
126
padding: 40px;
127
}
128
129
.section-label {
130
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.1em;
131
text-transform: uppercase; color: var(--accent-color); margin-bottom: 8px;
132
}
133
134
/* Cost badge */
135
.cost-badge {
136
display: inline-flex; align-items: center; gap: 6px;
137
background: rgba(197,160,89,0.1); color: var(--accent-color);
138
border: 1px solid rgba(197,160,89,0.3);
139
font-size: 0.8rem; font-weight: 600;
140
padding: 4px 12px; border-radius: 20px; margin-bottom: 24px;
141
}
142
143
/* Textarea / Input */
144
.persona-textarea {
145
width: 100%; min-height: 220px;
146
background: var(--bg-main); color: var(--text-primary);
147
border: 1px solid var(--border-color); border-radius: 4px;
148
padding: 16px; font-size: 0.95rem; line-height: 1.7;
149
resize: vertical; font-family: var(--font-family);
150
transition: border-color 0.2s; box-sizing: border-box;
151
}
152
.persona-textarea:focus { outline: none; border-color: var(--accent-color); }
153
.persona-textarea::placeholder { color: var(--text-secondary); }
154
155
.char-count {
156
text-align: right; font-size: 0.78rem;
157
color: var(--text-secondary); margin-top: 6px;
158
}
159
.char-count.warn { color: var(--danger-color); }
160
161
/* Hint */
162
.persona-hint {
163
background: var(--bg-main); border: 1px solid var(--border-color);
164
border-left: 3px solid var(--accent-color);
165
border-radius: 4px; padding: 16px; margin: 20px 0;
166
font-size: 0.85rem; color: var(--text-secondary); line-height: 1.7;
167
}
168
.persona-hint strong { color: var(--text-primary); }
169
170
/* Buttons */
171
.btn-primary-custom {
172
background: var(--accent-color); color: white;
173
border: none; padding: 14px 32px; border-radius: 4px;
174
font-size: 0.95rem; font-weight: 700; cursor: pointer;
175
transition: all 0.2s; letter-spacing: 0.02em;
176
}
177
.btn-primary-custom:hover { filter: brightness(1.1); transform: translateY(-1px); }
178
.btn-primary-custom:disabled {
179
opacity: 0.5; cursor: not-allowed; transform: none; filter: none;
180
}
181
182
/* Alert */
183
.alert-success-custom {
184
background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.3);
185
color: #16a34a; border-radius: 4px; padding: 14px 20px;
186
font-size: 0.9rem; margin-bottom: 24px;
187
}
188
body.dark-mode .alert-success-custom { color: #4ade80; }
189
190
/* Existing persona */
191
.existing-persona {
192
background: var(--bg-main); border: 1px solid var(--border-color);
193
border-radius: 4px; padding: 16px;
194
font-size: 0.9rem; color: var(--text-secondary);
195
white-space: pre-wrap; line-height: 1.7; max-height: 120px;
196
overflow-y: auto; margin-top: 8px;
197
}
198
199
/* Info row */
200
.info-row {
201
display: flex; align-items: center; gap: 10px;
202
padding: 12px 16px; background: var(--bg-main);
203
border: 1px solid var(--border-color); border-radius: 4px;
204
margin-bottom: 24px;
205
}
206
.info-row .discord-icon { color: #5865F2; font-size: 1.1rem; }
207
208
/* Divider */
209
.divider { border: none; border-top: 1px solid var(--border-color); margin: 28px 0; }
210
211
/* Responsive */
212
@media (max-width: 1100px) {
213
.layout-container { flex-direction: column; }
214
.content-column { padding: 40px 20px 60px; }
215
.info-column {
216
width: 100%; flex: auto;
217
border-left: none; border-top: 1px solid var(--border-color);
218
padding: 40px 20px;
219
flex-direction: row; flex-wrap: wrap; justify-content: center; gap: 16px;
220
}
221
.info-card { width: calc(50% - 8px); margin-bottom: 0; }
222
.info-column > .mt-auto { width: 100%; text-align: center; padding-top: 20px; }
223
}
224
@media (max-width: 600px) {
225
.global-header { padding: 0 16px; }
226
.content-column { padding: 32px 16px 60px; }
227
.luxury-card { padding: 24px; }
228
.page-hero h1 { font-size: 1.6rem; }
229
.info-card { width: 100%; }
230
}
231
</style>
232
</head>
233
234
<body class="<%= theme === 'dark' ? 'dark-mode' : '' %>">
235
236
<header class="global-header">
237
<div class="header-brand">
238
<a href="/hinana/lounge">
239
<img src="/image/lounge1.png" alt="비나래 라운지" class="header-logo">
240
</a>
241
</div>
242
<div class="header-controls">
243
<a href="/hinana/gallery#brand-assets">사이트 맵</a>
244
<form action="/toggle-theme" method="POST" style="margin:0;">
245
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
246
<button type="submit"><i class="bi bi-moon-stars"></i></button>
247
</form>
248
</div>
249
</header>
250
251
<div class="layout-container">
252
253
<!-- 메인 콘텐츠 -->
254
<div class="content-column">
255
256
<!-- Hero -->
257
<div class="page-hero">
258
<% if (discordId) { %>
259
<div class="discord-badge">
260
<i class="bi bi-discord"></i>
261
<span><%= discordName || discordId %></span>
262
</div>
263
<% } %>
264
<h1>페르소나 설정</h1>
265
<p>나만의 AI 페르소나를 써내려 갑니다.<br>Discord에서 <strong>/히나나</strong> 명령어로 바로 사용할 수 있어요.</p>
266
</div>
267
268
<!-- Main Card -->
269
<div class="luxury-card">
270
271
<% if (typeof saved !== 'undefined' && saved === '1') { %>
272
<div class="alert-success-custom">
273
<i class="bi bi-check-circle-fill me-2"></i>페르소나가 저장되었습니다! Discord에서 <strong>/히나나</strong> 명령어로 사용해 보세요.
274
</div>
275
<% } %>
276
277
<% if (!discordId) { %>
278
<div class="alert-success-custom" style="background:rgba(239,68,68,0.08); border-color:rgba(239,68,68,0.2); color:var(--danger-color);">
279
<i class="bi bi-exclamation-triangle-fill me-2"></i>Discord에서 <strong>/이치카와</strong> 명령어를 통해 접속해야 합니다.
280
</div>
281
<% } else { %>
282
283
<!-- Discord 연동 정보 -->
284
<div class="section-label">연동 Discord 계정</div>
285
<div class="info-row">
286
<i class="bi bi-discord discord-icon"></i>
287
<span style="font-weight:600;"><%= discordName || '알 수 없음' %></span>
288
<span style="font-size:0.78rem; color:var(--text-secondary);">ID: <%= discordId %></span>
289
</div>
290
291
<% if (existingPersona) { %>
292
<!-- 기존 페르소나 -->
293
<div class="section-label">현재 페르소나</div>
294
<% if (existingPersona.personaName) { %>
295
<div style="font-size:0.85rem; color:var(--text-secondary); margin-bottom:6px;">
296
이름: <strong style="color:var(--text-primary);"><%= existingPersona.personaName %></strong>
297
</div>
298
<% } %>
299
<div class="existing-persona"><%= existingPersona.persona %></div>
300
<div style="font-size:0.75rem; color:var(--text-secondary); margin-top:6px;">
301
마지막 수정: <%= new Date(existingPersona.updatedAt).toLocaleDateString('ko-KR') %>
302
</div>
303
<hr class="divider">
304
<% } %>
305
306
<!-- 비용 안내 -->
307
<% if (!existingPersona) { %>
308
<div class="cost-badge">
309
<i class="bi bi-bookmark-fill"></i>
310
책갈피 <%= personaCost %>개를 사용하여 새 페르소나를 써내려 갑니다.
311
</div>
312
<div style="font-size:0.82rem; color:var(--text-secondary); margin-bottom:20px;">
313
현재 보유 책갈피: <strong style="color:var(--accent-color);"><%= bookmarks %>개</strong>
314
</div>
315
<% } else { %>
316
<div class="cost-badge">
317
<i class="bi bi-bookmark-fill"></i>
318
책갈피 <%= personaUpdateCost %>개를 사용하여 페르소나를 수정합니다.
319
</div>
320
<div style="font-size:0.82rem; color:var(--text-secondary); margin-bottom:20px;">
321
현재 보유 책갈피: <strong style="color:var(--accent-color);"><%= bookmarks %>개</strong>
322
</div>
323
<% } %>
324
325
<!-- 작성 힌트 -->
326
<div class="persona-hint">
327
<strong>어떻게 쓸까요?</strong><br>
328
AI가 대화할 때 취할 성격, 말투, 역할 등을 자유롭게 적어주세요.<br>
329
예시: <em>"너는 이치카와 히나나야. 그럴리가 없겠지만, 말투는 조용하고 시적이며, 질문에 짧고 깊이 있게 답해."</em>
330
</div>
331
332
<!-- 입력 폼 -->
333
<form action="/hinana/persona/save" method="POST" id="personaForm">
334
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
335
<input type="hidden" name="discord_id" value="<%= discordId %>">
336
<input type="hidden" name="discord_name" value="<%= discordName %>">
337
338
<div class="section-label">페르소나 이름 <span style="font-weight:400; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(embed 제목에 표시됩니다)</span></div>
339
<input
340
type="text"
341
class="persona-textarea"
342
name="persona_name"
343
maxlength="50"
344
placeholder="AI의 이름 (예: 이치카와 히나나)"
345
value="<%= existingPersona ? (existingPersona.personaName || '') : '' %>"
346
style="min-height:unset; height:48px; resize:none; margin-bottom:20px;"
347
>
348
349
<div class="section-label"><%= existingPersona ? '페르소나 수정' : '새 페르소나 작성' %></div>
350
<textarea
351
class="persona-textarea"
352
name="persona_text"
353
id="personaText"
354
maxlength="2000"
355
placeholder="AI의 성격, 말투, 배경 등을 자유롭게 써주세요..."
356
oninput="updateCount(this)"
357
required><%= existingPersona ? existingPersona.persona : '' %></textarea>
358
<div class="char-count" id="charCount">0 / 2000</div>
359
360
<div style="margin-top:24px; display:flex; justify-content:flex-end;">
361
<button type="submit" class="btn-primary-custom" id="submitBtn"
362
<% if ((!existingPersona && bookmarks < personaCost) || (existingPersona && bookmarks < personaUpdateCost)) { %>disabled<% } %>>
363
<i class="bi bi-<%= existingPersona ? 'pencil-fill' : 'bookmark-fill' %> me-2"></i>
364
<%= existingPersona ? `책갈피 ${personaUpdateCost}개로 페르소나 수정` : `책갈피 ${personaCost}개로 페르소나 생성` %>
365
</button>
366
</div>
367
</form>
368
369
<% } %>
370
</div>
371
372
</div>
373
374
<!-- 우측 사이드바 -->
375
<div class="info-column">
376
377
<div class="info-card">
378
<div class="info-card-label">ACCOUNT</div>
379
<div style="font-size:1rem; font-weight:700; margin-bottom:12px;"><%= username %></div>
380
<a href="/logout?redirect=/hinana/lounge" class="btn btn-sm w-100 py-2"
381
style="border:1px solid var(--border-color); color:var(--text-secondary); font-size:0.8rem; letter-spacing:1px;">
382
SIGN OUT
383
</a>
384
</div>
385
386
<div class="info-card">
387
<div class="info-card-label">SYSTEM INFO</div>
388
<ul class="list-unstyled mb-0" style="font-size:0.75rem; color:var(--text-secondary);">
389
<li class="d-flex justify-content-between mb-1">
390
<span>Version</span>
391
<span class="text-end">Ver. 6.5.4.0-Kozeki Ui</span>
392
</li>
393
</ul>
394
</div>
395
396
<div class="mt-auto text-center" style="padding-top: 20px;">
397
<img src="/image/sign.png" id="persona_sign" style="width:160px; opacity:0.7; mix-blend-mode:multiply;">
398
<div class="mt-4 pt-4" style="border-top:1px solid var(--border-color); font-size:0.7rem; color:var(--text-secondary); line-height:1.8;">
399
<strong>비나래 라운지/비나래 아카이브</strong><br>
400
X - @NoctchillHinana<br>
401
ⓒ 2024~2026. 비나래 | hinana.moe
402
</div>
403
</div>
404
405
</div>
406
407
</div>
408
409
<script>
410
if (document.body.classList.contains('dark-mode')) {
411
const sign = document.getElementById('persona_sign');
412
if (sign) sign.style.mixBlendMode = 'screen';
413
}
414
415
function updateCount(el) {
416
const normalized = el.value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
417
const len = normalized.length;
418
const counter = document.getElementById('charCount');
419
counter.textContent = len + ' / 2000';
420
counter.className = 'char-count' + (len >= 1800 ? ' warn' : '');
421
}
422
423
const ta = document.getElementById('personaText');
424
if (ta) updateCount(ta);
425
426
// 폼 제출 전 클라이언트 검증
427
const form = document.getElementById('personaForm');
428
if (form) {
429
form.addEventListener('submit', function(e) {
430
const text = ta ? ta.value.replace(/\r\n/g, '\n').replace(/\r/g, '\n') : '';
431
if (text.trim().length > 2000) {
432
e.preventDefault();
433
showAlert('페르소나는 2000자 이내여야 합니다.\n현재: ' + text.trim().length + '자');
434
return;
435
}
436
});
437
}
438
439
// 서버 에러 팝업
440
const urlParams = new URLSearchParams(window.location.search);
441
const errorMsg = urlParams.get('error');
442
if (errorMsg) {
443
showAlert(errorMsg);
444
history.replaceState(null, '', window.location.pathname + (urlParams.get('saved') ? '?saved=1' : ''));
445
}
446
</script>
447
448
<script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); }</script>
449
</body>
450
</html>
451