first commit
This commit is contained in:
@@ -0,0 +1,805 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, Link, useParams } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { createStory, getStory, updateStory } from "../services/api";
|
||||
import type { Character } 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",
|
||||
"Антагонист",
|
||||
"Комик",
|
||||
];
|
||||
|
||||
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 [form, setForm] = useState({
|
||||
title: "",
|
||||
description: "",
|
||||
language: "ru",
|
||||
genres: [] as string[],
|
||||
customGenre: "",
|
||||
settings: [] as string[],
|
||||
customSetting: "",
|
||||
summary: "",
|
||||
plot: "",
|
||||
firstMessage: "",
|
||||
isNsfw: false,
|
||||
temperature: 1.3, // Креативность ИИ
|
||||
narrativeRules: "", // Правила повествования для ИИ
|
||||
// NPC персонажи мира
|
||||
characters: [{ name: "", description: "", role: "Союзник" }] as Character[],
|
||||
// Мир
|
||||
worldName: "",
|
||||
worldDescription: "",
|
||||
worldRules: [""],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
navigate("/");
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
// Загрузка истории для редактирования
|
||||
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 || 1.3,
|
||||
narrativeRules: story.narrativeRules || "",
|
||||
characters:
|
||||
story.characters?.length > 0
|
||||
? story.characters
|
||||
: [{ name: "", description: "", role: "Союзник" }],
|
||||
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: "Союзник" },
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeCharacter = (index: number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
characters: prev.characters.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
// Правила мира
|
||||
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>
|
||||
<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">Язык истории *</label>
|
||||
<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>
|
||||
<span className="nsfw-hint">
|
||||
Включите, если история содержит контент 18+
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="temperature">🎲 Креативность ИИ</label>
|
||||
<p className="field-hint">
|
||||
Управляет креативностью ответов ИИ. Низкая = сосредоточенный,
|
||||
Высокая = креативный
|
||||
</p>
|
||||
<div className="temperature-selector">
|
||||
{[
|
||||
{ value: 1.0, label: "🎯 Сосредоточенный", desc: "1.0" },
|
||||
{ value: 1.3, label: "⚖️ Сбалансированный", desc: "1.3" },
|
||||
{ value: 1.5, label: "✨ Креативный", desc: "1.5" },
|
||||
].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>🎭 Жанры</h2>
|
||||
|
||||
{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>🏰 Сеттинг</h2>
|
||||
|
||||
{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>📜 Сюжет</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>💬 Первое сообщение</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>📝 Правила повествования</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="narrativeRules">Инструкции для ИИ</label>
|
||||
<p className="field-hint">
|
||||
Кастомные правила поведения ИИ: стиль повествования, запреты,
|
||||
формат ответов. Если оставить пустым, будут использованы
|
||||
стандартные правила живой истории.
|
||||
</p>
|
||||
<textarea
|
||||
id="narrativeRules"
|
||||
value={form.narrativeRules}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, narrativeRules: e.target.value }))
|
||||
}
|
||||
placeholder={`Пример:
|
||||
|
||||
Ты — РассказчикGPT, ведущий интерактивную исекай-историю.
|
||||
|
||||
ПРАВИЛА:
|
||||
— Я сам пишу свои действия и реплики
|
||||
— Ты описываешь мир, реакции персонажей и последствия
|
||||
— НИКОГДА не принимай решений за меня
|
||||
— НИКОГДА не задавай мне вопросы
|
||||
— НИКОГДА не предлагай варианты действий
|
||||
|
||||
ФОРМАТ ДИАЛОГОВ:
|
||||
Все реплики персонажей оформляй **ЖИРНЫМ ШРИФТОМ**.
|
||||
Описание действий — обычным текстом.`}
|
||||
rows={14}
|
||||
className="markdown-input"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Мир */}
|
||||
<section className="form-section">
|
||||
<h2>🌍 Мир</h2>
|
||||
|
||||
<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 Персонажи мира</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-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>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="button" onClick={addCharacter} className="add-btn">
|
||||
+ Добавить персонажа
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user