|
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'; |
|
|
|
|
|
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 */ |
|
} |
|
`; |
|
|
|
|
|
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." |
|
} |
|
}; |
|
|
|
|
|
export let styleOptions = { ...defaultStyleOptions }; |
|
|
|
|
|
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.`; |
|
|
|
|
|
export const addMaterialToLibrary = (material) => { |
|
|
|
const materialKey = `${material.name.toLowerCase().replace(/\s+/g, '_')}_${Date.now()}`; |
|
|
|
|
|
const newMaterial = { |
|
name: material.name, |
|
prompt: material.prompt || BASE_PROMPT(material.name), |
|
thumbnail: material.image || material.thumbnail, |
|
originalDescription: material.name, |
|
isCustom: true |
|
}; |
|
|
|
|
|
styleOptions[materialKey] = newMaterial; |
|
|
|
|
|
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 materialKey; |
|
}; |
|
|
|
|
|
const enhanceMaterialDetails = async (materialDescription) => { |
|
console.log("Enhancing prompt for:", materialDescription); |
|
const basePrompt = BASE_PROMPT(materialDescription); |
|
|
|
try { |
|
const response = await fetch("/api/enhance-prompt", { |
|
method: "POST", |
|
headers: { "Content-Type": "application/json" }, |
|
body: JSON.stringify({ |
|
materialName: materialDescription, |
|
basePrompt: basePrompt |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
|
|
throw new Error(`API responded with status ${response.status}`); |
|
} |
|
|
|
const data = await response.json(); |
|
console.log("Enhanced prompt data:", data); |
|
|
|
|
|
|
|
|
|
if (data.enhancedPrompt && data.suggestedName) { |
|
return { |
|
name: data.suggestedName, |
|
prompt: data.enhancedPrompt |
|
}; |
|
} else { |
|
|
|
throw new Error('Invalid enhancement data received from /api/enhance-prompt'); |
|
} |
|
|
|
} catch (error) { |
|
console.error("Error enhancing prompt:", error); |
|
|
|
|
|
const capitalizedName = materialDescription |
|
.split(' ') |
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) |
|
.join(' '); |
|
|
|
return { |
|
name: `${capitalizedName} Style`, |
|
prompt: basePrompt |
|
}; |
|
} |
|
}; |
|
|
|
|
|
|
|
export const getPromptForStyle = (styleMode) => { |
|
if (!styleMode || !styleOptions[styleMode]) { |
|
return styleOptions.material.prompt; |
|
} |
|
return styleOptions[styleMode].prompt || styleOptions.material.prompt; |
|
}; |
|
|
|
|
|
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); |
|
|
|
|
|
useEffect(() => { |
|
loadCustomMaterials(); |
|
}, []); |
|
|
|
|
|
useEffect(() => { |
|
|
|
setMaterials({...styleOptions}); |
|
}, [styleOptions]); |
|
|
|
|
|
const loadCustomMaterials = () => { |
|
try { |
|
const savedMaterials = localStorage.getItem('customMaterials'); |
|
if (savedMaterials) { |
|
const parsedMaterials = JSON.parse(savedMaterials); |
|
|
|
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); |
|
} |
|
}; |
|
|
|
|
|
useEffect(() => { |
|
const delayedGeneration = async () => { |
|
|
|
if (!newMaterialName.trim() || recentlyAdded) return; |
|
|
|
|
|
setIsGeneratingText(true); |
|
|
|
try { |
|
|
|
const enhanced = await enhanceMaterialDetails(newMaterialName); |
|
console.log("Received enhanced data in useEffect:", enhanced); |
|
setGeneratedMaterialName(enhanced.name); |
|
setGeneratedPrompt(enhanced.prompt); |
|
|
|
|
|
} catch (error) { |
|
console.error('Error in material generation (useEffect):', error); |
|
|
|
|
|
|
|
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); |
|
} |
|
}; |
|
|
|
|
|
const timeoutId = setTimeout(delayedGeneration, 1500); |
|
return () => clearTimeout(timeoutId); |
|
}, [newMaterialName, recentlyAdded]); |
|
|
|
|
|
const compressImage = (dataUrl, maxWidth = 200) => { |
|
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', 0.7)); |
|
}; |
|
img.src = dataUrl; |
|
}); |
|
}; |
|
|
|
|
|
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; |
|
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(''); |
|
}; |
|
|
|
|
|
const handleClickOutsideModal = (e) => { |
|
|
|
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 = () => { |
|
|
|
const img = new Image(); |
|
img.onload = () => { |
|
|
|
const canvas = document.createElement('canvas'); |
|
|
|
const size = Math.min(img.width, img.height); |
|
canvas.width = size; |
|
canvas.height = size; |
|
|
|
|
|
const offsetX = (img.width - size) / 2; |
|
const offsetY = (img.height - size) / 2; |
|
|
|
|
|
const ctx = canvas.getContext('2d'); |
|
ctx.drawImage(img, offsetX, offsetY, size, size, 0, 0, size, size); |
|
|
|
|
|
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; |
|
|
|
|
|
const defaultPrompt = generatePromptForMaterial(newMaterialName); |
|
setCustomPrompt(defaultPrompt); |
|
|
|
|
|
setPreviewThumbnail(''); |
|
}; |
|
|
|
|
|
const readFileAsDataURL = (file) => { |
|
return new Promise((resolve, reject) => { |
|
const reader = new FileReader(); |
|
reader.onload = () => resolve(reader.result); |
|
reader.onerror = reject; |
|
reader.readAsDataURL(file); |
|
}); |
|
}; |
|
|
|
|
|
const compressImageForStorage = async (dataUrl) => { |
|
|
|
const maxWidth = 100; |
|
const quality = 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; |
|
}); |
|
}; |
|
|
|
|
|
const manageStorageLimit = async (newMaterial) => { |
|
try { |
|
|
|
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 (customKeys.length > 4) { |
|
|
|
const keysToRemove = customKeys.slice(0, customKeys.length - 4); |
|
|
|
keysToRemove.forEach(key => { |
|
delete parsedMaterials[key]; |
|
}); |
|
|
|
|
|
localStorage.setItem('customMaterials', JSON.stringify(parsedMaterials)); |
|
} |
|
} catch (error) { |
|
console.error('Error managing storage limit:', error); |
|
} |
|
}; |
|
|
|
|
|
const resetMaterialForm = () => { |
|
setNewMaterialName(''); |
|
setGeneratedMaterialName(''); |
|
setGeneratedPrompt(''); |
|
setCustomPrompt(''); |
|
setPreviewThumbnail(''); |
|
setUseCustomImage(false); |
|
setCustomImagePreview(''); |
|
setShowCustomPrompt(false); |
|
}; |
|
|
|
|
|
const openAddMaterialModal = () => { |
|
resetMaterialForm(); |
|
setRecentlyAdded(null); |
|
setShowAddMaterialModal(true); |
|
}; |
|
|
|
|
|
const handleEditMaterial = (materialId) => { |
|
const material = materials[materialId]; |
|
if (!material) return; |
|
|
|
|
|
setNewMaterialName(material.originalDescription || material.name); |
|
setGeneratedMaterialName(material.name || ''); |
|
|
|
|
|
const materialPrompt = material.prompt || ''; |
|
setGeneratedPrompt(materialPrompt); |
|
setCustomPrompt(materialPrompt); |
|
setShowCustomPrompt(true); |
|
|
|
|
|
if (material.thumbnail) { |
|
setPreviewThumbnail(material.thumbnail); |
|
setUseCustomImage(true); |
|
} else if (material.file) { |
|
setPreviewThumbnail(`/samples/${material.file}`); |
|
setUseCustomImage(true); |
|
} |
|
|
|
|
|
setShowMaterialNameEdit(true); |
|
|
|
setRecentlyAdded(materialId); |
|
setShowAddMaterialModal(true); |
|
}; |
|
|
|
|
|
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); |
|
|
|
|
|
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) { |
|
|
|
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 (previewThumbnail) { |
|
console.log('Keeping previous thumbnail after error'); |
|
} else { |
|
|
|
console.log('Using fallback thumbnail after API error'); |
|
const fallbackThumbnail = createFallbackThumbnail(newMaterialName); |
|
setPreviewThumbnail(fallbackThumbnail); |
|
} |
|
|
|
|
|
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); |
|
|
|
|
|
setTimeout(() => { |
|
if (document.body.contains(errorToast)) { |
|
document.body.removeChild(errorToast); |
|
} |
|
}, 3000); |
|
|
|
} finally { |
|
setIsGeneratingPreview(false); |
|
} |
|
}; |
|
|
|
|
|
const handleRefreshText = async () => { |
|
if (!newMaterialName.trim()) return; |
|
|
|
setIsGeneratingText(true); |
|
|
|
try { |
|
|
|
|
|
} catch (error) { |
|
console.error("Error generating material name and prompt:", error); |
|
} finally { |
|
setIsGeneratingText(false); |
|
} |
|
}; |
|
|
|
|
|
const handleNewMaterialDescription = async (description) => { |
|
if (!description.trim()) return; |
|
|
|
setIsGeneratingText(true); |
|
setIsGeneratingPreview(true); |
|
|
|
|
|
let thumbnailSet = false; |
|
|
|
try { |
|
|
|
console.log(`Generating enhanced description for: "${description}"`); |
|
const enhanced = await enhanceMaterialDetails(description); |
|
setGeneratedMaterialName(enhanced.name); |
|
setGeneratedPrompt(enhanced.prompt); |
|
|
|
|
|
if (!useCustomImage) { |
|
try { |
|
console.log(`Generating thumbnail with prompt: "${enhanced.prompt.substring(0, 100)}..."`); |
|
|
|
|
|
const thumbnailPromise = fetch("/api/generate", { |
|
method: "POST", |
|
headers: { |
|
"Content-Type": "application/json", |
|
}, |
|
body: JSON.stringify({ |
|
prompt: enhanced.prompt, |
|
}), |
|
}); |
|
|
|
|
|
const timeoutPromise = new Promise((_, reject) => |
|
setTimeout(() => reject(new Error('Thumbnail generation timed out')), 12000) |
|
); |
|
|
|
|
|
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); |
|
|
|
|
|
console.log('Using fallback static thumbnail'); |
|
|
|
|
|
const fallbackThumbnail = createFallbackThumbnail(description); |
|
setPreviewThumbnail(fallbackThumbnail); |
|
thumbnailSet = true; |
|
} |
|
} |
|
} catch (error) { |
|
console.error("Error in material generation:", error); |
|
|
|
|
|
const capitalizedName = description |
|
.split(' ') |
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) |
|
.join(' '); |
|
|
|
setGeneratedMaterialName(`${capitalizedName} Material`); |
|
setGeneratedPrompt(generatePromptForMaterial(description)); |
|
|
|
|
|
if (!thumbnailSet && !useCustomImage) { |
|
console.log('Using fallback static thumbnail after error'); |
|
const fallbackThumbnail = createFallbackThumbnail(description); |
|
setPreviewThumbnail(fallbackThumbnail); |
|
} |
|
} finally { |
|
setIsGeneratingText(false); |
|
setIsGeneratingPreview(false); |
|
} |
|
}; |
|
|
|
|
|
const createFallbackThumbnail = (text) => { |
|
|
|
let hash = 0; |
|
for (let i = 0; i < text.length; i++) { |
|
hash = text.charCodeAt(i) + ((hash << 5) - hash); |
|
} |
|
|
|
|
|
const r = (hash & 0xFF0000) >> 16; |
|
const g = (hash & 0x00FF00) >> 8; |
|
const b = hash & 0x0000FF; |
|
|
|
|
|
const canvas = document.createElement('canvas'); |
|
canvas.width = 100; |
|
canvas.height = 100; |
|
const ctx = canvas.getContext('2d'); |
|
|
|
|
|
const gradient = ctx.createLinearGradient(0, 0, 100, 100); |
|
gradient.addColorStop(0, `rgb(${r}, ${g}, ${b})`); |
|
gradient.addColorStop(1, `rgb(${b}, ${r}, ${g})`); |
|
|
|
|
|
ctx.fillStyle = gradient; |
|
ctx.fillRect(0, 0, 100, 100); |
|
|
|
|
|
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 canvas.toDataURL('image/jpeg', 0.9); |
|
}; |
|
|
|
const handleCreateMaterial = async () => { |
|
if (!newMaterialName.trim()) return; |
|
|
|
|
|
const materialId = recentlyAdded || `custom_${Date.now()}`; |
|
|
|
|
|
const displayName = generatedMaterialName || `${newMaterialName} Material`; |
|
|
|
|
|
const materialPrompt = showCustomPrompt ? customPrompt : (generatedPrompt || generatePromptForMaterial(newMaterialName)); |
|
|
|
|
|
const newMaterial = { |
|
name: displayName, |
|
prompt: materialPrompt, |
|
thumbnail: useCustomImage ? customImagePreview : previewThumbnail, |
|
originalDescription: newMaterialName, |
|
isCustom: true |
|
}; |
|
|
|
|
|
const updatedMaterials = { ...materials, [materialId]: newMaterial }; |
|
|
|
try { |
|
|
|
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)); |
|
|
|
|
|
styleOptions = updatedMaterials; |
|
setMaterials(updatedMaterials); |
|
|
|
|
|
setShowAddMaterialModal(false); |
|
|
|
|
|
resetMaterialForm(); |
|
|
|
|
|
setStyleMode(materialId); |
|
|
|
|
|
if (handleGenerate && typeof handleGenerate === 'function') { |
|
setTimeout(() => handleGenerate(), 100); |
|
} |
|
|
|
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(); |
|
|
|
|
|
if (styleOptions[key]?.isCustom) { |
|
if (window.confirm(`Are you sure you want to delete the "${styleOptions[key].name}" material?`)) { |
|
|
|
if (styleMode === key) { |
|
setStyleMode('material'); |
|
} |
|
|
|
|
|
const { [key]: deleted, ...remaining } = styleOptions; |
|
const updatedMaterials = { ...defaultStyleOptions, ...remaining }; |
|
styleOptions = updatedMaterials; |
|
setMaterials(updatedMaterials); |
|
|
|
|
|
const customMaterials = {}; |
|
Object.entries(remaining).forEach(([k, v]) => { |
|
if (!defaultStyleOptions[k]) { |
|
customMaterials[k] = v; |
|
} |
|
}); |
|
localStorage.setItem('customMaterials', JSON.stringify(customMaterials)); |
|
} |
|
} |
|
}; |
|
|
|
|
|
const getSortedMaterials = (materials) => { |
|
|
|
|
|
|
|
|
|
const originalMaterials = Object.entries(defaultStyleOptions) |
|
.filter(([key]) => key !== 'testMaterial') |
|
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); |
|
|
|
|
|
const customMaterials = Object.entries(materials) |
|
.filter(([key]) => !defaultStyleOptions[key] && key !== 'testMaterial') |
|
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); |
|
|
|
|
|
const testMaterial = materials.testMaterial ? { testMaterial: materials.testMaterial } : {}; |
|
|
|
|
|
return { |
|
...originalMaterials, |
|
...customMaterials, |
|
...testMaterial |
|
}; |
|
}; |
|
|
|
|
|
const getMobileSortedElements = (materials, styleMode, handleAddMaterial, handleGenerate) => { |
|
|
|
const regularMaterials = Object.entries(materials) |
|
.filter(([key]) => key !== 'testMaterial') |
|
.map(([key, material]) => ({ key, material })); |
|
|
|
|
|
const testMaterial = materials.testMaterial ? { key: 'testMaterial', material: materials.testMaterial } : null; |
|
|
|
return { |
|
addButton: handleAddMaterial, |
|
materials: regularMaterials, |
|
surpriseButton: testMaterial |
|
}; |
|
}; |
|
|
|
|
|
const compressImageForAPI = async (dataUrl) => { |
|
|
|
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; |
|
}); |
|
}; |
|
|
|
|
|
const handleReferenceImageUpload = async (e) => { |
|
const file = e.target.files?.[0]; |
|
if (!file) return; |
|
|
|
|
|
setRecentlyAdded(null); |
|
|
|
|
|
setIsGeneratingText(true); |
|
setIsGeneratingPreview(true); |
|
|
|
try { |
|
|
|
const reader = new FileReader(); |
|
reader.onloadend = async (event) => { |
|
const imageDataUrl = event.target.result; |
|
|
|
|
|
setCustomImagePreview(imageDataUrl); |
|
setUseCustomImage(true); |
|
|
|
|
|
const customApiKey = localStorage.getItem('customApiKey'); |
|
|
|
|
|
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}`); |
|
|
|
|
|
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) { |
|
|
|
setGeneratedMaterialName(data.suggestedName); |
|
setNewMaterialName(data.suggestedName); |
|
setGeneratedPrompt(data.enhancedPrompt); |
|
setCustomPrompt(data.enhancedPrompt); |
|
setShowCustomPrompt(true); |
|
|
|
|
|
if (data.imageData) { |
|
setPreviewThumbnail(`data:image/jpeg;base64,${data.imageData}`); |
|
setUseCustomImage(false); |
|
} |
|
} else { |
|
|
|
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); |
|
|
|
|
|
if (error.message?.includes('429')) { |
|
alert('API quota exceeded. Please try again later or add your own API key in settings.'); |
|
} |
|
|
|
|
|
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); |
|
} |
|
}; |
|
|
|
|
|
useEffect(() => { |
|
const handleClickOutside = (event) => { |
|
|
|
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]); |
|
|
|
|
|
const calculatePopoverPosition = (buttonElement, materialKey) => { |
|
if (!buttonElement) return; |
|
|
|
|
|
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) |
|
}); |
|
|
|
|
|
setShowPromptInfo(materialKey); |
|
|
|
|
|
setEditingPrompt(null); |
|
setEditedPromptText(materials[materialKey]?.prompt || ''); |
|
setHasPromptChanged(false); |
|
}; |
|
|
|
|
|
const handlePromptTextChange = (e) => { |
|
const newText = e.target.value; |
|
setEditedPromptText(newText); |
|
|
|
|
|
if (editingPrompt && materials[editingPrompt]) { |
|
const originalPrompt = materials[editingPrompt].prompt || ''; |
|
setHasPromptChanged(newText !== originalPrompt); |
|
} |
|
}; |
|
|
|
|
|
const saveEditedPrompt = () => { |
|
if (!editingPrompt || !editedPromptText.trim() || !hasPromptChanged) return; |
|
|
|
try { |
|
|
|
const updatedMaterial = { |
|
...materials[editingPrompt], |
|
prompt: editedPromptText.trim() |
|
}; |
|
|
|
|
|
const updatedMaterials = { |
|
...materials, |
|
[editingPrompt]: updatedMaterial |
|
}; |
|
|
|
setMaterials(updatedMaterials); |
|
styleOptions = updatedMaterials; |
|
|
|
|
|
const customMaterials = {}; |
|
for (const [k, v] of Object.entries(updatedMaterials)) { |
|
if (v.isCustom) { |
|
customMaterials[k] = v; |
|
} |
|
} |
|
|
|
localStorage.setItem('customMaterials', JSON.stringify(customMaterials)); |
|
|
|
|
|
setEditingPrompt(null); |
|
setShowPromptInfo(null); |
|
setHasPromptChanged(false); |
|
} catch (error) { |
|
console.error('Error saving edited prompt:', error); |
|
} |
|
}; |
|
|
|
|
|
const cancelEditing = () => { |
|
setEditingPrompt(null); |
|
setEditedPromptText(''); |
|
setHasPromptChanged(false); |
|
}; |
|
|
|
const handleGenerateThumbnail = useCallback(async () => { |
|
|
|
if (isGeneratingText || isGeneratingPreview || !generatedPrompt) return; |
|
|
|
console.log('Starting thumbnail generation...'); |
|
setIsGeneratingPreview(true); |
|
setThumbnailError(null); |
|
|
|
|
|
console.log('Using prompt for thumbnail:', generatedPrompt.substring(0, 100) + '...'); |
|
|
|
|
|
try { |
|
|
|
const response = await fetch("/api/generate-thumbnail", { |
|
method: "POST", |
|
headers: { "Content-Type": "application/json" }, |
|
|
|
body: JSON.stringify({ |
|
prompt: generatedPrompt, |
|
referenceImageData: DEFAULT_SHAPE_DATA_URL |
|
}), |
|
}); |
|
|
|
if (!response.ok) { |
|
|
|
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) { |
|
|
|
setGeneratedThumbnail(`data:image/png;base64,${data.imageData}`); |
|
console.log('Thumbnail generated successfully.'); |
|
} else { |
|
|
|
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); |
|
} finally { |
|
setIsGeneratingPreview(false); |
|
} |
|
}, [generatedPrompt, isGeneratingText, isGeneratingPreview]); |
|
|
|
return ( |
|
<div className="w-full" ref={styleSelectorRef}> |
|
{/* Inject scrollbar hiding CSS */} |
|
<style dangerouslySetInnerHTML={{ __html: scrollbarHideStyles }} /> |
|
|
|
{/* Mobile View */} |
|
<div className="md:hidden w-full"> |
|
<div className="overflow-x-auto pb-2 scrollbar-hide"> |
|
<div className="flex flex-nowrap gap-2 pr-2" style={{ minWidth: 'min-content' }}> |
|
{/* Add Material Button (First) */} |
|
<button |
|
onClick={handleAddMaterial} |
|
type="button" |
|
aria-label="Add new material" |
|
className="focus:outline-none group flex-shrink-0" |
|
style={{ scrollSnapAlign: 'start' }} |
|
> |
|
<div className="w-20 border border-dashed border-gray-200 overflow-hidden rounded-xl bg-gray-50 flex flex-col group-hover:bg-white group-hover:border-gray-300"> |
|
<div className="w-full relative flex-1 flex items-center justify-center" style={{ aspectRatio: '1/1' }}> |
|
<Plus className="w-6 h-6 text-gray-500 group-hover:text-gray-600" /> |
|
</div> |
|
<div className="px-1 py-1 text-left text-xs font-medium bg-gray-50 text-gray-500 w-full group-hover:bg-white group-hover:text-gray-600"> |
|
<div className="truncate"> |
|
Add Material |
|
</div> |
|
</div> |
|
</div> |
|
</button> |
|
|
|
{/* Regular Materials (Middle) */} |
|
{Object.entries(materials) |
|
.filter(([key]) => key !== 'testMaterial') |
|
.map(([key, { name, file, imageData, thumbnail, isCustom, prompt }]) => ( |
|
<button |
|
key={key} |
|
onClick={async () => { |
|
const isSameMaterial = styleMode === key; |
|
if (!isSameMaterial) { |
|
setStyleMode(key); |
|
} else { |
|
handleGenerate(); |
|
} |
|
}} |
|
type="button" |
|
aria-label={`Select ${name} style`} |
|
aria-pressed={styleMode === key} |
|
className="focus:outline-none relative group flex-shrink-0" |
|
style={{ scrollSnapAlign: 'start' }} |
|
> |
|
<div className={`w-20 border overflow-hidden rounded-xl ${ |
|
styleMode === key |
|
? 'border-blue-500 bg-white' |
|
: 'bg-gray-50 border-gray-200 group-hover:bg-white group-hover:border-gray-300' |
|
}`}> |
|
<div className="w-full relative" style={{ aspectRatio: '1/1' }}> |
|
<img |
|
src={imageData ? `data:image/jpeg;base64,${imageData}` : (file ? `/samples/${file}` : thumbnail || '')} |
|
alt={`${name} style example`} |
|
className="w-full h-full object-cover" |
|
onError={(e) => { |
|
console.error(`Error loading thumbnail for ${name} from ${e.target.src}`); |
|
// Provide a base64 encoded 1x1 transparent PNG as fallback |
|
e.target.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; |
|
}} |
|
/> |
|
<div className="absolute inset-0"> |
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity"> |
|
<button |
|
type="button" |
|
onClick={(e) => { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
calculatePopoverPosition(e.currentTarget, key); |
|
}} |
|
className="material-edit-button absolute top-1 right-1 bg-gray-800 bg-opacity-70 hover:bg-opacity-90 text-white rounded-full w-5 h-5 flex items-center justify-center transition-opacity" |
|
aria-label={isCustom ? `Edit ${name} material` : `View ${name} prompt`} |
|
> |
|
<Info className="w-2.5 h-2.5" /> |
|
</button> |
|
|
|
{isCustom && ( |
|
<button |
|
type="button" |
|
onClick={(e) => { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
handleDeleteMaterial(e, key); |
|
}} |
|
className="absolute top-1 left-1 bg-gray-800 bg-opacity-70 hover:bg-opacity-90 text-white rounded-full w-5 h-5 flex items-center justify-center transition-opacity" |
|
aria-label={`Delete ${name} material`} |
|
> |
|
<Trash2 className="w-2.5 h-2.5" /> |
|
</button> |
|
)} |
|
</div> |
|
</div> |
|
</div> |
|
<div className={`px-1 py-1 text-left text-xs font-medium ${ |
|
styleMode === key |
|
? 'bg-blue-50 text-blue-600' |
|
: 'bg-gray-50 text-gray-500 group-hover:bg-white group-hover:text-gray-600' |
|
}`}> |
|
<div className="truncate"> |
|
{name} |
|
</div> |
|
</div> |
|
</div> |
|
</button> |
|
))} |
|
|
|
{/* Surprise Me Button (Last) */} |
|
{materials.testMaterial && ( |
|
<button |
|
key="testMaterial" |
|
onClick={async () => { |
|
const isSameMaterial = styleMode === 'testMaterial'; |
|
if (!isSameMaterial) { |
|
setStyleMode('testMaterial'); |
|
} else { |
|
handleGenerate(); |
|
} |
|
}} |
|
type="button" |
|
aria-label={`Select ${materials.testMaterial.name} style`} |
|
aria-pressed={styleMode === 'testMaterial'} |
|
className="focus:outline-none relative group flex-shrink-0" |
|
style={{ scrollSnapAlign: 'end' }} |
|
> |
|
<div className={`w-20 border overflow-hidden rounded-xl ${ |
|
styleMode === 'testMaterial' |
|
? 'border-blue-500 bg-white' |
|
: 'bg-gray-50 border-gray-200 border-dashed group-hover:bg-white group-hover:border-gray-300' |
|
}`}> |
|
<div className="w-full relative" style={{ aspectRatio: '1/1' }}> |
|
<div className={`w-full h-full flex items-center justify-center ${ |
|
styleMode === 'testMaterial' ? 'bg-white' : 'bg-gray-50 group-hover:bg-white' |
|
}`}> |
|
<HelpCircle className={`w-8 h-8 ${ |
|
styleMode === 'testMaterial' ? 'text-blue-600' : 'text-gray-500 group-hover:text-gray-600' |
|
}`} /> |
|
</div> |
|
</div> |
|
<div className={`px-1 py-1 text-left text-xs font-medium ${ |
|
styleMode === 'testMaterial' |
|
? 'bg-blue-50 text-blue-600' |
|
: 'bg-gray-50 text-gray-500 group-hover:bg-white group-hover:text-gray-600' |
|
}`}> |
|
<div className="truncate"> |
|
{materials.testMaterial.name} |
|
</div> |
|
</div> |
|
</div> |
|
</button> |
|
)} |
|
</div> |
|
</div> |
|
</div> |
|
|
|
{/* Desktop View */} |
|
<div className="hidden md:block"> |
|
<div className="overflow-x-auto"> |
|
<div className="flex flex-wrap gap-2 overflow-y-auto max-h-[35vh] pr-2"> |
|
{/* 1. Original + 2. Local + 3. Test Material */} |
|
{Object.entries(getSortedMaterials(materials)).map(([key, { name, file, imageData, thumbnail, isCustom, prompt }]) => ( |
|
<button |
|
key={key} |
|
onClick={async () => { |
|
const isSameMaterial = styleMode === key; |
|
if (!isSameMaterial) { |
|
setStyleMode(key); |
|
} else { |
|
handleGenerate(); |
|
} |
|
}} |
|
type="button" |
|
aria-label={`Select ${name} style`} |
|
aria-pressed={styleMode === key} |
|
className="focus:outline-none relative group" |
|
> |
|
<div className={`w-20 border overflow-hidden rounded-xl ${ |
|
styleMode === key |
|
? key === 'testMaterial' ? 'border-blue-500 bg-white' : 'border-blue-500 bg-white' |
|
: key === 'testMaterial' |
|
? 'bg-gray-50 border-gray-200 border-dashed group-hover:bg-white group-hover:border-gray-300' |
|
: 'bg-gray-50 border-gray-200 group-hover:bg-white group-hover:border-gray-300' |
|
}`}> |
|
<div className="w-full relative" style={{ aspectRatio: '1/1' }}> |
|
{key === 'testMaterial' ? ( |
|
<div className={`w-full h-full flex items-center justify-center ${ |
|
styleMode === key ? 'bg-white' : 'bg-gray-50 group-hover:bg-white' |
|
}`}> |
|
<HelpCircle className={`w-8 h-8 ${ |
|
styleMode === key ? 'text-blue-600' : 'text-gray-500 group-hover:text-gray-600' |
|
}`} /> |
|
</div> |
|
) : ( |
|
<img |
|
src={imageData ? `data:image/jpeg;base64,${imageData}` : (file ? `/samples/${file}` : thumbnail || '')} |
|
alt={`${name} style example`} |
|
className="w-full h-full object-cover" |
|
onError={(e) => { |
|
console.error(`Error loading thumbnail for ${name} from ${e.target.src}`); |
|
// Provide a base64 encoded 1x1 transparent PNG as fallback |
|
e.target.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; |
|
}} |
|
/> |
|
)} |
|
<div className="absolute inset-0"> |
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity"> |
|
<button |
|
type="button" |
|
onClick={(e) => { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
calculatePopoverPosition(e.currentTarget, key); |
|
}} |
|
className="material-edit-button absolute top-1 right-1 bg-gray-800 bg-opacity-70 hover:bg-opacity-90 text-white rounded-full w-5 h-5 flex items-center justify-center transition-opacity" |
|
aria-label={isCustom ? `Edit ${name} material` : `View ${name} prompt`} |
|
> |
|
<Info className="w-2.5 h-2.5" /> |
|
</button> |
|
|
|
{isCustom && ( |
|
<button |
|
type="button" |
|
onClick={(e) => { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
handleDeleteMaterial(e, key); |
|
}} |
|
className="absolute top-1 left-1 bg-gray-800 bg-opacity-70 hover:bg-opacity-90 text-white rounded-full w-5 h-5 flex items-center justify-center transition-opacity" |
|
aria-label={`Delete ${name} material`} |
|
> |
|
<Trash2 className="w-2.5 h-2.5" /> |
|
</button> |
|
)} |
|
</div> |
|
</div> |
|
</div> |
|
<div className={`px-1 py-1 text-left text-xs font-medium ${ |
|
styleMode === key |
|
? 'bg-blue-50 text-blue-600' |
|
: 'bg-gray-50 text-gray-500 group-hover:bg-white group-hover:text-gray-600' |
|
}`}> |
|
<div className="truncate"> |
|
{name} |
|
</div> |
|
</div> |
|
</div> |
|
</button> |
|
))} |
|
|
|
<button |
|
onClick={handleAddMaterial} |
|
type="button" |
|
aria-label="Add new material" |
|
className="focus:outline-none group" |
|
> |
|
<div className="w-20 border border-dashed border-gray-200 overflow-hidden rounded-xl bg-gray-50 flex flex-col group-hover:bg-white group-hover:border-gray-300"> |
|
<div className="w-full relative flex-1 flex items-center justify-center" style={{ aspectRatio: '1/1' }}> |
|
<Plus className="w-6 h-6 text-gray-500 group-hover:text-gray-600" /> |
|
</div> |
|
<div className="px-1 py-1 text-left text-xs font-medium bg-gray-50 text-gray-500 w-full group-hover:bg-white group-hover:text-gray-600"> |
|
<div className="truncate"> |
|
Add Material |
|
</div> |
|
</div> |
|
</div> |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
{showPromptInfo && typeof document !== 'undefined' && createPortal( |
|
<div |
|
className="prompt-popover fixed z-[9999] bg-white border border-gray-200 text-gray-900 rounded-lg shadow-lg p-4 text-xs" |
|
style={{ |
|
top: `${promptPopoverPosition.top}px`, |
|
left: `${promptPopoverPosition.left}px`, |
|
width: '300px', |
|
transform: 'translateY(-100%)', |
|
maxHeight: '60vh', |
|
}} |
|
> |
|
<button |
|
type="button" |
|
className="absolute top-2 right-2 text-gray-400 hover:text-gray-600" |
|
onClick={() => { |
|
setShowPromptInfo(null); |
|
setEditingPrompt(null); |
|
}} |
|
aria-label="Close prompt info" |
|
> |
|
× |
|
</button> |
|
|
|
<div className="text-sm font-medium mb-2 text-gray-700"> |
|
{materials[showPromptInfo]?.name || ''} |
|
</div> |
|
|
|
{materials[showPromptInfo]?.isCustom ? ( |
|
<> |
|
<textarea |
|
className={`font-mono text-xs w-full h-60 bg-gray-50 border ${editingPrompt ? 'border-blue-300 focus:border-blue-500' : 'border-gray-200'} rounded p-2 text-gray-900`} |
|
value={editingPrompt ? editedPromptText : (materials[showPromptInfo]?.prompt || '')} |
|
onChange={handlePromptTextChange} |
|
onClick={(e) => { |
|
e.stopPropagation(); |
|
if (!editingPrompt) { |
|
setEditingPrompt(showPromptInfo); |
|
setEditedPromptText(materials[showPromptInfo]?.prompt || ''); |
|
} |
|
}} |
|
onFocus={(e) => { |
|
if (!editingPrompt) { |
|
setEditingPrompt(showPromptInfo); |
|
setEditedPromptText(materials[showPromptInfo]?.prompt || ''); |
|
// Place cursor at end of text |
|
const val = e.target.value; |
|
e.target.value = ''; |
|
e.target.value = val; |
|
} |
|
}} |
|
onKeyDown={(e) => { |
|
// Save changes on Ctrl+Enter or Cmd+Enter |
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { |
|
e.preventDefault(); |
|
if (editingPrompt && hasPromptChanged) { |
|
saveEditedPrompt(); |
|
} |
|
} |
|
e.stopPropagation(); |
|
}} |
|
placeholder="Edit prompt here..." |
|
readOnly={!editingPrompt} |
|
/> |
|
{/* {editingPrompt && ( |
|
<div className="text-xs text-gray-500 mt-1 mb-2 flex items-center"> |
|
<span className="mr-1">Pro tip:</span> |
|
<kbd className="px-1 py-0.5 border border-gray-300 rounded text-xs bg-gray-50 mr-1"> |
|
{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'} |
|
</kbd> |
|
<span className="mr-1">+</span> |
|
<kbd className="px-1 py-0.5 border border-gray-300 rounded text-xs bg-gray-50"> |
|
Enter |
|
</kbd> |
|
<span className="ml-1">to save</span> |
|
</div> |
|
)} */} |
|
<div className="flex justify-between mt-2"> |
|
<button |
|
type="button" |
|
className="flex items-center bg-red-500 hover:bg-red-600 text-white rounded px-2 py-1 text-xs" |
|
onClick={(e) => { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
handleDeleteMaterial(e, showPromptInfo); |
|
setShowPromptInfo(null); |
|
}} |
|
> |
|
<Trash2 className="w-3 h-3 mr-1" /> |
|
Delete |
|
</button> |
|
<button |
|
type="button" |
|
className={`flex items-center ${ |
|
editingPrompt |
|
? hasPromptChanged |
|
? 'bg-blue-500 hover:bg-blue-600' |
|
: 'bg-gray-300 text-gray-500 cursor-not-allowed' |
|
: 'bg-blue-500 hover:bg-blue-600' |
|
} text-white rounded px-2 py-1 text-xs`} |
|
onClick={(e) => { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
if (editingPrompt) { |
|
if (hasPromptChanged) { |
|
saveEditedPrompt(); |
|
} |
|
} else { |
|
setEditingPrompt(showPromptInfo); |
|
setEditedPromptText(materials[showPromptInfo]?.prompt || ''); |
|
} |
|
}} |
|
disabled={editingPrompt && !hasPromptChanged} |
|
> |
|
{editingPrompt |
|
? <><Check className="w-3 h-3 mr-1" />Save Changes</> |
|
: <><Edit className="w-3 h-3 mr-1" />Edit Prompt</> |
|
} |
|
</button> |
|
</div> |
|
</> |
|
) : ( |
|
<> |
|
<div className="font-mono text-xs overflow-y-auto max-h-60 border border-gray-200 rounded p-2 bg-gray-50 mb-3"> |
|
{materials[showPromptInfo]?.prompt || 'No prompt available'} |
|
</div> |
|
</> |
|
)} |
|
|
|
<div className="absolute bottom-0 left-1/2 w-3 h-3 bg-white border-b border-r border-gray-200 transform translate-y-1/2 rotate-45 -translate-x-1/2" /> |
|
</div>, |
|
document.body |
|
)} |
|
|
|
<AddMaterialModal |
|
showModal={showAddMaterialModal} |
|
onClose={() => setShowAddMaterialModal(false)} |
|
onAddMaterial={handleCreateMaterial} |
|
newMaterialName={newMaterialName} |
|
setNewMaterialName={setNewMaterialName} |
|
generatedMaterialName={generatedMaterialName} |
|
setGeneratedMaterialName={setGeneratedMaterialName} |
|
generatedPrompt={generatedPrompt} |
|
setGeneratedPrompt={setGeneratedPrompt} |
|
customPrompt={customPrompt} |
|
setCustomPrompt={setCustomPrompt} |
|
previewThumbnail={previewThumbnail} |
|
customImagePreview={customImagePreview} |
|
useCustomImage={useCustomImage} |
|
isGeneratingPreview={isGeneratingPreview} |
|
isGeneratingText={isGeneratingText} |
|
showMaterialNameEdit={showMaterialNameEdit} |
|
setShowMaterialNameEdit={setShowMaterialNameEdit} |
|
showCustomPrompt={showCustomPrompt} |
|
setShowCustomPrompt={setShowCustomPrompt} |
|
handleRefreshThumbnail={handleRefreshThumbnail} |
|
handleReferenceImageUpload={handleReferenceImageUpload} |
|
handleNewMaterialDescription={handleNewMaterialDescription} |
|
onStyleSelected={(materialKey) => { |
|
|
|
setStyleMode(materialKey); |
|
|
|
setMaterials({...styleOptions}); |
|
}} |
|
materials={materials} |
|
/> |
|
</div> |
|
); |
|
}; |
|
|
|
export default StyleSelector; |