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