Public Source Viewer

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

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

Redacted View
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