Spaces:
Configuration error
Configuration error
| import React, { useState, useEffect, useRef, useMemo } from 'react'; | |
| import { Modal } from '../Modal'; | |
| import { AdminConfig, NFT, GameCharacter, WeaponType, DecorativeFishType, BubbleSize, SavedReply } from '../../types'; | |
| import { generateImageWithGemini, GeminiImageResponse } from '../../geminiApi'; | |
| import { SoundEvent } from '../../sounds'; | |
| import { COOLDOWN_RED_ARROW_DEFENSE, RED_ARROW_PROJECTILE_SPEED, RED_ARROW_PROJECTILE_DAMAGE, PLAYER_SPEED, RED_ARROW_DEFAULT_SHOT_COUNT, RED_ARROW_PROJECTILE_IMAGE_URL, DOGE_WHALE_ESCAPE_PLAYER_IMAGE_URL, DOGE_WHALE_ESCAPE_WALL_IMAGE_URL, DOGE_WHALE_ESCAPE_GOAL_IMAGE_URL, DOGE_WHALE_ESCAPE_MINE_IMAGE_URL, DOGE_WHALE_ESCAPE_BOMB_IMAGE_URL, DOGE_WHALE_ESCAPE_JELLYFISH_IMAGE_URL, DOGE_WHALE_ESCAPE_CROCODILE_IMAGE_URL, DOGE_WHALE_ESCAPE_ALGAE_IMAGE_URL, DOGE_WHALE_ESCAPE_CURRENT_N_IMAGE_URL, DOGE_WHALE_ESCAPE_CURRENT_S_IMAGE_URL, DOGE_WHALE_ESCAPE_CURRENT_E_IMAGE_URL, DOGE_WHALE_ESCAPE_CURRENT_W_IMAGE_URL, DOGE_WHALE_ESCAPE_SNAKE_IMAGE_URL, DOGE_WHALE_ESCAPE_LIFEUP_IMAGE_URL } from '../../constants'; | |
| import { GoogleGenAI, GenerateContentResponse } from "@google/genai"; | |
| import { generateId } from '../../utils'; | |
| interface AdminPanelModalProps { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| adminConfig: AdminConfig; | |
| runtimeNftDefinitions: Record<string, NFT>; | |
| runtimeGameCharacters: Record<string, GameCharacter>; | |
| onUpdateAdminConfig: (newConfig: Partial<AdminConfig>) => void; | |
| onUpdateNftImage: (nftId: string, newImageUrl: string) => void; | |
| onUpdateCharacterAttributes: (characterId: string, updates: Partial<GameCharacter>) => void; | |
| showNotification: (message: string, type?: 'success' | 'error' | 'info' | 'warning' | 'special') => void; | |
| isMaximizable?: boolean; | |
| isMaximized?: boolean; | |
| onToggleMaximize?: () => void; | |
| onLogout: () => void; // New prop for logout | |
| } | |
| type AdminTab = 'aiGenerator' | 'nfts' | 'characters' | 'staking' | 'assets' | 'gameplay' | 'sounds' | 'effectsVisuals' | 'aiChat' | 'knowledgeBase' | 'dogeEscapeAssets'; | |
| type SoundUrlKey = Extract<keyof AdminConfig, `sound${string}Url`>; | |
| type AdminConfigImageKey = Extract<keyof AdminConfig, `${string}Image${string}` | `${string}ImageUrl`>; | |
| const convertFileToBase64 = (file: File): Promise<string> => { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.readAsDataURL(file); | |
| reader.onload = () => resolve(reader.result as string); | |
| reader.onerror = error => reject(error); | |
| }); | |
| }; | |
| interface CharacterAttributeInputs { | |
| name: string; | |
| imageURL: string; | |
| imageBase64?: string | null; | |
| defaultWeaponType?: WeaponType; | |
| autoFireHealthThreshold?: number; | |
| baseHealth?: number; | |
| baseSpeed?: number; | |
| } | |
| const formatWeaponTypeName = (enumKey: string) => { | |
| return enumKey.replace(/([A-Z])/g, ' $1').trim().replace(/_/g, ' '); | |
| }; | |
| const formatSoundEventName = (event: SoundEvent): string => { | |
| return event | |
| .split('_') | |
| .map(word => word.charAt(0).toUpperCase() + word.slice(1)) | |
| .join(' '); | |
| }; | |
| interface AIChatMessage { | |
| id: string; | |
| sender: 'user' | 'ai' | 'error'; | |
| text: string; | |
| lang?: string; // Language of the user's query | |
| } | |
| const languageOptions = [ | |
| { name: 'English', code: 'en' }, | |
| { name: 'Spanish', code: 'es' }, | |
| { name: 'French', code: 'fr' }, | |
| { name: 'German', code: 'de' }, | |
| { name: 'Japanese', code: 'ja' }, | |
| { name: 'Chinese', code: 'zh' }, | |
| { name: 'Korean', code: 'ko' }, | |
| ]; | |
| const dogeEscapeAssetConfigKeys: Array<{key: AdminConfigImageKey, displayName: string, defaultUrl: string}> = [ | |
| { key: 'dogeEscapePlayerImageUrl', displayName: 'Doge Escape Player', defaultUrl: DOGE_WHALE_ESCAPE_PLAYER_IMAGE_URL }, | |
| { key: 'dogeEscapeWallImageUrl', displayName: 'Doge Escape Wall', defaultUrl: DOGE_WHALE_ESCAPE_WALL_IMAGE_URL }, | |
| { key: 'dogeEscapeGoalImageUrl', displayName: 'Doge Escape Goal', defaultUrl: DOGE_WHALE_ESCAPE_GOAL_IMAGE_URL }, | |
| { key: 'dogeEscapeMineImageUrl', displayName: 'Doge Escape Mine', defaultUrl: DOGE_WHALE_ESCAPE_MINE_IMAGE_URL }, | |
| { key: 'dogeEscapeBombImageUrl', displayName: 'Doge Escape Bomb', defaultUrl: DOGE_WHALE_ESCAPE_BOMB_IMAGE_URL }, | |
| { key: 'dogeEscapeJellyfishImageUrl', displayName: 'Doge Escape Jellyfish', defaultUrl: DOGE_WHALE_ESCAPE_JELLYFISH_IMAGE_URL }, | |
| { key: 'dogeEscapeCrocodileImageUrl', displayName: 'Doge Escape Crocodile', defaultUrl: DOGE_WHALE_ESCAPE_CROCODILE_IMAGE_URL }, | |
| { key: 'dogeEscapeAlgaeImageUrl', displayName: 'Doge Escape Toxic Algae', defaultUrl: DOGE_WHALE_ESCAPE_ALGAE_IMAGE_URL }, | |
| { key: 'dogeEscapeCurrentNImageUrl', displayName: 'Doge Escape Current (North)', defaultUrl: DOGE_WHALE_ESCAPE_CURRENT_N_IMAGE_URL }, | |
| { key: 'dogeEscapeCurrentSImageUrl', displayName: 'Doge Escape Current (South)', defaultUrl: DOGE_WHALE_ESCAPE_CURRENT_S_IMAGE_URL }, | |
| { key: 'dogeEscapeCurrentEImageUrl', displayName: 'Doge Escape Current (East)', defaultUrl: DOGE_WHALE_ESCAPE_CURRENT_E_IMAGE_URL }, | |
| { key: 'dogeEscapeCurrentWImageUrl', displayName: 'Doge Escape Current (West)', defaultUrl: DOGE_WHALE_ESCAPE_CURRENT_W_IMAGE_URL }, | |
| { key: 'dogeEscapeSnakeImageUrl', displayName: 'Doge Escape Snake', defaultUrl: DOGE_WHALE_ESCAPE_SNAKE_IMAGE_URL }, | |
| { key: 'dogeEscapeLifeUpImageUrl', displayName: 'Doge Escape Life-Up', defaultUrl: DOGE_WHALE_ESCAPE_LIFEUP_IMAGE_URL }, | |
| ]; | |
| export const AdminPanelModal: React.FC<AdminPanelModalProps> = ({ | |
| isOpen, | |
| onClose, | |
| adminConfig, | |
| runtimeNftDefinitions, | |
| runtimeGameCharacters, | |
| onUpdateAdminConfig, | |
| onUpdateNftImage, | |
| onUpdateCharacterAttributes, | |
| showNotification, | |
| isMaximizable, | |
| isMaximized, | |
| onToggleMaximize, | |
| onLogout, // Destructure new prop | |
| }) => { | |
| const [activeTab, setActiveTab] = useState<AdminTab>('aiChat'); | |
| const [localAdminConfig, setLocalAdminConfig] = useState<AdminConfig>(adminConfig); | |
| const [nftImageInputs, setNftImageInputs] = useState<Record<string, string>>({}); | |
| const [pendingNftImageChanges, setPendingNftImageChanges] = useState<Record<string, string | null>>({}); | |
| const [characterAttributeInputs, setCharacterAttributeInputs] = useState<Record<string, CharacterAttributeInputs>>({}); | |
| const [aiPrompts, setAiPrompts] = useState<Record<string, string>>({}); | |
| const [aiGeneratedImagePreviews, setAiGeneratedImagePreviews] = useState<Record<string, string | null>>({}); | |
| const [isGenerating, setIsGenerating] = useState<Record<string, boolean>>({}); | |
| const [superAiAgentEnabled, setSuperAiAgentEnabled] = useState<boolean>(true); | |
| const [aiSuggestionDimensions, setAiSuggestionDimensions] = useState<string>(''); | |
| const [aiSuggestionDirection, setAiSuggestionDirection] = useState<string>('any'); | |
| const [aiSuggestionStyle, setAiSuggestionStyle] = useState<string>('any'); | |
| const [aiSuggestionBackground, setAiSuggestionBackground] = useState<string>('any'); | |
| const [adminAssetUrlInputs, setAdminAssetUrlInputs] = useState<Record<AdminConfigImageKey, string>>({} as Record<AdminConfigImageKey, string>); | |
| const [pendingAdminAssetBase64, setPendingAdminAssetBase64] = useState<Partial<Record<AdminConfigImageKey, string | null>>>({}); | |
| const [pendingSoundUploads, setPendingSoundUploads] = useState<Partial<Record<SoundUrlKey, string>>>({}); | |
| // AI Chat Bot State | |
| const [aiChatMessages, setAiChatMessages] = useState<AIChatMessage[]>([]); | |
| const [currentAiQuery, setCurrentAiQuery] = useState(''); | |
| const [selectedAiLanguage, setSelectedAiLanguage] = useState(languageOptions[0].code); // Default to English code | |
| const [isAiResponding, setIsAiResponding] = useState(false); | |
| const aiChatLogRef = useRef<HTMLDivElement>(null); | |
| const geminiAi = useMemo(() => new GoogleGenAI({ apiKey: process.env.API_KEY! }), []); | |
| // Knowledge Base State | |
| const [newReplyTopic, setNewReplyTopic] = useState<string>(''); | |
| const [newReplyAnswer, setNewReplyAnswer] = useState<string>(''); | |
| useEffect(() => { | |
| if (isOpen) { | |
| const newLocalAdminConfig = JSON.parse(JSON.stringify(adminConfig)); | |
| if (!newLocalAdminConfig.savedReplies) { // Initialize if undefined | |
| newLocalAdminConfig.savedReplies = []; | |
| } | |
| setLocalAdminConfig(newLocalAdminConfig); | |
| const initialNftImageInputs: Record<string, string> = {}; | |
| const initialPendingNftChanges: Record<string, string | null> = {}; | |
| Object.keys(runtimeNftDefinitions).forEach(nftId => { | |
| const img = runtimeNftDefinitions[nftId].image; | |
| initialNftImageInputs[nftId] = img.startsWith('data:image') ? '' : img; | |
| initialPendingNftChanges[nftId] = img.startsWith('data:image') ? img : null; | |
| }); | |
| setNftImageInputs(initialNftImageInputs); | |
| setPendingNftImageChanges(initialPendingNftChanges); | |
| const initialCharAttrInputs: Record<string, CharacterAttributeInputs> = {}; | |
| Object.keys(runtimeGameCharacters).forEach(charId => { | |
| const char = runtimeGameCharacters[charId]; | |
| initialCharAttrInputs[charId] = { | |
| name: char.name, | |
| imageURL: char.image.startsWith('data:image') ? '' : char.image, | |
| imageBase64: char.image.startsWith('data:image') ? char.image : null, | |
| defaultWeaponType: char.defaultWeaponType || WeaponType.StandardBullet, | |
| autoFireHealthThreshold: char.autoFireHealthThreshold || 0, | |
| baseHealth: char.baseHealth || (char.type === 'player' ? 100 : (char.type === 'enemy' ? 25 : 100)), | |
| baseSpeed: char.baseSpeed || (char.type === 'enemy' ? 1 : PLAYER_SPEED), | |
| }; | |
| }); | |
| setCharacterAttributeInputs(initialCharAttrInputs); | |
| const currentAdminAssetUrlInputs: Record<AdminConfigImageKey, string> = {} as Record<AdminConfigImageKey, string>; | |
| const currentPendingAdminAssetBase64: Partial<Record<AdminConfigImageKey, string | null>> = {}; | |
| (Object.keys(newLocalAdminConfig) as Array<keyof AdminConfig>).forEach(key => { | |
| if ((key.toLowerCase().includes('image') || key.toLowerCase().includes('imageurl')) && typeof newLocalAdminConfig[key] === 'string') { | |
| const imgVal = newLocalAdminConfig[key] as string; | |
| if (imgVal.startsWith('data:image')) { | |
| currentPendingAdminAssetBase64[key as AdminConfigImageKey] = imgVal; | |
| currentAdminAssetUrlInputs[key as AdminConfigImageKey] = ''; | |
| } else { | |
| currentAdminAssetUrlInputs[key as AdminConfigImageKey] = imgVal; | |
| currentPendingAdminAssetBase64[key as AdminConfigImageKey] = null; | |
| } | |
| } | |
| }); | |
| setAdminAssetUrlInputs(currentAdminAssetUrlInputs); | |
| setPendingAdminAssetBase64(currentPendingAdminAssetBase64); | |
| setAiPrompts({}); | |
| setAiGeneratedImagePreviews({}); | |
| setIsGenerating({}); | |
| setPendingSoundUploads({}); | |
| // Reset AI Chat state | |
| setAiChatMessages([]); | |
| setCurrentAiQuery(''); | |
| setIsAiResponding(false); | |
| // Reset Knowledge Base input fields | |
| setNewReplyTopic(''); | |
| setNewReplyAnswer(''); | |
| } | |
| }, [isOpen, adminConfig, runtimeNftDefinitions, runtimeGameCharacters]); | |
| const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => { | |
| const { name, value, type } = e.target; | |
| const checked = (e.target as HTMLInputElement).checked; | |
| setLocalAdminConfig(prev => ({ | |
| ...prev, | |
| [name]: type === 'checkbox' ? checked : type === 'number' ? parseFloat(value) : value, | |
| })); | |
| }; | |
| const handleNftImageUrlChange = (nftId: string, value: string) => { | |
| setNftImageInputs(prev => ({ ...prev, [nftId]: value })); | |
| setPendingNftImageChanges(prev => ({...prev, [nftId]: null})); | |
| }; | |
| const handleNftImageFileUpload = async (nftId: string, file: File | null) => { | |
| if (file) { | |
| try { | |
| const base64String = await convertFileToBase64(file); | |
| setPendingNftImageChanges(prev => ({...prev, [nftId]: base64String})); | |
| setNftImageInputs(prev => ({ ...prev, [nftId]: '' })); | |
| showNotification(`Image ready for NFT: ${runtimeNftDefinitions[nftId]?.name}. Click 'Apply Custom Image'.`, 'info'); | |
| } catch (error) { console.error("Error converting NFT image:", error); showNotification('Failed to load image file.', 'error');} | |
| } | |
| }; | |
| const handleApplyCustomNftImage = (nftId: string) => { | |
| const pendingBase64 = pendingNftImageChanges[nftId]; | |
| const urlInput = nftImageInputs[nftId]; | |
| if (pendingBase64) { | |
| onUpdateNftImage(nftId, pendingBase64); | |
| } else if (urlInput && urlInput.trim() !== '') { | |
| onUpdateNftImage(nftId, urlInput); | |
| } else { | |
| showNotification('No new image (URL or file) to apply.', 'warning'); | |
| } | |
| }; | |
| const handleCharacterAttributeChange = (characterId: string, field: keyof CharacterAttributeInputs, value: string | number | WeaponType | undefined) => { | |
| setCharacterAttributeInputs(prev => ({ | |
| ...prev, | |
| [characterId]: { | |
| ...(prev[characterId] || {} as CharacterAttributeInputs), | |
| [field]: value, | |
| ...(field === 'imageURL' && { imageBase64: null }) | |
| } | |
| })); | |
| }; | |
| const handleCharacterSelectChange = (characterId: string, field: keyof CharacterAttributeInputs, value: string) => { | |
| const weaponTypeEnumValue = WeaponType[value as keyof typeof WeaponType]; | |
| handleCharacterAttributeChange(characterId, field, weaponTypeEnumValue || WeaponType.StandardBullet); | |
| }; | |
| const handleCharacterImageFileUpload = async (characterId: string, file: File | null) => { | |
| if (file) { | |
| try { | |
| const base64String = await convertFileToBase64(file); | |
| setCharacterAttributeInputs(prev => ({ | |
| ...prev, | |
| [characterId]: { | |
| ...(prev[characterId] || {} as CharacterAttributeInputs), | |
| imageBase64: base64String, | |
| imageURL: '' | |
| } | |
| })); | |
| showNotification(`Image ready for Character: ${runtimeGameCharacters[characterId]?.name}. Click 'Apply Custom Image'.`, 'info'); | |
| } catch (error) { console.error("Error converting character image:", error); showNotification('Failed to load image file.', 'error');} | |
| } | |
| }; | |
| const handleApplyCustomCharacterImage = (characterId: string) => { | |
| const currentInputs = characterAttributeInputs[characterId]; | |
| if (!currentInputs) return; | |
| if (currentInputs.imageBase64) { | |
| onUpdateCharacterAttributes(characterId, { image: currentInputs.imageBase64 }); | |
| } else if (currentInputs.imageURL && currentInputs.imageURL.trim() !== '') { | |
| onUpdateCharacterAttributes(characterId, { image: currentInputs.imageURL }); | |
| } else { | |
| showNotification('No new image (URL or file) to apply for character.', 'warning'); | |
| } | |
| }; | |
| const handleApplyCharacterNonImageChanges = (characterId: string) => { | |
| const currentInputs = characterAttributeInputs[characterId]; | |
| if (!currentInputs) return; | |
| const updates: Partial<GameCharacter> = {}; | |
| const originalChar = runtimeGameCharacters[characterId]; | |
| if (currentInputs.name !== originalChar.name) updates.name = currentInputs.name; | |
| if (currentInputs.baseHealth !== originalChar.baseHealth && typeof currentInputs.baseHealth === 'number') updates.baseHealth = currentInputs.baseHealth; | |
| if (currentInputs.baseSpeed !== originalChar.baseSpeed && typeof currentInputs.baseSpeed === 'number') updates.baseSpeed = currentInputs.baseSpeed; | |
| if (originalChar.type === 'player') { | |
| if (currentInputs.defaultWeaponType !== (originalChar.defaultWeaponType || WeaponType.StandardBullet)) updates.defaultWeaponType = currentInputs.defaultWeaponType; | |
| const newThreshold = Number(currentInputs.autoFireHealthThreshold); | |
| if (newThreshold !== (originalChar.autoFireHealthThreshold || 0) && !isNaN(newThreshold)) updates.autoFireHealthThreshold = Math.max(0, Math.min(100, newThreshold)); | |
| } | |
| if (Object.keys(updates).length > 0) onUpdateCharacterAttributes(characterId, updates); | |
| else showNotification('No non-image attributes changed.', 'info'); | |
| }; | |
| const handleAdminConfigAssetUrlChange = (configKey: AdminConfigImageKey, value: string) => { | |
| setAdminAssetUrlInputs(prev => ({ ...prev, [configKey]: value })); | |
| setPendingAdminAssetBase64(prev => ({ ...prev, [configKey]: null })); | |
| setLocalAdminConfig(prev => ({ ...prev, [configKey]: value })); | |
| }; | |
| const handleAdminConfigAssetFileUpload = async (configKey: AdminConfigImageKey, file: File | null) => { | |
| if (file) { | |
| try { | |
| const base64String = await convertFileToBase64(file); | |
| setPendingAdminAssetBase64(prev => ({ ...prev, [configKey]: base64String })); | |
| setAdminAssetUrlInputs(prev => ({ ...prev, [configKey]: '' })); | |
| setLocalAdminConfig(prev => ({ ...prev, [configKey]: base64String })); | |
| showNotification(`Image ready for ${configKey}. Save all admin changes to finalize.`, 'info'); | |
| } catch (error) { console.error(`Error converting ${configKey} image:`, error); showNotification('Failed to load image file.', 'error'); } | |
| } | |
| }; | |
| const handleApplyCustomAdminConfigAssetImage = (configKey: AdminConfigImageKey) => { | |
| const pendingBase64 = pendingAdminAssetBase64[configKey]; | |
| const urlInput = adminAssetUrlInputs[configKey]; | |
| let applied = false; | |
| if (pendingBase64) { | |
| setLocalAdminConfig(prev => ({ ...prev, [configKey]: pendingBase64 })); | |
| applied = true; | |
| } else if (urlInput && urlInput.trim() !== '') { | |
| setLocalAdminConfig(prev => ({ ...prev, [configKey]: urlInput })); | |
| applied = true; | |
| } | |
| if (applied) showNotification(`Preview updated for ${configKey}. Save all admin changes to finalize.`, 'info'); | |
| else showNotification(`No new image (URL or file) to apply for ${configKey}.`, 'warning'); | |
| }; | |
| const handleSoundFileUpload = async (eventKey: SoundEvent, file: File | null) => { | |
| if (file) { | |
| try { | |
| const base64String = await convertFileToBase64(file); | |
| const adminSoundKeyPattern = `sound${eventKey.charAt(0).toUpperCase() + eventKey.slice(1).replace(/_([a-z])/g, (g) => g[1].toUpperCase())}Url`; | |
| const adminSoundKey = adminSoundKeyPattern as SoundUrlKey; | |
| setPendingSoundUploads(prev => ({...prev, [adminSoundKey]: base64String})); | |
| setLocalAdminConfig(prev => ({ ...prev, [adminSoundKey]: base64String })); | |
| showNotification(`${formatSoundEventName(eventKey)} sound updated. Save all admin changes to finalize.`, 'info'); | |
| } catch (error) { console.error(`Error converting sound file for ${eventKey}:`, error); showNotification(`Failed to load sound for ${eventKey}.`, 'error');} | |
| } | |
| }; | |
| const handleSaveAllAdminChanges = () => { | |
| const finalConfig = {...localAdminConfig}; | |
| (Object.keys(pendingAdminAssetBase64) as Array<AdminConfigImageKey>).forEach(key => { | |
| if (pendingAdminAssetBase64[key]) { | |
| finalConfig[key] = pendingAdminAssetBase64[key]!; | |
| } else if (adminAssetUrlInputs[key] && adminAssetUrlInputs[key].trim() !== '') { | |
| finalConfig[key] = adminAssetUrlInputs[key]; | |
| } | |
| }); | |
| Object.keys(pendingSoundUploads).forEach(stringKey => { | |
| const key = stringKey as SoundUrlKey; | |
| if (pendingSoundUploads[key]) { | |
| finalConfig[key] = pendingSoundUploads[key]!; | |
| } | |
| }); | |
| onUpdateAdminConfig(finalConfig); | |
| }; | |
| const handleAiPromptChange = (assetKey: string, prompt: string) => { | |
| setAiPrompts(prev => ({ ...prev, [assetKey]: prompt })); | |
| }; | |
| const generateCharacterPromptFromName = ( | |
| characterName: string, | |
| characterType: 'player' | 'enemy' | |
| ): string => { | |
| const cleanName = characterName.replace(/\p{Emoji}/gu, '').trim(); | |
| let baseDescription = ""; | |
| if (characterType === 'player') { | |
| baseDescription = `A game character asset, a player Doge Whale, visually representing: ${cleanName}.`; | |
| } else { | |
| baseDescription = `A game character asset, an enemy sea creature, visually representing: ${cleanName}.`; | |
| } | |
| let prompt = `${baseDescription}`; | |
| if (aiSuggestionStyle && aiSuggestionStyle !== 'any') { | |
| prompt += ` Style: ${aiSuggestionStyle}.`; | |
| } else { | |
| prompt += ` Style: 3D render.`; | |
| } | |
| if (aiSuggestionDimensions) { | |
| prompt += ` Dimensions: ${aiSuggestionDimensions}.`; | |
| } | |
| if (aiSuggestionDirection && aiSuggestionDirection !== 'any') { | |
| prompt += ` Direction: ${aiSuggestionDirection}.`; | |
| } | |
| if (aiSuggestionBackground && aiSuggestionBackground !== 'any') { | |
| prompt += ` Background: ${aiSuggestionBackground}.`; | |
| } else { | |
| prompt += ` Background: transparent background for versatility.`; | |
| } | |
| return prompt.trim(); | |
| }; | |
| const handleAutoGenerateForCharacter = async (characterId: string) => { | |
| const character = runtimeGameCharacters[characterId]; | |
| if (!character) return; | |
| const assetKey = `char_${characterId}`; | |
| const generatedPrompt = generateCharacterPromptFromName( | |
| character.name, | |
| character.type | |
| ); | |
| setAiPrompts(prev => ({ ...prev, [assetKey]: generatedPrompt })); | |
| showNotification(`Prompt suggested for ${character.name}: "${generatedPrompt.substring(0,50)}..."`, 'info'); | |
| await handleGenerateAiImage(assetKey, 'character', characterId); | |
| }; | |
| const handleGenerateAiImage = async (assetKey: string, itemType: string, idOrConfigKey: string) => { | |
| const userPrompt = aiPrompts[assetKey]; | |
| if (!userPrompt) { | |
| showNotification("Please enter or auto-generate a prompt for the AI.", "warning"); | |
| return; | |
| } | |
| setIsGenerating(prev => ({ ...prev, [assetKey]: true })); | |
| setAiGeneratedImagePreviews(prev => ({ ...prev, [assetKey]: null })); | |
| let fullPrompt = ""; | |
| let itemNameForNotification = idOrConfigKey; | |
| switch (itemType) { | |
| case 'nft': | |
| fullPrompt = `A digital art NFT for a game, depicting a Doge Whale character. The NFT should represent: ${userPrompt}.`; | |
| itemNameForNotification = runtimeNftDefinitions[idOrConfigKey]?.name || idOrConfigKey; | |
| break; | |
| case 'character': | |
| fullPrompt = userPrompt; | |
| itemNameForNotification = runtimeGameCharacters[idOrConfigKey]?.name || idOrConfigKey; | |
| break; | |
| case 'adminConfigAsset': // Generic handler for AdminConfig assets based on idOrConfigKey | |
| const configKeyName = idOrConfigKey as keyof AdminConfig; | |
| const displayNameForKey = configKeyName | |
| .replace('adminConfig_', '') | |
| .replace('ImageUrl', '') | |
| .replace('Image', '') | |
| .replace(/([A-Z])/g, ' $1').trim(); | |
| if (configKeyName.includes('dogeEscape')) { | |
| fullPrompt = `A game asset for 'Doge Whale Escape', a 2D maze game. The asset is a ${displayNameForKey}. Appearance: ${userPrompt}. Style: Clear, slightly pixelated or cartoonish, suitable for a grid-based game.`; | |
| } else if (configKeyName === 'gameBackgroundImageUrl') { | |
| fullPrompt = `A vibrant and immersive game background for an underwater space MMORPG featuring Doge Whales. The scene should be: ${userPrompt}. Scenic and detailed.`; | |
| } else if (configKeyName === 'defaultEnemyImageUrl') { | |
| fullPrompt = `A generic enemy creature for an underwater space game. Appearance: ${userPrompt}. Needs to be distinct and somewhat menacing.`; | |
| } else if (configKeyName === 'seaTokenImageUrl') { | |
| fullPrompt = `A single, distinct game currency item called a "Sea Token". It should look like: ${userPrompt}. Small, clear, iconic design.`; | |
| } else if (configKeyName === 'redArrowImageUrl') { | |
| fullPrompt = `A sleek, powerful red arrow projectile for a defensive weapon system in a space game. Appearance: ${userPrompt}. Dynamic, sci-fi style, glowing red.`; | |
| } else if (configKeyName === 'flameParticleImageUrl') { | |
| fullPrompt = `Small, bright, fiery particles or hot embers, suitable for a flame cone weapon effect in a game. Details: ${userPrompt}. Style: VFX sprite, clear background if possible.`; | |
| } else if (configKeyName === 'enemyBurningEffectImageUrl') { | |
| fullPrompt = `A visual effect for a game, representing an enemy character on fire. Could be a sprite sheet for an animation or a static overlay. Details: ${userPrompt}. Style: Game visual effect, transparent background suitable for overlay.`; | |
| } else if (configKeyName === 'fireballProjectileImageUrl') { | |
| fullPrompt = `A dynamic and impactful fireball projectile for a fantasy or sci-fi game weapon. Details: ${userPrompt}. Style: Magical, fiery, possibly with a trailing effect, on a transparent background.`; | |
| } else if (configKeyName.startsWith('decorativeFish')) { | |
| const fishType = displayNameForKey.replace('Decorative Fish ', ''); | |
| fullPrompt = `A decorative ${fishType.toLowerCase()} fish for an underwater space game. Appearance: ${userPrompt}.`; | |
| } else if (configKeyName.startsWith('bubble')) { | |
| const bubbleSize = displayNameForKey.replace('Bubble ', ''); | |
| fullPrompt = `A ${bubbleSize.toLowerCase()} bubble effect for an underwater game. Appearance: ${userPrompt}.`; | |
| } else { | |
| fullPrompt = `A game asset: ${displayNameForKey}. Description: ${userPrompt}.`; | |
| } | |
| itemNameForNotification = displayNameForKey; | |
| break; | |
| default: | |
| showNotification("Unknown item type for AI generation.", "error"); | |
| setIsGenerating(prev => ({ ...prev, [assetKey]: false })); | |
| return; | |
| } | |
| const result: GeminiImageResponse = await generateImageWithGemini(fullPrompt); | |
| setIsGenerating(prev => ({ ...prev, [assetKey]: false })); | |
| if (result.success && result.imageUrl) { | |
| setAiGeneratedImagePreviews(prev => ({ ...prev, [assetKey]: result.imageUrl! })); | |
| if (superAiAgentEnabled) { | |
| handleApplyAiImage(assetKey, itemType, idOrConfigKey, result.imageUrl); | |
| showNotification(`AI image generated and auto-applied to ${itemNameForNotification}!`, "success"); | |
| } else { | |
| showNotification(`Image generated for ${itemNameForNotification}! Preview below. Click 'Apply AI Image'.`, "success"); | |
| } | |
| } else { | |
| if (result.errorType === 'quota_exhausted') { | |
| showNotification(result.message || 'API Quota Exceeded. Check your plan or wait for reset.', 'error'); | |
| } else { | |
| showNotification(result.message || `Failed to generate image for ${itemNameForNotification}. Check console.`, 'error'); | |
| } | |
| } | |
| }; | |
| const handleApplyAiImage = (assetKey: string, itemType: string, idOrConfigKey: string, imageUrlToApply?: string) => { | |
| const imageUrl = imageUrlToApply || aiGeneratedImagePreviews[assetKey]; | |
| if (!imageUrl) { | |
| showNotification("No AI-generated image to apply.", "warning"); | |
| return; | |
| } | |
| let itemNameForNotification = idOrConfigKey; | |
| if (itemType === 'nft') { | |
| onUpdateNftImage(idOrConfigKey, imageUrl); | |
| itemNameForNotification = runtimeNftDefinitions[idOrConfigKey]?.name || idOrConfigKey; | |
| showNotification(`AI image applied to NFT: ${itemNameForNotification}.`, "success"); | |
| } else if (itemType === 'character') { | |
| onUpdateCharacterAttributes(idOrConfigKey, { image: imageUrl }); | |
| itemNameForNotification = runtimeGameCharacters[idOrConfigKey]?.name || idOrConfigKey; | |
| showNotification(`AI image applied to Character: ${itemNameForNotification}.`, "success"); | |
| } else if (itemType === 'adminConfigAsset') { // Updated for generic admin config assets | |
| const configKey = idOrConfigKey as AdminConfigImageKey; | |
| setLocalAdminConfig(prev => ({ ...prev, [configKey]: imageUrl })); | |
| setPendingAdminAssetBase64(prev => ({ ...prev, [configKey]: imageUrl })); | |
| setAdminAssetUrlInputs(prev => ({ ...prev, [configKey]: '' })); | |
| itemNameForNotification = configKey.replace('adminConfig_', '').replace('ImageUrl', '').replace('Image', '').replace(/([A-Z])/g, ' $1').trim(); | |
| showNotification(`AI image applied to ${itemNameForNotification}. Save all admin changes to finalize.`, "success"); | |
| } else { | |
| showNotification("Unknown item type for applying AI image.", "error"); | |
| return; | |
| } | |
| if (!superAiAgentEnabled || imageUrlToApply) { | |
| setAiGeneratedImagePreviews(prev => ({ ...prev, [assetKey]: null })); | |
| } | |
| }; | |
| // AI Chat Bot Logic | |
| const handleSendAiQuery = async () => { | |
| if (currentAiQuery.trim() === '' || isAiResponding) return; | |
| const userMessage: AIChatMessage = { | |
| id: generateId(), | |
| sender: 'user', | |
| text: currentAiQuery, | |
| lang: languageOptions.find(l => l.code === selectedAiLanguage)?.name || selectedAiLanguage, | |
| }; | |
| setAiChatMessages(prev => [...prev, userMessage]); | |
| setIsAiResponding(true); | |
| setCurrentAiQuery(''); | |
| try { | |
| const languageName = languageOptions.find(l => l.code === selectedAiLanguage)?.name || 'English'; | |
| let knowledgeBaseText = ""; | |
| if (localAdminConfig.savedReplies && localAdminConfig.savedReplies.length > 0) { | |
| knowledgeBaseText = "You have access to the following predefined knowledge base. Use this information when relevant to the user's query:\n"; | |
| knowledgeBaseText += JSON.stringify(localAdminConfig.savedReplies.map(r => ({topic: r.topic, answer: r.answer}))) + "\n---\n"; | |
| } | |
| const systemInstruction = `${knowledgeBaseText}You are a helpful AI assistant for a game administrator. Respond to the user's query in ${languageName}. Be concise and helpful.`; | |
| const response: GenerateContentResponse = await geminiAi.models.generateContent({ | |
| model: 'gemini-2.5-flash-preview-04-17', | |
| contents: userMessage.text, | |
| config: { systemInstruction: systemInstruction }, | |
| }); | |
| const aiTextResponse = response.text; | |
| const aiMessage: AIChatMessage = { | |
| id: generateId(), | |
| sender: 'ai', | |
| text: aiTextResponse, | |
| }; | |
| setAiChatMessages(prev => [...prev, aiMessage]); | |
| } catch (error: any) { | |
| console.error("Error with AI Chat:", error); | |
| const errorMessage: AIChatMessage = { | |
| id: generateId(), | |
| sender: 'error', | |
| text: `AI Error: ${error.message || 'Could not get a response.'}`, | |
| }; | |
| setAiChatMessages(prev => [...prev, errorMessage]); | |
| } finally { | |
| setIsAiResponding(false); | |
| } | |
| }; | |
| useEffect(() => { | |
| if (aiChatLogRef.current) { | |
| aiChatLogRef.current.scrollTop = aiChatLogRef.current.scrollHeight; | |
| } | |
| }, [aiChatMessages]); | |
| // Knowledge Base Management | |
| const handleAddReply = () => { | |
| if (!newReplyTopic.trim() || !newReplyAnswer.trim()) { | |
| showNotification("Topic and Answer cannot be empty.", "warning"); | |
| return; | |
| } | |
| setLocalAdminConfig(prev => ({ | |
| ...prev, | |
| savedReplies: [ | |
| ...(prev.savedReplies || []), | |
| { id: generateId(), topic: newReplyTopic.trim(), answer: newReplyAnswer.trim() } | |
| ] | |
| })); | |
| setNewReplyTopic(''); | |
| setNewReplyAnswer(''); | |
| showNotification("Reply added to knowledge base. Save all admin changes to finalize.", "info"); | |
| }; | |
| const handleDeleteReply = (replyId: string) => { | |
| setLocalAdminConfig(prev => ({ | |
| ...prev, | |
| savedReplies: (prev.savedReplies || []).filter(reply => reply.id !== replyId) | |
| })); | |
| showNotification("Reply removed from knowledge base. Save all admin changes to finalize.", "info"); | |
| }; | |
| const renderAssetImageEditor = ( | |
| editorAssetKey: string, | |
| displayName: string, | |
| itemType: 'nft' | 'character' | 'adminConfigAsset', | |
| idOrConfigKey: string, | |
| currentImageUrl: string | undefined | |
| ) => { | |
| const assetSpecificPrompt = aiPrompts[editorAssetKey] || ''; | |
| const isAssetGenerating = isGenerating[editorAssetKey] || false; | |
| const assetAiPreview = aiGeneratedImagePreviews[editorAssetKey] || null; | |
| const character = itemType === 'character' ? runtimeGameCharacters[idOrConfigKey] : null; | |
| let assetUrlInputValue = ''; | |
| if (itemType === 'adminConfigAsset') { | |
| assetUrlInputValue = adminAssetUrlInputs[idOrConfigKey as AdminConfigImageKey] || ''; | |
| } else if (itemType === 'nft') { | |
| assetUrlInputValue = nftImageInputs[idOrConfigKey] || ''; | |
| } else if (itemType === 'character') { | |
| assetUrlInputValue = characterAttributeInputs[idOrConfigKey]?.imageURL || ''; | |
| } | |
| return ( | |
| <div className="mb-8 p-4 border rounded-lg bg-theme-dark/40 border-theme-border/40"> | |
| <h5 className="text-lg font-medium text-gray-100 mb-3">{displayName}</h5> | |
| <img | |
| src={currentImageUrl || 'https://via.placeholder.com/150?text=No+Image'} | |
| alt={`${displayName} current preview`} | |
| className="w-32 h-32 object-contain rounded-md bg-black/40 border border-theme-border/60 mb-3" | |
| onError={(e) => (e.currentTarget.src = 'https://via.placeholder.com/150?text=Error')} | |
| /> | |
| <div className="mb-3"> | |
| <label htmlFor={`ai-prompt-${editorAssetKey}`} className="block text-sm font-medium text-gray-300 mb-1">AI Prompt:</label> | |
| <textarea id={`ai-prompt-${editorAssetKey}`} rows={3} value={assetSpecificPrompt} onChange={(e) => handleAiPromptChange(editorAssetKey, e.target.value)} placeholder="Describe the image or click Auto-Generate..." className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/> | |
| <div className="flex flex-wrap gap-2 mt-1.5"> | |
| {itemType === 'character' && character && ( | |
| <button | |
| onClick={() => handleAutoGenerateForCharacter(idOrConfigKey)} | |
| disabled={isAssetGenerating} | |
| className="py-2 px-4 bg-teal-600 hover:bg-teal-700 text-white text-sm font-semibold rounded-md transition-colors disabled:bg-teal-400 flex items-center" | |
| title={`Auto-generate prompt & image for ${character.name}`} | |
| > | |
| β¨ Auto-Generate Art | |
| </button> | |
| )} | |
| <button onClick={() => handleGenerateAiImage(editorAssetKey, itemType, idOrConfigKey)} disabled={isAssetGenerating} className="py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white text-sm font-semibold rounded-md transition-colors disabled:bg-pink-400 disabled:text-gray-100 flex items-center"> | |
| {isAssetGenerating && <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div>}Generate AI Image | |
| </button> | |
| </div> | |
| {assetAiPreview && ( | |
| <div className="mt-2"> | |
| <img src={assetAiPreview} alt="AI Preview" className="w-28 h-28 object-contain rounded-md bg-black/40 border border-theme-border/60"/> | |
| {!superAiAgentEnabled && ( | |
| <button onClick={() => handleApplyAiImage(editorAssetKey, itemType, idOrConfigKey)} className="mt-1.5 py-1.5 px-3 bg-pink-600 hover:bg-pink-700 text-white text-sm font-semibold rounded-md transition-colors">Apply AI Image</button> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| <div className="mb-3"> | |
| <label htmlFor={`file-${editorAssetKey}`} className="block text-sm font-medium text-gray-300 mb-1">Upload Image:</label> | |
| <input type="file" id={`file-${editorAssetKey}`} accept="image/*,image/gif" | |
| onChange={(e) => { | |
| const file = e.target.files ? e.target.files[0] : null; | |
| if (itemType === 'nft') handleNftImageFileUpload(idOrConfigKey, file); | |
| else if (itemType === 'character') handleCharacterImageFileUpload(idOrConfigKey, file); | |
| else if (itemType === 'adminConfigAsset') handleAdminConfigAssetFileUpload(idOrConfigKey as AdminConfigImageKey, file); | |
| }} | |
| className="w-full text-base text-gray-300 file:mr-3 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-pink-600 file:text-white hover:file:bg-pink-700"/> | |
| </div> | |
| <div className="mb-3"> | |
| <label htmlFor={`url-${editorAssetKey}`} className="block text-sm font-medium text-gray-300 mb-1">Image Source URL or Local Path:</label> | |
| <input type="text" id={`url-${editorAssetKey}`} value={assetUrlInputValue} | |
| onChange={(e) => { | |
| if (itemType === 'nft') handleNftImageUrlChange(idOrConfigKey, e.target.value); | |
| else if (itemType === 'character') handleCharacterAttributeChange(idOrConfigKey, 'imageURL', e.target.value); | |
| else if (itemType === 'adminConfigAsset') handleAdminConfigAssetUrlChange(idOrConfigKey as AdminConfigImageKey, e.target.value); | |
| }} | |
| className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500" placeholder="Enter image URL or local path"/> | |
| <p className="text-xs text-gray-400 mt-1">Enter URL (https://...), local path (e.g., /assets/images/image.png), or upload a file.</p> | |
| </div> | |
| {(itemType === 'nft' || itemType === 'character' || itemType === 'adminConfigAsset') && ( | |
| <button | |
| onClick={() => { | |
| if (itemType === 'nft') handleApplyCustomNftImage(idOrConfigKey); | |
| else if (itemType === 'character') handleApplyCustomCharacterImage(idOrConfigKey); | |
| else if (itemType === 'adminConfigAsset') handleApplyCustomAdminConfigAssetImage(idOrConfigKey as AdminConfigImageKey); | |
| }} | |
| className="mt-1.5 py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white text-sm font-semibold rounded-md transition-colors" | |
| > | |
| Apply Custom Image (Upload/URL) | |
| </button> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| const renderNftManagement = () => ( | |
| <div className="space-y-6 max-h-[60vh] overflow-y-auto pr-3"> | |
| <h3 className="text-xl font-semibold text-theme-accent mb-4">Manage NFT Images</h3> | |
| {Object.values(runtimeNftDefinitions).map(nft => ( | |
| renderAssetImageEditor(`nft_${nft.id}`, nft.name, 'nft', nft.id, pendingNftImageChanges[nft.id] || nftImageInputs[nft.id] || nft.image) | |
| ))} | |
| </div> | |
| ); | |
| const renderCharacterManagement = () => ( | |
| <div className="space-y-6 max-h-[60vh] overflow-y-auto pr-3"> | |
| <h3 className="text-xl font-semibold text-theme-accent mb-4">Manage Game Characters</h3> | |
| {Object.keys(runtimeGameCharacters).map(charId => { | |
| const character = runtimeGameCharacters[charId]; | |
| const currentInputs = characterAttributeInputs[charId] || { name: '', imageURL: '', defaultWeaponType: WeaponType.StandardBullet, autoFireHealthThreshold: 0, baseHealth: 100, baseSpeed: 1 }; | |
| const charEditorKey = `char_${charId}`; | |
| let currentImageDisplay = character.image; | |
| if (currentInputs.imageBase64) currentImageDisplay = currentInputs.imageBase64; | |
| else if (currentInputs.imageURL) currentImageDisplay = currentInputs.imageURL; | |
| return ( | |
| <div key={character.id} className="p-4 bg-theme-dark/60 rounded-lg border border-theme-border/60"> | |
| <h4 className="text-lg font-medium text-gray-100 mb-3">{currentInputs.name || character.name} <span className="text-sm text-gray-400">({character.type})</span></h4> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-start mb-3"> | |
| <div><label htmlFor={`charName-${charId}`} className="block text-sm font-medium text-gray-300 mb-1">Name:</label><input type="text" id={`charName-${charId}`} value={currentInputs.name} onChange={(e) => handleCharacterAttributeChange(charId, 'name', e.target.value)} className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div> | |
| <div><label htmlFor={`charBaseHealth-${charId}`} className="block text-sm font-medium text-gray-300 mb-1">Base Health:</label><input type="number" id={`charBaseHealth-${charId}`} value={currentInputs.baseHealth} onChange={(e) => handleCharacterAttributeChange(charId, 'baseHealth', parseInt(e.target.value, 10))} className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div> | |
| {character.type === 'enemy' && ( | |
| <div><label htmlFor={`charBaseSpeed-${charId}`} className="block text-sm font-medium text-gray-300 mb-1">Base Speed:</label><input type="number" step="0.1" id={`charBaseSpeed-${charId}`} value={currentInputs.baseSpeed} onChange={(e) => handleCharacterAttributeChange(charId, 'baseSpeed', parseFloat(e.target.value))} className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div> | |
| )} | |
| {character.type === 'player' && ( | |
| <> | |
| <div><label htmlFor={`charWeaponType-${charId}`} className="block text-sm font-medium text-gray-300 mb-1">Weapon:</label><select id={`charWeaponType-${charId}`} value={Object.keys(WeaponType).find(key => WeaponType[key as keyof typeof WeaponType] === currentInputs.defaultWeaponType) || 'StandardBullet'} onChange={(e) => handleCharacterSelectChange(charId, 'defaultWeaponType', e.target.value)} className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500">{Object.keys(WeaponType).map(weaponKey => (<option key={weaponKey} value={weaponKey}>{formatWeaponTypeName(weaponKey)}</option>))}</select></div> | |
| <div><label htmlFor={`charAutoFire-${charId}`} className="block text-sm font-medium text-gray-300 mb-1">Auto-Fire Threshold (%):</label><input type="number" id={`charAutoFire-${charId}`} value={currentInputs.autoFireHealthThreshold || 0} min="0" max="100" onChange={(e) => handleCharacterAttributeChange(charId, 'autoFireHealthThreshold', parseInt(e.target.value, 10))} className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div> | |
| </> | |
| )} | |
| </div> | |
| <button onClick={() => handleApplyCharacterNonImageChanges(charId)} className="mt-2 py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white text-sm font-semibold rounded-md transition-colors">Apply Attribute Changes</button> | |
| {renderAssetImageEditor( | |
| charEditorKey, | |
| `Image for ${currentInputs.name || character.name}`, | |
| 'character', | |
| charId, | |
| currentImageDisplay | |
| )} | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| ); | |
| const renderStakingRates = () => ( | |
| <div className="space-y-6"> | |
| <h3 className="text-xl font-semibold text-theme-accent mb-4">Staking Configuration</h3> | |
| <div><label htmlFor="globalNftStakingRate" className="block text-base font-medium text-gray-200 mb-1.5">Global NFT Staking Reward Rate (Sea Tokens per NFT per hour):</label><input type="number" id="globalNftStakingRate" name="globalNftStakingRate" value={localAdminConfig.globalNftStakingRate} onChange={handleInputChange} step="0.01" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/><p className="text-sm text-gray-400 mt-1.5">Note: Slot staking uses its own base rate. This may apply to other/future staking.</p></div> | |
| <div><label htmlFor="globalTokenStakingRate" className="block text-base font-medium text-gray-200 mb-1.5">Sea Token Staking Reward Rate (per hour, e.g., 0.05 for 5%):</label><input type="number" id="globalTokenStakingRate" name="globalTokenStakingRate" value={localAdminConfig.globalTokenStakingRate} onChange={handleInputChange} step="0.001" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div> | |
| </div> | |
| ); | |
| const renderGameAssets = () => ( | |
| <div className="space-y-8 max-h-[60vh] overflow-y-auto pr-3"> | |
| <p className="text-base text-gray-300">Asset images are now primarily managed in the "π€ AI Img Gen" tab. This tab shows current settings and allows specific URL overrides if needed. Ensure images exist at the specified paths if using local assets (e.g., /assets/images/your-file.png).</p> | |
| {(['gameBackgroundImageUrl', 'defaultEnemyImageUrl', 'seaTokenImageUrl', 'redArrowImageUrl', 'flameParticleImageUrl', 'enemyBurningEffectImageUrl', 'fireballProjectileImageUrl'] as AdminConfigImageKey[]).map(configKey => { | |
| const displayName = configKey | |
| .replace('Url', '') | |
| .replace('Image', '') | |
| .replace(/([A-Z])/g, ' $1') | |
| .replace(/^./, str => str.toUpperCase()) | |
| .trim(); | |
| return ( | |
| <div key={configKey} className="p-4 bg-theme-dark/60 rounded-lg border border-theme-border/60"> | |
| <h4 className="text-lg font-medium text-gray-100 mb-2">{displayName}</h4> | |
| <img | |
| src={pendingAdminAssetBase64[configKey] || adminAssetUrlInputs[configKey] || localAdminConfig[configKey] || 'https://via.placeholder.com/150?text=No+Image'} | |
| alt={`${displayName} Preview`} | |
| className="w-32 h-32 object-contain rounded-md bg-black/40 border border-theme-border/60 mb-2" | |
| onError={(e) => (e.currentTarget.src = 'https://via.placeholder.com/150?text=Error')} | |
| /> | |
| <div> | |
| <label htmlFor={`asset-url-${configKey}`} className="block text-sm font-medium text-gray-300 mb-1">Image Source URL or Local Path:</label> | |
| <input | |
| type="text" | |
| id={`asset-url-${configKey}`} | |
| value={adminAssetUrlInputs[configKey] || ''} | |
| onChange={(e) => handleAdminConfigAssetUrlChange(configKey, e.target.value)} | |
| className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500" | |
| placeholder="Enter image URL or local path" | |
| /> | |
| <p className="text-xs text-gray-400 mt-1">Enter URL (https://...), local path (e.g., /assets/images/image.png), or upload via AI Gen tab.</p> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| <div> | |
| <h3 className="text-xl font-semibold text-theme-accent mb-3">Decorative Fish & Bubbles</h3> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| {(['decorativeFishGoldenImage', 'decorativeFishStarImage', 'decorativeFishBluefinImage', 'bubbleSmallImage', 'bubbleMediumImage', 'bubbleLargeImage'] as AdminConfigImageKey[]).map(key => ( | |
| <div key={key} className="p-4 bg-theme-dark/60 rounded-lg border border-theme-border/60"> | |
| <h4 className="text-lg font-medium text-gray-100 mb-2">{key.replace(/([A-Z])/g, ' $1').replace('Image','').trim()}:</h4> | |
| <img src={pendingAdminAssetBase64[key] || adminAssetUrlInputs[key] || localAdminConfig[key] || `https://via.placeholder.com/100?text=${key.substring(0,10)}`} alt={`${key} preview`} className="w-24 h-24 object-contain rounded-lg bg-black/40 border border-theme-border/60 mb-2" onError={(e) => (e.currentTarget.src = 'https://via.placeholder.com/100?text=Error')}/> | |
| <div> | |
| <label htmlFor={`asset-url-${key}`} className="block text-sm font-medium text-gray-300 mb-1">Image Source URL or Local Path:</label> | |
| <input | |
| type="text" | |
| id={`asset-url-${key}`} | |
| value={adminAssetUrlInputs[key] || ''} | |
| onChange={(e) => handleAdminConfigAssetUrlChange(key, e.target.value)} | |
| className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500" | |
| placeholder="Enter image URL or local path" | |
| /> | |
| <p className="text-xs text-gray-400 mt-1">Enter URL (https://...), local path (e.g., /assets/images/image.png), or upload via AI Gen tab.</p> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| const renderDogeEscapeAssets = () => ( | |
| <div className="space-y-8 max-h-[60vh] overflow-y-auto pr-3"> | |
| <h3 className="text-xl font-semibold text-theme-accent mb-4">Doge Whale Escape Game Assets</h3> | |
| <p className="text-sm text-gray-300 mb-6">Customize the images used in the Doge Whale Escape mini-game. Use the AI Generator, upload files, or provide URLs.</p> | |
| {dogeEscapeAssetConfigKeys.map(({ key, displayName, defaultUrl }) => | |
| renderAssetImageEditor( | |
| `adminConfig_${key}`, | |
| displayName, | |
| 'adminConfigAsset', | |
| key, | |
| (pendingAdminAssetBase64[key] as string | undefined) || (adminAssetUrlInputs[key] as string | undefined) || (localAdminConfig[key] as string | undefined) || defaultUrl | |
| ) | |
| )} | |
| </div> | |
| ); | |
| const renderGameplayMechanics = () => ( | |
| <div className="space-y-6 max-h-[60vh] overflow-y-auto pr-3"> | |
| <h3 className="text-xl font-semibold text-theme-accent mb-4">Core Gameplay Mechanics</h3> | |
| <div className="flex items-center justify-between p-4 bg-theme-dark/60 rounded-lg"><label htmlFor="playerAutoFire" className="text-base font-medium text-gray-200">Player Auto-Fire (for relevant weapons):</label><input type="checkbox" id="playerAutoFire" name="playerAutoFire" checked={localAdminConfig.playerAutoFire} onChange={handleInputChange} className="form-checkbox h-6 w-6 text-pink-600 bg-gray-700 border-gray-600 rounded focus:ring-pink-500"/></div> | |
| <div className="flex items-center justify-between p-4 bg-theme-dark/60 rounded-lg"><label htmlFor="playerAutoTargets" className="text-base font-medium text-gray-200">Player Aim Assist (auto-targets nearest enemy):</label><input type="checkbox" id="playerAutoTargets" name="playerAutoTargets" checked={localAdminConfig.playerAutoTargets || false} onChange={handleInputChange} className="form-checkbox h-6 w-6 text-pink-600 bg-gray-700 border-gray-600 rounded focus:ring-pink-500"/></div> | |
| <div className="flex items-center justify-between p-4 bg-theme-dark/60 rounded-lg"><label htmlFor="enemiesAutoTarget" className="text-base font-medium text-gray-200">Enemies Auto-Target Player:</label><input type="checkbox" id="enemiesAutoTarget" name="enemiesAutoTarget" checked={localAdminConfig.enemiesAutoTarget} onChange={handleInputChange} className="form-checkbox h-6 w-6 text-pink-600 bg-gray-700 border-gray-600 rounded focus:ring-pink-500"/></div> | |
| <div className="p-4 bg-theme-dark/60 rounded-lg"> | |
| <label htmlFor="enemyFireCooldownMs" className="block text-base font-medium text-gray-200 mb-1.5">Enemy Fire Cooldown (ms):</label> | |
| <input type="number" id="enemyFireCooldownMs" name="enemyFireCooldownMs" value={localAdminConfig.enemyFireCooldownMs} onChange={handleInputChange} step="100" min="100" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/> | |
| <p className="text-sm text-gray-400 mt-1.5">Default: 2200ms. Lower is faster.</p> | |
| </div> | |
| <h3 className="text-xl font-semibold text-theme-accent mt-6 mb-4">Red Arrow Defense System</h3> | |
| <div className="flex items-center justify-between p-4 bg-theme-dark/60 rounded-lg"><label htmlFor="redArrowActive" className="text-base font-medium text-gray-200">Enable Red Arrow System (for Guardian Whale):</label><input type="checkbox" id="redArrowActive" name="redArrowActive" checked={localAdminConfig.redArrowActive} onChange={handleInputChange} className="form-checkbox h-6 w-6 text-pink-600 bg-gray-700 border-gray-600 rounded focus:ring-pink-500"/></div> | |
| <div className="p-4 bg-theme-dark/60 rounded-lg"> | |
| <label htmlFor="redArrowCooldownMs" className="block text-base font-medium text-gray-200 mb-1.5">Red Arrow Cooldown (ms per shot):</label> | |
| <input type="number" id="redArrowCooldownMs" name="redArrowCooldownMs" value={localAdminConfig.redArrowCooldownMs} onChange={handleInputChange} step="50" min="50" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/> | |
| <p className="text-sm text-gray-400 mt-1.5">Default: {COOLDOWN_RED_ARROW_DEFENSE}ms (2 shots per second).</p> | |
| </div> | |
| <div className="p-4 bg-theme-dark/60 rounded-lg"> | |
| <label htmlFor="redArrowShotCount" className="block text-base font-medium text-gray-200 mb-1.5">Red Arrows Fired Per Burst:</label> | |
| <input type="number" id="redArrowShotCount" name="redArrowShotCount" value={localAdminConfig.redArrowShotCount || RED_ARROW_DEFAULT_SHOT_COUNT} onChange={handleInputChange} step="1" min="1" max="10" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/> | |
| <p className="text-sm text-gray-400 mt-1.5">Default: {RED_ARROW_DEFAULT_SHOT_COUNT}. Number of arrows fired at once by Guardian Whale.</p> | |
| </div> | |
| <div className="p-4 bg-theme-dark/60 rounded-lg"> | |
| <label htmlFor="redArrowProjectileSpeed" className="block text-base font-medium text-gray-200 mb-1.5">Red Arrow Projectile Speed:</label> | |
| <input type="number" id="redArrowProjectileSpeed" name="redArrowProjectileSpeed" value={localAdminConfig.redArrowProjectileSpeed} onChange={handleInputChange} step="0.5" min="1" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/> | |
| <p className="text-sm text-gray-400 mt-1.5">Default: {RED_ARROW_PROJECTILE_SPEED}.</p> | |
| </div> | |
| <div className="p-4 bg-theme-dark/60 rounded-lg"> | |
| <label htmlFor="redArrowDamage" className="block text-base font-medium text-gray-200 mb-1.5">Red Arrow Damage:</label> | |
| <input type="number" id="redArrowDamage" name="redArrowDamage" value={localAdminConfig.redArrowDamage} onChange={handleInputChange} step="10" min="0" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/> | |
| <p className="text-sm text-gray-400 mt-1.5">Default: {RED_ARROW_PROJECTILE_DAMAGE} (High damage for intercept/kill effect).</p> | |
| </div> | |
| </div> | |
| ); | |
| const renderAiAssetGenerator = () => ( | |
| <div className="space-y-8 max-h-[60vh] overflow-y-auto pr-3"> | |
| <div className="p-5 bg-theme-dark/80 rounded-xl shadow-xl mb-6"> | |
| <h4 className="text-lg font-semibold text-theme-accent mb-4">AI Generation Global Suggestions</h4> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <div><label htmlFor="aiDimensions" className="block text-sm font-medium text-gray-300 mb-1">Dimensions/Aspect:</label><input type="text" id="aiDimensions" value={aiSuggestionDimensions} onChange={e => setAiSuggestionDimensions(e.target.value)} placeholder="e.g., 512x512, wide, portrait" className="w-full p-2.5 text-base rounded-md bg-theme-dark border-theme-border focus:ring-pink-500 focus:border-pink-500"/></div> | |
| <div><label htmlFor="aiDirection" className="block text-sm font-medium text-gray-300 mb-1">Facing Direction:</label><select id="aiDirection" value={aiSuggestionDirection} onChange={e => setAiSuggestionDirection(e.target.value)} className="w-full p-2.5 text-base rounded-md bg-theme-dark border-theme-border focus:ring-pink-500 focus:border-pink-500"><option value="any">Any</option><option value="facing right">Facing Right</option><option value="facing left">Facing Left</option><option value="front view">Front View</option><option value="side view">Side View</option></select></div> | |
| <div><label htmlFor="aiStyle" className="block text-sm font-medium text-gray-300 mb-1">Art Style:</label><select id="aiStyle" value={aiSuggestionStyle} onChange={e => setAiSuggestionStyle(e.target.value)} className="w-full p-2.5 text-base rounded-md bg-theme-dark border-theme-border focus:ring-pink-500 focus:border-pink-500"><option value="any">Any</option><option value="3D render">3D Render</option><option value="photorealistic">Photorealistic</option><option value="cartoon">Cartoon</option><option value="pixel art">Pixel Art</option><option value="epic fantasy">Epic Fantasy</option><option value="sci-fi">Sci-Fi</option><option value="watercolor">Watercolor</option><option value="abstract">Abstract</option></select></div> | |
| <div><label htmlFor="aiBackground" className="block text-sm font-medium text-gray-300 mb-1">Background Type:</label><select id="aiBackground" value={aiSuggestionBackground} onChange={e => setAiSuggestionBackground(e.target.value)} className="w-full p-2.5 text-base rounded-md bg-theme-dark border-theme-border focus:ring-pink-500 focus:border-pink-500"><option value="any">Any</option><option value="transparent background">Transparent</option><option value="simple color background">Simple Color</option><option value="detailed environment">Detailed Environment</option><option value="nebula">Nebula</option><option value="underwater scene">Underwater Scene</option></select></div> | |
| </div> | |
| <div className="flex items-center justify-between mt-4 pt-4 border-t border-theme-border/60"> | |
| <label htmlFor="superAiAgentToggle" className="text-sm font-medium text-gray-200">Super AI Agent (Auto-apply generated images):</label> | |
| <input type="checkbox" id="superAiAgentToggle" checked={superAiAgentEnabled} onChange={(e) => setSuperAiAgentEnabled(e.target.checked)} className="form-checkbox h-5 w-5 text-pink-600"/> | |
| </div> | |
| </div> | |
| <h3 className="text-xl font-semibold text-theme-accent mt-6 mb-4">Generate & Manage Asset Images</h3> | |
| <div className="p-4 bg-black/30 rounded-lg"><h4 className="text-lg font-semibold text-gray-200 mb-3 border-b border-theme-secondary/40 pb-2">NFT Images</h4>{Object.values(runtimeNftDefinitions).map(nft => renderAssetImageEditor(`nft_${nft.id}`, `NFT: ${nft.name}`, 'nft', nft.id, pendingNftImageChanges[nft.id] || nftImageInputs[nft.id] || nft.image ))}</div> | |
| <div className="p-4 bg-black/30 rounded-lg"><h4 className="text-lg font-semibold text-gray-200 mb-3 border-b border-theme-secondary/40 pb-2">Character Images</h4>{Object.values(runtimeGameCharacters).map(char => renderAssetImageEditor(`char_${char.id}`, `Character: ${char.name} (${char.type})`, 'character', char.id, (characterAttributeInputs[char.id]?.imageBase64 || characterAttributeInputs[char.id]?.imageURL || char.image) ))}</div> | |
| <div className="p-4 bg-black/30 rounded-lg"><h4 className="text-lg font-semibold text-gray-200 mb-3 border-b border-theme-secondary/40 pb-2">Core Game Visuals</h4> | |
| {renderAssetImageEditor('adminConfig_gameBackgroundImageUrl', 'Game Background', 'adminConfigAsset', 'gameBackgroundImageUrl', localAdminConfig.gameBackgroundImageUrl)} | |
| {renderAssetImageEditor('adminConfig_defaultEnemyImageUrl', 'Default Enemy Fallback', 'adminConfigAsset', 'defaultEnemyImageUrl', localAdminConfig.defaultEnemyImageUrl)} | |
| {renderAssetImageEditor('adminConfig_seaTokenImageUrl', 'Sea Token', 'adminConfigAsset', 'seaTokenImageUrl', localAdminConfig.seaTokenImageUrl)} | |
| {renderAssetImageEditor('adminConfig_redArrowImageUrl', 'Red Arrow Projectile', 'adminConfigAsset', 'redArrowImageUrl', localAdminConfig.redArrowImageUrl || RED_ARROW_PROJECTILE_IMAGE_URL)} | |
| </div> | |
| <div className="p-4 bg-black/30 rounded-lg"> | |
| <h4 className="text-lg font-semibold text-gray-200 mb-3 border-b border-theme-secondary/40 pb-2">Fire Effect Visuals π₯</h4> | |
| {renderAssetImageEditor('adminConfig_flameParticleImageUrl', 'Flame Particle Effect (for Flame Cone)', 'adminConfigAsset', 'flameParticleImageUrl', localAdminConfig.flameParticleImageUrl)} | |
| {renderAssetImageEditor('adminConfig_enemyBurningEffectImageUrl', 'Enemy Burning Effect (Overlay/Sprite)', 'adminConfigAsset', 'enemyBurningEffectImageUrl', localAdminConfig.enemyBurningEffectImageUrl)} | |
| {renderAssetImageEditor('adminConfig_fireballProjectileImageUrl', 'Fireball Projectile Image', 'adminConfigAsset', 'fireballProjectileImageUrl', localAdminConfig.fireballProjectileImageUrl)} | |
| </div> | |
| <div className="p-4 bg-black/30 rounded-lg"><h4 className="text-lg font-semibold text-gray-200 mb-3 border-b border-theme-secondary/40 pb-2">Decorative Fish Visuals</h4> | |
| {renderAssetImageEditor('adminConfig_decorativeFishGoldenImage', 'Golden Fish', 'adminConfigAsset', 'decorativeFishGoldenImage', localAdminConfig.decorativeFishGoldenImage)} | |
| {renderAssetImageEditor('adminConfig_decorativeFishStarImage', 'Star Fish', 'adminConfigAsset', 'decorativeFishStarImage', localAdminConfig.decorativeFishStarImage)} | |
| {renderAssetImageEditor('adminConfig_decorativeFishBluefinImage', 'Bluefin Fish', 'adminConfigAsset', 'decorativeFishBluefinImage', localAdminConfig.decorativeFishBluefinImage)} | |
| </div> | |
| <div className="p-4 bg-black/30 rounded-lg"><h4 className="text-lg font-semibold text-gray-200 mb-3 border-b border-theme-secondary/40 pb-2">Bubble Visuals</h4> | |
| {renderAssetImageEditor('adminConfig_bubbleSmallImage', 'Small Bubble', 'adminConfigAsset', 'bubbleSmallImage', localAdminConfig.bubbleSmallImage)} | |
| {renderAssetImageEditor('adminConfig_bubbleMediumImage', 'Medium Bubble', 'adminConfigAsset', 'bubbleMediumImage', localAdminConfig.bubbleMediumImage)} | |
| {renderAssetImageEditor('adminConfig_bubbleLargeImage', 'Large Bubble', 'adminConfigAsset', 'bubbleLargeImage', localAdminConfig.bubbleLargeImage)} | |
| </div> | |
| </div> | |
| ); | |
| const renderSoundManagement = () => ( | |
| <div className="space-y-6 max-h-[60vh] overflow-y-auto pr-3"> | |
| <h3 className="text-xl font-semibold text-theme-accent mb-3">Manage Sound Effects</h3> | |
| <p className="text-sm text-gray-300 mb-5">Upload custom sound files (MP3, WAV, OGG recommended). Changes are applied with "Save All Global Admin Config Changes".</p> | |
| {Object.values(SoundEvent).map(eventKey => { | |
| const formattedName = formatSoundEventName(eventKey); | |
| const adminSoundKeyPattern = `sound${eventKey.charAt(0).toUpperCase() + eventKey.slice(1).replace(/_([a-z])/g, (g) => g[1].toUpperCase())}Url`; | |
| const adminSoundKey = adminSoundKeyPattern as SoundUrlKey; | |
| const currentSoundSrc = localAdminConfig[adminSoundKey] as string | undefined; | |
| const displaySrc = currentSoundSrc && currentSoundSrc.startsWith('data:audio') | |
| ? 'Custom Sound Loaded' | |
| : (currentSoundSrc && currentSoundSrc.trim() !== '' ? currentSoundSrc.substring(0, 40) + (currentSoundSrc.length > 40 ? "..." : "") : 'No Sound Set'); | |
| return ( | |
| <div key={eventKey} className="p-4 bg-theme-dark/60 rounded-lg border border-theme-border/60"> | |
| <label htmlFor={`soundFile-${eventKey}`} className="block text-base font-medium text-gray-200 mb-1.5"> | |
| {formattedName}: <span className="text-sm text-gray-400 ml-1.5">({displaySrc})</span> | |
| </label> | |
| <input | |
| type="file" | |
| id={`soundFile-${eventKey}`} | |
| accept="audio/*" | |
| onChange={(e) => handleSoundFileUpload(eventKey, e.target.files ? e.target.files[0] : null)} | |
| className="w-full text-base text-gray-300 file:mr-3 file:py-2 file:px-4 file:rounded-md file:border-0 file:font-semibold file:bg-pink-600 file:text-white hover:file:bg-pink-700" | |
| /> | |
| {currentSoundSrc && currentSoundSrc.startsWith('data:audio') && ( | |
| <audio controls src={currentSoundSrc} className="mt-2.5 w-full h-12">Preview</audio> | |
| )} | |
| </div>); | |
| })} | |
| </div> | |
| ); | |
| const renderEffectsAndVisuals = () => ( | |
| <div className="space-y-8 max-h-[60vh] overflow-y-auto pr-3"> | |
| <h3 className="text-2xl font-semibold text-theme-accent mb-5">Effects & Visuals Configuration</h3> | |
| <div className="p-5 bg-theme-dark/60 rounded-xl border border-theme-border/60 mb-8"> | |
| <h4 className="text-xl font-medium text-gray-100 mb-3">Blood Particle Effects</h4> | |
| <div><label htmlFor="bloodParticleLifespanSeconds" className="block text-base font-medium text-gray-200 mb-1.5">Blood Particle Lifespan (seconds):</label><input type="number" id="bloodParticleLifespanSeconds" name="bloodParticleLifespanSeconds" value={localAdminConfig.bloodParticleLifespanSeconds} onChange={handleInputChange} step="0.1" min="0.1" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/><p className="text-sm text-gray-400 mt-1.5">Default: 4 seconds.</p></div> | |
| </div> | |
| <div className="p-5 bg-theme-dark/60 rounded-xl border border-theme-border/60 mb-8"> | |
| <h4 className="text-xl font-medium text-gray-100 mb-4">Rain Effect Settings</h4> | |
| <div className="flex items-center justify-between mb-4"><label htmlFor="rainEffectEnabled" className="text-base font-medium text-gray-200">Enable Rain Effect:</label><input type="checkbox" id="rainEffectEnabled" name="rainEffectEnabled" checked={localAdminConfig.rainEffectEnabled} onChange={handleInputChange} className="form-checkbox h-6 w-6 text-pink-600 bg-gray-700 border-gray-600 rounded focus:ring-pink-500"/></div> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4"> | |
| <div><label htmlFor="rainDropSpeedMin" className="block text-base font-medium text-gray-200 mb-1.5">Raindrop Min Speed:</label><input type="number" id="rainDropSpeedMin" name="rainDropSpeedMin" value={localAdminConfig.rainDropSpeedMin} onChange={handleInputChange} step="0.1" min="0.1" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div> | |
| <div><label htmlFor="rainDropSpeedMax" className="block text-base font-medium text-gray-200 mb-1.5">Raindrop Max Speed:</label><input type="number" id="rainDropSpeedMax" name="rainDropSpeedMax" value={localAdminConfig.rainDropSpeedMax} onChange={handleInputChange} step="0.1" min="0.1" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div> | |
| <div><label htmlFor="rainDurationSeconds" className="block text-base font-medium text-gray-200 mb-1.5">Rain Duration (seconds):</label><input type="number" id="rainDurationSeconds" name="rainDurationSeconds" value={localAdminConfig.rainDurationSeconds} onChange={handleInputChange} step="1" min="1" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div> | |
| <div><label htmlFor="rainIntervalSeconds" className="block text-base font-medium text-gray-200 mb-1.5">Rain Interval (seconds):</label><input type="number" id="rainIntervalSeconds" name="rainIntervalSeconds" value={localAdminConfig.rainIntervalSeconds} onChange={handleInputChange} step="1" min="1" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div> | |
| <div className="md:col-span-2"><label htmlFor="rainIntensity" className="block text-base font-medium text-gray-200 mb-1.5">Rain Intensity (number of drops):</label><input type="number" id="rainIntensity" name="rainIntensity" value={localAdminConfig.rainIntensity} onChange={handleInputChange} step="10" min="10" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div> | |
| </div> | |
| <p className="text-sm text-gray-400 mt-4">Rain cycles on and off based on duration and interval.</p> | |
| </div> | |
| </div> | |
| ); | |
| const renderAdminAiChat = () => ( | |
| <div className="flex flex-col h-[70vh] max-h-[75vh]"> | |
| <div className="mb-4 flex items-center gap-4 p-3 bg-theme-dark/60 rounded-lg border border-theme-border/50"> | |
| <label htmlFor="aiLanguageSelect" className="text-sm font-medium text-gray-200">Response Language:</label> | |
| <select | |
| id="aiLanguageSelect" | |
| value={selectedAiLanguage} | |
| onChange={(e) => setSelectedAiLanguage(e.target.value)} | |
| className="p-2.5 text-sm rounded-md bg-theme-dark border-theme-border text-white focus:ring-pink-500 focus:border-pink-500 flex-grow" | |
| aria-label="Select AI response language" | |
| > | |
| {languageOptions.map(lang => <option key={lang.code} value={lang.code}>{lang.name}</option>)} | |
| </select> | |
| </div> | |
| <div | |
| ref={aiChatLogRef} | |
| className="flex-grow overflow-y-auto p-4 rounded-lg border border-theme-border/30 mb-4 text-sm space-y-3 bg-black/20 shadow-inner" | |
| aria-live="polite" | |
| style={{minHeight: '200px'}} | |
| > | |
| {aiChatMessages.map((msg) => ( | |
| <div key={msg.id} className={`chat-message ${msg.sender === 'user' ? 'text-right' : 'text-left'}`}> | |
| <span className={`relative inline-block p-3 rounded-xl max-w-[85%] shadow-md ${ | |
| msg.sender === 'user' ? 'bg-pink-600 text-white' : | |
| msg.sender === 'ai' ? 'bg-blue-600 text-white' : | |
| 'bg-red-600 text-white' // Error | |
| }`}> | |
| <span className="block text-xs font-semibold text-gray-300/90 mb-1"> | |
| {msg.sender === 'user' ? `You (${msg.lang || 'Unknown'})` : msg.sender === 'ai' ? 'Admin AI' : 'Error'} | |
| </span> | |
| {msg.text.split('\n').map((line, idx) => <p key={idx} className="m-0 leading-relaxed">{line}</p>)} | |
| </span> | |
| </div> | |
| ))} | |
| {aiChatMessages.length === 0 && <p className="text-gray-400 text-center italic p-4">Admin AI is ready. Ask about game configuration, best practices, or technical aspects.</p>} | |
| {isAiResponding && ( | |
| <div className="text-left"> | |
| <span className="relative inline-block p-3 rounded-xl max-w-[85%] shadow-md bg-blue-500 text-white"> | |
| <span className="block text-xs font-semibold text-gray-200/90 mb-1">Admin AI typing...</span> | |
| <div className="flex items-center space-x-1.5"> | |
| <div className="w-2.5 h-2.5 bg-pink-300 rounded-full animate-pulse" style={{animationDelay: '0s'}}></div> | |
| <div className="w-2.5 h-2.5 bg-pink-300 rounded-full animate-pulse" style={{animationDelay: '0.2s'}}></div> | |
| <div className="w-2.5 h-2.5 bg-pink-300 rounded-full animate-pulse" style={{animationDelay: '0.4s'}}></div> | |
| </div> | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex gap-3 items-center mt-auto"> | |
| <input | |
| type="text" | |
| value={currentAiQuery} | |
| onChange={(e) => setCurrentAiQuery(e.target.value)} | |
| onKeyPress={(e) => e.key === 'Enter' && handleSendAiQuery()} | |
| placeholder="Ask the Admin AI..." | |
| className="flex-grow p-3 text-base rounded-lg bg-theme-dark/70 border-2 border-theme-border/60 text-white focus:ring-2 focus:ring-pink-500 focus:border-transparent transition-all placeholder-gray-400" | |
| aria-label="Admin AI chat message input" | |
| disabled={isAiResponding} | |
| /> | |
| <button | |
| onClick={handleSendAiQuery} | |
| className="py-3 px-6 bg-pink-600 hover:bg-pink-700 text-white font-semibold rounded-lg shadow-md transition-all duration-200 transform hover:scale-105 disabled:opacity-60 disabled:hover:scale-100" | |
| disabled={isAiResponding || currentAiQuery.trim() === ''} | |
| > | |
| {isAiResponding ? 'Sending...' : 'Send'} | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| const renderKnowledgeBaseManagement = () => ( | |
| <div className="space-y-6 max-h-[60vh] overflow-y-auto pr-3"> | |
| <h3 className="text-xl font-semibold text-theme-accent mb-4">Manage AI Knowledge Base</h3> | |
| <p className="text-sm text-gray-300 mb-4">Provide predefined topics and answers to enhance the AI's responses in both Player Profile Chat and Admin AI Chat. This helps the AI provide consistent and accurate information.</p> | |
| <div className="p-4 bg-theme-dark/60 rounded-lg border border-theme-border/60"> | |
| <h4 className="text-lg font-medium text-gray-100 mb-3">Add New Reply</h4> | |
| <div className="mb-3"> | |
| <label htmlFor="newReplyTopic" className="block text-sm font-medium text-gray-300 mb-1">Topic/Question:</label> | |
| <input type="text" id="newReplyTopic" value={newReplyTopic} onChange={(e) => setNewReplyTopic(e.target.value)} placeholder="e.g., How to get Sea Tokens?" className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/> | |
| </div> | |
| <div className="mb-3"> | |
| <label htmlFor="newReplyAnswer" className="block text-sm font-medium text-gray-300 mb-1">Answer/Information:</label> | |
| <textarea id="newReplyAnswer" value={newReplyAnswer} onChange={(e) => setNewReplyAnswer(e.target.value)} placeholder="e.g., You can get Sea Tokens by defeating enemies..." rows={3} className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/> | |
| </div> | |
| <button onClick={handleAddReply} className="py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white text-sm font-semibold rounded-md transition-colors">Add Reply</button> | |
| </div> | |
| <div className="mt-6"> | |
| <h4 className="text-lg font-medium text-gray-100 mb-3">Existing Replies:</h4> | |
| {(localAdminConfig.savedReplies && localAdminConfig.savedReplies.length > 0) ? ( | |
| <ul className="space-y-3"> | |
| {localAdminConfig.savedReplies.map(reply => ( | |
| <li key={reply.id} className="p-3 bg-theme-dark/40 rounded-md border border-theme-border/40"> | |
| <p className="font-semibold text-gray-200 text-sm">Topic: <span className="font-normal text-gray-300">{reply.topic}</span></p> | |
| <p className="font-semibold text-gray-200 text-sm mt-1">Answer: <span className="font-normal text-gray-300">{reply.answer}</span></p> | |
| <button onClick={() => handleDeleteReply(reply.id)} className="mt-2 py-1 px-3 bg-red-600 hover:bg-red-700 text-white text-xs font-semibold rounded-md transition-colors">Delete</button> | |
| </li> | |
| ))} | |
| </ul> | |
| ) : ( | |
| <p className="text-gray-400">No saved replies yet.</p> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| const tabs: { id: AdminTab, label: string, content: () => React.ReactNode, icon?: string }[] = [ | |
| { id: 'aiChat', label: 'Admin AI Chat', content: renderAdminAiChat, icon: 'π¬' }, | |
| { id: 'knowledgeBase', label: 'Knowledge Base', content: renderKnowledgeBaseManagement, icon: 'π' }, | |
| { id: 'aiGenerator', label: 'AI Img Gen', content: renderAiAssetGenerator, icon: 'π€' }, | |
| { id: 'dogeEscapeAssets', label: 'πΎ Doge Escape Assets', content: renderDogeEscapeAssets, icon: 'πΎ' }, | |
| { id: 'nfts', label: 'NFTs (Legacy)', content: renderNftManagement, icon: 'πΌοΈ' }, | |
| { id: 'characters', label: 'Characters (Legacy)', content: renderCharacterManagement, icon: 'πΎ' }, | |
| { id: 'assets', label: 'Assets (Legacy)', content: renderGameAssets, icon: 'π¨'}, | |
| { id: 'gameplay', label: 'Gameplay', content: renderGameplayMechanics, icon: 'βοΈ' }, | |
| { id: 'sounds', label: 'Sounds', content: renderSoundManagement, icon: 'π' }, | |
| { id: 'effectsVisuals', label: 'Visual Effects', content: renderEffectsAndVisuals, icon: 'β¨' }, | |
| { id: 'staking', label: 'Staking Rates', content: renderStakingRates, icon: 'π°' }, | |
| ]; | |
| return ( | |
| <Modal | |
| isOpen={isOpen} | |
| onClose={onClose} | |
| title="π οΈ Doge Whale - Admin Super Panel" | |
| size={isMaximized ? "xl" : "xl"} // Always use XL unless maximized which handles its own sizing | |
| isMaximizable={isMaximizable} | |
| isMaximized={isMaximized} | |
| onToggleMaximize={onToggleMaximize} | |
| > | |
| <div className="flex flex-col md:flex-row gap-4 md:gap-6"> | |
| <div className={`md:w-1/4 space-y-2 ${isMaximized ? 'md:w-1/5' : ''}`}> | |
| {tabs.map(tab => ( | |
| <button | |
| key={tab.id} | |
| onClick={() => setActiveTab(tab.id)} | |
| className={`w-full text-left py-2.5 px-4 text-sm font-medium rounded-md transition-colors duration-200 flex items-center gap-2 | |
| ${activeTab === tab.id ? 'bg-pink-700 text-white shadow-lg' : 'bg-pink-500 hover:bg-pink-600 text-white'}`} | |
| aria-pressed={activeTab === tab.id} | |
| > | |
| {tab.icon && <span className="text-lg">{tab.icon}</span>} | |
| {tab.label} | |
| </button> | |
| ))} | |
| </div> | |
| <div className={`md:w-3/4 ${isMaximized ? 'md:w-4/5' : ''} bg-theme-primary/20 p-4 rounded-lg shadow-inner`}> | |
| {tabs.find(tab => tab.id === activeTab)?.content()} | |
| </div> | |
| </div> | |
| <div className="mt-6 pt-4 border-t border-theme-border flex flex-col sm:flex-row justify-between items-center gap-3"> | |
| <button | |
| onClick={handleSaveAllAdminChanges} | |
| className="w-full sm:w-auto order-1 sm:order-1 py-3 px-6 bg-pink-600 hover:bg-pink-700 text-white font-bold rounded-lg transition-colors duration-200 shadow-md transform hover:scale-102" | |
| > | |
| Save All Global Admin Config Changes | |
| </button> | |
| <button | |
| onClick={onLogout} | |
| className="w-full sm:w-auto order-2 sm:order-2 py-3 px-6 bg-red-600 hover:bg-red-700 text-white font-bold rounded-lg transition-colors duration-200 shadow-md transform hover:scale-102" | |
| > | |
| Logout Admin | |
| </button> | |
| </div> | |
| </Modal> | |
| ); | |
| }; | |