first commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# DeepSeek API ключ для генерации историй
|
||||||
|
VITE_DEEPSEEK_API_KEY=your_api_key_here
|
||||||
|
|
||||||
|
# URL бэкенд сервера
|
||||||
|
VITE_API_URL=http://localhost:3001
|
||||||
+45
@@ -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
|
||||||
@@ -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...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -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
@@ -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>
|
||||||
Generated
+4485
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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
|
||||||
@@ -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
@@ -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}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+1200
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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;
|
||||||
@@ -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 |
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user