import { useState, useRef, useEffect, useCallback } from 'react'; import { RefreshCw, Plus, Upload, Edit, Trash2, RefreshCcw, HelpCircle, Sparkles, Info, Check, X } from 'lucide-react'; import AddMaterialModal from './AddMaterialModal.js'; import { createPortal } from 'react-dom'; // Add custom scrollbar hiding styles const scrollbarHideStyles = ` /* Hide scrollbar for Chrome, Safari and Opera */ .scrollbar-hide::-webkit-scrollbar { display: none; } /* Hide scrollbar for IE, Edge and Firefox */ .scrollbar-hide { -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ } `; // Define default style options with display names and prompts const defaultStyleOptions = { material: { name: "Chrome", file: "chrome.jpeg", prompt: "Recreate this doodle as a physical, floating chrome sculpture made of a chromium metal tubes or pipes in a professional studio setting. If it is typography, render it accordingly, but always always have a black background and studio lighting. Render it using Cinema 4D with Octane, using studio lighting against a pure black background. Make it look like a high-end elegant rendering of a sculptural piece. Flat Black background always" }, honey: { name: "Honey", file: "honey.jpeg", prompt: "Transform this sketch into a honey. Render it as if made entirely of translucent, golden honey with characteristic viscous drips and flows. Add realistic liquid properties including surface tension, reflections, and light refraction. Render it in Cinema 4D with Octane, using studio lighting against a pure black background. Flat Black background always" }, softbody: { name: "Soft Body", file: "softbody.jpeg", prompt: "Convert this drawing / text into a soft body physics render. Render it as if made of a soft, jelly-like material that responds to gravity and motion. Add realistic deformation, bounce, and squash effects typical of soft body dynamics. Use dramatic lighting against a black background to emphasize the material's translucency and surface properties. Render it in Cinema 4D with Octane, using studio lighting against a pure black background. Make it look like a high-end 3D animation frame." }, testMaterial: { name: "Surprise Me!", file: "test-material.jpeg", prompt: "Transform this sketch into an experimental material with unique and unexpected properties. Each generation should be different and surprising - it could be crystalline, liquid, gaseous, organic, metallic, or something completely unexpected. Use dramatic studio lighting against a pure black background to showcase the material's unique characteristics. Render it in a high-end 3D style with professional lighting and composition, emphasizing the most interesting and unexpected qualities of the chosen material." } }; // Create a mutable copy that will include user-added materials export let styleOptions = { ...defaultStyleOptions }; // Define the base prompt template const BASE_PROMPT = (materialName) => `Transform this sketch into a ${materialName.toLowerCase()} material. Render it in a high-end 3D visualization style with professional studio lighting against a pure black background. Make it look like an elegant Cinema 4D and Octane rendering with detailed material properties and characteristics. The final result should be an elegant visualization with perfect studio lighting, crisp shadows, and high-end material definition.`; // Function to add a material directly to the library export const addMaterialToLibrary = (material) => { // Create a unique key for the material based on its name and timestamp const materialKey = `${material.name.toLowerCase().replace(/\s+/g, '_')}_${Date.now()}`; // Create the material object const newMaterial = { name: material.name, prompt: material.prompt || BASE_PROMPT(material.name), thumbnail: material.image || material.thumbnail, originalDescription: material.name, isCustom: true }; // Add to styleOptions styleOptions[materialKey] = newMaterial; // Save to localStorage try { const savedMaterials = localStorage.getItem('customMaterials') || '{}'; const customMaterials = JSON.parse(savedMaterials); customMaterials[materialKey] = newMaterial; localStorage.setItem('customMaterials', JSON.stringify(customMaterials)); } catch (error) { console.error('Error saving material to localStorage:', error); } // Return the key so it can be selected return materialKey; }; // --- Updated function to use /api/enhance-prompt --- const enhanceMaterialDetails = async (materialDescription) => { console.log("Enhancing prompt for:", materialDescription); const basePrompt = BASE_PROMPT(materialDescription); // Generate the base prompt to send try { const response = await fetch("/api/enhance-prompt", { // Call the correct API endpoint method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ materialName: materialDescription, // Send the user's input as materialName basePrompt: basePrompt // Send the generated base prompt }) }); if (!response.ok) { // Handle API errors (like 500) throw new Error(`API responded with status ${response.status}`); } const data = await response.json(); console.log("Enhanced prompt data:", data); // Use the response from /api/enhance-prompt // The API itself has fallback logic if JSON parsing fails, // returning { enhancedPrompt: basePrompt, suggestedName: materialName } if (data.enhancedPrompt && data.suggestedName) { return { name: data.suggestedName, prompt: data.enhancedPrompt }; } else { // This case might occur if the API returns unexpected JSON structure throw new Error('Invalid enhancement data received from /api/enhance-prompt'); } } catch (error) { console.error("Error enhancing prompt:", error); // Fallback if the fetch fails or response is totally invalid const capitalizedName = materialDescription .split(' ') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' '); return { name: `${capitalizedName} Style`, // Slightly different fallback name prompt: basePrompt // Use the original base prompt as fallback }; } }; // --- End of updated function --- // Function to get prompt based on style mode export const getPromptForStyle = (styleMode) => { if (!styleMode || !styleOptions[styleMode]) { return styleOptions.material.prompt; } return styleOptions[styleMode].prompt || styleOptions.material.prompt; }; // Replace with a simpler generatePromptForMaterial function export const generatePromptForMaterial = (materialName) => { return `Transform this sketch into a ${materialName.toLowerCase()} material. Render it in a high-end 3D visualization style with professional studio lighting against a pure black background. Make it look like a elegant Cinema 4D and octane rendering with detailed material properties and characteristics.`; }; const StyleSelector = ({ styleMode, setStyleMode, handleGenerate }) => { const [showAddMaterialModal, setShowAddMaterialModal] = useState(false); const [newMaterialName, setNewMaterialName] = useState(''); const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false); const [useCustomImage, setUseCustomImage] = useState(false); const [customImagePreview, setCustomImagePreview] = useState(''); const [customImageFile, setCustomImageFile] = useState(null); const fileInputRef = useRef(null); const [recentlyAdded, setRecentlyAdded] = useState(null); const [customPrompt, setCustomPrompt] = useState(''); const [showCustomPrompt, setShowCustomPrompt] = useState(false); const [previewThumbnail, setPreviewThumbnail] = useState(''); const [isGeneratingPreview, setIsGeneratingPreview] = useState(false); const [materials, setMaterials] = useState(defaultStyleOptions); const [generatedMaterialName, setGeneratedMaterialName] = useState(''); const [generatedPrompt, setGeneratedPrompt] = useState(''); const [isGeneratingText, setIsGeneratingText] = useState(false); const [showMaterialNameEdit, setShowMaterialNameEdit] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const [showPromptInfo, setShowPromptInfo] = useState(null); const [promptPopoverPosition, setPromptPopoverPosition] = useState({ top: 0, left: 0 }); const styleSelectorRef = useRef(null); const [editingPrompt, setEditingPrompt] = useState(null); const [editedPromptText, setEditedPromptText] = useState(''); const [hasPromptChanged, setHasPromptChanged] = useState(false); const [generatedThumbnail, setGeneratedThumbnail] = useState(null); const [thumbnailError, setThumbnailError] = useState(null); // Load custom materials from local storage on component mount useEffect(() => { loadCustomMaterials(); }, []); // Add effect to update materials state when styleOptions changes useEffect(() => { // This ensures the UI reflects changes to styleOptions made from outside this component setMaterials({...styleOptions}); }, [styleOptions]); // Extract loadCustomMaterials into its own named function const loadCustomMaterials = () => { try { const savedMaterials = localStorage.getItem('customMaterials'); if (savedMaterials) { const parsedMaterials = JSON.parse(savedMaterials); // Update both the styleOptions and the state const updatedMaterials = { ...defaultStyleOptions, ...parsedMaterials }; styleOptions = updatedMaterials; setMaterials(updatedMaterials); console.log('Loaded custom materials from local storage'); } } catch (error) { console.error('Error loading custom materials:', error); } }; // Modify the useEffect that handles material descriptions useEffect(() => { const delayedGeneration = async () => { // Skip if no material name or if we're in edit mode if (!newMaterialName.trim() || recentlyAdded) return; // ONLY set text generation loading state setIsGeneratingText(true); try { // Use our updated enhanceMaterialDetails function (which now calls /api/enhance-prompt) const enhanced = await enhanceMaterialDetails(newMaterialName); console.log("Received enhanced data in useEffect:", enhanced); setGeneratedMaterialName(enhanced.name); setGeneratedPrompt(enhanced.prompt); // DO NOT generate thumbnail here } catch (error) { console.error('Error in material generation (useEffect):', error); // Fall back to basic generation if enhanceMaterialDetails fails catastrophically // (This shouldn't happen often as enhanceMaterialDetails has its own internal fallback) const capitalizedName = newMaterialName .split(' ') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' '); setGeneratedMaterialName(`${capitalizedName} Style`); setGeneratedPrompt(BASE_PROMPT(newMaterialName)); } finally { setIsGeneratingText(false); } }; // Delay generation to avoid too many API calls while typing const timeoutId = setTimeout(delayedGeneration, 1500); return () => clearTimeout(timeoutId); }, [newMaterialName, recentlyAdded]); // Dependencies remain the same // Helper function to resize and compress image data const compressImage = (dataUrl, maxWidth = 200) => { return new Promise((resolve) => { const img = new Image(); img.onload = () => { // Create a canvas to resize the image const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); // Calculate new dimensions let width = img.width; let height = img.height; if (width > maxWidth) { height = (height * maxWidth) / width; width = maxWidth; } canvas.width = width; canvas.height = height; // Draw and export as JPEG with lower quality ctx.drawImage(img, 0, 0, width, height); resolve(canvas.toDataURL('image/jpeg', 0.7)); }; img.src = dataUrl; }); }; // Function to debug storage usage const checkStorageUsage = () => { let totalSize = 0; let itemCount = 0; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); const value = localStorage.getItem(key); const size = (key.length + value.length) * 2; // Approximate size in bytes (UTF-16) totalSize += size; itemCount++; console.log(`Item: ${key}, Size: ${(size / 1024).toFixed(2)}KB`); } console.log(`Total localStorage usage: ${(totalSize / 1024 / 1024).toFixed(2)}MB, Items: ${itemCount}`); return totalSize; }; const handleAddMaterial = () => { resetMaterialForm(); setRecentlyAdded(null); setShowAddMaterialModal(true); }; const handleCloseModal = () => { setShowAddMaterialModal(false); setNewMaterialName(''); setUseCustomImage(false); setCustomImagePreview(''); setCustomImageFile(null); setCustomPrompt(''); setShowCustomPrompt(false); setPreviewThumbnail(''); }; // Handle clicking outside of the modal to close it const handleClickOutsideModal = (e) => { // If the clicked element is the backdrop (has the modalBackdrop class) if (e.target.classList.contains('modalBackdrop')) { handleCloseModal(); } }; const handleFileChange = (e) => { const file = e.target.files[0]; if (!file) return; if (!file.type.startsWith('image/')) { alert('Please select an image file'); return; } const reader = new FileReader(); reader.onload = () => { // When the file is loaded, create a temporary image to extract a square crop const img = new Image(); img.onload = () => { // Create a canvas element to crop the image to a square const canvas = document.createElement('canvas'); // Determine the size of the square (min of width and height) const size = Math.min(img.width, img.height); canvas.width = size; canvas.height = size; // Calculate the position to start drawing to center the crop const offsetX = (img.width - size) / 2; const offsetY = (img.height - size) / 2; // Draw the cropped image to the canvas const ctx = canvas.getContext('2d'); ctx.drawImage(img, offsetX, offsetY, size, size, 0, 0, size, size); // Convert the canvas to a data URL const croppedImageDataUrl = canvas.toDataURL(file.type); setCustomImagePreview(croppedImageDataUrl); setCustomImageFile(file); }; img.src = reader.result; }; reader.readAsDataURL(file); }; const triggerFileInput = () => { fileInputRef.current.click(); }; const handleGenerateDefaultPrompt = () => { if (!newMaterialName.trim()) return; // Generate default prompt based on material name const defaultPrompt = generatePromptForMaterial(newMaterialName); setCustomPrompt(defaultPrompt); // Clear the preview so it will regenerate with the new prompt setPreviewThumbnail(''); }; // Add a helper function to read file as data URL const readFileAsDataURL = (file) => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); }); }; // Option 1: Add function to compress images more aggressively before storage const compressImageForStorage = async (dataUrl) => { // Use a smaller max width for storage const maxWidth = 100; // Reduce from 200 to 100 const quality = 0.5; // Reduce quality from 0.7 to 0.5 return new Promise((resolve) => { const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); let width = img.width; let height = img.height; if (width > maxWidth) { height = (height * maxWidth) / width; width = maxWidth; } canvas.width = width; canvas.height = height; ctx.drawImage(img, 0, 0, width, height); resolve(canvas.toDataURL('image/jpeg', quality)); }; img.src = dataUrl; }); }; // Option 2: Add function to manage storage limits const manageStorageLimit = async (newMaterial) => { try { // Get current materials const savedMaterials = localStorage.getItem('customMaterials'); if (!savedMaterials) return; const parsedMaterials = JSON.parse(savedMaterials); const customKeys = Object.keys(parsedMaterials).filter(key => !Object.keys(defaultStyleOptions).includes(key)); // If we have too many custom materials, remove the oldest ones if (customKeys.length > 4) { // Limit to 5 custom materials // Sort by creation time (if you have that data) or just take the first ones const keysToRemove = customKeys.slice(0, customKeys.length - 4); keysToRemove.forEach(key => { delete parsedMaterials[key]; }); // Save the reduced set back to localStorage localStorage.setItem('customMaterials', JSON.stringify(parsedMaterials)); } } catch (error) { console.error('Error managing storage limit:', error); } }; // Add a function to reset the form fields const resetMaterialForm = () => { setNewMaterialName(''); setGeneratedMaterialName(''); setGeneratedPrompt(''); setCustomPrompt(''); setPreviewThumbnail(''); setUseCustomImage(false); setCustomImagePreview(''); setShowCustomPrompt(false); }; // Update the openAddMaterialModal function to reset form on open const openAddMaterialModal = () => { resetMaterialForm(); setRecentlyAdded(null); setShowAddMaterialModal(true); }; // Modify handleEditMaterial to handle all material properties const handleEditMaterial = (materialId) => { const material = materials[materialId]; if (!material) return; // Set form fields with existing data setNewMaterialName(material.originalDescription || material.name); // Fallback to name if no original description setGeneratedMaterialName(material.name || ''); // Set prompt const materialPrompt = material.prompt || ''; setGeneratedPrompt(materialPrompt); setCustomPrompt(materialPrompt); setShowCustomPrompt(true); // Show the editable prompt by default // Set thumbnail if (material.thumbnail) { setPreviewThumbnail(material.thumbnail); setUseCustomImage(true); // Mark as custom image to prevent regeneration } else if (material.file) { setPreviewThumbnail(`/samples/${material.file}`); setUseCustomImage(true); } // Enable name editing by default setShowMaterialNameEdit(true); setRecentlyAdded(materialId); setShowAddMaterialModal(true); }; // Add a function to manually refresh the thumbnail const handleRefreshThumbnail = async (prompt) => { if (!newMaterialName.trim() || useCustomImage) { console.log('Skipping thumbnail refresh: No material name or using custom image'); return; } setIsGeneratingPreview(true); try { const promptToUse = showCustomPrompt && customPrompt.trim() ? customPrompt : generatePromptForMaterial(newMaterialName); // Use the dedicated thumbnail endpoint instead const response = await fetch("/api/generate-thumbnail", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ prompt: promptToUse, referenceImageData: DEFAULT_SHAPE_DATA_URL }), }); if (!response.ok) { throw new Error(`API error: ${response.status} ${response.statusText}`); } const data = await response.json(); console.log('Thumbnail API response:', { success: data.success, hasImageData: !!data.imageData, error: data.error }); if (data.success && data.imageData) { // Set the preview thumbnail setPreviewThumbnail(`data:image/jpeg;base64,${data.imageData}`); console.log('Successfully set new thumbnail'); } else { throw new Error(data.error || 'No image data received'); } } catch (error) { console.error("Error generating preview thumbnail:", error); // If we have a previous thumbnail, keep it if (previewThumbnail) { console.log('Keeping previous thumbnail after error'); } else { // Otherwise generate a fallback console.log('Using fallback thumbnail after API error'); const fallbackThumbnail = createFallbackThumbnail(newMaterialName); setPreviewThumbnail(fallbackThumbnail); } // Show a brief notification about the error const errorToast = document.createElement('div'); errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg z-50'; errorToast.innerText = 'Thumbnail generation failed. Using fallback.'; document.body.appendChild(errorToast); // Remove the notification after 3 seconds setTimeout(() => { if (document.body.contains(errorToast)) { document.body.removeChild(errorToast); } }, 3000); } finally { setIsGeneratingPreview(false); } }; // Add a function to manually refresh the text const handleRefreshText = async () => { if (!newMaterialName.trim()) return; setIsGeneratingText(true); try { // ... existing code for text generation ... // This can reuse the same code from the useEffect } catch (error) { console.error("Error generating material name and prompt:", error); } finally { setIsGeneratingText(false); } }; // Modify handleNewMaterialDescription to only handle preview generation const handleNewMaterialDescription = async (description) => { if (!description.trim()) return; setIsGeneratingText(true); setIsGeneratingPreview(true); // Keep track of whether we've set a thumbnail yet let thumbnailSet = false; try { // First, get the enhanced description console.log(`Generating enhanced description for: "${description}"`); const enhanced = await enhanceMaterialDetails(description); setGeneratedMaterialName(enhanced.name); setGeneratedPrompt(enhanced.prompt); // Generate thumbnail with the enhanced prompt if (!useCustomImage) { try { console.log(`Generating thumbnail with prompt: "${enhanced.prompt.substring(0, 100)}..."`); // Set a timeout to ensure we don't wait too long for the API const thumbnailPromise = fetch("/api/generate", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ prompt: enhanced.prompt, }), }); // Add a timeout to the thumbnail generation const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Thumbnail generation timed out')), 12000) ); // Race the thumbnail generation against the timeout const response = await Promise.race([thumbnailPromise, timeoutPromise]); if (!response.ok) { throw new Error(`API returned status ${response.status}`); } const data = await response.json(); if (data.success && data.imageData) { console.log('Successfully received thumbnail data'); setPreviewThumbnail(`data:image/jpeg;base64,${data.imageData}`); thumbnailSet = true; } else { throw new Error(data.error || 'No image data received'); } } catch (thumbnailError) { console.error("Error generating thumbnail:", thumbnailError); // Fall back to a static thumbnail if we couldn't generate one console.log('Using fallback static thumbnail'); // Create a colored square as a fallback thumbnail const fallbackThumbnail = createFallbackThumbnail(description); setPreviewThumbnail(fallbackThumbnail); thumbnailSet = true; } } } catch (error) { console.error("Error in material generation:", error); // Set fallback values if we failed to generate enhanced content const capitalizedName = description .split(' ') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' '); setGeneratedMaterialName(`${capitalizedName} Material`); setGeneratedPrompt(generatePromptForMaterial(description)); // If we haven't set a thumbnail yet, create a fallback one if (!thumbnailSet && !useCustomImage) { console.log('Using fallback static thumbnail after error'); const fallbackThumbnail = createFallbackThumbnail(description); setPreviewThumbnail(fallbackThumbnail); } } finally { setIsGeneratingText(false); setIsGeneratingPreview(false); } }; // Add a function to create a fallback thumbnail const createFallbackThumbnail = (text) => { // Generate a consistent color from the text let hash = 0; for (let i = 0; i < text.length; i++) { hash = text.charCodeAt(i) + ((hash << 5) - hash); } // Convert hash to RGB color const r = (hash & 0xFF0000) >> 16; const g = (hash & 0x00FF00) >> 8; const b = hash & 0x0000FF; // Create a small canvas to generate the thumbnail const canvas = document.createElement('canvas'); canvas.width = 100; canvas.height = 100; const ctx = canvas.getContext('2d'); // Create gradient background const gradient = ctx.createLinearGradient(0, 0, 100, 100); gradient.addColorStop(0, `rgb(${r}, ${g}, ${b})`); gradient.addColorStop(1, `rgb(${b}, ${r}, ${g})`); // Fill the background ctx.fillStyle = gradient; ctx.fillRect(0, 0, 100, 100); // Add text first letter ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; ctx.font = 'bold 48px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text.charAt(0).toUpperCase(), 50, 50); // Return as data URL return canvas.toDataURL('image/jpeg', 0.9); }; const handleCreateMaterial = async () => { if (!newMaterialName.trim()) return; // Generate a unique ID for the material const materialId = recentlyAdded || `custom_${Date.now()}`; // Use the generated material name instead of the description const displayName = generatedMaterialName || `${newMaterialName} Material`; // Use the generated or custom prompt const materialPrompt = showCustomPrompt ? customPrompt : (generatedPrompt || generatePromptForMaterial(newMaterialName)); // Create the new material object const newMaterial = { name: displayName, prompt: materialPrompt, thumbnail: useCustomImage ? customImagePreview : previewThumbnail, originalDescription: newMaterialName, isCustom: true }; // Update both our state and storage references const updatedMaterials = { ...materials, [materialId]: newMaterial }; try { // Apply compression and save to localStorage if (useCustomImage && customImagePreview) { newMaterial.thumbnail = await compressImageForStorage(customImagePreview); } else if (previewThumbnail) { newMaterial.thumbnail = await compressImageForStorage(previewThumbnail); } await manageStorageLimit(newMaterial); localStorage.setItem('customMaterials', JSON.stringify(updatedMaterials)); // Update state and global reference styleOptions = updatedMaterials; setMaterials(updatedMaterials); // Close the modal setShowAddMaterialModal(false); // Reset form resetMaterialForm(); // Auto-select the newly created material setStyleMode(materialId); // Trigger generation with the new material if (handleGenerate && typeof handleGenerate === 'function') { setTimeout(() => handleGenerate(), 100); // Small delay to ensure styleMode is updated } console.log("Material created successfully:", materialId); } catch (error) { console.error('Storage error:', error); alert("Couldn't save your material. Please try clearing some browser data."); } }; const handleDeleteMaterial = (event, key) => { event.stopPropagation(); // Prevent triggering the parent button's onClick // Only allow deleting custom materials if (styleOptions[key]?.isCustom) { if (window.confirm(`Are you sure you want to delete the "${styleOptions[key].name}" material?`)) { // If currently selected, switch to default material if (styleMode === key) { setStyleMode('material'); } // Delete the material const { [key]: deleted, ...remaining } = styleOptions; const updatedMaterials = { ...defaultStyleOptions, ...remaining }; styleOptions = updatedMaterials; setMaterials(updatedMaterials); // Save the updated materials const customMaterials = {}; Object.entries(remaining).forEach(([k, v]) => { if (!defaultStyleOptions[k]) { customMaterials[k] = v; } }); localStorage.setItem('customMaterials', JSON.stringify(customMaterials)); } } }; // Add a function to sort materials in the desired order const getSortedMaterials = (materials) => { // For mobile view, we'll handle the order in the render function // This function now only handles desktop order // 1. Get original materials (excluding Test Material) const originalMaterials = Object.entries(defaultStyleOptions) .filter(([key]) => key !== 'testMaterial') .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); // 2. Get custom/locally saved materials (excluding Test Material) const customMaterials = Object.entries(materials) .filter(([key]) => !defaultStyleOptions[key] && key !== 'testMaterial') .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); // 3. Get Test Material const testMaterial = materials.testMaterial ? { testMaterial: materials.testMaterial } : {}; // Combine in desired order return { ...originalMaterials, ...customMaterials, ...testMaterial }; }; // New function to separate materials for mobile view const getMobileSortedElements = (materials, styleMode, handleAddMaterial, handleGenerate) => { // 1. Extract all materials except testMaterial (Surprise Me) const regularMaterials = Object.entries(materials) .filter(([key]) => key !== 'testMaterial') .map(([key, material]) => ({ key, material })); // 2. Get the Surprise Me button if it exists const testMaterial = materials.testMaterial ? { key: 'testMaterial', material: materials.testMaterial } : null; return { addButton: handleAddMaterial, materials: regularMaterials, surpriseButton: testMaterial }; }; // Add near your existing compression functions const compressImageForAPI = async (dataUrl) => { // Use a moderate size to ensure API can handle it const maxWidth = 800; const quality = 0.7; return new Promise((resolve) => { const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); let width = img.width; let height = img.height; if (width > maxWidth) { height = (height * maxWidth) / width; width = maxWidth; } canvas.width = width; canvas.height = height; ctx.drawImage(img, 0, 0, width, height); resolve(canvas.toDataURL('image/jpeg', quality)); }; img.src = dataUrl; }); }; // Fix the reference image upload function const handleReferenceImageUpload = async (e) => { const file = e.target.files?.[0]; if (!file) return; // Clear recentlyAdded to ensure we're not in edit mode setRecentlyAdded(null); // Set loading states setIsGeneratingText(true); setIsGeneratingPreview(true); try { // Process the image for preview const reader = new FileReader(); reader.onloadend = async (event) => { const imageDataUrl = event.target.result; // Set UI state for image setCustomImagePreview(imageDataUrl); setUseCustomImage(true); // Get custom API key if it exists const customApiKey = localStorage.getItem('customApiKey'); // Call the visual-enhance-prompt API try { const compressedImage = await compressImageForAPI(imageDataUrl); const response = await fetch('/api/visual-enhance-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ image: compressedImage, customApiKey, basePrompt: 'Transform this sketch into a material with professional studio lighting against a pure black background. Render it in Cinema 4D with Octane for a high-end 3D visualization.' }), }); console.log('API response status:', response.status); if (!response.ok) { const errorText = await response.text().catch(() => ''); console.error('API error details:', errorText); console.error(`API returned ${response.status}`); // Set fallback values right here when the API response is not OK setGeneratedMaterialName('Reference Material'); setNewMaterialName('Reference Material'); setGeneratedPrompt('Transform this reference into a 3D rendering with dramatic lighting on a black background.'); setCustomPrompt('Transform this reference into a 3D rendering with dramatic lighting on a black background.'); setShowCustomPrompt(true); } else { const data = await response.json(); if (data.enhancedPrompt && data.suggestedName) { // Update the material information setGeneratedMaterialName(data.suggestedName); setNewMaterialName(data.suggestedName); setGeneratedPrompt(data.enhancedPrompt); setCustomPrompt(data.enhancedPrompt); setShowCustomPrompt(true); // If we have imageData from the API, use it as the preview if (data.imageData) { setPreviewThumbnail(`data:image/jpeg;base64,${data.imageData}`); setUseCustomImage(false); // Use the generated thumbnail instead of the raw image } } else { // Handle case where API returns success but missing data console.error('API response missing enhancedPrompt or suggestedName fields'); setGeneratedMaterialName('Reference Material'); setNewMaterialName('Reference Material'); setGeneratedPrompt('Transform this reference into a 3D rendering with dramatic lighting on a black background.'); setCustomPrompt('Transform this reference into a 3D rendering with dramatic lighting on a black background.'); setShowCustomPrompt(true); } } } catch (error) { console.error('Error analyzing reference image:', error); // Show specific error message for quota exceeded if (error.message?.includes('429')) { alert('API quota exceeded. Please try again later or add your own API key in settings.'); } // Set fallback values setGeneratedMaterialName('Reference Material'); setNewMaterialName('Reference Material'); setGeneratedPrompt('Transform this reference into a 3D rendering with dramatic lighting on a black background.'); setCustomPrompt('Transform this reference into a 3D rendering with dramatic lighting on a black background.'); setShowCustomPrompt(true); } finally { setIsGeneratingText(false); setIsGeneratingPreview(false); } }; reader.readAsDataURL(file); } catch (error) { console.error('Error processing image:', error); setIsGeneratingText(false); setIsGeneratingPreview(false); } }; // Add this near the top of the component useEffect(() => { const handleClickOutside = (event) => { // Close if clicking outside the popover and the style selector if (showPromptInfo && !event.target.closest('.prompt-popover') && !event.target.closest('.material-edit-button')) { setShowPromptInfo(null); setEditingPrompt(null); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [showPromptInfo]); // Update the calculatePopoverPosition function const calculatePopoverPosition = (buttonElement, materialKey) => { if (!buttonElement) return; // If clicking the same material that's already open, close it if (showPromptInfo === materialKey) { setShowPromptInfo(null); setEditingPrompt(null); setEditedPromptText(''); setHasPromptChanged(false); return; } const rect = buttonElement.getBoundingClientRect(); const popoverWidth = 300; setPromptPopoverPosition({ top: rect.top - 10, left: rect.left + (rect.width / 2) - (popoverWidth / 2) }); // Set which material's prompt to show setShowPromptInfo(materialKey); // Reset editing state setEditingPrompt(null); setEditedPromptText(materials[materialKey]?.prompt || ''); setHasPromptChanged(false); }; // Add function to handle text changes in the textarea const handlePromptTextChange = (e) => { const newText = e.target.value; setEditedPromptText(newText); // Check if the text has changed from the original if (editingPrompt && materials[editingPrompt]) { const originalPrompt = materials[editingPrompt].prompt || ''; setHasPromptChanged(newText !== originalPrompt); } }; // Add function to save edited prompt const saveEditedPrompt = () => { if (!editingPrompt || !editedPromptText.trim() || !hasPromptChanged) return; try { // Create updated material with new prompt const updatedMaterial = { ...materials[editingPrompt], prompt: editedPromptText.trim() }; // Update materials state and styleOptions const updatedMaterials = { ...materials, [editingPrompt]: updatedMaterial }; setMaterials(updatedMaterials); styleOptions = updatedMaterials; // Save to localStorage (only custom materials) const customMaterials = {}; for (const [k, v] of Object.entries(updatedMaterials)) { if (v.isCustom) { customMaterials[k] = v; } } localStorage.setItem('customMaterials', JSON.stringify(customMaterials)); // Close edit mode setEditingPrompt(null); setShowPromptInfo(null); setHasPromptChanged(false); } catch (error) { console.error('Error saving edited prompt:', error); } }; // Add function to cancel editing const cancelEditing = () => { setEditingPrompt(null); setEditedPromptText(''); setHasPromptChanged(false); }; const handleGenerateThumbnail = useCallback(async () => { // Prevent generation if text is still generating or already previewing if (isGeneratingText || isGeneratingPreview || !generatedPrompt) return; console.log('Starting thumbnail generation...'); setIsGeneratingPreview(true); setThumbnailError(null); // Clear previous errors // --- CONFIRM THE PROMPT BEING USED --- console.log('Using prompt for thumbnail:', generatedPrompt.substring(0, 100) + '...'); // --- You can check this log in your browser console --- try { // Fetch the thumbnail from the API endpoint const response = await fetch("/api/generate-thumbnail", { method: "POST", headers: { "Content-Type": "application/json" }, // Send the CURRENT generatedPrompt and the default shape body: JSON.stringify({ prompt: generatedPrompt, // <-- Uses the state variable! referenceImageData: DEFAULT_SHAPE_DATA_URL // Base shape image }), }); if (!response.ok) { // Handle HTTP errors from the API const errorData = await response.json().catch(() => ({ error: 'Failed to parse error response' })); throw new Error(`API Error (${response.status}): ${errorData.error || 'Unknown error'}`); } const data = await response.json(); if (data.success && data.imageData) { // Update state with the new thumbnail data setGeneratedThumbnail(`data:image/png;base64,${data.imageData}`); console.log('Thumbnail generated successfully.'); } else { // Handle cases where API reports success=false or missing data throw new Error(data.error || 'Thumbnail generation failed: No image data received.'); } } catch (error) { console.error("Error generating thumbnail:", error); setThumbnailError(error.message || 'An unexpected error occurred during thumbnail generation.'); setGeneratedThumbnail(null); // Clear any previous thumbnail on error } finally { setIsGeneratingPreview(false); // Ensure loading state is turned off } }, [generatedPrompt, isGeneratingText, isGeneratingPreview]); // Dependencies return (