diff --git a/index.html b/index.html index ca815f1..29db50b 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,14 @@ /> ReSekai + + + + + diff --git a/src/components/Header.css b/src/components/Header.css index ed894dd..469c691 100644 --- a/src/components/Header.css +++ b/src/components/Header.css @@ -138,4 +138,179 @@ .user-name { display: none; } + + .auth-section { + display: none; + } +} + +/* Mobile menu button (hamburger) */ +.mobile-menu-btn { + display: none; + flex-direction: column; + justify-content: center; + align-items: center; + width: 40px; + height: 40px; + background: transparent; + border: none; + cursor: pointer; + padding: 8px; + gap: 5px; + z-index: 1001; +} + +.mobile-menu-btn span { + display: block; + width: 24px; + height: 2px; + background: white; + border-radius: 2px; + transition: all 0.3s ease; +} + +.mobile-menu-btn.open span:nth-child(1) { + transform: rotate(45deg) translate(5px, 5px); +} + +.mobile-menu-btn.open span:nth-child(2) { + opacity: 0; +} + +.mobile-menu-btn.open span:nth-child(3) { + transform: rotate(-45deg) translate(5px, -5px); +} + +@media (max-width: 768px) { + .mobile-menu-btn { + display: flex; + } +} + +/* Mobile menu */ +.mobile-menu { + display: none; + position: fixed; + top: 64px; + right: -100%; + width: 280px; + max-width: 80vw; + height: calc(100vh - 64px); + background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); + z-index: 1000; + transition: right 0.3s ease; + padding: 1.5rem; + box-shadow: -4px 0 20px rgba(0, 0, 0, 0.5); + overflow-y: auto; +} + +.mobile-menu.open { + right: 0; +} + +@media (max-width: 768px) { + .mobile-menu { + display: block; + } +} + +.mobile-nav { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 2rem; +} + +.mobile-nav-link { + display: block; + color: rgba(255, 255, 255, 0.9); + text-decoration: none; + font-size: 1.1rem; + font-weight: 500; + padding: 1rem; + border-radius: 12px; + transition: all 0.2s; +} + +.mobile-nav-link:hover, +.mobile-nav-link:active { + background: rgba(255, 255, 255, 0.1); + color: white; +} + +.mobile-user { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + margin-bottom: 1rem; +} + +.mobile-user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid #764ba2; +} + +.mobile-user-name { + flex: 1; + color: white; + font-weight: 500; +} + +.mobile-logout-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + padding: 0.5rem 1rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; +} + +.mobile-logout-btn:hover { + background: rgba(255, 255, 255, 0.2); +} + +.mobile-login-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + background: #5865f2; + border: none; + color: white; + padding: 1rem; + border-radius: 12px; + cursor: pointer; + font-size: 1rem; + font-weight: 500; + transition: all 0.2s; +} + +.mobile-login-btn:hover { + background: #4752c4; +} + +/* Overlay */ +.mobile-menu-overlay { + display: none; + position: fixed; + top: 64px; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999; +} + +@media (max-width: 768px) { + .mobile-menu-overlay { + display: block; + } } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 9936663..a875e1f 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Link } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; import { getDiscordAvatarUrl } from "../services/api"; @@ -5,11 +6,14 @@ import "./Header.css"; export function Header() { const { user, isLoading, isAuthenticated, login, logout } = useAuth(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + const closeMenu = () => setMobileMenuOpen(false); return (
- + ⚔️ ReSekai @@ -57,11 +61,69 @@ export function Header() { > - Войти через Discord + Войти )}
+ + {/* Mobile menu button */} + + + {/* Mobile menu */} +
+ + {isAuthenticated && user ? ( +
+ {user.username} + {user.username} + +
+ ) : ( + + )} +
+ + {/* Overlay */} + {mobileMenuOpen && ( +
+ )}
); } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 0fca018..97af23e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,3 +2,4 @@ export { useCharacterDetection } from "./useCharacterDetection"; export { useStreamingResponse } from "./useStreamingResponse"; export { useGameSession } from "./useGameSession"; export { useLazyMessages } from "./useLazyMessages"; +export { useStoryGeneration } from "./useStoryGeneration"; diff --git a/src/hooks/useStoryGeneration.ts b/src/hooks/useStoryGeneration.ts new file mode 100644 index 0000000..b991371 --- /dev/null +++ b/src/hooks/useStoryGeneration.ts @@ -0,0 +1,99 @@ +import { useState, useCallback } from "react"; +import { sendMessage } from "../services/deepseek"; +import type { Character } from "../types"; + +interface StoryGenerationContext { + title?: string; + summary?: string; + genres?: string[]; + settings?: string[]; + worldName?: string; + worldDescription?: string; + isNsfw?: boolean; + characters?: Character[]; +} + +interface UseStoryGenerationResult { + isGenerating: boolean; + generatePlot: (context: StoryGenerationContext) => Promise; +} + +/** + * Hook for AI-powered story generation + * Uses {user} placeholder for protagonist name + */ +export function useStoryGeneration(): UseStoryGenerationResult { + const [isGenerating, setIsGenerating] = useState(false); + + const generatePlot = useCallback( + async (context: StoryGenerationContext): Promise => { + const { title, summary, genres, settings, worldName, worldDescription, isNsfw, characters } = context; + + // Validate minimum requirements + if (!title && !summary && (!genres || genres.length === 0) && (!settings || settings.length === 0)) { + return null; + } + + setIsGenerating(true); + try { + const contextLines: string[] = []; + if (title) contextLines.push(`Название: ${title}`); + if (summary) contextLines.push(`Краткое содержание: ${summary}`); + if (genres && genres.length > 0) contextLines.push(`Жанры: ${genres.join(", ")}`); + if (settings && settings.length > 0) contextLines.push(`Сеттинг: ${settings.join(", ")}`); + if (worldName) contextLines.push(`Мир: ${worldName}`); + if (worldDescription) contextLines.push(`Описание мира: ${worldDescription}`); + if (isNsfw) contextLines.push("NSFW контент разрешён"); + + const namedCharacters = characters?.filter(c => c.name.trim()) || []; + if (namedCharacters.length > 0) { + contextLines.push(`Персонажи: ${namedCharacters.map(c => `${c.name} (${c.role})`).join(", ")}`); + } + + const prompt = `На основе следующей информации создай краткий сюжет для интерактивной исекай-истории. + +${contextLines.join("\n")} + +ВАЖНО: Для главного героя всегда используй {user} вместо конкретного имени. + +Напиши сюжет КРАТКО (до 1000 символов) в формате Markdown: +# Завязка +(Как {user} попадает в этот мир) + +# Основной конфликт +(Главная проблема/цель) + +# Ключевые события +- Событие 1 +- Событие 2 + +Будь лаконичен! Ответ только на русском языке.`; + + const response = await sendMessage( + [ + { + role: "system", + content: "Ты сценарист исекай-историй. Пиши кратко и по делу. Для главного героя ВСЕГДА используй {user}. Максимум 1000 символов.", + }, + { role: "user", content: prompt }, + ], + 0.9, + 500 + ); + + return response; + } catch (error) { + console.error("Ошибка генерации сюжета:", error); + return null; + } finally { + setIsGenerating(false); + } + }, + [] + ); + + return { + isGenerating, + generatePlot, + }; +} diff --git a/src/pages/CreateStoryPage.css b/src/pages/CreateStoryPage.css index 34aff89..9a07370 100644 --- a/src/pages/CreateStoryPage.css +++ b/src/pages/CreateStoryPage.css @@ -902,12 +902,51 @@ .temp-label { font-size: 0.9rem; } + + /* Character card mobile */ + .character-content { + flex-direction: column; + align-items: center; + } + + .character-avatar-section { + width: 100%; + flex-direction: row; + justify-content: center; + gap: 1rem; + } + + .character-avatar-preview, + .character-avatar-placeholder { + width: 80px; + height: 120px; + } + + .character-fields { + width: 100%; + } + + .character-row { + grid-template-columns: 1fr; + } + + .character-row select { + width: 100%; + } + + .character-buttons { + flex-direction: column; + } + + .add-btn { + width: 100%; + } } -/* Character row with two selects */ +/* Character row with three selects */ .character-row { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem; } @@ -1024,3 +1063,49 @@ font-size: 0.8rem; color: #888; } + +/* Plot generation button */ +.plot-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.btn-generate-plot { + padding: 0.5rem 1rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + border-radius: 8px; + color: white; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.btn-generate-plot:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.btn-generate-plot:disabled { + opacity: 0.6; + cursor: wait; +} + +@media (max-width: 600px) { + .plot-header { + flex-direction: column; + align-items: stretch; + } + + .btn-generate-plot { + width: 100%; + justify-content: center; + } +} diff --git a/src/pages/CreateStoryPage.tsx b/src/pages/CreateStoryPage.tsx index 65ed834..9c7c1f2 100644 --- a/src/pages/CreateStoryPage.tsx +++ b/src/pages/CreateStoryPage.tsx @@ -9,6 +9,7 @@ import { getNPCCharacters, } from "../services/api"; import { generateAvatarUrl } from "../services/imageGen"; +import { useStoryGeneration } from "../hooks/useStoryGeneration"; import type { Character, CharacterAge, @@ -93,6 +94,9 @@ export default function CreateStoryPage() { const [savedNPCs, setSavedNPCs] = useState([]); const [showNPCSelector, setShowNPCSelector] = useState(false); + // Story generation hook + const { isGenerating: generatingPlot, generatePlot } = useStoryGeneration(); + const [form, setForm] = useState({ title: "", description: "", @@ -344,6 +348,31 @@ export default function CreateStoryPage() { } }; + // Генерация сюжета от ИИ + const handleGeneratePlot = async () => { + const result = await generatePlot({ + title: form.title, + summary: form.summary, + genres: form.genres, + settings: form.settings, + worldName: form.worldName, + worldDescription: form.worldDescription, + isNsfw: form.isNsfw, + characters: form.characters, + }); + + if (result === null && !form.title && !form.summary && form.genres.length === 0 && form.settings.length === 0) { + alert("Заполните хотя бы название, краткое содержание, жанры или сеттинг для генерации сюжета"); + return; + } + + if (result) { + setForm(prev => ({ ...prev, plot: result })); + } else { + alert("Ошибка при генерации сюжета"); + } + }; + // Правила мира const handleRuleChange = (index: number, value: string) => { const newRules = [...form.worldRules]; @@ -697,7 +726,17 @@ export default function CreateStoryPage() {
- +
+ + +

Основа для ИИ — подробное описание сюжета и ключевых событий. Поддерживается Markdown. diff --git a/src/services/deepseek.ts b/src/services/deepseek.ts index 5ce7e86..6ac3b95 100644 --- a/src/services/deepseek.ts +++ b/src/services/deepseek.ts @@ -35,6 +35,7 @@ interface DeepSeekResponse { export async function sendMessage( messages: DeepSeekMessage[], temperature: number = 0.8, + max_tokens: number = 1000, ): Promise { const response = await fetch(`${API_BASE}/api/deepseek/chat`, { method: "POST", @@ -45,7 +46,7 @@ export async function sendMessage( body: JSON.stringify({ messages, temperature, - max_tokens: 1000, + max_tokens, }), });