← back to Handbag Authentication
public/mobile-camera.html
712 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Handbag Camera Upload</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #000;
color: white;
overflow: hidden;
}
.camera-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
}
/* Camera View */
.camera-view {
flex: 1;
position: relative;
background: #000;
overflow: hidden;
}
#camera-feed {
width: 100%;
height: 100%;
object-fit: cover;
}
#canvas {
display: none;
}
/* Camera Overlay */
.camera-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.guide-box {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
height: 60%;
border: 2px dashed rgba(255, 255, 255, 0.5);
border-radius: 16px;
}
.guide-text {
position: absolute;
top: 20%;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
padding: 12px 24px;
border-radius: 20px;
font-size: 14px;
text-align: center;
}
/* Top Bar */
.top-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: env(safe-area-inset-top) 16px 16px;
background: linear-gradient(to bottom, rgba(0,0,0,0.8), transparent);
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
}
.back-btn, .flip-btn {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
width: 44px;
height: 44px;
border-radius: 50%;
font-size: 20px;
cursor: pointer;
backdrop-filter: blur(10px);
}
/* Controls */
.camera-controls {
padding: 20px calc(env(safe-area-inset-bottom) + 20px) 20px;
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
display: flex;
align-items: center;
justify-content: space-around;
}
.capture-btn {
width: 70px;
height: 70px;
border-radius: 50%;
background: white;
border: 4px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
transition: transform 0.1s;
}
.capture-btn:active {
transform: scale(0.95);
}
.gallery-btn, .flash-btn {
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
border-radius: 50%;
font-size: 20px;
cursor: pointer;
backdrop-filter: blur(10px);
}
/* Preview Mode */
.preview-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
display: none;
flex-direction: column;
z-index: 100;
}
.preview-container.active {
display: flex;
}
.preview-image {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.preview-image img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 12px;
}
.preview-actions {
padding: 20px;
display: flex;
gap: 12px;
padding-bottom: calc(env(safe-area-inset-bottom) + 20px);
}
.preview-actions button {
flex: 1;
padding: 16px;
border-radius: 12px;
border: none;
font-size: 16px;
font-weight: 600;
cursor: pointer;
}
.retake-btn {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.upload-btn {
background: white;
color: #000;
}
/* Upload Progress */
.upload-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.95);
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
z-index: 200;
}
.upload-progress.active {
display: flex;
}
.progress-spinner {
width: 60px;
height: 60px;
border: 4px solid rgba(255, 255, 255, 0.2);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 24px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.progress-text {
font-size: 18px;
margin-bottom: 12px;
}
.progress-detail {
font-size: 14px;
opacity: 0.7;
}
/* Results */
.results-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #f5f5f7;
display: none;
flex-direction: column;
z-index: 300;
}
.results-container.active {
display: flex;
}
.results-header {
background: white;
padding: calc(env(safe-area-inset-top) + 16px) 16px 16px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.results-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.result-card {
background: white;
border-radius: 16px;
padding: 20px;
margin-bottom: 16px;
}
.result-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 12px;
color: #1d1d1f;
}
.result-item {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
}
.result-item:last-child {
border-bottom: none;
}
.result-label {
color: #86868b;
font-size: 14px;
}
.result-value {
color: #1d1d1f;
font-size: 14px;
font-weight: 600;
}
.confidence-bar {
width: 100%;
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
}
.confidence-fill {
height: 100%;
background: linear-gradient(90deg, #34c759, #30d158);
transition: width 0.3s;
}
</style>
</head>
<body>
<!-- Camera Container -->
<div class="camera-container">
<div class="camera-view">
<video id="camera-feed" autoplay playsinline></video>
<canvas id="canvas"></canvas>
<!-- Overlay -->
<div class="camera-overlay">
<div class="guide-box"></div>
<div class="guide-text">
📸 Position handbag in frame<br>
<small>Include all corners and details</small>
</div>
</div>
<!-- Top Bar -->
<div class="top-bar">
<button class="back-btn" onclick="goBack()">✕</button>
<button class="flip-btn" onclick="flipCamera()">🔄</button>
</div>
</div>
<!-- Controls -->
<div class="camera-controls">
<button class="gallery-btn" onclick="openGallery()">
🖼️
<input type="file" accept="image/*" id="gallery-input" style="display:none" onchange="handleGallerySelect(event)">
</button>
<button class="capture-btn" onclick="capturePhoto()"></button>
<button class="flash-btn" onclick="toggleFlash()">⚡</button>
</div>
</div>
<!-- Preview Container -->
<div class="preview-container" id="preview-container">
<div class="top-bar">
<button class="back-btn" onclick="closePreview()">✕</button>
<span></span>
</div>
<div class="preview-image">
<img id="preview-img" src="" alt="Captured photo">
</div>
<div class="preview-actions">
<button class="retake-btn" onclick="retakePhoto()">Retake</button>
<button class="upload-btn" onclick="uploadPhoto()">Upload & Analyze</button>
</div>
</div>
<!-- Upload Progress -->
<div class="upload-progress" id="upload-progress">
<div class="progress-spinner"></div>
<div class="progress-text">Analyzing handbag...</div>
<div class="progress-detail">AI processing in progress</div>
</div>
<!-- Results Container -->
<div class="results-container" id="results-container">
<div class="results-header">
<button class="back-btn" onclick="closeResults()" style="position:static;background:transparent;color:#1d1d1f;">✕</button>
<h2 style="color:#1d1d1f;font-size:18px;">Analysis Results</h2>
<span style="width:44px"></span>
</div>
<div class="results-content" id="results-content">
<!-- Results will be populated here -->
</div>
</div>
<script>
let stream = null;
let capturedImage = null;
let currentCamera = 'user'; // 'user' or 'environment'
let flashEnabled = false;
// Initialize camera
async function initCamera() {
try {
const constraints = {
video: {
facingMode: currentCamera,
width: { ideal: 1920 },
height: { ideal: 1080 }
}
};
stream = await navigator.mediaDevices.getUserMedia(constraints);
document.getElementById('camera-feed').srcObject = stream;
} catch (error) {
console.error('Camera access error:', error);
alert('Cannot access camera. Please check permissions.');
}
}
// Capture photo
function capturePhoto() {
const video = document.getElementById('camera-feed');
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
// Set canvas size to match video
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Draw video frame to canvas
context.drawImage(video, 0, 0);
// Get image data
capturedImage = canvas.toDataURL('image/jpeg', 0.9);
// Show preview
document.getElementById('preview-img').src = capturedImage;
document.getElementById('preview-container').classList.add('active');
}
// Flip camera
async function flipCamera() {
currentCamera = currentCamera === 'user' ? 'environment' : 'user';
if (stream) {
stream.getTracks().forEach(track => track.stop());
}
await initCamera();
}
// Toggle flash
function toggleFlash() {
flashEnabled = !flashEnabled;
const track = stream.getVideoTracks()[0];
if (track.getCapabilities().torch) {
track.applyConstraints({
advanced: [{ torch: flashEnabled }]
});
} else {
alert('Flash not supported on this device');
}
}
// Gallery select
function openGallery() {
document.getElementById('gallery-input').click();
}
function handleGallerySelect(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
capturedImage = e.target.result;
document.getElementById('preview-img').src = capturedImage;
document.getElementById('preview-container').classList.add('active');
};
reader.readAsDataURL(file);
}
}
// Preview actions
function closePreview() {
document.getElementById('preview-container').classList.remove('active');
}
function retakePhoto() {
closePreview();
capturedImage = null;
}
// Upload and analyze
async function uploadPhoto() {
if (!capturedImage) return;
// Show progress
document.getElementById('upload-progress').classList.add('active');
closePreview();
try {
// Convert base64 to blob
const blob = await fetch(capturedImage).then(r => r.blob());
// Create form data
const formData = new FormData();
formData.append('image', blob, 'handbag.jpg');
// Upload to server
const response = await fetch('/api/analyze/image', {
method: 'POST',
body: formData
});
const result = await response.json();
// Hide progress
document.getElementById('upload-progress').classList.remove('active');
// Show results
displayResults(result);
} catch (error) {
console.error('Upload error:', error);
document.getElementById('upload-progress').classList.remove('active');
alert('Upload failed. Please try again.');
}
}
// Display results
function displayResults(data) {
const container = document.getElementById('results-content');
if (!data.success) {
container.innerHTML = `
<div class="result-card">
<div class="result-title">❌ Error</div>
<p>${data.error || 'Analysis failed'}</p>
</div>
`;
document.getElementById('results-container').classList.add('active');
return;
}
const analysis = data.analysis || {};
const matches = data.matches || {};
const priceComps = data.priceComparisons || [];
// Calculate confidence and authenticity
const confidence = Math.round((analysis.confidence || 0.5) * 100);
const authenticity = confidence;
// Format price range
let estimatedValue = 'Unknown';
if (analysis.priceRange && analysis.priceRange.max > 0) {
estimatedValue = `$${analysis.priceRange.min.toLocaleString()} - $${analysis.priceRange.max.toLocaleString()}`;
}
const results = {
brand: `${analysis.brand || 'Unknown'} (${confidence}% confidence)`,
model: analysis.model || 'Unknown',
material: analysis.material || 'Unknown',
color: analysis.color || 'Unknown',
condition: analysis.condition || 'Unknown',
authenticity: authenticity,
estimatedValue: estimatedValue,
features: analysis.features || []
};
container.innerHTML = `
<div class="result-card">
<div class="result-title">🔍 Identification</div>
<div class="result-item">
<span class="result-label">Brand</span>
<span class="result-value">${results.brand}</span>
</div>
<div class="result-item">
<span class="result-label">Model</span>
<span class="result-value">${results.model}</span>
</div>
<div class="result-item">
<span class="result-label">Material</span>
<span class="result-value">${results.material}</span>
</div>
<div class="result-item">
<span class="result-label">Color</span>
<span class="result-value">${results.color}</span>
</div>
</div>
<div class="result-card">
<div class="result-title">✅ Authenticity Check</div>
<div class="result-item">
<span class="result-label">Confidence Score</span>
<span class="result-value">${results.authenticity}%</span>
</div>
<div class="confidence-bar">
<div class="confidence-fill" style="width: ${results.authenticity}%"></div>
</div>
</div>
<div class="result-card">
<div class="result-title">💰 Estimated Value</div>
<div class="result-item">
<span class="result-label">Market Value</span>
<span class="result-value">${results.estimatedValue}</span>
</div>
<div class="result-item">
<span class="result-label">Condition</span>
<span class="result-value">${results.condition}</span>
</div>
</div>
<div class="result-card">
<div class="result-title">📋 Key Features</div>
${results.features.length > 0 ? results.features.map(f => `
<div class="result-item">
<span class="result-value" style="font-weight:normal">• ${f}</span>
</div>
`).join('') : '<p style="color:#888;padding:10px">No features detected</p>'}
</div>
<div class="result-card">
<div class="result-title">🗄️ Database Matches</div>
<div class="result-item">
<span class="result-label">Total Found</span>
<span class="result-value">${matches.total || 0} items</span>
</div>
<div class="result-item">
<span class="result-label">Retailer DB</span>
<span class="result-value">${(matches.retailer || []).length} items</span>
</div>
<div class="result-item">
<span class="result-label">Luxury DB</span>
<span class="result-value">${(matches.luxury || []).length} items</span>
</div>
<div class="result-item">
<span class="result-label">Museum DB</span>
<span class="result-value">${(matches.museum || []).length} items</span>
</div>
<div class="result-item">
<span class="result-label">Live Auctions</span>
<span class="result-value">${(matches.liveAuctions || []).length} items</span>
</div>
<div class="result-item">
<span class="result-label">360° Images</span>
<span class="result-value">${(matches.abo360 || []).length} items</span>
</div>
</div>
${priceComps.length > 0 ? `
<div class="result-card">
<div class="result-title">💵 Price Comparisons</div>
${priceComps.map(comp => `
<div class="result-item">
<span class="result-label">${comp.market}</span>
<span class="result-value">
${comp.priceRange.min ? `$${comp.priceRange.min.toLocaleString()}` : ''}
${comp.priceRange.avg ? `- $${comp.priceRange.avg.toLocaleString()}` : ''}
${comp.count ? ` (${comp.count} items)` : ''}
</span>
</div>
`).join('')}
</div>
` : ''}
${matches.liveAuctions && matches.liveAuctions.length > 0 ? `
<div class="result-card">
<div class="result-title">🔥 Live Auctions (Japan)</div>
${matches.liveAuctions.slice(0, 5).map(item => `
<div style="border-bottom: 1px solid #eee; padding: 12px 0;">
<div style="font-weight: 600; margin-bottom: 4px;">${item.title}</div>
<div style="color: #667eea; font-size: 1.2rem; font-weight: 700;">
$${item.priceUSD ? item.priceUSD.toFixed(0) : 'N/A'}
</div>
<div style="color: #888; font-size: 0.875rem;">
¥${item.priceJPY ? item.priceJPY.toLocaleString() : 'N/A'} • ${item.condition || 'Used'}
</div>
<a href="${item.url}" target="_blank" rel="noopener noreferrer" style="color: #667eea; text-decoration: none; font-size: 0.875rem;">
View on ${item.source} →
</a>
</div>
`).join('')}
</div>
` : ''}
`;
document.getElementById('results-container').classList.add('active');
}
function closeResults() {
document.getElementById('results-container').classList.remove('active');
}
function goBack() {
if (stream) {
stream.getTracks().forEach(track => track.stop());
}
window.history.back();
}
// Initialize on load
window.addEventListener('load', initCamera);
</script>
</body>
</html>