first commit
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import {
|
||||
getStory,
|
||||
deleteStory as apiDeleteStory,
|
||||
getSession,
|
||||
getPlayerCharacters,
|
||||
} from "../services/api";
|
||||
import type { Story, GameSession, PlayerCharacter } from "../types";
|
||||
import "./StoryDetailPage.css";
|
||||
|
||||
export default function StoryDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [story, setStory] = useState<Story | null>(null);
|
||||
const [session, setSession] = useState<GameSession | null>(null);
|
||||
const [playerCharacters, setPlayerCharacters] = useState<PlayerCharacter[]>(
|
||||
[],
|
||||
);
|
||||
const [selectedCharacter, setSelectedCharacter] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [showCharacterSelect, setShowCharacterSelect] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStory = async () => {
|
||||
if (!id || !isAuthenticated) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const foundStory = await getStory(id);
|
||||
if (foundStory) {
|
||||
setStory({
|
||||
...foundStory,
|
||||
id: (foundStory as any)._id || foundStory.id,
|
||||
});
|
||||
const existingSession = await getSession(id);
|
||||
if (existingSession) {
|
||||
setSession(existingSession);
|
||||
setSelectedCharacter(existingSession.playerId || null);
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем персонажей пользователя
|
||||
const characters = await getPlayerCharacters();
|
||||
setPlayerCharacters(characters);
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadStory();
|
||||
}, [id, isAuthenticated]);
|
||||
|
||||
// Получаем имя персонажа-фаворита для замены {user}
|
||||
const favoriteCharacter = playerCharacters.find((c) => c.isFavorite);
|
||||
const replaceUserPlaceholder = (text: string) => {
|
||||
if (!favoriteCharacter) return text;
|
||||
return text.replace(/\{user\}/gi, favoriteCharacter.name);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (story && confirm("Удалить эту историю и все связанные данные?")) {
|
||||
const success = await apiDeleteStory(story.id);
|
||||
if (success) {
|
||||
navigate("/");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartGame = () => {
|
||||
if (playerCharacters.length === 0) {
|
||||
// Нет персонажей — предлагаем создать
|
||||
if (confirm("У вас нет персонажей. Хотите создать персонажа для игры?")) {
|
||||
navigate("/characters");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
// Новая игра — показываем выбор персонажа
|
||||
setShowCharacterSelect(true);
|
||||
} else {
|
||||
// Продолжаем с тем же персонажем
|
||||
navigate(`/play/${story!.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmCharacter = () => {
|
||||
if (!selectedCharacter) {
|
||||
alert("Выберите персонажа для игры");
|
||||
return;
|
||||
}
|
||||
navigate(`/play/${story!.id}?character=${selectedCharacter}`);
|
||||
};
|
||||
|
||||
// Примерный подсчёт токенов (1 токен ≈ 3 символа для русского текста)
|
||||
const estimateTokens = (messages: typeof session.messages) => {
|
||||
if (!messages || messages.length === 0) return 0;
|
||||
const totalChars = messages.reduce(
|
||||
(sum, msg) => sum + msg.content.length,
|
||||
0,
|
||||
);
|
||||
return Math.round(totalChars / 3);
|
||||
};
|
||||
|
||||
const formatTokens = (tokens: number) => {
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
|
||||
return tokens.toString();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="story-detail-page">
|
||||
<div className="loading-state">
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!story) {
|
||||
return (
|
||||
<div className="story-detail-page">
|
||||
<div className="not-found">
|
||||
<h2>История не найдена</h2>
|
||||
<Link to="/" className="back-link">
|
||||
← Вернуться к списку
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="story-detail-page">
|
||||
<Link to="/" className="back-link">
|
||||
← Назад к историям
|
||||
</Link>
|
||||
|
||||
{/* Модальное окно выбора персонажа */}
|
||||
{showCharacterSelect && (
|
||||
<div className="character-select-overlay">
|
||||
<div className="character-select-modal">
|
||||
<h2>👤 Выберите персонажа</h2>
|
||||
<p className="select-hint">
|
||||
Выберите, за кого вы хотите играть в этой истории
|
||||
</p>
|
||||
|
||||
<div className="character-select-grid">
|
||||
{[...playerCharacters]
|
||||
.sort((a, b) => {
|
||||
if (a.isFavorite && !b.isFavorite) return -1;
|
||||
if (!a.isFavorite && b.isFavorite) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map((char) => (
|
||||
<div
|
||||
key={char.id}
|
||||
className={`character-select-card ${selectedCharacter === char.id ? "selected" : ""} ${char.isFavorite ? "favorite" : ""}`}
|
||||
onClick={() => setSelectedCharacter(char.id)}
|
||||
>
|
||||
{char.isFavorite && (
|
||||
<span className="favorite-star">⭐</span>
|
||||
)}
|
||||
<div className="character-select-avatar">
|
||||
{char.avatarUrl ? (
|
||||
<img src={char.avatarUrl} alt={char.name} />
|
||||
) : (
|
||||
<span>👤</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="character-select-info">
|
||||
<h4>{char.name}</h4>
|
||||
{char.description && (
|
||||
<p>
|
||||
{char.description
|
||||
.replace(/\{user\}/gi, char.name)
|
||||
.substring(0, 80)}
|
||||
...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedCharacter === char.id && (
|
||||
<div className="check-mark">✓</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="character-select-actions">
|
||||
<button
|
||||
className="btn-cancel"
|
||||
onClick={() => setShowCharacterSelect(false)}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<Link to="/characters" className="btn-create">
|
||||
+ Создать нового
|
||||
</Link>
|
||||
<button
|
||||
className="btn-confirm"
|
||||
onClick={handleConfirmCharacter}
|
||||
disabled={!selectedCharacter}
|
||||
>
|
||||
Начать игру
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="story-hero">
|
||||
<div
|
||||
className="hero-bg"
|
||||
style={{
|
||||
backgroundImage: story.coverImage
|
||||
? `url(${story.coverImage})`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<div className="hero-content">
|
||||
<div className="hero-badges">
|
||||
{story.isNsfw && <span className="nsfw-hero-badge">🔞 NSFW</span>}
|
||||
{story.genre.map((g) => (
|
||||
<span key={g} className="genre-badge">
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h1>{story.title}</h1>
|
||||
<div className="hero-settings">
|
||||
{Array.isArray(story.setting) ? (
|
||||
story.setting.map((s) => (
|
||||
<span key={s} className="setting-badge">
|
||||
🏰 {s}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="setting-badge">🏰 {story.setting}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="story-details">
|
||||
{story.summary && (
|
||||
<section className="detail-section summary-section">
|
||||
<p className="summary-text">
|
||||
{replaceUserPlaceholder(story.summary)}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{story.plot && (
|
||||
<section className="detail-section">
|
||||
<h2>📜 Сюжет</h2>
|
||||
<div className="plot-content">
|
||||
{replaceUserPlaceholder(story.plot)
|
||||
.split("\n")
|
||||
.map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{story.characters && story.characters.length > 0 && (
|
||||
<section className="detail-section">
|
||||
<h2>👥 Персонажи мира</h2>
|
||||
<div className="characters-grid">
|
||||
{story.characters.map((char, i) => (
|
||||
<div key={i} className="character-item">
|
||||
<div className="character-item-header">
|
||||
<h4>{char.name}</h4>
|
||||
<span className="role-badge">{char.role}</span>
|
||||
</div>
|
||||
<p>{char.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="detail-section">
|
||||
<h2>🌐 Мир: {story.world.name}</h2>
|
||||
<p>{story.world.description}</p>
|
||||
{story.world.rules.length > 0 && (
|
||||
<div className="world-rules">
|
||||
<strong>Правила мира:</strong>
|
||||
<ul>
|
||||
{story.world.rules.map((rule, i) => (
|
||||
<li key={i}>{rule}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{session && (
|
||||
<section className="detail-section session-info">
|
||||
<h2>🎮 Текущий прогресс</h2>
|
||||
<div className="session-stats">
|
||||
<div className="stat">
|
||||
<span className="stat-label">Сообщений</span>
|
||||
<span className="stat-value">{session.messages.length}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Локация</span>
|
||||
<span className="stat-value">
|
||||
{session.currentState.location || "Неизвестно"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">≈ Токенов</span>
|
||||
<span className="stat-value">
|
||||
{formatTokens(estimateTokens(session.messages))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="story-actions">
|
||||
<button onClick={handleStartGame} className="action-btn play-btn">
|
||||
{session ? "🎮 Продолжить игру" : "🎮 Начать приключение"}
|
||||
</button>
|
||||
<Link to={`/edit/${story.id}`} className="action-btn edit-btn">
|
||||
✏️ Редактировать
|
||||
</Link>
|
||||
<button onClick={handleDelete} className="action-btn delete-btn">
|
||||
🗑️ Удалить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="story-meta-footer">
|
||||
<span>
|
||||
Создано: {new Date(story.createdAt).toLocaleDateString("ru-RU")}
|
||||
</span>
|
||||
<span>
|
||||
Обновлено: {new Date(story.updatedAt).toLocaleDateString("ru-RU")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user