This commit is contained in:
commit
46af290db3
100 files changed
+16663
No files matched your search
@@ -0,0 +1,38 @@
|
||||
# Node modules (устанавливаются в Docker)
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Next.js build output (собирается в Docker)
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Документация
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Тесты
|
||||
tests/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
|
||||
# Прочее
|
||||
.env*
|
||||
!.env.example
|
||||
*.log
|
||||
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
## This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1,55 @@
|
||||
# Этап 1: Установка зависимостей
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем package.json и package-lock.json
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Устанавливаем зависимости
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Этап 2: Сборка приложения
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем зависимости из предыдущего этапа
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Переменные окружения для сборки (если нужны)
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Сборка Next.js
|
||||
RUN npm run build
|
||||
|
||||
# Этап 3: Production runtime
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Переменные окружения
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3000 \
|
||||
HOSTNAME="0.0.0.0"
|
||||
|
||||
# Создаём группу и пользователя для безопасности
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
# Копируем собранные файлы из builder
|
||||
# Standalone output содержит только необходимое для запуска
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Устанавливаем владельца файлов
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
# Переключаемся на непривилегированного пользователя
|
||||
USER nextjs
|
||||
|
||||
# Открываем порт
|
||||
EXPOSE 3000
|
||||
|
||||
# Команда запуска
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone', // Критично для Docker!
|
||||
reactStrictMode: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
cacheComponents: true,
|
||||
partialPrefetching: true,
|
||||
turbopack: {
|
||||
rules: {
|
||||
"*.css": {
|
||||
loaders: ["@tailwindcss/turbopack"],
|
||||
as: "*.css",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+7619
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tiptap/extension-blockquote": "^3.32.2",
|
||||
"@tiptap/extension-bubble-menu": "^3.32.2",
|
||||
"@tiptap/extension-color": "^3.32.2",
|
||||
"@tiptap/extension-font-family": "^3.32.2",
|
||||
"@tiptap/extension-text-style": "^3.32.2",
|
||||
"@tiptap/extension-underline": "^3.32.2",
|
||||
"@tiptap/react": "^3.32.2",
|
||||
"@tiptap/starter-kit": "^3.32.2",
|
||||
"axios": "^1.21.0",
|
||||
"lucide-react": "^1.67.0",
|
||||
"next": "16.4.4",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"zustand": "^5.0.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/turbopack": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.4.4",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,5 @@
|
||||
import DiffTest from '@/components/test/DiffTest';
|
||||
|
||||
export default function DiffTestPage() {
|
||||
return <DiffTest />;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,27 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import AuthScreen from '@/components/auth/AuthScreen';
|
||||
import LeftPanel from '@/components/sidebar/LeftPanel';
|
||||
import StoryBiblePanel from '@/components/sidebar/StoryBiblePanel';
|
||||
import { Editor } from '@/components/editor/Editor';
|
||||
import ModelSettingsPanel from '@/components/settings/ModelSettings';
|
||||
|
||||
export default function Home() {
|
||||
const { isAuthenticated, isLoadingAuth } = useAppStore();
|
||||
|
||||
if (isLoadingAuth) {
|
||||
return null; // AuthScreen показывает спиннер при checkAuth
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <AuthScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-900 overflow-hidden">
|
||||
{/* Left panel - Projects + Document Tree */}
|
||||
<div className="w-80 border-r border-gray-700 flex-shrink-0">
|
||||
<LeftPanel />
|
||||
</div>
|
||||
|
||||
{/* Middle panel - Story Bible */}
|
||||
<div className="w-80 border-r border-gray-700 flex-shrink-0">
|
||||
<StoryBiblePanel />
|
||||
</div>
|
||||
|
||||
{/* Main content - Editor */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<Editor />
|
||||
</div>
|
||||
|
||||
{/* Model settings button */}
|
||||
<ModelSettingsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import AITest from '@/components/test/AITest';
|
||||
|
||||
export default function TestPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 p-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<AITest />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { BookOpen, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function AuthScreen() {
|
||||
const { login, register, isLoadingAuth } = useAppStore();
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (isLogin) {
|
||||
await login(username, password);
|
||||
} else {
|
||||
await register(username, email, password);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Ошибка авторизации');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingAuth) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-900 text-gray-100">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-900 p-4">
|
||||
<div className="w-full max-w-md bg-gray-800 rounded-xl shadow-2xl border border-gray-700 overflow-hidden">
|
||||
<div className="p-8 text-center border-b border-gray-700">
|
||||
<BookOpen className="w-12 h-12 text-blue-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-gray-100">Writer IDE</h1>
|
||||
<p className="text-gray-400 mt-2">Ваше пространство для творчества</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="flex mb-6 bg-gray-700 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => { setIsLogin(true); setError(''); }}
|
||||
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isLogin ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Вход
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsLogin(false); setError(''); }}
|
||||
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
!isLogin ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Регистрация
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Имя пользователя</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full p-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full p-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full p-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-900/30 border border-red-800 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
{isLogin ? 'Войти' : 'Создать аккаунт'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-gray-500 mt-6">
|
||||
{isLogin ? 'Нет аккаунта? ' : 'Уже есть аккаунт? '}
|
||||
<button
|
||||
onClick={() => { setIsLogin(!isLogin); setError(''); }}
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{isLogin ? 'Зарегистрироваться' : 'Войти'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { Check, X } from 'lucide-react';
|
||||
|
||||
interface AIDiffViewProps {
|
||||
originalText: string;
|
||||
aiText: string;
|
||||
onAccept: () => void;
|
||||
onReject: () => void;
|
||||
}
|
||||
|
||||
export default function AIDiffView({ originalText, aiText, onAccept, onReject }: AIDiffViewProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-100">Предпросмотр изменений</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onAccept}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
Принять
|
||||
</button>
|
||||
<button
|
||||
onClick={onReject}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Отклонить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6 grid grid-cols-2 gap-6">
|
||||
{/* Original */}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-300 uppercase">Оригинал</h3>
|
||||
</div>
|
||||
<div className="flex-1 p-4 bg-red-900/20 border border-red-800/30 rounded-lg text-gray-100 whitespace-pre-wrap leading-relaxed">
|
||||
{originalText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Suggestion */}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-300 uppercase">Предложение AI</h3>
|
||||
</div>
|
||||
<div className="flex-1 p-4 bg-green-900/20 border border-green-800/30 rounded-lg text-gray-100 whitespace-pre-wrap leading-relaxed">
|
||||
{aiText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="p-4 border-t border-gray-700 bg-gray-850">
|
||||
<p className="text-sm text-gray-400 text-center">
|
||||
Нажмите "Принять" для замены выделенного текста или "Отклонить" для отмены
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Editor } from '@tiptap/react';
|
||||
import { BubbleMenu } from '@tiptap/react/menus';
|
||||
import { Sparkles, RefreshCw, Expand, Minimize, MessageSquare, PlusCircle } from 'lucide-react';
|
||||
|
||||
interface AIMenuProps {
|
||||
editor: Editor | null;
|
||||
onAction: (action: string, selectedText: string) => void;
|
||||
}
|
||||
|
||||
type AIAction = 'rewrite' | 'expand' | 'simplify' | 'custom' | 'add_to_bible';
|
||||
|
||||
export default function AIMenu({ editor, onAction }: AIMenuProps) {
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
const handleAction = (action: AIAction) => {
|
||||
const { from, to } = editor.state.selection;
|
||||
const selectedText = editor.state.doc.textBetween(from, to, ' ');
|
||||
|
||||
if (!selectedText.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
onAction(action, selectedText);
|
||||
setShowDropdown(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
shouldShow={({ editor, from, to }) => {
|
||||
return from !== to && !editor.isActive('image');
|
||||
}}
|
||||
>
|
||||
<div className="relative inline-block">
|
||||
{/* Кнопка вызова AI меню */}
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg shadow-lg transition-colors text-sm font-medium"
|
||||
title="AI действия"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
AI
|
||||
</button>
|
||||
|
||||
{/* Dropdown с действиями */}
|
||||
{showDropdown && (
|
||||
<div className="absolute top-full left-0 mt-2 w-56 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50">
|
||||
<div className="p-2 space-y-1">
|
||||
<button
|
||||
onClick={() => handleAction('rewrite')}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left text-gray-200 hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 text-blue-400" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">Переписать</div>
|
||||
<div className="text-xs text-gray-400">Сделать более живым</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAction('expand')}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left text-gray-200 hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
<Expand className="w-4 h-4 text-green-400" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">Расширить</div>
|
||||
<div className="text-xs text-gray-400">Добавить деталей</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAction('simplify')}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left text-gray-200 hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
<Minimize className="w-4 h-4 text-yellow-400" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">Упростить</div>
|
||||
<div className="text-xs text-gray-400">Сделать короче</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="border-t border-gray-700 my-1" />
|
||||
|
||||
<button
|
||||
onClick={() => handleAction('custom')}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left text-gray-200 hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 text-purple-400" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">Свой промпт...</div>
|
||||
<div className="text-xs text-gray-400">Указать задачу</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAction('add_to_bible')}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left text-gray-200 hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
<PlusCircle className="w-4 h-4 text-pink-400" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">Добавить в Story Bible</div>
|
||||
<div className="text-xs text-gray-400">Извлечь сущности</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X, User, MapPin, Calendar, Check } from 'lucide-react';
|
||||
|
||||
interface ExtractedEntity {
|
||||
name: string;
|
||||
description: string;
|
||||
traits?: string[];
|
||||
appearance?: string;
|
||||
role?: string;
|
||||
atmosphere?: string;
|
||||
type?: string;
|
||||
importance?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface AddToBibleModalProps {
|
||||
extractedEntities: {
|
||||
new_characters: ExtractedEntity[];
|
||||
new_locations: ExtractedEntity[];
|
||||
new_events: ExtractedEntity[];
|
||||
};
|
||||
onConfirm: (selected: {
|
||||
characters: ExtractedEntity[];
|
||||
locations: ExtractedEntity[];
|
||||
events: ExtractedEntity[];
|
||||
}) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AddToBibleModal({ extractedEntities, onConfirm, onClose }: AddToBibleModalProps) {
|
||||
const [selectedChars, setSelectedChars] = useState<Set<number>>(
|
||||
new Set(extractedEntities.new_characters.map((_, i) => i))
|
||||
);
|
||||
const [selectedLocs, setSelectedLocs] = useState<Set<number>>(
|
||||
new Set(extractedEntities.new_locations.map((_, i) => i))
|
||||
);
|
||||
const [selectedEvents, setSelectedEvents] = useState<Set<number>>(
|
||||
new Set(extractedEntities.new_events.map((_, i) => i))
|
||||
);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm({
|
||||
characters: extractedEntities.new_characters.filter((_, i) => selectedChars.has(i)),
|
||||
locations: extractedEntities.new_locations.filter((_, i) => selectedLocs.has(i)),
|
||||
events: extractedEntities.new_events.filter((_, i) => selectedEvents.has(i)),
|
||||
});
|
||||
};
|
||||
|
||||
const totalSelected = selectedChars.size + selectedLocs.size + selectedEvents.size;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-100">Добавить в Story Bible</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-200">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||
{/* Characters */}
|
||||
{extractedEntities.new_characters.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-100 flex items-center gap-2 mb-3">
|
||||
<User className="w-5 h-5 text-blue-400" />
|
||||
Персонажи ({extractedEntities.new_characters.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{extractedEntities.new_characters.map((char, idx) => (
|
||||
<label
|
||||
key={idx}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedChars.has(idx)
|
||||
? 'bg-blue-900/20 border-blue-600'
|
||||
: 'bg-gray-700/30 border-gray-600 hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedChars.has(idx)}
|
||||
onChange={(e) => {
|
||||
const newSet = new Set(selectedChars);
|
||||
if (e.target.checked) {
|
||||
newSet.add(idx);
|
||||
} else {
|
||||
newSet.delete(idx);
|
||||
}
|
||||
setSelectedChars(newSet);
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-gray-100">{char.name}</div>
|
||||
<div className="text-sm text-gray-400 mt-1">{char.description}</div>
|
||||
{char.traits && char.traits.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{char.traits.map((trait, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-blue-900/30 text-blue-300 text-xs rounded">
|
||||
{trait}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Locations */}
|
||||
{extractedEntities.new_locations.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-100 flex items-center gap-2 mb-3">
|
||||
<MapPin className="w-5 h-5 text-green-400" />
|
||||
Локации ({extractedEntities.new_locations.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{extractedEntities.new_locations.map((loc, idx) => (
|
||||
<label
|
||||
key={idx}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedLocs.has(idx)
|
||||
? 'bg-green-900/20 border-green-600'
|
||||
: 'bg-gray-700/30 border-gray-600 hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedLocs.has(idx)}
|
||||
onChange={(e) => {
|
||||
const newSet = new Set(selectedLocs);
|
||||
if (e.target.checked) {
|
||||
newSet.add(idx);
|
||||
} else {
|
||||
newSet.delete(idx);
|
||||
}
|
||||
setSelectedLocs(newSet);
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-gray-100">{loc.name}</div>
|
||||
<div className="text-sm text-gray-400 mt-1">{loc.description}</div>
|
||||
{loc.atmosphere && (
|
||||
<div className="text-xs text-gray-500 mt-2 italic">{loc.atmosphere}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Events */}
|
||||
{extractedEntities.new_events.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-100 flex items-center gap-2 mb-3">
|
||||
<Calendar className="w-5 h-5 text-purple-400" />
|
||||
События ({extractedEntities.new_events.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{extractedEntities.new_events.map((event, idx) => (
|
||||
<label
|
||||
key={idx}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedEvents.has(idx)
|
||||
? 'bg-purple-900/20 border-purple-600'
|
||||
: 'bg-gray-700/30 border-gray-600 hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEvents.has(idx)}
|
||||
onChange={(e) => {
|
||||
const newSet = new Set(selectedEvents);
|
||||
if (e.target.checked) {
|
||||
newSet.add(idx);
|
||||
} else {
|
||||
newSet.delete(idx);
|
||||
}
|
||||
setSelectedEvents(newSet);
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-gray-100">{event.name || event.title}</div>
|
||||
<div className="text-sm text-gray-400 mt-1">{event.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{extractedEntities.new_characters.length === 0 &&
|
||||
extractedEntities.new_locations.length === 0 &&
|
||||
extractedEntities.new_events.length === 0 && (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
<p>Новых сущностей не обнаружено</p>
|
||||
<p className="text-sm mt-1">Попробуйте выделить другой текст</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-700 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-400">
|
||||
Выбрано: {totalSelected} {totalSelected === 1 ? 'сущность' : totalSelected <= 4 ? 'сущности' : 'сущностей'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={totalSelected === 0}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:bg-gray-600 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useEditor } from '@tiptap/react';
|
||||
import { BubbleMenu } from '@tiptap/extension-bubble-menu';
|
||||
import { Editor } from '@tiptap/react';
|
||||
import { ReactNode, useEffect, useRef } from 'react';
|
||||
|
||||
interface BubbleMenuWrapperProps {
|
||||
editor: Editor | null;
|
||||
children: ReactNode;
|
||||
shouldShow?: (props: { editor: Editor; from: number; to: number }) => boolean;
|
||||
tippyOptions?: {
|
||||
duration?: number;
|
||||
moveTransition?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function BubbleMenuWrapper({
|
||||
editor,
|
||||
children,
|
||||
shouldShow,
|
||||
tippyOptions,
|
||||
}: BubbleMenuWrapperProps) {
|
||||
const bubbleMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Создаем BubbleMenu extension
|
||||
const bubbleMenuExtension = BubbleMenu.configure({
|
||||
shouldShow: shouldShow || ({ from, to }) => from !== to,
|
||||
tippyOptions: {
|
||||
duration: 100,
|
||||
moveTransition: 'transform 0.15s ease-out',
|
||||
...tippyOptions,
|
||||
},
|
||||
});
|
||||
|
||||
// Добавляем extension к редактору (если еще не добавлен)
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const hasBubbleMenu = editor.options.extensions.some(
|
||||
(ext: { name: string }) => ext.name === 'bubbleMenu'
|
||||
);
|
||||
|
||||
if (!hasBubbleMenu) {
|
||||
editor.extendGlobalExtension?.(bubbleMenuExtension);
|
||||
}
|
||||
}, [editor, bubbleMenuExtension]);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div ref={bubbleMenuRef}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
'use client';
|
||||
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import type { Editor as TiptapEditor } from '@tiptap/core';
|
||||
import { Toolbar } from './Toolbar';
|
||||
import AIMenu from './AIMenu';
|
||||
import AddToBibleModal from './AddToBibleModal';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Color } from '@tiptap/extension-color';
|
||||
// StarterKit уже включает underline и blockquote, поэтому не добавляем их отдельно
|
||||
import { FontFamily } from '@tiptap/extension-font-family';
|
||||
import { FontSize } from '@/lib/tiptap-extensions/FontSize';
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { generateText, autoUpdateBible, extractEntities } from '@/lib/ai-api';
|
||||
import AIDiffView from './AIDiffView';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
// Расширяем Window для глобального callback
|
||||
declare global {
|
||||
interface Window {
|
||||
saveCurrentEditorContent?: () => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
interface ExtractedEntity {
|
||||
name: string;
|
||||
description: string;
|
||||
traits?: string[];
|
||||
appearance?: string;
|
||||
role?: string;
|
||||
atmosphere?: string;
|
||||
type?: string;
|
||||
importance?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface ExtractedEntitiesData {
|
||||
new_characters: ExtractedEntity[];
|
||||
new_locations: ExtractedEntity[];
|
||||
new_events: ExtractedEntity[];
|
||||
}
|
||||
|
||||
export function Editor() {
|
||||
const {
|
||||
documents,
|
||||
activeDocumentId,
|
||||
activeProjectId,
|
||||
updateDocumentContent,
|
||||
loadStoryBible,
|
||||
autoUpdateBible: autoUpdateEnabled,
|
||||
isSaving
|
||||
} = useAppStore();
|
||||
|
||||
// Находим активный документ
|
||||
const activeDocument = documents.find(d => d.id === activeDocumentId);
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const bibleUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const editorRef = useRef<TiptapEditor | null>(null);
|
||||
|
||||
// AI workflow state
|
||||
const [selectedText, setSelectedText] = useState('');
|
||||
const [aiGeneratedText, setAiGeneratedText] = useState('');
|
||||
const [showDiffView, setShowDiffView] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectionRange, setSelectionRange] = useState<{ from: number; to: number } | null>(null);
|
||||
|
||||
// State для модального окна добавления в Story Bible
|
||||
const [showAddToBibleModal, setShowAddToBibleModal] = useState(false);
|
||||
const [extractedEntities, setExtractedEntities] = useState<{
|
||||
new_characters: ExtractedEntity[];
|
||||
new_locations: ExtractedEntity[];
|
||||
new_events: ExtractedEntity[];
|
||||
} | null>(null);
|
||||
|
||||
// Функция для извлечения чистого текста из HTML
|
||||
const getPlainText = (html: string): string => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
return div.textContent || div.innerText || '';
|
||||
};
|
||||
|
||||
// Функция для debounced обновления контента с сохранением на backend
|
||||
const debouncedUpdate = useCallback(
|
||||
(html: string) => {
|
||||
if (!activeProjectId || !activeDocumentId) return;
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
debounceTimerRef.current = setTimeout(async () => {
|
||||
await updateDocumentContent(activeProjectId, activeDocumentId, html);
|
||||
|
||||
// Автообновление Story Bible (с задержкой после сохранения)
|
||||
if (autoUpdateEnabled) {
|
||||
if (bibleUpdateTimeoutRef.current) {
|
||||
clearTimeout(bibleUpdateTimeoutRef.current);
|
||||
}
|
||||
|
||||
bibleUpdateTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const plainText = getPlainText(html);
|
||||
if (plainText.trim().length > 50) {
|
||||
const result = await autoUpdateBible(
|
||||
activeProjectId,
|
||||
plainText,
|
||||
autoUpdateEnabled
|
||||
);
|
||||
|
||||
if (result.updated) {
|
||||
console.log('Story Bible updated:', result.message);
|
||||
// Перезагружаем Story Bible в store
|
||||
loadStoryBible(activeProjectId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auto-update Bible error:', error);
|
||||
}
|
||||
}, 3000); // Задержка 3 секунды после сохранения
|
||||
}
|
||||
}, 1000); // Задержка 1 секунда для сохранения
|
||||
},
|
||||
[activeProjectId, activeDocumentId, updateDocumentContent, autoUpdateEnabled, loadStoryBible]
|
||||
);
|
||||
|
||||
// Функция для немедленного сохранения текущего контента
|
||||
const saveCurrentContent = useCallback(async (ed: TiptapEditor | null) => {
|
||||
const currentEditor = ed || editorRef.current;
|
||||
if (currentEditor && activeProjectId && activeDocumentId) {
|
||||
const currentContent = currentEditor.getHTML();
|
||||
|
||||
// Очищаем таймер debounce
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Немедленно сохраняем
|
||||
try {
|
||||
await updateDocumentContent(activeProjectId, activeDocumentId, currentContent);
|
||||
console.log('[Editor] Content saved before switching:', currentContent.substring(0, 100));
|
||||
} catch (error) {
|
||||
console.error('[Editor] Failed to save before switching:', error);
|
||||
}
|
||||
}
|
||||
}, [activeProjectId, activeDocumentId, updateDocumentContent]);
|
||||
|
||||
// Очистка таймеров при размонтировании
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
if (bibleUpdateTimeoutRef.current) {
|
||||
clearTimeout(bibleUpdateTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Обработчик действий AI
|
||||
const handleAIAction = async (action: string, text: string) => {
|
||||
if (!editor) return;
|
||||
|
||||
const { from, to } = editor.state.selection;
|
||||
setSelectedText(text);
|
||||
setSelectionRange({ from, to });
|
||||
|
||||
// Специальная обработка для add_to_bible
|
||||
if (action === 'add_to_bible') {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await extractEntities(text);
|
||||
setExtractedEntities(result.entities);
|
||||
setShowAddToBibleModal(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка при извлечении сущностей');
|
||||
console.error('Extract entities error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let prompt = '';
|
||||
switch (action) {
|
||||
case 'rewrite':
|
||||
prompt = `Перепиши следующий текст, сделай его более живым, образным и кинематографичным. Сохрани основной смысл, но улучши стиль:\n\n${text}`;
|
||||
break;
|
||||
case 'expand':
|
||||
prompt = `Расширь следующий текст, добавь больше деталей, описаний, сенсорных деталей (запахи, звуки, ощущения). Увеличь объем примерно в 1.5 раза:\n\n${text}`;
|
||||
break;
|
||||
case 'simplify':
|
||||
prompt = `Упрости следующий текст, сделай его более кратким и понятным. Убери лишние слова и сложные конструкции, сохрани основной смысл:\n\n${text}`;
|
||||
break;
|
||||
case 'custom':
|
||||
prompt = `Улучши следующий текст:\n\n${text}`;
|
||||
break;
|
||||
default:
|
||||
prompt = `Улучши следующий текст:\n\n${text}`;
|
||||
}
|
||||
|
||||
const generatedText = await generateText({
|
||||
prompt,
|
||||
preset: 'creative_draft',
|
||||
taskType: 'generation',
|
||||
selected_text: text,
|
||||
});
|
||||
|
||||
setAiGeneratedText(generatedText);
|
||||
setShowDiffView(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Произошла ошибка при генерации');
|
||||
console.error('AI generation error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit, // включает underline и blockquote
|
||||
TextStyle,
|
||||
Color,
|
||||
FontFamily,
|
||||
FontSize,
|
||||
],
|
||||
content: activeDocument?.content || '',
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
// Sync editor ref
|
||||
editorRef.current = ed;
|
||||
|
||||
if (activeProjectId && activeDocumentId) {
|
||||
const content = ed.getHTML();
|
||||
console.log('[Editor] onUpdate - content length:', content.length);
|
||||
console.log('[Editor] onUpdate - content preview:', content.substring(0, 150));
|
||||
debouncedUpdate(content);
|
||||
}
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-invert max-w-none focus:outline-none font-serif text-lg leading-relaxed',
|
||||
},
|
||||
},
|
||||
immediatelyRender: false,
|
||||
onCreate: ({ editor: ed }) => {
|
||||
editorRef.current = ed;
|
||||
},
|
||||
});
|
||||
|
||||
// Регистрируем глобальный callback для сохранения
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editorRef.current = editor;
|
||||
// Создаём обёртку без параметров для глобального доступа
|
||||
window.saveCurrentEditorContent = () => saveCurrentContent(editor);
|
||||
|
||||
return () => {
|
||||
delete window.saveCurrentEditorContent;
|
||||
};
|
||||
}
|
||||
}, [editor, saveCurrentContent]);
|
||||
|
||||
// Синхронизация редактора при смене активного документа
|
||||
useEffect(() => {
|
||||
if (!editor || !activeDocument) return;
|
||||
|
||||
console.log('=== Loading document ===');
|
||||
console.log('[Editor] Document ID:', activeDocument.id);
|
||||
console.log('[Editor] Document title:', activeDocument.title);
|
||||
console.log('[Editor] Content length:', activeDocument.content?.length || 0);
|
||||
console.log('[Editor] Content preview:', activeDocument.content?.substring(0, 200));
|
||||
|
||||
// Проверяем, что контент действительно изменился
|
||||
const currentContent = editor.getHTML();
|
||||
const newContent = activeDocument.content || '';
|
||||
|
||||
// Загружаем только если контент действительно другой
|
||||
if (currentContent !== newContent) {
|
||||
console.log('[Editor] Content differs, loading new content');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(editor.commands as any).setContent(newContent, false, { preserveWhitespace: 'full' });
|
||||
} else {
|
||||
console.log('[Editor] Content is the same, skipping reload');
|
||||
}
|
||||
}, [activeDocumentId, editor, activeDocument]);
|
||||
|
||||
// Если нет активного документа или это не сцена
|
||||
if (!activeDocument || activeDocument.type !== 'scene') {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<p className="text-lg">Выберите сцену для редактирования</p>
|
||||
<p className="text-sm mt-1">Кликните на сцену в дереве документов</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Обработчики для AIDiffView
|
||||
const handleAcceptChanges = () => {
|
||||
if (editor && selectionRange && aiGeneratedText) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection({ from: selectionRange.from, to: selectionRange.to })
|
||||
.deleteRange({ from: selectionRange.from, to: selectionRange.to })
|
||||
.insertContent(aiGeneratedText)
|
||||
.run();
|
||||
}
|
||||
setShowDiffView(false);
|
||||
setAiGeneratedText('');
|
||||
setSelectedText('');
|
||||
setSelectionRange(null);
|
||||
};
|
||||
|
||||
const handleRejectChanges = () => {
|
||||
setShowDiffView(false);
|
||||
setAiGeneratedText('');
|
||||
setSelectedText('');
|
||||
setSelectionRange(null);
|
||||
};
|
||||
|
||||
// Обработчик подтверждения добавления в Story Bible
|
||||
const handleAddToBibleConfirm = async (selected: {
|
||||
characters: ExtractedEntity[];
|
||||
locations: ExtractedEntity[];
|
||||
events: ExtractedEntity[];
|
||||
}) => {
|
||||
// Получаем данные из store
|
||||
const state = useAppStore.getState();
|
||||
const { activeProjectId, storyBible, updateStoryBible } = state;
|
||||
|
||||
if (!activeProjectId || !storyBible) return;
|
||||
|
||||
try {
|
||||
// Создаем копию текущей Bible
|
||||
const updatedBible = JSON.parse(JSON.stringify(storyBible));
|
||||
|
||||
// Добавляем выбранные персонажи
|
||||
selected.characters.forEach(char => {
|
||||
updatedBible.characters.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: char.name,
|
||||
description: char.description || '',
|
||||
traits: char.traits || [],
|
||||
appearance: char.appearance || '',
|
||||
role: char.role || 'supporting'
|
||||
});
|
||||
});
|
||||
|
||||
// Добавляем выбранные локации
|
||||
selected.locations.forEach(loc => {
|
||||
updatedBible.locations.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: loc.name,
|
||||
description: loc.description || '',
|
||||
atmosphere: loc.atmosphere || '',
|
||||
type: loc.type || 'other'
|
||||
});
|
||||
});
|
||||
|
||||
// Добавляем выбранные события
|
||||
selected.events.forEach(event => {
|
||||
updatedBible.events.push({
|
||||
id: crypto.randomUUID(),
|
||||
title: event.title || event.name || '',
|
||||
description: event.description || '',
|
||||
importance: event.importance || 'minor'
|
||||
});
|
||||
});
|
||||
|
||||
// Сохраняем обновленную Bible через API
|
||||
await updateStoryBible(activeProjectId, updatedBible);
|
||||
|
||||
// Закрываем модальное окно
|
||||
setShowAddToBibleModal(false);
|
||||
setExtractedEntities(null);
|
||||
|
||||
// Логируем результат
|
||||
const totalAdded = selected.characters.length + selected.locations.length + selected.events.length;
|
||||
console.log(`Добавлено в Story Bible: ${totalAdded} сущностей`);
|
||||
} catch (error) {
|
||||
console.error('Failed to add to Bible:', error);
|
||||
setError('Ошибка при добавлении в Story Bible');
|
||||
}
|
||||
};
|
||||
|
||||
// Проверка на наличие активного проекта
|
||||
if (!activeProjectId) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<p className="text-lg">Выберите проект</p>
|
||||
<p className="text-sm mt-1">Или создайте новый в левой панели</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Проверка на наличие активного документа
|
||||
if (!activeDocument) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<p className="text-lg">Нет документов в проекте</p>
|
||||
<p className="text-sm mt-1">Создайте документ через Story Bible или добавьте его позже</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Индикатор сохранения */}
|
||||
<div className="flex justify-end px-4 py-2 bg-gray-800 border-b border-gray-700 shrink-0">
|
||||
<span className={`text-xs ${isSaving ? 'text-yellow-400' : 'text-green-400'}`}>
|
||||
{isSaving ? 'Сохранение...' : 'Сохранено'}
|
||||
</span>
|
||||
</div>
|
||||
<Toolbar editor={editor} />
|
||||
{/* Скроллируемая область редактора */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="prose prose-invert max-w-3xl mx-auto p-8">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
<AIMenu editor={editor} onAction={handleAIAction} />
|
||||
|
||||
{/* Diff view для предпросмотра AI изменений */}
|
||||
{showDiffView && (
|
||||
<AIDiffView
|
||||
originalText={selectedText}
|
||||
aiText={aiGeneratedText}
|
||||
onAccept={handleAcceptChanges}
|
||||
onReject={handleRejectChanges}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Модальное окно добавления в Story Bible */}
|
||||
{showAddToBibleModal && extractedEntities && (
|
||||
<AddToBibleModal
|
||||
extractedEntities={extractedEntities}
|
||||
onConfirm={handleAddToBibleConfirm}
|
||||
onClose={() => {
|
||||
setShowAddToBibleModal(false);
|
||||
setExtractedEntities(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Индикатор загрузки */}
|
||||
{isLoading && (
|
||||
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg shadow-lg z-50">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">AI генерирует...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Индикатор ошибки */}
|
||||
{error && (
|
||||
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg shadow-lg z-50">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-2 text-white/70 hover:text-white">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X, Download, Loader2, FileText, BookOpen } from 'lucide-react';
|
||||
import { exportDocument } from '@/lib/api';
|
||||
|
||||
interface ExportModalProps {
|
||||
projectId: string;
|
||||
documentId: string;
|
||||
documentTitle: string;
|
||||
documentType: 'book' | 'chapter';
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ExportModal({ projectId, documentId, documentTitle, documentType, onClose }: ExportModalProps) {
|
||||
const [selectedFormat, setSelectedFormat] = useState<'epub' | 'fb2' | 'pdf' | 'docx'>('epub');
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await exportDocument(projectId, documentId, selectedFormat);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка при экспорте');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formats = [
|
||||
{ id: 'epub' as const, name: 'EPUB', desc: 'Стандарт для электронных книг (Apple Books, Kindle)', icon: BookOpen },
|
||||
{ id: 'fb2' as const, name: 'FB2', desc: 'Популярный формат в СНГ (FBReader, LitRes)', icon: FileText },
|
||||
{ id: 'pdf' as const, name: 'PDF', desc: 'Для печати и фиксированного форматирования', icon: FileText },
|
||||
{ id: 'docx' as const, name: 'DOCX', desc: 'Для передачи на редактуру (Word, LibreOffice)', icon: FileText },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-md w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-100">Экспорт: {documentTitle}</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-200" disabled={isExporting}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
Выберите формат для экспорта {documentType === 'book' ? 'всей книги' : 'этой главы и всех вложенных сцен'}.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{formats.map((fmt) => {
|
||||
const Icon = fmt.icon;
|
||||
return (
|
||||
<label
|
||||
key={fmt.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedFormat === fmt.id
|
||||
? 'bg-blue-900/20 border-blue-600'
|
||||
: 'bg-gray-700/30 border-gray-600 hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
value={fmt.id}
|
||||
checked={selectedFormat === fmt.id}
|
||||
onChange={() => setSelectedFormat(fmt.id)}
|
||||
className="text-blue-600 focus:ring-blue-500"
|
||||
disabled={isExporting}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4 text-gray-300" />
|
||||
<span className="font-semibold text-gray-100">{fmt.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">{fmt.desc}</p>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-900/20 border border-red-800 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-700 flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isExporting}
|
||||
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:bg-blue-800 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Генерация...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Скачать
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X, Loader2, Sparkles, Download } from 'lucide-react';
|
||||
import { generateImage, GenerateImageResponse } from '@/lib/api';
|
||||
|
||||
interface GenerateImageModalProps {
|
||||
projectId: string;
|
||||
documentId: string;
|
||||
documentTitle: string;
|
||||
documentType: 'book' | 'chapter';
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function GenerateImageModal({
|
||||
projectId,
|
||||
documentId,
|
||||
documentTitle,
|
||||
documentType,
|
||||
onClose
|
||||
}: GenerateImageModalProps) {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [generatedImage, setGeneratedImage] = useState<GenerateImageResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const suggestedPrompts = documentType === 'book'
|
||||
? [
|
||||
'Epic fantasy book cover, magical castle in the clouds, dramatic lighting, digital art',
|
||||
'Mystery novel cover, dark city street at night, fog, noir style',
|
||||
'Romance book cover, couple silhouette at sunset, warm colors, artistic'
|
||||
]
|
||||
: [
|
||||
'Detailed illustration of a medieval tavern interior, warm lighting, fantasy style',
|
||||
'Character portrait, young adventurer with a sword, determined expression, digital painting',
|
||||
'Landscape illustration, mysterious forest path, magical atmosphere, concept art'
|
||||
];
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
setGeneratedImage(null);
|
||||
|
||||
try {
|
||||
const result = await generateImage({
|
||||
prompt,
|
||||
project_id: projectId,
|
||||
document_id: documentId,
|
||||
document_type: documentType,
|
||||
});
|
||||
setGeneratedImage(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка при генерации изображения');
|
||||
console.error('Generate image error:', err);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!generatedImage) return;
|
||||
|
||||
// Скачиваем через blob из base64 данных
|
||||
const byteString = atob(generatedImage.data_url.split(',')[1]);
|
||||
const mimeString = generatedImage.data_url.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${documentTitle.replace(/[^a-z0-9]/gi, '_')}_${generatedImage.filename}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-100">
|
||||
Генерация изображения: {documentTitle}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-200" disabled={isGenerating}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||
{/* Prompt input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Описание изображения (промпт)
|
||||
</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Опишите, что вы хотите увидеть на изображении... (лучше на английском)"
|
||||
className="w-full p-3 bg-gray-700 text-gray-200 rounded-lg border border-gray-600 focus:border-blue-500 focus:outline-none min-h-[120px] resize-none"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
💡 Совет: Пишите промпт на английском для лучшего результата. Будьте конкретны: стиль, освещение, настроение.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Suggested prompts */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-2">Примеры промптов:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestedPrompts.map((suggestion, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setPrompt(suggestion)}
|
||||
disabled={isGenerating}
|
||||
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 text-xs rounded-lg border border-gray-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{suggestion.length > 50 ? suggestion.substring(0, 50) + '...' : suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate button */}
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
className="w-full py-3 bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Генерация изображения... (может занять 10-30 секунд)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-5 h-5" />
|
||||
Сгенерировать изображение
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-900/20 border border-red-800 rounded-lg text-red-300">
|
||||
<p className="font-medium">Ошибка генерации</p>
|
||||
<p className="text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generated image */}
|
||||
{generatedImage && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-100">Результат</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Скачать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-lg overflow-hidden border border-gray-700">
|
||||
<img
|
||||
src={generatedImage.data_url}
|
||||
alt="Generated image"
|
||||
className="w-full h-auto max-h-[500px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
|
||||
<p className="text-xs text-gray-400 mb-1">Промпт:</p>
|
||||
<p className="text-sm text-gray-300">{generatedImage.prompt}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Модель: {generatedImage.model}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-700 bg-gray-850">
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
💰 Генерация изображений использует API OpenRouter и может стоить ~$0.02-0.05 за изображение
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { type Editor } from '@tiptap/react';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline as UnderlineIcon,
|
||||
Quote,
|
||||
Eraser,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ToolbarProps {
|
||||
editor: Editor | null;
|
||||
}
|
||||
|
||||
export function Toolbar({ editor }: ToolbarProps) {
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
name: 'bold',
|
||||
icon: Bold,
|
||||
isActive: () => editor.isActive('bold'),
|
||||
onClick: () => editor.chain().focus().toggleBold().run(),
|
||||
},
|
||||
{
|
||||
name: 'italic',
|
||||
icon: Italic,
|
||||
isActive: () => editor.isActive('italic'),
|
||||
onClick: () => editor.chain().focus().toggleItalic().run(),
|
||||
},
|
||||
{
|
||||
name: 'underline',
|
||||
icon: UnderlineIcon,
|
||||
isActive: () => editor.isActive('underline'),
|
||||
onClick: () => editor.chain().focus().toggleUnderline().run(),
|
||||
},
|
||||
{
|
||||
name: 'blockquote',
|
||||
icon: Quote,
|
||||
isActive: () => editor.isActive('blockquote'),
|
||||
onClick: () => editor.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-800 border-b border-gray-700 flex-wrap">
|
||||
{/* Группировка: Bold, Italic, Underline */}
|
||||
{buttons.map(({ name, icon: Icon, isActive, onClick }) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={onClick}
|
||||
className={`p-2 rounded transition-colors ${
|
||||
isActive()
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
title={name.charAt(0).toUpperCase() + name.slice(1)}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="w-px h-6 bg-gray-600 mx-2" />
|
||||
|
||||
{/* Группировка: Blockquote, Color, Clear */}
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
className={`p-2 rounded transition-colors ${
|
||||
editor.isActive('blockquote')
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
title="Цитата"
|
||||
>
|
||||
<Quote className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Цвет текста */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="color"
|
||||
onChange={(e) => editor.chain().focus().setColor(e.target.value).run()}
|
||||
className="p-1 rounded w-8 h-8 cursor-pointer bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||
title="Цвет текста"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Очистить форматирование */}
|
||||
<button
|
||||
onClick={() => editor.chain().focus().unsetAllMarks().run()}
|
||||
className={`p-2 rounded transition-colors ${
|
||||
editor.isActive('bold') || editor.isActive('italic') || editor.isActive('underline')
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
title="Очистить форматирование"
|
||||
>
|
||||
<Eraser className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-gray-600 mx-2" />
|
||||
|
||||
{/* Выбор шрифта */}
|
||||
<select
|
||||
onChange={(e) => editor.chain().focus().setFontFamily(e.target.value).run()}
|
||||
className="bg-gray-700 text-gray-200 border border-gray-600 rounded px-2 py-1 text-sm"
|
||||
title="Шрифт"
|
||||
>
|
||||
<option value="Georgia, serif">Serif</option>
|
||||
<option value="Inter, sans-serif">Sans-serif</option>
|
||||
<option value="'Courier New', monospace">Monospace</option>
|
||||
</select>
|
||||
|
||||
{/* Размер шрифта */}
|
||||
<select
|
||||
onChange={(e) => editor.chain().focus().setFontSize(e.target.value).run()}
|
||||
className="bg-gray-700 text-gray-200 border border-gray-600 rounded px-2 py-1 text-sm"
|
||||
title="Размер шрифта"
|
||||
>
|
||||
<option value="14px">14px</option>
|
||||
<option value="16px">16px</option>
|
||||
<option value="18px">18px</option>
|
||||
<option value="20px">20px</option>
|
||||
<option value="24px">24px</option>
|
||||
<option value="28px">28px</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { getAvailableModels, ModelInfo } from '@/lib/ai-api';
|
||||
import { Settings, X, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function ModelSettingsPanel() {
|
||||
const { modelSettings, updateModelSetting, autoUpdateBible, setAutoUpdateBible } = useAppStore();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<{
|
||||
ollama: ModelInfo[];
|
||||
openrouter: ModelInfo[];
|
||||
}>({ ollama: [], openrouter: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ollamaModels, openrouterModels] = await Promise.all([
|
||||
getAvailableModels('ollama'),
|
||||
getAvailableModels('openrouter'),
|
||||
]);
|
||||
setAvailableModels({
|
||||
ollama: ollamaModels.models || [],
|
||||
openrouter: openrouterModels.models || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadModels();
|
||||
}
|
||||
}, [isOpen, loadModels]);
|
||||
|
||||
const taskLabels = {
|
||||
generation: {
|
||||
label: 'Генерация текста',
|
||||
description: 'Основная модель для переписывания, расширения и улучшения текста',
|
||||
},
|
||||
background: {
|
||||
label: 'Фоновая обработка',
|
||||
description: 'Модель для извлечения сущностей и обновления Story Bible',
|
||||
},
|
||||
analysis: {
|
||||
label: 'Анализ текста',
|
||||
description: 'Модель для проверки логики и консистентности',
|
||||
},
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-4 left-4 p-3 bg-gray-700 hover:bg-gray-600 text-white rounded-full shadow-lg transition-colors z-40"
|
||||
title="Настройки моделей"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-100">Настройки моделей AI</h2>
|
||||
<button onClick={() => setIsOpen(false)} className="text-gray-400 hover:text-gray-200">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-400" />
|
||||
<span className="ml-2 text-gray-400">Загрузка списка моделей...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(Object.keys(taskLabels) as Array<keyof typeof taskLabels>).map((taskType) => (
|
||||
<div
|
||||
key={taskType}
|
||||
className="p-4 bg-gray-700/50 rounded-lg border border-gray-600"
|
||||
>
|
||||
<div className="mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-100">
|
||||
{taskLabels[taskType].label}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{taskLabels[taskType].description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Provider */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Провайдер
|
||||
</label>
|
||||
<select
|
||||
value={modelSettings[taskType].provider}
|
||||
onChange={(e) => {
|
||||
const provider = e.target.value as 'ollama' | 'openrouter';
|
||||
const defaultModel =
|
||||
availableModels[provider][0]?.id || '';
|
||||
updateModelSetting(taskType, provider, defaultModel);
|
||||
}}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
|
||||
>
|
||||
<option value="ollama">Ollama (Локально)</option>
|
||||
<option value="openrouter">OpenRouter (Облако)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Модель
|
||||
</label>
|
||||
<select
|
||||
value={modelSettings[taskType].model}
|
||||
onChange={(e) =>
|
||||
updateModelSetting(
|
||||
taskType,
|
||||
modelSettings[taskType].provider,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
|
||||
>
|
||||
{availableModels[modelSettings[taskType].provider]?.length > 0 ? (
|
||||
availableModels[modelSettings[taskType].provider]?.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="">Нет доступных моделей</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model description */}
|
||||
{availableModels[modelSettings[taskType].provider]?.find(
|
||||
(m) => m.id === modelSettings[taskType].model
|
||||
)?.description && (
|
||||
<p className="text-xs text-gray-500 mt-2 italic">
|
||||
{availableModels[modelSettings[taskType].provider]?.find(
|
||||
(m) => m.id === modelSettings[taskType].model
|
||||
)?.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Автоматическое обновление Story Bible */}
|
||||
<div className="p-4 bg-gray-700/50 rounded-lg border border-gray-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-100">
|
||||
Автоматическое обновление Story Bible
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
При сохранении сцены AI автоматически извлекает новых персонажей, локации и события
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoUpdateBible(!autoUpdateBible)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
autoUpdateBible ? 'bg-blue-600' : 'bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
autoUpdateBible ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-700 bg-gray-850">
|
||||
<p className="text-sm text-gray-400 text-center">
|
||||
Настройки сохраняются автоматически в localStorage браузера
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAppStore, DocumentTreeNode } from '@/stores/appStore';
|
||||
import { Book, FileText, ChevronRight, ChevronDown, Plus, Trash2, Edit2, Download, Image as ImageIcon } from 'lucide-react';
|
||||
import ExportModal from '../editor/ExportModal';
|
||||
import GenerateImageModal from '../editor/GenerateImageModal';
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: DocumentTreeNode;
|
||||
level: number;
|
||||
onContextMenu: (e: React.MouseEvent, node: DocumentTreeNode) => void;
|
||||
}
|
||||
|
||||
function TreeNode({ node, level, onContextMenu }: TreeNodeProps) {
|
||||
const { activeDocumentId, switchToDocument } = useAppStore();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isActive = activeDocumentId === node.id;
|
||||
|
||||
const handleClick = async () => {
|
||||
if (node.type === 'scene') {
|
||||
// Сначала сохраняем текущий документ (через глобальный callback)
|
||||
if (window.saveCurrentEditorContent) {
|
||||
await window.saveCurrentEditorContent();
|
||||
}
|
||||
|
||||
// Затем безопасно переключаем
|
||||
await switchToDocument(node.id);
|
||||
} else if (hasChildren) {
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (node.type) {
|
||||
case 'book':
|
||||
return <Book className="w-4 h-4 text-blue-400" />;
|
||||
case 'chapter':
|
||||
return <FileText className="w-4 h-4 text-green-400" />;
|
||||
case 'scene':
|
||||
return <FileText className="w-4 h-4 text-gray-400" />;
|
||||
default:
|
||||
return <FileText className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onContextMenu={(e) => onContextMenu(e, node)}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-gray-700/50 rounded transition-colors ${
|
||||
isActive ? 'bg-blue-900/30 border-l-2 border-blue-500' : ''
|
||||
}`}
|
||||
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
)
|
||||
) : (
|
||||
<div className="w-4" />
|
||||
)}
|
||||
{getIcon()}
|
||||
<span className="text-sm text-gray-200 flex-1 truncate">{node.title}</span>
|
||||
</div>
|
||||
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{node.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocumentTree() {
|
||||
const { activeProjectId, documentsTree, loadDocumentsTree, createDocument, deleteDocument, updateDocument } = useAppStore();
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; node: DocumentTreeNode } | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const [showGenerateImageModal, setShowGenerateImageModal] = useState(false);
|
||||
const [exportTarget, setExportTarget] = useState<{ id: string; title: string; type: 'book' | 'chapter' } | null>(null);
|
||||
const [imageTarget, setImageTarget] = useState<{ id: string; title: string; type: 'book' | 'chapter' } | null>(null);
|
||||
const [createParentId, setCreateParentId] = useState<string | null>(null);
|
||||
const [createType, setCreateType] = useState<'book' | 'chapter' | 'scene'>('book');
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProjectId) {
|
||||
loadDocumentsTree(activeProjectId);
|
||||
}
|
||||
}, [activeProjectId, loadDocumentsTree]);
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, node: DocumentTreeNode) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, node });
|
||||
};
|
||||
|
||||
const handleCloseContextMenu = () => {
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (!contextMenu) return;
|
||||
|
||||
setExportTarget({
|
||||
id: contextMenu.node.id,
|
||||
title: contextMenu.node.title,
|
||||
type: contextMenu.node.type as 'book' | 'chapter',
|
||||
});
|
||||
setShowExportModal(true);
|
||||
handleCloseContextMenu();
|
||||
};
|
||||
|
||||
const handleGenerateImage = () => {
|
||||
if (!contextMenu) return;
|
||||
|
||||
setImageTarget({
|
||||
id: contextMenu.node.id,
|
||||
title: contextMenu.node.title,
|
||||
type: contextMenu.node.type as 'book' | 'chapter',
|
||||
});
|
||||
setShowGenerateImageModal(true);
|
||||
handleCloseContextMenu();
|
||||
};
|
||||
|
||||
const handleCreate = (parentType: string) => {
|
||||
let type: 'book' | 'chapter' | 'scene';
|
||||
|
||||
if (parentType === 'root') {
|
||||
type = 'book';
|
||||
} else if (parentType === 'book') {
|
||||
type = 'chapter';
|
||||
} else {
|
||||
type = 'scene';
|
||||
}
|
||||
|
||||
setCreateType(type);
|
||||
setCreateParentId(parentType === 'root' ? null : contextMenu?.node.id || null);
|
||||
setShowCreateModal(true);
|
||||
handleCloseContextMenu();
|
||||
};
|
||||
|
||||
const handleCreateSubmit = async () => {
|
||||
if (!newTitle.trim() || !activeProjectId) return;
|
||||
|
||||
await createDocument(activeProjectId, newTitle, createType, createParentId || undefined);
|
||||
setShowCreateModal(false);
|
||||
setNewTitle('');
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!contextMenu || !activeProjectId) return;
|
||||
|
||||
if (confirm(`Удалить "${contextMenu.node.title}" и все вложенные элементы?`)) {
|
||||
await deleteDocument(activeProjectId, contextMenu.node.id);
|
||||
}
|
||||
handleCloseContextMenu();
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!contextMenu || !activeProjectId) return;
|
||||
|
||||
const newName = prompt('Новое название:', contextMenu.node.title);
|
||||
if (newName && newName !== contextMenu.node.title) {
|
||||
await updateDocument(activeProjectId, contextMenu.node.id, { title: newName });
|
||||
await loadDocumentsTree(activeProjectId);
|
||||
}
|
||||
handleCloseContextMenu();
|
||||
};
|
||||
|
||||
if (!activeProjectId) {
|
||||
return <div className="p-4 text-gray-400">Выберите проект</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" onClick={handleCloseContextMenu}>
|
||||
{/* Header — фиксированный */}
|
||||
<div className="p-4 border-b border-gray-700 flex items-center justify-between flex-shrink-0">
|
||||
<h2 className="text-lg font-bold text-gray-100">Структура книги</h2>
|
||||
<button
|
||||
onClick={() => handleCreate('root')}
|
||||
className="p-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
title="Создать книгу"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tree — скроллируемая область */}
|
||||
<div className="flex-1 overflow-auto min-h-0 p-2">
|
||||
{documentsTree.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
<Book className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Нет книг</p>
|
||||
<p className="text-sm mt-1">Создайте первую книгу</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{documentsTree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="fixed bg-gray-800 border border-gray-700 rounded-lg shadow-xl py-1 z-50"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
{contextMenu.node.type === 'book' && (
|
||||
<button
|
||||
onClick={() => handleCreate('book')}
|
||||
className="w-full px-4 py-2 text-left text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить главу
|
||||
</button>
|
||||
)}
|
||||
{contextMenu.node.type === 'chapter' && (
|
||||
<button
|
||||
onClick={() => handleCreate('chapter')}
|
||||
className="w-full px-4 py-2 text-left text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить сцену
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleRename}
|
||||
className="w-full px-4 py-2 text-left text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
Переименовать
|
||||
</button>
|
||||
<div className="border-t border-gray-700 my-1" />
|
||||
{(contextMenu.node.type === 'book' || contextMenu.node.type === 'chapter') && (
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="w-full px-4 py-2 text-left text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Экспортировать
|
||||
</button>
|
||||
)}
|
||||
{(contextMenu.node.type === 'book' || contextMenu.node.type === 'chapter') && (
|
||||
<button
|
||||
onClick={handleGenerateImage}
|
||||
className="w-full px-4 py-2 text-left text-sm text-gray-200 hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
Сгенерировать изображение
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-md w-full">
|
||||
<div className="p-4 border-b border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-100">
|
||||
Создать {createType === 'book' ? 'книгу' : createType === 'chapter' ? 'главу' : 'сцену'}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="Название..."
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateSubmit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-700 flex gap-2">
|
||||
<button
|
||||
onClick={handleCreateSubmit}
|
||||
disabled={!newTitle.trim()}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:bg-gray-600 disabled:cursor-not-allowed"
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setNewTitle('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export Modal */}
|
||||
{showExportModal && exportTarget && activeProjectId && (
|
||||
<ExportModal
|
||||
projectId={activeProjectId}
|
||||
documentId={exportTarget.id}
|
||||
documentTitle={exportTarget.title}
|
||||
documentType={exportTarget.type}
|
||||
onClose={() => {
|
||||
setShowExportModal(false);
|
||||
setExportTarget(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Generate Image Modal */}
|
||||
{showGenerateImageModal && imageTarget && activeProjectId && (
|
||||
<GenerateImageModal
|
||||
projectId={activeProjectId}
|
||||
documentId={imageTarget.id}
|
||||
documentTitle={imageTarget.title}
|
||||
documentType={imageTarget.type}
|
||||
onClose={() => {
|
||||
setShowGenerateImageModal(false);
|
||||
setImageTarget(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function FileExplorer() {
|
||||
return (
|
||||
<div className="h-full bg-gray-800 border-r border-gray-700 flex flex-col">
|
||||
<div className="p-4 border-b border-gray-700">
|
||||
<h2 className="text-sm font-semibold uppercase text-gray-400">
|
||||
File Explorer
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-gray-500 text-sm">
|
||||
Дерево файлов проекта...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import ProjectManager from './ProjectManager';
|
||||
import DocumentTree from './DocumentTree';
|
||||
|
||||
export default function LeftPanel() {
|
||||
const { activeProjectId } = useAppStore();
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-800">
|
||||
{/* Верхняя часть - ProjectManager (фиксированная высота) */}
|
||||
<div className="flex-shrink-0 border-b border-gray-700" style={{ maxHeight: '40%' }}>
|
||||
<ProjectManager />
|
||||
</div>
|
||||
|
||||
{/* Нижняя часть - DocumentTree (занимает оставшееся пространство) */}
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
{activeProjectId ? (
|
||||
<DocumentTree />
|
||||
) : (
|
||||
<div className="p-4 text-center text-gray-400">
|
||||
<p className="text-sm">Выберите проект</p>
|
||||
<p className="text-xs mt-1">для просмотра структуры</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { Plus, Trash2, Folder, LogOut } from 'lucide-react';
|
||||
|
||||
export default function ProjectManager() {
|
||||
const {
|
||||
projects,
|
||||
activeProjectId,
|
||||
loadProjects,
|
||||
setActiveProject,
|
||||
createProject,
|
||||
deleteProject,
|
||||
logout,
|
||||
user
|
||||
} = useAppStore();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newProjectTitle, setNewProjectTitle] = useState('');
|
||||
const [newProjectDescription, setNewProjectDescription] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (!newProjectTitle.trim()) return;
|
||||
|
||||
const project = await createProject(newProjectTitle, newProjectDescription);
|
||||
await setActiveProject(project.id);
|
||||
setShowCreateModal(false);
|
||||
setNewProjectTitle('');
|
||||
setNewProjectDescription('');
|
||||
};
|
||||
|
||||
const handleDeleteProject = async (projectId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('Вы уверены, что хотите удалить этот проект? Все данные будут потеряны.')) {
|
||||
await deleteProject(projectId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" style={{ height: '100%' }}>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-100">Проекты</h2>
|
||||
{user && <p className="text-xs text-gray-400">{user.username}</p>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="p-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
title="Создать проект"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-2 bg-gray-700 hover:bg-red-600 text-gray-300 hover:text-white rounded-lg transition-colors"
|
||||
title="Выйти"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projects list */}
|
||||
<div className="overflow-auto p-4" style={{ maxHeight: 'calc(100% - 60px)' }}>
|
||||
{projects.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
<Folder className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Нет проектов</p>
|
||||
<p className="text-sm mt-1">Создайте первый проект</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
onClick={() => setActiveProject(project.id)}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
activeProjectId === project.id
|
||||
? 'bg-blue-900/30 border-blue-600'
|
||||
: 'bg-gray-700/50 border-gray-600 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-100 flex items-center gap-2">
|
||||
<Folder className="w-4 h-4" />
|
||||
{project.title}
|
||||
</h3>
|
||||
{project.description && (
|
||||
<p className="text-sm text-gray-400 mt-1">{project.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Создан: {new Date(project.created_at).toLocaleDateString('ru-RU')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleDeleteProject(project.id, e)}
|
||||
className="p-1 text-gray-400 hover:text-red-400 transition-colors"
|
||||
title="Удалить проект"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create project modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-md w-full">
|
||||
<div className="p-4 border-b border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-100">Новый проект</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Название проекта *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newProjectTitle}
|
||||
onChange={(e) => setNewProjectTitle(e.target.value)}
|
||||
placeholder="Например: Мой первый роман"
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Описание (опционально)
|
||||
</label>
|
||||
<textarea
|
||||
value={newProjectDescription}
|
||||
onChange={(e) => setNewProjectDescription(e.target.value)}
|
||||
placeholder="Краткое описание проекта..."
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 focus:border-blue-500 focus:outline-none min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-700 flex gap-2">
|
||||
<button
|
||||
onClick={handleCreateProject}
|
||||
disabled={!newProjectTitle.trim()}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:bg-gray-600 disabled:cursor-not-allowed"
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setNewProjectTitle('');
|
||||
setNewProjectDescription('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { storyBibleApi, type Character, type Location, type Event, type StoryThread } from '@/lib/api';
|
||||
import { Users, MapPin, Calendar, GitBranch, Plus, Edit2, Trash2, X, Sparkles } from 'lucide-react';
|
||||
|
||||
type TabType = 'characters' | 'locations' | 'events' | 'threads';
|
||||
|
||||
interface AddEditModalProps {
|
||||
type: TabType;
|
||||
item: Character | Location | Event | StoryThread | null;
|
||||
onSave: (data: unknown) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function StoryBiblePanel() {
|
||||
const { storyBible, activeProjectId, loadStoryBible, setStoryBible } = useAppStore();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('characters');
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<Character | Location | Event | StoryThread | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProjectId && !storyBible) {
|
||||
loadStoryBible(activeProjectId);
|
||||
}
|
||||
}, [activeProjectId, storyBible, loadStoryBible]);
|
||||
|
||||
const handleAdd = async (data: unknown) => {
|
||||
if (!activeProjectId) return;
|
||||
|
||||
try {
|
||||
let updatedBible: StoryBible;
|
||||
const { getProjectStoryBible, updateProjectStoryBible } = storyBibleApi;
|
||||
|
||||
// Получаем текущую Bible
|
||||
updatedBible = await getProjectStoryBible(activeProjectId);
|
||||
|
||||
// Добавляем новый элемент (используем Date для генерации ID)
|
||||
const newItem = {
|
||||
id: new Date().toISOString().replace(/[:.]/g, '-'),
|
||||
...data,
|
||||
};
|
||||
|
||||
switch (activeTab) {
|
||||
case 'characters':
|
||||
updatedBible.characters = [...updatedBible.characters, newItem as Character];
|
||||
break;
|
||||
case 'locations':
|
||||
updatedBible.locations = [...updatedBible.locations, newItem as Location];
|
||||
break;
|
||||
case 'events':
|
||||
updatedBible.events = [...updatedBible.events, newItem as Event];
|
||||
break;
|
||||
case 'threads':
|
||||
updatedBible.threads = [...updatedBible.threads, newItem as StoryThread];
|
||||
break;
|
||||
}
|
||||
|
||||
// Сохраняем обратно
|
||||
await updateProjectStoryBible(activeProjectId, updatedBible);
|
||||
setStoryBible(updatedBible);
|
||||
setShowAddModal(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to add item:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!storyBible || !activeProjectId) return;
|
||||
|
||||
const updatedBible = { ...storyBible };
|
||||
switch (activeTab) {
|
||||
case 'characters':
|
||||
updatedBible.characters = updatedBible.characters.filter((c: Character) => c.id !== id);
|
||||
break;
|
||||
case 'locations':
|
||||
updatedBible.locations = updatedBible.locations.filter((l: Location) => l.id !== id);
|
||||
break;
|
||||
case 'events':
|
||||
updatedBible.events = updatedBible.events.filter((e: Event) => e.id !== id);
|
||||
break;
|
||||
case 'threads':
|
||||
updatedBible.threads = updatedBible.threads.filter((t: StoryThread) => t.id !== id);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const { updateProjectStoryBible } = storyBibleApi;
|
||||
await updateProjectStoryBible(activeProjectId, updatedBible);
|
||||
setStoryBible(updatedBible);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete item:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (data: unknown) => {
|
||||
if (!activeProjectId || !editingItem) return;
|
||||
|
||||
try {
|
||||
const { getProjectStoryBible, updateProjectStoryBible } = storyBibleApi;
|
||||
let updatedBible = await getProjectStoryBible(activeProjectId);
|
||||
|
||||
switch (activeTab) {
|
||||
case 'characters':
|
||||
updatedBible.characters = updatedBible.characters.map((c: Character) =>
|
||||
c.id === (editingItem as Character).id ? { ...c, ...(data as Partial<Character>) } : c
|
||||
);
|
||||
break;
|
||||
case 'locations':
|
||||
updatedBible.locations = updatedBible.locations.map((l: Location) =>
|
||||
l.id === (editingItem as Location).id ? { ...l, ...(data as Partial<Location>) } : l
|
||||
);
|
||||
break;
|
||||
case 'events':
|
||||
updatedBible.events = updatedBible.events.map((e: Event) =>
|
||||
e.id === (editingItem as Event).id ? { ...e, ...(data as Partial<Event>) } : e
|
||||
);
|
||||
break;
|
||||
case 'threads':
|
||||
updatedBible.threads = updatedBible.threads.map((t: StoryThread) =>
|
||||
t.id === (editingItem as StoryThread).id ? { ...t, ...(data as Partial<StoryThread>) } : t
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
await updateProjectStoryBible(activeProjectId, updatedBible);
|
||||
setStoryBible(updatedBible);
|
||||
setShowAddModal(false);
|
||||
setEditingItem(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to edit item:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCleanup = async () => {
|
||||
if (!activeProjectId) return;
|
||||
|
||||
if (confirm('Очистить дубликаты в Story Bible? Это действие нельзя отменить.')) {
|
||||
try {
|
||||
const result = await storyBibleApi.cleanupDuplicates(activeProjectId);
|
||||
alert(result.message);
|
||||
await loadStoryBible(activeProjectId);
|
||||
} catch (error) {
|
||||
console.error('Cleanup failed:', error);
|
||||
alert('Ошибка при очистке дубликатов');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Если нет активного проекта, показываем сообщение
|
||||
if (!activeProjectId) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<p className="text-sm">Выберите проект</p>
|
||||
<p className="text-xs mt-1">для просмотра Story Bible</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Если нет Story Bible после загрузки проекта
|
||||
if (!storyBible) {
|
||||
return <div className="p-4 text-gray-400">Загрузка Story Bible...</div>;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'characters' as TabType,
|
||||
label: 'Персонажи',
|
||||
icon: Users,
|
||||
count: storyBible.characters.length,
|
||||
},
|
||||
{ id: 'locations' as TabType, label: 'Локации', icon: MapPin, count: storyBible.locations.length },
|
||||
{ id: 'events' as TabType, label: 'События', icon: Calendar, count: storyBible.events.length },
|
||||
{ id: 'threads' as TabType, label: 'Нити', icon: GitBranch, count: storyBible.threads.length },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-800">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-gray-100">Story Bible</h2>
|
||||
<button
|
||||
onClick={handleCleanup}
|
||||
className="p-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition-colors"
|
||||
title="Очистить дубликаты"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-700">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex-1 flex flex-col items-center gap-1 p-3 text-xs transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-gray-700 text-blue-400 border-b-2 border-blue-400'
|
||||
: 'text-gray-400 hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{tab.label}</span>
|
||||
<span className="text-xs opacity-60">{tab.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="space-y-3">
|
||||
{activeTab === 'characters' &&
|
||||
storyBible.characters.map((char: Character) => (
|
||||
<div key={char.id} className="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-100">{char.name}</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">{char.description}</p>
|
||||
{char.traits.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{char.traits.map((trait: string, idx: number) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-0.5 bg-blue-900/30 text-blue-300 text-xs rounded"
|
||||
>
|
||||
{trait}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 ml-2">
|
||||
<button
|
||||
onClick={() => { setEditingItem(char); setShowAddModal(true); }}
|
||||
className="p-1 text-gray-400 hover:text-blue-400"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(char.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{activeTab === 'locations' &&
|
||||
storyBible.locations.map((loc: Location) => (
|
||||
<div key={loc.id} className="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-100">{loc.name}</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">{loc.description}</p>
|
||||
{loc.atmosphere && <p className="text-xs text-gray-500 mt-2 italic">{loc.atmosphere}</p>}
|
||||
</div>
|
||||
<div className="flex gap-1 ml-2">
|
||||
<button onClick={() => { setEditingItem(loc); setShowAddModal(true); }} className="p-1 text-gray-400 hover:text-blue-400">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(loc.id)} className="p-1 text-gray-400 hover:text-red-400">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{activeTab === 'events' &&
|
||||
storyBible.events.map((event: Event) => (
|
||||
<div key={event.id} className="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-100">{event.title}</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">{event.description}</p>
|
||||
{event.chapter && <p className="text-xs text-gray-500 mt-2">{event.chapter}</p>}
|
||||
</div>
|
||||
<div className="flex gap-1 ml-2">
|
||||
<button onClick={() => { setEditingItem(event); setShowAddModal(true); }} className="p-1 text-gray-400 hover:text-blue-400">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(event.id)} className="p-1 text-gray-400 hover:text-red-400">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{activeTab === 'threads' &&
|
||||
storyBible.threads.map((thread: StoryThread) => (
|
||||
<div key={thread.id} className="p-3 bg-gray-700/50 rounded-lg border border-gray-600">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-100">{thread.title}</h3>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs rounded ${
|
||||
thread.status === 'active'
|
||||
? 'bg-green-900/30 text-green-300'
|
||||
: thread.status === 'resolved'
|
||||
? 'bg-blue-900/30 text-blue-300'
|
||||
: 'bg-gray-900/30 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{thread.status === 'active'
|
||||
? 'Активна'
|
||||
: thread.status === 'resolved'
|
||||
? 'Завершена'
|
||||
: 'Отменена'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-1">{thread.description}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 ml-2">
|
||||
<button onClick={() => { setEditingItem(thread); setShowAddModal(true); }} className="p-1 text-gray-400 hover:text-blue-400">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(thread.id)} className="p-1 text-gray-400 hover:text-red-400">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add button */}
|
||||
<div className="p-4 border-t border-gray-700">
|
||||
<button
|
||||
onClick={() => { setEditingItem(null); setShowAddModal(true); }}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
{showAddModal && (
|
||||
<AddEditModal
|
||||
type={activeTab}
|
||||
item={editingItem}
|
||||
onSave={editingItem ? handleEdit : handleAdd}
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
setEditingItem(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Модальное окно для добавления/редактирования
|
||||
function AddEditModal({ type, item, onSave, onClose }: AddEditModalProps) {
|
||||
const [formData, setFormData] = useState<unknown>(item || {});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-800 rounded-lg shadow-2xl max-w-md w-full max-h-[90vh] overflow-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-100">{item ? 'Редактировать' : 'Добавить'}</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-200">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
{type === 'characters' && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Имя"
|
||||
value={(formData as Character).name || ''}
|
||||
onChange={(e) => setFormData({ ...(formData as Character), name: e.target.value })}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Описание"
|
||||
value={(formData as Character).description || ''}
|
||||
onChange={(e) => setFormData({ ...(formData as Character), description: e.target.value })}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[80px]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Черты характера (через запятую)"
|
||||
value={(formData as Character).traits?.join(', ') || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...(formData as Character), traits: e.target.value.split(',').map((t: string) => t.trim()) })
|
||||
}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Внешность"
|
||||
value={(formData as Character).appearance || ''}
|
||||
onChange={(e) => setFormData({ ...(formData as Character), appearance: e.target.value })}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[60px]"
|
||||
/>
|
||||
<select
|
||||
value={(formData as Character).role || 'supporting'}
|
||||
onChange={(e) => setFormData({ ...(formData as Character), role: e.target.value })}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
|
||||
>
|
||||
<option value="protagonist">Протагонист</option>
|
||||
<option value="antagonist">Антагонист</option>
|
||||
<option value="supporting">Второстепенный</option>
|
||||
<option value="minor">Эпизодический</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'locations' && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название"
|
||||
value={(formData as Location).name || ''}
|
||||
onChange={(e) => setFormData({ ...(formData as Location), name: e.target.value })}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Описание"
|
||||
value={(formData as Location).description || ''}
|
||||
onChange={(e) => setFormData({ ...(formData as Location), description: e.target.value })}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[80px]"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Атмосфера"
|
||||
value={(formData as Location).atmosphere || ''}
|
||||
onChange={(e) => setFormData({ ...(formData as Location), atmosphere: e.target.value })}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[60px]"
|
||||
/>
|
||||
<select
|
||||
value={(formData as Location).type || 'other'}
|
||||
onChange={(e) => setFormData({ ...(formData as Location), type: e.target.value })}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
|
||||
>
|
||||
<option value="city">Город</option>
|
||||
<option value="building">Здание</option>
|
||||
<option value="nature">Природа</option>
|
||||
<option value="interior">Интерьер</option>
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'events' && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название"
|
||||
value={(formData as Event).title || ''}
|
||||
onChange={(e) => setFormData({ ...(formData as Event), title: e.target.value })}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Описание"
|
||||
value={(formData as Event).description || ''}
|
||||
onChange={(e) => setFormData({ ...(formData as Event), description: e.target.value })}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[80px]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Глава"
|
||||
value={(formData as Event).chapter || ''}
|
||||
onChange={(e) => setFormData({ ...(formData as Event), chapter: e.target.value })}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
|
||||
/>
|
||||
<select
|
||||
value={(formData as Event).importance || 'minor'}
|
||||
onChange={(e) => setFormData({ ...(formData as Event), importance: e.target.value })}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
|
||||
>
|
||||
<option value="major">Важное</option>
|
||||
<option value="minor">Незначительное</option>
|
||||
<option value="background">Фоновое</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'threads' && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название"
|
||||
value={(formData as StoryThread).title || ''}
|
||||
onChange={(e) => setFormData({ ...(formData as StoryThread), title: e.target.value })}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Описание"
|
||||
value={(formData as StoryThread).description || ''}
|
||||
onChange={(e) => setFormData({ ...(formData as StoryThread), description: e.target.value })}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[80px]"
|
||||
/>
|
||||
<select
|
||||
value={(formData as StoryThread).status || 'active'}
|
||||
onChange={(e) => setFormData({ ...(formData as StoryThread), status: e.target.value })}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
|
||||
>
|
||||
<option value="active">Активна</option>
|
||||
<option value="resolved">Завершена</option>
|
||||
<option value="abandoned">Отменена</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function StoryExplorer() {
|
||||
return (
|
||||
<div className="h-full bg-gray-800 border-r border-gray-700 flex flex-col">
|
||||
<div className="p-4 border-b border-gray-700">
|
||||
<h2 className="text-sm font-semibold uppercase text-gray-400">
|
||||
Story Explorer
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="text-gray-500 text-sm">Дерево сюжета...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { generateText, Preset, Provider } from '@/lib/ai-api';
|
||||
|
||||
export default function AITest() {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [result, setResult] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [preset, setPreset] = useState<Preset>('creative_draft');
|
||||
const [provider, setProvider] = useState<Provider>('ollama');
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setResult('');
|
||||
|
||||
try {
|
||||
const text = await generateText({
|
||||
prompt,
|
||||
preset,
|
||||
provider,
|
||||
});
|
||||
setResult(text);
|
||||
} catch (error) {
|
||||
setResult(`Ошибка: ${error instanceof Error ? error.message : 'Неизвестная ошибка'}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-gray-800 rounded-lg space-y-4">
|
||||
<h2 className="text-xl font-bold text-gray-100">AI Test Component</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">Provider:</label>
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value as Provider)}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
|
||||
>
|
||||
<option value="ollama">Ollama (Local)</option>
|
||||
<option value="openrouter">OpenRouter (Cloud)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">Preset:</label>
|
||||
<select
|
||||
value={preset}
|
||||
onChange={(e) => setPreset(e.target.value as Preset)}
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600"
|
||||
>
|
||||
<option value="creative_draft">Creative Draft (temp 0.85)</option>
|
||||
<option value="editor">Editor (temp 0.3)</option>
|
||||
<option value="brainstorm">Brainstorm (temp 1.0)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">Prompt:</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Введите промпт..."
|
||||
className="w-full p-2 bg-gray-700 text-gray-200 rounded border border-gray-600 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || !prompt.trim()}
|
||||
className="w-full p-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Генерация...' : 'Сгенерировать'}
|
||||
</button>
|
||||
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">Результат:</label>
|
||||
<div className="p-3 bg-gray-900 text-gray-100 rounded border border-gray-700 whitespace-pre-wrap">
|
||||
{result}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import AIDiffView from '../editor/AIDiffView';
|
||||
|
||||
export default function DiffTest() {
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
|
||||
if (!showDiff) {
|
||||
return (
|
||||
<div className="p-8 bg-gray-900 min-h-screen">
|
||||
<button
|
||||
onClick={() => setShowDiff(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
Показать Diff View
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AIDiffView
|
||||
originalText="Это оригинальный текст, который нужно улучшить. Он содержит базовую информацию, но lacks деталей и образности."
|
||||
aiText="Это оригинальный текст, требующий существенного улучшения. Он содержит лишь поверхностную информацию, лишённую живой образности и глубины, которые могли бы сделать его по-настоящему увлекательным для читателя."
|
||||
onAccept={() => {
|
||||
console.log('Accepted!');
|
||||
setShowDiff(false);
|
||||
}}
|
||||
onReject={() => {
|
||||
console.log('Rejected!');
|
||||
setShowDiff(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import axios from 'axios';
|
||||
import type { StoryBible } from '@/lib/api';
|
||||
|
||||
// Типы для запроса
|
||||
export type Provider = 'ollama' | 'openrouter';
|
||||
export type Preset = 'creative_draft' | 'editor' | 'brainstorm';
|
||||
export type TaskType = 'generation' | 'background' | 'analysis';
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
models?: ModelInfo[];
|
||||
}
|
||||
|
||||
export interface GenerateRequest {
|
||||
prompt: string;
|
||||
provider?: Provider;
|
||||
model?: string;
|
||||
api_key?: string;
|
||||
preset?: Preset;
|
||||
options?: Record<string, unknown>;
|
||||
story_bible?: StoryBible; // Story Bible для контекста
|
||||
selected_text?: string; // Выделенный текст для анализа
|
||||
taskType?: TaskType;
|
||||
}
|
||||
|
||||
// Типы для ответа
|
||||
export interface GenerateResponse {
|
||||
generated_text: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
preset: string;
|
||||
}
|
||||
|
||||
// API клиент
|
||||
const aiApi = axios.create({
|
||||
baseURL: 'http://localhost:8000/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Интерсептор для автоматического добавления токена авторизации
|
||||
aiApi.interceptors.request.use((config) => {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
/**
|
||||
* Получение списка доступных моделей для провайдера
|
||||
*/
|
||||
export async function getAvailableModels(
|
||||
provider: Provider
|
||||
): Promise<ModelInfo> {
|
||||
try {
|
||||
const response = await aiApi.get<{ models: ModelInfo[] }>(
|
||||
'/ai/models',
|
||||
{ params: { provider } }
|
||||
);
|
||||
|
||||
return {
|
||||
id: provider,
|
||||
name: provider === 'ollama' ? 'Ollama Models' : 'OpenRouter Models',
|
||||
models: response.data.models || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to load models from ${provider}:`, error);
|
||||
return {
|
||||
id: provider,
|
||||
name: provider === 'ollama' ? 'Ollama Models' : 'OpenRouter Models',
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация текста через AI с использованием настроек из store
|
||||
*/
|
||||
export async function generateText(
|
||||
request: Omit<GenerateRequest, 'provider' | 'model'> & { provider?: Provider; model?: string }
|
||||
): Promise<string> {
|
||||
const { useAppStore } = await import('@/stores/appStore');
|
||||
const { modelSettings, storyBible } = useAppStore.getState();
|
||||
|
||||
// Если provider и model явно указаны, используем их
|
||||
if (request.provider && request.model) {
|
||||
try {
|
||||
const response = await aiApi.post<GenerateResponse>('/ai/generate', {
|
||||
...request,
|
||||
story_bible: storyBible,
|
||||
});
|
||||
return response.data.generated_text;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(`AI generation failed: ${error.response?.data?.detail || error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Иначе используем настройки из store
|
||||
const taskType = (request as Record<string, unknown>).taskType || 'generation';
|
||||
const settings = modelSettings[taskType as keyof typeof modelSettings];
|
||||
|
||||
try {
|
||||
const response = await aiApi.post<GenerateResponse>('/ai/generate', {
|
||||
...request,
|
||||
provider: settings.provider,
|
||||
model: settings.model,
|
||||
story_bible: storyBible,
|
||||
});
|
||||
return response.data.generated_text;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(`AI generation failed: ${error.response?.data?.detail || error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Упрощенная функция для быстрого вызова
|
||||
*/
|
||||
export async function quickGenerate(
|
||||
prompt: string,
|
||||
preset: Preset = 'creative_draft',
|
||||
provider: Provider = 'ollama',
|
||||
model?: string
|
||||
): Promise<string> {
|
||||
return generateText({
|
||||
prompt,
|
||||
preset,
|
||||
provider,
|
||||
model,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Auto-update Story Bible ───
|
||||
|
||||
export interface AutoUpdateBibleResponse {
|
||||
updated: boolean;
|
||||
new_characters: number;
|
||||
new_locations: number;
|
||||
new_events: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function autoUpdateBible(
|
||||
projectId: string,
|
||||
sceneText: string,
|
||||
enabled: boolean = true
|
||||
): Promise<AutoUpdateBibleResponse> {
|
||||
try {
|
||||
const response = await aiApi.post<AutoUpdateBibleResponse>('/ai/auto-update-bible', {
|
||||
project_id: projectId,
|
||||
scene_text: sceneText,
|
||||
auto_update_enabled: enabled,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(`Auto-update failed: ${error.response?.data?.detail || error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Extract Entities ───
|
||||
|
||||
export interface ExtractEntitiesResponse {
|
||||
entities: {
|
||||
new_characters: any[];
|
||||
new_locations: any[];
|
||||
new_events: any[];
|
||||
};
|
||||
model_used: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export async function extractEntities(text: string): Promise<ExtractEntitiesResponse> {
|
||||
try {
|
||||
const response = await aiApi.post<ExtractEntitiesResponse>('/ai/extract-entities', {
|
||||
text,
|
||||
existing_bible: null, // Можно передать текущую Bible для дедупликации
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(`Extract entities failed: ${error.response?.data?.detail || error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import axios from 'axios';
|
||||
import type { DocumentNode, DocumentTreeNode, ModelSettings } from '@/stores/appStore';
|
||||
|
||||
// Конфигурация API
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
|
||||
|
||||
// Типы
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
// Типы для конфигурации моделей
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
context_length?: number;
|
||||
pricing?: string;
|
||||
}
|
||||
|
||||
export interface ModelsResponse {
|
||||
provider: string;
|
||||
default_model: string;
|
||||
models: ModelInfo[];
|
||||
}
|
||||
|
||||
export type Provider = 'ollama' | 'openrouter';
|
||||
|
||||
// Типы для Project
|
||||
export interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Типы для Story Bible
|
||||
export interface Character {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
traits: string[];
|
||||
appearance: string;
|
||||
role: 'protagonist' | 'antagonist' | 'supporting' | 'minor';
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
atmosphere: string;
|
||||
type: 'city' | 'building' | 'nature' | 'interior' | 'other';
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
chapter?: string;
|
||||
importance: 'major' | 'minor' | 'background';
|
||||
}
|
||||
|
||||
export interface StoryThread {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'active' | 'resolved' | 'abandoned';
|
||||
related_characters: string[];
|
||||
related_events: string[];
|
||||
}
|
||||
|
||||
export interface StoryBible {
|
||||
characters: Character[];
|
||||
locations: Location[];
|
||||
events: Event[];
|
||||
threads: StoryThread[];
|
||||
last_updated?: string;
|
||||
}
|
||||
|
||||
export interface CleanupDuplicatesResponse {
|
||||
removed_characters: number;
|
||||
removed_locations: number;
|
||||
removed_events: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Экспортируем все типы Story Bible
|
||||
export type { Character, Location, Event, StoryThread, StoryBible };
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Интерсептор для автоматического добавления токена
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
|
||||
interface ApiProjectResponse {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApiProjectCreate {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
|
||||
// API функции для работы с проектами
|
||||
export const projectsApi = {
|
||||
/**
|
||||
* Получить все проекты
|
||||
*/
|
||||
async getAllProjects(): Promise<Project[]> {
|
||||
const response = await apiClient.get<ApiProjectResponse[]>('/projects');
|
||||
return response.data.map((p) => ({
|
||||
...p,
|
||||
created_at: p.created_at,
|
||||
updated_at: p.updated_at,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Создать новый проект
|
||||
*/
|
||||
async createProject(project: Omit<ApiProjectCreate, 'id'>): Promise<Project> {
|
||||
const response = await apiClient.post<ApiProjectResponse>('/projects', project);
|
||||
return {
|
||||
...response.data,
|
||||
created_at: response.data.created_at,
|
||||
updated_at: response.data.updated_at,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Обновить проект
|
||||
*/
|
||||
async updateProject(
|
||||
projectId: string,
|
||||
updates: Partial<Omit<ApiProjectCreate, 'id'>>
|
||||
): Promise<Project> {
|
||||
const response = await apiClient.put<ApiProjectResponse>(`/projects/${projectId}`, updates);
|
||||
return {
|
||||
...response.data,
|
||||
created_at: response.data.created_at,
|
||||
updated_at: response.data.updated_at,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Удалить проект
|
||||
*/
|
||||
async deleteProject(projectId: string): Promise<void> {
|
||||
await apiClient.delete(`/projects/${projectId}`);
|
||||
},
|
||||
};
|
||||
|
||||
// API функции для работы с документами (иерархические)
|
||||
export const documentsApi = {
|
||||
/**
|
||||
* Получить документы в виде дерева
|
||||
*/
|
||||
async getDocumentsTree(projectId: string): Promise<DocumentTreeNode[]> {
|
||||
const response = await apiClient.get<DocumentTreeNode[]>(
|
||||
`/projects/${projectId}/documents/tree`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить все документы (плоский список)
|
||||
*/
|
||||
async getProjectDocuments(projectId: string): Promise<DocumentNode[]> {
|
||||
const response = await apiClient.get<DocumentNode[]>(
|
||||
`/projects/${projectId}/documents`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить документ по ID
|
||||
*/
|
||||
async getDocument(projectId: string, documentId: string): Promise<DocumentNode> {
|
||||
const response = await apiClient.get<DocumentNode>(
|
||||
`/projects/${projectId}/documents/${documentId}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Создать документ в проекте
|
||||
*/
|
||||
async createDocument(
|
||||
projectId: string,
|
||||
title: string,
|
||||
type: 'book' | 'chapter' | 'scene',
|
||||
parentId?: string,
|
||||
content: string = ''
|
||||
): Promise<DocumentNode> {
|
||||
const response = await apiClient.post<DocumentNode>(
|
||||
`/projects/${projectId}/documents`,
|
||||
{
|
||||
title,
|
||||
type,
|
||||
parent_id: parentId,
|
||||
content,
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Обновить документ (включая перенос)
|
||||
*/
|
||||
async updateDocument(
|
||||
projectId: string,
|
||||
documentId: string,
|
||||
updates: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
parent_id?: string;
|
||||
order?: number;
|
||||
}
|
||||
): Promise<DocumentNode> {
|
||||
const response = await apiClient.put<DocumentNode>(
|
||||
`/projects/${projectId}/documents/${documentId}`,
|
||||
updates
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Удалить документ с каскадом
|
||||
*/
|
||||
async deleteDocument(projectId: string, documentId: string): Promise<void> {
|
||||
await apiClient.delete(`/projects/${projectId}/documents/${documentId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить прямых детей узла
|
||||
*/
|
||||
async getChildren(projectId: string, parentId: string): Promise<DocumentNode[]> {
|
||||
const response = await apiClient.get<DocumentNode[]>(
|
||||
`/projects/${projectId}/documents/${parentId}/children`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// --- Legacy функции для обратной совместимости ---
|
||||
|
||||
/**
|
||||
* Обновить контент документа (legacy)
|
||||
*/
|
||||
async updateDocumentContent(
|
||||
projectId: string,
|
||||
documentId: string,
|
||||
content: string
|
||||
): Promise<DocumentNode> {
|
||||
const updatedDoc = await documentsApi.updateDocument(projectId, documentId, {
|
||||
content,
|
||||
});
|
||||
return updatedDoc;
|
||||
},
|
||||
};
|
||||
|
||||
// API функции для Story Bible
|
||||
export const storyBibleApi = {
|
||||
/**
|
||||
* Получить Story Bible проекта
|
||||
*/
|
||||
async getProjectStoryBible(projectId: string): Promise<StoryBible> {
|
||||
const response = await apiClient.get<StoryBible>(`/projects/${projectId}/bible`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Обновить Story Bible проекта
|
||||
*/
|
||||
async updateProjectStoryBible(
|
||||
projectId: string,
|
||||
bible: StoryBible
|
||||
): Promise<StoryBible> {
|
||||
const response = await apiClient.put<StoryBible>(`/projects/${projectId}/bible`, bible);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Очистить дубликаты в Story Bible
|
||||
*/
|
||||
async cleanupDuplicates(projectId: string): Promise<CleanupDuplicatesResponse> {
|
||||
const response = await apiClient.post<CleanupDuplicatesResponse>(
|
||||
`/projects/${projectId}/bible/cleanup-duplicates`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
const apiClientFactory = () => apiClient;
|
||||
|
||||
export default apiClientFactory;
|
||||
|
||||
/**
|
||||
* Экспорт документа в выбранный формат (epub, fb2, pdf, docx)
|
||||
*/
|
||||
export async function exportDocument(
|
||||
projectId: string,
|
||||
documentId: string,
|
||||
format: 'epub' | 'fb2' | 'pdf' | 'docx'
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await apiClient.post(`/export`,
|
||||
{ project_id: projectId, document_id: documentId, format },
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
|
||||
// Создаем временную ссылку для скачивания
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// Определяем расширение файла
|
||||
const extension = format === 'docx' ? 'docx' : format;
|
||||
link.setAttribute('download', `export.${extension}`);
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(`Export failed: ${error.response?.data?.detail || error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список доступных моделей для провайдера
|
||||
*/
|
||||
export async function getAvailableModels(provider: Provider): Promise<ModelsResponse> {
|
||||
try {
|
||||
const response = await apiClient.get<ModelsResponse>(`/ai/models?provider=${provider}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(`Failed to fetch models: ${error.response?.data?.detail || error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить данные модели для конкретного endpoint (generation, background, analysis)
|
||||
*/
|
||||
export async function getModelForTask(taskType: keyof ModelSettings): Promise<ModelsResponse> {
|
||||
try {
|
||||
// Получаем настройки из localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedSettings = localStorage.getItem('modelSettings');
|
||||
if (savedSettings) {
|
||||
const settings: ModelSettings = JSON.parse(savedSettings);
|
||||
const modelSetting = settings[taskType];
|
||||
if (modelSetting) {
|
||||
const response = await apiClient.get<ModelsResponse>(
|
||||
`/ai/models?provider=${modelSetting.provider}`
|
||||
);
|
||||
// Обновляем default_model на выбранный пользователем
|
||||
return {
|
||||
...response.data,
|
||||
default_model: modelSetting.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Если нет настроек, используем дефолтные
|
||||
const response = await apiClient.get<ModelsResponse>(`/ai/models?provider=ollama`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(`Failed to fetch model for ${taskType}: ${error.response?.data?.detail || error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// === ФУНКЦИИ ГЕНЕРАЦИИ ИЗОБРАЖЕНИЙ ===
|
||||
|
||||
export interface GenerateImageRequest {
|
||||
prompt: string;
|
||||
project_id: string;
|
||||
document_id: string; // ID документа (книги или главы)
|
||||
document_type: 'book' | 'chapter'; // Тип документа
|
||||
model?: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export interface GenerateImageResponse {
|
||||
image_id: string;
|
||||
filename: string;
|
||||
path: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
data_url: string; // base64 изображение для немедленного отображения
|
||||
document_id: string;
|
||||
document_type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сгенерировать изображение
|
||||
*/
|
||||
export async function generateImage(request: GenerateImageRequest): Promise<GenerateImageResponse> {
|
||||
const response = await apiClient.post<GenerateImageResponse>('/ai/generate-image', request);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить URL изображения
|
||||
*/
|
||||
export function getImageUrl(projectId: string, filename: string): string {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace('/api', '') || 'http://localhost:8000';
|
||||
return `${baseUrl}/api/images/${projectId}/${filename}`;
|
||||
}
|
||||
|
||||
// === ФУНКЦИИ АВТОРИЗАЦИИ ===
|
||||
|
||||
/**
|
||||
* Войти в систему
|
||||
*/
|
||||
export async function login(username: string, password: string): Promise<AuthResponse> {
|
||||
const response = await apiClient.post<AuthResponse>('/auth/login', { username, password });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Зарегистрировать нового пользователя
|
||||
*/
|
||||
export async function register(username: string, email: string, password: string): Promise<AuthResponse> {
|
||||
const response = await apiClient.post<AuthResponse>('/auth/register', { username, email, password });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить текущего пользователя
|
||||
*/
|
||||
export async function fetchCurrentUser(): Promise<User> {
|
||||
const response = await apiClient.get<User>('/auth/me');
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Mark, mergeAttributes } from '@tiptap/core';
|
||||
|
||||
export interface FontSizeOptions {
|
||||
types: string[];
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
fontSize: {
|
||||
setFontSize: (size: string) => ReturnType;
|
||||
unsetFontSize: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const FontSize = Mark.create<FontSizeOptions>({
|
||||
name: 'fontSize',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
types: ['fontSize'],
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
fontSize: {
|
||||
default: null as string | null,
|
||||
parseHTML: element => element.getAttribute('data-font-size'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.fontSize) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
'data-font-size': attributes.fontSize,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'span[data-font-size]',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ mark, HTMLAttributes }) {
|
||||
const fontSize = mark.attrs.fontSize;
|
||||
if (!fontSize) {
|
||||
return {};
|
||||
}
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
style: `font-size: ${fontSize}`,
|
||||
}),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setFontSize: (size: string) => {
|
||||
return ({ chain }) => {
|
||||
return chain()
|
||||
.setMark(this.name, { fontSize: size })
|
||||
.run();
|
||||
};
|
||||
},
|
||||
unsetFontSize: () => {
|
||||
return ({ chain }) => {
|
||||
return chain()
|
||||
.setMark(this.name, { fontSize: null })
|
||||
.run();
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
import { create } from 'zustand';
|
||||
import { projectsApi, documentsApi, storyBibleApi, type StoryBible, type Project, type User, login, register, fetchCurrentUser } from '@/lib/api';
|
||||
|
||||
// Типы данных
|
||||
export type DocumentType = 'book' | 'chapter' | 'scene';
|
||||
|
||||
export interface DocumentNode {
|
||||
id: string;
|
||||
project_id: string;
|
||||
parent_id: string | null;
|
||||
title: string;
|
||||
content: string;
|
||||
type: 'book' | 'chapter' | 'scene';
|
||||
order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DocumentTreeNode extends Omit<DocumentNode, 'children'> {
|
||||
children: DocumentTreeNode[];
|
||||
}
|
||||
|
||||
// Для обратной совместимости
|
||||
export type Document = DocumentNode;
|
||||
|
||||
export interface ModelSettings {
|
||||
generation: {
|
||||
provider: 'ollama' | 'openrouter';
|
||||
model: string;
|
||||
};
|
||||
background: {
|
||||
provider: 'ollama' | 'openrouter';
|
||||
model: string;
|
||||
};
|
||||
analysis: {
|
||||
provider: 'ollama' | 'openrouter';
|
||||
model: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
// Авторизация
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoadingAuth: boolean;
|
||||
|
||||
// Проекты
|
||||
projects: Project[];
|
||||
activeProjectId: string | null;
|
||||
|
||||
// Документы (иерархические)
|
||||
documentsTree: DocumentTreeNode[];
|
||||
documents: DocumentNode[]; // Плоский список для быстрого доступа
|
||||
activeDocumentId: string | null;
|
||||
|
||||
// Story Bible
|
||||
storyBible: StoryBible | null;
|
||||
|
||||
// Настройки
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
modelSettings: ModelSettings;
|
||||
autoUpdateBible: boolean;
|
||||
|
||||
// Actions для проектов
|
||||
setUser: (user: User | null) => void;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
register: (username: string, email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<void>;
|
||||
|
||||
loadProjects: () => Promise<void>;
|
||||
setActiveProject: (projectId: string) => Promise<void>;
|
||||
createProject: (title: string, description?: string) => Promise<Project>;
|
||||
deleteProject: (projectId: string) => Promise<void>;
|
||||
|
||||
// Actions для документов (иерархические)
|
||||
loadDocumentsTree: (projectId: string) => Promise<void>;
|
||||
setActiveDocument: (documentId: string) => void;
|
||||
switchToDocument: (documentId: string) => Promise<void>;
|
||||
createDocument: (
|
||||
projectId: string,
|
||||
title: string,
|
||||
type: 'book' | 'chapter' | 'scene',
|
||||
parentId?: string,
|
||||
content?: string
|
||||
) => Promise<DocumentNode>;
|
||||
updateDocument: (
|
||||
projectId: string,
|
||||
documentId: string,
|
||||
updates: { title?: string; content?: string; parent_id?: string; order?: number }
|
||||
) => Promise<void>;
|
||||
updateDocumentContent: (
|
||||
projectId: string,
|
||||
documentId: string,
|
||||
content: string
|
||||
) => Promise<void>;
|
||||
deleteDocument: (projectId: string, documentId: string) => Promise<void>;
|
||||
|
||||
// Actions для Story Bible (теперь требуют projectId)
|
||||
loadStoryBible: (projectId: string) => Promise<void>;
|
||||
setStoryBible: (bible: StoryBible) => void;
|
||||
updateStoryBible: (projectId: string, bible: StoryBible) => Promise<void>;
|
||||
|
||||
// Actions для настроек
|
||||
setModelSettings: (settings: ModelSettings) => void;
|
||||
updateModelSetting: (
|
||||
taskType: keyof ModelSettings,
|
||||
provider: 'ollama' | 'openrouter',
|
||||
model: string
|
||||
) => void;
|
||||
setAutoUpdateBible: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const defaultModelSettings: ModelSettings = {
|
||||
generation: { provider: 'ollama', model: 'qwen2.5:1.5b' },
|
||||
background: { provider: 'ollama', model: 'qwen2.5:1.5b' },
|
||||
analysis: { provider: 'ollama', model: 'qwen2.5:1.5b' },
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => ({
|
||||
// Авторизация
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoadingAuth: true,
|
||||
|
||||
// Проекты
|
||||
projects: [],
|
||||
activeProjectId: null,
|
||||
|
||||
// Документы (иерархические)
|
||||
documentsTree: [],
|
||||
documents: [], // Плоский список для быстрого доступа
|
||||
activeDocumentId: null,
|
||||
|
||||
// Story Bible
|
||||
storyBible: null,
|
||||
|
||||
// Загрузка/сохранение
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
|
||||
// Настройки моделей
|
||||
modelSettings: defaultModelSettings,
|
||||
autoUpdateBible: true,
|
||||
|
||||
// === МЕТОДЫ АВТОРИЗАЦИИ ===
|
||||
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||
|
||||
login: async (username, password) => {
|
||||
const response = await login(username, password);
|
||||
localStorage.setItem('auth_token', response.access_token);
|
||||
const user = await fetchCurrentUser();
|
||||
set({ user, isAuthenticated: true });
|
||||
await get().loadProjects(); // Загружаем проекты после входа
|
||||
},
|
||||
|
||||
register: async (username, email, password) => {
|
||||
const response = await register(username, email, password);
|
||||
localStorage.setItem('auth_token', response.access_token);
|
||||
const user = await fetchCurrentUser();
|
||||
set({ user, isAuthenticated: true });
|
||||
await get().loadProjects();
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('auth_token');
|
||||
set({ user: null, isAuthenticated: false, projects: [], activeProjectId: null });
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
set({ isLoadingAuth: true });
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
try {
|
||||
const user = await fetchCurrentUser();
|
||||
set({ user, isAuthenticated: true });
|
||||
await get().loadProjects();
|
||||
} catch (error) {
|
||||
console.error('Token invalid, logging out');
|
||||
get().logout();
|
||||
}
|
||||
}
|
||||
set({ isLoadingAuth: false });
|
||||
},
|
||||
|
||||
// Загрузка проектов
|
||||
loadProjects: async () => {
|
||||
try {
|
||||
const { getAllProjects } = projectsApi;
|
||||
const projects = await getAllProjects();
|
||||
set({ projects });
|
||||
|
||||
// Если есть проекты и нет активного, выбираем первый
|
||||
if (projects.length > 0 && !get().activeProjectId) {
|
||||
await get().setActiveProject(projects[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load projects:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Установка активного проекта
|
||||
setActiveProject: async (projectId: string) => {
|
||||
set({ activeProjectId: projectId });
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('activeProjectId', projectId);
|
||||
}
|
||||
|
||||
// Загружаем документы и Bible этого проекта
|
||||
await Promise.all([
|
||||
get().loadDocumentsTree(projectId),
|
||||
get().loadStoryBible(projectId),
|
||||
]);
|
||||
},
|
||||
|
||||
// Создание проекта
|
||||
createProject: async (title, description = '') => {
|
||||
const { createProject: apiCreateProject } = projectsApi;
|
||||
const project = await apiCreateProject({ title, description });
|
||||
set((state) => ({ projects: [...state.projects, project] }));
|
||||
return project;
|
||||
},
|
||||
|
||||
// Удаление проекта
|
||||
deleteProject: async (projectId) => {
|
||||
const { deleteProject: apiDeleteProject } = projectsApi;
|
||||
await apiDeleteProject(projectId);
|
||||
set((state) => ({
|
||||
projects: state.projects.filter(p => p.id !== projectId),
|
||||
activeProjectId: state.activeProjectId === projectId ? null : state.activeProjectId,
|
||||
}));
|
||||
},
|
||||
|
||||
// Загрузка дерева документов проекта
|
||||
loadDocumentsTree: async (projectId) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const { getDocumentsTree, getProjectDocuments } = documentsApi;
|
||||
const [tree, flat] = await Promise.all([
|
||||
getDocumentsTree(projectId),
|
||||
getProjectDocuments(projectId),
|
||||
]);
|
||||
// Выбираем активным документом первую сцену, или null
|
||||
const firstScene = flat.find(d => d.type === 'scene');
|
||||
set({
|
||||
documentsTree: tree,
|
||||
documents: flat,
|
||||
activeDocumentId: firstScene?.id || null,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load documents tree:', error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// Установка активного документа
|
||||
setActiveDocument: (documentId) => {
|
||||
set({ activeDocumentId: documentId });
|
||||
},
|
||||
|
||||
// Безопасное переключение документа с предварительным сохранением
|
||||
switchToDocument: async (documentId: string) => {
|
||||
const { activeDocumentId, activeProjectId, documents } = get();
|
||||
|
||||
// Сначала сохраняем текущий документ (если есть)
|
||||
if (activeDocumentId && activeProjectId) {
|
||||
const currentDoc = documents.find(d => d.id === activeDocumentId);
|
||||
if (currentDoc) {
|
||||
try {
|
||||
// Вызываем глобальный callback из Editor для сохранения текущего контента
|
||||
if (window.saveCurrentEditorContent) {
|
||||
console.log('[Store] Saving current document before switch:', activeDocumentId);
|
||||
await window.saveCurrentEditorContent();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Store] Failed to save before switch:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Переключаем активный документ
|
||||
set({ activeDocumentId: documentId });
|
||||
},
|
||||
|
||||
// Создание нового документа
|
||||
createDocument: async (projectId, title, type, parentId, content = '') => {
|
||||
const { createDocument: apiCreate } = documentsApi;
|
||||
const doc = await apiCreate(projectId, title, type, parentId, content);
|
||||
await get().loadDocumentsTree(projectId);
|
||||
return doc;
|
||||
},
|
||||
|
||||
// Обновление документа
|
||||
updateDocument: async (projectId, documentId, updates) => {
|
||||
const { updateDocument: apiUpdate } = documentsApi;
|
||||
await apiUpdate(projectId, documentId, updates);
|
||||
|
||||
// Обновляем в store (плоский список)
|
||||
set((state) => ({
|
||||
documents: state.documents.map(doc =>
|
||||
doc.id === documentId ? { ...doc, ...updates, updated_at: new Date().toISOString() } : doc
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
// Удаление документа
|
||||
deleteDocument: async (projectId, documentId) => {
|
||||
const { deleteDocument: apiDelete } = documentsApi;
|
||||
await apiDelete(projectId, documentId);
|
||||
await get().loadDocumentsTree(projectId);
|
||||
|
||||
// Если удалённый документ был активным, сбрасываем
|
||||
if (get().activeDocumentId === documentId) {
|
||||
const flat = get().documents;
|
||||
const firstScene = flat.find(d => d.type === 'scene');
|
||||
set({ activeDocumentId: firstScene?.id || null });
|
||||
}
|
||||
},
|
||||
|
||||
// Загрузка Story Bible проекта
|
||||
loadStoryBible: async (projectId) => {
|
||||
try {
|
||||
const { getProjectStoryBible } = storyBibleApi;
|
||||
const bible = await getProjectStoryBible(projectId);
|
||||
set({ storyBible: bible });
|
||||
} catch (error) {
|
||||
console.error('Failed to load Story Bible:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Установка Story Bible
|
||||
setStoryBible: (bible) => set({ storyBible: bible }),
|
||||
|
||||
// Обновление Story Bible через API
|
||||
updateStoryBible: async (projectId, bible) => {
|
||||
const { updateProjectStoryBible } = storyBibleApi;
|
||||
await updateProjectStoryBible(projectId, bible);
|
||||
set({ storyBible: bible });
|
||||
},
|
||||
|
||||
// Управление настройками моделей
|
||||
setModelSettings: (settings) => {
|
||||
set({ modelSettings: settings });
|
||||
// Сохраняем в localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('modelSettings', JSON.stringify(settings));
|
||||
}
|
||||
},
|
||||
|
||||
updateModelSetting: (taskType, provider, model) => {
|
||||
set((state) => {
|
||||
const newSettings = {
|
||||
...state.modelSettings,
|
||||
[taskType]: { provider, model },
|
||||
};
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('modelSettings', JSON.stringify(newSettings));
|
||||
}
|
||||
return { modelSettings: newSettings };
|
||||
});
|
||||
},
|
||||
|
||||
// Обновление только контента документа (обёртка над updateDocument)
|
||||
updateDocumentContent: async (projectId, documentId, content) => {
|
||||
try {
|
||||
const { updateDocumentContent: apiUpdate } = documentsApi;
|
||||
await apiUpdate(projectId, documentId, content);
|
||||
|
||||
// ВАЖНО: Обновляем в store сразу, не дожидаясь перезагрузки
|
||||
set((state) => ({
|
||||
documents: state.documents.map(doc =>
|
||||
doc.id === documentId ? { ...doc, content, updated_at: new Date().toISOString() } : doc
|
||||
),
|
||||
}));
|
||||
|
||||
console.log('[Store] Document content updated in store:', documentId);
|
||||
} catch (error) {
|
||||
console.error('[Store] Failed to update document:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Переключатель автообновления Story Bible
|
||||
setAutoUpdateBible: (enabled) => {
|
||||
set({ autoUpdateBible: enabled });
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('autoUpdateBible', JSON.stringify(enabled));
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Загрузка сохранённых состояний при инициализации
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedProjectId = localStorage.getItem('activeProjectId');
|
||||
if (savedProjectId) {
|
||||
useAppStore.setState({ activeProjectId: savedProjectId });
|
||||
}
|
||||
|
||||
const savedSettings = localStorage.getItem('modelSettings');
|
||||
if (savedSettings) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedSettings);
|
||||
useAppStore.setState({ modelSettings: parsed });
|
||||
} catch (e) {
|
||||
console.error('Failed to load model settings:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const savedAutoUpdateBible = localStorage.getItem('autoUpdateBible');
|
||||
if (savedAutoUpdateBible !== null) {
|
||||
try {
|
||||
useAppStore.setState({ autoUpdateBible: JSON.parse(savedAutoUpdateBible) });
|
||||
} catch (e) {
|
||||
console.error('Failed to load autoUpdateBible setting:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка авторизации
|
||||
useAppStore.getState().checkAuth();
|
||||
}
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# Скрипт для сборки и тестирования Docker-образа frontend локально
|
||||
|
||||
echo "🔨 Building frontend Docker image..."
|
||||
docker build -t writer-ide-frontend:local .
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Build failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Build successful!"
|
||||
echo ""
|
||||
echo "🚀 To run the container:"
|
||||
echo "docker run -p 3000:3000 -e NEXT_PUBLIC_API_URL=http://localhost:8000/api writer-ide-frontend:local"
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in new issue
Block a user