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 </head>
175
176 <body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
177 <header class="global-header">
178 <div class="header-brand">
179 <a href="/hinana/lounge">
180 <img src="/image/lounge1.png" alt="Logo" class="header-logo">
181 </a>
182 </div>
183 <nav class="header-nav d-flex gap-4">
184 <a href="/hinana/index" class="nav-link text-white-50 small fw-bold">Archive</a>
185 <a href="/hinana/info" class="nav-link text-white-50 small fw-bold">Info</a>
186 <a href="/hinana/lounge" class="nav-link text-white fw-bold">Lounge</a>
187 </nav>
188 <div class="header-controls" style="display:flex; align-items:center; gap:12px;">
189 <a href="/hinana/gallery#brand-assets" class="text-white-50 small fw-bold" style="text-decoration:none;">사이트 맵</a>
190 <form action="/toggle-theme" method="POST" style="margin:0;">
191 <button type="submit" class="btn text-white p-1"><i class="bi bi-moon-stars"></i></button>
192 </form>
193 </div>
194 </header>
195
196 <div class="layout-container">
197 <div class="content-column">
198 <div class="shop-hero">
199 <span style="color: var(--accent-color); letter-spacing: 5px; font-weight: bold; font-size: 0.8rem;">BOOKMARK EXCHANGE</span>
200 <h2 class="info-title">책갈피 교환소</h2>
201 <div style="width: 60px; height: 1px; background: var(--accent-color); margin: 0 auto;"></div>
202 <p class="text-secondary small mt-3">책갈피를 사용하여 다양한 커스텀 아이템을 구매할 수 있습니다.</p>
203 </div>
204
205 <% if (!username) { %>
206 <div class="shop-card text-center py-5">
207 <i class="bi bi-lock fs-1 mb-3 d-block" style="color: var(--text-secondary); opacity: 0.5;"></i>
208 <p class="text-secondary mb-3">교환소를 이용하려면 로그인이 필요합니다.</p>
209 <a href="/login?redirect=/hinana/shop" class="btn-buy" style="text-decoration: none; display: inline-block;">로그인</a>
210 </div>
211 <% } else { %>
212 <div class="shop-card">
213 <div class="bookmark-bar">
214 <i class="bi bi-bookmark-fill" style="color: var(--accent-color); font-size: 1.2rem;"></i>
215 <span style="font-size: 0.85rem; color: var(--text-secondary);">보유 책갈피:</span>
216 <span id="shop-bookmark-count" style="font-weight: 700; color: var(--accent-color); font-size: 1.1rem;"><%= bookmarks %></span>
217 <span style="font-size: 0.85rem; color: var(--text-secondary);">개</span>
218 </div>
219
220 <div class="shop-item">
221 <div class="shop-item-info">
222 <div class="shop-item-icon"><i class="bi bi-patch-check-fill"></i></div>
223 <div>
224 <div class="shop-item-name">인증마크 <span style="font-weight: 400; font-size: 0.8rem; color: var(--text-secondary);">(3일)</span></div>
225 <div class="shop-item-desc">닉네임 옆에 파란색 인증마크가 표시됩니다.</div>
226 <% if (isUserVerified && verifiedUntil) { %>
227 <div id="badge-status" style="font-size: 0.75rem; color: #16a34a; margin-top: 4px;">
228 <i class="bi bi-check-circle-fill"></i> 현재 활성화됨 · 만료: <span id="badge-expiry"><%= fmtDate(verifiedUntil) %></span> (재구매 시 기간 연장)
229 </div>
230 <% } else if (isUserVerified) { %>
231 <div id="badge-status" style="font-size: 0.75rem; color: #16a34a; margin-top: 4px;">
232 <i class="bi bi-check-circle-fill"></i> 현재 활성화됨 (재구매 시 기간 연장)
233 </div>
234 <% } else { %>
235 <div id="badge-status" style="display: none;"></div>
236 <% } %>
237 </div>
238 </div>
239 <div style="display: flex; flex-direction: column; align-items: flex-end; gap: 8px;">
240 <div class="shop-item-price"><i class="bi bi-bookmark-fill"></i> 15개</div>
241 <button class="btn-buy" onclick="buyBadge()">구매</button>
242 </div>
243 </div>
244
245 <div class="shop-item">
246 <div class="shop-item-info">
247 <div class="shop-item-icon"><i class="bi bi-person-bounding-box" style="color: #16a34a;"></i></div>
248 <div>
249 <div class="shop-item-name">프로필 사진</div>
250 <div class="shop-item-desc">게시글과 프로필에 나만의 사진을 설정합니다.</div>
251 <% if (typeof currentUserProfileImage !== 'undefined' && currentUserProfileImage) { %>
252 <div id="profile-status" style="font-size: 0.75rem; color: #16a34a; margin-top: 4px;">
253 <i class="bi bi-check-circle-fill"></i> 현재 설정됨
254 <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);">
255 </div>
256 <% } %>
257 </div>
258 </div>
259 <div style="display: flex; flex-direction: column; align-items: flex-end; gap: 8px;">
260 <div class="shop-item-price"><i class="bi bi-bookmark-fill"></i> 5개</div>
261 <input type="file" id="profile-file-input" accept="image/*" style="display:none;" onchange="openCropModal(this)">
262 <button class="btn-buy" onclick="document.getElementById('profile-file-input').click()">이미지 선택</button>
263 </div>
264 </div>
265 </div>
266 <% } %>
267 </div>
268
269 <div class="info-column">
270 <div class="info-card p-4 mb-4 text-center">
271 <div style="font-size: 0.65rem; color: var(--accent-color); letter-spacing: 3px; font-weight: 700; margin-bottom: 20px;">PASSENGER INFO</div>
272 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
273 <div>
274 <% if(username) { %>
275 <a href="/logout?redirect=/hinana/shop" class="btn btn-outline-dark btn-sm w-100 py-2">SIGN OUT</a>
276 <% } else { %>
277 <a href="/login?redirect=/hinana/shop" class="btn btn-dark btn-sm w-100 py-2">SIGN IN</a>
278 <% } %>
279 </div>
280 </div>
281
282 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
283 <div class="info-card p-4 mb-4 border-warning shadow-sm">
284 <div class="small fw-bold text-warning mb-3"><i class="bi bi-gear-wide-connected me-2"></i> ADMINISTRATION</div>
285 <a href="/hinana/admin" class="d-flex justify-content-between align-items-center text-decoration-none" style="color: inherit;">
286 <span class="small"><i class="bi bi-bookmark-star-fill me-1"></i> 책갈피 관리</span>
287 <i class="bi bi-chevron-right text-secondary"></i>
288 </a>
289 </div>
290 <% } %>
291
292 <div class="info-card p-4 mb-4">
293 <div style="font-size: 0.65rem; color: var(--accent-color); letter-spacing: 3px; font-weight: 700; margin-bottom: 15px;">SYSTEM INFO</div>
294 <ul class="small text-secondary list-unstyled mb-0">
295 <li class="mb-1 d-flex justify-content-between">
296 <span>Version</span>
297 <span class="text-end">Ver. 6.5.4.0-Kozeki Ui</span>
298 </li>
299 </ul>
300 </div>
301
302 <div class="mt-auto text-center pt-5">
303 <img src="/image/sign.png" style="width: 160px; opacity: 0.7; mix-blend-mode: multiply;">
304 <div class="mt-4 pt-4 border-top" style="font-size: 0.7rem; color: var(--text-secondary); line-height: 1.8;">
305 <strong>비나래 라운지</strong><br>
306 X - @NoctchillHinana<br>
307 &copy; 2024~2026. 비나래 | hinana.moe
308 </div>
309 </div>
310 </div>
311 </div>
312
313 <!-- 프로필 크롭 모달 -->
314 <div class="crop-overlay" id="crop-overlay">
315 <div class="crop-modal">
316 <div class="crop-title">프로필 사진 영역 선택</div>
317 <div class="crop-container" id="crop-container">
318 <img id="crop-image" src="" alt="">
319 </div>
320 <div class="crop-zoom">
321 <label><i class="bi bi-zoom-out"></i></label>
322 <input type="range" id="crop-zoom" min="0.1" max="3" step="0.01" value="1">
323 <label><i class="bi bi-zoom-in"></i></label>
324 </div>
325 <div class="crop-actions">
326 <button class="btn-cancel" onclick="closeCropModal()">취소</button>
327 <button class="btn-buy" onclick="confirmCrop()">이 영역으로 구매</button>
328 </div>
329 </div>
330 </div>
331 <canvas id="crop-canvas" style="display:none;"></canvas>
332
333 <script>
334 async function buyBadge() {
335 if (!await showConfirm('인증마크를 구매하시겠습니까? (책갈피 15개 차감)')) return;
336 try {
337 const res = await fetch('/hinana/shop/buy-badge', { method: 'POST', headers: { 'Content-Type': 'application/json' } });
338 const data = await res.json();
339 if (data.success && data.verifiedUntil) {
340 const expiry = new Date(data.verifiedUntil);
341 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');
342 const formatted = Y+'/'+M+'/'+D+', '+h+':'+mi+':'+sc;
343 await showAlert(data.message + '\n만료일: ' + formatted);
344 document.getElementById('shop-bookmark-count').textContent = data.remaining;
345 const statusEl = document.getElementById('badge-status');
346 statusEl.style.display = '';
347 statusEl.style.color = '#16a34a';
348 statusEl.style.fontSize = '0.75rem';
349 statusEl.style.marginTop = '4px';
350 statusEl.innerHTML = '<i class="bi bi-check-circle-fill"></i> 현재 활성화됨 · 만료: <span id="badge-expiry">' + formatted + '</span> (재구매 시 기간 연장)';
351 } else {
352 await showAlert(data.message);
353 }
354 } catch (e) {
355 await showAlert('오류가 발생했습니다.');
356 }
357 }
358 // 크롭 모달 관련 변수
359 let cropImg = null, cropX = 0, cropY = 0, cropScale = 1;
360 let isDragging = false, dragStartX = 0, dragStartY = 0, dragStartCropX = 0, dragStartCropY = 0;
361 let naturalW = 0, naturalH = 0;
362 const CROP_SIZE = 280;
363
364 function openCropModal(input) {
365 if (!input.files || !input.files[0]) return;
366 const reader = new FileReader();
367 reader.onload = function(e) {
368 const img = document.getElementById('crop-image');
369 img.onload = function() {
370 naturalW = img.naturalWidth;
371 naturalH = img.naturalHeight;
372 // 초기 스케일: 이미지가 원형 영역을 꽉 채우도록
373 const minDim = Math.min(naturalW, naturalH);
374 cropScale = CROP_SIZE / minDim;
375 document.getElementById('crop-zoom').min = (CROP_SIZE / Math.max(naturalW, naturalH)).toFixed(3);
376 document.getElementById('crop-zoom').max = (CROP_SIZE / Math.min(naturalW, naturalH) * 3).toFixed(3);
377 document.getElementById('crop-zoom').value = cropScale;
378 // 이미지를 중앙에 위치
379 cropX = (CROP_SIZE - naturalW * cropScale) / 2;
380 cropY = (CROP_SIZE - naturalH * cropScale) / 2;
381 updateCropPosition();
382 document.getElementById('crop-overlay').classList.add('active');
383 };
384 img.src = e.target.result;
385 };
386 reader.readAsDataURL(input.files[0]);
387 }
388
389 function updateCropPosition() {
390 const img = document.getElementById('crop-image');
391 img.style.left = cropX + 'px';
392 img.style.top = cropY + 'px';
393 img.style.width = (naturalW * cropScale) + 'px';
394 img.style.height = (naturalH * cropScale) + 'px';
395 }
396
397 function closeCropModal() {
398 document.getElementById('crop-overlay').classList.remove('active');
399 document.getElementById('profile-file-input').value = '';
400 }
401
402 // 드래그
403 (function() {
404 const container = document.getElementById('crop-container');
405 function startDrag(x, y) {
406 isDragging = true;
407 dragStartX = x; dragStartY = y;
408 dragStartCropX = cropX; dragStartCropY = cropY;
409 }
410 function moveDrag(x, y) {
411 if (!isDragging) return;
412 cropX = dragStartCropX + (x - dragStartX);
413 cropY = dragStartCropY + (y - dragStartY);
414 updateCropPosition();
415 }
416 function endDrag() { isDragging = false; }
417
418 container.addEventListener('mousedown', e => { e.preventDefault(); startDrag(e.clientX, e.clientY); });
419 window.addEventListener('mousemove', e => moveDrag(e.clientX, e.clientY));
420 window.addEventListener('mouseup', endDrag);
421 container.addEventListener('touchstart', e => { e.preventDefault(); const t = e.touches[0]; startDrag(t.clientX, t.clientY); }, { passive: false });
422 window.addEventListener('touchmove', e => { const t = e.touches[0]; moveDrag(t.clientX, t.clientY); });
423 window.addEventListener('touchend', endDrag);
424
425 // 줌 슬라이더
426 document.getElementById('crop-zoom').addEventListener('input', function() {
427 const newScale = parseFloat(this.value);
428 // 줌 중심을 원형 영역 중심으로
429 const cx = CROP_SIZE / 2, cy = CROP_SIZE / 2;
430 cropX = cx - (cx - cropX) / cropScale * newScale;
431 cropY = cy - (cy - cropY) / cropScale * newScale;
432 cropScale = newScale;
433 updateCropPosition();
434 });
435
436 // 마우스 휠 줌
437 container.addEventListener('wheel', function(e) {
438 e.preventDefault();
439 const slider = document.getElementById('crop-zoom');
440 const delta = e.deltaY > 0 ? -0.02 : 0.02;
441 const newScale = Math.max(parseFloat(slider.min), Math.min(parseFloat(slider.max), cropScale + delta));
442 const cx = CROP_SIZE / 2, cy = CROP_SIZE / 2;
443 cropX = cx - (cx - cropX) / cropScale * newScale;
444 cropY = cy - (cy - cropY) / cropScale * newScale;
445 cropScale = newScale;
446 slider.value = cropScale;
447 updateCropPosition();
448 }, { passive: false });
449
450 // ESC로 닫기
451 window.addEventListener('keydown', e => {
452 if (e.key === 'Escape' && document.getElementById('crop-overlay').classList.contains('active')) closeCropModal();
453 });
454 })();
455
456 async function confirmCrop() {
457 if (!await showConfirm('프로필 사진을 구매하시겠습니까? (책갈피 5개 차감)')) return;
458
459 // 캔버스에 크롭 결과 그리기
460 const canvas = document.getElementById('crop-canvas');
461 const OUTPUT_SIZE = 256;
462 canvas.width = OUTPUT_SIZE;
463 canvas.height = OUTPUT_SIZE;
464 const ctx = canvas.getContext('2d');
465
466 const img = document.getElementById('crop-image');
467 // 원형 영역(CROP_SIZE x CROP_SIZE) 기준 → OUTPUT_SIZE로 매핑
468 const ratio = OUTPUT_SIZE / CROP_SIZE;
469 const sx = -cropX / cropScale;
470 const sy = -cropY / cropScale;
471 const sw = CROP_SIZE / cropScale;
472 const sh = CROP_SIZE / cropScale;
473
474 ctx.clearRect(0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
475 ctx.drawImage(img, sx, sy, sw, sh, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
476
477 canvas.toBlob(async function(blob) {
478 if (!blob) { await showAlert('이미지 처리 중 오류가 발생했습니다.'); return; }
479 const formData = new FormData();
480 formData.append('profileImage', blob, 'profile.png');
481 try {
482 const res = await fetch('/hinana/shop/buy-profile-pic', { method: 'POST', body: formData });
483 const data = await res.json();
484 if (data.success) {
485 document.getElementById('shop-bookmark-count').textContent = data.remaining;
486 await showAlert(data.message);
487 location.reload();
488 } else {
489 await showAlert(data.message);
490 }
491 } catch (e) {
492 await showAlert('오류가 발생했습니다.');
493 }
494 }, 'image/png');
495 }
496 </script>
497 <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); }</script>
498 </body>
499 </html>
500