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
<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