Files
ReSekai/src/pages/StoryDetailPage.tsx
T

349 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import {
getStory,
deleteStory as apiDeleteStory,
getSessionsList,
getPlayerCharacters,
} from "../services/api";
import type { Story, PlayerCharacter } from "../types";
import "./StoryDetailPage.css";
interface SessionsInfo {
count: number;
totalMessages: number;
}
export default function StoryDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const [story, setStory] = useState<Story | null>(null);
const [sessionsInfo, setSessionsInfo] = useState<SessionsInfo | 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 sessions = await getSessionsList(id);
if (sessions.length > 0) {
const totalMessages = sessions.reduce((sum, s) => sum + s.messagesCount, 0);
setSessionsInfo({
count: sessions.length,
totalMessages,
});
}
}
// Загружаем персонажей пользователя
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 (!sessionsInfo) {
// Новая игра — показываем выбор персонажа
setShowCharacterSelect(true);
} else {
// Продолжаем с тем же персонажем
navigate(`/play/${story!.id}`);
}
};
const handleConfirmCharacter = () => {
if (!selectedCharacter) {
alert("Выберите персонажа для игры");
return;
}
navigate(`/play/${story!.id}?character=${selectedCharacter}`);
};
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>
{sessionsInfo && (
<section className="detail-section session-info">
<h2>🎮 Текущий прогресс</h2>
<div className="session-stats">
<div className="stat">
<span className="stat-label">Сессий</span>
<span className="stat-value">{sessionsInfo.count}</span>
</div>
<div className="stat">
<span className="stat-label">Сообщений</span>
<span className="stat-value">{sessionsInfo.totalMessages}</span>
</div>
<div className="stat">
<span className="stat-label"> Токенов</span>
<span className="stat-value">
{formatTokens(sessionsInfo.totalMessages * 50)}
</span>
</div>
</div>
</section>
)}
<div className="story-actions">
<button onClick={handleStartGame} className="action-btn play-btn">
{sessionsInfo ? "🎮 Продолжить игру" : "🎮 Начать приключение"}
</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>
);
}