first commit
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
.characters-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.characters-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.characters-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.characters-header p {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.create-character-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto 2rem;
|
||||
padding: 1rem 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.create-character-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.characters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.character-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.character-card.favorite {
|
||||
border-color: rgba(255, 215, 0, 0.4);
|
||||
background: rgba(255, 215, 0, 0.05);
|
||||
}
|
||||
|
||||
.character-card:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.character-card.favorite:hover {
|
||||
border-color: rgba(255, 215, 0, 0.6);
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: all 0.2s;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.favorite-btn:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.favorite-btn.active {
|
||||
opacity: 1;
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.character-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.character-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.character-info {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.character-info h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.character-description {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.character-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.character-actions button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Form Overlay */
|
||||
.character-form-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: 2rem;
|
||||
}
|
||||
|
||||
.character-form {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.character-form h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.character-form .form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.character-form label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.character-form .field-hint {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.character-form .field-hint code {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: #a0b4ff;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.character-form input,
|
||||
.character-form textarea {
|
||||
width: 100%;
|
||||
padding: 0.8rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.character-form input:focus,
|
||||
.character-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.character-form textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.avatar-preview {
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar-preview img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid #667eea;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.form-actions button {
|
||||
flex: 1;
|
||||
padding: 0.8rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-save:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-save:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
font-size: 3rem;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.characters-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.characters-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.characters-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import {
|
||||
getPlayerCharacters,
|
||||
createPlayerCharacter,
|
||||
updatePlayerCharacter,
|
||||
deletePlayerCharacter,
|
||||
} from "../services/api";
|
||||
import type { PlayerCharacter } from "../types";
|
||||
import "./CharactersPage.css";
|
||||
|
||||
export default function CharactersPage() {
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const [characters, setCharacters] = useState<PlayerCharacter[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isEditing, setIsEditing] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
avatarUrl: "",
|
||||
isFavorite: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
navigate("/");
|
||||
return;
|
||||
}
|
||||
|
||||
const loadCharacters = async () => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
setIsLoading(true);
|
||||
const data = await getPlayerCharacters();
|
||||
setCharacters(data);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
if (!authLoading) {
|
||||
loadCharacters();
|
||||
}
|
||||
}, [isAuthenticated, authLoading, navigate]);
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({ name: "", description: "", avatarUrl: "", isFavorite: false });
|
||||
setIsCreating(false);
|
||||
setIsEditing(null);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.name.trim()) return;
|
||||
|
||||
const newCharacter = await createPlayerCharacter({
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
avatarUrl: form.avatarUrl.trim() || undefined,
|
||||
isFavorite: form.isFavorite,
|
||||
});
|
||||
|
||||
if (newCharacter) {
|
||||
setCharacters([newCharacter, ...characters]);
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string) => {
|
||||
if (!form.name.trim()) return;
|
||||
|
||||
const success = await updatePlayerCharacter(id, {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
avatarUrl: form.avatarUrl.trim() || undefined,
|
||||
isFavorite: form.isFavorite,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
setCharacters(
|
||||
characters.map((c) =>
|
||||
c.id === id
|
||||
? {
|
||||
...c,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
avatarUrl: form.avatarUrl.trim() || undefined,
|
||||
isFavorite: form.isFavorite,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Удалить этого персонажа?")) return;
|
||||
|
||||
const success = await deletePlayerCharacter(id);
|
||||
if (success) {
|
||||
setCharacters(characters.filter((c) => c.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (character: PlayerCharacter) => {
|
||||
setForm({
|
||||
name: character.name,
|
||||
description: character.description,
|
||||
avatarUrl: character.avatarUrl || "",
|
||||
isFavorite: character.isFavorite || false,
|
||||
});
|
||||
setIsEditing(character.id);
|
||||
setIsCreating(false);
|
||||
};
|
||||
|
||||
const toggleFavorite = async (character: PlayerCharacter) => {
|
||||
const newFavorite = !character.isFavorite;
|
||||
const success = await updatePlayerCharacter(character.id, {
|
||||
isFavorite: newFavorite,
|
||||
});
|
||||
if (success) {
|
||||
setCharacters(
|
||||
characters.map((c) =>
|
||||
c.id === character.id ? { ...c, isFavorite: newFavorite } : c,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Сортировка: фавориты первыми
|
||||
const sortedCharacters = [...characters].sort((a, b) => {
|
||||
if (a.isFavorite && !b.isFavorite) return -1;
|
||||
if (!a.isFavorite && b.isFavorite) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const startCreate = () => {
|
||||
resetForm();
|
||||
setIsCreating(true);
|
||||
};
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div className="characters-page">
|
||||
<div className="loading-state">
|
||||
<div className="loading-spinner">⏳</div>
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="characters-page">
|
||||
<header className="characters-header">
|
||||
<h1>👤 Мои персонажи</h1>
|
||||
<p>Создавайте персонажей для игры в истории</p>
|
||||
</header>
|
||||
|
||||
{/* Форма создания/редактирования */}
|
||||
{(isCreating || isEditing) && (
|
||||
<div className="character-form-overlay">
|
||||
<div className="character-form">
|
||||
<h2>{isCreating ? "✨ Новый персонаж" : "✏️ Редактирование"}</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Имя персонажа *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="Как зовут вашего персонажа?"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Описание</label>
|
||||
<p className="field-hint">
|
||||
Используйте <code>{"{user}"}</code> для автоподстановки имени
|
||||
персонажа
|
||||
</p>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
placeholder="Опишите внешность, характер, историю... Пример: {user} - молодой маг с тёмными волосами"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>URL аватара</label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.avatarUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, avatarUrl: e.target.value }))
|
||||
}
|
||||
placeholder="https://example.com/avatar.png"
|
||||
/>
|
||||
{form.avatarUrl && (
|
||||
<div className="avatar-preview">
|
||||
<img src={form.avatarUrl} alt="Preview" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" onClick={resetForm}>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-save"
|
||||
onClick={() =>
|
||||
isCreating ? handleCreate() : handleUpdate(isEditing!)
|
||||
}
|
||||
disabled={!form.name.trim()}
|
||||
>
|
||||
{isCreating ? "Создать" : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопка создания */}
|
||||
<button className="create-character-btn" onClick={startCreate}>
|
||||
➕ Создать персонажа
|
||||
</button>
|
||||
|
||||
{/* Список персонажей */}
|
||||
{characters.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">👤</div>
|
||||
<h2>Нет персонажей</h2>
|
||||
<p>Создайте своего первого персонажа для игры!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="characters-grid">
|
||||
{sortedCharacters.map((character) => (
|
||||
<div
|
||||
key={character.id}
|
||||
className={`character-card ${character.isFavorite ? "favorite" : ""}`}
|
||||
>
|
||||
<button
|
||||
className={`favorite-btn ${character.isFavorite ? "active" : ""}`}
|
||||
onClick={() => toggleFavorite(character)}
|
||||
title={
|
||||
character.isFavorite
|
||||
? "Убрать из избранного"
|
||||
: "Добавить в избранное"
|
||||
}
|
||||
>
|
||||
{character.isFavorite ? "⭐" : "☆"}
|
||||
</button>
|
||||
<div className="character-avatar">
|
||||
{character.avatarUrl ? (
|
||||
<img src={character.avatarUrl} alt={character.name} />
|
||||
) : (
|
||||
<span className="avatar-placeholder">👤</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="character-info">
|
||||
<h3>{character.name}</h3>
|
||||
{character.description && (
|
||||
<p className="character-description">
|
||||
{character.description.replace(
|
||||
/\{user\}/gi,
|
||||
character.name,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="character-actions">
|
||||
<button
|
||||
className="btn-edit"
|
||||
onClick={() => startEdit(character)}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
className="btn-delete"
|
||||
onClick={() => handleDelete(character.id)}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
.create-story-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
color: #888;
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.create-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.create-header h1 {
|
||||
font-size: 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.create-header p {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #1a1a1a;
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.form-section h2 {
|
||||
font-size: 1.2rem;
|
||||
color: #fff;
|
||||
margin: 0 0 1.25rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
color: #ccc;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #0d0d0d;
|
||||
border: 2px solid #333;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.form-group input::placeholder,
|
||||
.form-group textarea::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.genres-grid,
|
||||
.settings-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Выбранные теги */
|
||||
.selected-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.selected-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.6rem 0.4rem 0.9rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.tag-remove:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Кастомный жанр */
|
||||
.custom-genre-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.custom-genre-input input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-genre-btn {
|
||||
width: 44px;
|
||||
background: #2a2a2a;
|
||||
border: 2px solid #333;
|
||||
border-radius: 10px;
|
||||
color: #667eea;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-genre-btn:hover:not(:disabled) {
|
||||
background: #667eea;
|
||||
border-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.add-genre-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* NSFW Toggle */
|
||||
.nsfw-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #0d0d0d;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-switch .toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
|
||||
transition: 0.3s;
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
.toggle-switch .toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider:before {
|
||||
transform: translateX(26px);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.nsfw-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nsfw-label {
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.nsfw-label.active {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.nsfw-hint {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Temperature Selector */
|
||||
.temperature-selector {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.temp-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem;
|
||||
background: #0d0d0d;
|
||||
border: 2px solid #333;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.temp-btn:hover {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.temp-btn.active {
|
||||
border-color: #667eea;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(102, 126, 234, 0.15) 0%,
|
||||
rgba(118, 75, 162, 0.15) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.temp-label {
|
||||
font-size: 0.95rem;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.temp-btn.active .temp-label {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.temp-value {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.temp-btn.active .temp-value {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.genre-btn,
|
||||
.setting-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #2a2a2a;
|
||||
border: 2px solid #333;
|
||||
border-radius: 20px;
|
||||
color: #aaa;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.genre-btn:hover,
|
||||
.setting-btn:hover {
|
||||
border-color: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.genre-btn.selected,
|
||||
.setting-btn.selected {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.array-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.array-input input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
width: 40px;
|
||||
background: #2a2a2a;
|
||||
border: 2px solid #333;
|
||||
border-radius: 10px;
|
||||
color: #ff6b6b;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: transparent;
|
||||
border: 2px dashed #333;
|
||||
border-radius: 10px;
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
border-color: #667eea;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: #2a2a2a;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: #aaa;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Языки */
|
||||
.language-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.language-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #2a2a2a;
|
||||
border: 2px solid #333;
|
||||
border-radius: 10px;
|
||||
color: #aaa;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.language-btn:hover {
|
||||
border-color: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.language-btn.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Tags grid */
|
||||
.tags-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tag-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #2a2a2a;
|
||||
border: 2px solid #333;
|
||||
border-radius: 20px;
|
||||
color: #aaa;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tag-btn:hover {
|
||||
border-color: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tag-btn.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tag-btn.setting.active {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
}
|
||||
|
||||
.setting-tag {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
|
||||
}
|
||||
|
||||
/* Remove tag button */
|
||||
.remove-tag {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.remove-tag:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Custom tag input */
|
||||
.custom-tag-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.custom-tag-input input {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #0d0d0d;
|
||||
border: 2px solid #333;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.custom-tag-input input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.custom-tag-input button {
|
||||
width: 44px;
|
||||
background: #2a2a2a;
|
||||
border: 2px solid #333;
|
||||
border-radius: 10px;
|
||||
color: #667eea;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.custom-tag-input button:hover:not(:disabled) {
|
||||
background: #667eea;
|
||||
border-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.custom-tag-input button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Field hints */
|
||||
.field-hint {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.word-count {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.word-count.over {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* Markdown input */
|
||||
.markdown-input {
|
||||
font-family: "Fira Code", "Monaco", monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Character fields */
|
||||
.character-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.character-fields input,
|
||||
.character-fields select,
|
||||
.character-fields textarea {
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.character-fields input:focus,
|
||||
.character-fields select:focus,
|
||||
.character-fields textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
/* Подсказки */
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin: -0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
/* Персонажи */
|
||||
.character-card {
|
||||
background: #0d0d0d;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.character-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.character-number {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.remove-btn.small {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #0d0d0d;
|
||||
border: 2px solid #333;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.form-group select option {
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.create-story-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.language-grid {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.language-btn {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
.game-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0d0d0d;
|
||||
}
|
||||
|
||||
.game-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.game-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #1a1a1a;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #2a2a2a;
|
||||
border-radius: 10px;
|
||||
color: #ccc;
|
||||
text-decoration: none;
|
||||
font-size: 1.2rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-info h1 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header-protagonist {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-badge.health {
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
color: #ff8a8a;
|
||||
}
|
||||
|
||||
.stat-badge.location {
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
color: #a0b4ff;
|
||||
}
|
||||
|
||||
.game-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 85%;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message.assistant .message-content {
|
||||
background: #1f1f1f;
|
||||
color: #ddd;
|
||||
border: 1px solid #333;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-content p + p {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Markdown стили */
|
||||
.message-content strong {
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-content em {
|
||||
font-style: italic;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.message-content ul,
|
||||
.message-content ol {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.message-content li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.message-content blockquote {
|
||||
border-left: 3px solid #667eea;
|
||||
padding-left: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
color: #aaa;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.message-content code {
|
||||
background: #2a2a2a;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.message-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid #444;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
margin-top: 0.35rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.message.user .message-time {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message.loading .message-content {
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-8px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
border-radius: 10px;
|
||||
color: #ff8a8a;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error-message button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ff8a8a;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
overflow-x: auto;
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
.quick-actions button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #222;
|
||||
border: 1px solid #333;
|
||||
border-radius: 20px;
|
||||
color: #aaa;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.quick-actions button:hover {
|
||||
background: #2a2a2a;
|
||||
color: #fff;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem 1.5rem;
|
||||
background: #131313;
|
||||
border-top: 1px solid #222;
|
||||
}
|
||||
|
||||
.input-container textarea {
|
||||
flex: 1;
|
||||
padding: 0.875rem 1rem;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #333;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-container textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.input-container textarea::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
opacity 0.2s;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Скроллбар */
|
||||
.messages-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb {
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.game-header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
useParams,
|
||||
Link,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import {
|
||||
getStory,
|
||||
getSession,
|
||||
saveSession as apiSaveSession,
|
||||
getPlayerCharacter,
|
||||
} from "../services/api";
|
||||
import {
|
||||
generateStoryResponse,
|
||||
buildSystemPrompt,
|
||||
sendMessage,
|
||||
generateStorySummary,
|
||||
extractKeyEvents,
|
||||
} from "../services/deepseek";
|
||||
import type {
|
||||
Story,
|
||||
GameSession,
|
||||
ChatMessage,
|
||||
PlayerCharacter,
|
||||
} from "../types";
|
||||
import "./GamePage.css";
|
||||
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
// Примерный подсчёт токенов (1 токен ≈ 3 символа для русского)
|
||||
function estimateTokens(messages: ChatMessage[]): number {
|
||||
if (!messages || messages.length === 0) return 0;
|
||||
const totalChars = messages.reduce((sum, msg) => sum + msg.content.length, 0);
|
||||
return Math.round(totalChars / 3);
|
||||
}
|
||||
|
||||
function formatTokens(tokens: number): string {
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
|
||||
return tokens.toString();
|
||||
}
|
||||
|
||||
// Пытаемся определить локацию из последних сообщений
|
||||
function detectLocation(messages: ChatMessage[]): string {
|
||||
if (!messages || messages.length === 0) return "Неизвестно";
|
||||
|
||||
// Берём последние 3 сообщения ассистента
|
||||
const recentAssistant = messages
|
||||
.filter((m) => m.role === "assistant")
|
||||
.slice(-3)
|
||||
.map((m) => m.content)
|
||||
.join(" ");
|
||||
|
||||
// Паттерны для определения локации
|
||||
const locationPatterns = [
|
||||
/(?:находи(?:тесь|шься)|оказыва(?:етесь|ешься)|стои(?:те|шь))\s+(?:в|на|у)\s+([^.,!?]+)/i,
|
||||
/(?:вошл[аи]?|входи(?:те|шь)|попада(?:ете|ешь))\s+(?:в|на)\s+([^.,!?]+)/i,
|
||||
/(?:прибыл[аи]?|приход(?:ите|ишь)|добрал(?:ись|ась|ся))\s+(?:в|на|до)\s+([^.,!?]+)/i,
|
||||
/(?:комнат[аеуы]|зал[аеуы]?|пещер[аеуы]|лес[ау|замок|двор(?:ец)?|тавер[нуыа]|город[ауе]?|деревн[яюией]|тронн[ыйая]|подземель[яеи])\s*([^.,!?]*)/i,
|
||||
];
|
||||
|
||||
for (const pattern of locationPatterns) {
|
||||
const match = recentAssistant.match(pattern);
|
||||
if (match && match[1]) {
|
||||
// Чистим и обрезаем результат
|
||||
let location = match[1].trim();
|
||||
if (location.length > 25) location = location.substring(0, 25) + "...";
|
||||
return location;
|
||||
}
|
||||
}
|
||||
|
||||
// Простой поиск ключевых слов
|
||||
const simpleLocations: [RegExp, string][] = [
|
||||
[/тронн(?:ый|ого|ом)\s*зал/i, "Тронный зал"],
|
||||
[/тавер[нуыа]/i, "Таверна"],
|
||||
[/замок|замк[ауе]/i, "Замок"],
|
||||
[/лес[ау]?/i, "Лес"],
|
||||
[/пещер[аеуы]/i, "Пещера"],
|
||||
[/город[ауе]?/i, "Город"],
|
||||
[/деревн[яюией]/i, "Деревня"],
|
||||
[/подземель[яеи]/i, "Подземелье"],
|
||||
[/двор(?:ец|ц[ауе])/i, "Дворец"],
|
||||
[/рын(?:ок|к[ауе])/i, "Рынок"],
|
||||
[/храм[ауе]?/i, "Храм"],
|
||||
[/библиотек/i, "Библиотека"],
|
||||
[/казарм/i, "Казармы"],
|
||||
];
|
||||
|
||||
for (const [pattern, name] of simpleLocations) {
|
||||
if (pattern.test(recentAssistant)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
return "Неизвестно";
|
||||
}
|
||||
|
||||
export default function GamePage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [story, setStory] = useState<Story | null>(null);
|
||||
const [session, setSession] = useState<GameSession | null>(null);
|
||||
const [playerCharacter, setPlayerCharacter] =
|
||||
useState<PlayerCharacter | null>(null);
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadGame = async () => {
|
||||
if (!id || !isAuthenticated) {
|
||||
setIsInitialLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const foundStory = await getStory(id);
|
||||
if (foundStory) {
|
||||
const normalizedStory = {
|
||||
...foundStory,
|
||||
id: (foundStory as any)._id || foundStory.id,
|
||||
};
|
||||
setStory(normalizedStory);
|
||||
|
||||
let existingSession = await getSession(id);
|
||||
console.log(
|
||||
"[GamePage] Loaded session:",
|
||||
existingSession?.messages?.length || 0,
|
||||
"messages",
|
||||
);
|
||||
const characterId = searchParams.get("character");
|
||||
|
||||
// Загружаем персонажа
|
||||
let character: PlayerCharacter | null = null;
|
||||
if (characterId) {
|
||||
character = await getPlayerCharacter(characterId);
|
||||
setPlayerCharacter(character);
|
||||
} else if (existingSession?.playerId) {
|
||||
character = await getPlayerCharacter(existingSession.playerId);
|
||||
setPlayerCharacter(character);
|
||||
}
|
||||
|
||||
if (!existingSession) {
|
||||
console.log("[GamePage] No existing session, creating new");
|
||||
existingSession = {
|
||||
storyId: id,
|
||||
playerId: characterId || undefined,
|
||||
messages: [],
|
||||
currentState: {
|
||||
location: "Неизвестно",
|
||||
health: 100,
|
||||
inventory: [],
|
||||
questProgress: {},
|
||||
},
|
||||
};
|
||||
} else if (characterId && existingSession.playerId !== characterId) {
|
||||
// Новый персонаж — новая сессия
|
||||
console.log("[GamePage] New character selected, resetting session");
|
||||
existingSession = {
|
||||
storyId: id,
|
||||
playerId: characterId,
|
||||
messages: [],
|
||||
currentState: {
|
||||
location: "Неизвестно",
|
||||
health: 100,
|
||||
inventory: [],
|
||||
questProgress: {},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
console.log(
|
||||
"[GamePage] Using existing session with",
|
||||
existingSession.messages.length,
|
||||
"messages",
|
||||
);
|
||||
}
|
||||
|
||||
setSession(existingSession);
|
||||
|
||||
// Начинаем историю если это новая сессия
|
||||
if (existingSession.messages.length === 0 && character) {
|
||||
startStory(normalizedStory, existingSession, character);
|
||||
}
|
||||
}
|
||||
setIsInitialLoading(false);
|
||||
};
|
||||
|
||||
loadGame();
|
||||
}, [id, isAuthenticated, searchParams]);
|
||||
|
||||
// Обновляем историю при возврате на страницу (после редактирования)
|
||||
useEffect(() => {
|
||||
const handleFocus = async () => {
|
||||
if (!id || !isAuthenticated || isInitialLoading) return;
|
||||
|
||||
const updatedStory = await getStory(id);
|
||||
if (updatedStory) {
|
||||
const normalizedStory = {
|
||||
...updatedStory,
|
||||
id: (updatedStory as any)._id || updatedStory.id,
|
||||
};
|
||||
setStory(normalizedStory);
|
||||
console.log("[GamePage] История обновлена после возврата на страницу");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => window.removeEventListener("focus", handleFocus);
|
||||
}, [id, isAuthenticated, isInitialLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [session?.messages]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
const startStory = async (
|
||||
storyData: Story,
|
||||
sessionData: GameSession,
|
||||
character: PlayerCharacter,
|
||||
) => {
|
||||
// Если есть заготовленное первое сообщение, используем его
|
||||
if (storyData.firstMessage && storyData.firstMessage.trim()) {
|
||||
// Заменяем {user} на имя персонажа
|
||||
const firstMessageContent = storyData.firstMessage.replace(
|
||||
/\{user\}/gi,
|
||||
character.name,
|
||||
);
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: "assistant",
|
||||
content: firstMessageContent,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const updatedSession: GameSession = {
|
||||
...sessionData,
|
||||
messages: [assistantMessage],
|
||||
};
|
||||
|
||||
await apiSaveSession(storyData.id, updatedSession);
|
||||
setSession(updatedSession);
|
||||
return;
|
||||
}
|
||||
|
||||
// Иначе генерируем через ИИ
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const systemPrompt = buildSystemPrompt(storyData, character);
|
||||
const response = await sendMessage([
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: "Начни историю." },
|
||||
]);
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: "assistant",
|
||||
content: response,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const updatedSession: GameSession = {
|
||||
...sessionData,
|
||||
messages: [assistantMessage],
|
||||
};
|
||||
|
||||
await apiSaveSession(storyData.id, updatedSession);
|
||||
setSession(updatedSession);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Произошла ошибка");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || !story || !session || isLoading) return;
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: "user",
|
||||
content: input.trim(),
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const updatedMessages = [...session.messages, userMessage];
|
||||
const tempSession = { ...session, messages: updatedMessages };
|
||||
setSession(tempSession);
|
||||
setInput("");
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Передаём session для оптимизированного контекста
|
||||
const response = await generateStoryResponse(
|
||||
story,
|
||||
session.messages,
|
||||
input.trim(),
|
||||
playerCharacter || undefined,
|
||||
session,
|
||||
);
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: "assistant",
|
||||
content: response,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const allMessages = [...updatedMessages, assistantMessage];
|
||||
|
||||
// Обновляем ключевые события
|
||||
const newKeyEvents = await extractKeyEvents(
|
||||
response,
|
||||
session.keyEvents || [],
|
||||
);
|
||||
|
||||
// Генерируем сводку каждые 20 сообщений
|
||||
let newSummary = session.storySummary;
|
||||
if (allMessages.length % 20 === 0 && allMessages.length > 0) {
|
||||
console.log("[GamePage] Generating story summary...");
|
||||
newSummary = await generateStorySummary(
|
||||
story,
|
||||
allMessages,
|
||||
session.storySummary,
|
||||
);
|
||||
}
|
||||
|
||||
const finalSession: GameSession = {
|
||||
...session,
|
||||
messages: allMessages,
|
||||
keyEvents: newKeyEvents,
|
||||
storySummary: newSummary,
|
||||
};
|
||||
|
||||
const saved = await apiSaveSession(story.id, finalSession);
|
||||
console.log(
|
||||
"[GamePage] Session saved:",
|
||||
saved,
|
||||
"Messages:",
|
||||
allMessages.length,
|
||||
);
|
||||
setSession(finalSession);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Произошла ошибка");
|
||||
// Откатываем сообщение пользователя при ошибке
|
||||
setSession(session);
|
||||
setInput(userMessage.content);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickAction = (action: string) => {
|
||||
setInput(action);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<div className="game-page">
|
||||
<div className="game-loading">
|
||||
<p>Загрузка игры...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!story) {
|
||||
return (
|
||||
<div className="game-page">
|
||||
<div className="game-error">
|
||||
<h2>История не найдена</h2>
|
||||
<Link to="/" className="back-link">
|
||||
← Вернуться к списку
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-page">
|
||||
<header className="game-header">
|
||||
<Link to={`/story/${story.id}`} className="back-btn">
|
||||
←
|
||||
</Link>
|
||||
<div className="header-info">
|
||||
<h1>{story.title}</h1>
|
||||
<span className="header-protagonist">
|
||||
👤 {playerCharacter?.name || "Герой"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="header-stats">
|
||||
<span className="stat-badge tokens">
|
||||
🎟️ {formatTokens(estimateTokens(session?.messages || []))}
|
||||
</span>
|
||||
<span className="stat-badge location">
|
||||
📍 {detectLocation(session?.messages || [])}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="game-content">
|
||||
<div className="messages-container">
|
||||
{session?.messages.map((message) => (
|
||||
<div key={message.id} className={`message ${message.role}`}>
|
||||
<div className="message-content">
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
</div>
|
||||
<span className="message-time">
|
||||
{new Date(message.timestamp).toLocaleTimeString("ru-RU", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="message assistant loading">
|
||||
<div className="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<span>⚠️ {error}</span>
|
||||
<button onClick={() => setError(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* RPG кнопки скрыты — раскомментировать при необходимости
|
||||
<div className="quick-actions">
|
||||
<button onClick={() => handleQuickAction("Осмотреться вокруг")}>
|
||||
👀 Осмотреться
|
||||
</button>
|
||||
<button onClick={() => handleQuickAction("Проверить инвентарь")}>
|
||||
🎒 Инвентарь
|
||||
</button>
|
||||
<button onClick={() => handleQuickAction("Поговорить с кем-нибудь")}>
|
||||
💬 Говорить
|
||||
</button>
|
||||
<button onClick={() => handleQuickAction("Идти вперёд")}>
|
||||
🚶 Идти
|
||||
</button>
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<div className="input-container">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Что ты хочешь сделать?..."
|
||||
disabled={isLoading}
|
||||
rows={2}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="send-btn"
|
||||
>
|
||||
{isLoading ? "⏳" : "➤"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
.stories-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.stories-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stories-header h1 {
|
||||
font-size: 2.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stories-header p {
|
||||
color: #888;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stories-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #333;
|
||||
border-radius: 12px;
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: #1a1a1a;
|
||||
border-radius: 20px;
|
||||
border: 2px dashed #333;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
color: #ccc;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #888;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stories-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.story-card {
|
||||
background: #1a1a1a;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #333;
|
||||
transition:
|
||||
transform 0.3s,
|
||||
box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.story-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.story-cover {
|
||||
height: 160px;
|
||||
background: linear-gradient(135deg, #2d1f3d 0%, #1a1a2e 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
font-size: 4rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.nsfw-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: linear-gradient(135deg, #ff4757 0%, #ff6b6b 100%);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 2px 8px rgba(255, 71, 87, 0.4);
|
||||
}
|
||||
|
||||
.story-content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.story-content h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.story-genres {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.genre-tag {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: #a0b4ff;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.story-description {
|
||||
color: #aaa;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.story-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.story-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
background: #2a2a2a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.btn-view:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.btn-play {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-play:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #2a2a2a;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { getStories, deleteStory as apiDeleteStory } from "../services/api";
|
||||
import type { Story } from "../types";
|
||||
import "./StoriesPage.css";
|
||||
|
||||
export default function StoriesPage() {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const [stories, setStories] = useState<Story[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStories = async () => {
|
||||
if (!isAuthenticated) {
|
||||
setStories([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const data = await getStories();
|
||||
// Преобразуем _id в id для совместимости
|
||||
const normalizedStories = data.map((s: any) => ({
|
||||
...s,
|
||||
id: s._id || s.id,
|
||||
}));
|
||||
setStories(normalizedStories);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
if (!authLoading) {
|
||||
loadStories();
|
||||
}
|
||||
}, [isAuthenticated, authLoading]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm("Удалить эту историю?")) {
|
||||
const success = await apiDeleteStory(id);
|
||||
if (success) {
|
||||
setStories(stories.filter((s) => s.id !== id));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredStories = stories.filter(
|
||||
(story) =>
|
||||
story.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
story.genre.some((g) =>
|
||||
g.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="stories-page">
|
||||
<header className="stories-header">
|
||||
<h1>📚 Мои Исекай Истории</h1>
|
||||
<p>Создавай и играй в уникальные приключения</p>
|
||||
</header>
|
||||
|
||||
{!isAuthenticated ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">🔐</div>
|
||||
<h2>Войдите, чтобы начать</h2>
|
||||
<p>Авторизуйтесь через Discord для создания и сохранения историй</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="loading-state">
|
||||
<div className="loading-spinner">⏳</div>
|
||||
<p>Загрузка историй...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="stories-controls">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="🔍 Поиск историй..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
<Link to="/create" className="create-btn">
|
||||
✨ Создать историю
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{filteredStories.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📖</div>
|
||||
<h2>Нет историй</h2>
|
||||
<p>Создайте свою первую исекай историю!</p>
|
||||
<Link to="/create" className="create-btn">
|
||||
✨ Создать историю
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stories-grid">
|
||||
{filteredStories.map((story) => (
|
||||
<div key={story.id} className="story-card">
|
||||
<div
|
||||
className="story-cover"
|
||||
style={{
|
||||
backgroundImage: story.coverImage
|
||||
? `url(${story.coverImage})`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{!story.coverImage && (
|
||||
<span className="cover-placeholder">⚔️</span>
|
||||
)}
|
||||
{story.isNsfw && (
|
||||
<span className="nsfw-badge">🔞 NSFW</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="story-content">
|
||||
<h3>{story.title}</h3>
|
||||
<div className="story-genres">
|
||||
{story.genre.map((g) => (
|
||||
<span key={g} className="genre-tag">
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="story-description">{story.description}</p>
|
||||
<div className="story-meta">
|
||||
<span>🌍 {story.world.name}</span>
|
||||
<span>👤 {story.player?.name || "Герой"}</span>
|
||||
</div>
|
||||
<div className="story-actions">
|
||||
<Link to={`/story/${story.id}`} className="btn btn-view">
|
||||
📖 Подробнее
|
||||
</Link>
|
||||
<Link to={`/play/${story.id}`} className="btn btn-play">
|
||||
🎮 Играть
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(story.id)}
|
||||
className="btn btn-delete"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
.story-detail-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
color: #888;
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
.story-hero {
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2rem;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, #2d1f3d 0%, #1a1a2e 100%);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.hero-bg::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(26, 26, 26, 0.95) 0%,
|
||||
rgba(26, 26, 26, 0.4) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 3rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.hero-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hero-genres {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nsfw-hero-badge {
|
||||
padding: 0.4rem 1rem;
|
||||
background: linear-gradient(135deg, #ff4757 0%, #ff6b6b 100%);
|
||||
color: white;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 10px rgba(255, 71, 87, 0.4);
|
||||
}
|
||||
|
||||
.genre-badge {
|
||||
padding: 0.35rem 1rem;
|
||||
background: rgba(102, 126, 234, 0.3);
|
||||
color: #a0b4ff;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hero-content h1 {
|
||||
font-size: 2.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
background: linear-gradient(135deg, #fff 0%, #ccc 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-setting {
|
||||
color: #aaa;
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hero-settings {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.setting-badge {
|
||||
padding: 0.3rem 0.8rem;
|
||||
background: rgba(255, 179, 71, 0.2);
|
||||
color: #ffb347;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.story-details {
|
||||
background: #1a1a1a;
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.detail-section:last-of-type {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-section h2 {
|
||||
color: #fff;
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-section p {
|
||||
color: #bbb;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.protagonist-card {
|
||||
background: #222;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.protagonist-card h3 {
|
||||
color: #667eea;
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.protagonist-card p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.abilities {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.abilities strong {
|
||||
color: #fff;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.abilities-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ability-tag {
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: rgba(118, 75, 162, 0.2);
|
||||
color: #c9a0ff;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.world-rules {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.world-rules strong {
|
||||
color: #fff;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.world-rules ul {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.world-rules li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.session-info {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem !important;
|
||||
border: 1px solid rgba(102, 126, 234, 0.3) !important;
|
||||
}
|
||||
|
||||
.session-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.story-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.875rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
color: #ff6b6b;
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
}
|
||||
|
||||
.story-meta-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #333;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Персонажи */
|
||||
.characters-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.character-item {
|
||||
background: #222;
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.character-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.character-item h4 {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: #a0b4ff;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.character-item p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.hero-content h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.story-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Модальное окно выбора персонажа */
|
||||
.character-select-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.character-select-modal {
|
||||
background: #1a1a1a;
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.character-select-modal h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: #fff;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.select-hint {
|
||||
color: #888;
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.character-select-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.character-select-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #222;
|
||||
border-radius: 12px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.character-select-card:hover {
|
||||
border-color: #444;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.character-select-card.selected {
|
||||
border-color: #667eea;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.character-select-card.favorite {
|
||||
border-color: rgba(255, 215, 0, 0.4);
|
||||
}
|
||||
|
||||
.favorite-star {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.character-select-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.character-select-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.character-select-avatar span {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.character-select-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.character-select-info h4 {
|
||||
margin: 0 0 0.25rem;
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.character-select-info p {
|
||||
margin: 0;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check-mark {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: #667eea;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.character-select-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.character-select-actions .btn-cancel {
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #2a2a2a;
|
||||
color: #aaa;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.character-select-actions .btn-cancel:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.character-select-actions .btn-create {
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: rgba(118, 75, 162, 0.2);
|
||||
color: #c9a0ff;
|
||||
border: 1px solid rgba(118, 75, 162, 0.4);
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.character-select-actions .btn-create:hover {
|
||||
background: rgba(118, 75, 162, 0.3);
|
||||
}
|
||||
|
||||
.character-select-actions .btn-confirm {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.character-select-actions .btn-confirm:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.character-select-actions .btn-confirm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Summary section */
|
||||
.summary-section {
|
||||
text-align: center;
|
||||
padding: 1.5rem !important;
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
font-size: 1.15rem;
|
||||
font-style: italic;
|
||||
color: #ccc;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Plot content */
|
||||
.plot-content {
|
||||
color: #bbb;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.plot-content p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.plot-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import {
|
||||
getStory,
|
||||
deleteStory as apiDeleteStory,
|
||||
getSession,
|
||||
getPlayerCharacters,
|
||||
} from "../services/api";
|
||||
import type { Story, GameSession, PlayerCharacter } from "../types";
|
||||
import "./StoryDetailPage.css";
|
||||
|
||||
export default function StoryDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [story, setStory] = useState<Story | null>(null);
|
||||
const [session, setSession] = useState<GameSession | null>(null);
|
||||
const [playerCharacters, setPlayerCharacters] = useState<PlayerCharacter[]>(
|
||||
[],
|
||||
);
|
||||
const [selectedCharacter, setSelectedCharacter] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [showCharacterSelect, setShowCharacterSelect] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStory = async () => {
|
||||
if (!id || !isAuthenticated) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const foundStory = await getStory(id);
|
||||
if (foundStory) {
|
||||
setStory({
|
||||
...foundStory,
|
||||
id: (foundStory as any)._id || foundStory.id,
|
||||
});
|
||||
const existingSession = await getSession(id);
|
||||
if (existingSession) {
|
||||
setSession(existingSession);
|
||||
setSelectedCharacter(existingSession.playerId || null);
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем персонажей пользователя
|
||||
const characters = await getPlayerCharacters();
|
||||
setPlayerCharacters(characters);
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadStory();
|
||||
}, [id, isAuthenticated]);
|
||||
|
||||
// Получаем имя персонажа-фаворита для замены {user}
|
||||
const favoriteCharacter = playerCharacters.find((c) => c.isFavorite);
|
||||
const replaceUserPlaceholder = (text: string) => {
|
||||
if (!favoriteCharacter) return text;
|
||||
return text.replace(/\{user\}/gi, favoriteCharacter.name);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (story && confirm("Удалить эту историю и все связанные данные?")) {
|
||||
const success = await apiDeleteStory(story.id);
|
||||
if (success) {
|
||||
navigate("/");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartGame = () => {
|
||||
if (playerCharacters.length === 0) {
|
||||
// Нет персонажей — предлагаем создать
|
||||
if (confirm("У вас нет персонажей. Хотите создать персонажа для игры?")) {
|
||||
navigate("/characters");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
// Новая игра — показываем выбор персонажа
|
||||
setShowCharacterSelect(true);
|
||||
} else {
|
||||
// Продолжаем с тем же персонажем
|
||||
navigate(`/play/${story!.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmCharacter = () => {
|
||||
if (!selectedCharacter) {
|
||||
alert("Выберите персонажа для игры");
|
||||
return;
|
||||
}
|
||||
navigate(`/play/${story!.id}?character=${selectedCharacter}`);
|
||||
};
|
||||
|
||||
// Примерный подсчёт токенов (1 токен ≈ 3 символа для русского текста)
|
||||
const estimateTokens = (messages: typeof session.messages) => {
|
||||
if (!messages || messages.length === 0) return 0;
|
||||
const totalChars = messages.reduce(
|
||||
(sum, msg) => sum + msg.content.length,
|
||||
0,
|
||||
);
|
||||
return Math.round(totalChars / 3);
|
||||
};
|
||||
|
||||
const formatTokens = (tokens: number) => {
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
|
||||
return tokens.toString();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="story-detail-page">
|
||||
<div className="loading-state">
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!story) {
|
||||
return (
|
||||
<div className="story-detail-page">
|
||||
<div className="not-found">
|
||||
<h2>История не найдена</h2>
|
||||
<Link to="/" className="back-link">
|
||||
← Вернуться к списку
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="story-detail-page">
|
||||
<Link to="/" className="back-link">
|
||||
← Назад к историям
|
||||
</Link>
|
||||
|
||||
{/* Модальное окно выбора персонажа */}
|
||||
{showCharacterSelect && (
|
||||
<div className="character-select-overlay">
|
||||
<div className="character-select-modal">
|
||||
<h2>👤 Выберите персонажа</h2>
|
||||
<p className="select-hint">
|
||||
Выберите, за кого вы хотите играть в этой истории
|
||||
</p>
|
||||
|
||||
<div className="character-select-grid">
|
||||
{[...playerCharacters]
|
||||
.sort((a, b) => {
|
||||
if (a.isFavorite && !b.isFavorite) return -1;
|
||||
if (!a.isFavorite && b.isFavorite) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map((char) => (
|
||||
<div
|
||||
key={char.id}
|
||||
className={`character-select-card ${selectedCharacter === char.id ? "selected" : ""} ${char.isFavorite ? "favorite" : ""}`}
|
||||
onClick={() => setSelectedCharacter(char.id)}
|
||||
>
|
||||
{char.isFavorite && (
|
||||
<span className="favorite-star">⭐</span>
|
||||
)}
|
||||
<div className="character-select-avatar">
|
||||
{char.avatarUrl ? (
|
||||
<img src={char.avatarUrl} alt={char.name} />
|
||||
) : (
|
||||
<span>👤</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="character-select-info">
|
||||
<h4>{char.name}</h4>
|
||||
{char.description && (
|
||||
<p>
|
||||
{char.description
|
||||
.replace(/\{user\}/gi, char.name)
|
||||
.substring(0, 80)}
|
||||
...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedCharacter === char.id && (
|
||||
<div className="check-mark">✓</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="character-select-actions">
|
||||
<button
|
||||
className="btn-cancel"
|
||||
onClick={() => setShowCharacterSelect(false)}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<Link to="/characters" className="btn-create">
|
||||
+ Создать нового
|
||||
</Link>
|
||||
<button
|
||||
className="btn-confirm"
|
||||
onClick={handleConfirmCharacter}
|
||||
disabled={!selectedCharacter}
|
||||
>
|
||||
Начать игру
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="story-hero">
|
||||
<div
|
||||
className="hero-bg"
|
||||
style={{
|
||||
backgroundImage: story.coverImage
|
||||
? `url(${story.coverImage})`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<div className="hero-content">
|
||||
<div className="hero-badges">
|
||||
{story.isNsfw && <span className="nsfw-hero-badge">🔞 NSFW</span>}
|
||||
{story.genre.map((g) => (
|
||||
<span key={g} className="genre-badge">
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h1>{story.title}</h1>
|
||||
<div className="hero-settings">
|
||||
{Array.isArray(story.setting) ? (
|
||||
story.setting.map((s) => (
|
||||
<span key={s} className="setting-badge">
|
||||
🏰 {s}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="setting-badge">🏰 {story.setting}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="story-details">
|
||||
{story.summary && (
|
||||
<section className="detail-section summary-section">
|
||||
<p className="summary-text">
|
||||
{replaceUserPlaceholder(story.summary)}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{story.plot && (
|
||||
<section className="detail-section">
|
||||
<h2>📜 Сюжет</h2>
|
||||
<div className="plot-content">
|
||||
{replaceUserPlaceholder(story.plot)
|
||||
.split("\n")
|
||||
.map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{story.characters && story.characters.length > 0 && (
|
||||
<section className="detail-section">
|
||||
<h2>👥 Персонажи мира</h2>
|
||||
<div className="characters-grid">
|
||||
{story.characters.map((char, i) => (
|
||||
<div key={i} className="character-item">
|
||||
<div className="character-item-header">
|
||||
<h4>{char.name}</h4>
|
||||
<span className="role-badge">{char.role}</span>
|
||||
</div>
|
||||
<p>{char.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="detail-section">
|
||||
<h2>🌐 Мир: {story.world.name}</h2>
|
||||
<p>{story.world.description}</p>
|
||||
{story.world.rules.length > 0 && (
|
||||
<div className="world-rules">
|
||||
<strong>Правила мира:</strong>
|
||||
<ul>
|
||||
{story.world.rules.map((rule, i) => (
|
||||
<li key={i}>{rule}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{session && (
|
||||
<section className="detail-section session-info">
|
||||
<h2>🎮 Текущий прогресс</h2>
|
||||
<div className="session-stats">
|
||||
<div className="stat">
|
||||
<span className="stat-label">Сообщений</span>
|
||||
<span className="stat-value">{session.messages.length}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Локация</span>
|
||||
<span className="stat-value">
|
||||
{session.currentState.location || "Неизвестно"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">≈ Токенов</span>
|
||||
<span className="stat-value">
|
||||
{formatTokens(estimateTokens(session.messages))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="story-actions">
|
||||
<button onClick={handleStartGame} className="action-btn play-btn">
|
||||
{session ? "🎮 Продолжить игру" : "🎮 Начать приключение"}
|
||||
</button>
|
||||
<Link to={`/edit/${story.id}`} className="action-btn edit-btn">
|
||||
✏️ Редактировать
|
||||
</Link>
|
||||
<button onClick={handleDelete} className="action-btn delete-btn">
|
||||
🗑️ Удалить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="story-meta-footer">
|
||||
<span>
|
||||
Создано: {new Date(story.createdAt).toLocaleDateString("ru-RU")}
|
||||
</span>
|
||||
<span>
|
||||
Обновлено: {new Date(story.updatedAt).toLocaleDateString("ru-RU")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user