feat: add Inter font, extract useStoryGeneration hook, fix mobile menu

This commit is contained in:
Alexej Wolff
2026-05-06 00:11:33 +02:00
parent c9a7144960
commit c379074781
8 changed files with 476 additions and 6 deletions
+8
View File
@@ -8,6 +8,14 @@
/>
<title>ReSekai</title>
<!-- Google Fonts: Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<!-- PWA -->
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#667eea" />
+175
View File
@@ -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;
}
}
+64 -2
View File
@@ -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 (
<header className="header">
<div className="header-content">
<Link to="/" className="logo">
<Link to="/" className="logo" onClick={closeMenu}>
<span className="logo-icon"></span>
<span className="logo-text">ReSekai</span>
</Link>
@@ -57,11 +61,69 @@ export function Header() {
>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Войти
</button>
)}
</div>
{/* Mobile menu button */}
<button
className={`mobile-menu-btn${mobileMenuOpen ? " open" : ""}`}
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Меню"
>
<span></span>
<span></span>
<span></span>
</button>
</div>
{/* Mobile menu */}
<div className={`mobile-menu${mobileMenuOpen ? " open" : ""}`}>
<nav className="mobile-nav">
<Link to="/" className="mobile-nav-link" onClick={closeMenu}>
📚 Истории
</Link>
{isAuthenticated && (
<>
<Link to="/create" className="mobile-nav-link" onClick={closeMenu}>
Создать историю
</Link>
<Link to="/characters" className="mobile-nav-link" onClick={closeMenu}>
👤 Персонажи
</Link>
<Link to="/npc" className="mobile-nav-link" onClick={closeMenu}>
👥 NPC
</Link>
</>
)}
</nav>
{isAuthenticated && user ? (
<div className="mobile-user">
<img
src={getDiscordAvatarUrl(user)}
alt={user.username}
className="mobile-user-avatar"
/>
<span className="mobile-user-name">{user.username}</span>
<button onClick={() => { logout(); closeMenu(); }} className="mobile-logout-btn">
Выйти
</button>
</div>
) : (
<button onClick={() => { login(); closeMenu(); }} className="mobile-login-btn">
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Войти через Discord
</button>
)}
</div>
</div>
{/* Overlay */}
{mobileMenuOpen && (
<div className="mobile-menu-overlay" onClick={closeMenu} />
)}
</header>
);
}
+1
View File
@@ -2,3 +2,4 @@ export { useCharacterDetection } from "./useCharacterDetection";
export { useStreamingResponse } from "./useStreamingResponse";
export { useGameSession } from "./useGameSession";
export { useLazyMessages } from "./useLazyMessages";
export { useStoryGeneration } from "./useStoryGeneration";
+99
View File
@@ -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<string | null>;
}
/**
* 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<string | null> => {
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,
};
}
+87 -2
View File
@@ -902,12 +902,51 @@
.temp-label {
font-size: 0.9rem;
}
/* Character card mobile */
.character-content {
flex-direction: column;
align-items: center;
}
/* Character row with two selects */
.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 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;
}
}
+39
View File
@@ -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<NPCCharacter[]>([]);
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() {
</h2>
<div className="form-group">
<div className="plot-header">
<label htmlFor="plot">Полный сюжет *</label>
<button
type="button"
onClick={handleGeneratePlot}
className="btn-generate-plot"
disabled={generatingPlot}
>
{generatingPlot ? "⏳ Генерация..." : "✨ Сгенерировать сюжет"}
</button>
</div>
<p className="field-hint">
Основа для ИИ подробное описание сюжета и ключевых событий.
Поддерживается Markdown.
+2 -1
View File
@@ -35,6 +35,7 @@ interface DeepSeekResponse {
export async function sendMessage(
messages: DeepSeekMessage[],
temperature: number = 0.8,
max_tokens: number = 1000,
): Promise<string> {
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,
}),
});