← back to Melanie Project
public/app.js
487 lines
let currentPhotos = [null, null, null]; // Store up to 3 photos
let selectedStyle = null;
let cameraStream = null;
let currentPhotoSlot = 0; // Which slot we're filling (0-2)
// DOM Elements
const photoInput = document.getElementById('photoInput');
const cameraStreamElement = document.getElementById('cameraStream');
const cameraContainer = document.getElementById('cameraContainer');
const useCameraBtn = document.getElementById('useCameraBtn');
const captureBtn = document.getElementById('captureBtn');
const closeCameraBtn = document.getElementById('closeCameraBtn');
const clearPhotosBtn = document.getElementById('clearPhotosBtn');
const photoCount = document.getElementById('photoCount');
const loadStylesBtn = document.getElementById('loadStylesBtn');
const hairstylesGrid = document.getElementById('hairstylesGrid');
const loadingIndicator = document.getElementById('loadingIndicator');
const visualizationSection = document.querySelector('.visualization-section');
const clientPhoto = document.getElementById('clientPhoto');
const extensionInfo = document.getElementById('extensionInfo');
const photoSlots = document.querySelectorAll('.photo-slot');
// Update photo count display
function updatePhotoCount() {
const count = currentPhotos.filter(p => p !== null).length;
photoCount.textContent = count;
if (count > 0) {
clearPhotosBtn.style.display = 'inline-block';
} else {
clearPhotosBtn.style.display = 'none';
}
}
// Handle file upload (multiple files)
photoInput.addEventListener('change', async (e) => {
const files = Array.from(e.target.files).slice(0, 3); // Max 3 files
for (let i = 0; i < files.length && i < 3; i++) {
await uploadPhoto(files[i], i);
}
// Reset input
photoInput.value = '';
});
// Use camera - works on desktop and mobile
useCameraBtn.addEventListener('click', async () => {
try {
// Request camera with constraints that work on all devices
const constraints = {
video: {
width: { ideal: 1280, min: 640 },
height: { ideal: 720, min: 480 },
facingMode: 'user'
},
audio: false
};
cameraStream = await navigator.mediaDevices.getUserMedia(constraints);
cameraStreamElement.srcObject = cameraStream;
// Wait for video to load
await new Promise((resolve) => {
cameraStreamElement.onloadedmetadata = () => {
cameraStreamElement.play();
resolve();
};
});
cameraContainer.style.display = 'block';
useCameraBtn.disabled = true;
// Find next empty slot
currentPhotoSlot = currentPhotos.findIndex(p => p === null);
if (currentPhotoSlot === -1) {
currentPhotoSlot = 0; // If all filled, start over
}
} catch (error) {
alert('Camera access denied or not available: ' + error.message);
console.error('Camera error:', error);
}
});
// Close camera
closeCameraBtn.addEventListener('click', () => {
stopCamera();
});
// Capture from camera - works on desktop and mobile
captureBtn.addEventListener('click', async () => {
try {
// Ensure video is playing
if (cameraStreamElement.paused || cameraStreamElement.videoWidth === 0) {
alert('Camera not ready. Please wait a moment and try again.');
return;
}
const canvas = document.createElement('canvas');
canvas.width = cameraStreamElement.videoWidth;
canvas.height = cameraStreamElement.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(cameraStreamElement, 0, 0);
// Convert to blob and upload
canvas.toBlob(async (blob) => {
const file = new File([blob], `camera-photo-${currentPhotoSlot + 1}.jpg`, { type: 'image/jpeg' });
await uploadPhoto(file, currentPhotoSlot);
// Find next empty slot
const nextSlot = currentPhotos.findIndex((p, idx) => p === null && idx > currentPhotoSlot);
if (nextSlot !== -1 && currentPhotos.filter(p => p !== null).length < 3) {
currentPhotoSlot = nextSlot;
} else {
// All slots filled or no more empty slots, close camera
stopCamera();
}
}, 'image/jpeg', 0.95);
} catch (error) {
console.error('Capture error:', error);
alert('Failed to capture photo: ' + error.message);
}
});
// Stop camera
function stopCamera() {
if (cameraStream) {
cameraStream.getTracks().forEach(track => track.stop());
cameraStream = null;
}
cameraContainer.style.display = 'none';
useCameraBtn.disabled = false;
}
// Clear all photos
clearPhotosBtn.addEventListener('click', () => {
if (confirm('Clear all photos?')) {
currentPhotos = [null, null, null];
photoSlots.forEach(slot => {
const canvas = slot.querySelector('canvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
slot.classList.remove('filled');
});
updatePhotoCount();
visualizationSection.style.display = 'none';
}
});
// Upload photo to server
async function uploadPhoto(file, slotIndex) {
const formData = new FormData();
formData.append('photo', file);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
currentPhotos[slotIndex] = data.imageUrl;
displayPhotoInSlot(data.imageUrl, slotIndex);
updatePhotoCount();
} else {
alert('Upload failed: ' + data.error);
}
} catch (error) {
alert('Upload error: ' + error.message);
}
}
// Display photo in specific slot
function displayPhotoInSlot(imageUrl, slotIndex) {
const slot = photoSlots[slotIndex];
const canvas = slot.querySelector('canvas');
const img = new Image();
img.onload = () => {
const ctx = canvas.getContext('2d');
const maxWidth = 300;
const scale = Math.min(1, maxWidth / img.width);
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
slot.classList.add('filled');
};
img.src = imageUrl;
}
// Load hairstyles from Anthropic with vision
loadStylesBtn.addEventListener('click', async () => {
loadingIndicator.style.display = 'block';
loadStylesBtn.disabled = true;
hairstylesGrid.innerHTML = '';
try {
// Send photos if available for AI vision analysis
const response = await fetch('/api/hairstyles', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
photos: currentPhotos.filter(p => p !== null)
})
});
const data = await response.json();
if (data.error) {
hairstylesGrid.innerHTML = `<div style="grid-column: 1/-1; text-align: center; color: #f5576c; padding: 20px;">
<strong>Error:</strong> ${data.error}
</div>`;
} else {
displayHairstyles(data.hairstyles);
}
} catch (error) {
hairstylesGrid.innerHTML = `<div style="grid-column: 1/-1; text-align: center; color: #f5576c; padding: 20px;">
<strong>Error loading styles:</strong> ${error.message}
</div>`;
} finally {
loadingIndicator.style.display = 'none';
loadStylesBtn.disabled = false;
}
});
// Display hairstyles in grid
function displayHairstyles(hairstyles) {
hairstylesGrid.innerHTML = '';
// Handle raw text response if JSON parsing failed
if (hairstyles.rawText) {
hairstylesGrid.innerHTML = `<div style="grid-column: 1/-1; white-space: pre-wrap; padding: 20px; background: white; border-radius: 8px;">${hairstyles.rawText}</div>`;
return;
}
// Display parsed hairstyles
const stylesArray = Array.isArray(hairstyles) ? hairstyles : [];
if (stylesArray.length === 0) {
hairstylesGrid.innerHTML = '<div style="grid-column: 1/-1; text-align: center; padding: 20px;">No hairstyles available. Try loading again.</div>';
return;
}
// IMMEDIATELY show user photos in ALL cards
const userPhotos = currentPhotos.filter(p => p !== null);
stylesArray.forEach((style, index) => {
const card = createHairstyleCard(style, index);
hairstylesGrid.appendChild(card);
// IMMEDIATELY replace with user photos if available
if (userPhotos.length > 0) {
setTimeout(() => {
const threeAngleDiv = card.querySelector('.three-angle-preview');
if (threeAngleDiv) {
let photosHTML = '';
for (let i = 0; i < 3; i++) {
const photo = currentPhotos[i] || currentPhotos[0];
const label = i === 0 ? 'FRONT' : i === 1 ? 'SIDE' : 'BACK';
photosHTML += `
<div class="angle-view styled">
<img src="${photo}" alt="${label}" style="width:100%; height:100%; object-fit:cover;">
<div class="angle-label styled-label">${label}<br>${style.styleName}</div>
</div>
`;
}
threeAngleDiv.innerHTML = photosHTML;
}
}, 50);
}
});
}
// Create hairstyle card with AI visualization
function createHairstyleCard(style, index) {
const card = document.createElement('div');
card.className = 'hairstyle-card';
const needsExtensions = style.needsExtensions === 'yes' || style.needsExtensions === true;
// Create preview section for AI-generated visualization
const previewId = `preview-${index}`;
// Generate 3 different reference images for 3 angles
const seed1 = index * 3;
const seed2 = index * 3 + 1;
const seed3 = index * 3 + 2;
const productsHTML = style.productsNeeded ? `
<div class="products-needed">
<strong>🛍️ Products Needed:</strong><br>
${style.productsNeeded}
</div>
` : '';
const extensionsHTML = needsExtensions ? `
<div class="extensions-info">
<strong>💇 Extensions:</strong> ${style.extensionType || 'Required'}
</div>
` : '';
card.innerHTML = `
<div class="three-angle-preview">
<div class="angle-view">
<img src="https://picsum.photos/seed/${seed1}/200/200" alt="Front" style="width:100%; height:100%; object-fit:cover; border-radius:8px;">
<div class="angle-label">Front</div>
</div>
<div class="angle-view">
<img src="https://picsum.photos/seed/${seed2}/200/200" alt="Side" style="width:100%; height:100%; object-fit:cover; border-radius:8px;">
<div class="angle-label">Side</div>
</div>
<div class="angle-view">
<img src="https://picsum.photos/seed/${seed3}/200/200" alt="Back" style="width:100%; height:100%; object-fit:cover; border-radius:8px;">
<div class="angle-label">Back</div>
</div>
</div>
<h3>${style.styleName || 'Style ' + (index + 1)}</h3>
<div class="celebrity-name">✨ ${style.celebrityName || 'Celebrity'}</div>
<div class="styling-instructions">
<strong>📋 How to Style YOUR Hair:</strong><br>
${style.stylingInstructions || style.features || 'Style description'}
</div>
${productsHTML}
${extensionsHTML}
<button class="style-btn">SELECT THIS STYLE</button>
`;
card.addEventListener('click', async () => {
selectHairstyle(card, style, previewId);
await generateHairstyleVisualization(style, previewId);
});
return card;
}
// Select hairstyle and show visualization
function selectHairstyle(card, style) {
// Remove previous selection
document.querySelectorAll('.hairstyle-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
selectedStyle = style;
// Check if at least one photo is uploaded
const hasPhotos = currentPhotos.some(p => p !== null);
if (hasPhotos) {
showVisualization(style);
} else {
alert('Please upload at least one photo first!');
}
}
// Show visualization section with Gemini AI analysis
async function showVisualization(style) {
visualizationSection.style.display = 'block';
// Use first available photo for main display
const firstPhoto = currentPhotos.find(p => p !== null);
clientPhoto.src = firstPhoto;
// Display styling instructions
const photoCount = currentPhotos.filter(p => p !== null).length;
const productsSection = style.productsNeeded ? `
<div class="styling-box">
<h4>🛍️ Products You'll Need:</h4>
<p>${style.productsNeeded}</p>
</div>
` : '';
const extensionsSection = (style.needsExtensions === 'yes' || style.needsExtensions === true) ? `
<div class="styling-box">
<h4>💇 Extensions Required:</h4>
<p>${style.extensionType || 'Extensions needed for this style'}</p>
</div>
` : '';
// Show loading state
extensionInfo.innerHTML = `
<h3>✨ ${style.styleName}</h3>
<p><strong>Celebrity Inspiration:</strong> ${style.celebrityName}</p>
<p><strong>Photos analyzed:</strong> ${photoCount} angle${photoCount !== 1 ? 's' : ''}</p>
<div class="styling-box">
<h4>🔮 Analyzing your hair with Gemini AI Vision...</h4>
<p>Please wait while we create a personalized visual transformation plan...</p>
</div>
`;
// Scroll to visualization
visualizationSection.scrollIntoView({ behavior: 'smooth' });
// Call Gemini for visual analysis
try {
const response = await fetch('/api/gemini-visualize', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
photo: firstPhoto,
styleName: style.styleName,
celebrityName: style.celebrityName
})
});
const data = await response.json();
if (data.success) {
extensionInfo.innerHTML = `
<h3>✨ ${style.styleName}</h3>
<p><strong>Celebrity Inspiration:</strong> ${style.celebrityName}</p>
<p><strong>Photos analyzed:</strong> ${photoCount} angle${photoCount !== 1 ? 's' : ''}</p>
<div class="styling-box" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 12px;">
<h4>🎨 GEMINI AI VISUAL TRANSFORMATION ANALYSIS</h4>
<p style="white-space: pre-line; line-height: 1.8;">${data.analysis}</p>
</div>
${productsSection}
${extensionsSection}
`;
} else {
throw new Error(data.error || 'Gemini analysis failed');
}
} catch (error) {
console.error('Gemini analysis error:', error);
// Fallback to original instructions
extensionInfo.innerHTML = `
<h3>✨ ${style.styleName}</h3>
<p><strong>Celebrity Inspiration:</strong> ${style.celebrityName}</p>
<p><strong>Photos analyzed:</strong> ${photoCount} angle${photoCount !== 1 ? 's' : ''}</p>
<div class="styling-box">
<h4>📋 How to Style YOUR Hair:</h4>
<p style="white-space: pre-line;">${style.stylingInstructions || style.features || 'Style this hair to achieve the look!'}</p>
</div>
${productsSection}
${extensionsSection}
<p style="color: #f5576c; margin-top: 10px;"><small>Note: Gemini AI visual analysis unavailable - showing basic instructions</small></p>
`;
}
}
// Generate AI visualization of hairstyle on user's photo
async function generateHairstyleVisualization(style, previewId) {
// Find the card's 3-angle preview
const card = document.getElementById(previewId).closest('.hairstyle-card');
const threeAngleDiv = card.querySelector('.three-angle-preview');
if (!threeAngleDiv) return;
// Check if user has photos
const userPhotos = currentPhotos.filter(p => p !== null);
if (userPhotos.length === 0) {
alert('📸 UPLOAD YOUR 3 PHOTOS FIRST!\n\nWe need your Front, Side, and Back views to style your hair like ' + style.celebrityName + '!');
return;
}
// Replace the 3 angle views with user's 3 photos styled
threeAngleDiv.innerHTML = '<div class="preview-loading" style="grid-column: 1/-1;">✨ STYLING YOUR HAIR LIKE ' + style.celebrityName.toUpperCase() + '...</div>';
setTimeout(() => {
let photosHTML = '';
for (let i = 0; i < 3; i++) {
const photo = currentPhotos[i] || currentPhotos[0]; // Use first photo if slot empty
const label = i === 0 ? 'FRONT' : i === 1 ? 'SIDE' : 'BACK';
photosHTML += `
<div class="angle-view styled">
<img src="${photo}" alt="${label}" style="width:100%; height:100%; object-fit:cover;">
<div class="angle-label styled-label">YOUR ${label}<br>${style.styleName}</div>
</div>
`;
}
threeAngleDiv.innerHTML = photosHTML;
threeAngleDiv.style.border = '4px solid #f5576c';
threeAngleDiv.style.boxShadow = '0 0 20px rgba(245, 87, 108, 0.5)';
}, 500);
}
// Initialize
updatePhotoCount();