|
| 1 | +import React, { useState } from 'react'; |
| 2 | +import { X, Plus, Trash2, Check } from 'lucide-react'; |
| 3 | +import { |
| 4 | + type CharacterConfig, |
| 5 | + type CharacterCollection, |
| 6 | + CHARACTER_EMOTION_LIST, |
| 7 | + generateCharacterId, |
| 8 | + getCharacterList, |
| 9 | +} from '@/lib/characterManager'; |
| 10 | +import styles from './panel.module.scss'; |
| 11 | + |
| 12 | +interface CharacterPanelProps { |
| 13 | + collection: CharacterCollection; |
| 14 | + onSave: (collection: CharacterCollection) => void; |
| 15 | + onClose: () => void; |
| 16 | +} |
| 17 | + |
| 18 | +const CharacterPanel: React.FC<CharacterPanelProps> = ({ collection, onSave, onClose }) => { |
| 19 | + const [col, setCol] = useState<CharacterCollection>(() => ({ ...collection })); |
| 20 | + const [editingId, setEditingId] = useState<string | null>(null); |
| 21 | + |
| 22 | + const characters = getCharacterList(col); |
| 23 | + const activeId = col.activeId; |
| 24 | + const editing = editingId ? col.items[editingId] : null; |
| 25 | + |
| 26 | + const handleSelect = (id: string) => { |
| 27 | + setCol({ ...col, activeId: id }); |
| 28 | + }; |
| 29 | + |
| 30 | + const handleDelete = (id: string) => { |
| 31 | + if (characters.length <= 1) return; |
| 32 | + const items = { ...col.items }; |
| 33 | + delete items[id]; |
| 34 | + const newActiveId = col.activeId === id ? Object.keys(items)[0] : col.activeId; |
| 35 | + setCol({ activeId: newActiveId, items }); |
| 36 | + if (editingId === id) setEditingId(null); |
| 37 | + }; |
| 38 | + |
| 39 | + const handleAdd = () => { |
| 40 | + const id = generateCharacterId(); |
| 41 | + const newChar: CharacterConfig = { |
| 42 | + id, |
| 43 | + character_name: 'New Character', |
| 44 | + character_gender_desc: '', |
| 45 | + character_desc: '', |
| 46 | + character_emotion_list: [...CHARACTER_EMOTION_LIST], |
| 47 | + character_meta_info: { base_image_url: '' }, |
| 48 | + }; |
| 49 | + setCol({ ...col, items: { ...col.items, [id]: newChar } }); |
| 50 | + setEditingId(id); |
| 51 | + }; |
| 52 | + |
| 53 | + const handleSave = () => { |
| 54 | + onSave(col); |
| 55 | + }; |
| 56 | + |
| 57 | + if (editing) { |
| 58 | + return ( |
| 59 | + <CharacterEditor |
| 60 | + character={editing} |
| 61 | + onSave={(updated) => { |
| 62 | + setCol({ ...col, items: { ...col.items, [updated.id]: updated } }); |
| 63 | + setEditingId(null); |
| 64 | + }} |
| 65 | + onClose={() => setEditingId(null)} |
| 66 | + /> |
| 67 | + ); |
| 68 | + } |
| 69 | + |
| 70 | + return ( |
| 71 | + <div className={styles.overlay} onClick={onClose}> |
| 72 | + <div className={styles.panel} onClick={(e) => e.stopPropagation()}> |
| 73 | + <div className={styles.panelHeader}> |
| 74 | + <span className={styles.panelTitle}>Characters</span> |
| 75 | + <button className={styles.closeBtn} onClick={onClose}> |
| 76 | + <X size={18} /> |
| 77 | + </button> |
| 78 | + </div> |
| 79 | + |
| 80 | + <div className={styles.panelBody}> |
| 81 | + <div className={styles.listView}> |
| 82 | + {characters.map((char) => ( |
| 83 | + <div |
| 84 | + key={char.id} |
| 85 | + className={`${styles.listItem} ${char.id === activeId ? styles.listItemActive : ''}`} |
| 86 | + onClick={() => handleSelect(char.id)} |
| 87 | + > |
| 88 | + <div className={styles.listItemAvatar}> |
| 89 | + {char.character_meta_info?.base_image_url ? ( |
| 90 | + <img src={char.character_meta_info.base_image_url} alt={char.character_name} /> |
| 91 | + ) : ( |
| 92 | + <span>{char.character_name.charAt(0)}</span> |
| 93 | + )} |
| 94 | + </div> |
| 95 | + <div className={styles.listItemInfo}> |
| 96 | + <div className={styles.listItemName}>{char.character_name}</div> |
| 97 | + <div className={styles.listItemDesc}> |
| 98 | + {char.character_gender_desc || 'No gender set'} |
| 99 | + </div> |
| 100 | + </div> |
| 101 | + <div className={styles.listItemActions}> |
| 102 | + {char.id === activeId && ( |
| 103 | + <span className={styles.activeBadge}> |
| 104 | + <Check size={12} /> |
| 105 | + </span> |
| 106 | + )} |
| 107 | + <button |
| 108 | + className={styles.listItemBtn} |
| 109 | + onClick={(e) => { |
| 110 | + e.stopPropagation(); |
| 111 | + setEditingId(char.id); |
| 112 | + }} |
| 113 | + title="Edit" |
| 114 | + > |
| 115 | + Edit |
| 116 | + </button> |
| 117 | + {characters.length > 1 && ( |
| 118 | + <button |
| 119 | + className={styles.listItemBtn} |
| 120 | + onClick={(e) => { |
| 121 | + e.stopPropagation(); |
| 122 | + handleDelete(char.id); |
| 123 | + }} |
| 124 | + title="Delete" |
| 125 | + > |
| 126 | + <Trash2 size={14} /> |
| 127 | + </button> |
| 128 | + )} |
| 129 | + </div> |
| 130 | + </div> |
| 131 | + ))} |
| 132 | + </div> |
| 133 | + </div> |
| 134 | + |
| 135 | + <div className={styles.panelFooter}> |
| 136 | + <button className={styles.addBtn} onClick={handleAdd}> |
| 137 | + <Plus size={14} /> New Character |
| 138 | + </button> |
| 139 | + <div style={{ flex: 1 }} /> |
| 140 | + <button className={styles.cancelBtn} onClick={onClose}> |
| 141 | + Cancel |
| 142 | + </button> |
| 143 | + <button className={styles.saveBtn} onClick={handleSave}> |
| 144 | + Save |
| 145 | + </button> |
| 146 | + </div> |
| 147 | + </div> |
| 148 | + </div> |
| 149 | + ); |
| 150 | +}; |
| 151 | + |
| 152 | +// --------------------------------------------------------------------------- |
| 153 | +// Character Editor (single character editing form) |
| 154 | +// --------------------------------------------------------------------------- |
| 155 | + |
| 156 | +const CharacterEditor: React.FC<{ |
| 157 | + character: CharacterConfig; |
| 158 | + onSave: (config: CharacterConfig) => void; |
| 159 | + onClose: () => void; |
| 160 | +}> = ({ character, onSave, onClose }) => { |
| 161 | + const [name, setName] = useState(character.character_name); |
| 162 | + const [gender, setGender] = useState(character.character_gender_desc); |
| 163 | + const [desc, setDesc] = useState(character.character_desc); |
| 164 | + const [imageUrl, setImageUrl] = useState(character.character_meta_info?.base_image_url || ''); |
| 165 | + const [emotions, setEmotions] = useState<string[]>([...character.character_emotion_list]); |
| 166 | + const [emotionImages, setEmotionImages] = useState<Record<string, string>>(() => { |
| 167 | + const images: Record<string, string> = { ...character.character_meta_info?.emotion_images }; |
| 168 | + // Populate from emotion_videos (use first video URL) if emotion_images is missing |
| 169 | + const videos = character.character_meta_info?.emotion_videos; |
| 170 | + if (videos) { |
| 171 | + for (const [emotion, urls] of Object.entries(videos)) { |
| 172 | + if (!images[emotion] && urls?.length) { |
| 173 | + images[emotion] = urls[0]; |
| 174 | + } |
| 175 | + } |
| 176 | + } |
| 177 | + return images; |
| 178 | + }); |
| 179 | + const [emotionVideos, setEmotionVideos] = useState<Record<string, string[]>>(() => ({ |
| 180 | + ...character.character_meta_info?.emotion_videos, |
| 181 | + })); |
| 182 | + const [newEmotion, setNewEmotion] = useState(''); |
| 183 | + |
| 184 | + const handleAddEmotion = () => { |
| 185 | + const e = newEmotion.trim().toLowerCase(); |
| 186 | + if (e && !emotions.includes(e)) { |
| 187 | + setEmotions([...emotions, e]); |
| 188 | + setNewEmotion(''); |
| 189 | + } |
| 190 | + }; |
| 191 | + |
| 192 | + const handleRemoveEmotion = (emotion: string) => { |
| 193 | + setEmotions(emotions.filter((e) => e !== emotion)); |
| 194 | + const updatedImages = { ...emotionImages }; |
| 195 | + delete updatedImages[emotion]; |
| 196 | + setEmotionImages(updatedImages); |
| 197 | + const updatedVideos = { ...emotionVideos }; |
| 198 | + delete updatedVideos[emotion]; |
| 199 | + setEmotionVideos(updatedVideos); |
| 200 | + }; |
| 201 | + |
| 202 | + const handleResetEmotions = () => { |
| 203 | + setEmotions([...CHARACTER_EMOTION_LIST]); |
| 204 | + }; |
| 205 | + |
| 206 | + const updateEmotionImage = (emotion: string, url: string) => { |
| 207 | + setEmotionImages({ ...emotionImages, [emotion]: url }); |
| 208 | + }; |
| 209 | + |
| 210 | + const handleSave = () => { |
| 211 | + const cleanImages: Record<string, string> = {}; |
| 212 | + for (const [k, v] of Object.entries(emotionImages)) { |
| 213 | + if (v?.trim()) cleanImages[k] = v.trim(); |
| 214 | + } |
| 215 | + |
| 216 | + const cleanVideos: Record<string, string[]> = {}; |
| 217 | + for (const [k, v] of Object.entries(emotionVideos)) { |
| 218 | + if (v?.length) cleanVideos[k] = v; |
| 219 | + } |
| 220 | + |
| 221 | + onSave({ |
| 222 | + id: character.id, |
| 223 | + character_name: name.trim() || 'Unnamed', |
| 224 | + character_gender_desc: gender.trim(), |
| 225 | + character_desc: desc.trim(), |
| 226 | + character_emotion_list: emotions, |
| 227 | + character_meta_info: { |
| 228 | + ...character.character_meta_info, |
| 229 | + base_image_url: imageUrl.trim() || undefined, |
| 230 | + emotion_images: Object.keys(cleanImages).length > 0 ? cleanImages : undefined, |
| 231 | + emotion_videos: Object.keys(cleanVideos).length > 0 ? cleanVideos : undefined, |
| 232 | + }, |
| 233 | + }); |
| 234 | + }; |
| 235 | + |
| 236 | + return ( |
| 237 | + <div className={styles.overlay} onClick={onClose}> |
| 238 | + <div className={styles.panel} onClick={(e) => e.stopPropagation()}> |
| 239 | + <div className={styles.panelHeader}> |
| 240 | + <span className={styles.panelTitle}>Edit Character</span> |
| 241 | + <button className={styles.closeBtn} onClick={onClose}> |
| 242 | + <X size={18} /> |
| 243 | + </button> |
| 244 | + </div> |
| 245 | + |
| 246 | + <div className={styles.panelBody}> |
| 247 | + {imageUrl && ( |
| 248 | + <div className={styles.avatarPreview}> |
| 249 | + <img src={imageUrl} alt={name} className={styles.avatarImg} /> |
| 250 | + </div> |
| 251 | + )} |
| 252 | + |
| 253 | + <div className={styles.field}> |
| 254 | + <label className={styles.label}>Name</label> |
| 255 | + <input |
| 256 | + className={styles.input} |
| 257 | + value={name} |
| 258 | + onChange={(e) => setName(e.target.value)} |
| 259 | + placeholder="Character name" |
| 260 | + /> |
| 261 | + </div> |
| 262 | + |
| 263 | + <div className={styles.field}> |
| 264 | + <label className={styles.label}>Gender</label> |
| 265 | + <input |
| 266 | + className={styles.input} |
| 267 | + value={gender} |
| 268 | + onChange={(e) => setGender(e.target.value)} |
| 269 | + placeholder="female / male / non-binary / ..." |
| 270 | + /> |
| 271 | + </div> |
| 272 | + |
| 273 | + <div className={styles.field}> |
| 274 | + <label className={styles.label}>Persona Description</label> |
| 275 | + <textarea |
| 276 | + className={styles.textarea} |
| 277 | + value={desc} |
| 278 | + onChange={(e) => setDesc(e.target.value)} |
| 279 | + rows={6} |
| 280 | + placeholder="Describe the character's personality, background, speaking style..." |
| 281 | + /> |
| 282 | + </div> |
| 283 | + |
| 284 | + <div className={styles.field}> |
| 285 | + <label className={styles.label}>Default Avatar (base image)</label> |
| 286 | + <input |
| 287 | + className={styles.input} |
| 288 | + value={imageUrl} |
| 289 | + onChange={(e) => setImageUrl(e.target.value)} |
| 290 | + placeholder="https://..." |
| 291 | + /> |
| 292 | + </div> |
| 293 | + |
| 294 | + <div className={styles.field}> |
| 295 | + <label className={styles.label}> |
| 296 | + Emotions & Expressions |
| 297 | + <button className={styles.resetLink} onClick={handleResetEmotions}> |
| 298 | + Reset to defaults |
| 299 | + </button> |
| 300 | + </label> |
| 301 | + <div className={styles.emotionImageList}> |
| 302 | + {emotions.map((e) => ( |
| 303 | + <div key={e} className={styles.emotionImageRow}> |
| 304 | + <div className={styles.emotionImageHeader}> |
| 305 | + <span className={styles.emotionTag}> |
| 306 | + {e} |
| 307 | + <button |
| 308 | + className={styles.emotionRemove} |
| 309 | + onClick={() => handleRemoveEmotion(e)} |
| 310 | + > |
| 311 | + <Trash2 size={10} /> |
| 312 | + </button> |
| 313 | + </span> |
| 314 | + {emotionImages[e] && |
| 315 | + (/\.(mp4|webm|mov|ogg)(\?|$)/i.test(emotionImages[e]) ? ( |
| 316 | + <video |
| 317 | + src={emotionImages[e]} |
| 318 | + className={styles.emotionThumb} |
| 319 | + autoPlay |
| 320 | + loop |
| 321 | + muted |
| 322 | + playsInline |
| 323 | + /> |
| 324 | + ) : ( |
| 325 | + <img src={emotionImages[e]} alt={e} className={styles.emotionThumb} /> |
| 326 | + ))} |
| 327 | + </div> |
| 328 | + <input |
| 329 | + className={styles.input} |
| 330 | + value={emotionImages[e] || ''} |
| 331 | + onChange={(ev) => updateEmotionImage(e, ev.target.value)} |
| 332 | + placeholder={`Image/Video URL for "${e}" (optional)`} |
| 333 | + /> |
| 334 | + </div> |
| 335 | + ))} |
| 336 | + </div> |
| 337 | + <div className={styles.emotionAdd}> |
| 338 | + <input |
| 339 | + className={styles.input} |
| 340 | + value={newEmotion} |
| 341 | + onChange={(e) => setNewEmotion(e.target.value)} |
| 342 | + onKeyDown={(e) => e.key === 'Enter' && handleAddEmotion()} |
| 343 | + placeholder="Add emotion..." |
| 344 | + /> |
| 345 | + <button className={styles.addBtn} onClick={handleAddEmotion}> |
| 346 | + <Plus size={14} /> |
| 347 | + </button> |
| 348 | + </div> |
| 349 | + </div> |
| 350 | + </div> |
| 351 | + |
| 352 | + <div className={styles.panelFooter}> |
| 353 | + <button className={styles.cancelBtn} onClick={onClose}> |
| 354 | + Back |
| 355 | + </button> |
| 356 | + <button className={styles.saveBtn} onClick={handleSave}> |
| 357 | + Done |
| 358 | + </button> |
| 359 | + </div> |
| 360 | + </div> |
| 361 | + </div> |
| 362 | + ); |
| 363 | +}; |
| 364 | + |
| 365 | +export default CharacterPanel; |
0 commit comments