376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import { useAuth } from "../contexts/AuthContext";
|
||
import {
|
||
getNPCCharacters,
|
||
createNPCCharacter,
|
||
updateNPCCharacter,
|
||
deleteNPCCharacter,
|
||
} from "../services/api";
|
||
import { generateAvatarUrl } from "../services/imageGen";
|
||
import type { NPCCharacter, CharacterAge, CharacterGender } from "../types";
|
||
import "./NPCPage.css";
|
||
|
||
const AGE_OPTIONS: { value: CharacterAge; label: string }[] = [
|
||
{ value: "child", label: "Ребёнок" },
|
||
{ value: "teenager", label: "Подросток" },
|
||
{ value: "adult", label: "Взрослый" },
|
||
{ value: "elderly", label: "Пожилой" },
|
||
];
|
||
|
||
const ROLE_OPTIONS = [
|
||
"Союзник",
|
||
"Злодей",
|
||
"Наставник",
|
||
"Романс",
|
||
"Нейтральный NPC",
|
||
"Антагонист",
|
||
"Комик",
|
||
];
|
||
|
||
const GENDER_OPTIONS: { value: CharacterGender; label: string }[] = [
|
||
{ value: "female", label: "Женский" },
|
||
{ value: "male", label: "Мужской" },
|
||
];
|
||
|
||
export default function NPCPage() {
|
||
const { user } = useAuth();
|
||
const [npcs, setNpcs] = useState<NPCCharacter[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [showForm, setShowForm] = useState(false);
|
||
const [editingNpc, setEditingNpc] = useState<NPCCharacter | null>(null);
|
||
const [generatingAvatar, setGeneratingAvatar] = useState(false);
|
||
|
||
// Форма
|
||
const [name, setName] = useState("");
|
||
const [description, setDescription] = useState("");
|
||
const [role, setRole] = useState("Союзник");
|
||
const [age, setAge] = useState<CharacterAge>("adult");
|
||
const [gender, setGender] = useState<CharacterGender>("female");
|
||
const [isNsfw, setIsNsfw] = useState(false);
|
||
const [avatarUrl, setAvatarUrl] = useState("");
|
||
const [customPrompt, setCustomPrompt] = useState("");
|
||
|
||
useEffect(() => {
|
||
loadNPCs();
|
||
}, []);
|
||
|
||
const loadNPCs = async () => {
|
||
setLoading(true);
|
||
const data = await getNPCCharacters();
|
||
setNpcs(data);
|
||
setLoading(false);
|
||
};
|
||
|
||
const resetForm = () => {
|
||
setName("");
|
||
setDescription("");
|
||
setRole("Союзник");
|
||
setAge("adult");
|
||
setGender("female");
|
||
setIsNsfw(false);
|
||
setAvatarUrl("");
|
||
setCustomPrompt("");
|
||
setEditingNpc(null);
|
||
setShowForm(false);
|
||
};
|
||
|
||
const openEditForm = (npc: NPCCharacter) => {
|
||
setName(npc.name);
|
||
setDescription(npc.description);
|
||
setRole(npc.role);
|
||
setAge(npc.age);
|
||
setGender(npc.gender || "female");
|
||
setIsNsfw(npc.isNsfw || false);
|
||
setAvatarUrl(npc.avatarUrl || "");
|
||
setEditingNpc(npc);
|
||
setShowForm(true);
|
||
};
|
||
|
||
const handleGenerateAvatar = async () => {
|
||
// Если нет ни кастомного промпта, ни описания - ошибка
|
||
if (!customPrompt.trim() && !description.trim()) {
|
||
alert("Введите промпт или описание персонажа");
|
||
return;
|
||
}
|
||
|
||
setGeneratingAvatar(true);
|
||
try {
|
||
const url = await generateAvatarUrl({
|
||
description,
|
||
name,
|
||
age,
|
||
gender,
|
||
customPrompt: customPrompt.trim() || undefined,
|
||
isNsfw,
|
||
});
|
||
setAvatarUrl(url);
|
||
} catch (error) {
|
||
console.error("Ошибка генерации аватара:", error);
|
||
alert("Ошибка генерации аватара: " + (error as Error).message);
|
||
} finally {
|
||
setGeneratingAvatar(false);
|
||
}
|
||
};
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
|
||
if (!name.trim() || !description.trim()) {
|
||
alert("Заполните имя и описание");
|
||
return;
|
||
}
|
||
|
||
const npcData = {
|
||
name: name.trim(),
|
||
description: description.trim(),
|
||
role,
|
||
age,
|
||
gender,
|
||
isNsfw,
|
||
avatarUrl: avatarUrl || undefined,
|
||
};
|
||
|
||
if (editingNpc) {
|
||
const success = await updateNPCCharacter(editingNpc.id, npcData);
|
||
if (success) {
|
||
await loadNPCs();
|
||
resetForm();
|
||
} else {
|
||
alert("Ошибка обновления NPC");
|
||
}
|
||
} else {
|
||
const created = await createNPCCharacter(npcData);
|
||
if (created) {
|
||
await loadNPCs();
|
||
resetForm();
|
||
} else {
|
||
alert("Ошибка создания NPC");
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: string) => {
|
||
if (!confirm("Удалить этого NPC?")) return;
|
||
|
||
const success = await deleteNPCCharacter(id);
|
||
if (success) {
|
||
await loadNPCs();
|
||
} else {
|
||
alert("Ошибка удаления");
|
||
}
|
||
};
|
||
|
||
if (!user) {
|
||
return (
|
||
<div className="npc-page">
|
||
<div className="auth-required">
|
||
<h2>Требуется авторизация</h2>
|
||
<p>Войдите через Discord для доступа к NPC персонажам</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="npc-page">
|
||
<div className="npc-header">
|
||
<h1>🎭 NPC Персонажи</h1>
|
||
<p>Создавайте и управляйте NPC для ваших историй</p>
|
||
<button className="btn-create-npc" onClick={() => setShowForm(true)}>
|
||
+ Создать NPC
|
||
</button>
|
||
</div>
|
||
|
||
{showForm && (
|
||
<div className="npc-form-overlay" onClick={() => resetForm()}>
|
||
<div className="npc-form-modal" onClick={(e) => e.stopPropagation()}>
|
||
<h2>{editingNpc ? "Редактировать NPC" : "Создать NPC"}</h2>
|
||
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="form-row">
|
||
<div className="form-left">
|
||
<div className="form-group">
|
||
<label>Имя персонажа</label>
|
||
<input
|
||
type="text"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="Введите имя..."
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>Роль</label>
|
||
<select
|
||
value={role}
|
||
onChange={(e) => setRole(e.target.value)}
|
||
>
|
||
{ROLE_OPTIONS.map((r) => (
|
||
<option key={r} value={r}>
|
||
{r}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>Возраст</label>
|
||
<select
|
||
value={age}
|
||
onChange={(e) => setAge(e.target.value as CharacterAge)}
|
||
>
|
||
{AGE_OPTIONS.map((opt) => (
|
||
<option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>Пол</label>
|
||
<select
|
||
value={gender}
|
||
onChange={(e) =>
|
||
setGender(e.target.value as CharacterGender)
|
||
}
|
||
>
|
||
{GENDER_OPTIONS.map((opt) => (
|
||
<option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="form-group form-checkbox">
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
checked={isNsfw}
|
||
onChange={(e) => setIsNsfw(e.target.checked)}
|
||
/>
|
||
NSFW (18+)
|
||
</label>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>Описание</label>
|
||
<textarea
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
placeholder="Опишите внешность и характер персонажа..."
|
||
rows={5}
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-right">
|
||
<div className="avatar-section">
|
||
<div className="avatar-preview-large">
|
||
{avatarUrl ? (
|
||
<img src={avatarUrl} alt={name} />
|
||
) : (
|
||
<div className="avatar-placeholder-large">👤</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn-generate-avatar"
|
||
onClick={handleGenerateAvatar}
|
||
disabled={
|
||
generatingAvatar ||
|
||
(!customPrompt.trim() && !description.trim())
|
||
}
|
||
>
|
||
{generatingAvatar ? "Генерация..." : "🎨 Сгенерировать"}
|
||
</button>
|
||
<textarea
|
||
value={customPrompt}
|
||
onChange={(e) => setCustomPrompt(e.target.value)}
|
||
placeholder="Кастомный промпт (на английском). Если пусто - сгенерируется из описания, пола и возраста"
|
||
className="avatar-prompt-input"
|
||
rows={3}
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={avatarUrl}
|
||
onChange={(e) => setAvatarUrl(e.target.value)}
|
||
placeholder="Или вставьте URL изображения..."
|
||
className="avatar-url-input"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-actions">
|
||
<button
|
||
type="button"
|
||
className="btn-cancel"
|
||
onClick={resetForm}
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button type="submit" className="btn-save">
|
||
{editingNpc ? "Сохранить" : "Создать"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div className="loading">Загрузка...</div>
|
||
) : npcs.length === 0 ? (
|
||
<div className="empty-state">
|
||
<p>У вас пока нет NPC персонажей</p>
|
||
<p>Создайте первого персонажа для ваших историй!</p>
|
||
</div>
|
||
) : (
|
||
<div className="npc-grid">
|
||
{npcs.map((npc) => (
|
||
<div key={npc.id} className="npc-card">
|
||
<div className="npc-card-avatar">
|
||
{npc.avatarUrl ? (
|
||
<img src={npc.avatarUrl} alt={npc.name} />
|
||
) : (
|
||
<div className="npc-avatar-placeholder">👤</div>
|
||
)}
|
||
</div>
|
||
<div className="npc-card-info">
|
||
<h3>{npc.name}</h3>
|
||
<span className="npc-role">{npc.role}</span>
|
||
<span className="npc-age">
|
||
{AGE_OPTIONS.find((a) => a.value === npc.age)?.label ||
|
||
npc.age}
|
||
</span>
|
||
{npc.isNsfw && <span className="npc-nsfw">18+</span>}
|
||
<p className="npc-description">{npc.description}</p>
|
||
</div>
|
||
<div className="npc-card-actions">
|
||
<button
|
||
className="btn-edit"
|
||
onClick={() => openEditForm(npc)}
|
||
title="Редактировать"
|
||
>
|
||
✏️
|
||
</button>
|
||
<button
|
||
className="btn-delete"
|
||
onClick={() => handleDelete(npc.id)}
|
||
title="Удалить"
|
||
>
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|