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
+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>
);
}