Public Source Viewer

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

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

Redacted View
view/hinana/shop.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, user-scalable=no, maximum-scale=1.0">
7 <link rel="manifest" href="/manifest.json">
8 <meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#0f141e' : '#f8f7f5' %>">
9 <meta name="apple-mobile-web-app-title" content="비나래 라운지">
10 <meta property="og:image" content="/image/train_hinana.png" />
11 <meta property="og:description" content="비나래 라운지 | 책갈피 교환소"/>
12 <meta property="og:url" content="hinana.moe/hinana/shop"/>
13 <meta property="og:title" content="비나래 라운지"/>
14 <title>비나래 라운지 | 책갈피 교환소</title>
15
16 <link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
17 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
18 <link href="https://fonts.googleapis.com/css?family=Montserrat:400,700" rel="stylesheet" type="text/css">
19 <link href="https://fonts.googleapis.com/css?family=Lato:300,400,700" rel="stylesheet" type="text/css">
20 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
21 <script src="/js/popup.js"></script>
22
23 <style>
24 :root {
25 --font-family: 'Noto Sans KR', sans-serif;
26 --bg-main: #f8f7f5; --bg-secondary: #ffffff; --bg-tertiary: #1a2238;
27 --text-primary: #1a2238; --text-secondary: #5e6676;
28 --accent-color: #c5a059; --border-color: #e5e1da;
29 --shadow-md: 0 10px 40px -10px rgba(26, 34, 56, 0.12);
30 --shadow-sm: 0 2px 8px rgba(0,0,0,0.05);
31 }
32
33 body.dark-mode {
34 --bg-main: #0f141e; --bg-secondary: #1a2238; --bg-tertiary: #0a0e17;
35 --text-primary: #e7e5e4; --text-secondary: #a8a29e;
36 --accent-color: #d4b47a; --border-color: #2e3a59;
37 }
38
39 html, body {
40 height: auto !important; min-height: 100%; margin: 0; padding: 0;
41 font-family: var(--font-family); background-color: var(--bg-main); color: var(--text-primary);
42 overflow-x: hidden; overflow-y: auto; width: 100%;
43 }
44 a { text-decoration: none; color: inherit; }
45 * { box-sizing: border-box; }
46
47 .global-header {
48 height: 60px; background-color: var(--bg-tertiary); border-bottom: 1px solid var(--border-color);
49 display: flex; align-items: center; justify-content: space-between; padding: 0 40px;
50 position: sticky; top: 0; z-index: 1000; color: white; flex-wrap: wrap;
51 }
52 .header-logo { height: 32px; filter: none !important; -webkit-filter: none !important; mix-blend-mode: normal !important; }
53 .header-brand { display: flex; align-items: center; flex: 0 0 auto; }
54 .header-nav { display: flex; gap: 20px; align-items: center; transition: all 0.3s ease; }
55
56 .layout-container {
57 display: flex; min-height: calc(100vh - 60px);
58 background-color: var(--bg-main);
59 width: 100%; max-width: 100vw; overflow-x: hidden;
60 }
61
62 .content-column {
63 flex: 1; padding: 60px 40px;
64 display: flex; flex-direction: column; align-items: center;
65 width: 100%; min-width: 0;
66 }
67
68 .shop-hero { text-align: center; margin-bottom: 40px; }
69 .info-title { font-size: 2.5rem; font-weight: 700; color: var(--text-primary); margin: 10px 0; letter-spacing: -1px; }
70
71 .shop-card {
72 width: 100%; max-width: 800px; background-color: var(--bg-secondary);
73 padding: 30px; border-radius: 4px; border: 1px solid var(--border-color);
74 border-top: 5px solid var(--accent-color) !important; box-shadow: var(--shadow-md);
75 }
76
77 .bookmark-bar {
78 display: flex; align-items: center; gap: 8px;
79 padding: 14px 18px; background: var(--bg-main); border-radius: 8px;
80 border: 1px solid var(--border-color); margin-bottom: 25px;
81 }
82
83 .shop-item {
84 display: flex; justify-content: space-between; align-items: center;
85 padding: 20px; border: 1px solid var(--border-color); border-radius: 8px;
86 background-color: var(--bg-main); transition: all 0.2s; margin-bottom: 12px;
87 }
88 .shop-item:last-child { margin-bottom: 0; }
89 .shop-item:hover { border-color: var(--accent-color); box-shadow: var(--shadow-sm); }
90 .shop-item-info { display: flex; align-items: center; gap: 15px; }
91 .shop-item-icon { font-size: 2rem; color: #1d9bf0; }
92 .shop-item-name { font-weight: 700; font-size: 1rem; color: var(--text-primary); }
93 .shop-item-desc { font-size: 0.8rem; color: var(--text-secondary); margin-top: 2px; }
94 .shop-item-price { display: flex; align-items: center; gap: 6px; font-weight: 700; color: var(--accent-color); font-size: 0.9rem; }
95 .btn-buy {
96 background-color: var(--accent-color); color: white; border: none;
97 padding: 8px 20px; border-radius: 6px; font-weight: 600; font-size: 0.85rem;
98 cursor: pointer; transition: opacity 0.2s;
99 }
100 .btn-buy:hover { opacity: 0.85; }
101
102 /* 프로필 크롭 모달 */
103 .crop-overlay {
104 display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
105 background: rgba(0,0,0,0.7); z-index: 9999;
106 justify-content: center; align-items: center;
107 }
108 .crop-overlay.active { display: flex; }
109 .crop-modal {
110 background: var(--bg-secondary); border-radius: 12px; padding: 24px;
111 border: 1px solid var(--border-color); max-width: 400px; width: 90%;
112 box-shadow: 0 20px 60px rgba(0,0,0,0.3);
113 }
114 .crop-title {
115 font-weight: 700; font-size: 1rem; margin-bottom: 16px; text-align: center;
116 color: var(--text-primary);
117 }
118 .crop-container {
119 position: relative; width: 280px; height: 280px;
120 margin: 0 auto; overflow: hidden; border-radius: 50%;
121 border: 3px solid var(--accent-color); cursor: grab;
122 background: #000;
123 }
124 .crop-container:active { cursor: grabbing; }
125 .crop-container img {
126 position: absolute; user-select: none; -webkit-user-drag: none;
127 transform-origin: 0 0;
128 }
129 .crop-zoom {
130 display: flex; align-items: center; gap: 10px; margin: 16px auto; width: 280px;
131 }
132 .crop-zoom input[type="range"] {
133 flex: 1; accent-color: var(--accent-color);
134 }
135 .crop-zoom label { font-size: 0.75rem; color: var(--text-secondary); white-space: nowrap; }
136 .crop-actions {
137 display: flex; gap: 10px; justify-content: center; margin-top: 16px;
138 }
139 .crop-actions .btn-cancel {
140 background: transparent; border: 1px solid var(--border-color); color: var(--text-primary);
141 padding: 8px 20px; border-radius: 6px; font-weight: 600; font-size: 0.85rem; cursor: pointer;
142 }
143 .crop-actions .btn-cancel:hover { background: var(--bg-main); }
144
145 .info-column {
146 flex: 0 0 300px; width: 300px;
147 background-color: var(--bg-secondary); border-left: 1px solid var(--border-color);
148 padding: 30px; display: flex; flex-direction: column;
149 }
150 .info-card { background-color: var(--bg-main); border: 1px solid var(--border-color); border-radius: 8px; margin-bottom: 20px; }
151
152 .verified-badge { color: #1d9bf0; font-size: 0.85em; margin-left: 2px; }
153 .verified-badge-admin { color: var(--accent-color); font-size: 0.85em; margin-left: 2px; }
154
155 @media (max-width: 1200px) {
156 .global-header { padding: 10px 20px; }
157 .layout-container { flex-direction: column; align-items: center; }
158 .content-column { width: 100%; padding: 40px 20px; }
159 .info-column { width: 100%; flex: auto; border-left: none; border-top: 1px solid var(--border-color); padding: 40px 20px; }
160 .info-column > div:last-child { margin-bottom: 60px; }
161 }
162 @media (max-width: 991px) {
163 .global-header { height: auto; min-height: 70px; }
164 .header-nav { order: 2; gap: 15px !important; }
165 .header-controls {
166 width: 100%; order: 3; display: flex; justify-content: flex-end;
167 margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1);
168 }
169 .header-brand { order: 1; }
170 .shop-item { flex-direction: column; gap: 15px; align-items: flex-start; }
171 .shop-item > div:last-child { align-self: flex-end; }
172 }
173 </style>
174 <link rel="stylesheet" href="/css/lounge-modern.css">
175 </head>
176
177 <body class="lounge-fixed-nav <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
178 <header class="global-header">
179 <div class="header-brand">
180 <a href="/hinana/lounge">
181 <img src="/image/<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'lounge1.png' : 'lounge.png' %>" alt="Logo" class="header-logo">
182 </a>
183 </div>
184 <nav class="header-nav d-flex gap-4">
185 <a href="/hinana/index" class="nav-link text-white-50 small fw-bold">Archive</a>
186 <a href="/hinana/info" class="nav-link text-white-50 small fw-bold">Info</a>
187 <a href="/hinana/lounge" class="nav-link text-white fw-bold">Lounge</a>
188 </nav>
189 <div class="header-controls" style="display:flex; align-items:center; gap:12px;">
190 <a href="/hinana/gallery#brand-assets" class="text-white-50 small fw-bold" style="text-decoration:none;">사이트 맵</a>
191 <form action="/toggle-theme" method="POST" style="margin:0;">
192 <button type="submit" class="btn text-white p-1"><i class="bi bi-moon-stars"></i></button>
193 </form>
194 </div>
195 </header>
196
197 <div class="layout-container">
198 <div class="content-column">
199 <div class="shop-hero">
200 <span style="color: var(--accent-color); letter-spacing: 5px; font-weight: bold; font-size: 0.8rem;">BOOKMARK EXCHANGE</span>
201 <h2 class="info-title">책갈피 교환소</h2>
202 <div style="width: 60px; height: 1px; background: var(--accent-color); margin: 0 auto;"></div>
203 <p class="text-secondary small mt-3">책갈피를 사용하여 다양한 커스텀 아이템을 구매할 수 있습니다.</p>
204 </div>
205
206 <% if (!username) { %>
207 <div class="shop-card text-center py-5">
208 <i class="bi bi-lock fs-1 mb-3 d-block" style="color: var(--text-secondary); opacity: 0.5;"></i>
209 <p class="text-secondary mb-3">교환소를 이용하려면 로그인이 필요합니다.</p>
210 <a href="/login?redirect=/hinana/shop" class="btn-buy" style="text-decoration: none; display: inline-block;">로그인</a>
211 </div>
212 <% } else { %>
213 <div class="shop-card">
214 <div class="bookmark-bar">
215 <i class="bi bi-bookmark-fill" style="color: var(--accent-color); font-size: 1.2rem;"></i>
216 <span style="font-size: 0.85rem; color: var(--text-secondary);">보유 책갈피:</span>
217 <span id="shop-bookmark-count" style="font-weight: 700; color: var(--accent-color); font-size: 1.1rem;"><%= bookmarks %></span>
218 <span style="font-size: 0.85rem; color: var(--text-secondary);">개</span>
219 </div>
220
221 <div class="shop-item">
222 <div class="shop-item-info">
223 <div class="shop-item-icon"><i class="bi bi-patch-check-fill"></i></div>
224 <div>
225 <div class="shop-item-name">인증마크 <span style="font-weight: 400; font-size: 0.8rem; color: var(--text-secondary);">(3일)</span></div>
226 <div class="shop-item-desc">닉네임 옆에 파란색 인증마크가 표시됩니다.</div>
227 <% if (isUserVerified && verifiedUntil) { %>
228 <div id="badge-status" style="font-size: 0.75rem; color: #16a34a; margin-top: 4px;">
229 <i class="bi bi-check-circle-fill"></i> 현재 활성화됨 · 만료: <span id="badge-expiry"><%= fmtDate(verifiedUntil) %></span> (재구매 시 기간 연장)
230 </div>
231 <% } else if (isUserVerified) { %>
232 <div id="badge-status" style="font-size: 0.75rem; color: #16a34a; margin-top: 4px;">
233 <i class="bi bi-check-circle-fill"></i> 현재 활성화됨 (재구매 시 기간 연장)
234 </div>
235 <% } else { %>
236 <div id="badge-status" style="display: none;"></div>
237 <% } %>
238 </div>
239 </div>
240 <div style="display: flex; flex-direction: column; align-items: flex-end; gap: 8px;">
241 <div class="shop-item-price"><i class="bi bi-bookmark-fill"></i> 15개</div>
242 <button class="btn-buy" onclick="buyBadge()">구매</button>
243 </div>
244 </div>
245
246 <div class="shop-item">
247 <div class="shop-item-info">
248 <div class="shop-item-icon"><i class="bi bi-person-bounding-box" style="color: #16a34a;"></i></div>
249 <div>
250 <div class="shop-item-name">프로필 사진</div>
251 <div class="shop-item-desc">게시글과 프로필에 나만의 사진을 설정합니다.</div>
252 <div style="font-size:0.72rem; color:var(--text-secondary); margin-top:4px;">JPG, PNG, GIF, WEBP · 최대 2MB</div>
253 <% if (typeof currentUserProfileImage !== 'undefined' && currentUserProfileImage) { %>
254 <div id="profile-status" style="font-size: 0.75rem; color: #16a34a; margin-top: 4px;">
255 <i class="bi bi-check-circle-fill"></i> 현재 설정됨
256 <img src="<%= currentUserProfileImage %>" style="width:24px; height:24px; border-radius:50%; object-fit:cover; margin-left:4px; vertical-align:middle; border:1px solid var(--border-color);">
257 </div>
258 <% } %>
259 </div>
260 </div>
261 <div style="display: flex; flex-direction: column; align-items: flex-end; gap: 8px;">
262 <div class="shop-item-price"><i class="bi bi-bookmark-fill"></i> 5개</div>
263 <input type="file" id="profile-file-input" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none;" onchange="openCropModal(this)">
264 <button class="btn-buy" onclick="document.getElementById('profile-file-input').click()">이미지 선택</button>
265 </div>
266 </div>
267 </div>
268 <% } %>
269 </div>
270
271 <div class="info-column">
272 <div class="info-card p-4 mb-4 text-center">
273 <div style="font-size: 0.65rem; color: var(--accent-color); letter-spacing: 3px; font-weight: 700; margin-bottom: 20px;">PASSENGER INFO</div>
274 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
275 <div>
276 <% if(username) { %>
277 <a href="/logout?redirect=/hinana/shop" class="btn btn-outline-dark btn-sm w-100 py-2">SIGN OUT</a>
278 <% } else { %>
279 <a href="/login?redirect=/hinana/shop" class="btn btn-dark btn-sm w-100 py-2">SIGN IN</a>
280 <% } %>
281 </div>
282 </div>
283
284 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
285 <div class="info-card p-4 mb-4 border-warning shadow-sm">
286 <div class="small fw-bold text-warning mb-3"><i class="bi bi-gear-wide-connected me-2"></i> ADMINISTRATION</div>
287 <a href="/hinana/admin" class="d-flex justify-content-between align-items-center text-decoration-none" style="color: inherit;">
288 <span class="small"><i class="bi bi-bookmark-star-fill me-1"></i> 책갈피 관리</span>
289 <i class="bi bi-chevron-right text-secondary"></i>
290 </a>
291 </div>
292 <% } %>
293
294 <div class="info-card p-4 mb-4">
295 <div style="font-size: 0.65rem; color: var(--accent-color); letter-spacing: 3px; font-weight: 700; margin-bottom: 15px;">SYSTEM INFO</div>
296 <ul class="small text-secondary list-unstyled mb-0">
297 <li class="mb-1 d-flex justify-content-between">
298 <span>Version</span>
299 <span class="text-end">Ver. 8.0.3.2-Utsumi Aoba</span>
300 </li>
301 </ul>
302 </div>
303
304 </div>
305 </div>
306
307 <!-- 프로필 크롭 모달 -->
308 <footer class="site-footer">
309 <div class="footer-brand">
310 <img src="/image/sign.png" alt="비나래" class="footer-logo">
311 <div>
312 <div class="footer-title">비나래 라운지</div>
313 <div>Archive My Lounge</div>
314 </div>
315 </div>
316 <div>
317 <div class="footer-links">
318 <a href="/hinana/index">Archive</a>
319 <a href="/hinana/lounge">Lounge</a>
320 <a href="/hinana/gallery">Gallery</a>
321 <a href="/hinana/plaza">Plaza</a>
322 <a href="https://x.com/NoctchillHinana" target="_blank" rel="noreferrer">X</a>
323 </div>
324 <div class="footer-copy"><span class="footer-version">Ver. 8.0.3.2-Utsumi Aoba</span>ⓒ 2024~2026. 비나래 | hinana.moe</div>
325 </div>
326 </footer>
327
328 <div class="crop-overlay" id="crop-overlay">
329 <div class="crop-modal">
330 <div class="crop-title">프로필 사진 영역 선택</div>
331 <div class="crop-container" id="crop-container">
332 <img id="crop-image" src="" alt="">
333 </div>
334 <div class="crop-zoom">
335 <label><i class="bi bi-zoom-out"></i></label>
336 <input type="range" id="crop-zoom" min="0.1" max="3" step="0.01" value="1">
337 <label><i class="bi bi-zoom-in"></i></label>
338 </div>
339 <div class="crop-actions">
340 <button class="btn-cancel" onclick="closeCropModal()">취소</button>
341 <button class="btn-buy" onclick="confirmCrop()">이 영역으로 구매</button>
342 </div>
343 </div>
344 </div>
345 <canvas id="crop-canvas" style="display:none;"></canvas>
346
347 <script>
348 async function buyBadge() {
349 if (!await showConfirm('인증마크를 구매하시겠습니까? (책갈피 15개 차감)')) return;
350 try {
351 const res = await fetch('/hinana/shop/buy-badge', { method: 'POST', headers: { 'Content-Type': 'application/json' } });
352 const data = await res.json();
353 if (data.success && data.verifiedUntil) {
354 const expiry = new Date(data.verifiedUntil);
355 const Y = expiry.getFullYear(), M = String(expiry.getMonth()+1).padStart(2,'0'), D = String(expiry.getDate()).padStart(2,'0'), h = String(expiry.getHours()).padStart(2,'0'), mi = String(expiry.getMinutes()).padStart(2,'0'), sc = String(expiry.getSeconds()).padStart(2,'0');
356 const formatted = Y+'/'+M+'/'+D+', '+h+':'+mi+':'+sc;
357 await showAlert(data.message + '\n만료일: ' + formatted);
358 document.getElementById('shop-bookmark-count').textContent = data.remaining;
359 const statusEl = document.getElementById('badge-status');
360 statusEl.style.display = '';
361 statusEl.style.color = '#16a34a';
362 statusEl.style.fontSize = '0.75rem';
363 statusEl.style.marginTop = '4px';
364 statusEl.innerHTML = '<i class="bi bi-check-circle-fill"></i> 현재 활성화됨 · 만료: <span id="badge-expiry">' + formatted + '</span> (재구매 시 기간 연장)';
365 } else {
366 await showAlert(data.message);
367 }
368 } catch (e) {
369 await showAlert('오류가 발생했습니다.');
370 }
371 }
372 // 크롭 모달 관련 변수
373 let cropImg = null, cropX = 0, cropY = 0, cropScale = 1;
374 let isDragging = false, dragStartX = 0, dragStartY = 0, dragStartCropX = 0, dragStartCropY = 0;
375 let naturalW = 0, naturalH = 0;
376 const CROP_SIZE = 280;
377
378 function openCropModal(input) {
379 if (!input.files || !input.files[0]) return;
380 const file = input.files[0];
381 const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
382 if (!allowedTypes.includes(file.type)) {
383 showAlert('JPG, PNG, GIF 또는 WEBP 이미지만 선택할 수 있습니다.');
384 input.value = '';
385 return;
386 }
387 if (file.size > 2 * 1024 * 1024) {
388 showAlert('프로필 이미지는 최대 2MB까지 선택할 수 있습니다.');
389 input.value = '';
390 return;
391 }
392 const reader = new FileReader();
393 reader.onload = function(e) {
394 const img = document.getElementById('crop-image');
395 img.onload = function() {
396 naturalW = img.naturalWidth;
397 naturalH = img.naturalHeight;
398 // 초기 스케일: 이미지가 원형 영역을 꽉 채우도록
399 const minDim = Math.min(naturalW, naturalH);
400 cropScale = CROP_SIZE / minDim;
401 document.getElementById('crop-zoom').min = (CROP_SIZE / Math.max(naturalW, naturalH)).toFixed(3);
402 document.getElementById('crop-zoom').max = (CROP_SIZE / Math.min(naturalW, naturalH) * 3).toFixed(3);
403 document.getElementById('crop-zoom').value = cropScale;
404 // 이미지를 중앙에 위치
405 cropX = (CROP_SIZE - naturalW * cropScale) / 2;
406 cropY = (CROP_SIZE - naturalH * cropScale) / 2;
407 updateCropPosition();
408 document.getElementById('crop-overlay').classList.add('active');
409 };
410 img.src = e.target.result;
411 };
412 reader.readAsDataURL(file);
413 }
414
415 function updateCropPosition() {
416 const img = document.getElementById('crop-image');
417 img.style.left = cropX + 'px';
418 img.style.top = cropY + 'px';
419 img.style.width = (naturalW * cropScale) + 'px';
420 img.style.height = (naturalH * cropScale) + 'px';
421 }
422
423 function closeCropModal() {
424 document.getElementById('crop-overlay').classList.remove('active');
425 document.getElementById('profile-file-input').value = '';
426 }
427
428 // 드래그
429 (function() {
430 const container = document.getElementById('crop-container');
431 function startDrag(x, y) {
432 isDragging = true;
433 dragStartX = x; dragStartY = y;
434 dragStartCropX = cropX; dragStartCropY = cropY;
435 }
436 function moveDrag(x, y) {
437 if (!isDragging) return;
438 cropX = dragStartCropX + (x - dragStartX);
439 cropY = dragStartCropY + (y - dragStartY);
440 updateCropPosition();
441 }
442 function endDrag() { isDragging = false; }
443
444 container.addEventListener('mousedown', e => { e.preventDefault(); startDrag(e.clientX, e.clientY); });
445 window.addEventListener('mousemove', e => moveDrag(e.clientX, e.clientY));
446 window.addEventListener('mouseup', endDrag);
447 container.addEventListener('touchstart', e => { e.preventDefault(); const t = e.touches[0]; startDrag(t.clientX, t.clientY); }, { passive: false });
448 window.addEventListener('touchmove', e => { const t = e.touches[0]; moveDrag(t.clientX, t.clientY); });
449 window.addEventListener('touchend', endDrag);
450
451 // 줌 슬라이더
452 document.getElementById('crop-zoom').addEventListener('input', function() {
453 const newScale = parseFloat(this.value);
454 // 줌 중심을 원형 영역 중심으로
455 const cx = CROP_SIZE / 2, cy = CROP_SIZE / 2;
456 cropX = cx - (cx - cropX) / cropScale * newScale;
457 cropY = cy - (cy - cropY) / cropScale * newScale;
458 cropScale = newScale;
459 updateCropPosition();
460 });
461
462 // 마우스 휠 줌
463 container.addEventListener('wheel', function(e) {
464 e.preventDefault();
465 const slider = document.getElementById('crop-zoom');
466 const delta = e.deltaY > 0 ? -0.02 : 0.02;
467 const newScale = Math.max(parseFloat(slider.min), Math.min(parseFloat(slider.max), cropScale + delta));
468 const cx = CROP_SIZE / 2, cy = CROP_SIZE / 2;
469 cropX = cx - (cx - cropX) / cropScale * newScale;
470 cropY = cy - (cy - cropY) / cropScale * newScale;
471 cropScale = newScale;
472 slider.value = cropScale;
473 updateCropPosition();
474 }, { passive: false });
475
476 // ESC로 닫기
477 window.addEventListener('keydown', e => {
478 if (e.key === 'Escape' && document.getElementById('crop-overlay').classList.contains('active')) closeCropModal();
479 });
480 })();
481
482 async function confirmCrop() {
483 if (!await showConfirm('프로필 사진을 구매하시겠습니까? (책갈피 5개 차감)')) return;
484
485 // 캔버스에 크롭 결과 그리기
486 const canvas = document.getElementById('crop-canvas');
487 const OUTPUT_SIZE = 256;
488 canvas.width = OUTPUT_SIZE;
489 canvas.height = OUTPUT_SIZE;
490 const ctx = canvas.getContext('2d');
491
492 const img = document.getElementById('crop-image');
493 // 원형 영역(CROP_SIZE x CROP_SIZE) 기준 → OUTPUT_SIZE로 매핑
494 const ratio = OUTPUT_SIZE / CROP_SIZE;
495 const sx = -cropX / cropScale;
496 const sy = -cropY / cropScale;
497 const sw = CROP_SIZE / cropScale;
498 const sh = CROP_SIZE / cropScale;
499
500 ctx.clearRect(0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
501 ctx.drawImage(img, sx, sy, sw, sh, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
502
503 canvas.toBlob(async function(blob) {
504 if (!blob) { await showAlert('이미지 처리 중 오류가 발생했습니다.'); return; }
505 const formData = new FormData();
506 formData.append('profileImage', blob, 'profile.png');
507 try {
508 const res = await fetch('/hinana/shop/buy-profile-pic', { method: 'POST', body: formData });
509 const data = await res.json();
510 if (data.success) {
511 document.getElementById('shop-bookmark-count').textContent = data.remaining;
512 await showAlert(data.message);
513 location.reload();
514 } else {
515 await showAlert(data.message);
516 }
517 } catch (e) {
518 await showAlert('오류가 발생했습니다.');
519 }
520 }, 'image/png');
521 }
522 </script>
523 <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); }</script>
524 </body>
525 </html>
526