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