fix: input alignment
This commit is contained in:
+2
-2
@@ -493,7 +493,7 @@ app.get("/api/admin/stats", requireAuth, async (req, res) => {
|
|||||||
// Считаем статистику для каждой истории
|
// Считаем статистику для каждой истории
|
||||||
const storyStats = userStories.map((story) => {
|
const storyStats = userStories.map((story) => {
|
||||||
const session = userSessions.find(
|
const session = userSessions.find(
|
||||||
(s) => s.storyId === story._id.toString()
|
(s) => s.storyId === story._id.toString(),
|
||||||
);
|
);
|
||||||
const messages = session?.messages || [];
|
const messages = session?.messages || [];
|
||||||
const messageCount = messages.length;
|
const messageCount = messages.length;
|
||||||
@@ -501,7 +501,7 @@ app.get("/api/admin/stats", requireAuth, async (req, res) => {
|
|||||||
// Примерный подсчёт токенов (1 токен ≈ 3 символа для русского)
|
// Примерный подсчёт токенов (1 токен ≈ 3 символа для русского)
|
||||||
const totalChars = messages.reduce(
|
const totalChars = messages.reduce(
|
||||||
(sum, msg) => sum + (msg.content?.length || 0),
|
(sum, msg) => sum + (msg.content?.length || 0),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
const tokens = Math.round(totalChars / 3);
|
const tokens = Math.round(totalChars / 3);
|
||||||
|
|
||||||
|
|||||||
+26
-15
@@ -1,6 +1,7 @@
|
|||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom";
|
||||||
import { AuthProvider } from "./contexts/AuthContext";
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
import { Header } from "./components/Header";
|
import { Header } from "./components/Header";
|
||||||
|
import { Footer } from "./components/Footer";
|
||||||
import StoriesPage from "./pages/StoriesPage";
|
import StoriesPage from "./pages/StoriesPage";
|
||||||
import StoryDetailPage from "./pages/StoryDetailPage";
|
import StoryDetailPage from "./pages/StoryDetailPage";
|
||||||
import CreateStoryPage from "./pages/CreateStoryPage";
|
import CreateStoryPage from "./pages/CreateStoryPage";
|
||||||
@@ -9,24 +10,34 @@ import GamePage from "./pages/GamePage";
|
|||||||
import AdminPage from "./pages/AdminPage";
|
import AdminPage from "./pages/AdminPage";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const location = useLocation();
|
||||||
|
const isGamePage = location.pathname.startsWith("/play/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
{!isGamePage && <Header />}
|
||||||
|
<main className="main-content">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<StoriesPage />} />
|
||||||
|
<Route path="/story/:id" element={<StoryDetailPage />} />
|
||||||
|
<Route path="/create" element={<CreateStoryPage />} />
|
||||||
|
<Route path="/edit/:id" element={<CreateStoryPage />} />
|
||||||
|
<Route path="/characters" element={<CharactersPage />} />
|
||||||
|
<Route path="/play/:id" element={<GamePage />} />
|
||||||
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
{!isGamePage && <Footer />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<div className="app">
|
<AppContent />
|
||||||
<Header />
|
|
||||||
<main className="main-content">
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<StoriesPage />} />
|
|
||||||
<Route path="/story/:id" element={<StoryDetailPage />} />
|
|
||||||
<Route path="/create" element={<CreateStoryPage />} />
|
|
||||||
<Route path="/edit/:id" element={<CreateStoryPage />} />
|
|
||||||
<Route path="/characters" element={<CharactersPage />} />
|
|
||||||
<Route path="/play/:id" element={<GamePage />} />
|
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
.footer {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-top: 1px solid #2a2a2a;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brand {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-copyright {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(100, 181, 246, 0.1);
|
||||||
|
border: 1px solid rgba(100, 181, 246, 0.2);
|
||||||
|
border-radius: 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-stats:hover {
|
||||||
|
background: rgba(100, 181, 246, 0.15);
|
||||||
|
border-color: rgba(100, 181, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-value {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64b5f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.footer-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-stats {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { getAdminStats } from "../services/api";
|
||||||
|
import "./Footer.css";
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const [totalTokens, setTotalTokens] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadStats = async () => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
const stats = await getAdminStats();
|
||||||
|
if (stats) {
|
||||||
|
setTotalTokens(stats.totalTokens);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadStats();
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
const formatTokens = (tokens: number) => {
|
||||||
|
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(2)}M`;
|
||||||
|
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
|
||||||
|
return tokens.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="footer">
|
||||||
|
<div className="footer-content">
|
||||||
|
<div className="footer-left">
|
||||||
|
<span className="footer-brand">⚔️ ReSekai</span>
|
||||||
|
<span className="footer-copyright">© 2026</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAuthenticated && (
|
||||||
|
<Link to="/admin" className="footer-stats">
|
||||||
|
<span className="stats-icon">📊</span>
|
||||||
|
<span className="stats-label">Токенов:</span>
|
||||||
|
<span className="stats-value">
|
||||||
|
{totalTokens !== null ? formatTokens(totalTokens) : "..."}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,9 +26,6 @@ export function Header() {
|
|||||||
<Link to="/characters" className="nav-link">
|
<Link to="/characters" className="nav-link">
|
||||||
Персонажи
|
Персонажи
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/admin" className="nav-link">
|
|
||||||
📊 Статистика
|
|
||||||
</Link>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -43,7 +43,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition:
|
||||||
|
transform 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card:hover {
|
||||||
|
|||||||
+11
-6
@@ -34,7 +34,7 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getAdminStats();
|
const data = await getAdminStats();
|
||||||
@@ -116,14 +116,16 @@ export default function AdminPage() {
|
|||||||
<div className="stat-card highlight">
|
<div className="stat-card highlight">
|
||||||
<span className="stat-icon">🔤</span>
|
<span className="stat-icon">🔤</span>
|
||||||
<div className="stat-info">
|
<div className="stat-info">
|
||||||
<span className="stat-value">{formatTokens(stats.totalTokens)}</span>
|
<span className="stat-value">
|
||||||
|
{formatTokens(stats.totalTokens)}
|
||||||
|
</span>
|
||||||
<span className="stat-label">Всего токенов</span>
|
<span className="stat-label">Всего токенов</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Статистика по историям</h2>
|
<h2>Статистика по историям</h2>
|
||||||
|
|
||||||
<div className="stories-table-wrapper">
|
<div className="stories-table-wrapper">
|
||||||
<table className="stories-table">
|
<table className="stories-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -137,7 +139,9 @@ export default function AdminPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{stats.stories.length === 0 ? (
|
{stats.stories.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="no-data">Нет историй</td>
|
<td colSpan={4} className="no-data">
|
||||||
|
Нет историй
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
stats.stories.map((story) => (
|
stats.stories.map((story) => (
|
||||||
@@ -155,8 +159,9 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
<div className="token-info">
|
<div className="token-info">
|
||||||
<p>
|
<p>
|
||||||
💡 <strong>Примечание:</strong> Токены рассчитаны приблизительно (1 токен ≈ 3 символа для русского текста).
|
💡 <strong>Примечание:</strong> Токены рассчитаны приблизительно (1
|
||||||
Реальное потребление может отличаться.
|
токен ≈ 3 символа для русского текста). Реальное потребление может
|
||||||
|
отличаться.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -288,6 +288,7 @@
|
|||||||
|
|
||||||
.input-container {
|
.input-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 1rem 1.5rem 1.5rem;
|
padding: 1rem 1.5rem 1.5rem;
|
||||||
background: #131313;
|
background: #131313;
|
||||||
@@ -296,6 +297,8 @@
|
|||||||
|
|
||||||
.input-container textarea {
|
.input-container textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 50px;
|
||||||
|
max-height: 120px;
|
||||||
padding: 0.875rem 1rem;
|
padding: 0.875rem 1rem;
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
border: 2px solid #333;
|
border: 2px solid #333;
|
||||||
@@ -318,6 +321,7 @@
|
|||||||
|
|
||||||
.send-btn {
|
.send-btn {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
|
min-width: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
border: none;
|
border: none;
|
||||||
@@ -328,7 +332,7 @@
|
|||||||
transition:
|
transition:
|
||||||
transform 0.2s,
|
transform 0.2s,
|
||||||
opacity 0.2s;
|
opacity 0.2s;
|
||||||
align-self: flex-end;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-btn:hover:not(:disabled) {
|
.send-btn:hover:not(:disabled) {
|
||||||
|
|||||||
+1
-63
@@ -1,9 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import {
|
import { useParams, Link, useSearchParams } from "react-router-dom";
|
||||||
useParams,
|
|
||||||
Link,
|
|
||||||
useSearchParams,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import {
|
import {
|
||||||
@@ -44,61 +40,6 @@ function formatTokens(tokens: number): string {
|
|||||||
return tokens.toString();
|
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() {
|
export default function GamePage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -409,9 +350,6 @@ export default function GamePage() {
|
|||||||
<span className="stat-badge tokens">
|
<span className="stat-badge tokens">
|
||||||
🎟️ {formatTokens(estimateTokens(session?.messages || []))}
|
🎟️ {formatTokens(estimateTokens(session?.messages || []))}
|
||||||
</span>
|
</span>
|
||||||
<span className="stat-badge location">
|
|
||||||
📍 {detectLocation(session?.messages || [])}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user