Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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
© 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