feat: 제품 이미지 카메라 촬영 기능 추가

- HTML5 getUserMedia로 카메라 촬영 지원 (모바일 후면 카메라 기본)
- 1:1 가이드 박스 UI로 정사각형 크롭 안내
- 백엔드: PIL로 800x800 리사이즈 + 썸네일 생성
- 기존 URL 교체 기능과 탭 방식으로 통합

버그 수정:
- closeReplaceModal() 호출 전 변수 복사로 null 전송 문제 해결
- None 값 방어 코드 추가

Docs: TROUBLESHOOTING-CAMERA-UPLOAD.md 추가
This commit is contained in:
thug0bin
2026-03-04 10:08:40 +09:00
parent 30d95c8579
commit 546a5e7ae6
3 changed files with 419 additions and 14 deletions

View File

@@ -308,6 +308,30 @@
to { transform: translateX(0); opacity: 1; }
}
/* 탭 스타일 */
.tab-btn {
flex: 1;
padding: 10px 16px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: #9ca3af;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.tab-btn:hover {
background: rgba(255,255,255,0.1);
}
.tab-btn.active {
background: linear-gradient(135deg, #8b5cf6, #6366f1);
color: white;
border-color: transparent;
}
.empty-state {
text-align: center;
padding: 60px 20px;
@@ -411,28 +435,80 @@
</div>
</div>
<!-- 이미지 교체 모달 -->
<!-- 이미지 교체 모달 (탭 방식: URL / 촬영) -->
<div class="modal" id="replaceModal">
<div class="modal-content" style="max-width: 500px;">
<h3>🔄 이미지 교체</h3>
<p style="color: #9ca3af; margin-bottom: 8px; font-size: 13px;">
구글 이미지 등에서 찾은 URL을 붙여넣으세요
</p>
<div id="replaceProductInfo" style="background: rgba(139,92,246,0.1); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
<div style="font-weight: 600;" id="replaceProductName"></div>
<div style="font-size: 12px; color: #a855f7; font-family: monospace;" id="replaceBarcode"></div>
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 4px; font-size: 13px;">이미지 URL *</label>
<input type="text" id="replaceImageUrl" class="search-box" style="width: 100%;"
placeholder="https://example.com/image.jpg">
<div style="font-size: 11px; color: #6b7280; margin-top: 4px;">
💡 이미지 우클릭 → "이미지 주소 복사"로 URL을 가져오세요
<!-- 탭 버튼 -->
<div class="replace-tabs" style="display: flex; gap: 8px; margin-bottom: 16px;">
<button class="tab-btn active" onclick="switchReplaceTab('url')" id="tabBtnUrl">
🔗 URL 입력
</button>
<button class="tab-btn" onclick="switchReplaceTab('camera')" id="tabBtnCamera">
📷 촬영
</button>
</div>
<!-- URL 입력 탭 -->
<div id="tabUrl" class="tab-content">
<p style="color: #9ca3af; margin-bottom: 8px; font-size: 13px;">
구글 이미지 등에서 찾은 URL을 붙여넣으세요
</p>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 4px; font-size: 13px;">이미지 URL *</label>
<input type="text" id="replaceImageUrl" class="search-box" style="width: 100%;"
placeholder="https://example.com/image.jpg">
<div style="font-size: 11px; color: #6b7280; margin-top: 4px;">
💡 이미지 우클릭 → "이미지 주소 복사"로 URL을 가져오세요
</div>
</div>
<div style="text-align: right;">
<button class="btn btn-secondary" onclick="closeReplaceModal()">취소</button>
<button class="btn btn-primary" onclick="submitReplace()">교체하기</button>
</div>
</div>
<div style="text-align: right;">
<button class="btn btn-secondary" onclick="closeReplaceModal()">취소</button>
<button class="btn btn-primary" onclick="submitReplace()">교체하기</button>
<!-- 카메라 촬영 탭 -->
<div id="tabCamera" class="tab-content" style="display: none;">
<!-- 카메라 뷰 -->
<div id="cameraContainer" style="position: relative; width: 100%; aspect-ratio: 1; background: #000; border-radius: 8px; overflow: hidden; margin-bottom: 12px;">
<video id="cameraVideo" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover;"></video>
<!-- 1:1 가이드 오버레이 -->
<div id="cameraGuide" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none;">
<svg width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none">
<!-- 반투명 외곽 -->
<rect x="0" y="0" width="100" height="100" fill="rgba(0,0,0,0.4)"/>
<!-- 중앙 투명 영역 -->
<rect x="10" y="10" width="80" height="80" fill="transparent" stroke="#a855f7" stroke-width="0.5" stroke-dasharray="2,2"/>
<!-- 모서리 강조 -->
<path d="M10,20 L10,10 L20,10" fill="none" stroke="#a855f7" stroke-width="1"/>
<path d="M80,10 L90,10 L90,20" fill="none" stroke="#a855f7" stroke-width="1"/>
<path d="M90,80 L90,90 L80,90" fill="none" stroke="#a855f7" stroke-width="1"/>
<path d="M20,90 L10,90 L10,80" fill="none" stroke="#a855f7" stroke-width="1"/>
</svg>
</div>
<!-- 촬영된 이미지 미리보기 -->
<canvas id="captureCanvas" style="display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></canvas>
</div>
<p style="color: #9ca3af; font-size: 12px; text-align: center; margin-bottom: 12px;">
📦 제품을 보라색 가이드 안에 맞춰주세요
</p>
<!-- 버튼들 -->
<div id="cameraButtons" style="display: flex; gap: 8px; justify-content: center;">
<button class="btn btn-secondary" onclick="closeReplaceModal()">취소</button>
<button class="btn btn-primary" id="captureBtn" onclick="capturePhoto()">📸 촬영</button>
</div>
<div id="previewButtons" style="display: none; gap: 8px; justify-content: center;">
<button class="btn btn-secondary" onclick="retakePhoto()">↩️ 다시 촬영</button>
<button class="btn btn-primary" onclick="submitCapture()">✅ 저장</button>
</div>
</div>
</div>
</div>
@@ -757,6 +833,9 @@
// 이미지 교체
let replaceTargetBarcode = null;
let replaceTargetName = null;
let cameraStream = null;
let capturedImageData = null;
function openReplaceModal(barcode, productName) {
console.log('openReplaceModal called with:', barcode, productName);
@@ -767,16 +846,173 @@
}
replaceTargetBarcode = barcode;
document.getElementById('replaceProductName').textContent = productName || barcode;
replaceTargetName = productName || barcode;
document.getElementById('replaceProductName').textContent = replaceTargetName;
document.getElementById('replaceBarcode').textContent = barcode;
document.getElementById('replaceImageUrl').value = '';
// 탭 초기화 (URL 탭으로)
switchReplaceTab('url');
document.getElementById('replaceModal').classList.add('show');
document.getElementById('replaceImageUrl').focus();
}
function closeReplaceModal() {
stopCamera();
document.getElementById('replaceModal').classList.remove('show');
replaceTargetBarcode = null;
replaceTargetName = null;
capturedImageData = null;
}
function switchReplaceTab(tab) {
// 탭 버튼 활성화
document.getElementById('tabBtnUrl').classList.toggle('active', tab === 'url');
document.getElementById('tabBtnCamera').classList.toggle('active', tab === 'camera');
// 탭 콘텐츠 표시
document.getElementById('tabUrl').style.display = tab === 'url' ? 'block' : 'none';
document.getElementById('tabCamera').style.display = tab === 'camera' ? 'block' : 'none';
// 카메라 탭이면 카메라 시작
if (tab === 'camera') {
startCamera();
} else {
stopCamera();
}
}
async function startCamera() {
try {
// 이전 스트림 정리
stopCamera();
// 후면 카메라 우선 (모바일)
const constraints = {
video: {
facingMode: { ideal: 'environment' }, // 후면 카메라
width: { ideal: 1920 },
height: { ideal: 1920 }
},
audio: false
};
cameraStream = await navigator.mediaDevices.getUserMedia(constraints);
const video = document.getElementById('cameraVideo');
video.srcObject = cameraStream;
video.style.display = 'block';
// 캡처 상태 초기화
document.getElementById('captureCanvas').style.display = 'none';
document.getElementById('cameraGuide').style.display = 'block';
document.getElementById('cameraButtons').style.display = 'flex';
document.getElementById('previewButtons').style.display = 'none';
capturedImageData = null;
} catch (err) {
console.error('카메라 접근 오류:', err);
showToast('카메라에 접근할 수 없습니다: ' + err.message, 'error');
}
}
function stopCamera() {
if (cameraStream) {
cameraStream.getTracks().forEach(track => track.stop());
cameraStream = null;
}
const video = document.getElementById('cameraVideo');
video.srcObject = null;
}
function capturePhoto() {
const video = document.getElementById('cameraVideo');
const canvas = document.getElementById('captureCanvas');
const ctx = canvas.getContext('2d');
// 비디오 크기
const vw = video.videoWidth;
const vh = video.videoHeight;
// 1:1 영역 계산 (가이드 박스: 80% 영역)
const minDim = Math.min(vw, vh);
const cropSize = minDim * 0.8;
const sx = (vw - cropSize) / 2;
const sy = (vh - cropSize) / 2;
// 캔버스를 800x800으로 설정 (최종 해상도)
canvas.width = 800;
canvas.height = 800;
// 크롭하여 그리기
ctx.drawImage(video, sx, sy, cropSize, cropSize, 0, 0, 800, 800);
// base64 저장
capturedImageData = canvas.toDataURL('image/jpeg', 0.92);
// UI 전환 (미리보기 모드)
video.style.display = 'none';
canvas.style.display = 'block';
document.getElementById('cameraGuide').style.display = 'none';
document.getElementById('cameraButtons').style.display = 'none';
document.getElementById('previewButtons').style.display = 'flex';
// 카메라 스트림은 유지 (다시 촬영 위해)
}
function retakePhoto() {
const video = document.getElementById('cameraVideo');
const canvas = document.getElementById('captureCanvas');
// UI 전환 (카메라 모드)
video.style.display = 'block';
canvas.style.display = 'none';
document.getElementById('cameraGuide').style.display = 'block';
document.getElementById('cameraButtons').style.display = 'flex';
document.getElementById('previewButtons').style.display = 'none';
capturedImageData = null;
}
async function submitCapture() {
if (!capturedImageData) {
showToast('촬영된 이미지가 없습니다', 'error');
return;
}
if (!replaceTargetBarcode) {
showToast('바코드 정보가 없습니다', 'error');
return;
}
// 모달 닫기 전에 값 복사 (closeReplaceModal에서 null로 리셋되므로)
const barcode = replaceTargetBarcode;
const productName = replaceTargetName;
const imageData = capturedImageData;
closeReplaceModal();
showToast(`"${productName}" 이미지 저장 중...`, 'info');
try {
const res = await fetch(`/api/admin/product-images/${barcode}/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_data: imageData,
product_name: productName
})
});
const data = await res.json();
if (data.success) {
showToast('✅ 촬영 이미지 저장 완료!', 'success');
loadStats();
loadImages();
} else {
showToast(data.error || '저장 실패', 'error');
}
} catch (err) {
showToast('오류: ' + err.message, 'error');
}
}
async function submitReplace() {