1
0
Fork 0
tsgames/src/games/storywriter/components/character-editor.tsx

327 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useAppState, type Character } from "../contexts/state";
import { useState } from "preact/hooks";
import styles from '../assets/character-editor.module.css';
import LLM from "../utils/llm";
import { ContentEditable } from "@common/components/ContentEditable";
export const CharacterEditor = () => {
const { currentStory, dispatch, connection, model } = useAppState();
const [newNickname, setNewNickname] = useState<Record<string, string>>({});
const [newRelation, setNewRelation] = useState<Record<string, { name: string; relation: string }>>({});
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
const [generatingShortDesc, setGeneratingShortDesc] = useState<string | null>(null);
if (!currentStory) {
return null;
}
const handleAddCharacter = () => {
dispatch({
type: 'ADD_CHARACTER',
storyId: currentStory.id,
character: {
id: crypto.randomUUID(),
name: 'New Character',
nicknames: [],
shortDescription: '',
description: '',
relations: [],
},
});
};
const handleEditCharacter = (characterId: string, field: keyof Character, value: any) => {
dispatch({
type: 'EDIT_CHARACTER',
storyId: currentStory.id,
characterId,
updates: { [field]: value },
});
};
const handleDeleteCharacter = (characterId: string) => {
dispatch({
type: 'DELETE_CHARACTER',
storyId: currentStory.id,
characterId,
});
};
const handleAddRelation = (characterId: string) => {
const rel = newRelation[characterId] || { name: '', relation: '' };
if (!rel.name.trim() || !rel.relation.trim()) return;
dispatch({
type: 'ADD_CHARACTER_RELATION',
storyId: currentStory.id,
characterId,
relation: { name: rel.name.trim(), relation: rel.relation.trim() },
});
setNewRelation({ ...newRelation, [characterId]: { name: '', relation: '' } });
};
const handleEditRelation = (characterId: string, targetName: string, field: 'name' | 'relation', value: string) => {
dispatch({
type: 'EDIT_CHARACTER_RELATION',
storyId: currentStory.id,
characterId,
targetName,
updates: { [field]: value },
});
};
const handleDeleteRelation = (characterId: string, targetName: string) => {
dispatch({
type: 'DELETE_CHARACTER_RELATION',
storyId: currentStory.id,
characterId,
targetName,
});
};
const handleNicknameAdd = (characterId: string) => {
const nickname = (newNickname[characterId] || '').trim();
if (!nickname) return;
const character = currentStory.characters.find(c => c.id === characterId);
if (character) {
handleEditCharacter(characterId, 'nicknames', [...character.nicknames, nickname]);
setNewNickname({ ...newNickname, [characterId]: '' });
}
};
const handleNicknameDelete = (characterId: string, nickname: string) => {
const character = currentStory.characters.find(c => c.id === characterId);
if (character) {
handleEditCharacter(characterId, 'nicknames', character.nicknames.filter(n => n !== nickname));
}
};
const handleNewRelationChange = (characterId: string, field: 'name' | 'relation', value: string) => {
const current = newRelation[characterId] || { name: '', relation: '' };
setNewRelation({ ...newRelation, [characterId]: { ...current, [field]: value } });
};
const handleGenerateShortDescription = async (characterId: string) => {
if (!connection || !model) return;
const character = currentStory.characters.find(c => c.id === characterId);
if (!character || !character.description.trim()) return;
setGeneratingShortDesc(characterId);
try {
const shortDesc = await LLM.summarize(connection, model.id, character.description, 'sentence');
handleEditCharacter(characterId, 'shortDescription', shortDesc.trim());
} catch (error) {
console.error('Failed to generate short description:', error);
} finally {
setGeneratingShortDesc(null);
}
};
return (
<div class={styles.characterEditor}>
<div class={styles.header}>
<h2>Characters</h2>
<button class={styles.addButton} onClick={handleAddCharacter}>
+ Add Character
</button>
</div>
<div class={styles.list}>
{currentStory.characters.length === 0 && (
<p class={styles.empty}>No characters yet. Add your first character!</p>
)}
{currentStory.characters.map((character) => (
<div key={character.id} class={styles.characterCard}>
<div class={styles.cardHeader}>
<input
type="text"
class={styles.nameInput}
value={character.name}
onInput={(e) => handleEditCharacter(character.id, 'name', e.currentTarget.value)}
onFocus={(e) => e.currentTarget.select()}
placeholder="Character name"
/>
{showDeleteConfirm === character.id ? (
<div class={styles.deleteConfirm}>
<span>Delete?</span>
<button
class={styles.confirmButton}
onClick={() => {
handleDeleteCharacter(character.id);
setShowDeleteConfirm(null);
}}
>
Yes
</button>
<button
class={styles.cancelButton}
onClick={() => setShowDeleteConfirm(null)}
>
No
</button>
</div>
) : (
<button
class={styles.deleteButton}
onClick={() => setShowDeleteConfirm(character.id)}
>
×
</button>
)}
</div>
<div class={styles.field}>
<div class={styles.label}>Description</div>
<ContentEditable
autoLines
class={styles.textarea}
value={character.description}
onInput={(e) => handleEditCharacter(character.id, 'description', e.currentTarget.textContent)}
placeholder="Full character description..."
/>
</div>
<div class={styles.field}>
<div class={styles.label}>
Short Description
<button
class={styles.generateButton}
onClick={() => handleGenerateShortDescription(character.id)}
disabled={!character.description.trim() || generatingShortDesc === character.id || !connection || !model}
>
{generatingShortDesc === character.id ? 'Generating...' : 'Generate'}
</button>
</div>
<textarea
class={styles.textarea}
value={character.shortDescription}
onInput={(e) => handleEditCharacter(character.id, 'shortDescription', e.currentTarget.value)}
placeholder="Brief description (one line)..."
rows={1}
/>
</div>
<div class={styles.field}>
<div class={styles.label}>
Nicknames
<div class={styles.addInput}>
<input
type="text"
class={styles.addInputField}
value={newNickname[character.id] || ''}
onInput={(e) => setNewNickname({ ...newNickname, [character.id]: e.currentTarget.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleNicknameAdd(character.id);
}
}}
placeholder="Add nickname..."
/>
<button
class={styles.smallButton}
onClick={() => handleNicknameAdd(character.id)}
>
+
</button>
</div>
</div>
<div class={styles.nicknames}>
{character.nicknames.length === 0 && (
<span class={styles.empty}>No nicknames</span>
)}
{character.nicknames.map((nickname, idx) => (
<span key={idx} class={styles.badge}>
{nickname}
<button
class={styles.badgeRemove}
onClick={() => handleNicknameDelete(character.id, nickname)}
>
×
</button>
</span>
))}
</div>
</div>
<div class={styles.field}>
<div class={styles.label}>
Relations
<div class={styles.addInput}>
<select
class={styles.addInputField}
value={(newRelation[character.id]?.name) || ''}
onInput={(e) => handleNewRelationChange(character.id, 'name', e.currentTarget.value)}
>
<option value="" disabled>Select character</option>
{currentStory.characters
.filter(c => c.id !== character.id)
.map(c => (
<option key={c.id} value={c.name}>{c.name}</option>
))}
</select>
<input
type="text"
class={styles.addInputField}
value={(newRelation[character.id]?.relation) || ''}
onInput={(e) => handleNewRelationChange(character.id, 'relation', e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddRelation(character.id);
}
}}
placeholder="Relationship"
/>
<button
class={styles.smallButton}
onClick={() => handleAddRelation(character.id)}
>
+
</button>
</div>
</div>
<div class={styles.relations}>
{character.relations.length === 0 && (
<span class={styles.empty}>No relations</span>
)}
{character.relations.map((rel, idx) => (
<div key={idx} class={styles.relationRow}>
<select
class={styles.relationInput}
value={rel.name}
onInput={(e) => handleEditRelation(character.id, rel.name, 'name', e.currentTarget.value)}
>
{currentStory.characters
.filter(c => c.id !== character.id)
.map(c => (
<option key={c.id} value={c.name}>{c.name}</option>
))}
</select>
<input
type="text"
class={styles.relationInput}
value={rel.relation}
onInput={(e) => handleEditRelation(character.id, rel.name, 'relation', e.currentTarget.value)}
placeholder="Relationship"
/>
<button
class={styles.smallButton}
onClick={() => handleDeleteRelation(character.id, rel.name)}
>
×
</button>
</div>
))}
</div>
</div>
</div>
))}
</div>
</div>
);
};