349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|