1055 lines
34 KiB
TypeScript
1055 lines
34 KiB
TypeScript
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,
|
||
getNPCCharacters,
|
||
} from "../services/api";
|
||
import { generateAvatarUrl } from "../services/imageGen";
|
||
import type {
|
||
Character,
|
||
CharacterAge,
|
||
CharacterGender,
|
||
NPCCharacter,
|
||
} from "../types";
|
||
import "./CreateStoryPage.css";
|
||
|
||
const GENRES = [
|
||
"Фэнтези",
|
||
"Магия",
|
||
"Приключения",
|
||
"Романтика",
|
||
"Экшн",
|
||
"Комедия",
|
||
"Драма",
|
||
"Тёмное фэнтези",
|
||
"LitRPG",
|
||
"Хорроры",
|
||
"Sci-Fi",
|
||
"Повседневность",
|
||
];
|
||
|
||
const SETTINGS = [
|
||
"Средневековье",
|
||
"Восточное фэнтези",
|
||
"Стимпанк",
|
||
"Магическая академия",
|
||
"Королевство демонов",
|
||
"Мир мечей и магии",
|
||
"Постапокалипсис",
|
||
"Параллельный мир",
|
||
"Современность",
|
||
"Космос",
|
||
"Подземелья",
|
||
"Королевский двор",
|
||
];
|
||
|
||
const LANGUAGES = [
|
||
{ code: "ru", name: "🇷🇺 Русский" },
|
||
{ code: "en", name: "🇺🇸 English" },
|
||
{ code: "ja", name: "🇯🇵 日本語" },
|
||
{ code: "zh", name: "🇨🇳 中文" },
|
||
{ code: "ko", name: "🇰🇷 한국어" },
|
||
{ code: "es", name: "🇪🇸 Español" },
|
||
{ code: "de", name: "🇩🇪 Deutsch" },
|
||
{ code: "fr", name: "🇫🇷 Français" },
|
||
];
|
||
|
||
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);
|
||
const navigate = useNavigate();
|
||
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: "",
|
||
description: "",
|
||
language: "ru",
|
||
genres: [] as string[],
|
||
customGenre: "",
|
||
settings: [] as string[],
|
||
customSetting: "",
|
||
summary: "",
|
||
plot: "",
|
||
firstMessage: "",
|
||
isNsfw: false,
|
||
temperature: 0.9, // Креативность ИИ
|
||
narrativeRules: "", // Правила повествования для ИИ
|
||
// NPC персонажи мира
|
||
characters: [
|
||
{
|
||
name: "",
|
||
description: "",
|
||
role: "Союзник",
|
||
age: "adult" as CharacterAge,
|
||
gender: "female" as CharacterGender,
|
||
},
|
||
] as Character[],
|
||
// Мир
|
||
worldName: "",
|
||
worldDescription: "",
|
||
worldRules: [""],
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (!isAuthenticated) {
|
||
navigate("/");
|
||
}
|
||
}, [isAuthenticated, navigate]);
|
||
|
||
// Загрузка сохранённых NPC
|
||
useEffect(() => {
|
||
const loadSavedNPCs = async () => {
|
||
if (isAuthenticated) {
|
||
const npcs = await getNPCCharacters();
|
||
setSavedNPCs(npcs);
|
||
}
|
||
};
|
||
loadSavedNPCs();
|
||
}, [isAuthenticated]);
|
||
|
||
// Загрузка истории для редактирования
|
||
useEffect(() => {
|
||
const loadStory = async () => {
|
||
if (!id || !isAuthenticated) return;
|
||
|
||
setIsLoading(true);
|
||
const story = await getStory(id);
|
||
|
||
if (story) {
|
||
setForm({
|
||
title: story.title,
|
||
description: story.description || "",
|
||
language: story.language || "ru",
|
||
genres: story.genre || [],
|
||
customGenre: "",
|
||
settings: Array.isArray(story.setting)
|
||
? story.setting
|
||
: [story.setting],
|
||
customSetting: "",
|
||
summary: story.summary || "",
|
||
plot: story.plot || "",
|
||
firstMessage: story.firstMessage || "",
|
||
isNsfw: story.isNsfw || false,
|
||
temperature: story.temperature || 0.9,
|
||
narrativeRules: story.narrativeRules || "",
|
||
characters:
|
||
story.characters?.length > 0
|
||
? story.characters
|
||
: [
|
||
{
|
||
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 : [""],
|
||
});
|
||
} else {
|
||
navigate("/");
|
||
}
|
||
|
||
setIsLoading(false);
|
||
};
|
||
|
||
loadStory();
|
||
}, [id, isAuthenticated, navigate]);
|
||
|
||
// Жанры
|
||
const handleGenreToggle = (genre: string) => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
genres: prev.genres.includes(genre)
|
||
? prev.genres.filter((g) => g !== genre)
|
||
: [...prev.genres, genre],
|
||
}));
|
||
};
|
||
|
||
const removeGenre = (genre: string) => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
genres: prev.genres.filter((g) => g !== genre),
|
||
}));
|
||
};
|
||
|
||
const addCustomGenre = () => {
|
||
if (
|
||
form.customGenre.trim() &&
|
||
!form.genres.includes(form.customGenre.trim())
|
||
) {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
genres: [...prev.genres, prev.customGenre.trim()],
|
||
customGenre: "",
|
||
}));
|
||
}
|
||
};
|
||
|
||
const handleCustomGenreKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
addCustomGenre();
|
||
}
|
||
};
|
||
|
||
// Сеттинги (теги как жанры)
|
||
const handleSettingToggle = (setting: string) => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
settings: prev.settings.includes(setting)
|
||
? prev.settings.filter((s) => s !== setting)
|
||
: [...prev.settings, setting],
|
||
}));
|
||
};
|
||
|
||
const removeSetting = (setting: string) => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
settings: prev.settings.filter((s) => s !== setting),
|
||
}));
|
||
};
|
||
|
||
const addCustomSetting = () => {
|
||
if (
|
||
form.customSetting.trim() &&
|
||
!form.settings.includes(form.customSetting.trim())
|
||
) {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
settings: [...prev.settings, prev.customSetting.trim()],
|
||
customSetting: "",
|
||
}));
|
||
}
|
||
};
|
||
|
||
const handleCustomSettingKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
addCustomSetting();
|
||
}
|
||
};
|
||
|
||
// NPC Персонажи
|
||
const handleCharacterChange = (
|
||
index: number,
|
||
field: keyof Character,
|
||
value: string,
|
||
) => {
|
||
const newCharacters = [...form.characters];
|
||
newCharacters[index] = { ...newCharacters[index], [field]: value };
|
||
setForm((prev) => ({ ...prev, characters: newCharacters }));
|
||
};
|
||
|
||
const addCharacter = () => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
characters: [
|
||
...prev.characters,
|
||
{
|
||
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,
|
||
characters: prev.characters.filter((_, i) => i !== index),
|
||
}));
|
||
};
|
||
|
||
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];
|
||
newRules[index] = value;
|
||
setForm((prev) => ({ ...prev, worldRules: newRules }));
|
||
};
|
||
|
||
const addRule = () => {
|
||
setForm((prev) => ({ ...prev, worldRules: [...prev.worldRules, ""] }));
|
||
};
|
||
|
||
const removeRule = (index: number) => {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
worldRules: prev.worldRules.filter((_, i) => i !== index),
|
||
}));
|
||
};
|
||
|
||
// Подсчёт слов
|
||
const countWords = (text: string) => {
|
||
return text
|
||
.trim()
|
||
.split(/\s+/)
|
||
.filter((w) => w.length > 0).length;
|
||
};
|
||
|
||
const summaryWordCount = countWords(form.summary);
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setIsSubmitting(true);
|
||
|
||
try {
|
||
const allGenres = [...form.genres];
|
||
if (form.customGenre.trim()) {
|
||
allGenres.push(form.customGenre.trim());
|
||
}
|
||
|
||
const allSettings = [...form.settings];
|
||
if (form.customSetting.trim()) {
|
||
allSettings.push(form.customSetting.trim());
|
||
}
|
||
|
||
const storyData = {
|
||
title: form.title,
|
||
description: form.description || `Исекай история: ${form.title}`,
|
||
coverImage: "",
|
||
language:
|
||
LANGUAGES.find((l) => l.code === form.language)?.name.split(" ")[1] ||
|
||
"Русский",
|
||
genre: allGenres,
|
||
setting: allSettings,
|
||
summary: form.summary,
|
||
plot: form.plot,
|
||
firstMessage: form.firstMessage,
|
||
characters: form.characters.filter((c) => c.name.trim()),
|
||
isNsfw: form.isNsfw,
|
||
temperature: form.temperature,
|
||
narrativeRules: form.narrativeRules.trim() || undefined,
|
||
world: {
|
||
name: form.worldName,
|
||
description: form.worldDescription,
|
||
rules: form.worldRules.filter((r) => r.trim()),
|
||
},
|
||
};
|
||
|
||
if (isEditMode && id) {
|
||
const success = await updateStory(id, storyData);
|
||
if (success) {
|
||
navigate(`/story/${id}`);
|
||
}
|
||
} else {
|
||
const story = await createStory(storyData);
|
||
if (story) {
|
||
navigate(`/story/${story.id}`);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Error saving story:", error);
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="create-story-page">
|
||
<div className="loading-state">
|
||
<p>Загрузка...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="create-story-page">
|
||
<Link to={isEditMode ? `/story/${id}` : "/"} className="back-link">
|
||
← {isEditMode ? "Назад к истории" : "Назад к историям"}
|
||
</Link>
|
||
|
||
<header className="create-header">
|
||
<h1>
|
||
{isEditMode ? "✏️ Редактировать историю" : "✨ Создать новую историю"}
|
||
</h1>
|
||
<p>
|
||
{isEditMode
|
||
? "Измени свою исекай историю"
|
||
: "Настрой свой уникальный исекай мир"}
|
||
</p>
|
||
</header>
|
||
|
||
<form onSubmit={handleSubmit} className="create-form">
|
||
{/* Основная информация */}
|
||
<section className="form-section">
|
||
<h2>📖 Основная информация</h2>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="title">Название истории *</label>
|
||
<p className="field-hint">
|
||
Только для отображения. Не используется ИИ.
|
||
</p>
|
||
<input
|
||
type="text"
|
||
id="title"
|
||
value={form.title}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({ ...prev, title: e.target.value }))
|
||
}
|
||
placeholder="Например: Перерождение в мире магии"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="language">
|
||
Язык истории * <span className="ai-tag">🤖 ИИ</span>
|
||
</label>
|
||
<p className="field-hint">ИИ будет отвечать на выбранном языке.</p>
|
||
<div className="language-grid">
|
||
{LANGUAGES.map((lang) => (
|
||
<button
|
||
key={lang.code}
|
||
type="button"
|
||
className={`language-btn ${form.language === lang.code ? "active" : ""}`}
|
||
onClick={() =>
|
||
setForm((prev) => ({ ...prev, language: lang.code }))
|
||
}
|
||
>
|
||
{lang.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>
|
||
Краткое содержание *{" "}
|
||
<span
|
||
className={`word-count ${summaryWordCount > 20 ? "over" : ""}`}
|
||
>
|
||
({summaryWordCount}/20 слов)
|
||
</span>
|
||
</label>
|
||
<p className="field-hint">
|
||
Краткое описание для привлечения внимания. Не используется ИИ.
|
||
</p>
|
||
<textarea
|
||
value={form.summary}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({ ...prev, summary: e.target.value }))
|
||
}
|
||
placeholder="Захватывающее описание в 1-2 предложениях..."
|
||
rows={2}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<div className="nsfw-toggle">
|
||
<label className="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
checked={form.isNsfw}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({ ...prev, isNsfw: e.target.checked }))
|
||
}
|
||
/>
|
||
<span className="toggle-slider"></span>
|
||
</label>
|
||
<div className="nsfw-info">
|
||
<span className={`nsfw-label ${form.isNsfw ? "active" : ""}`}>
|
||
🔞 NSFW контент <span className="ai-tag">🤖 ИИ</span>
|
||
</span>
|
||
<span className="nsfw-hint">
|
||
Снимает ограничения ИИ на контент 18+
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="temperature">
|
||
🎲 Креативность ИИ <span className="ai-tag">🤖 ИИ</span>
|
||
</label>
|
||
<p className="field-hint">
|
||
Управляет креативностью ответов ИИ. Низкая = сосредоточенный,
|
||
Высокая = креативный
|
||
</p>
|
||
<div className="temperature-selector">
|
||
{[
|
||
{ value: 0.9, label: "⚖️ Сбалансированный", desc: "0.9" },
|
||
{ value: 0.95, label: "✨ Живой", desc: "0.95" },
|
||
{ value: 1.05, label: "🎨 Креативный", desc: "1.05" },
|
||
].map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
className={`temp-btn ${form.temperature === opt.value ? "active" : ""}`}
|
||
onClick={() =>
|
||
setForm((prev) => ({ ...prev, temperature: opt.value }))
|
||
}
|
||
>
|
||
<span className="temp-label">{opt.label}</span>
|
||
<span className="temp-value">{opt.desc}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Жанры */}
|
||
<section className="form-section">
|
||
<h2>
|
||
🎭 Жанры <span className="ai-tag">🤖 ИИ</span>
|
||
</h2>
|
||
<p className="section-hint">
|
||
Влияет на стиль и атмосферу генерации ИИ.
|
||
</p>
|
||
|
||
{form.genres.length > 0 && (
|
||
<div className="selected-tags">
|
||
{form.genres.map((genre) => (
|
||
<span key={genre} className="selected-tag">
|
||
{genre}
|
||
<button
|
||
type="button"
|
||
className="remove-tag"
|
||
onClick={() => removeGenre(genre)}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="tags-grid">
|
||
{GENRES.map((genre) => (
|
||
<button
|
||
key={genre}
|
||
type="button"
|
||
className={`tag-btn ${form.genres.includes(genre) ? "active" : ""}`}
|
||
onClick={() => handleGenreToggle(genre)}
|
||
>
|
||
{genre}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="custom-tag-input">
|
||
<input
|
||
type="text"
|
||
value={form.customGenre}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({ ...prev, customGenre: e.target.value }))
|
||
}
|
||
onKeyDown={handleCustomGenreKeyDown}
|
||
placeholder="Свой жанр..."
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={addCustomGenre}
|
||
disabled={!form.customGenre.trim()}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Сеттинги */}
|
||
<section className="form-section">
|
||
<h2>
|
||
🏰 Сеттинг <span className="ai-tag">🤖 ИИ</span>
|
||
</h2>
|
||
<p className="section-hint">Определяет мир и эпоху для ИИ.</p>
|
||
|
||
{form.settings.length > 0 && (
|
||
<div className="selected-tags">
|
||
{form.settings.map((setting) => (
|
||
<span key={setting} className="selected-tag setting-tag">
|
||
{setting}
|
||
<button
|
||
type="button"
|
||
className="remove-tag"
|
||
onClick={() => removeSetting(setting)}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="tags-grid">
|
||
{SETTINGS.map((setting) => (
|
||
<button
|
||
key={setting}
|
||
type="button"
|
||
className={`tag-btn setting ${form.settings.includes(setting) ? "active" : ""}`}
|
||
onClick={() => handleSettingToggle(setting)}
|
||
>
|
||
{setting}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="custom-tag-input">
|
||
<input
|
||
type="text"
|
||
value={form.customSetting}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({ ...prev, customSetting: e.target.value }))
|
||
}
|
||
onKeyDown={handleCustomSettingKeyDown}
|
||
placeholder="Свой сеттинг..."
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={addCustomSetting}
|
||
disabled={!form.customSetting.trim()}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Сюжет */}
|
||
<section className="form-section">
|
||
<h2>
|
||
📜 Сюжет <span className="ai-tag">🤖 ИИ</span>
|
||
</h2>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="plot">Полный сюжет *</label>
|
||
<p className="field-hint">
|
||
Основа для ИИ — подробное описание сюжета и ключевых событий.
|
||
Поддерживается Markdown.
|
||
</p>
|
||
<textarea
|
||
id="plot"
|
||
value={form.plot}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({ ...prev, plot: e.target.value }))
|
||
}
|
||
placeholder={`# Завязка
|
||
Главный герой попадает в новый мир...
|
||
|
||
# Развитие
|
||
Он встречает союзников и врагов...
|
||
|
||
# Ключевые события
|
||
- Первая встреча с антагонистом
|
||
- Получение уникальной способности
|
||
- ...`}
|
||
rows={12}
|
||
className="markdown-input"
|
||
required
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Первое сообщение */}
|
||
<section className="form-section">
|
||
<h2>
|
||
💬 Первое сообщение <span className="ai-tag">🤖 ИИ</span>
|
||
</h2>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="firstMessage">Начало истории</label>
|
||
<p className="field-hint">
|
||
Первое сообщение, которое увидит игрок. Если пусто — ИИ
|
||
сгенерирует автоматически.
|
||
</p>
|
||
<textarea
|
||
id="firstMessage"
|
||
value={form.firstMessage}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({ ...prev, firstMessage: e.target.value }))
|
||
}
|
||
placeholder="Ты открываешь глаза и видишь перед собой незнакомый потолок..."
|
||
rows={6}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Правила повествования */}
|
||
<section className="form-section">
|
||
<h2>
|
||
📝 Правила повествования <span className="ai-tag">🤖 ИИ</span>
|
||
</h2>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="narrativeRules">Инструкции для ИИ</label>
|
||
<p className="field-hint">
|
||
Кастомные правила поведения ИИ. Если пусто — используются
|
||
стандартные правила. Поддерживается Markdown.
|
||
</p>
|
||
<textarea
|
||
id="narrativeRules"
|
||
value={form.narrativeRules}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({ ...prev, narrativeRules: e.target.value }))
|
||
}
|
||
placeholder={`Пример:
|
||
|
||
Ты — РассказчикGPT, ведущий интерактивную исекай-историю.
|
||
|
||
ПРАВИЛА:
|
||
— Я сам пишу свои действия и реплики
|
||
— Ты описываешь мир, реакции персонажей и последствия
|
||
— НИКОГДА не принимай решений за меня
|
||
— НИКОГДА не задавай мне вопросы
|
||
— НИКОГДА не предлагай варианты действий
|
||
|
||
ФОРМАТ ДИАЛОГОВ:
|
||
Все реплики персонажей оформляй **ЖИРНЫМ ШРИФТОМ**.
|
||
Описание действий — обычным текстом.`}
|
||
rows={14}
|
||
className="markdown-input"
|
||
/>
|
||
{form.narrativeRules.trim() && (
|
||
<div className="markdown-preview">
|
||
<div className="preview-header">👁️ Превью</div>
|
||
<div className="preview-content">
|
||
<ReactMarkdown>{form.narrativeRules}</ReactMarkdown>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{/* Мир */}
|
||
<section className="form-section">
|
||
<h2>
|
||
🌍 Мир <span className="ai-tag">🤖 ИИ</span>
|
||
</h2>
|
||
<p className="section-hint">
|
||
Весь раздел используется ИИ для построения контекста мира.
|
||
</p>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="worldName">Название мира *</label>
|
||
<input
|
||
type="text"
|
||
id="worldName"
|
||
value={form.worldName}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({ ...prev, worldName: e.target.value }))
|
||
}
|
||
placeholder="Например: Эльдория"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="worldDescription">Описание мира *</label>
|
||
<textarea
|
||
id="worldDescription"
|
||
value={form.worldDescription}
|
||
onChange={(e) =>
|
||
setForm((prev) => ({
|
||
...prev,
|
||
worldDescription: e.target.value,
|
||
}))
|
||
}
|
||
placeholder="Опишите мир: его историю, географию, магическую систему..."
|
||
rows={4}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label>Правила мира</label>
|
||
<p className="field-hint">
|
||
Особые законы и ограничения, действующие в этом мире.
|
||
</p>
|
||
{form.worldRules.map((rule, index) => (
|
||
<div key={index} className="array-input">
|
||
<input
|
||
type="text"
|
||
value={rule}
|
||
onChange={(e) => handleRuleChange(index, e.target.value)}
|
||
placeholder={`Правило ${index + 1}`}
|
||
/>
|
||
{form.worldRules.length > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => removeRule(index)}
|
||
className="remove-btn"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
<button type="button" onClick={addRule} className="add-btn">
|
||
+ Добавить правило
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
{/* NPC Персонажи */}
|
||
<section className="form-section">
|
||
<h2>
|
||
👥 NPC Персонажи мира <span className="ai-tag">🤖 ИИ</span>
|
||
</h2>
|
||
<p className="section-hint">
|
||
Персонажи, которых ИИ будет использовать в истории.
|
||
</p>
|
||
|
||
{form.characters.map((char, index) => (
|
||
<div key={index} className="character-card">
|
||
<div className="character-header">
|
||
<span>Персонаж {index + 1}</span>
|
||
{form.characters.length > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => removeCharacter(index)}
|
||
className="remove-btn"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<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>
|
||
))}
|
||
|
||
<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 */}
|
||
<div className="form-actions">
|
||
<Link to={isEditMode ? `/story/${id}` : "/"} className="cancel-btn">
|
||
Отмена
|
||
</Link>
|
||
<button type="submit" className="submit-btn" disabled={isSubmitting}>
|
||
{isSubmitting
|
||
? isEditMode
|
||
? "Сохранение..."
|
||
: "Создание..."
|
||
: isEditMode
|
||
? "💾 Сохранить изменения"
|
||
: "✨ Создать историю"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|