Files
ReSekai/src/pages/CreateStoryPage.tsx
T

1055 lines
34 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}