719 lines
31 KiB
JavaScript
719 lines
31 KiB
JavaScript
// src/App.jsx
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import JSZip from 'jszip';
|
|
import { IoMdAperture } from 'react-icons/io';
|
|
import API_BASE_URL from './config';
|
|
import * as skinapi from 'face-api.js';
|
|
import { useNavigate } from 'react-router';
|
|
import { saveToDB } from './db';
|
|
import buildInfo from './build-info.json';
|
|
|
|
const App = () => {
|
|
const errorDescriptions = {
|
|
'error_below_min_image_size': 'Input image resolution is too small',
|
|
'error_exceed_max_image_size': 'Input image resolution is too large',
|
|
'error_src_skin_too_small': 'The skin area in the uploaded image is too small. The width of the skin needs to be greater than 60% of the width of the image.',
|
|
'error_src_face_too_small': 'The face in the uploaded image is too small. Please ensure the face takes up more than 60% of the image width.',
|
|
'error_src_skin_out_of_bound': 'The skin area in the uploaded image is out of bound',
|
|
'error_lighting_dark': 'The lighting in the uploaded image is too dark'
|
|
};
|
|
|
|
const [view, setView] = useState('landing');
|
|
const [file, setFile] = useState(null);
|
|
const [previewUrl, setPreviewUrl] = useState('');
|
|
const [videoStream, setVideoStream] = useState(null);
|
|
const [error, setError] = useState(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [skinBox, setskinBox] = useState(null);
|
|
const [modelsLoaded, setModelsLoaded] = useState(false);
|
|
const [analysisResults, setAnalysisResults] = useState(null);
|
|
const [maskMap, setMaskMap] = useState({});
|
|
const [zipUrl, setZipUrl] = useState(null);
|
|
const [cropEnabled, setCropEnabled] = useState(true);
|
|
const [showHelp, setShowHelp] = useState(false);
|
|
const [landmarks, setLandmarks] = useState(null);
|
|
|
|
|
|
const navigate = useNavigate();
|
|
const videoRef = useRef(null);
|
|
const canvasRef = useRef(null);
|
|
|
|
// ========== CONFIGURATION ==========
|
|
|
|
// --- Load skin-api models ---
|
|
useEffect(() => {
|
|
console.log(`%c Build Version: ${buildInfo.version} | Date: ${buildInfo.date}`, 'color: limelite-red; font-weight: bold; font-size: 14px;');
|
|
const loadModels = async () => {
|
|
await Promise.all([
|
|
skinapi.nets.tinyFaceDetector.loadFromUri(process.env.PUBLIC_URL + '/models'),
|
|
skinapi.nets.faceLandmark68TinyNet.loadFromUri(process.env.PUBLIC_URL + '/models')
|
|
]);
|
|
setModelsLoaded(true);
|
|
};
|
|
loadModels();
|
|
// Set flag for new analysis to allow signup even if number exists
|
|
sessionStorage.setItem('isNewAnalysis', 'true');
|
|
}, []);
|
|
|
|
|
|
|
|
// --- Real-time skin detection ---
|
|
useEffect(() => {
|
|
let intervalId;
|
|
if (view === 'camera' && videoRef.current && modelsLoaded) {
|
|
const detectskin = async () => {
|
|
if (!videoRef.current || videoRef.current.readyState < 2) return;
|
|
try {
|
|
const detections = await skinapi.detectSingleFace(videoRef.current, new skinapi.TinyFaceDetectorOptions());
|
|
if (detections) setskinBox(detections.box);
|
|
else setskinBox(null);
|
|
} catch (e) {
|
|
console.error("Face detection error:", e);
|
|
}
|
|
};
|
|
intervalId = setInterval(detectskin, 300);
|
|
}
|
|
return () => clearInterval(intervalId);
|
|
}, [view, modelsLoaded]);
|
|
|
|
// --- Video setup ---
|
|
useEffect(() => {
|
|
if (videoRef.current && videoStream) videoRef.current.srcObject = videoStream;
|
|
}, [videoStream]);
|
|
|
|
// Auto-start camera when view is 'camera'
|
|
useEffect(() => {
|
|
let localStream = null;
|
|
let isMounted = true;
|
|
|
|
if (view === 'camera' && !videoStream) {
|
|
const startCamera = async () => {
|
|
setError(null);
|
|
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
if (isMounted) setError('Camera API not accessible. Ensure you are using HTTPS.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Attempt High-Res User Facing Camera
|
|
localStream = await navigator.mediaDevices.getUserMedia({
|
|
video: {
|
|
facingMode: 'user',
|
|
width: { ideal: 1920 },
|
|
height: { ideal: 1080 }
|
|
}
|
|
});
|
|
|
|
if (!isMounted) {
|
|
localStream.getTracks().forEach(t => t.stop());
|
|
return;
|
|
}
|
|
setVideoStream(localStream);
|
|
|
|
} catch (err) {
|
|
console.warn('Primary camera request failed, attempting fallback...', err);
|
|
if (!isMounted) return;
|
|
|
|
try {
|
|
// Fallback to basic constraints
|
|
localStream = await navigator.mediaDevices.getUserMedia({ video: true });
|
|
|
|
if (!isMounted) {
|
|
localStream.getTracks().forEach(t => t.stop());
|
|
return;
|
|
}
|
|
setVideoStream(localStream);
|
|
|
|
} catch (err2) {
|
|
console.error('All camera requests failed:', err2);
|
|
if (!isMounted) return;
|
|
|
|
let msg = 'Could not access the camera.';
|
|
if (err2.name === 'NotAllowedError') msg = 'Camera access denied. check permissions.';
|
|
else if (err2.name === 'NotFoundError') msg = 'No camera found. Connect a webcam.';
|
|
else if (err2.name === 'NotReadableError') msg = 'Camera is in use by another app or tab.';
|
|
|
|
setError(msg);
|
|
}
|
|
}
|
|
};
|
|
|
|
startCamera();
|
|
}
|
|
|
|
// CLEANUP FUNCTION
|
|
return () => {
|
|
isMounted = false;
|
|
if (localStream) {
|
|
localStream.getTracks().forEach(track => track.stop());
|
|
}
|
|
};
|
|
}, [view]);
|
|
|
|
useEffect(() => {
|
|
if (view !== 'camera' && videoStream) {
|
|
videoStream.getTracks().forEach(track => track.stop());
|
|
setVideoStream(null);
|
|
}
|
|
}, [view, videoStream]);
|
|
|
|
// --- Helper functions ---
|
|
const safeJsonParse = async (res) => {
|
|
const text = await res.text();
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (e) {
|
|
console.error("Non-JSON API response:", text.substring(0, 200));
|
|
throw new Error("Server returned an invalid non-JSON response. Please check your proxy or API URL configuration.");
|
|
}
|
|
};
|
|
|
|
const resizeImageFile = async (file, maxWidth = 2048, maxHeight = 2048, minShortSide = 1080) => {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
let { width, height } = img;
|
|
let scaleRatio = 1;
|
|
if (width > maxWidth || height > maxHeight) scaleRatio = Math.min(maxWidth / width, maxHeight / height);
|
|
const shortSide = Math.min(width, height);
|
|
if (shortSide * scaleRatio < minShortSide) scaleRatio = Math.max(minShortSide / shortSide, scaleRatio);
|
|
const targetWidth = Math.round(width * scaleRatio);
|
|
const targetHeight = Math.round(height * scaleRatio);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = targetWidth;
|
|
canvas.height = targetHeight;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
|
|
canvas.toBlob(blob => {
|
|
if (!blob) return reject(new Error('Canvas empty'));
|
|
resolve(new File([blob], file.name.replace(/\.\w+$/, '.jpg'), { type: 'image/jpeg' }));
|
|
}, 'image/jpeg', 0.9);
|
|
};
|
|
img.onerror = () => reject(new Error('Image load failed'));
|
|
const reader = new FileReader();
|
|
reader.onload = e => img.src = e.target.result;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
};
|
|
|
|
const blobToBase64 = blob => new Promise(resolve => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(reader.result);
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
|
|
|
|
|
|
const handleFileUpload = (e) => {
|
|
const file = e.target.files[0];
|
|
if (file) {
|
|
setFile(file);
|
|
setPreviewUrl(URL.createObjectURL(file));
|
|
setView('preview');
|
|
setError(null);
|
|
}
|
|
};
|
|
|
|
|
|
const handleCameraClick = () => {
|
|
// Reset to trigger camera initialization via useEffect
|
|
if (videoStream) {
|
|
videoStream.getTracks().forEach(t => t.stop());
|
|
setVideoStream(null);
|
|
}
|
|
setFile(null);
|
|
setPreviewUrl('');
|
|
setView('camera');
|
|
setError(null);
|
|
};
|
|
|
|
const handleCaptureskin = () => {
|
|
if (!videoRef.current) return;
|
|
|
|
const video = videoRef.current;
|
|
|
|
// --- Scale Resolution Logic ---
|
|
const videoWidth = video.videoWidth;
|
|
const videoHeight = video.videoHeight;
|
|
|
|
// We want higher resolution capture.
|
|
// Usually video element size is smaller than stream size.
|
|
// We are using videoHeight/Width which gives the intrinsic size of the stream.
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = videoWidth;
|
|
canvas.height = videoHeight;
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
// Flip horizontally to match the mirrored viewfinder display
|
|
ctx.translate(videoWidth, 0);
|
|
ctx.scale(-1, 1);
|
|
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
|
|
let finalCanvas;
|
|
|
|
if (cropEnabled) {
|
|
// Define crop area (match your overlay)
|
|
// The overlay is hardcoded as w-40 h-56 in Tailwind (160px x 224px) inside a h-80/96 container.
|
|
// We need to calculate the proportional area from the actual video video stream.
|
|
|
|
// Since the overlay CSS is relative to the displayed video size, we must compute ratios.
|
|
// However, for simplicity and robustness in this demo, let's just take a center crop
|
|
// which is approx 50% width and 70% height of the frame
|
|
|
|
const cropWidth = canvas.width * 0.55;
|
|
const cropHeight = canvas.height * 0.75;
|
|
const startX = (canvas.width - cropWidth) / 2;
|
|
const startY = (canvas.height - cropHeight) / 2;
|
|
|
|
finalCanvas = document.createElement("canvas");
|
|
finalCanvas.width = cropWidth;
|
|
finalCanvas.height = cropHeight;
|
|
const croppedCtx = finalCanvas.getContext("2d");
|
|
|
|
croppedCtx.drawImage(
|
|
canvas,
|
|
startX, startY, cropWidth, cropHeight,
|
|
0, 0, cropWidth, cropHeight
|
|
);
|
|
} else {
|
|
finalCanvas = canvas;
|
|
}
|
|
|
|
finalCanvas.toBlob((blob) => {
|
|
const capturedFile = new File([blob], "captured-skin.jpg", { type: "image/jpeg" });
|
|
setFile(capturedFile);
|
|
setPreviewUrl(URL.createObjectURL(capturedFile));
|
|
|
|
if (videoStream) videoStream.getTracks().forEach((t) => t.stop());
|
|
setVideoStream(null);
|
|
setView("preview");
|
|
}, "image/jpeg", 0.95);
|
|
};
|
|
|
|
|
|
const handleRetakeClick = () => {
|
|
setView('camera');
|
|
setFile(null);
|
|
setPreviewUrl('');
|
|
setAnalysisResults(null);
|
|
setMaskMap({});
|
|
setError(null);
|
|
};
|
|
|
|
const handleSubmitClick = async () => {
|
|
if (!file) return;
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// --- Detect Face and Auto-Crop ---
|
|
let processedFile = file;
|
|
try {
|
|
const img = new Image();
|
|
const imgUrl = URL.createObjectURL(file);
|
|
await new Promise((resolve, reject) => {
|
|
img.onload = resolve;
|
|
img.onerror = reject;
|
|
img.src = imgUrl;
|
|
});
|
|
|
|
// Run face detection on the static image
|
|
const detections = await skinapi.detectSingleFace(img, new skinapi.TinyFaceDetectorOptions());
|
|
if (detections) {
|
|
const { x, y, width: fw, height: fh } = detections.box;
|
|
|
|
// Target width should be such that the face takes up 65% of the width
|
|
// fw / target_width = 0.65 => target_width = fw / 0.65
|
|
const targetWidth = Math.min(img.width, fw / 0.65);
|
|
|
|
// Let's make it a square crop for best results, or keep original aspect ratio if possible
|
|
// But PerfectCorp just needs face to be large. Let's do a slightly taller rectangle or square.
|
|
const targetHeight = Math.min(img.height, targetWidth * 1.2);
|
|
|
|
const centerX = x + fw / 2;
|
|
const centerY = y + fh / 2;
|
|
|
|
let cropX = Math.max(0, centerX - targetWidth / 2);
|
|
let cropY = Math.max(0, centerY - targetHeight / 2);
|
|
|
|
// Adjust if crop goes out of bounds
|
|
if (cropX + targetWidth > img.width) cropX = img.width - targetWidth;
|
|
if (cropY + targetHeight > img.height) cropY = img.height - targetHeight;
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = targetWidth;
|
|
canvas.height = targetHeight;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, cropX, cropY, targetWidth, targetHeight, 0, 0, targetWidth, targetHeight);
|
|
|
|
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.95));
|
|
processedFile = new File([blob], file.name.replace(/\.\w+$/, '.jpg'), { type: 'image/jpeg' });
|
|
}
|
|
URL.revokeObjectURL(imgUrl);
|
|
} catch (e) {
|
|
console.warn('Face detection/auto-crop failed, falling back to original image', e);
|
|
}
|
|
|
|
// --- Ensure image meets API min size ---
|
|
let resized;
|
|
try {
|
|
resized = await resizeImageFile(processedFile, 2048, 2048, 1080);
|
|
} catch (e) {
|
|
throw new Error(`Image resizing failed: ${e.message}`);
|
|
}
|
|
|
|
// --- Upload & Analyze via Backend Proxy ---
|
|
let result;
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('image', resized);
|
|
|
|
const response = await fetch(`${API_BASE_URL}/api/perfectcorp/analyze`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const responseData = await safeJsonParse(response);
|
|
if (!response.ok) {
|
|
throw new Error(responseData.error || 'Backend perfectcorp proxy failed');
|
|
}
|
|
|
|
result = { result: responseData.result }; // Wrap to match old structure
|
|
} catch (e) {
|
|
throw new Error(`Backend Analysis Request failed: ${e.message}`);
|
|
}
|
|
|
|
// --- Process New S2S JSON Response ---
|
|
try {
|
|
const scores = result.result?.scores;
|
|
if (!scores) throw new Error("No scores found in result from backend.");
|
|
|
|
const originalBase64 = await blobToBase64(await fetch(previewUrl).then(r => r.blob()));
|
|
|
|
const newMaskMap = {};
|
|
for (const key of Object.keys(scores)) {
|
|
const maskUrl = scores[key].output_mask_name;
|
|
if (!maskUrl) continue;
|
|
|
|
try {
|
|
// Fetch the image from S3 to cache it locally as base64
|
|
const imgRes = await fetch(maskUrl);
|
|
if (imgRes.ok) {
|
|
const b = await imgRes.blob();
|
|
newMaskMap[maskUrl] = await blobToBase64(b);
|
|
} else {
|
|
newMaskMap[maskUrl] = maskUrl; // Fallback to URL
|
|
}
|
|
} catch(err) {
|
|
newMaskMap[maskUrl] = maskUrl;
|
|
}
|
|
}
|
|
|
|
await saveToDB('analysisData', {
|
|
scoreData: scores,
|
|
maskImages: newMaskMap,
|
|
originalImage: originalBase64,
|
|
zipDownloadUrl: null
|
|
});
|
|
|
|
setAnalysisResults(scores);
|
|
setMaskMap(newMaskMap);
|
|
setView('results');
|
|
} catch (e) {
|
|
throw new Error(`Processing analysis result failed: ${e.message}`);
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
setError(`Analysis failed: ${e.message}`);
|
|
setView('preview');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (view === 'results') navigate('/result');
|
|
}, [view, navigate]);
|
|
|
|
// Auto-start analysis removed to allow manual confirmation with Tick button
|
|
|
|
// --- Render ---
|
|
return (
|
|
<div className="min-h-screen relative overflow-hidden bg-gradient-to-r from-limelite-maroon to-limelite-dark text-white">
|
|
<div className="container mx-auto px-4 py-10 md:py-16 relative z-10 min-h-screen flex flex-col items-center justify-start gap-4 md:gap-8">
|
|
|
|
{/* Header */}
|
|
<header className="fixed top-0 left-0 right-0 z-[60] py-4 px-6 flex items-center justify-between bg-transparent">
|
|
<div className="flex items-center gap-4">
|
|
<img src={process.env.PUBLIC_URL + '/assets/images/logo.jpg'} alt="Limelite Logo" className="h-10 md:h-14 w-auto" />
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
className="text-white hover:text-white/80 transition-colors"
|
|
onClick={() => setShowHelp(!showHelp)}
|
|
>
|
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</button>
|
|
|
|
|
|
{view !== 'landing' && (
|
|
<button
|
|
className="text-white hover:text-white/80 transition-colors"
|
|
onClick={() => {
|
|
if (videoStream) {
|
|
videoStream.getTracks().forEach(t => t.stop());
|
|
setVideoStream(null);
|
|
}
|
|
setskinBox(null);
|
|
setView('landing');
|
|
setFile(null);
|
|
setPreviewUrl('');
|
|
setAnalysisResults(null);
|
|
}}
|
|
|
|
>
|
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
</header>
|
|
|
|
|
|
|
|
<main className="w-full max-w-2xl mx-auto flex flex-col items-center justify-center relative">
|
|
|
|
{/* Landing View */}
|
|
{view === 'landing' && (
|
|
<div className="flex flex-col items-center justify-center w-full px-4 gap-8 fade-in mt-12 md:mt-24 relative">
|
|
|
|
{/* Background Ambient Glows */}
|
|
<div className="absolute top-10 left-1/2 w-[300px] h-[300px] bg-limelite-red/20 rounded-full blur-[100px] -translate-x-1/2 -z-10"></div>
|
|
<div className="absolute bottom-10 left-1/4 w-[200px] h-[200px] bg-limelite-maroon/20 rounded-full blur-[80px] -z-10"></div>
|
|
|
|
{/* Decorative AI Icon */}
|
|
<div className="relative mb-4">
|
|
<div className="absolute inset-0 bg-limelite-red/20 blur-xl rounded-full animate-pulse"></div>
|
|
<div className="w-20 h-20 bg-white/5 border border-white/10 rounded-full flex items-center justify-center backdrop-blur-md relative z-10 shadow-xl shadow-limelite-red/10">
|
|
<svg className="w-10 h-10 text-limelite-red" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-center space-y-4 mb-2 z-10">
|
|
<h1 className="text-4xl md:text-5xl font-serif italic text-white leading-tight tracking-[0.02em]">
|
|
Intelligent Face Analysis
|
|
</h1>
|
|
<p className="text-gray-400 font-light max-w-md mx-auto text-sm md:text-base px-4">
|
|
Experience our next-generation AI. We analyze your facial features and skin condition to generate a deeply personalized aesthetic report.
|
|
</p>
|
|
<p className="text-xs text-limelite-red font-sans font-bold tracking-widest uppercase mt-6 max-w-sm mx-auto">
|
|
Select your action to begin
|
|
</p>
|
|
</div>
|
|
|
|
{/* Action Buttons Container */}
|
|
<div className="w-full flex flex-col sm:flex-row items-center justify-center gap-4 max-w-sm sm:max-w-md mx-auto z-10">
|
|
{/* Take Photo Card */}
|
|
<button
|
|
onClick={() => setView('camera')}
|
|
className="w-full sm:flex-1 bg-white text-[#1A1A1A] rounded-xl px-6 py-4 font-bold text-sm tracking-widest uppercase shadow-[0_0_20px_rgba(255,255,255,0.1)] hover:shadow-[0_0_25px_rgba(255,255,255,0.2)] hover:bg-gray-100 transition-all text-center border-2 border-transparent"
|
|
>
|
|
Take Photo
|
|
</button>
|
|
|
|
{/* Upload Photo Card */}
|
|
<button
|
|
onClick={() => document.getElementById('uploadInput').click()}
|
|
className="w-full sm:flex-1 bg-[#1A1A1A] text-white border border-gray-700 rounded-xl px-6 py-4 font-bold text-sm tracking-widest uppercase shadow-lg hover:bg-black transition-all text-center hover:border-gray-500"
|
|
>
|
|
Upload Photo
|
|
</button>
|
|
<input
|
|
type="file"
|
|
id="uploadInput"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleFileUpload}
|
|
/>
|
|
</div>
|
|
|
|
{/* Feature Chips */}
|
|
<div className="flex flex-wrap items-center justify-center gap-3 mt-8 z-10 opacity-70">
|
|
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-white/5 border border-white/10 text-xs text-gray-300">
|
|
<svg className="w-3.5 h-3.5 text-limelite-red" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
|
Precision Mapping
|
|
</div>
|
|
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-white/5 border border-white/10 text-xs text-gray-300">
|
|
<svg className="w-3.5 h-3.5 text-limelite-red" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
|
Deep Skin Analysis
|
|
</div>
|
|
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-white/5 border border-white/10 text-xs text-gray-300">
|
|
<svg className="w-3.5 h-3.5 text-limelite-red" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
|
Instant PDF Report
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
)}
|
|
{
|
|
view === 'camera' && (
|
|
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
|
<div className="relative flex-1 overflow-hidden">
|
|
<video ref={videoRef} autoPlay playsInline className="w-full h-full object-cover transform scale-x-[-1]"></video>
|
|
|
|
{/* skin Mesh / Wireframe Overlay Placeholder */}
|
|
<div className="absolute inset-0 z-10 pointer-events-none">
|
|
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/40"></div>
|
|
</div>
|
|
|
|
{/* Shutter Button container */}
|
|
<div className="absolute bottom-12 left-0 right-0 flex justify-center items-center z-20">
|
|
<button
|
|
onClick={handleCaptureskin}
|
|
className="transform active:scale-95 transition-all cursor-pointer bg-limelite-red w-36 h-18 py-4 px-10 rounded-full shadow-[0_0_20px_rgba(0,151,195,0.3)] flex items-center justify-center border-none"
|
|
>
|
|
<IoMdAperture className="w-12 h-12 text-white" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
{/* Preview View */}
|
|
{
|
|
view === 'preview' && (
|
|
<div className="fixed inset-0 z-50 bg-black flex flex-col fade-in">
|
|
<div className="relative flex-1 overflow-hidden">
|
|
{previewUrl ? (
|
|
<img src={previewUrl} alt="Preview" className="w-full h-full object-cover" />
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center text-gray-400">No Image</div>
|
|
)}
|
|
|
|
{/* Scanning Animation only when loading */}
|
|
{isLoading && (
|
|
<div className="absolute inset-0 z-20 pointer-events-none overflow-hidden">
|
|
<div className="w-full h-[2px] bg-limelite-red shadow-[0_0_20px_rgba(0,151,195,0.8)] absolute top-0 animate-[scan_3s_linear_infinite]"></div>
|
|
<div className="w-full h-32 bg-gradient-to-t from-limelite-red/20 to-transparent absolute top-0 -translate-y-full animate-[scanTrace_3s_linear_infinite]"></div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Bottom Controls */}
|
|
<div className="absolute bottom-10 left-0 right-0 flex flex-col items-center justify-center gap-4 z-30 px-6">
|
|
<div className="flex items-center gap-4 w-full justify-center">
|
|
|
|
|
|
<button
|
|
onClick={handleRetakeClick}
|
|
disabled={isLoading}
|
|
className="h-14 px-6 bg-white text-black rounded-none flex items-center justify-center gap-2 shadow-lg hover:bg-gray-200 transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
aria-label="Retake"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
<span className="font-bold text-sm tracking-widest uppercase">Retake</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleSubmitClick}
|
|
disabled={isLoading}
|
|
className="h-14 w-20 bg-limelite-red text-white rounded-none flex items-center justify-center shadow-lg hover:bg-limelite-maroon transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
aria-label="Confirm"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M1 13l4 4L15 7m-6 6l4 4L23 7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
{/* Error Popup */}
|
|
{
|
|
error && (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm p-6 animate-[fadeIn_0.2s_ease-out]">
|
|
<div className="bg-white rounded-none shadow-2xl w-full max-w-sm p-8 text-center">
|
|
<div className="w-16 h-16 bg-cyan-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
|
<svg className="w-8 h-8 text-limelite-red" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-2xl font-bold text-gray-900 mb-2">Analysis Failed</h3>
|
|
<p className="text-gray-600 mb-8 leading-relaxed text-sm">
|
|
{(() => {
|
|
const foundCode = Object.keys(errorDescriptions).find(code => error && error.includes(code));
|
|
return foundCode ? errorDescriptions[foundCode] : error;
|
|
})()}
|
|
</p>
|
|
<button
|
|
onClick={handleRetakeClick}
|
|
className="w-full bg-limelite-red text-white font-bold py-4 px-6 rounded-none hover:bg-limelite-maroon transition-all active:scale-95 uppercase tracking-widest text-sm"
|
|
>
|
|
Retake Photo
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
</main >
|
|
|
|
{/* Help Popup */}
|
|
{showHelp && (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-[fadeIn_0.2s_ease-out]">
|
|
<div className="bg-white rounded-none shadow-2xl w-full max-w-sm p-8 relative">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h2 className="text-3xl font-bold text-black">Help</h2>
|
|
<button
|
|
onClick={() => setShowHelp(false)}
|
|
className="p-2 -mr-2 text-black hover:bg-gray-100 rounded-full transition-colors"
|
|
>
|
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Content... */}
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-gray-400 mt-2.5 shrink-0"></div>
|
|
<p className="text-lg text-gray-700 leading-relaxed">
|
|
To access previous reports, please contact us at <a href="mailto:hello@limelite.com" className="font-bold text-black">hello@limelite.com</a>
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-gray-400 mt-2.5 shrink-0"></div>
|
|
<p className="text-lg text-gray-700 leading-relaxed">
|
|
If you experience any technical issues, please contact us at <a href="mailto:support@example.com" className="font-bold text-black">support@example.com</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div >
|
|
</div >
|
|
);
|
|
};
|
|
|
|
export default App; |