feat: NPC system improvements - custom prompt, NSFW, full body generation

This commit is contained in:
Alexej Wolff
2026-05-05 00:11:43 +02:00
parent efd2332875
commit dad5aa47cb
11 changed files with 1860 additions and 43 deletions
+201
View File
@@ -734,6 +734,86 @@
margin-bottom: 1rem;
}
.character-content {
display: flex;
gap: 1rem;
}
.character-avatar-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.character-avatar-preview {
width: 120px;
height: 180px;
border-radius: 12px;
object-fit: cover;
object-position: top center;
border: 2px solid #667eea;
background: linear-gradient(90deg, #1a1a1a 25%, #2a2a2a 50%, #1a1a1a 75%);
background-size: 200% 100%;
animation: avatar-loading 1.5s infinite;
}
.character-avatar-preview[src] {
animation: none;
background: none;
}
@keyframes avatar-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.character-avatar-placeholder {
width: 120px;
height: 180px;
border-radius: 12px;
background: #1a1a1a;
border: 2px dashed #333;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: #666;
}
.btn-generate-avatar {
padding: 0.4rem 0.8rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
color: white;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-generate-avatar:hover:not(:disabled) {
transform: scale(1.05);
box-shadow: 0 0 10px rgba(102, 126, 234, 0.5);
}
.btn-generate-avatar:disabled {
opacity: 0.7;
cursor: wait;
}
.character-fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.character-number {
font-weight: 600;
color: #667eea;
@@ -823,3 +903,124 @@
font-size: 0.9rem;
}
}
/* Character row with two selects */
.character-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.character-row select {
padding: 0.5rem;
background: #0d0d0d;
border: 2px solid #333;
border-radius: 8px;
color: white;
font-size: 0.9rem;
}
/* Character buttons */
.character-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.add-btn.secondary {
background: transparent;
border: 2px solid #667eea;
color: #667eea;
}
.add-btn.secondary:hover {
background: rgba(102, 126, 234, 0.1);
}
/* NPC Selector Modal */
.npc-selector-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.npc-selector-modal {
background: #1a1a1a;
border-radius: 16px;
padding: 1.5rem;
max-width: 700px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
}
.npc-selector-modal h3 {
margin: 0 0 1rem 0;
text-align: center;
}
.npc-selector-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.npc-selector-item {
background: #0d0d0d;
border: 2px solid #333;
border-radius: 12px;
padding: 0.75rem;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.npc-selector-item:hover {
border-color: #667eea;
transform: translateY(-2px);
}
.npc-selector-item img {
width: 100%;
height: 140px;
object-fit: cover;
object-position: top center;
border-radius: 8px;
margin-bottom: 0.5rem;
}
.npc-selector-item .npc-placeholder {
width: 100%;
height: 140px;
background: #1a1a1a;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
margin-bottom: 0.5rem;
}
.npc-selector-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.npc-selector-info strong {
font-size: 0.95rem;
}
.npc-selector-info span {
font-size: 0.8rem;
color: #888;
}
+250 -38
View File
@@ -2,8 +2,19 @@ import { useState, useEffect } from "react";
import { useNavigate, Link, useParams } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import { useAuth } from "../contexts/AuthContext";
import { createStory, getStory, updateStory } from "../services/api";
import type { Character } from "../types";
import {
createStory,
getStory,
updateStory,
getNPCCharacters,
} from "../services/api";
import { generateAvatarUrl } from "../services/imageGen";
import type {
Character,
CharacterAge,
CharacterGender,
NPCCharacter,
} from "../types";
import "./CreateStoryPage.css";
const GENRES = [
@@ -51,12 +62,24 @@ const CHARACTER_ROLES = [
"Союзник",
"Злодей",
"Наставник",
"Романтический интерес",
"Романс",
"Нейтральный NPC",
"Антагонист",
"Комик",
];
const CHARACTER_AGES: { value: CharacterAge; label: string }[] = [
{ value: "child", label: "Ребёнок" },
{ value: "teenager", label: "Подросток" },
{ value: "adult", label: "Взрослый" },
{ value: "elderly", label: "Пожилой" },
];
const CHARACTER_GENDERS: { value: CharacterGender; label: string }[] = [
{ value: "female", label: "Женский" },
{ value: "male", label: "Мужской" },
];
export default function CreateStoryPage() {
const { id } = useParams<{ id: string }>();
const isEditMode = Boolean(id);
@@ -64,6 +87,11 @@ export default function CreateStoryPage() {
const { isAuthenticated } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(isEditMode);
const [generatingAvatarIndex, setGeneratingAvatarIndex] = useState<
number | null
>(null);
const [savedNPCs, setSavedNPCs] = useState<NPCCharacter[]>([]);
const [showNPCSelector, setShowNPCSelector] = useState(false);
const [form, setForm] = useState({
title: "",
@@ -80,7 +108,15 @@ export default function CreateStoryPage() {
temperature: 0.9, // Креативность ИИ
narrativeRules: "", // Правила повествования для ИИ
// NPC персонажи мира
characters: [{ name: "", description: "", role: "Союзник" }] as Character[],
characters: [
{
name: "",
description: "",
role: "Союзник",
age: "adult" as CharacterAge,
gender: "female" as CharacterGender,
},
] as Character[],
// Мир
worldName: "",
worldDescription: "",
@@ -93,6 +129,17 @@ export default function CreateStoryPage() {
}
}, [isAuthenticated, navigate]);
// Загрузка сохранённых NPC
useEffect(() => {
const loadSavedNPCs = async () => {
if (isAuthenticated) {
const npcs = await getNPCCharacters();
setSavedNPCs(npcs);
}
};
loadSavedNPCs();
}, [isAuthenticated]);
// Загрузка истории для редактирования
useEffect(() => {
const loadStory = async () => {
@@ -121,7 +168,15 @@ export default function CreateStoryPage() {
characters:
story.characters?.length > 0
? story.characters
: [{ name: "", description: "", role: "Союзник" }],
: [
{
name: "",
description: "",
role: "Союзник",
age: "adult" as CharacterAge,
gender: "female" as CharacterGender,
},
],
worldName: story.world?.name || "",
worldDescription: story.world?.description || "",
worldRules: story.world?.rules?.length > 0 ? story.world.rules : [""],
@@ -226,11 +281,35 @@ export default function CreateStoryPage() {
...prev,
characters: [
...prev.characters,
{ name: "", description: "", role: "Союзник" },
{
name: "",
description: "",
role: "Союзник",
age: "adult" as CharacterAge,
gender: "female" as CharacterGender,
},
],
}));
};
const selectSavedNPC = (npc: NPCCharacter) => {
setForm((prev) => ({
...prev,
characters: [
...prev.characters,
{
name: npc.name,
description: npc.description,
role: npc.role,
age: npc.age,
gender: npc.gender || "female",
avatarUrl: npc.avatarUrl,
},
],
}));
setShowNPCSelector(false);
};
const removeCharacter = (index: number) => {
setForm((prev) => ({
...prev,
@@ -238,6 +317,33 @@ export default function CreateStoryPage() {
}));
};
const handleGenerateAvatar = async (index: number) => {
const char = form.characters[index];
if (!char.name && !char.description) {
alert("Заполните имя или описание персонажа для генерации аватара");
return;
}
setGeneratingAvatarIndex(index);
try {
const avatarUrl = await generateAvatarUrl({
description: char.description || char.role,
name: char.name,
age: char.age,
gender: char.gender,
});
const newCharacters = [...form.characters];
newCharacters[index] = { ...newCharacters[index], avatarUrl };
setForm((prev) => ({ ...prev, characters: newCharacters }));
} catch (error) {
console.error("Ошибка генерации аватара:", error);
alert("Ошибка при генерации аватара");
} finally {
setGeneratingAvatarIndex(null);
}
};
// Правила мира
const handleRuleChange = (index: number, value: string) => {
const newRules = [...form.worldRules];
@@ -783,42 +889,148 @@ export default function CreateStoryPage() {
)}
</div>
<div className="character-fields">
<input
type="text"
value={char.name}
onChange={(e) =>
handleCharacterChange(index, "name", e.target.value)
}
placeholder="Имя"
/>
<select
value={char.role}
onChange={(e) =>
handleCharacterChange(index, "role", e.target.value)
}
>
{CHARACTER_ROLES.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
<textarea
value={char.description}
onChange={(e) =>
handleCharacterChange(index, "description", e.target.value)
}
placeholder="Описание персонажа..."
rows={2}
/>
<div className="character-content">
{/* Avatar section */}
<div className="character-avatar-section">
{char.avatarUrl ? (
<img
src={char.avatarUrl}
alt={char.name || "Персонаж"}
className="character-avatar-preview"
/>
) : (
<div className="character-avatar-placeholder">👤</div>
)}
<button
type="button"
onClick={() => handleGenerateAvatar(index)}
className="btn-generate-avatar"
disabled={generatingAvatarIndex === index}
>
{generatingAvatarIndex === index ? "⏳" : "🎨"}
</button>
</div>
{/* Fields section */}
<div className="character-fields">
<input
type="text"
value={char.name}
onChange={(e) =>
handleCharacterChange(index, "name", e.target.value)
}
placeholder="Имя"
/>
<div className="character-row">
<select
value={char.role}
onChange={(e) =>
handleCharacterChange(index, "role", e.target.value)
}
>
{CHARACTER_ROLES.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
<select
value={char.age || "adult"}
onChange={(e) =>
handleCharacterChange(index, "age", e.target.value)
}
>
{CHARACTER_AGES.map((age) => (
<option key={age.value} value={age.value}>
{age.label}
</option>
))}
</select>
<select
value={char.gender || "female"}
onChange={(e) =>
handleCharacterChange(index, "gender", e.target.value)
}
>
{CHARACTER_GENDERS.map((g) => (
<option key={g.value} value={g.value}>
{g.label}
</option>
))}
</select>
</div>
<textarea
value={char.description}
onChange={(e) =>
handleCharacterChange(
index,
"description",
e.target.value,
)
}
placeholder="Описание персонажа..."
rows={2}
/>
</div>
</div>
</div>
))}
<button type="button" onClick={addCharacter} className="add-btn">
+ Добавить персонажа
</button>
<div className="character-buttons">
<button type="button" onClick={addCharacter} className="add-btn">
+ Добавить персонажа
</button>
{savedNPCs.length > 0 && (
<button
type="button"
onClick={() => setShowNPCSelector(true)}
className="add-btn secondary"
>
📚 Выбрать из библиотеки ({savedNPCs.length})
</button>
)}
</div>
{/* NPC Selector Modal */}
{showNPCSelector && (
<div
className="npc-selector-overlay"
onClick={() => setShowNPCSelector(false)}
>
<div
className="npc-selector-modal"
onClick={(e) => e.stopPropagation()}
>
<h3>Выберите NPC из библиотеки</h3>
<div className="npc-selector-grid">
{savedNPCs.map((npc) => (
<div
key={npc.id}
className="npc-selector-item"
onClick={() => selectSavedNPC(npc)}
>
{npc.avatarUrl ? (
<img src={npc.avatarUrl} alt={npc.name} />
) : (
<div className="npc-placeholder">👤</div>
)}
<div className="npc-selector-info">
<strong>{npc.name}</strong>
<span>{npc.role}</span>
</div>
</div>
))}
</div>
<button
type="button"
className="cancel-btn"
onClick={() => setShowNPCSelector(false)}
>
Закрыть
</button>
</div>
</div>
)}
</section>
{/* Submit */}
+466
View File
@@ -0,0 +1,466 @@
.npc-page {
max-width: 1800px;
margin: 0 auto;
padding: 2rem;
}
.npc-header {
text-align: center;
margin-bottom: 2rem;
}
.npc-header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.npc-header p {
color: #aaa;
margin-bottom: 1.5rem;
}
.btn-create-npc {
padding: 0.75rem 1.5rem;
font-size: 1rem;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.btn-create-npc:hover {
background: #5a6fd6;
}
/* NPC Grid */
.npc-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1rem;
}
.npc-card {
background: #1a1a1a;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
border: 1px solid #333;
transition:
transform 0.2s,
box-shadow 0.2s;
height: 380px;
}
.npc-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.npc-card-avatar {
width: 100%;
height: 100%;
overflow: hidden;
background: #0d0d0d;
position: absolute;
top: 0;
left: 0;
}
.npc-card-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
}
.npc-avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 4rem;
background: #2a2a2a;
color: #666;
}
.npc-card-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.75rem;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
z-index: 1;
}
.npc-card-info h3 {
margin: 0 0 0.4rem 0;
font-size: 1rem;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
.npc-role {
display: inline-block;
padding: 0.2rem 0.4rem;
background: rgba(102, 126, 234, 0.8);
color: white;
font-size: 0.65rem;
border-radius: 4px;
margin-right: 0.3rem;
}
.npc-age {
display: inline-block;
padding: 0.2rem 0.4rem;
background: rgba(42, 42, 42, 0.8);
color: #ccc;
font-size: 0.65rem;
border-radius: 4px;
}
.npc-nsfw {
display: inline-block;
padding: 0.2rem 0.4rem;
background: rgba(220, 53, 69, 0.9);
color: white;
font-size: 0.65rem;
border-radius: 4px;
margin-left: 0.3rem;
font-weight: bold;
}
.npc-description {
display: none;
}
.npc-card-actions {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
gap: 0.5rem;
opacity: 0;
transition: opacity 0.2s;
}
.npc-card:hover .npc-card-actions {
opacity: 1;
}
.npc-card-actions button {
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s;
}
.npc-card-actions button:hover {
transform: scale(1.1);
}
.btn-edit {
background: #2a2a2a;
}
.btn-delete {
background: rgba(220, 53, 69, 0.8);
}
/* Form Modal */
.npc-form-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.npc-form-modal {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 16px;
width: 100%;
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
padding: 2rem;
}
.npc-form-modal h2 {
margin: 0 0 1.5rem 0;
text-align: center;
}
.form-row {
display: flex;
gap: 2rem;
}
.form-left {
flex: 1;
}
.form-right {
width: 280px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #aaa;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #333;
border-radius: 8px;
background: #0d0d0d;
color: #fff;
font-size: 1rem;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #667eea;
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.form-checkbox label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
color: #fff;
}
.form-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #dc3545;
cursor: pointer;
}
/* Avatar Section */
.avatar-section {
text-align: center;
}
.avatar-section label {
display: block;
margin-bottom: 0.75rem;
font-weight: 500;
color: #aaa;
}
.avatar-preview-large {
width: fit-content;
max-width: 100%;
margin: 0 auto 1rem auto;
border-radius: 12px;
overflow: hidden;
background: transparent;
border: 2px dashed #333;
display: inline-flex;
align-items: center;
justify-content: center;
}
.avatar-preview-large img {
max-width: 100%;
max-height: 450px;
display: block;
border-radius: 10px;
}
.avatar-placeholder-large {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 5rem;
color: #666;
}
.btn-generate-avatar {
width: 100%;
padding: 0.75rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
margin-bottom: 0.75rem;
transition: opacity 0.2s;
}
.btn-generate-avatar:hover:not(:disabled) {
opacity: 0.9;
}
.btn-generate-avatar:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.avatar-prompt-input {
width: 100%;
padding: 0.75rem;
font-size: 0.85rem;
border: 1px solid #333;
border-radius: 6px;
background: #0d0d0d;
color: #fff;
resize: vertical;
min-height: 60px;
font-family: inherit;
}
.avatar-prompt-input:focus {
outline: none;
border-color: #667eea;
}
.avatar-url-input {
width: 100%;
padding: 0.5rem;
font-size: 0.85rem;
border: 1px solid #333;
border-radius: 6px;
background: #0d0d0d;
color: #fff;
}
/* Form Actions */
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #333;
}
.btn-cancel {
padding: 0.75rem 1.5rem;
background: #2a2a2a;
border: 1px solid #333;
border-radius: 8px;
color: #fff;
cursor: pointer;
}
.btn-cancel:hover {
background: #333;
}
.btn-save {
padding: 0.75rem 1.5rem;
background: #667eea;
border: none;
border-radius: 8px;
color: white;
cursor: pointer;
}
.btn-save:hover {
background: #5a6fd6;
}
/* Empty & Loading States */
.empty-state,
.loading,
.auth-required {
text-align: center;
padding: 4rem 2rem;
color: #aaa;
}
.auth-required h2 {
margin-bottom: 1rem;
}
/* Responsive */
@media (max-width: 1600px) {
.npc-grid {
grid-template-columns: repeat(6, 1fr);
}
}
@media (max-width: 1400px) {
.npc-grid {
grid-template-columns: repeat(5, 1fr);
}
}
@media (max-width: 1100px) {
.npc-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 900px) {
.npc-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.form-row {
flex-direction: column;
}
.form-right {
width: 100%;
}
.avatar-preview-large {
height: 240px;
}
.npc-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 500px) {
.npc-grid {
grid-template-columns: 1fr;
}
.npc-card {
height: 320px;
}
}
+372
View File
@@ -0,0 +1,372 @@
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>
);
}