/**
* TryOn.js - Virtual dressing room component for mixing and matching clothing.
*
* @fileoverview Provides try-on functionality, including uploading personal images,
* generating AI-assisted outfit previews, managing virtual closets, saving combinations,
* viewing recommendations, and navigating the user interface.
*
* @authors
* - Peini SHE: Initial implementation and layout, reccomendation and result image presentation
* - Zhihao CAO: Image generation workflow, result image rendering, combination save and show
* - Zixin DING:
* - Implemented clothing closet panel (category filtering, removal)
* - Implemented combination panel (delete combination, display result image on click)
* - Implemented search bar interaction (enter-to-search routing)
*
* @description
* This component is the core UI for the virtual try-on experience. It integrates frontend interaction
* with the backend API for image processing (StableVITON), clothing management, and personalization.
*/
import { useNavigate } from "react-router-dom";
import { useState, useEffect, useRef } from "react";
import "./TryOn.css";
/**
* TryOn component allows users to preview clothing on their own image, manage closet, and save looks.
*
* @component
* @param {Object} props
* @param {string} props.username - Current username.
* @param {string} props.userId - ID of the logged-in user.
* @param {string|null} props.uploadedImage - User's uploaded image for try-on.
* @param {Function} props.setUploadedImage - Setter for uploaded image.
* @param {string|null} props.resultImage - Resulting AI-generated image.
* @param {Function} props.setResultImage - Setter for generated result image.
* @returns {JSX.Element}
*/
function TryOn({ username, userId, uploadedImage, setUploadedImage, resultImage, setResultImage }) {
const navigate = useNavigate();
const timeoutRef = useRef(null);
const [showDropdown, setShowDropdown] = useState(false);
const [myCloset, setMyCloset] = useState([]);
const [combinations, setCombinations] = useState([]);
const [recommendations, setRecommendations] = useState([]);
const [selectedCategory, setSelectedCategory] = useState("tops");
const [searchQuery, setSearchQuery] = useState("");
const [error, setError] = useState("");
const [activePanel, setActivePanel] = useState(null);
const handleSearch = (e) => {
if (e.key === "Enter" && searchQuery.trim() !== "") {
navigate(`/fullcloset?user_id=${userId}&query=${encodeURIComponent(searchQuery)}`);
}
};
/**
* Toggle visibility of side panels like closet, combinations, and recommendations.
* @param {string} panelName - The name of the panel to toggle.
*/
const togglePanel = (panelName) => {
setActivePanel((prevPanel) => (prevPanel === panelName ? null : panelName));
};
// modified by Zixin Ding: Fix url problem
// added by Zhihao Cao --->process image and represent the result
// auto upload without any aother operation---> useEffect
// tips: the default value of isGenerating is false
const [isGenerating, setIsGenerating] = useState(false); //generate button must be disabled when generating
/**
* Send clothing and user image to backend for generating a virtual try-on preview.
* @param {string} itemUrl - URL of the selected clothing image.
* @param {string} itemCategory - Category of clothing (e.g., tops, dresses).
*/
const handleGenerateImage = async (itemId,itemUrl,itemCategory) => { //itemUrl is the url of the item in the closet
setIsGenerating(true); // disable the button
if (!uploadedImage) {
console.error("failed to get user_image");
window.alert("Please upload your image first"); // alert notification
return;
}
// use document notice to replace the windows.alert() [need user to respond]
const loadingMessage = document.createElement('div');
loadingMessage.textContent = "Please wait while the image is being generated...";
loadingMessage.style.position = 'fixed';
loadingMessage.style.top = '50%';
loadingMessage.style.left = '50%';
loadingMessage.style.transform = 'translate(-50%, -50%)';
loadingMessage.style.padding = '10px';
loadingMessage.style.backgroundColor = '#000';
loadingMessage.style.color = '#fff';
loadingMessage.style.zIndex = '9999'; // ensure the message is on top of other elements
document.body.appendChild(loadingMessage);
try {
const response = await fetch("http://localhost:5000/process_image", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
mode: "cors",
// TODO: wait for the user_id to be added
body: JSON.stringify({
item_id: itemId,
cloth_url: itemUrl,
user_id: userId,
item_category:itemCategory
}),
});
document.body.removeChild(loadingMessage);
// After consideration: there is no waiting ui for the user to pick the clothes
const result = await response.json();
if(!response.ok) {
window.alert("failed to generate"); // alert
return;
}
// TODO: change resultImage into stable-viton result
//tips: use "`" to represent the result instead of '' or " "
const uploadedImager = `/show_image/${userId}/${result.image_path}`;
setResultImage(uploadedImager);
} catch (error) {
document.body.removeChild(loadingMessage);
window.alert(error.message);
} finally {
setIsGenerating(false); // re-enable the button
}
};
/**
* Auto-fetch user's uploaded image from backend if not set.
*/
useEffect(() => {
const fetchUserImage = async () => {
if (!userId || uploadedImage) return;
try {
const response = await fetch(`http://localhost:5000/get_user_info?user_id=${userId}`);
const data = await response.json();
if (response.ok && data.image_path) {
const fixedPath = data.image_path.replace(/\\/g, "/"); // Windows fix
const fullUrl = `http://localhost:5000/${fixedPath}`;
setUploadedImage(fullUrl);
}
} catch (error) {
console.error("Failed to fetch user image", error);
}
};
fetchUserImage();
}, [userId, uploadedImage]);
/**
* Fetch closet items by selected category.
*/
// Added by Zixin Ding
useEffect(() => {
if (!userId) {
console.warn("No userId provided. Unable to fetch closet.");
return;
}
const fetchCloset = async () => {
try {
const response = await fetch(`http://localhost:5000/get-closet?user_id=${userId}&category=${selectedCategory}`);
const data = await response.json();
if (data.error) {
setError(data.error);
setMyCloset([]);
} else {
setMyCloset(data.closet);
setError("");
}
} catch (err) {
setError("Failed to fetch closet items");
}
};
fetchCloset();
}, [selectedCategory, userId]); // Monitor category change
/**
* Remove a clothing item from the user's closet.
* @param {string} clothingId - ID of the clothing to remove.
*/
// Added by Zixin Ding
const handleRemoveFromCloset = async (clothingId) => {
try {
const response = await fetch("http://localhost:5000/remove-from-closet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId, clothing_id: clothingId }),
});
const result = await response.json();
if (response.ok) {
setMyCloset(myCloset.filter(item => item.id !== clothingId));
} else {
setError(result.error);
}
} catch (error) {
setError("Failed to remove item.");
}
};
/**
* Save a clothing combination with optional result image.
*/
const handleSaveCombination = async () => {
// if (!resultImage) {
// console.error("No outfit image available");
// return;
// }
// authored by Zhihao Cao
const selectedTop = myCloset.find(item => item.category === "tops")?.id || null;
const selectedBottom = myCloset.find(item => item.category === "bottoms")?.id || null;
const selectedDress = myCloset.find(item => item.category === "dresses")?.id || null;
if (!resultImage) {
window.alert("please try on firstly")
return;
}
const response = await fetch("http://127.0.0.1:5000/save-combination", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: userId,
top_id: selectedTop,
bottom_id: selectedBottom,
dress_id: selectedDress,
// TODO: fix to true result
resultImage: resultImage
})
});
const data = await response.json();
if (!response.ok) {
window.alert("save failed")
return;
}
console.log("Saved outfit:", data);
if (response.ok) {
setCombinations(prev => [...prev, { id: data.id, image: data.url }]);
window.alert(data.message);
if (data.message === "Combination saved!") {
setCombinations(prev => [...prev, { id: data.id, image: data.url }]);
}
}
}
/**
* Load previously saved clothing combinations.
*/
useEffect(() => {
if (!userId) {
console.warn("No userId provided. Unable to fetch combinations.");
return;
}
const getCombinations = async () => {
try {
const response = await fetch(`http://127.0.0.1:5000/get-combinations?user_id=${userId}`);
const data = await response.json();
if (data.error) {
setError(data.error);
setCombinations([]);
} else {
setCombinations(data.combinations);
setError("");
}
} catch (err) {
setError("Failed to fetch combinations");
}
};
getCombinations();
}, [userId]);
/**
* Fetch personalized clothing recommendations.
*/
useEffect(() => {
if (!userId) {
console.warn("No userId provided. Unable to fetch recommendations.");
return;
}
const getRecommendationsPersonal = async () => {
try {
const response = await fetch(`http://127.0.0.1:5000/recommend/user/${userId}`);
const data = await response.json();
if (data.error) {
setError(data.error);
setRecommendations([]);
} else {
setRecommendations(data.personalized_recommendations);
setError("");
}
} catch (err) {
setError("Failed to fetch recommendations");
}
};
getRecommendationsPersonal();
}, [userId]);
const handleDeleteCombination = async (combinationId) => {
try {
const response = await fetch("http://localhost:5000/delete-combination", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: combinationId }),
});
const result = await response.json();
if (response.ok) {
setCombinations(prev => prev.filter(c => c.id !== combinationId));
window.alert("Deleted successfully!");
} else {
window.alert(result.error || "Failed to delete combination");
}
} catch (error) {
window.alert("Request failed: " + error.message);
}
};
return (
<div className="tryon-container">
{/* Header */}
<header className="tryon-header">
<h1 className="logo">OVDR</h1>
<input
type="text"
className="search-bar"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearch}
placeholder="Search by keywords to access the clothes you like"
/>
{/* Account Dropdown */}
<div
className="account-container"
onMouseEnter={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setShowDropdown(true);}}
onMouseLeave={() => {timeoutRef.current = setTimeout(() => {
setShowDropdown(false);
}, 500);}}
>
<button className="account-btn">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Z"></path>
<path d="M12 6a3 3 0 1 1-3 3 3 3 0 0 1 3-3ZM6 18a6 6 0 0 1 12 0"></path>
</svg>
</button>
{showDropdown && (
<div className="account-dropdown">
<div className="account-info">Hello {username}!</div>
<div className="dropdown-item" onClick={() => navigate(`/history?user_id=${userId}`)}>
View My History
</div>
<div className="dropdown-item" onClick={() => navigate("/login")}>Log out</div>
</div>
)}
</div>
</header>
{/* Display uploaded image or prompt user to upload */}
<div className="tryon-content">
{uploadedImage ? (
<>
<button className="change-img-btn" onClick={() => navigate("/upload")}>Change Image</button>
<div className="tryon-display">
{/* Display uploaded image (result first)*/}
<img src={resultImage||uploadedImage} alt="Try on result" className="tryon-result-img" />
</div>
<div className="tryon-actions">
<button className="save-btn" onClick={handleSaveCombination}>Save Combination</button>
<button className="continue-btn" onClick={() => navigate("/download")}>Send to Email</button>
</div>
</>
) : (
<div className="upload-prompt">
<h2 className="tryon-placeholder">You haven't uploaded a full-body image yet.</h2>
<p>Please upload one now to start using the virtual dressing room.</p>
<button className="upload-btn" onClick={() => navigate("/upload")}>Upload Image</button>
</div>
)}
</div>
{/* Sidebar */}
<aside className="tryon-sidebar">
<button className={`sidebar-btn ${activePanel === "closet" ? "active" : ""}`}
onClick={() => togglePanel("closet")}
>
My Closet
</button>
<button className={"sidebar-btn"}
onClick={() => navigate(`/fullcloset?user_id=${userId}`)}
>
View All Clothes
</button>
<button className={`sidebar-btn ${activePanel === "combinations" ? "active" : ""}`}
onClick={() => togglePanel("combinations")}
>
Saved Combinations
</button>
<button className={`sidebar-btn ${activePanel === "recommendations" ? "active" : ""}`}
onClick={() => togglePanel("recommendations")}
>
Explore Recommendations
</button>
</aside>
{/* Closet Panel */}
{activePanel === "closet" && (
<div className="mycloset-panel">
<h3>My Try-On Closet</h3>
<select
className="mycloset-dropdown"
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
>
<option value="tops">Tops</option>
<option value="dresses">Dresses</option>
<option value="bottoms">Bottoms</option>
</select>
{error && <p className="error-message">{error}</p>}
<ul className="mycloset-list">
{myCloset.length > 0 ? (
myCloset.map((item) => (
<li key={item.id} className="mycloset-item">
<img src={item.url} alt="Clothing" className="mycloset-img"/>
{/*added by zhihao Cao ---> generate the image button*/}
<button className="generate-btn" onClick={(e) =>
{
if(isGenerating) {
window.alert("Please wait for the image to be generated");
e.stopPropagation();
return;
}
handleGenerateImage(item.id,item.url,item.category) /* Prevents the button from being clicked more than once*/
}
}
disabled={isGenerating}
>
{isGenerating ? "Generating..." : "Tryon"}
</button>
{/*end*/}
<button className="remove-btn" onClick={() => handleRemoveFromCloset(item.id)}>Remove</button>
</li>
))
) : (
<p>No items found for this category.</p>
)}
</ul>
</div>
)}
{/* Combinations Panel */}
{activePanel === "combinations" && (
<div className="combination-panel">
<h3>My Combinations</h3>
<ul className="combination-list">
{combinations.length > 0 ? (
combinations.map((item) => (
<li key={item.id} className="combination-item">
<img
src={item.url}
alt="combination"
className="combination-img"
onClick={() => setResultImage(item.url)} // ✅ 只点击图片才设置为主图
style={{ cursor: "pointer" }}
/>
<button
className="remove-btn"
onClick={() => handleDeleteCombination(item.id)}
>
Delete
</button>
</li>
))
): (
<p>No saved combinations.</p>
)}
</ul>
</div>
)}
{/* Recommendations Panel */}
{activePanel === "recommendations" && (
<div className="recommendations-panel">
<h3>People like you also prefer</h3>
<ul className="recommendation-list">
{recommendations.map((item) => (
<li key={item.id} className="recommendation-item" onClick={() => navigate(`/detail/${item.id}`, { state: { item }})}>
<img src={item.url} alt="recommends" className="recommendation-img"/>
</li>
))}
</ul>
</div>
)}
{/* Footer */}
<footer className="tryon-footer">
<a href="http://cslinux.nottingham.edu.cn/~Team202407/">About Us</a>
<a href="/privacy.html" target="_blank" rel="noopener noreferrer">Privacy Policy</a>
<a href="/docs/user_manual.pdf" target="_blank" rel="noopener noreferrer">Manual</a>
<a href="/contact.html">Help and Contact</a>
<p>Developed by TEAM2024.07</p>
</footer>
</div>
);
}
export default TryOn;