first commit

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