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="<%= theme === 'dark' ? '#0e1218' : '#f7f9fb' %>">
9 <title>페르소나 설정 - 비나래 아카이브</title>
10 <link rel="stylesheet" href="/vendors/bootstrap/css/bootstrap.min.css">
11 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
12 <script src="/js/popup.js"></script>
13 <style>
14 :root{--font:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans KR",sans-serif;--bg:#f7f9fb;--paper:#fff;--panel:rgba(255,255,255,.88);--text:#18202b;--muted:#687586;--accent:#c5a059;--accent2:#d45f86;--line:rgba(24,32,43,.12);--header:rgba(247,249,251,.86);--danger:#c43737;--shadow:0 20px 58px rgba(27,39,56,.1)}
15 body.dark-mode{--bg:#0e1218;--paper:#151b24;--panel:rgba(20,27,37,.9);--text:#eef4fb;--muted:#a3afbd;--accent:#d4b47a;--accent2:#ee89aa;--line:rgba(238,244,251,.13);--header:rgba(14,18,24,.88);--danger:#ff7777;--shadow:0 22px 64px rgba(0,0,0,.4)}
16 *{box-sizing:border-box}html,body{margin:0;min-height:100%;font-family:var(--font);background:var(--bg);color:var(--text)}body{padding-top:62px}a{color:inherit;text-decoration:none}button,input,textarea{font:inherit}
17 .global-header{position:fixed;inset:0 0 auto;z-index:3000;min-height:62px;padding:0 22px;display:flex;align-items:center;justify-content:space-between;gap:18px;background:var(--header);border-bottom:1px solid var(--line);backdrop-filter:blur(18px);box-shadow:0 10px 30px rgba(27,39,56,.07)}
18 .header-logo{height:30px;width:auto;mix-blend-mode:multiply}.dark-mode .header-logo{mix-blend-mode:screen}.header-nav{position:absolute;left:50%;transform:translateX(-50%);display:flex;gap:16px}.nav-link{padding:8px 10px;border-radius:7px;color:var(--muted);font-size:.9rem;font-weight:800}.nav-link:hover,.nav-link.active{color:var(--text);background:color-mix(in srgb,var(--paper) 70%,transparent)}
19 .header-controls{display:flex;align-items:center;gap:8px}.icon-btn{width:36px;height:36px;display:inline-grid;place-items:center;border:1px solid var(--line);border-radius:50%;background:var(--panel);color:var(--muted)}.account-link{padding:8px 11px;border:1px solid var(--line);border-radius:7px;font-size:.8rem;font-weight:800;color:var(--muted)}
20 .shell{width:min(1180px,calc(100% - 36px));margin:0 auto;padding:42px 0 70px}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;padding-bottom:22px;border-bottom:1px solid var(--line)}.eyebrow{font-size:.7rem;font-weight:900;color:var(--accent);text-transform:uppercase}.page-head h1{font-size:2rem;margin:5px 0 7px;font-weight:950}.page-head p{margin:0;color:var(--muted);font-size:.9rem}.discord-state{display:flex;align-items:center;gap:10px;padding:10px 13px;border:1px solid var(--line);background:var(--panel);border-radius:7px;font-size:.8rem}.discord-state i{color:#5865f2;font-size:1.1rem}.discord-state strong{display:block;color:var(--text)}
21 .workspace{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:20px;margin-top:22px;align-items:start}.editor,.side-panel{background:var(--paper);border:1px solid var(--line);border-radius:7px}.editor{padding:26px;box-shadow:var(--shadow)}.side-panel{padding:17px;margin-bottom:12px}.panel-label,.field-label{display:block;font-size:.69rem;font-weight:900;color:var(--accent);text-transform:uppercase;margin-bottom:8px}.side-panel h2{font-size:.95rem;font-weight:900;margin:0 0 8px}.side-panel p{font-size:.78rem;color:var(--muted);line-height:1.55;margin:0}
22 .notice{padding:13px 15px;border:1px solid color-mix(in srgb,var(--accent) 35%,var(--line));border-left:3px solid var(--accent);background:color-mix(in srgb,var(--accent) 7%,transparent);font-size:.82rem;margin-bottom:20px}.notice.danger{border-color:color-mix(in srgb,var(--danger) 35%,var(--line));border-left-color:var(--danger);color:var(--danger)}
23 .slots{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-bottom:24px}.slot{display:block;min-width:0;padding:12px;border:1px solid var(--line);border-radius:6px;background:var(--bg);transition:border-color .18s,transform .18s}.slot:hover{transform:translateY(-1px);border-color:var(--accent)}.slot.active{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}.slot-head{display:flex;justify-content:space-between;gap:8px;font-size:.8rem;font-weight:900}.slot-name{display:block;margin-top:7px;color:var(--muted);font-size:.74rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.slot-empty{opacity:.7}
24 .current{padding:14px;background:var(--bg);border:1px solid var(--line);border-radius:5px;margin-bottom:20px}.current-head{display:flex;justify-content:space-between;gap:12px;margin-bottom:8px;font-size:.77rem;color:var(--muted)}.current-text{max-height:112px;overflow:auto;white-space:pre-wrap;font-size:.82rem;line-height:1.65;color:var(--muted)}
25 .cost-row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:11px 13px;margin-bottom:20px;border-block:1px solid var(--line);font-size:.8rem}.cost{font-weight:850;color:var(--accent)}.balance{color:var(--muted)}
26 .field{margin-bottom:18px}.field-note{font-size:.72rem;color:var(--muted);font-weight:500;text-transform:none}.text-input{width:100%;border:1px solid var(--line);border-radius:5px;background:var(--bg);color:var(--text);padding:12px 13px}.text-input:focus{outline:2px solid color-mix(in srgb,var(--accent) 30%,transparent);border-color:var(--accent)}textarea.text-input{min-height:250px;resize:vertical;line-height:1.65}.char-count{text-align:right;color:var(--muted);font-size:.72rem;margin-top:5px}.char-count.warn{color:var(--danger)}
27 .form-foot{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-top:22px}.hint{font-size:.75rem;color:var(--muted);max-width:430px;line-height:1.5}.submit{min-height:44px;border:0;border-radius:5px;padding:0 18px;background:var(--accent);color:#fff;font-weight:900;white-space:nowrap}.submit:disabled{opacity:.45;cursor:not-allowed}.side-links{display:grid;gap:1px}.side-links a{display:flex;justify-content:space-between;align-items:center;padding:9px 0;border-bottom:1px solid var(--line);font-size:.8rem;color:var(--muted)}.side-links a:last-child{border:0}.side-links a:hover{color:var(--text)}
28 .site-footer{width:min(1180px,calc(100% - 36px));margin:0 auto 22px;padding:25px 0 4px;border-top:1px solid var(--line);display:flex;align-items:flex-end;justify-content:space-between;gap:24px;color:var(--muted);font-size:.76rem}.footer-brand{display:flex;align-items:center;gap:13px}.footer-logo{width:90px;mix-blend-mode:multiply}.dark-mode .footer-logo{mix-blend-mode:screen}.footer-title{font-weight:950;color:var(--text);font-size:.9rem}.footer-links{display:flex;justify-content:flex-end;gap:14px;margin-bottom:7px}.footer-links a:hover{color:var(--text)}.footer-copy{text-align:right}.footer-version{margin-right:10px}
29 @media(max-width:850px){body{padding-top:108px}.global-header{min-height:108px;align-items:flex-start;padding:11px 16px}.header-nav{top:60px;width:100%;justify-content:center;border-top:1px solid var(--line);padding-top:7px}.workspace{grid-template-columns:1fr}.sidebar{display:grid;grid-template-columns:1fr 1fr;gap:10px}.side-panel{margin:0}.page-head{align-items:flex-start}.site-footer{align-items:flex-start}}
30 @media(max-width:600px){.shell,.site-footer{width:min(100% - 24px,1180px)}.shell{padding-top:28px}.page-head{display:block}.discord-state{margin-top:15px}.page-head h1{font-size:1.55rem}.editor{padding:18px}.slots{grid-template-columns:1fr}.form-foot,.site-footer{display:block}.submit{width:100%;margin-top:14px}.sidebar{grid-template-columns:1fr}.footer-links{justify-content:flex-start;margin-top:18px}.footer-copy{text-align:left}.account-link{display:none}}
31 </style>
32 </head>
33 <body class="<%= theme === 'dark' ? 'dark-mode' : '' %>">
34 <header class="global-header">
35 <a href="/hinana/lounge"><img src="/image/<%= theme === 'dark' ? 'lounge1.png' : 'lounge.png' %>" alt="비나래 라운지" class="header-logo"></a>
36 <nav class="header-nav" aria-label="주요 메뉴"><a href="/hinana/index" class="nav-link">Archive</a><a href="/hinana/lounge" class="nav-link active">Lounge</a><a href="/hinana/gallery" class="nav-link">Gallery</a><a href="/hinana/plaza" class="nav-link">Plaza</a></nav>
37 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
38 </header>
39
40 <main class="shell">
41 <section class="page-head"><div><div class="eyebrow">Discord AI Persona</div><h1>페르소나 설정</h1><p>Discord AI가 사용할 성격과 말투를 슬롯별로 관리합니다.</p></div><div class="discord-state"><i class="bi bi-discord"></i><div><strong><%= discordId ? (discordName || '연동된 Discord') : 'Discord 연결 필요' %></strong><span><%= discordId ? '연동됨' : '/이치카와 명령어로 접속해 주세요' %></span></div></div></section>
42
43 <div class="workspace">
44 <section class="editor">
45 <% if (typeof saved !== 'undefined' && saved === '1') { %><div class="notice"><i class="bi bi-check-circle-fill me-2"></i>페르소나가 저장되었습니다. Discord에서 바로 사용할 수 있습니다.</div><% } %>
46 <% if (!discordId) { %><div class="notice danger"><i class="bi bi-exclamation-triangle-fill me-2"></i>Discord에서 <strong>/이치카와</strong> 명령어를 통해 접속해야 페르소나를 편집할 수 있습니다.</div><% } else { %>
47 <span class="panel-label">Persona Slots</span>
48 <div class="slots"><% for(let slot=1;slot<=(typeof maxPersonaSlots!=='undefined'?maxPersonaSlots:3);slot++){ const slotPersona=(typeof personas!=='undefined'?personas:[]).find(function(p){return(p.personaSlot||1)===slot;}); %><a class="slot <%= selectedSlot===slot?'active':'' %>" href="/hinana/persona?slot=<%= slot %>"><span class="slot-head"><span><%= slot %>번 슬롯</span><% if(slotPersona){ %><i class="bi bi-check-circle-fill" style="color:var(--accent)"></i><% } %></span><span class="slot-name <%= slotPersona?'':'slot-empty' %>"><%= slotPersona?(slotPersona.personaName||'이름 없는 페르소나'):'비어 있음' %><%= slotPersona&&slotPersona.isActive?' · 기본':'' %></span></a><% } %></div>
49 <% if(existingPersona){ %><div class="current"><div class="current-head"><strong><%= existingPersona.personaName||'현재 페르소나' %></strong><span><%= new Date(existingPersona.updatedAt).toLocaleDateString('ko-KR') %> 수정</span></div><div class="current-text"><%= existingPersona.persona %></div></div><% } %>
50 <div class="cost-row"><span class="cost"><i class="bi bi-bookmark-fill me-1"></i><%= existingPersona?personaUpdateCost:personaCost %>개로 <%= existingPersona?'수정':'생성' %></span><span class="balance">보유 <strong><%= bookmarks %>개</strong></span></div>
51 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
52 <div class="field"><label class="field-label" for="personaName">페르소나 이름 <span class="field-note">Discord embed 제목에 표시</span></label><input id="personaName" class="text-input" name="persona_name" maxlength="50" placeholder="예: 이치카와 히나나" value="<%= existingPersona?(existingPersona.personaName||''):'' %>"></div>
53 <div class="field"><label class="field-label" for="personaText"><%= existingPersona?'페르소나 수정':'새 페르소나 작성' %></label><textarea id="personaText" class="text-input" name="persona_text" maxlength="2000" placeholder="AI의 성격, 말투, 역할과 대화 방식을 적어주세요." required><%= existingPersona?existingPersona.persona:'' %></textarea><div class="char-count" id="charCount">0 / 2000</div></div>
54 <div class="form-foot"><div class="hint">성격, 말투, 사용자를 부르는 방식과 피해야 할 표현을 구체적으로 적으면 결과가 안정적입니다.</div><button class="submit" id="submitBtn" <%= ((!existingPersona&&bookmarks<personaCost)||(existingPersona&&bookmarks<personaUpdateCost))?'disabled':'' %>><i class="bi bi-<%= existingPersona?'pencil-fill':'bookmark-fill' %> me-2"></i><%= existingPersona?'페르소나 수정':'페르소나 생성' %></button></div>
55 </form><% } %>
56 </section>
57 <aside class="sidebar"><section class="side-panel"><span class="panel-label">Connection</span><h2><%= discordName||'Discord 미연동' %></h2><p><%= discordId?`ID ${discordId}`:'Discord 봇에서 연결 링크를 열어주세요.' %></p></section><section class="side-panel"><span class="panel-label">Account</span><h2><%= username %></h2><div class="side-links"><a href="/hinana/userInfo"><span><i class="bi bi-person me-1"></i> 계정 정보</span><i class="bi bi-chevron-right"></i></a><a href="/hinana/lounge"><span><i class="bi bi-grid me-1"></i> 라운지</span><i class="bi bi-chevron-right"></i></a><a href="/logout?redirect=/hinana/lounge"><span><i class="bi bi-box-arrow-right me-1"></i> 로그아웃</span><i class="bi bi-chevron-right"></i></a></div></section><section class="side-panel"><span class="panel-label">Usage</span><h2>Discord /히나나</h2><p>선택한 슬롯 번호와 함께 명령어를 실행하면 저장한 페르소나로 답변합니다.</p></section></aside>
58 </div>
59 </main>
60
61 <footer class="site-footer"><div class="footer-brand"><img src="/image/sign.png" alt="비나래" class="footer-logo"><div><div class="footer-title">비나래 라운지</div><div>Archive My Lounge</div></div></div><div><div class="footer-links"><a href="/hinana/index">Archive</a><a href="/hinana/lounge">Lounge</a><a href="/hinana/gallery">Gallery</a><a href="/hinana/plaza">Plaza</a><a href="https://x.com/NoctchillHinana" target="_blank" rel="noreferrer">X</a></div><div class="footer-copy"><span class="footer-version">Ver. 8.0.3.2-Utsumi Aoba</span>&copy; 2024~2026. 비나래 | hinana.moe</div></div></footer>
62
63 <script>
64 const textarea=document.getElementById('personaText'),counter=document.getElementById('charCount');function updateCount(){if(!textarea||!counter)return;const length=textarea.value.replace(/\r\n?/g,'\n').length;counter.textContent=length+' / 2000';counter.className='char-count'+(length>=1800?' warn':'')}if(textarea){textarea.addEventListener('input',updateCount);updateCount()}
65 const form=document.getElementById('personaForm');if(form)form.addEventListener('submit',function(event){const text=textarea?textarea.value.replace(/\r\n?/g,'\n').trim():'';if(text.length>2000){event.preventDefault();showAlert('페르소나는 2000자 이내여야 합니다.')}});
66 const params=new URLSearchParams(location.search),error=params.get('error');if(error){showAlert(error);params.delete('error');history.replaceState(null,'',location.pathname+(params.toString()?'?'+params.toString():''))}if('serviceWorker'in navigator)navigator.serviceWorker.register('/sw.js');
67 </script>
68 </body>
69 </html>
70