feat: add Inter font, extract useStoryGeneration hook, fix mobile menu
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ export { useCharacterDetection } from "./useCharacterDetection";
|
||||
export { useStreamingResponse } from "./useStreamingResponse";
|
||||
export { useGameSession } from "./useGameSession";
|
||||
export { useLazyMessages } from "./useLazyMessages";
|
||||
export { useStoryGeneration } from "./useStoryGeneration";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user