327 lines
16 KiB
TypeScript
327 lines
16 KiB
TypeScript
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>
|
||
);
|
||
};
|