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
+5
View File
@@ -0,0 +1,5 @@
# DeepSeek API ключ для генерации историй
VITE_DEEPSEEK_API_KEY=your_api_key_here
# URL бэкенд сервера
VITE_API_URL=http://localhost:3001
+45
View File
@@ -0,0 +1,45 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependencies
node_modules
# Build
dist
dist-ssr
build
# Environment
.env
.env.local
.env.*.local
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.swp
*~
# OS
Thumbs.db
# TypeScript
*.tsbuildinfo
# Testing
coverage
.nyc_output
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>resekai</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4485
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "resekai",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+16
View File
@@ -0,0 +1,16 @@
# MongoDB
MONGODB_URI=mongodb://localhost:27017/resekai
# Discord OAuth2
DISCORD_CLIENT_ID=your_discord_client_id
DISCORD_CLIENT_SECRET=your_discord_client_secret
DISCORD_REDIRECT_URI=http://localhost:3001/auth/discord/callback
# Session
SESSION_SECRET=your_super_secret_session_key
# Frontend URL
FRONTEND_URL=http://localhost:5174
# Server
PORT=3001
+28
View File
@@ -0,0 +1,28 @@
# Dependencies
node_modules
# Environment
.env
.env.local
.env.*.local
# Logs
logs
*.log
npm-debug.log*
# OS
.DS_Store
Thumbs.db
# Editor
.vscode
.idea
*.swp
*~
# Runtime
pids
*.pid
*.seed
*.pid.lock
+480
View File
@@ -0,0 +1,480 @@
import express from "express";
import cors from "cors";
import session from "express-session";
import MongoStore from "connect-mongo";
import { MongoClient, ObjectId } from "mongodb";
import dotenv from "dotenv";
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3001;
// MongoDB подключение
const mongoClient = new MongoClient(process.env.MONGODB_URI);
let db;
async function connectDB() {
await mongoClient.connect();
db = mongoClient.db("resekai");
console.log("✅ Connected to MongoDB");
}
// Middleware
app.use(express.json());
app.use(
cors({
origin: process.env.FRONTEND_URL,
credentials: true,
}),
);
app.use(
session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: MongoStore.create({
mongoUrl: process.env.MONGODB_URI,
dbName: "resekai",
collectionName: "sessions",
}),
cookie: {
secure: false, // true в production с HTTPS
httpOnly: true,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
},
}),
);
// Discord OAuth2 конфиг
const DISCORD_API = "https://discord.com/api/v10";
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID;
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET;
const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI;
// ============ AUTH ROUTES ============
// Начало авторизации Discord
app.get("/auth/discord", (req, res) => {
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
redirect_uri: DISCORD_REDIRECT_URI,
response_type: "code",
scope: "identify email",
});
res.redirect(`https://discord.com/api/oauth2/authorize?${params}`);
});
// Callback от Discord
app.get("/auth/discord/callback", async (req, res) => {
const { code } = req.query;
if (!code) {
return res.redirect(`${process.env.FRONTEND_URL}?error=no_code`);
}
try {
// Получаем токен
const tokenResponse = await fetch(`${DISCORD_API}/oauth2/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
client_secret: DISCORD_CLIENT_SECRET,
grant_type: "authorization_code",
code,
redirect_uri: DISCORD_REDIRECT_URI,
}),
});
const tokenData = await tokenResponse.json();
if (!tokenData.access_token) {
console.error("Token error:", tokenData);
return res.redirect(`${process.env.FRONTEND_URL}?error=token_failed`);
}
// Получаем информацию о пользователе
const userResponse = await fetch(`${DISCORD_API}/users/@me`, {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
});
const discordUser = await userResponse.json();
// Сохраняем или обновляем пользователя в БД
const users = db.collection("users");
const existingUser = await users.findOne({ discordId: discordUser.id });
let user;
if (existingUser) {
// Обновляем существующего пользователя
await users.updateOne(
{ discordId: discordUser.id },
{
$set: {
username: discordUser.username,
email: discordUser.email,
avatar: discordUser.avatar,
updatedAt: new Date(),
},
},
);
user = await users.findOne({ discordId: discordUser.id });
} else {
// Создаём нового пользователя
const newUser = {
discordId: discordUser.id,
username: discordUser.username,
email: discordUser.email,
avatar: discordUser.avatar,
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await users.insertOne(newUser);
user = { ...newUser, _id: result.insertedId };
}
// Сохраняем в сессию
req.session.userId = user._id.toString();
req.session.discordId = discordUser.id;
res.redirect(`${process.env.FRONTEND_URL}?auth=success`);
} catch (error) {
console.error("Auth error:", error);
res.redirect(`${process.env.FRONTEND_URL}?error=auth_failed`);
}
});
// Получить текущего пользователя
app.get("/auth/me", async (req, res) => {
if (!req.session.userId) {
return res.json({ user: null });
}
try {
const users = db.collection("users");
const user = await users.findOne({ _id: new ObjectId(req.session.userId) });
if (!user) {
return res.json({ user: null });
}
res.json({
user: {
id: user._id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatar: user.avatar,
},
});
} catch (error) {
console.error("Get user error:", error);
res.json({ user: null });
}
});
// Выход
app.post("/auth/logout", (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: "Logout failed" });
}
res.clearCookie("connect.sid");
res.json({ success: true });
});
});
// ============ STORIES ROUTES ============
// Middleware для проверки авторизации
const requireAuth = (req, res, next) => {
if (!req.session.userId) {
return res.status(401).json({ error: "Unauthorized" });
}
next();
};
// Получить все истории пользователя
app.get("/api/stories", requireAuth, async (req, res) => {
try {
const stories = db.collection("stories");
const userStories = await stories
.find({ userId: req.session.userId })
.sort({ updatedAt: -1 })
.toArray();
res.json(userStories);
} catch (error) {
console.error("Get stories error:", error);
res.status(500).json({ error: "Failed to get stories" });
}
});
// Получить одну историю
app.get("/api/stories/:id", requireAuth, async (req, res) => {
try {
const stories = db.collection("stories");
const story = await stories.findOne({
_id: new ObjectId(req.params.id),
userId: req.session.userId,
});
if (!story) {
return res.status(404).json({ error: "Story not found" });
}
res.json(story);
} catch (error) {
console.error("Get story error:", error);
res.status(500).json({ error: "Failed to get story" });
}
});
// Создать историю
app.post("/api/stories", requireAuth, async (req, res) => {
try {
const stories = db.collection("stories");
const newStory = {
...req.body,
userId: req.session.userId,
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await stories.insertOne(newStory);
res.json({ ...newStory, _id: result.insertedId });
} catch (error) {
console.error("Create story error:", error);
res.status(500).json({ error: "Failed to create story" });
}
});
// Обновить историю
app.put("/api/stories/:id", requireAuth, async (req, res) => {
try {
const stories = db.collection("stories");
const result = await stories.updateOne(
{
_id: new ObjectId(req.params.id),
userId: req.session.userId,
},
{
$set: {
...req.body,
updatedAt: new Date(),
},
},
);
if (result.matchedCount === 0) {
return res.status(404).json({ error: "Story not found" });
}
res.json({ success: true });
} catch (error) {
console.error("Update story error:", error);
res.status(500).json({ error: "Failed to update story" });
}
});
// Удалить историю
app.delete("/api/stories/:id", requireAuth, async (req, res) => {
try {
const stories = db.collection("stories");
const result = await stories.deleteOne({
_id: new ObjectId(req.params.id),
userId: req.session.userId,
});
if (result.deletedCount === 0) {
return res.status(404).json({ error: "Story not found" });
}
// Также удаляем связанные сессии игры
const sessions = db.collection("game_sessions");
await sessions.deleteMany({ storyId: req.params.id });
res.json({ success: true });
} catch (error) {
console.error("Delete story error:", error);
res.status(500).json({ error: "Failed to delete story" });
}
});
// ============ GAME SESSIONS ROUTES ============
// Получить сессию игры
app.get("/api/sessions/:storyId", requireAuth, async (req, res) => {
try {
const sessions = db.collection("game_sessions");
const session = await sessions.findOne({
storyId: req.params.storyId,
userId: req.session.userId,
});
res.json(session);
} catch (error) {
console.error("Get session error:", error);
res.status(500).json({ error: "Failed to get session" });
}
});
// Сохранить сессию игры
app.post("/api/sessions/:storyId", requireAuth, async (req, res) => {
try {
const sessions = db.collection("game_sessions");
// Преобразуем timestamp строки обратно в Date
const messages = (req.body.messages || []).map((msg) => ({
...msg,
timestamp: new Date(msg.timestamp),
}));
// Убираем _id и createdAt из body чтобы не было конфликтов
const { createdAt, _id, id, ...bodyWithoutMeta } = req.body;
const sessionData = {
...bodyWithoutMeta,
messages,
storyId: req.params.storyId,
userId: req.session.userId,
updatedAt: new Date(),
};
await sessions.updateOne(
{
storyId: req.params.storyId,
userId: req.session.userId,
},
{
$set: sessionData,
$setOnInsert: { createdAt: new Date() },
},
{ upsert: true },
);
res.json({ success: true });
} catch (error) {
console.error("Save session error:", error);
res.status(500).json({ error: "Failed to save session" });
}
});
// ============ PLAYER CHARACTERS ROUTES ============
// Получить всех персонажей пользователя
app.get("/api/characters", requireAuth, async (req, res) => {
try {
const characters = db.collection("player_characters");
const userCharacters = await characters
.find({ userId: req.session.userId })
.sort({ updatedAt: -1 })
.toArray();
res.json(userCharacters);
} catch (error) {
console.error("Get characters error:", error);
res.status(500).json({ error: "Failed to get characters" });
}
});
// Получить одного персонажа
app.get("/api/characters/:id", requireAuth, async (req, res) => {
try {
const characters = db.collection("player_characters");
const character = await characters.findOne({
_id: new ObjectId(req.params.id),
userId: req.session.userId,
});
if (!character) {
return res.status(404).json({ error: "Character not found" });
}
res.json(character);
} catch (error) {
console.error("Get character error:", error);
res.status(500).json({ error: "Failed to get character" });
}
});
// Создать персонажа
app.post("/api/characters", requireAuth, async (req, res) => {
try {
const characters = db.collection("player_characters");
const newCharacter = {
...req.body,
userId: req.session.userId,
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await characters.insertOne(newCharacter);
res.json({ ...newCharacter, _id: result.insertedId });
} catch (error) {
console.error("Create character error:", error);
res.status(500).json({ error: "Failed to create character" });
}
});
// Обновить персонажа
app.put("/api/characters/:id", requireAuth, async (req, res) => {
try {
const characters = db.collection("player_characters");
const result = await characters.updateOne(
{
_id: new ObjectId(req.params.id),
userId: req.session.userId,
},
{
$set: {
...req.body,
updatedAt: new Date(),
},
},
);
if (result.matchedCount === 0) {
return res.status(404).json({ error: "Character not found" });
}
res.json({ success: true });
} catch (error) {
console.error("Update character error:", error);
res.status(500).json({ error: "Failed to update character" });
}
});
// Удалить персонажа
app.delete("/api/characters/:id", requireAuth, async (req, res) => {
try {
const characters = db.collection("player_characters");
const result = await characters.deleteOne({
_id: new ObjectId(req.params.id),
userId: req.session.userId,
});
if (result.deletedCount === 0) {
return res.status(404).json({ error: "Character not found" });
}
res.json({ success: true });
} catch (error) {
console.error("Delete character error:", error);
res.status(500).json({ error: "Failed to delete character" });
}
});
// Запуск сервера
connectDB().then(() => {
app.listen(PORT, () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
});
});
+1200
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "resekai-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node --watch index.js",
"start": "node index.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"mongodb": "^6.3.0",
"dotenv": "^16.3.1",
"express-session": "^1.17.3",
"connect-mongo": "^5.1.0"
}
}
+34
View File
@@ -0,0 +1,34 @@
.app {
min-height: 100vh;
background: #0d0d0d;
color: white;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem;
color: rgba(255, 255, 255, 0.6);
}
.loading-spinner {
font-size: 3rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext";
import { Header } from "./components/Header";
import StoriesPage from "./pages/StoriesPage";
import StoryDetailPage from "./pages/StoryDetailPage";
import CreateStoryPage from "./pages/CreateStoryPage";
import CharactersPage from "./pages/CharactersPage";
import GamePage from "./pages/GamePage";
import "./App.css";
function App() {
return (
<BrowserRouter>
<AuthProvider>
<div className="app">
<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 />} />
</Routes>
</main>
</div>
</AuthProvider>
</BrowserRouter>
);
}
export default App;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+139
View File
@@ -0,0 +1,139 @@
.header {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 0 2rem;
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: white;
font-size: 1.5rem;
font-weight: 700;
}
.logo-icon {
font-size: 1.8rem;
}
.logo-text {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav {
display: flex;
gap: 1.5rem;
}
.nav-link {
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 8px;
transition: all 0.2s;
}
.nav-link:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
}
.auth-section {
display: flex;
align-items: center;
}
.auth-loading {
color: rgba(255, 255, 255, 0.6);
font-size: 0.9rem;
}
.user-menu {
display: flex;
align-items: center;
gap: 0.75rem;
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid #764ba2;
}
.user-name {
color: white;
font-weight: 500;
}
.logout-button {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.logout-button:hover {
background: rgba(255, 255, 255, 0.2);
}
.discord-login-button {
display: flex;
align-items: center;
gap: 0.5rem;
background: #5865f2;
border: none;
color: white;
padding: 0.6rem 1.2rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
transition: all 0.2s;
}
.discord-login-button:hover {
background: #4752c4;
transform: translateY(-1px);
}
.discord-icon {
width: 20px;
height: 20px;
}
@media (max-width: 768px) {
.header {
padding: 0 1rem;
}
.nav {
display: none;
}
.user-name {
display: none;
}
}
+64
View File
@@ -0,0 +1,64 @@
import { Link } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { getDiscordAvatarUrl } from "../services/api";
import "./Header.css";
export function Header() {
const { user, isLoading, isAuthenticated, login, logout } = useAuth();
return (
<header className="header">
<div className="header-content">
<Link to="/" className="logo">
<span className="logo-icon"></span>
<span className="logo-text">ReSekai</span>
</Link>
<nav className="nav">
<Link to="/" className="nav-link">
Истории
</Link>
{isAuthenticated && (
<>
<Link to="/create" className="nav-link">
Создать
</Link>
<Link to="/characters" className="nav-link">
Персонажи
</Link>
</>
)}
</nav>
<div className="auth-section">
{isLoading ? (
<div className="auth-loading">Загрузка...</div>
) : isAuthenticated && user ? (
<div className="user-menu">
<img
src={getDiscordAvatarUrl(user)}
alt={user.username}
className="user-avatar"
/>
<span className="user-name">{user.username}</span>
<button onClick={logout} className="logout-button">
Выйти
</button>
</div>
) : (
<button onClick={login} className="discord-login-button">
<svg
className="discord-icon"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Войти через Discord
</button>
)}
</div>
</div>
</header>
);
}
+89
View File
@@ -0,0 +1,89 @@
import {
createContext,
useContext,
useState,
useEffect,
type ReactNode,
} from "react";
import {
getCurrentUser,
logout as apiLogout,
type User,
} from "../services/api";
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: () => void;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshUser = async () => {
try {
const currentUser = await getCurrentUser();
setUser(currentUser);
} catch (error) {
console.error("Failed to refresh user:", error);
setUser(null);
}
};
useEffect(() => {
const initAuth = async () => {
setIsLoading(true);
await refreshUser();
setIsLoading(false);
};
initAuth();
// Проверяем URL параметры после OAuth
const params = new URLSearchParams(window.location.search);
if (params.get("auth") === "success") {
// Убираем параметр из URL
window.history.replaceState({}, "", window.location.pathname);
refreshUser();
}
}, []);
const login = () => {
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3001";
window.location.href = `${API_URL}/auth/discord`;
};
const logout = async () => {
await apiLogout();
setUser(null);
};
return (
<AuthContext.Provider
value={{
user,
isLoading,
isAuthenticated: !!user,
login,
logout,
refreshUser,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
+68
View File
@@ -0,0 +1,68 @@
* {
box-sizing: border-box;
}
:root {
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: rgba(255, 255, 255, 0.87);
background-color: #0d0d0d;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.2;
}
button {
font-family: inherit;
}
/* Глобальные утилиты */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+368
View File
@@ -0,0 +1,368 @@
.characters-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.characters-header {
text-align: center;
margin-bottom: 2rem;
}
.characters-header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.characters-header p {
color: rgba(255, 255, 255, 0.6);
}
.create-character-btn {
display: block;
width: 100%;
max-width: 400px;
margin: 0 auto 2rem;
padding: 1rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 12px;
color: white;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.create-character-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.4);
}
/* Grid */
.characters-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.character-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
transition: all 0.3s;
position: relative;
}
.character-card.favorite {
border-color: rgba(255, 215, 0, 0.4);
background: rgba(255, 215, 0, 0.05);
}
.character-card:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(102, 126, 234, 0.3);
}
.character-card.favorite:hover {
border-color: rgba(255, 215, 0, 0.6);
}
.favorite-btn {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
opacity: 0.5;
transition: all 0.2s;
padding: 0.25rem;
}
.favorite-btn:hover {
opacity: 1;
transform: scale(1.2);
}
.favorite-btn.active {
opacity: 1;
color: #ffd700;
}
.character-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
background: rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
}
.character-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
font-size: 2.5rem;
}
.character-info {
text-align: center;
flex: 1;
}
.character-info h3 {
font-size: 1.3rem;
margin-bottom: 0.5rem;
}
.character-description {
color: rgba(255, 255, 255, 0.6);
font-size: 0.9rem;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.character-actions {
display: flex;
justify-content: center;
gap: 0.5rem;
}
.character-actions button {
padding: 0.5rem 1rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s;
}
.btn-edit {
background: rgba(102, 126, 234, 0.2);
color: #667eea;
}
.btn-edit:hover {
background: rgba(102, 126, 234, 0.3);
}
.btn-delete {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.btn-delete:hover {
background: rgba(239, 68, 68, 0.3);
}
/* Form Overlay */
.character-form-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 2rem;
}
.character-form {
background: #1a1a2e;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 2rem;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.character-form h2 {
text-align: center;
margin-bottom: 1.5rem;
font-size: 1.5rem;
}
.character-form .form-group {
margin-bottom: 1.5rem;
}
.character-form label {
display: block;
margin-bottom: 0.5rem;
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
.character-form .field-hint {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 0.5rem;
}
.character-form .field-hint code {
background: rgba(102, 126, 234, 0.2);
color: #a0b4ff;
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-family: monospace;
}
.character-form input,
.character-form textarea {
width: 100%;
padding: 0.8rem 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 10px;
color: white;
font-size: 1rem;
transition: all 0.2s;
}
.character-form input:focus,
.character-form textarea:focus {
outline: none;
border-color: #667eea;
background: rgba(255, 255, 255, 0.08);
}
.character-form textarea {
resize: vertical;
min-height: 100px;
}
.avatar-preview {
margin-top: 1rem;
text-align: center;
}
.avatar-preview img {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #667eea;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.form-actions button {
flex: 1;
padding: 0.8rem 1.5rem;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-cancel {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.btn-cancel:hover {
background: rgba(255, 255, 255, 0.2);
}
.btn-save {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-save:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
.btn-save:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
}
.empty-icon {
font-size: 5rem;
margin-bottom: 1rem;
}
.empty-state h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.empty-state p {
color: rgba(255, 255, 255, 0.6);
}
/* Loading */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem;
color: rgba(255, 255, 255, 0.6);
}
.loading-spinner {
font-size: 3rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.characters-page {
padding: 1rem;
}
.characters-header h1 {
font-size: 2rem;
}
.characters-grid {
grid-template-columns: 1fr;
}
}
+301
View File
@@ -0,0 +1,301 @@
import { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import {
getPlayerCharacters,
createPlayerCharacter,
updatePlayerCharacter,
deletePlayerCharacter,
} from "../services/api";
import type { PlayerCharacter } from "../types";
import "./CharactersPage.css";
export default function CharactersPage() {
const navigate = useNavigate();
const { isAuthenticated, isLoading: authLoading } = useAuth();
const [characters, setCharacters] = useState<PlayerCharacter[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [form, setForm] = useState({
name: "",
description: "",
avatarUrl: "",
isFavorite: false,
});
useEffect(() => {
if (!authLoading && !isAuthenticated) {
navigate("/");
return;
}
const loadCharacters = async () => {
if (!isAuthenticated) return;
setIsLoading(true);
const data = await getPlayerCharacters();
setCharacters(data);
setIsLoading(false);
};
if (!authLoading) {
loadCharacters();
}
}, [isAuthenticated, authLoading, navigate]);
const resetForm = () => {
setForm({ name: "", description: "", avatarUrl: "", isFavorite: false });
setIsCreating(false);
setIsEditing(null);
};
const handleCreate = async () => {
if (!form.name.trim()) return;
const newCharacter = await createPlayerCharacter({
name: form.name.trim(),
description: form.description.trim(),
avatarUrl: form.avatarUrl.trim() || undefined,
isFavorite: form.isFavorite,
});
if (newCharacter) {
setCharacters([newCharacter, ...characters]);
resetForm();
}
};
const handleUpdate = async (id: string) => {
if (!form.name.trim()) return;
const success = await updatePlayerCharacter(id, {
name: form.name.trim(),
description: form.description.trim(),
avatarUrl: form.avatarUrl.trim() || undefined,
isFavorite: form.isFavorite,
});
if (success) {
setCharacters(
characters.map((c) =>
c.id === id
? {
...c,
name: form.name.trim(),
description: form.description.trim(),
avatarUrl: form.avatarUrl.trim() || undefined,
isFavorite: form.isFavorite,
}
: c,
),
);
resetForm();
}
};
const handleDelete = async (id: string) => {
if (!confirm("Удалить этого персонажа?")) return;
const success = await deletePlayerCharacter(id);
if (success) {
setCharacters(characters.filter((c) => c.id !== id));
}
};
const startEdit = (character: PlayerCharacter) => {
setForm({
name: character.name,
description: character.description,
avatarUrl: character.avatarUrl || "",
isFavorite: character.isFavorite || false,
});
setIsEditing(character.id);
setIsCreating(false);
};
const toggleFavorite = async (character: PlayerCharacter) => {
const newFavorite = !character.isFavorite;
const success = await updatePlayerCharacter(character.id, {
isFavorite: newFavorite,
});
if (success) {
setCharacters(
characters.map((c) =>
c.id === character.id ? { ...c, isFavorite: newFavorite } : c,
),
);
}
};
// Сортировка: фавориты первыми
const sortedCharacters = [...characters].sort((a, b) => {
if (a.isFavorite && !b.isFavorite) return -1;
if (!a.isFavorite && b.isFavorite) return 1;
return 0;
});
const startCreate = () => {
resetForm();
setIsCreating(true);
};
if (authLoading || isLoading) {
return (
<div className="characters-page">
<div className="loading-state">
<div className="loading-spinner"></div>
<p>Загрузка...</p>
</div>
</div>
);
}
return (
<div className="characters-page">
<header className="characters-header">
<h1>👤 Мои персонажи</h1>
<p>Создавайте персонажей для игры в истории</p>
</header>
{/* Форма создания/редактирования */}
{(isCreating || isEditing) && (
<div className="character-form-overlay">
<div className="character-form">
<h2>{isCreating ? "✨ Новый персонаж" : "✏️ Редактирование"}</h2>
<div className="form-group">
<label>Имя персонажа *</label>
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="Как зовут вашего персонажа?"
autoFocus
/>
</div>
<div className="form-group">
<label>Описание</label>
<p className="field-hint">
Используйте <code>{"{user}"}</code> для автоподстановки имени
персонажа
</p>
<textarea
value={form.description}
onChange={(e) =>
setForm((prev) => ({ ...prev, description: e.target.value }))
}
placeholder="Опишите внешность, характер, историю... Пример: {user} - молодой маг с тёмными волосами"
rows={4}
/>
</div>
<div className="form-group">
<label>URL аватара</label>
<input
type="url"
value={form.avatarUrl}
onChange={(e) =>
setForm((prev) => ({ ...prev, avatarUrl: e.target.value }))
}
placeholder="https://example.com/avatar.png"
/>
{form.avatarUrl && (
<div className="avatar-preview">
<img src={form.avatarUrl} alt="Preview" />
</div>
)}
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" onClick={resetForm}>
Отмена
</button>
<button
type="button"
className="btn-save"
onClick={() =>
isCreating ? handleCreate() : handleUpdate(isEditing!)
}
disabled={!form.name.trim()}
>
{isCreating ? "Создать" : "Сохранить"}
</button>
</div>
</div>
</div>
)}
{/* Кнопка создания */}
<button className="create-character-btn" onClick={startCreate}>
Создать персонажа
</button>
{/* Список персонажей */}
{characters.length === 0 ? (
<div className="empty-state">
<div className="empty-icon">👤</div>
<h2>Нет персонажей</h2>
<p>Создайте своего первого персонажа для игры!</p>
</div>
) : (
<div className="characters-grid">
{sortedCharacters.map((character) => (
<div
key={character.id}
className={`character-card ${character.isFavorite ? "favorite" : ""}`}
>
<button
className={`favorite-btn ${character.isFavorite ? "active" : ""}`}
onClick={() => toggleFavorite(character)}
title={
character.isFavorite
? "Убрать из избранного"
: "Добавить в избранное"
}
>
{character.isFavorite ? "⭐" : "☆"}
</button>
<div className="character-avatar">
{character.avatarUrl ? (
<img src={character.avatarUrl} alt={character.name} />
) : (
<span className="avatar-placeholder">👤</span>
)}
</div>
<div className="character-info">
<h3>{character.name}</h3>
{character.description && (
<p className="character-description">
{character.description.replace(
/\{user\}/gi,
character.name,
)}
</p>
)}
</div>
<div className="character-actions">
<button
className="btn-edit"
onClick={() => startEdit(character)}
>
</button>
<button
className="btn-delete"
onClick={() => handleDelete(character.id)}
>
🗑
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
+708
View File
@@ -0,0 +1,708 @@
.create-story-page {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.back-link {
display: inline-block;
color: #888;
text-decoration: none;
margin-bottom: 1.5rem;
transition: color 0.2s;
}
.back-link:hover {
color: #667eea;
}
.create-header {
text-align: center;
margin-bottom: 2rem;
}
.create-header h1 {
font-size: 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.create-header p {
color: #888;
}
.create-form {
display: flex;
flex-direction: column;
gap: 2rem;
}
.form-section {
background: #1a1a1a;
border-radius: 16px;
padding: 1.5rem;
border: 1px solid #333;
}
.form-section h2 {
font-size: 1.2rem;
color: #fff;
margin: 0 0 1.25rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #333;
}
.form-group {
margin-bottom: 1rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
color: #ccc;
font-size: 0.9rem;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem 1rem;
background: #0d0d0d;
border: 2px solid #333;
border-radius: 10px;
color: white;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #667eea;
}
.form-group input::placeholder,
.form-group textarea::placeholder {
color: #555;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.genres-grid,
.settings-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
/* Выбранные теги */
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 0.75rem;
background: rgba(102, 126, 234, 0.1);
border-radius: 10px;
border: 1px solid rgba(102, 126, 234, 0.2);
}
.selected-tag {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem 0.4rem 0.9rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.tag-remove {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 50%;
color: white;
font-size: 0.7rem;
cursor: pointer;
transition: background 0.2s;
}
.tag-remove:hover {
background: rgba(255, 255, 255, 0.4);
}
/* Кастомный жанр */
.custom-genre-input {
display: flex;
gap: 0.5rem;
}
.custom-genre-input input {
flex: 1;
}
.add-genre-btn {
width: 44px;
background: #2a2a2a;
border: 2px solid #333;
border-radius: 10px;
color: #667eea;
font-size: 1.2rem;
cursor: pointer;
transition: all 0.2s;
}
.add-genre-btn:hover:not(:disabled) {
background: #667eea;
border-color: #667eea;
color: white;
}
.add-genre-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* NSFW Toggle */
.nsfw-toggle {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: #0d0d0d;
border-radius: 12px;
border: 1px solid #333;
}
.toggle-switch {
position: relative;
width: 56px;
height: 30px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-switch .toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
transition: 0.3s;
border-radius: 30px;
}
.toggle-switch .toggle-slider:before {
position: absolute;
content: "";
height: 22px;
width: 22px;
left: 4px;
bottom: 4px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
.toggle-switch input:checked + .toggle-slider {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%);
}
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(26px);
background-color: white;
}
.nsfw-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.nsfw-label {
font-weight: 600;
color: #888;
font-size: 1rem;
}
.nsfw-label.active {
color: #ff6b6b;
}
.nsfw-hint {
font-size: 0.8rem;
color: #888;
}
/* Temperature Selector */
.temperature-selector {
display: flex;
gap: 0.75rem;
}
.temp-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 1rem;
background: #0d0d0d;
border: 2px solid #333;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.temp-btn:hover {
border-color: #667eea;
}
.temp-btn.active {
border-color: #667eea;
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.15) 0%,
rgba(118, 75, 162, 0.15) 100%
);
}
.temp-label {
font-size: 0.95rem;
color: #ddd;
}
.temp-btn.active .temp-label {
color: #fff;
}
.temp-value {
font-size: 0.75rem;
color: #666;
}
.temp-btn.active .temp-value {
color: #667eea;
}
.genre-btn,
.setting-btn {
padding: 0.5rem 1rem;
background: #2a2a2a;
border: 2px solid #333;
border-radius: 20px;
color: #aaa;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.genre-btn:hover,
.setting-btn:hover {
border-color: #667eea;
color: #fff;
}
.genre-btn.selected,
.setting-btn.selected {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: transparent;
color: white;
}
.array-input {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.array-input input {
flex: 1;
}
.remove-btn {
width: 40px;
background: #2a2a2a;
border: 2px solid #333;
border-radius: 10px;
color: #ff6b6b;
cursor: pointer;
transition: all 0.2s;
}
.remove-btn:hover {
background: rgba(255, 107, 107, 0.1);
border-color: #ff6b6b;
}
.add-btn {
width: 100%;
padding: 0.6rem;
background: transparent;
border: 2px dashed #333;
border-radius: 10px;
color: #888;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.add-btn:hover {
border-color: #667eea;
color: #667eea;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
padding-top: 1rem;
}
.cancel-btn {
padding: 0.875rem 1.5rem;
background: #2a2a2a;
border: none;
border-radius: 12px;
color: #aaa;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
}
.cancel-btn:hover {
background: #333;
color: #fff;
}
.submit-btn {
padding: 0.875rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 12px;
color: white;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.submit-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Языки */
.language-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.language-btn {
padding: 0.5rem 1rem;
background: #2a2a2a;
border: 2px solid #333;
border-radius: 10px;
color: #aaa;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.language-btn:hover {
border-color: #667eea;
color: #fff;
}
.language-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: transparent;
color: white;
}
/* Tags grid */
.tags-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tag-btn {
padding: 0.5rem 1rem;
background: #2a2a2a;
border: 2px solid #333;
border-radius: 20px;
color: #aaa;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.tag-btn:hover {
border-color: #667eea;
color: #fff;
}
.tag-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: transparent;
color: white;
}
.tag-btn.setting.active {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
.setting-tag {
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
}
/* Remove tag button */
.remove-tag {
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 50%;
width: 18px;
height: 18px;
color: white;
font-size: 0.8rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.remove-tag:hover {
background: rgba(255, 255, 255, 0.4);
}
/* Custom tag input */
.custom-tag-input {
display: flex;
gap: 0.5rem;
}
.custom-tag-input input {
flex: 1;
padding: 0.75rem 1rem;
background: #0d0d0d;
border: 2px solid #333;
border-radius: 10px;
color: white;
font-size: 1rem;
}
.custom-tag-input input:focus {
outline: none;
border-color: #667eea;
}
.custom-tag-input button {
width: 44px;
background: #2a2a2a;
border: 2px solid #333;
border-radius: 10px;
color: #667eea;
font-size: 1.2rem;
cursor: pointer;
transition: all 0.2s;
}
.custom-tag-input button:hover:not(:disabled) {
background: #667eea;
border-color: #667eea;
color: white;
}
.custom-tag-input button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Field hints */
.field-hint {
font-size: 0.8rem;
color: #666;
margin-bottom: 0.5rem;
}
.word-count {
font-size: 0.8rem;
color: #888;
}
.word-count.over {
color: #ff6b6b;
}
/* Markdown input */
.markdown-input {
font-family: "Fira Code", "Monaco", monospace;
font-size: 0.9rem;
line-height: 1.6;
}
/* Character fields */
.character-fields {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.character-fields input,
.character-fields select,
.character-fields textarea {
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid #333;
border-radius: 8px;
color: white;
font-size: 0.95rem;
}
.character-fields input:focus,
.character-fields select:focus,
.character-fields textarea:focus {
outline: none;
border-color: #667eea;
}
/* Подсказки */
.hint {
display: block;
margin-top: 0.5rem;
font-size: 0.8rem;
color: #666;
}
.section-hint {
color: #888;
font-size: 0.9rem;
margin: -0.5rem 0 1rem;
}
/* Персонажи */
.character-card {
background: #0d0d0d;
border: 1px solid #333;
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
}
.character-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.character-number {
font-weight: 600;
color: #667eea;
font-size: 0.9rem;
}
.remove-btn.small {
width: 28px;
height: 28px;
font-size: 0.8rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-group select {
width: 100%;
padding: 0.75rem 1rem;
background: #0d0d0d;
border: 2px solid #333;
border-radius: 10px;
color: white;
font-size: 1rem;
font-family: inherit;
cursor: pointer;
transition: border-color 0.2s;
}
.form-group select:focus {
outline: none;
border-color: #667eea;
}
.form-group select option {
background: #1a1a1a;
color: white;
}
@media (max-width: 600px) {
.create-story-page {
padding: 1rem;
}
.form-section {
padding: 1rem;
}
.form-actions {
flex-direction: column;
}
.cancel-btn,
.submit-btn {
width: 100%;
text-align: center;
}
.form-row {
grid-template-columns: 1fr;
}
.language-grid {
flex-direction: column;
}
.language-btn {
text-align: center;
}
}
+805
View File
@@ -0,0 +1,805 @@
import { useState, useEffect } from "react";
import { useNavigate, Link, useParams } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { createStory, getStory, updateStory } from "../services/api";
import type { Character } from "../types";
import "./CreateStoryPage.css";
const GENRES = [
"Фэнтези",
"Магия",
"Приключения",
"Романтика",
"Экшн",
"Комедия",
"Драма",
"Тёмное фэнтези",
"LitRPG",
"Хорроры",
"Sci-Fi",
"Повседневность",
];
const SETTINGS = [
"Средневековье",
"Восточное фэнтези",
"Стимпанк",
"Магическая академия",
"Королевство демонов",
"Мир мечей и магии",
"Постапокалипсис",
"Параллельный мир",
"Современность",
"Космос",
"Подземелья",
"Королевский двор",
];
const LANGUAGES = [
{ code: "ru", name: "🇷🇺 Русский" },
{ code: "en", name: "🇺🇸 English" },
{ code: "ja", name: "🇯🇵 日本語" },
{ code: "zh", name: "🇨🇳 中文" },
{ code: "ko", name: "🇰🇷 한국어" },
{ code: "es", name: "🇪🇸 Español" },
{ code: "de", name: "🇩🇪 Deutsch" },
{ code: "fr", name: "🇫🇷 Français" },
];
const CHARACTER_ROLES = [
"Союзник",
"Злодей",
"Наставник",
"Романтический интерес",
"Нейтральный NPC",
"Антагонист",
"Комик",
];
export default function CreateStoryPage() {
const { id } = useParams<{ id: string }>();
const isEditMode = Boolean(id);
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(isEditMode);
const [form, setForm] = useState({
title: "",
description: "",
language: "ru",
genres: [] as string[],
customGenre: "",
settings: [] as string[],
customSetting: "",
summary: "",
plot: "",
firstMessage: "",
isNsfw: false,
temperature: 1.3, // Креативность ИИ
narrativeRules: "", // Правила повествования для ИИ
// NPC персонажи мира
characters: [{ name: "", description: "", role: "Союзник" }] as Character[],
// Мир
worldName: "",
worldDescription: "",
worldRules: [""],
});
useEffect(() => {
if (!isAuthenticated) {
navigate("/");
}
}, [isAuthenticated, navigate]);
// Загрузка истории для редактирования
useEffect(() => {
const loadStory = async () => {
if (!id || !isAuthenticated) return;
setIsLoading(true);
const story = await getStory(id);
if (story) {
setForm({
title: story.title,
description: story.description || "",
language: story.language || "ru",
genres: story.genre || [],
customGenre: "",
settings: Array.isArray(story.setting)
? story.setting
: [story.setting],
customSetting: "",
summary: story.summary || "",
plot: story.plot || "",
firstMessage: story.firstMessage || "",
isNsfw: story.isNsfw || false,
temperature: story.temperature || 1.3,
narrativeRules: story.narrativeRules || "",
characters:
story.characters?.length > 0
? story.characters
: [{ name: "", description: "", role: "Союзник" }],
worldName: story.world?.name || "",
worldDescription: story.world?.description || "",
worldRules: story.world?.rules?.length > 0 ? story.world.rules : [""],
});
} else {
navigate("/");
}
setIsLoading(false);
};
loadStory();
}, [id, isAuthenticated, navigate]);
// Жанры
const handleGenreToggle = (genre: string) => {
setForm((prev) => ({
...prev,
genres: prev.genres.includes(genre)
? prev.genres.filter((g) => g !== genre)
: [...prev.genres, genre],
}));
};
const removeGenre = (genre: string) => {
setForm((prev) => ({
...prev,
genres: prev.genres.filter((g) => g !== genre),
}));
};
const addCustomGenre = () => {
if (
form.customGenre.trim() &&
!form.genres.includes(form.customGenre.trim())
) {
setForm((prev) => ({
...prev,
genres: [...prev.genres, prev.customGenre.trim()],
customGenre: "",
}));
}
};
const handleCustomGenreKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
addCustomGenre();
}
};
// Сеттинги (теги как жанры)
const handleSettingToggle = (setting: string) => {
setForm((prev) => ({
...prev,
settings: prev.settings.includes(setting)
? prev.settings.filter((s) => s !== setting)
: [...prev.settings, setting],
}));
};
const removeSetting = (setting: string) => {
setForm((prev) => ({
...prev,
settings: prev.settings.filter((s) => s !== setting),
}));
};
const addCustomSetting = () => {
if (
form.customSetting.trim() &&
!form.settings.includes(form.customSetting.trim())
) {
setForm((prev) => ({
...prev,
settings: [...prev.settings, prev.customSetting.trim()],
customSetting: "",
}));
}
};
const handleCustomSettingKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
addCustomSetting();
}
};
// NPC Персонажи
const handleCharacterChange = (
index: number,
field: keyof Character,
value: string,
) => {
const newCharacters = [...form.characters];
newCharacters[index] = { ...newCharacters[index], [field]: value };
setForm((prev) => ({ ...prev, characters: newCharacters }));
};
const addCharacter = () => {
setForm((prev) => ({
...prev,
characters: [
...prev.characters,
{ name: "", description: "", role: "Союзник" },
],
}));
};
const removeCharacter = (index: number) => {
setForm((prev) => ({
...prev,
characters: prev.characters.filter((_, i) => i !== index),
}));
};
// Правила мира
const handleRuleChange = (index: number, value: string) => {
const newRules = [...form.worldRules];
newRules[index] = value;
setForm((prev) => ({ ...prev, worldRules: newRules }));
};
const addRule = () => {
setForm((prev) => ({ ...prev, worldRules: [...prev.worldRules, ""] }));
};
const removeRule = (index: number) => {
setForm((prev) => ({
...prev,
worldRules: prev.worldRules.filter((_, i) => i !== index),
}));
};
// Подсчёт слов
const countWords = (text: string) => {
return text
.trim()
.split(/\s+/)
.filter((w) => w.length > 0).length;
};
const summaryWordCount = countWords(form.summary);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const allGenres = [...form.genres];
if (form.customGenre.trim()) {
allGenres.push(form.customGenre.trim());
}
const allSettings = [...form.settings];
if (form.customSetting.trim()) {
allSettings.push(form.customSetting.trim());
}
const storyData = {
title: form.title,
description: form.description || `Исекай история: ${form.title}`,
coverImage: "",
language:
LANGUAGES.find((l) => l.code === form.language)?.name.split(" ")[1] ||
"Русский",
genre: allGenres,
setting: allSettings,
summary: form.summary,
plot: form.plot,
firstMessage: form.firstMessage,
characters: form.characters.filter((c) => c.name.trim()),
isNsfw: form.isNsfw,
temperature: form.temperature,
narrativeRules: form.narrativeRules.trim() || undefined,
world: {
name: form.worldName,
description: form.worldDescription,
rules: form.worldRules.filter((r) => r.trim()),
},
};
if (isEditMode && id) {
const success = await updateStory(id, storyData);
if (success) {
navigate(`/story/${id}`);
}
} else {
const story = await createStory(storyData);
if (story) {
navigate(`/story/${story.id}`);
}
}
} catch (error) {
console.error("Error saving story:", error);
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<div className="create-story-page">
<div className="loading-state">
<p>Загрузка...</p>
</div>
</div>
);
}
return (
<div className="create-story-page">
<Link to={isEditMode ? `/story/${id}` : "/"} className="back-link">
{isEditMode ? "Назад к истории" : "Назад к историям"}
</Link>
<header className="create-header">
<h1>
{isEditMode ? "✏️ Редактировать историю" : "✨ Создать новую историю"}
</h1>
<p>
{isEditMode
? "Измени свою исекай историю"
: "Настрой свой уникальный исекай мир"}
</p>
</header>
<form onSubmit={handleSubmit} className="create-form">
{/* Основная информация */}
<section className="form-section">
<h2>📖 Основная информация</h2>
<div className="form-group">
<label htmlFor="title">Название истории *</label>
<input
type="text"
id="title"
value={form.title}
onChange={(e) =>
setForm((prev) => ({ ...prev, title: e.target.value }))
}
placeholder="Например: Перерождение в мире магии"
required
/>
</div>
<div className="form-group">
<label htmlFor="language">Язык истории *</label>
<div className="language-grid">
{LANGUAGES.map((lang) => (
<button
key={lang.code}
type="button"
className={`language-btn ${form.language === lang.code ? "active" : ""}`}
onClick={() =>
setForm((prev) => ({ ...prev, language: lang.code }))
}
>
{lang.name}
</button>
))}
</div>
</div>
<div className="form-group">
<label>
Краткое содержание *{" "}
<span
className={`word-count ${summaryWordCount > 20 ? "over" : ""}`}
>
({summaryWordCount}/20 слов)
</span>
</label>
<p className="field-hint">
Краткое описание для привлечения внимания. Не используется ИИ.
</p>
<textarea
value={form.summary}
onChange={(e) =>
setForm((prev) => ({ ...prev, summary: e.target.value }))
}
placeholder="Захватывающее описание в 1-2 предложениях..."
rows={2}
required
/>
</div>
<div className="form-group">
<div className="nsfw-toggle">
<label className="toggle-switch">
<input
type="checkbox"
checked={form.isNsfw}
onChange={(e) =>
setForm((prev) => ({ ...prev, isNsfw: e.target.checked }))
}
/>
<span className="toggle-slider"></span>
</label>
<div className="nsfw-info">
<span className={`nsfw-label ${form.isNsfw ? "active" : ""}`}>
🔞 NSFW контент
</span>
<span className="nsfw-hint">
Включите, если история содержит контент 18+
</span>
</div>
</div>
</div>
<div className="form-group">
<label htmlFor="temperature">🎲 Креативность ИИ</label>
<p className="field-hint">
Управляет креативностью ответов ИИ. Низкая = сосредоточенный,
Высокая = креативный
</p>
<div className="temperature-selector">
{[
{ value: 1.0, label: "🎯 Сосредоточенный", desc: "1.0" },
{ value: 1.3, label: "⚖️ Сбалансированный", desc: "1.3" },
{ value: 1.5, label: "✨ Креативный", desc: "1.5" },
].map((opt) => (
<button
key={opt.value}
type="button"
className={`temp-btn ${form.temperature === opt.value ? "active" : ""}`}
onClick={() =>
setForm((prev) => ({ ...prev, temperature: opt.value }))
}
>
<span className="temp-label">{opt.label}</span>
<span className="temp-value">{opt.desc}</span>
</button>
))}
</div>
</div>
</section>
{/* Жанры */}
<section className="form-section">
<h2>🎭 Жанры</h2>
{form.genres.length > 0 && (
<div className="selected-tags">
{form.genres.map((genre) => (
<span key={genre} className="selected-tag">
{genre}
<button
type="button"
className="remove-tag"
onClick={() => removeGenre(genre)}
>
×
</button>
</span>
))}
</div>
)}
<div className="tags-grid">
{GENRES.map((genre) => (
<button
key={genre}
type="button"
className={`tag-btn ${form.genres.includes(genre) ? "active" : ""}`}
onClick={() => handleGenreToggle(genre)}
>
{genre}
</button>
))}
</div>
<div className="custom-tag-input">
<input
type="text"
value={form.customGenre}
onChange={(e) =>
setForm((prev) => ({ ...prev, customGenre: e.target.value }))
}
onKeyDown={handleCustomGenreKeyDown}
placeholder="Свой жанр..."
/>
<button
type="button"
onClick={addCustomGenre}
disabled={!form.customGenre.trim()}
>
+
</button>
</div>
</section>
{/* Сеттинги */}
<section className="form-section">
<h2>🏰 Сеттинг</h2>
{form.settings.length > 0 && (
<div className="selected-tags">
{form.settings.map((setting) => (
<span key={setting} className="selected-tag setting-tag">
{setting}
<button
type="button"
className="remove-tag"
onClick={() => removeSetting(setting)}
>
×
</button>
</span>
))}
</div>
)}
<div className="tags-grid">
{SETTINGS.map((setting) => (
<button
key={setting}
type="button"
className={`tag-btn setting ${form.settings.includes(setting) ? "active" : ""}`}
onClick={() => handleSettingToggle(setting)}
>
{setting}
</button>
))}
</div>
<div className="custom-tag-input">
<input
type="text"
value={form.customSetting}
onChange={(e) =>
setForm((prev) => ({ ...prev, customSetting: e.target.value }))
}
onKeyDown={handleCustomSettingKeyDown}
placeholder="Свой сеттинг..."
/>
<button
type="button"
onClick={addCustomSetting}
disabled={!form.customSetting.trim()}
>
+
</button>
</div>
</section>
{/* Сюжет */}
<section className="form-section">
<h2>📜 Сюжет</h2>
<div className="form-group">
<label htmlFor="plot">Полный сюжет *</label>
<p className="field-hint">
Подробное описание сюжета, которое будет управлять историей.
Используется ИИ. Поддерживается Markdown.
</p>
<textarea
id="plot"
value={form.plot}
onChange={(e) =>
setForm((prev) => ({ ...prev, plot: e.target.value }))
}
placeholder={`# Завязка
Главный герой попадает в новый мир...
# Развитие
Он встречает союзников и врагов...
# Ключевые события
- Первая встреча с антагонистом
- Получение уникальной способности
- ...`}
rows={12}
className="markdown-input"
required
/>
</div>
</section>
{/* Первое сообщение */}
<section className="form-section">
<h2>💬 Первое сообщение</h2>
<div className="form-group">
<label htmlFor="firstMessage">Начало истории</label>
<p className="field-hint">
Первое сообщение, которое увидит игрок при начале игры. Если
оставить пустым, ИИ сгенерирует начало автоматически.
</p>
<textarea
id="firstMessage"
value={form.firstMessage}
onChange={(e) =>
setForm((prev) => ({ ...prev, firstMessage: e.target.value }))
}
placeholder="Ты открываешь глаза и видишь перед собой незнакомый потолок..."
rows={6}
/>
</div>
</section>
{/* Правила повествования */}
<section className="form-section">
<h2>📝 Правила повествования</h2>
<div className="form-group">
<label htmlFor="narrativeRules">Инструкции для ИИ</label>
<p className="field-hint">
Кастомные правила поведения ИИ: стиль повествования, запреты,
формат ответов. Если оставить пустым, будут использованы
стандартные правила живой истории.
</p>
<textarea
id="narrativeRules"
value={form.narrativeRules}
onChange={(e) =>
setForm((prev) => ({ ...prev, narrativeRules: e.target.value }))
}
placeholder={`Пример:
Ты — РассказчикGPT, ведущий интерактивную исекай-историю.
ПРАВИЛА:
— Я сам пишу свои действия и реплики
— Ты описываешь мир, реакции персонажей и последствия
— НИКОГДА не принимай решений за меня
— НИКОГДА не задавай мне вопросы
— НИКОГДА не предлагай варианты действий
ФОРМАТ ДИАЛОГОВ:
Все реплики персонажей оформляй **ЖИРНЫМ ШРИФТОМ**.
Описание действий — обычным текстом.`}
rows={14}
className="markdown-input"
/>
</div>
</section>
{/* Мир */}
<section className="form-section">
<h2>🌍 Мир</h2>
<div className="form-group">
<label htmlFor="worldName">Название мира *</label>
<input
type="text"
id="worldName"
value={form.worldName}
onChange={(e) =>
setForm((prev) => ({ ...prev, worldName: e.target.value }))
}
placeholder="Например: Эльдория"
required
/>
</div>
<div className="form-group">
<label htmlFor="worldDescription">Описание мира *</label>
<textarea
id="worldDescription"
value={form.worldDescription}
onChange={(e) =>
setForm((prev) => ({
...prev,
worldDescription: e.target.value,
}))
}
placeholder="Опишите мир: его историю, географию, магическую систему..."
rows={4}
required
/>
</div>
<div className="form-group">
<label>Правила мира</label>
<p className="field-hint">
Особые законы и ограничения, действующие в этом мире.
</p>
{form.worldRules.map((rule, index) => (
<div key={index} className="array-input">
<input
type="text"
value={rule}
onChange={(e) => handleRuleChange(index, e.target.value)}
placeholder={`Правило ${index + 1}`}
/>
{form.worldRules.length > 1 && (
<button
type="button"
onClick={() => removeRule(index)}
className="remove-btn"
>
×
</button>
)}
</div>
))}
<button type="button" onClick={addRule} className="add-btn">
+ Добавить правило
</button>
</div>
</section>
{/* NPC Персонажи */}
<section className="form-section">
<h2>👥 NPC Персонажи мира</h2>
<p className="section-hint">
Персонажи, которых игрок встретит в истории.
</p>
{form.characters.map((char, index) => (
<div key={index} className="character-card">
<div className="character-header">
<span>Персонаж {index + 1}</span>
{form.characters.length > 1 && (
<button
type="button"
onClick={() => removeCharacter(index)}
className="remove-btn"
>
×
</button>
)}
</div>
<div className="character-fields">
<input
type="text"
value={char.name}
onChange={(e) =>
handleCharacterChange(index, "name", e.target.value)
}
placeholder="Имя"
/>
<select
value={char.role}
onChange={(e) =>
handleCharacterChange(index, "role", e.target.value)
}
>
{CHARACTER_ROLES.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
<textarea
value={char.description}
onChange={(e) =>
handleCharacterChange(index, "description", e.target.value)
}
placeholder="Описание персонажа..."
rows={2}
/>
</div>
</div>
))}
<button type="button" onClick={addCharacter} className="add-btn">
+ Добавить персонажа
</button>
</section>
{/* Submit */}
<div className="form-actions">
<Link to={isEditMode ? `/story/${id}` : "/"} className="cancel-btn">
Отмена
</Link>
<button type="submit" className="submit-btn" disabled={isSubmitting}>
{isSubmitting
? isEditMode
? "Сохранение..."
: "Создание..."
: isEditMode
? "💾 Сохранить изменения"
: "✨ Создать историю"}
</button>
</div>
</form>
</div>
);
}
+385
View File
@@ -0,0 +1,385 @@
.game-page {
height: 100vh;
display: flex;
flex-direction: column;
background: #0d0d0d;
}
.game-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 1rem;
}
.game-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
background: #1a1a1a;
border-bottom: 1px solid #333;
}
.back-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: #2a2a2a;
border-radius: 10px;
color: #ccc;
text-decoration: none;
font-size: 1.2rem;
transition: background 0.2s;
}
.back-btn:hover {
background: #333;
}
.header-info {
flex: 1;
}
.header-info h1 {
font-size: 1.1rem;
margin: 0;
color: #fff;
}
.header-protagonist {
font-size: 0.85rem;
color: #888;
}
.header-stats {
display: flex;
gap: 0.5rem;
}
.stat-badge {
padding: 0.4rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
}
.stat-badge.health {
background: rgba(255, 107, 107, 0.15);
color: #ff8a8a;
}
.stat-badge.location {
background: rgba(102, 126, 234, 0.15);
color: #a0b4ff;
}
.game-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.message {
max-width: 85%;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message.user {
align-self: flex-end;
}
.message.assistant {
align-self: flex-start;
}
.message-content {
padding: 1rem 1.25rem;
border-radius: 16px;
line-height: 1.6;
}
.message.user .message-content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-bottom-right-radius: 4px;
}
.message.assistant .message-content {
background: #1f1f1f;
color: #ddd;
border: 1px solid #333;
border-bottom-left-radius: 4px;
}
.message-content p {
margin: 0;
}
.message-content p + p {
margin-top: 0.75rem;
}
/* Markdown стили */
.message-content strong {
font-weight: 700;
color: #fff;
}
.message-content em {
font-style: italic;
color: #bbb;
}
.message-content ul,
.message-content ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.message-content li {
margin: 0.25rem 0;
}
.message-content blockquote {
border-left: 3px solid #667eea;
padding-left: 1rem;
margin: 0.75rem 0;
color: #aaa;
font-style: italic;
}
.message-content code {
background: #2a2a2a;
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
}
.message-content hr {
border: none;
border-top: 1px solid #444;
margin: 1rem 0;
}
.message-time {
display: block;
font-size: 0.7rem;
color: #666;
margin-top: 0.35rem;
padding: 0 0.5rem;
}
.message.user .message-time {
text-align: right;
}
.message.loading .message-content {
padding: 1.25rem 1.5rem;
}
.typing-indicator {
display: flex;
gap: 4px;
}
.typing-indicator span {
width: 8px;
height: 8px;
background: #667eea;
border-radius: 50%;
animation: typing 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(1) {
animation-delay: 0s;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-8px);
opacity: 1;
}
}
.error-message {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
border-radius: 10px;
color: #ff8a8a;
font-size: 0.9rem;
}
.error-message button {
background: none;
border: none;
color: #ff8a8a;
cursor: pointer;
padding: 0.25rem;
}
.quick-actions {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
overflow-x: auto;
background: #131313;
}
.quick-actions button {
padding: 0.5rem 1rem;
background: #222;
border: 1px solid #333;
border-radius: 20px;
color: #aaa;
font-size: 0.85rem;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.quick-actions button:hover {
background: #2a2a2a;
color: #fff;
border-color: #667eea;
}
.input-container {
display: flex;
gap: 0.75rem;
padding: 1rem 1.5rem 1.5rem;
background: #131313;
border-top: 1px solid #222;
}
.input-container textarea {
flex: 1;
padding: 0.875rem 1rem;
background: #1a1a1a;
border: 2px solid #333;
border-radius: 12px;
color: white;
font-size: 1rem;
font-family: inherit;
resize: none;
transition: border-color 0.2s;
}
.input-container textarea:focus {
outline: none;
border-color: #667eea;
}
.input-container textarea::placeholder {
color: #666;
}
.send-btn {
width: 50px;
height: 50px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 12px;
color: white;
font-size: 1.25rem;
cursor: pointer;
transition:
transform 0.2s,
opacity 0.2s;
align-self: flex-end;
}
.send-btn:hover:not(:disabled) {
transform: scale(1.05);
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Скроллбар */
.messages-container::-webkit-scrollbar {
width: 6px;
}
.messages-container::-webkit-scrollbar-track {
background: transparent;
}
.messages-container::-webkit-scrollbar-thumb {
background: #333;
border-radius: 3px;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: #444;
}
@media (max-width: 600px) {
.game-header {
padding: 0.75rem 1rem;
}
.header-stats {
display: none;
}
.messages-container {
padding: 1rem;
}
.message {
max-width: 95%;
}
.quick-actions {
padding: 0.5rem 1rem;
}
.input-container {
padding: 0.75rem 1rem 1rem;
}
}
+499
View File
@@ -0,0 +1,499 @@
import { useState, useEffect, useRef } from "react";
import {
useParams,
Link,
useNavigate,
useSearchParams,
} from "react-router-dom";
import ReactMarkdown from "react-markdown";
import { useAuth } from "../contexts/AuthContext";
import {
getStory,
getSession,
saveSession as apiSaveSession,
getPlayerCharacter,
} from "../services/api";
import {
generateStoryResponse,
buildSystemPrompt,
sendMessage,
generateStorySummary,
extractKeyEvents,
} from "../services/deepseek";
import type {
Story,
GameSession,
ChatMessage,
PlayerCharacter,
} from "../types";
import "./GamePage.css";
function generateId(): string {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
// Примерный подсчёт токенов (1 токен ≈ 3 символа для русского)
function estimateTokens(messages: ChatMessage[]): number {
if (!messages || messages.length === 0) return 0;
const totalChars = messages.reduce((sum, msg) => sum + msg.content.length, 0);
return Math.round(totalChars / 3);
}
function formatTokens(tokens: number): string {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
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() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const [story, setStory] = useState<Story | null>(null);
const [session, setSession] = useState<GameSession | null>(null);
const [playerCharacter, setPlayerCharacter] =
useState<PlayerCharacter | null>(null);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const loadGame = async () => {
if (!id || !isAuthenticated) {
setIsInitialLoading(false);
return;
}
const foundStory = await getStory(id);
if (foundStory) {
const normalizedStory = {
...foundStory,
id: (foundStory as any)._id || foundStory.id,
};
setStory(normalizedStory);
let existingSession = await getSession(id);
console.log(
"[GamePage] Loaded session:",
existingSession?.messages?.length || 0,
"messages",
);
const characterId = searchParams.get("character");
// Загружаем персонажа
let character: PlayerCharacter | null = null;
if (characterId) {
character = await getPlayerCharacter(characterId);
setPlayerCharacter(character);
} else if (existingSession?.playerId) {
character = await getPlayerCharacter(existingSession.playerId);
setPlayerCharacter(character);
}
if (!existingSession) {
console.log("[GamePage] No existing session, creating new");
existingSession = {
storyId: id,
playerId: characterId || undefined,
messages: [],
currentState: {
location: "Неизвестно",
health: 100,
inventory: [],
questProgress: {},
},
};
} else if (characterId && existingSession.playerId !== characterId) {
// Новый персонаж — новая сессия
console.log("[GamePage] New character selected, resetting session");
existingSession = {
storyId: id,
playerId: characterId,
messages: [],
currentState: {
location: "Неизвестно",
health: 100,
inventory: [],
questProgress: {},
},
};
} else {
console.log(
"[GamePage] Using existing session with",
existingSession.messages.length,
"messages",
);
}
setSession(existingSession);
// Начинаем историю если это новая сессия
if (existingSession.messages.length === 0 && character) {
startStory(normalizedStory, existingSession, character);
}
}
setIsInitialLoading(false);
};
loadGame();
}, [id, isAuthenticated, searchParams]);
// Обновляем историю при возврате на страницу (после редактирования)
useEffect(() => {
const handleFocus = async () => {
if (!id || !isAuthenticated || isInitialLoading) return;
const updatedStory = await getStory(id);
if (updatedStory) {
const normalizedStory = {
...updatedStory,
id: (updatedStory as any)._id || updatedStory.id,
};
setStory(normalizedStory);
console.log("[GamePage] История обновлена после возврата на страницу");
}
};
window.addEventListener("focus", handleFocus);
return () => window.removeEventListener("focus", handleFocus);
}, [id, isAuthenticated, isInitialLoading]);
useEffect(() => {
scrollToBottom();
}, [session?.messages]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const startStory = async (
storyData: Story,
sessionData: GameSession,
character: PlayerCharacter,
) => {
// Если есть заготовленное первое сообщение, используем его
if (storyData.firstMessage && storyData.firstMessage.trim()) {
// Заменяем {user} на имя персонажа
const firstMessageContent = storyData.firstMessage.replace(
/\{user\}/gi,
character.name,
);
const assistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: firstMessageContent,
timestamp: new Date(),
};
const updatedSession: GameSession = {
...sessionData,
messages: [assistantMessage],
};
await apiSaveSession(storyData.id, updatedSession);
setSession(updatedSession);
return;
}
// Иначе генерируем через ИИ
setIsLoading(true);
setError(null);
try {
const systemPrompt = buildSystemPrompt(storyData, character);
const response = await sendMessage([
{ role: "system", content: systemPrompt },
{ role: "user", content: "Начни историю." },
]);
const assistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: response,
timestamp: new Date(),
};
const updatedSession: GameSession = {
...sessionData,
messages: [assistantMessage],
};
await apiSaveSession(storyData.id, updatedSession);
setSession(updatedSession);
} catch (err) {
setError(err instanceof Error ? err.message : "Произошла ошибка");
} finally {
setIsLoading(false);
}
};
const handleSend = async () => {
if (!input.trim() || !story || !session || isLoading) return;
const userMessage: ChatMessage = {
id: generateId(),
role: "user",
content: input.trim(),
timestamp: new Date(),
};
const updatedMessages = [...session.messages, userMessage];
const tempSession = { ...session, messages: updatedMessages };
setSession(tempSession);
setInput("");
setIsLoading(true);
setError(null);
try {
// Передаём session для оптимизированного контекста
const response = await generateStoryResponse(
story,
session.messages,
input.trim(),
playerCharacter || undefined,
session,
);
const assistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: response,
timestamp: new Date(),
};
const allMessages = [...updatedMessages, assistantMessage];
// Обновляем ключевые события
const newKeyEvents = await extractKeyEvents(
response,
session.keyEvents || [],
);
// Генерируем сводку каждые 20 сообщений
let newSummary = session.storySummary;
if (allMessages.length % 20 === 0 && allMessages.length > 0) {
console.log("[GamePage] Generating story summary...");
newSummary = await generateStorySummary(
story,
allMessages,
session.storySummary,
);
}
const finalSession: GameSession = {
...session,
messages: allMessages,
keyEvents: newKeyEvents,
storySummary: newSummary,
};
const saved = await apiSaveSession(story.id, finalSession);
console.log(
"[GamePage] Session saved:",
saved,
"Messages:",
allMessages.length,
);
setSession(finalSession);
} catch (err) {
setError(err instanceof Error ? err.message : "Произошла ошибка");
// Откатываем сообщение пользователя при ошибке
setSession(session);
setInput(userMessage.content);
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleQuickAction = (action: string) => {
setInput(action);
inputRef.current?.focus();
};
if (isInitialLoading) {
return (
<div className="game-page">
<div className="game-loading">
<p>Загрузка игры...</p>
</div>
</div>
);
}
if (!story) {
return (
<div className="game-page">
<div className="game-error">
<h2>История не найдена</h2>
<Link to="/" className="back-link">
Вернуться к списку
</Link>
</div>
</div>
);
}
return (
<div className="game-page">
<header className="game-header">
<Link to={`/story/${story.id}`} className="back-btn">
</Link>
<div className="header-info">
<h1>{story.title}</h1>
<span className="header-protagonist">
👤 {playerCharacter?.name || "Герой"}
</span>
</div>
<div className="header-stats">
<span className="stat-badge tokens">
🎟 {formatTokens(estimateTokens(session?.messages || []))}
</span>
<span className="stat-badge location">
📍 {detectLocation(session?.messages || [])}
</span>
</div>
</header>
<div className="game-content">
<div className="messages-container">
{session?.messages.map((message) => (
<div key={message.id} className={`message ${message.role}`}>
<div className="message-content">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
<span className="message-time">
{new Date(message.timestamp).toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
))}
{isLoading && (
<div className="message assistant loading">
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
)}
{error && (
<div className="error-message">
<span> {error}</span>
<button onClick={() => setError(null)}></button>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* RPG кнопки скрыты — раскомментировать при необходимости
<div className="quick-actions">
<button onClick={() => handleQuickAction("Осмотреться вокруг")}>
👀 Осмотреться
</button>
<button onClick={() => handleQuickAction("Проверить инвентарь")}>
🎒 Инвентарь
</button>
<button onClick={() => handleQuickAction("Поговорить с кем-нибудь")}>
💬 Говорить
</button>
<button onClick={() => handleQuickAction("Идти вперёд")}>
🚶 Идти
</button>
</div>
*/}
<div className="input-container">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Что ты хочешь сделать?..."
disabled={isLoading}
rows={2}
/>
<button
onClick={handleSend}
disabled={!input.trim() || isLoading}
className="send-btn"
>
{isLoading ? "⏳" : "➤"}
</button>
</div>
</div>
</div>
);
}
+232
View File
@@ -0,0 +1,232 @@
.stories-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.stories-header {
text-align: center;
margin-bottom: 2rem;
}
.stories-header h1 {
font-size: 2.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.stories-header p {
color: #888;
font-size: 1.1rem;
}
.stories-controls {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 250px;
padding: 0.75rem 1rem;
border: 2px solid #333;
border-radius: 12px;
background: #1a1a1a;
color: white;
font-size: 1rem;
transition: border-color 0.3s;
}
.search-input:focus {
outline: none;
border-color: #667eea;
}
.create-btn {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition:
transform 0.2s,
box-shadow 0.2s;
}
.create-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: #1a1a1a;
border-radius: 20px;
border: 2px dashed #333;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.empty-state h2 {
color: #ccc;
margin-bottom: 0.5rem;
}
.empty-state p {
color: #888;
margin-bottom: 1.5rem;
}
.stories-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
}
.story-card {
background: #1a1a1a;
border-radius: 16px;
overflow: hidden;
border: 1px solid #333;
transition:
transform 0.3s,
box-shadow 0.3s;
}
.story-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
.story-cover {
height: 160px;
background: linear-gradient(135deg, #2d1f3d 0%, #1a1a2e 100%);
display: flex;
align-items: center;
justify-content: center;
background-size: cover;
background-position: center;
position: relative;
}
.cover-placeholder {
font-size: 4rem;
opacity: 0.5;
}
.nsfw-badge {
position: absolute;
top: 10px;
right: 10px;
padding: 0.3rem 0.6rem;
background: linear-gradient(135deg, #ff4757 0%, #ff6b6b 100%);
color: white;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 0 2px 8px rgba(255, 71, 87, 0.4);
}
.story-content {
padding: 1.25rem;
}
.story-content h3 {
margin: 0 0 0.75rem;
font-size: 1.25rem;
color: white;
}
.story-genres {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.genre-tag {
padding: 0.25rem 0.75rem;
background: rgba(102, 126, 234, 0.2);
color: #a0b4ff;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
}
.story-description {
color: #aaa;
font-size: 0.9rem;
line-height: 1.5;
margin-bottom: 1rem;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.story-meta {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
font-size: 0.85rem;
color: #888;
}
.story-actions {
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
}
.btn-view {
background: #2a2a2a;
color: #ccc;
}
.btn-view:hover {
background: #333;
}
.btn-play {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
flex: 1;
}
.btn-play:hover {
opacity: 0.9;
}
.btn-delete {
background: #2a2a2a;
color: #ff6b6b;
}
.btn-delete:hover {
background: rgba(255, 107, 107, 0.2);
}
+153
View File
@@ -0,0 +1,153 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { getStories, deleteStory as apiDeleteStory } from "../services/api";
import type { Story } from "../types";
import "./StoriesPage.css";
export default function StoriesPage() {
const { isAuthenticated, isLoading: authLoading } = useAuth();
const [stories, setStories] = useState<Story[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadStories = async () => {
if (!isAuthenticated) {
setStories([]);
setIsLoading(false);
return;
}
setIsLoading(true);
const data = await getStories();
// Преобразуем _id в id для совместимости
const normalizedStories = data.map((s: any) => ({
...s,
id: s._id || s.id,
}));
setStories(normalizedStories);
setIsLoading(false);
};
if (!authLoading) {
loadStories();
}
}, [isAuthenticated, authLoading]);
const handleDelete = async (id: string) => {
if (confirm("Удалить эту историю?")) {
const success = await apiDeleteStory(id);
if (success) {
setStories(stories.filter((s) => s.id !== id));
}
}
};
const filteredStories = stories.filter(
(story) =>
story.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
story.genre.some((g) =>
g.toLowerCase().includes(searchTerm.toLowerCase()),
),
);
return (
<div className="stories-page">
<header className="stories-header">
<h1>📚 Мои Исекай Истории</h1>
<p>Создавай и играй в уникальные приключения</p>
</header>
{!isAuthenticated ? (
<div className="empty-state">
<div className="empty-icon">🔐</div>
<h2>Войдите, чтобы начать</h2>
<p>Авторизуйтесь через Discord для создания и сохранения историй</p>
</div>
) : isLoading ? (
<div className="loading-state">
<div className="loading-spinner"></div>
<p>Загрузка историй...</p>
</div>
) : (
<>
<div className="stories-controls">
<input
type="text"
placeholder="🔍 Поиск историй..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
<Link to="/create" className="create-btn">
Создать историю
</Link>
</div>
{filteredStories.length === 0 ? (
<div className="empty-state">
<div className="empty-icon">📖</div>
<h2>Нет историй</h2>
<p>Создайте свою первую исекай историю!</p>
<Link to="/create" className="create-btn">
Создать историю
</Link>
</div>
) : (
<div className="stories-grid">
{filteredStories.map((story) => (
<div key={story.id} className="story-card">
<div
className="story-cover"
style={{
backgroundImage: story.coverImage
? `url(${story.coverImage})`
: undefined,
}}
>
{!story.coverImage && (
<span className="cover-placeholder"></span>
)}
{story.isNsfw && (
<span className="nsfw-badge">🔞 NSFW</span>
)}
</div>
<div className="story-content">
<h3>{story.title}</h3>
<div className="story-genres">
{story.genre.map((g) => (
<span key={g} className="genre-tag">
{g}
</span>
))}
</div>
<p className="story-description">{story.description}</p>
<div className="story-meta">
<span>🌍 {story.world.name}</span>
<span>👤 {story.player?.name || "Герой"}</span>
</div>
<div className="story-actions">
<Link to={`/story/${story.id}`} className="btn btn-view">
📖 Подробнее
</Link>
<Link to={`/play/${story.id}`} className="btn btn-play">
🎮 Играть
</Link>
<button
onClick={() => handleDelete(story.id)}
className="btn btn-delete"
>
🗑
</button>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
);
}
+583
View File
@@ -0,0 +1,583 @@
.story-detail-page {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
.back-link {
display: inline-block;
color: #888;
text-decoration: none;
margin-bottom: 1.5rem;
transition: color 0.2s;
}
.back-link:hover {
color: #667eea;
}
.not-found {
text-align: center;
padding: 4rem;
}
.story-hero {
position: relative;
border-radius: 20px;
overflow: hidden;
margin-bottom: 2rem;
min-height: 280px;
}
.hero-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #2d1f3d 0%, #1a1a2e 100%);
background-size: cover;
background-position: center;
}
.hero-bg::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
to top,
rgba(26, 26, 26, 0.95) 0%,
rgba(26, 26, 26, 0.4) 100%
);
}
.hero-content {
position: relative;
z-index: 1;
padding: 3rem 2rem;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 280px;
}
.hero-badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.hero-genres {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.nsfw-hero-badge {
padding: 0.4rem 1rem;
background: linear-gradient(135deg, #ff4757 0%, #ff6b6b 100%);
color: white;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 700;
box-shadow: 0 2px 10px rgba(255, 71, 87, 0.4);
}
.genre-badge {
padding: 0.35rem 1rem;
background: rgba(102, 126, 234, 0.3);
color: #a0b4ff;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.hero-content h1 {
font-size: 2.5rem;
margin: 0 0 0.5rem;
background: linear-gradient(135deg, #fff 0%, #ccc 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-setting {
color: #aaa;
font-size: 1.1rem;
margin: 0;
}
.hero-settings {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.setting-badge {
padding: 0.3rem 0.8rem;
background: rgba(255, 179, 71, 0.2);
color: #ffb347;
border-radius: 12px;
font-size: 0.85rem;
}
.story-details {
background: #1a1a1a;
border-radius: 20px;
padding: 2rem;
border: 1px solid #333;
}
.detail-section {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid #333;
}
.detail-section:last-of-type {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.detail-section h2 {
color: #fff;
font-size: 1.3rem;
margin-bottom: 1rem;
}
.detail-section p {
color: #bbb;
line-height: 1.7;
}
.protagonist-card {
background: #222;
border-radius: 12px;
padding: 1.5rem;
}
.protagonist-card h3 {
color: #667eea;
margin: 0 0 1rem;
font-size: 1.2rem;
}
.protagonist-card p {
margin-bottom: 1rem;
}
.abilities {
margin-top: 1rem;
}
.abilities strong {
color: #fff;
display: block;
margin-bottom: 0.5rem;
}
.abilities-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.ability-tag {
padding: 0.35rem 0.75rem;
background: rgba(118, 75, 162, 0.2);
color: #c9a0ff;
border-radius: 8px;
font-size: 0.85rem;
}
.world-rules {
margin-top: 1.5rem;
}
.world-rules strong {
color: #fff;
display: block;
margin-bottom: 0.5rem;
}
.world-rules ul {
margin: 0;
padding-left: 1.5rem;
color: #aaa;
}
.world-rules li {
margin-bottom: 0.5rem;
}
.session-info {
background: rgba(102, 126, 234, 0.1);
border-radius: 12px;
padding: 1.5rem !important;
border: 1px solid rgba(102, 126, 234, 0.3) !important;
}
.session-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
}
.stat {
text-align: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
}
.stat-label {
display: block;
color: #888;
font-size: 0.8rem;
margin-bottom: 0.5rem;
}
.stat-value {
display: block;
color: #fff;
font-size: 1.1rem;
font-weight: 600;
}
.story-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
flex-wrap: wrap;
}
.action-btn {
padding: 0.875rem 1.5rem;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
}
.play-btn {
flex: 1;
min-width: 200px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-align: center;
}
.play-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
}
.edit-btn {
background: #2a2a2a;
color: #ccc;
}
.edit-btn:hover {
background: #333;
}
.delete-btn {
background: rgba(255, 107, 107, 0.1);
color: #ff6b6b;
border: 1px solid rgba(255, 107, 107, 0.3);
}
.delete-btn:hover {
background: rgba(255, 107, 107, 0.2);
}
.story-meta-footer {
display: flex;
justify-content: space-between;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #333;
font-size: 0.85rem;
color: #666;
}
/* Персонажи */
.characters-grid {
display: grid;
gap: 1rem;
}
.character-item {
background: #222;
border-radius: 12px;
padding: 1.25rem;
border: 1px solid #333;
}
.character-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.character-item h4 {
margin: 0;
color: #fff;
font-size: 1.1rem;
}
.role-badge {
padding: 0.25rem 0.75rem;
background: rgba(102, 126, 234, 0.2);
color: #a0b4ff;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
}
.character-item p {
margin: 0;
font-size: 0.9rem;
}
@media (max-width: 600px) {
.hero-content h1 {
font-size: 1.8rem;
}
.story-actions {
flex-direction: column;
}
.action-btn {
text-align: center;
}
}
/* Модальное окно выбора персонажа */
.character-select-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.character-select-modal {
background: #1a1a1a;
border-radius: 20px;
padding: 2rem;
max-width: 600px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
border: 1px solid #333;
}
.character-select-modal h2 {
margin: 0 0 0.5rem;
color: #fff;
font-size: 1.5rem;
}
.select-hint {
color: #888;
margin: 0 0 1.5rem;
font-size: 0.95rem;
}
.character-select-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.character-select-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: #222;
border-radius: 12px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.character-select-card:hover {
border-color: #444;
background: #2a2a2a;
}
.character-select-card.selected {
border-color: #667eea;
background: rgba(102, 126, 234, 0.1);
}
.character-select-card.favorite {
border-color: rgba(255, 215, 0, 0.4);
}
.favorite-star {
position: absolute;
top: 0.5rem;
right: 0.5rem;
font-size: 1rem;
}
.character-select-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
overflow: hidden;
background: #333;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.character-select-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.character-select-avatar span {
font-size: 1.5rem;
}
.character-select-info {
flex: 1;
min-width: 0;
}
.character-select-info h4 {
margin: 0 0 0.25rem;
color: #fff;
font-size: 1.1rem;
}
.character-select-info p {
margin: 0;
color: #888;
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.check-mark {
position: absolute;
right: 1rem;
width: 28px;
height: 28px;
background: #667eea;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 1rem;
}
.character-select-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.character-select-actions .btn-cancel {
padding: 0.75rem 1.25rem;
background: #2a2a2a;
color: #aaa;
border: none;
border-radius: 10px;
cursor: pointer;
font-size: 0.95rem;
transition: all 0.2s;
}
.character-select-actions .btn-cancel:hover {
background: #333;
}
.character-select-actions .btn-create {
padding: 0.75rem 1.25rem;
background: rgba(118, 75, 162, 0.2);
color: #c9a0ff;
border: 1px solid rgba(118, 75, 162, 0.4);
border-radius: 10px;
text-decoration: none;
font-size: 0.95rem;
transition: all 0.2s;
}
.character-select-actions .btn-create:hover {
background: rgba(118, 75, 162, 0.3);
}
.character-select-actions .btn-confirm {
flex: 1;
padding: 0.75rem 1.25rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
transition: all 0.2s;
}
.character-select-actions .btn-confirm:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.character-select-actions .btn-confirm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Summary section */
.summary-section {
text-align: center;
padding: 1.5rem !important;
background: rgba(102, 126, 234, 0.05);
border-radius: 12px;
margin-bottom: 2rem;
border-bottom: none !important;
}
.summary-text {
font-size: 1.15rem;
font-style: italic;
color: #ccc;
margin: 0;
line-height: 1.6;
}
/* Plot content */
.plot-content {
color: #bbb;
line-height: 1.8;
}
.plot-content p {
margin-bottom: 1rem;
}
.plot-content p:last-child {
margin-bottom: 0;
}
+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>
);
}
+302
View File
@@ -0,0 +1,302 @@
// API сервис для работы с бэкендом
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3001";
// ============ AUTH ============
export interface User {
id: string;
discordId: string;
username: string;
email: string;
avatar: string | null;
}
export function getDiscordLoginUrl(): string {
return `${API_URL}/auth/discord`;
}
export async function getCurrentUser(): Promise<User | null> {
try {
const response = await fetch(`${API_URL}/auth/me`, {
credentials: "include",
});
const data = await response.json();
return data.user;
} catch (error) {
console.error("Failed to get current user:", error);
return null;
}
}
export async function logout(): Promise<boolean> {
try {
const response = await fetch(`${API_URL}/auth/logout`, {
method: "POST",
credentials: "include",
});
return response.ok;
} catch (error) {
console.error("Failed to logout:", error);
return false;
}
}
export function getDiscordAvatarUrl(user: User): string {
if (user.avatar) {
return `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png`;
}
// Default Discord avatar
const defaultAvatarIndex = parseInt(user.discordId) % 5;
return `https://cdn.discordapp.com/embed/avatars/${defaultAvatarIndex}.png`;
}
// ============ STORIES ============
import type { Story } from "../types";
export async function getStories(): Promise<Story[]> {
try {
const response = await fetch(`${API_URL}/api/stories`, {
credentials: "include",
});
if (response.status === 401) {
return [];
}
if (!response.ok) {
throw new Error("Failed to fetch stories");
}
return await response.json();
} catch (error) {
console.error("Failed to get stories:", error);
return [];
}
}
export async function getStory(id: string): Promise<Story | null> {
try {
const response = await fetch(`${API_URL}/api/stories/${id}`, {
credentials: "include",
});
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error("Failed to get story:", error);
return null;
}
}
export async function createStory(
story: Omit<Story, "id" | "createdAt" | "updatedAt">,
): Promise<Story | null> {
try {
const response = await fetch(`${API_URL}/api/stories`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(story),
});
if (!response.ok) {
throw new Error("Failed to create story");
}
const data = await response.json();
return { ...data, id: data._id };
} catch (error) {
console.error("Failed to create story:", error);
return null;
}
}
export async function updateStory(
id: string,
story: Partial<Story>,
): Promise<boolean> {
try {
const response = await fetch(`${API_URL}/api/stories/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(story),
});
return response.ok;
} catch (error) {
console.error("Failed to update story:", error);
return false;
}
}
export async function deleteStory(id: string): Promise<boolean> {
try {
const response = await fetch(`${API_URL}/api/stories/${id}`, {
method: "DELETE",
credentials: "include",
});
return response.ok;
} catch (error) {
console.error("Failed to delete story:", error);
return false;
}
}
// ============ GAME SESSIONS ============
import type { GameSession } from "../types";
export async function getSession(storyId: string): Promise<GameSession | null> {
try {
const response = await fetch(`${API_URL}/api/sessions/${storyId}`, {
credentials: "include",
});
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error("Failed to get session:", error);
return null;
}
}
export async function saveSession(
storyId: string,
session: Omit<GameSession, "storyId">,
): Promise<boolean> {
try {
const response = await fetch(`${API_URL}/api/sessions/${storyId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(session),
});
return response.ok;
} catch (error) {
console.error("Failed to save session:", error);
return false;
}
}
// ============ PLAYER CHARACTERS ============
import type { PlayerCharacter } from "../types";
export async function getPlayerCharacters(): Promise<PlayerCharacter[]> {
try {
const response = await fetch(`${API_URL}/api/characters`, {
credentials: "include",
});
if (response.status === 401) {
return [];
}
if (!response.ok) {
throw new Error("Failed to fetch characters");
}
const data = await response.json();
return data.map((c: any) => ({ ...c, id: c._id || c.id }));
} catch (error) {
console.error("Failed to get characters:", error);
return [];
}
}
export async function getPlayerCharacter(
id: string,
): Promise<PlayerCharacter | null> {
try {
const response = await fetch(`${API_URL}/api/characters/${id}`, {
credentials: "include",
});
if (!response.ok) {
return null;
}
const data = await response.json();
return { ...data, id: data._id || data.id };
} catch (error) {
console.error("Failed to get character:", error);
return null;
}
}
export async function createPlayerCharacter(
character: Omit<PlayerCharacter, "id" | "userId" | "createdAt" | "updatedAt">,
): Promise<PlayerCharacter | null> {
try {
const response = await fetch(`${API_URL}/api/characters`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(character),
});
if (!response.ok) {
throw new Error("Failed to create character");
}
const data = await response.json();
return { ...data, id: data._id };
} catch (error) {
console.error("Failed to create character:", error);
return null;
}
}
export async function updatePlayerCharacter(
id: string,
character: Partial<PlayerCharacter>,
): Promise<boolean> {
try {
const response = await fetch(`${API_URL}/api/characters/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(character),
});
return response.ok;
} catch (error) {
console.error("Failed to update character:", error);
return false;
}
}
export async function deletePlayerCharacter(id: string): Promise<boolean> {
try {
const response = await fetch(`${API_URL}/api/characters/${id}`, {
method: "DELETE",
credentials: "include",
});
return response.ok;
} catch (error) {
console.error("Failed to delete character:", error);
return false;
}
}
+386
View File
@@ -0,0 +1,386 @@
// DeepSeek API сервис для генерации историй
import type {
Story,
ChatMessage,
PlayerCharacter,
GameSession,
} from "../types";
const DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions";
// Настройки контекста
const RECENT_MESSAGES_COUNT = 10; // Последние N сообщений для контекста
const SUMMARY_THRESHOLD = 20; // После скольких сообщений генерировать сводку
// API ключ должен храниться в переменных окружения
const getApiKey = () => import.meta.env.VITE_DEEPSEEK_API_KEY || "";
interface DeepSeekMessage {
role: "system" | "user" | "assistant";
content: string;
}
interface DeepSeekResponse {
choices: {
message: {
content: string;
};
}[];
usage?: {
prompt_tokens: number;
completion_tokens: number;
prompt_cache_hit_tokens?: number;
prompt_cache_miss_tokens?: number;
};
}
export async function sendMessage(
messages: DeepSeekMessage[],
temperature: number = 0.8,
): Promise<string> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error(
"DeepSeek API ключ не настроен. Добавьте VITE_DEEPSEEK_API_KEY в .env файл",
);
}
const response = await fetch(DEEPSEEK_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages,
temperature,
max_tokens: 1000,
}),
});
if (!response.ok) {
throw new Error(`DeepSeek API error: ${response.status}`);
}
const data: DeepSeekResponse = await response.json();
// Логируем использование кэша (для отладки)
if (data.usage) {
console.log(
`[DeepSeek] Tokens - Prompt: ${data.usage.prompt_tokens}, Completion: ${data.usage.completion_tokens}`,
);
if (data.usage.prompt_cache_hit_tokens !== undefined) {
console.log(
`[DeepSeek] Cache - Hit: ${data.usage.prompt_cache_hit_tokens}, Miss: ${data.usage.prompt_cache_miss_tokens}`,
);
}
}
return data.choices[0]?.message?.content || "";
}
/**
* Строит базовый системный промпт (правила стиля) - КЭШИРУЕТСЯ
*/
export function buildStyleRules(
story: Story,
player?: PlayerCharacter,
): string {
const settingInfo = Array.isArray(story.setting)
? story.setting.join(", ")
: story.setting;
const playerDescription = player?.description
? player.description.replace(/\{user\}/gi, player.name)
: "Не указано";
// NSFW блок
const nsfwBlock = story.isNsfw
? `
=== NSFW РЕЖИМ АКТИВЕН ===
Это история для взрослых. Сняты ограничения на:
— Сцены физического насилия, крови, травм и смерти
— Сцены сексуального характера и интимной близости
— Тёмные темы: пытки, жестокость, моральная серость
— Откровенные описания тел, ран, боли
ПРАВИЛА NSFW:
1. Описывай сцены детально, но художественно
2. Насилие должно иметь последствия и вес
3. Интимные сцены — по инициативе игрока
4. Не цензурируй происходящее
5. Сохраняй атмосферу и тон истории
`
: "";
// Если есть пользовательские правила - используем их
if (story.narrativeRules && story.narrativeRules.trim()) {
return `${story.narrativeRules}
${nsfwBlock}
=== МЕТАДАННЫЕ ===
ЯЗЫК: ${story.language}
ЖАНР: ${story.genre.join(", ")}
СЕТТИНГ: ${settingInfo}
=== ПЕРСОНАЖ ИГРОКА ===
Имя: ${player?.name || "Герой"}
Описание: ${playerDescription}`;
}
// Дефолтные правила для историй без кастомных настроек
return `Ты — РассказчикGPT, ведущий интерактивную историю.
=== МЕТАДАННЫЕ ===
ЯЗЫК: ${story.language}
ЖАНР: ${story.genre.join(", ")}
СЕТТИНГ: ${settingInfo}
${nsfwBlock}
=== ПРАВИЛА ПОВЕСТВОВАНИЯ ===
1. Игрок сам пишет свои действия и реплики
2. Вплетай действия игрока в сцену, описывай реакции персонажей и последствия
3. НИКОГДА не принимай решений за игрока
4. НИКОГДА не задавай вопросы игроку
5. НИКОГДА не предлагай варианты действий
6. НИКОГДА не додумывай намерения игрока
7. Не делай таймскипов без явного указания
8. Если действие не написано игроком — оно НЕ произошло
=== ПРАВИЛА ГЛАВНОГО ГЕРОЯ ===
Ты НЕ ИМЕЕШЬ ПРАВА:
— описывать физические действия ГГ
— описывать его внутренние ощущения, эмоции или мысли
— вкладывать в него реплики или слова
Пока игрок сам не напишет действие или реплику,
герой считается неподвижным, молчащим и наблюдающим.
=== РЕАКЦИИ НА РЕПЛИКИ ГГ ===
Персонажи обязаны явно реагировать на слова ГГ:
— отвечать на заданные вопросы
— менять тон, поведение или атмосферу
— показывать паузы, напряжение, замешательство
Запрещено игнорировать реплики ГГ.
=== ФОРМАТ ДИАЛОГОВ (ОБЯЗАТЕЛЬНО!) ===
ВСЕ реплики персонажей оформляй через двойные звёздочки: **"текст реплики"**
Пример правильного формата:
Бекк нахмурилась. **"Не доверяю ему,"** — процедила она сквозь зубы.
Лапис мягко улыбнулась. **"Всё будет хорошо."**
НЕ используй обычные кавычки без звёздочек!
Описание действий и окружения — обычным текстом без звёздочек.
Отвечай на языке: ${story.language}
=== ПЕРСОНАЖ ИГРОКА ===
Имя: ${player?.name || "Герой"}
Описание: ${playerDescription}`;
}
/**
* Строит контекст мира (лор) - КЭШИРУЕТСЯ
*/
export function buildWorldContext(story: Story): string {
const charactersInfo =
story.characters.length > 0
? story.characters
.map((c) => `- ${c.name} (${c.role}): ${c.description}`)
.join("\n")
: "Не указаны";
return `
=== МИР ===
Название: ${story.world.name}
Описание: ${story.world.description}
Правила мира: ${story.world.rules.join("; ")}
=== ПЕРСОНАЖИ МИРА ===
${charactersInfo}
=== ОСНОВНОЙ СЮЖЕТ ===
${story.plot}`;
}
/**
* Строит динамический контекст (состояние + сводка)
*/
export function buildDynamicContext(session: GameSession): string {
const state = session.currentState;
const summary = session.storySummary || "История только началась.";
const keyEvents = session.keyEvents?.length
? session.keyEvents.slice(-5).join("\n- ")
: "Пока нет значимых событий.";
return `
=== ТЕКУЩЕЕ СОСТОЯНИЕ ===
Локация: ${state.location}
Здоровье: ${state.health}%
Инвентарь: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Пусто"}
=== СВОДКА ИСТОРИИ ===
${summary}
=== КЛЮЧЕВЫЕ СОБЫТИЯ ===
- ${keyEvents}`;
}
/**
* Полный системный промпт (для обратной совместимости)
*/
export function buildSystemPrompt(
story: Story,
player?: PlayerCharacter,
): string {
return buildStyleRules(story, player) + "\n" + buildWorldContext(story);
}
/**
* Генерирует ответ с оптимизированным контекстом
*/
export async function generateStoryResponse(
story: Story,
chatHistory: ChatMessage[],
userMessage: string,
player?: PlayerCharacter,
session?: GameSession,
): Promise<string> {
// 1. Правила стиля (кэшируется DeepSeek)
const styleRules = buildStyleRules(story, player);
// 2. Контекст мира (кэшируется DeepSeek)
const worldContext = buildWorldContext(story);
// 3. Динамический контекст (состояние + сводка)
const dynamicContext = session ? buildDynamicContext(session) : "";
// 4. Последние N сообщений (не вся история!)
const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT);
// Собираем финальный системный промпт
const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext;
const messages: DeepSeekMessage[] = [
{ role: "system", content: systemPrompt },
...recentMessages.map((msg) => ({
role: msg.role as "user" | "assistant",
content: msg.content,
})),
{ role: "user", content: userMessage },
];
// Используем temperature из настроек истории (по умолчанию 1.3)
return sendMessage(messages, story.temperature || 1.3);
}
/**
* Генерирует сводку истории (вызывать периодически)
*/
export async function generateStorySummary(
story: Story,
messages: ChatMessage[],
previousSummary?: string,
): Promise<string> {
// Берём сообщения для суммаризации (исключая последние, они свежие)
const messagesToSummarize = messages.slice(0, -RECENT_MESSAGES_COUNT);
if (messagesToSummarize.length < SUMMARY_THRESHOLD) {
return previousSummary || "";
}
const conversationText = messagesToSummarize
.map((m) => `${m.role === "user" ? "Игрок" : "Рассказчик"}: ${m.content}`)
.join("\n\n");
const prompt = previousSummary
? `Обнови сводку истории, добавив новые события.
ПРЕДЫДУЩАЯ СВОДКА:
${previousSummary}
НОВЫЕ СОБЫТИЯ:
${conversationText}
Напиши обновлённую сводку (3-5 предложений), сохраняя ключевые моменты:`
: `Создай краткую сводку событий этой истории (3-5 предложений):
${conversationText}
Сводка должна содержать: что произошло, где находится герой, какие важные решения принял:`;
const summaryMessages: DeepSeekMessage[] = [
{
role: "system",
content: `Ты - помощник для создания сводок историй. Пиши кратко и по существу на ${story.language}.`,
},
{ role: "user", content: prompt },
];
return sendMessage(summaryMessages, 0.3);
}
/**
* Извлекает ключевые события из ответа AI
*/
export async function extractKeyEvents(
aiResponse: string,
existingEvents: string[] = [],
): Promise<string[]> {
// Простая эвристика: ищем важные действия
const importantPatterns = [
/(?:ты |вы )(?:получил|нашёл|победил|убил|спас|встретил|узнал|открыл|достиг)/gi,
/(?:новый |новая |новое )(?:квест|задание|способность|предмет|союзник)/gi,
/(?:умер|погиб|потерял|предал)/gi,
];
const newEvents: string[] = [];
for (const pattern of importantPatterns) {
const matches = aiResponse.match(pattern);
if (matches) {
// Извлекаем предложение с событием
const sentences = aiResponse.split(/[.!?]/);
for (const sentence of sentences) {
if (
pattern.test(sentence) &&
sentence.length > 20 &&
sentence.length < 150
) {
newEvents.push(sentence.trim());
break;
}
}
}
}
// Объединяем с существующими, ограничиваем до 10
const allEvents = [...existingEvents, ...newEvents];
return allEvents.slice(-10);
}
export async function generateStoryDescription(
title: string,
genre: string[],
setting: string[],
worldDescription: string,
): Promise<string> {
const messages: DeepSeekMessage[] = [
{
role: "system",
content:
"Ты - писатель исекай историй. Создавай краткие, захватывающие описания для историй.",
},
{
role: "user",
content: `Создай краткое описание (2-3 предложения) для исекай истории:
Название: ${title}
Жанр: ${genre.join(", ")}
Сеттинг: ${setting.join(", ")}
Мир: ${worldDescription}
Описание должно быть интригующим и заставлять хотеть начать приключение.`,
},
];
return sendMessage(messages, 0.7);
}
+96
View File
@@ -0,0 +1,96 @@
// Сервис для хранения данных в localStorage
import type { Story, GameSession } from "../types";
const STORIES_KEY = "resekai_stories";
const SESSIONS_KEY = "resekai_sessions";
// Истории
export function getStories(): Story[] {
const data = localStorage.getItem(STORIES_KEY);
if (!data) return [];
const stories = JSON.parse(data);
return stories.map((s: Story) => ({
...s,
createdAt: new Date(s.createdAt),
updatedAt: new Date(s.updatedAt),
}));
}
export function getStoryById(id: string): Story | undefined {
const stories = getStories();
return stories.find((s) => s.id === id);
}
export function saveStory(story: Story): void {
const stories = getStories();
const index = stories.findIndex((s) => s.id === story.id);
if (index >= 0) {
stories[index] = story;
} else {
stories.push(story);
}
localStorage.setItem(STORIES_KEY, JSON.stringify(stories));
}
export function deleteStory(id: string): void {
const stories = getStories().filter((s) => s.id !== id);
localStorage.setItem(STORIES_KEY, JSON.stringify(stories));
// Удаляем связанные сессии
const sessions = getSessions().filter((s) => s.storyId !== id);
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
}
// Игровые сессии
export function getSessions(): GameSession[] {
const data = localStorage.getItem(SESSIONS_KEY);
if (!data) return [];
const sessions = JSON.parse(data);
return sessions.map((s: GameSession) => ({
...s,
createdAt: new Date(s.createdAt),
updatedAt: new Date(s.updatedAt),
messages: s.messages.map((m) => ({
...m,
timestamp: new Date(m.timestamp),
})),
}));
}
export function getSessionByStoryId(storyId: string): GameSession | undefined {
const sessions = getSessions();
return sessions.find((s) => s.storyId === storyId);
}
export function getSessionById(id: string): GameSession | undefined {
const sessions = getSessions();
return sessions.find((s) => s.id === id);
}
export function saveSession(session: GameSession): void {
const sessions = getSessions();
const index = sessions.findIndex((s) => s.id === session.id);
if (index >= 0) {
sessions[index] = session;
} else {
sessions.push(session);
}
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
}
export function deleteSession(id: string): void {
const sessions = getSessions().filter((s) => s.id !== id);
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions));
}
// Генерация ID
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
+76
View File
@@ -0,0 +1,76 @@
// Типы для историй и чата
export interface Character {
name: string;
description: string;
role: string; // например: "союзник", "злодей", "нейтральный NPC"
}
// Персонаж пользователя (для игры)
export interface PlayerCharacter {
id: string;
userId: string;
name: string;
description: string;
avatarUrl?: string;
isFavorite?: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface Story {
id: string;
title: string;
description: string;
coverImage: string;
language: string;
genre: string[];
setting: string[];
summary: string; // краткое содержание (до 20 слов, не для ИИ)
plot: string; // сюжет (для ИИ, поддерживает Markdown)
firstMessage: string; // первое сообщение ИИ
characters: Character[]; // NPC персонажи мира
isNsfw: boolean; // NSFW контент
narrativeRules?: string; // правила повествования для ИИ (стиль, запреты, формат)
temperature?: number; // креативность ИИ (1.0, 1.3, 1.5)
world: {
name: string;
description: string;
rules: string[];
};
createdAt: Date;
updatedAt: Date;
}
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
timestamp: Date;
}
export interface GameSession {
id?: string;
storyId: string;
playerId?: string; // ID выбранного персонажа игрока
messages: ChatMessage[];
currentState: {
location: string;
health: number;
inventory: string[];
questProgress: Record<string, boolean>;
};
// Сводка важных событий для контекста AI
storySummary?: string;
// Ключевые события (для краткой памяти)
keyEvents?: string[];
createdAt?: Date;
updatedAt?: Date;
}
export interface CreateStoryForm {
title: string;
genre: string[];
setting: string[];
worldDescription: string;
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})