Public Source Viewer

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

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

Redacted View
view/hinana/write.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 <meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#000000' : '#ffffff' %>">
8 <meta name="apple-mobile-web-app-title" content="비나래 아카이브 도서관">
9 <meta property="og:image" content="/image/2.png" />
10 <meta property="og:description" content="비나래 아카이브 도서관"/>
11 <meta property="og:url" content="https://hinana.moe/hinana/blog"/>
12 <meta property="og:title" content="비나래 아카이브 도서관"/>
13 <link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
14 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
15 <link href="https://fonts.googleapis.com/css?family=Montserrat:400,700" rel="stylesheet" type="text/css">
16 <link href="https://fonts.googleapis.com/css?family=Lato:300,400,700" rel="stylesheet" type="text/css">
17 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
18 <link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
19 <script src="https://cdn.quilljs.com/1.3.7/quill.min.js"></script>
20 <title>비나래 아카이브 도서관</title>
21
22 <style>
23 :root {
24 --font-family: 'Noto Sans KR', sans-serif;
25 --bg-main: #ffffff; --bg-secondary: #f7f9f9; --bg-tertiary: #eff3f4;
26 --text-primary: #0f1419; --text-secondary: #536471;
27 --accent-color: #1d9bf0; --danger-color: #f4212e; --border-color: #eff3f4;
28 --shadow-sm: 0 1px 3px rgba(0,0,0,0.08); --shadow-md: 0 4px 12px rgba(0,0,0,0.10);
29 }
30 body.dark-mode {
31 --bg-main: #000000; --bg-secondary: #16181c; --bg-tertiary: #202327;
32 --text-primary: #e7e9ea; --text-secondary: #71767b;
33 --accent-color: #1d9bf0; --danger-color: #f4212e; --border-color: #2f3336;
34 --shadow-sm: 0 1px 3px rgba(255,255,255,0.04); --shadow-md: 0 4px 12px rgba(0,0,0,0.6);
35 }
36
37 html, body { margin: 0; min-height: 100vh; font-family: var(--font-family); background-color: var(--bg-main); color: var(--text-primary); }
38 a { text-decoration: none; color: inherit; }
39
40 .global-header {
41 height: 60px;
42 background-color: rgba(255,255,255,0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
43 border-bottom: 1px solid var(--border-color);
44 display: flex; align-items: center; justify-content: space-between; padding: 0 20px;
45 position: sticky; top: 0; z-index: 1000;
46 }
47 body.dark-mode .global-header { background-color: rgba(0,0,0,0.85); }
48 .header-brand { display: flex; align-items: center; }
49 .header-logo { height: 28px; width: auto; }
50 body:not(.dark-mode) .logo-night { display: none; }
51 body.dark-mode .logo-day { display: none; }
52
53 .header-nav {
54 position: absolute; left: 50%; transform: translateX(-50%);
55 display: flex; gap: 20px; align-items: center;
56 }
57 .nav-link { font-weight: 600; font-size: 0.95rem; color: var(--text-secondary); cursor: pointer; transition: color 0.2s; }
58 .nav-link:hover { color: var(--accent-color); }
59 .nav-link.active { color: var(--text-primary); }
60 .nav-divider { opacity: 0.3; color: var(--text-secondary); }
61
62 .header-controls { display: flex; align-items: center; gap: 10px; z-index: 10; position: relative; }
63
64 /* Quill 에디터 테마 */
65 .ql-toolbar { border-color: var(--border-color) !important; background: var(--bg-tertiary); border-radius: 8px 8px 0 0; }
66 .ql-container { border-color: var(--border-color) !important; background: var(--bg-main); border-radius: 0 0 8px 8px; font-size: 1rem; font-family: var(--font-family); min-height: 400px; }
67 .ql-editor { min-height: 400px; color: var(--text-primary); }
68 .ql-editor.ql-blank::before { color: var(--text-secondary); }
69 body.dark-mode .ql-toolbar .ql-stroke { stroke: var(--text-secondary); }
70 body.dark-mode .ql-toolbar .ql-fill { fill: var(--text-secondary); }
71 body.dark-mode .ql-toolbar .ql-picker { color: var(--text-secondary); }
72 body.dark-mode .ql-toolbar button:hover .ql-stroke,
73 body.dark-mode .ql-toolbar .ql-active .ql-stroke { stroke: var(--accent-color); }
74 body.dark-mode .ql-toolbar button:hover .ql-fill,
75 body.dark-mode .ql-toolbar .ql-active .ql-fill { fill: var(--accent-color); }
76 body.dark-mode .ql-picker-options { background: var(--bg-secondary); border-color: var(--border-color); }
77 body.dark-mode .ql-picker-item { color: var(--text-primary); }
78 .icon-btn { border: none; background: transparent; color: var(--text-secondary); font-size: 1.1rem; cursor: pointer; padding: 4px; transition: color 0.2s; }
79 .icon-btn:hover { color: var(--text-primary); }
80
81 .content-area {
82 max-width: 900px; margin: 40px auto; padding: 0 20px;
83 }
84
85 .write-card {
86 background-color: var(--bg-secondary); border: 1px solid var(--border-color);
87 border-radius: 12px; box-shadow: var(--shadow-sm); padding: 40px;
88 }
89 .write-card h2 { font-size: 1.5rem; font-weight: 700; color: var(--accent-color); margin-bottom: 30px; }
90
91 .form-control {
92 background-color: var(--bg-main); border: 1px solid var(--border-color);
93 color: var(--text-primary); border-radius: 8px;
94 }
95 .form-control:focus {
96 border-color: var(--accent-color); box-shadow: 0 0 0 2px rgba(29, 155, 240, 0.15);
97 }
98 .form-label { color: var(--text-secondary); font-weight: 600; font-size: 0.9rem; }
99 .tag-options { display: flex; flex-wrap: wrap; gap: 8px; }
100 .tag-chip-check { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border-color); border-radius: 999px; padding: 7px 12px; background: var(--bg-main); color: var(--text-primary); cursor: pointer; font-weight: 600; }
101 .tag-chip-check input { accent-color: var(--accent-color); }
102 .tag-delete-btn { border: none; background: transparent; color: var(--text-secondary); padding: 0 0 0 2px; line-height: 1; cursor: pointer; }
103 .tag-delete-btn:hover { color: #dc3545; }
104 .tag-create-row { display: flex; gap: 8px; margin-top: 10px; }
105 .tag-create-row .form-control { max-width: 220px; }
106
107 .btn-write {
108 background-color: var(--accent-color); color: white; border: none;
109 padding: 10px 30px; border-radius: 8px; font-weight: 600; cursor: pointer; transition: opacity 0.2s;
110 }
111 .btn-write:hover { opacity: 0.85; color: white; }
112
113 .btn-home {
114 border: 1px solid var(--border-color); background: transparent;
115 color: var(--text-secondary); padding: 10px 30px; border-radius: 8px; cursor: pointer; transition: all 0.2s;
116 }
117 .btn-home:hover { border-color: var(--accent-color); color: var(--accent-color); }
118
119 .footer-area {
120 text-align: center; padding: 40px 20px; color: var(--text-secondary); font-size: 0.75rem; line-height: 1.8;
121 }
122 .footer-area img { width: 160px; opacity: 0.7; mix-blend-mode: multiply; }
123 body.dark-mode .footer-area img { mix-blend-mode: screen; }
124
125 @media (max-width: 768px) {
126 .header-nav { position: static; transform: none; gap: 12px; }
127 .global-header { flex-wrap: wrap; height: auto; padding: 10px 15px; gap: 8px; }
128 .write-card { padding: 25px; }
129 }
130 </style>
131 </head>
132
133 <body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
134 <header class="global-header">
135 <div class="header-brand">
136 <a href="/hinana/index">
137 <img src="/image/archive.png" alt="Hinana Archive" class="header-logo logo-day">
138 <img src="/image/archive1.png" alt="Hinana Archive" class="header-logo logo-night">
139 </a>
140 </div>
141 <nav class="header-nav">
142 <a href="/hinana/index" class="nav-link">Archive</a>
143 <a href="/hinana/info" class="nav-link">Info</a>
144 <a href="/hinana/blog" class="nav-link active">Blog</a>
145 <a href="/hinana/lounge" class="nav-link">Lounge</a>
146 <span class="nav-divider">|</span>
147 <% if (username) { %>
148 <a href="/logout?redirect=/hinana/blog" class="nav-link text-danger fw-bold">Logout</a>
149 <% } else { %>
150 <a href="/login?redirect=/hinana/write" class="nav-link fw-bold" style="color: var(--accent-color);">Login</a>
151 <% } %>
152 </nav>
153 <div class="header-controls">
154 <a href="/hinana/gallery#brand-assets" class="nav-link" style="font-size:0.9rem;">사이트 맵</a>
155 <form action="/toggle-theme" method="POST" class="d-inline">
156 <button type="submit" class="icon-btn" title="테마 변경">
157 <i class="bi <%= (typeof theme !== 'undefined' && theme==='dark') ? 'bi-moon-stars-fill':'bi-sun-fill' %>"></i>
158 </button>
159 </form>
160 </div>
161 </header>
162
163 <div class="content-area">
164 <div class="write-card">
165 <h2>글 작성</h2>
166 <form action="/hinana/post" method="POST" enctype="multipart/form-data">
167 <div class="mb-3">
168 <label for="title" class="form-label">제목</label>
169 <input type="text" class="form-control" id="title" name="title" required>
170 </div>
171 <div class="mb-3">
172 <label class="form-label">태그</label>
173 <div class="tag-options">
174 <% if (typeof availableTags !== 'undefined' && availableTags.length) { %>
175 <% availableTags.forEach(function(tag) { %>
176 <label class="tag-chip-check">
177 <input type="checkbox" name="tags" value="<%= tag %>">
178 <span>#<%= tag %></span>
179 <button type="button" data-tag="<%= tag %>" class="tag-delete-btn" title="태그 삭제" onclick="deleteBlogTagFromButton(event, this)">×</button>
180 </label>
181 <% }) %>
182 <% } else { %>
183 <span class="text-secondary small">생성된 태그가 없습니다.</span>
184 <% } %>
185 </div>
186 <div class="tag-create-row">
187 <input type="text" name="tagName" form="blog-tag-form" class="form-control" maxlength="20" placeholder="새 태그">
188 <button type="submit" form="blog-tag-form" class="btn-home">태그 추가</button>
189 </div>
190 </div>
191 <div class="mb-3">
192 <label class="form-label">내용</label>
193 <div id="quill-editor"></div>
194 <input type="hidden" id="content" name="content">
195 </div>
196 <div class="d-flex gap-2">
197 <button type="submit" class="btn-write">작성하기</button>
198 <a href="/hinana/blog" class="btn-home">돌아가기</a>
199 </div>
200 </form>
201 <form id="blog-tag-form" action="/hinana/blog/tags" method="POST">
202 <input type="hidden" name="redirect" value="/hinana/write">
203 </form>
204 <form id="blog-tag-delete-form" action="/hinana/blog/tags/delete" method="POST">
205 <input type="hidden" name="redirect" value="/hinana/write">
206 <input type="hidden" name="tagName" value="">
207 </form>
208 </div>
209 </div>
210
211 <div class="footer-area">
212 <img src="/image/sign.png" alt="sign"><br>
213 <strong>비나래 ARCHIVE</strong><br>
214 X - @NoctchillHinana<br>
215 &copy; 2024~2026. 비나래 | hinana.moe
216 </div>
217
218 <script>
219 const quill = new Quill('#quill-editor', {
220 theme: 'snow',
221 placeholder: '내용을 입력하세요...',
222 modules: {
223 toolbar: {
224 container: [
225 [{ header: [1, 2, 3, false] }],
226 ['bold', 'italic', 'underline'],
227 [{ list: 'ordered' }, { list: 'bullet' }],
228 ['link', 'image'],
229 ['clean']
230 ],
231 handlers: { image: quillImageHandler }
232 }
233 }
234 });
235
236 function quillImageHandler() {
237 const input = document.createElement('input');
238 input.setAttribute('type', 'file');
239 input.setAttribute('accept', 'image/*');
240 input.click();
241 input.onchange = function () {
242 const file = input.files[0];
243 if (!file) return;
244 const fd = new FormData();
245 fd.append('image', file);
246 fetch('/upload/image', { method: 'POST', body: fd })
247 .then(r => r.json())
248 .then(data => {
249 const range = quill.getSelection();
250 quill.insertEmbed(range ? range.index : 0, 'image', data.location);
251 })
252 .catch(() => alert('이미지 업로드에 실패했어요.'));
253 };
254 }
255
256 document.querySelector('form[action="/hinana/post"]').addEventListener('submit', function () {
257 document.getElementById('content').value = quill.root.innerHTML;
258 });
259
260 function deleteBlogTagFromButton(event, button) {
261 event.preventDefault();
262 event.stopPropagation();
263
264 if (!confirm('태그를 삭제할까요?')) {
265 return;
266 }
267
268 const form = document.getElementById('blog-tag-delete-form');
269 form.querySelector('input[name="tagName"]').value = button.dataset.tag || '';
270 form.submit();
271 }
272 </script>
273 </body>
274 </html>
275