Initial commit: added frontend and backend
This commit is contained in:
@@ -0,0 +1,658 @@
|
||||
// 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_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: #0097C3; 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 () => {
|
||||
const detections = await skinapi.detectSingleFace(videoRef.current, new skinapi.TinyFaceDetectorOptions());
|
||||
if (detections) setskinBox(detections.box);
|
||||
else setskinBox(null);
|
||||
};
|
||||
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 {
|
||||
// --- Ensure image meets API min size ---
|
||||
let resized;
|
||||
try {
|
||||
resized = await resizeImageFile(file, 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 ZIP & save analysis ---
|
||||
try {
|
||||
const maskZipUrl = result.result?.results?.[0]?.data?.[0]?.url || result.result?.url;
|
||||
if (!maskZipUrl) throw new Error("No mask ZIP URL found in result");
|
||||
setZipUrl(maskZipUrl);
|
||||
|
||||
const zipRes = await fetch(maskZipUrl);
|
||||
if (!zipRes.ok) throw new Error(`Failed to download ZIP file (status: ${zipRes.status})`);
|
||||
const zipBlob = await zipRes.blob();
|
||||
const zip = await JSZip.loadAsync(zipBlob);
|
||||
|
||||
const scoreFile = zip.file("skinanalysisResult/score_info.json") || zip.file("score_info.json");
|
||||
const scores = scoreFile ? JSON.parse(await scoreFile.async("text")) : result.result?.results?.[0]?.scores || {};
|
||||
if (Object.keys(scores).length === 0) throw new Error("Could not extract scores from analysis result.");
|
||||
|
||||
|
||||
const newMaskMap = {};
|
||||
for (const key of Object.keys(scores)) {
|
||||
const maskName = scores[key].output_mask_name;
|
||||
if (!maskName) continue;
|
||||
const f = zip.file(`skinanalysisResult/${maskName}`) || zip.file(`masks/${maskName}`) || zip.file(maskName);
|
||||
if (!f) continue;
|
||||
const b = await f.async('blob');
|
||||
newMaskMap[maskName] = await blobToBase64(b);
|
||||
}
|
||||
|
||||
const originalBase64 = await blobToBase64(await fetch(previewUrl).then(r => r.blob()));
|
||||
await saveToDB('analysisData', {
|
||||
scoreData: scores,
|
||||
maskImages: newMaskMap,
|
||||
originalImage: originalBase64,
|
||||
zipDownloadUrl: maskZipUrl
|
||||
});
|
||||
|
||||
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-[#F8F9FA]">
|
||||
<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-[#0097C3] shadow-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<img src={process.env.PUBLIC_URL + '/assets/images/elixify_logo.png'} alt="Elixify Logo" className="h-12 md:h-16 w-auto brightness-0 invert" />
|
||||
</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-20 md:mt-32">
|
||||
<div className="text-center space-y-4 mb-2">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-[#0e5e73] leading-tight tracking-[0.02em] uppercase" style={{ fontFamily: "'Oswald', sans-serif" }}>
|
||||
AI-POWERED<br />SKIN ANALYSER
|
||||
</h1>
|
||||
<p className="text-lg text-gray-700 font-medium tracking-wide max-w-sm mx-auto">
|
||||
Experience professional-grade skin analysis in seconds
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons Container */}
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
{/* Take Photo Card */}
|
||||
<div
|
||||
onClick={() => setView('camera')}
|
||||
className="bg-white border-2 border-dashed border-[#0097C3]/30 p-8 flex flex-col items-center justify-center gap-4 cursor-pointer hover:border-[#0097C3] transition-all duration-300 shadow-xl group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-[#0097C3]/10 rounded-full flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<svg className="w-8 h-8 text-[#0097C3]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-bold text-black uppercase tracking-widest">Take a Photo</p>
|
||||
<p className="text-gray-500 text-sm mt-1">Capture using your camera</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Photo Card */}
|
||||
<div
|
||||
onClick={() => document.getElementById('uploadInput').click()}
|
||||
className="bg-white border-2 border-dashed border-[#0097C3]/30 p-8 flex flex-col items-center justify-center gap-4 cursor-pointer hover:border-[#0097C3] transition-all duration-300 shadow-xl group"
|
||||
>
|
||||
<div className="w-16 h-16 bg-[#0097C3]/10 rounded-full flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<svg className="w-8 h-8 text-[#0097C3]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-bold text-black uppercase tracking-widest">Upload Photo</p>
|
||||
<p className="text-gray-500 text-sm mt-1">Select from your gallery</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
id="uploadInput"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Link */}
|
||||
<div className="mt-8">
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="text-gray-400 hover:text-[#0097C3] transition-colors text-xs uppercase tracking-widest font-bold"
|
||||
>
|
||||
Admin Login
|
||||
</button>
|
||||
</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-[#0097C3] 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-[#0097C3] 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-[#0097C3]/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-[#0097C3] text-white rounded-none flex items-center justify-center shadow-lg hover:bg-[#007ba0] 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-[#0097C3]" 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-[#0097C3] text-white font-bold py-4 px-6 rounded-none hover:bg-[#007ba0] 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@elixify.com" className="font-bold text-black">hello@elixify.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;
|
||||
Reference in New Issue
Block a user