first commit

This commit is contained in:
Alexej Wolff
2026-02-11 00:15:59 +01:00
commit cc003ffbd5
39 changed files with 12170 additions and 0 deletions
+805
View File
@@ -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>
);
}