Update files

This commit is contained in:
glpshchn 2026-01-01 20:57:05 +03:00
parent a2aedc95af
commit 0af5b0e638
20 changed files with 1350 additions and 64 deletions

View File

@ -49,3 +49,4 @@ TagSchema.index({ usageCount: -1 });
module.exports = mongoose.model('Tag', TagSchema); module.exports = mongoose.model('Tag', TagSchema);

View File

@ -3,7 +3,7 @@ const mongoose = require('mongoose');
const UserSchema = new mongoose.Schema({ const UserSchema = new mongoose.Schema({
telegramId: { telegramId: {
type: String, type: String,
required: true, sparse: true, // Разрешаем null для web-пользователей
unique: true unique: true
}, },
username: { username: {
@ -128,6 +128,9 @@ const UserSchema = new mongoose.Schema({
type: Boolean, type: Boolean,
default: false default: false
}, },
// Magic-link токены для авторизации
magicLinkToken: String,
magicLinkExpires: Date,
createdAt: { createdAt: {
type: Date, type: Date,
default: Date.now default: Date.now

View File

@ -312,6 +312,282 @@ router.post('/verify', authenticate, async (req, res) => {
} }
}); });
// Magic-link авторизация: отправка ссылки на email
router.post('/magic-link/send', strictAuthLimiter, async (req, res) => {
try {
const { email } = req.body;
if (!email || typeof email !== 'string') {
return res.status(400).json({ error: 'Email обязателен' });
}
const emailLower = email.toLowerCase().trim();
// Простая валидация email
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailLower)) {
return res.status(400).json({ error: 'Неверный формат email' });
}
// Найти или создать пользователя
let user = await User.findOne({ email: emailLower });
if (!user) {
// Создаем нового пользователя
user = new User({
email: emailLower,
username: emailLower.split('@')[0], // Временный username из email
role: 'user',
emailVerified: false
});
await user.save();
console.log('[Auth] Создан новый пользователь для email:', emailLower);
}
// Генерируем magic-link токен
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 минут
// Сохраняем токен в пользователя
user.magicLinkToken = token;
user.magicLinkExpires = expiresAt;
await user.save();
// Формируем ссылку
const frontendUrl = config.frontendUrl || 'https://nkm.guru';
const magicLink = `${frontendUrl}/auth/verify?token=${token}`;
// Отправляем email
const { sendEmail } = require('../utils/email');
try {
await sendEmail(
emailLower,
'Вход в Nakama',
`
<h1>Добро пожаловать в Nakama!</h1>
<p>Нажмите на ссылку ниже, чтобы войти:</p>
<p><a href="${magicLink}" style="display: inline-block; padding: 12px 24px; background: #007AFF; color: white; text-decoration: none; border-radius: 8px;">Войти в Nakama</a></p>
<p>Ссылка действительна 15 минут.</p>
<p style="color: #666; font-size: 14px;">Если вы не запрашивали эту ссылку, просто проигнорируйте это письмо.</p>
`,
`Войдите в Nakama по ссылке: ${magicLink}\n\nСсылка действительна 15 минут.`
);
console.log('[Auth] Magic-link отправлен на:', emailLower);
res.json({ success: true, message: 'Ссылка отправлена на email' });
} catch (emailError) {
console.error('[Auth] Ошибка отправки email:', emailError);
res.status(500).json({ error: 'Не удалось отправить email. Попробуйте позже.' });
}
} catch (error) {
console.error('[Auth] Ошибка magic-link/send:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Magic-link авторизация: верификация токена
router.get('/magic-link/verify', async (req, res) => {
try {
const { token } = req.query;
if (!token) {
return res.status(400).json({ error: 'Токен не указан' });
}
// Найти пользователя с этим токеном
const user = await User.findOne({
magicLinkToken: token,
magicLinkExpires: { $gt: new Date() }
});
if (!user) {
return res.status(401).json({ error: 'Ссылка недействительна или устарела' });
}
// Проверяем, новый пользователь или существующий
const isNewUser = !user.passwordHash;
// Если это новый пользователь, требуем установить пароль
if (isNewUser) {
// Возвращаем флаг, что нужна установка пароля
return res.json({
requiresPassword: true,
token: token, // Возвращаем токен для следующего запроса
email: user.email
});
}
// Для существующих пользователей сразу авторизуем
// Очищаем токен
user.magicLinkToken = undefined;
user.magicLinkExpires = undefined;
user.emailVerified = true;
user.lastActiveAt = new Date();
await user.save();
// Генерируем JWT токен для web-сессии
const { signAccessToken, signRefreshToken, ACCESS_COOKIE, REFRESH_COOKIE } = require('../utils/tokens');
const accessToken = signAccessToken(user._id.toString());
const refreshToken = signRefreshToken(user._id.toString());
// Устанавливаем cookies
res.cookie(ACCESS_COOKIE, accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 5 * 60 * 1000 // 5 минут
});
res.cookie(REFRESH_COOKIE, refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 дней
});
console.log('[Auth] Magic-link верифицирован для:', user.email);
return respondWithUser(user, res);
} catch (error) {
console.error('[Auth] Ошибка magic-link/verify:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Установка пароля при регистрации через magic-link
router.post('/magic-link/set-password', async (req, res) => {
try {
const { token, password, username } = req.body;
if (!token || !password) {
return res.status(400).json({ error: 'Токен и пароль обязательны' });
}
// Валидация пароля
if (password.length < 8 || password.length > 24) {
return res.status(400).json({ error: 'Пароль должен быть от 8 до 24 символов' });
}
// Найти пользователя с этим токеном
const user = await User.findOne({
magicLinkToken: token,
magicLinkExpires: { $gt: new Date() }
});
if (!user) {
return res.status(401).json({ error: 'Ссылка недействительна или устарела' });
}
// Хешируем пароль
const bcrypt = require('bcrypt');
const passwordHash = await bcrypt.hash(password, 10);
// Обновляем пользователя
user.passwordHash = passwordHash;
user.emailVerified = true;
user.magicLinkToken = undefined;
user.magicLinkExpires = undefined;
user.lastActiveAt = new Date();
if (username && !user.username) {
user.username = username;
}
await user.save();
// Генерируем JWT токен
const { signAccessToken, signRefreshToken, ACCESS_COOKIE, REFRESH_COOKIE } = require('../utils/tokens');
const accessToken = signAccessToken(user._id.toString());
const refreshToken = signRefreshToken(user._id.toString());
// Устанавливаем cookies
res.cookie(ACCESS_COOKIE, accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 5 * 60 * 1000
});
res.cookie(REFRESH_COOKIE, refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000
});
console.log('[Auth] Пароль установлен для:', user.email);
return respondWithUser(user, res);
} catch (error) {
console.error('[Auth] Ошибка magic-link/set-password:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Email + password авторизация
router.post('/login-email', strictAuthLimiter, async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email и пароль обязательны' });
}
const emailLower = email.toLowerCase().trim();
// Найти пользователя с паролем
const user = await User.findOne({
email: emailLower,
passwordHash: { $exists: true }
}).select('+passwordHash');
if (!user) {
return res.status(401).json({ error: 'Неверный email или пароль' });
}
if (user.banned) {
return res.status(403).json({ error: 'Аккаунт заблокирован' });
}
// Проверить пароль
const bcrypt = require('bcrypt');
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
return res.status(401).json({ error: 'Неверный email или пароль' });
}
// Обновить активность
user.lastActiveAt = new Date();
await user.save();
// Генерируем JWT токены
const { signAccessToken, signRefreshToken, ACCESS_COOKIE, REFRESH_COOKIE } = require('../utils/tokens');
const accessToken = signAccessToken(user._id.toString());
const refreshToken = signRefreshToken(user._id.toString());
res.cookie(ACCESS_COOKIE, accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 5 * 60 * 1000
});
res.cookie(REFRESH_COOKIE, refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000
});
console.log('[Auth] Email авторизация для:', user.email);
return respondWithUser(user, res);
} catch (error) {
console.error('[Auth] Ошибка login-email:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Проверка сохраненной сессии по telegramId (для OAuth пользователей) // Проверка сохраненной сессии по telegramId (для OAuth пользователей)
router.post('/session', async (req, res) => { router.post('/session', async (req, res) => {
try { try {
@ -366,5 +642,83 @@ router.post('/session', async (req, res) => {
} }
}); });
// Привязка Telegram к существующему аккаунту
router.post('/link-telegram', authenticate, async (req, res) => {
try {
const { telegramId, username, firstName, lastName, photoUrl } = req.body;
if (!telegramId) {
return res.status(400).json({ error: 'telegramId обязателен' });
}
// Проверить, не привязан ли уже этот Telegram к другому аккаунту
const existingUser = await User.findOne({ telegramId });
if (existingUser && existingUser._id.toString() !== req.user._id.toString()) {
return res.status(400).json({ error: 'Этот Telegram уже привязан к другому аккаунту' });
}
// Привязываем Telegram
req.user.telegramId = telegramId;
if (username) req.user.username = username;
if (firstName) req.user.firstName = firstName;
if (lastName) req.user.lastName = lastName;
if (photoUrl) req.user.photoUrl = photoUrl;
await req.user.save();
console.log('[Auth] Telegram привязан к аккаунту:', req.user.email);
return respondWithUser(req.user, res);
} catch (error) {
console.error('[Auth] Ошибка привязки Telegram:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Привязка email и установка пароля (для Telegram пользователей)
router.post('/link-email', authenticate, async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email и пароль обязательны' });
}
// Валидация пароля
if (password.length < 8 || password.length > 24) {
return res.status(400).json({ error: 'Пароль должен быть от 8 до 24 символов' });
}
const emailLower = email.toLowerCase().trim();
// Проверить формат email
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailLower)) {
return res.status(400).json({ error: 'Неверный формат email' });
}
// Проверить, не занят ли email
const existingUser = await User.findOne({ email: emailLower });
if (existingUser && existingUser._id.toString() !== req.user._id.toString()) {
return res.status(400).json({ error: 'Этот email уже используется' });
}
// Хешируем пароль
const bcrypt = require('bcrypt');
const passwordHash = await bcrypt.hash(password, 10);
// Привязываем email и пароль
req.user.email = emailLower;
req.user.passwordHash = passwordHash;
req.user.emailVerified = true;
await req.user.save();
console.log('[Auth] Email привязан к аккаунту:', req.user.telegramId);
return respondWithUser(req.user, res);
} catch (error) {
console.error('[Auth] Ошибка привязки email:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
module.exports = router; module.exports = router;

View File

@ -50,15 +50,12 @@ function AppContent() {
initTelegramApp() initTelegramApp()
const tg = window.Telegram?.WebApp const tg = window.Telegram?.WebApp
const isTelegramMiniApp = !!(tg && tg.initData)
if (!tg) { console.log('[App] Режим:', isTelegramMiniApp ? 'Telegram Mini App' : 'Web (гостевой)')
throw new Error('Откройте приложение из Telegram.')
}
if (!tg.initData) {
throw new Error('Telegram не передал initData. Откройте приложение из официального клиента.')
}
if (isTelegramMiniApp) {
// Telegram Mini App - авторизация через initData
console.log('[App] Telegram WebApp найден, initData:', tg.initData ? 'есть' : 'нет') console.log('[App] Telegram WebApp найден, initData:', tg.initData ? 'есть' : 'нет')
tg.disableVerticalSwipes?.() tg.disableVerticalSwipes?.()
@ -80,16 +77,8 @@ function AppContent() {
startInitDataChecker() startInitDataChecker()
// Обработка start_param для открытия конкретного поста // Обработка start_param для открытия конкретного поста
// startParam может быть в разных местах в зависимости от способа открытия
const startParam = tg?.startParam || tg?.initDataUnsafe?.start_param || tg?.initDataUnsafe?.startParam const startParam = tg?.startParam || tg?.initDataUnsafe?.start_param || tg?.initDataUnsafe?.startParam
console.log('[App] Проверка start_param:', {
startParam: tg?.startParam,
initDataUnsafe_start_param: tg?.initDataUnsafe?.start_param,
initDataUnsafe_startParam: tg?.initDataUnsafe?.startParam,
final: startParam
})
if (!startParamProcessed.current && startParam?.startsWith('post_')) { if (!startParamProcessed.current && startParam?.startsWith('post_')) {
startParamProcessed.current = true startParamProcessed.current = true
const postId = startParam.replace('post_', '') const postId = startParam.replace('post_', '')
@ -98,6 +87,39 @@ function AppContent() {
navigate(`/feed?post=${postId}`, { replace: true }) navigate(`/feed?post=${postId}`, { replace: true })
}, 200) }, 200)
} }
} else {
// Web-версия - гостевой режим
console.log('[App] Запуск в гостевом режиме')
// Получить или создать guest_id
let guestId = localStorage.getItem('nakama_guest_id')
if (!guestId) {
guestId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
localStorage.setItem('nakama_guest_id', guestId)
console.log('[App] Создан guest_id:', guestId)
} else {
console.log('[App] Использую существующий guest_id:', guestId)
}
// Создаем объект гостевого пользователя
const guestUser = {
id: guestId,
username: 'Guest',
isGuest: true,
role: 'guest',
settings: {
whitelist: {
noNSFW: true,
noHomo: true
},
searchPreference: 'furry'
}
}
setUser(guestUser)
setError(null)
console.log('[App] Гостевой пользователь создан:', guestUser)
}
} catch (err) { } catch (err) {
console.error('[App] Ошибка инициализации:', err) console.error('[App] Ошибка инициализации:', err)
console.error('[App] Детали ошибки:', { console.error('[App] Детали ошибки:', {
@ -106,7 +128,7 @@ function AppContent() {
status: err.response?.status status: err.response?.status
}) })
setError(err?.response?.data?.error || err.message || 'Ошибка авторизации') setError(err?.response?.data?.error || err.message || 'Ошибка авторизации')
setUser(null) // Явно сбросить user при ошибке setUser(null)
} finally { } finally {
setLoading(false) setLoading(false)
console.log('[App] Инициализация завершена, loading:', false) console.log('[App] Инициализация завершена, loading:', false)

View File

@ -0,0 +1,259 @@
.auth-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 16px;
animation: fadeIn 0.2s ease-out;
}
.auth-modal-content {
background: var(--bg-secondary);
border-radius: 20px;
width: 100%;
max-width: 400px;
position: relative;
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.auth-modal-close {
position: absolute;
top: 16px;
right: 16px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-primary);
border: none;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
z-index: 1;
}
.auth-modal-close:hover {
background: var(--divider-color);
}
.auth-modal-close:active {
transform: scale(0.9);
}
.auth-modal-body {
padding: 32px 24px 24px;
}
.auth-modal-body h2 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 8px 0;
text-align: center;
}
.auth-modal-subtitle {
font-size: 15px;
color: var(--text-secondary);
text-align: center;
margin: 0 0 32px 0;
line-height: 1.4;
}
.auth-btn {
width: 100%;
padding: 14px 20px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.auth-btn:active {
transform: scale(0.98);
}
.auth-btn.telegram {
background: #0088cc;
color: white;
margin-bottom: 16px;
}
.auth-btn.telegram:hover {
background: #007ab8;
}
.auth-btn.email {
background: #007AFF;
color: white;
}
.auth-btn.email:hover {
background: #0066DD;
}
.auth-btn.email:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.auth-btn.secondary {
background: var(--bg-primary);
color: var(--text-primary);
}
.auth-btn.secondary:hover {
background: var(--divider-color);
}
.auth-divider {
display: flex;
align-items: center;
margin: 16px 0;
color: var(--text-secondary);
font-size: 14px;
}
.auth-divider::before,
.auth-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--divider-color);
}
.auth-divider span {
padding: 0 16px;
}
.auth-email-section {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
}
.auth-email-input {
width: 100%;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid var(--divider-color);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 16px;
outline: none;
transition: all 0.2s;
}
.auth-email-input:focus {
border-color: #007AFF;
background: var(--bg-secondary);
}
.auth-email-input::placeholder {
color: var(--text-secondary);
}
.auth-hint {
font-size: 13px;
color: var(--text-secondary);
text-align: center;
margin: 0;
line-height: 1.4;
}
.auth-success {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
}
.success-icon {
font-size: 64px;
margin-bottom: 8px;
}
.auth-success h3 {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.auth-success p {
font-size: 15px;
color: var(--text-primary);
margin: 0;
line-height: 1.4;
}
.auth-success strong {
color: #007AFF;
}
.spinner-small {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Темная тема */
[data-theme="dark"] .auth-modal-overlay {
background: rgba(0, 0, 0, 0.85);
}
[data-theme="dark"] .auth-modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
[data-theme="dark"] .auth-email-input {
background: var(--bg-primary);
border-color: var(--border-color);
}
[data-theme="dark"] .auth-email-input:focus {
border-color: #0A84FF;
background: var(--bg-secondary);
}

View File

@ -0,0 +1,114 @@
import { useState } from 'react'
import { X, Send } from 'lucide-react'
import { sendMagicLink } from '../utils/api'
import './AuthModal.css'
export default function AuthModal({ reason, onClose, onAuth }) {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState('')
const handleSendMagicLink = async () => {
if (!email.trim() || !email.includes('@')) {
setError('Введите корректный email')
return
}
try {
setLoading(true)
setError('')
await sendMagicLink(email.trim())
setSent(true)
setLoading(false)
} catch (error) {
console.error('Ошибка отправки:', error)
setError(error.response?.data?.error || 'Ошибка отправки. Попробуйте снова.')
setLoading(false)
}
}
const handleTelegramAuth = () => {
// Открываем Telegram бота для авторизации
window.open('https://t.me/YOUR_BOT_USERNAME', '_blank')
}
return (
<div className="auth-modal-overlay" onClick={onClose}>
<div className="auth-modal-content" onClick={e => e.stopPropagation()}>
<button className="auth-modal-close" onClick={onClose}>
<X size={24} />
</button>
<div className="auth-modal-body">
<h2>Войти в Nakama</h2>
<p className="auth-modal-subtitle">{reason || 'Чтобы публиковать посты и сохранять настройки'}</p>
{!sent ? (
<>
{/* Telegram авторизация */}
<button
className="auth-btn telegram"
onClick={handleTelegramAuth}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69a.2.2 0 00-.05-.18c-.06-.05-.14-.03-.21-.02-.09.02-1.49.95-4.22 2.79-.4.27-.76.41-1.08.4-.36-.01-1.04-.2-1.55-.37-.63-.2-1.12-.31-1.08-.66.02-.18.27-.36.74-.55 2.92-1.27 4.86-2.11 5.83-2.51 2.78-1.16 3.35-1.36 3.73-1.36.08 0 .27.02.39.12.1.08.13.19.14.27-.01.06.01.24 0 .38z"/>
</svg>
Войти через Telegram
</button>
<div className="auth-divider">
<span>или</span>
</div>
{/* Email авторизация */}
<div className="auth-email-section">
<input
type="email"
placeholder="email@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
onKeyPress={e => {
if (e.key === 'Enter') {
handleSendMagicLink()
}
}}
className="auth-email-input"
/>
<button
className="auth-btn email"
onClick={handleSendMagicLink}
disabled={loading || !email.trim()}
>
{loading ? (
<div className="spinner-small" />
) : (
<>
<Send size={18} />
Отправить ссылку
</>
)}
</button>
</div>
<p className="auth-hint">
Отправим ссылку для входа на email. Регистрация не требуется.
</p>
</>
) : (
<div className="auth-success">
<div className="success-icon"></div>
<h3>Проверьте почту</h3>
<p>Мы отправили ссылку для входа на <strong>{email}</strong></p>
<p className="auth-hint">Ссылка действительна 15 минут</p>
<button className="auth-btn secondary" onClick={onClose}>
Закрыть
</button>
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -62,3 +62,4 @@
} }

View File

@ -18,7 +18,7 @@ export default function Navigation() {
{({ isActive }) => ( {({ isActive }) => (
<> <>
<Layers size={24} strokeWidth={isActive ? 2.5 : 2} /> <Layers size={24} strokeWidth={isActive ? 2.5 : 2} />
<span>Media</span> <span>Медиа</span>
</> </>
)} )}
</NavLink> </NavLink>

View File

@ -0,0 +1,94 @@
.onboarding-post {
background: var(--bg-secondary);
border-radius: 16px;
padding: 24px;
margin-bottom: 16px;
display: flex;
gap: 16px;
box-shadow: 0 2px 8px var(--shadow-md);
animation: slideIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.onboarding-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.onboarding-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.onboarding-content h3 {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.onboarding-content p {
font-size: 15px;
color: var(--text-secondary);
line-height: 1.4;
margin: 0;
}
.onboarding-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.onboarding-btn {
padding: 10px 20px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.onboarding-btn.primary {
background: #007AFF;
color: white;
}
.onboarding-btn.primary:active {
transform: scale(0.95);
opacity: 0.9;
}
.onboarding-btn.secondary {
background: var(--bg-primary);
color: var(--text-secondary);
}
.onboarding-btn.secondary:active {
transform: scale(0.95);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Темная тема */
[data-theme="dark"] .onboarding-post {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}

View File

@ -0,0 +1,63 @@
import { Sparkles, Tag, Layers } from 'lucide-react'
import './OnboardingPost.css'
export default function OnboardingPost({ type, onAction, onDismiss }) {
const content = {
welcome: {
icon: Sparkles,
title: '👋 Добро пожаловать в Nakama!',
text: 'Здесь ты найдешь furry и anime контент от сообщества',
action: 'Настроить интересы',
color: '#007AFF'
},
tags: {
icon: Tag,
title: '🎯 Настрой свою ленту',
text: 'Выбери теги, чтобы видеть только интересный контент',
action: 'Выбрать теги',
color: '#FF8A33'
},
media: {
icon: Layers,
title: '🎨 Открой для себя больше',
text: 'Furry, Anime арт и Music — всё в одном месте',
action: 'Посмотреть',
color: '#9b59b6'
}
}
const current = content[type] || content.welcome
const Icon = current.icon
return (
<div className="onboarding-post">
<div className="onboarding-icon" style={{ backgroundColor: `${current.color}15` }}>
<Icon size={32} color={current.color} strokeWidth={2} />
</div>
<div className="onboarding-content">
<h3>{current.title}</h3>
<p>{current.text}</p>
<div className="onboarding-actions">
<button
className="onboarding-btn primary"
onClick={onAction}
style={{ backgroundColor: current.color }}
>
{current.action}
</button>
{onDismiss && (
<button
className="onboarding-btn secondary"
onClick={onDismiss}
>
Позже
</button>
)}
</div>
</div>
</div>
)
}

View File

@ -22,7 +22,7 @@ const TAG_NAMES = {
other: 'Other' other: 'Other'
} }
export default function PostCard({ post, currentUser, onUpdate }) { export default function PostCard({ post, currentUser, onUpdate, onAuthRequired }) {
const navigate = useNavigate() const navigate = useNavigate()
const [liked, setLiked] = useState(post.likes?.includes(currentUser.id) || false) const [liked, setLiked] = useState(post.likes?.includes(currentUser.id) || false)
const [likesCount, setLikesCount] = useState(post.likes?.length || 0) const [likesCount, setLikesCount] = useState(post.likes?.length || 0)
@ -32,6 +32,8 @@ export default function PostCard({ post, currentUser, onUpdate }) {
const [showMenu, setShowMenu] = useState(false) const [showMenu, setShowMenu] = useState(false)
const [menuButtonPosition, setMenuButtonPosition] = useState(null) const [menuButtonPosition, setMenuButtonPosition] = useState(null)
const isGuest = currentUser?.isGuest === true
// Проверка на существование автора // Проверка на существование автора
if (!post.author) { if (!post.author) {
console.warn('[PostCard] Post without author:', post._id) console.warn('[PostCard] Post without author:', post._id)
@ -51,6 +53,15 @@ export default function PostCard({ post, currentUser, onUpdate }) {
}) })
const handleLike = async () => { const handleLike = async () => {
// Проверка: гость не может лайкать
if (isGuest) {
if (onAuthRequired) {
onAuthRequired('Войдите, чтобы лайкать посты')
}
hapticFeedback('error')
return
}
try { try {
hapticFeedback('light') hapticFeedback('light')
const result = await likePost(post._id) const result = await likePost(post._id)

View File

@ -3,6 +3,8 @@ import { useSearchParams, useNavigate } from 'react-router-dom'
import { getPosts, getPost } from '../utils/api' import { getPosts, getPost } from '../utils/api'
import PostCard from '../components/PostCard' import PostCard from '../components/PostCard'
import CreatePostModal from '../components/CreatePostModal' import CreatePostModal from '../components/CreatePostModal'
import OnboardingPost from '../components/OnboardingPost'
import AuthModal from '../components/AuthModal'
import { Plus, Settings } from 'lucide-react' import { Plus, Settings } from 'lucide-react'
import { hapticFeedback } from '../utils/telegram' import { hapticFeedback } from '../utils/telegram'
import './Feed.css' import './Feed.css'
@ -13,10 +15,17 @@ export default function Feed({ user }) {
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false)
const [showAuthModal, setShowAuthModal] = useState(false) // Модалка авторизации
const [authReason, setAuthReason] = useState('') // Причина показа модалки
const [filter, setFilter] = useState('all') const [filter, setFilter] = useState('all')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true) const [hasMore, setHasMore] = useState(true)
const [highlightPostId, setHighlightPostId] = useState(null) const [highlightPostId, setHighlightPostId] = useState(null)
const [onboardingVisible, setOnboardingVisible] = useState({
welcome: true,
tags: true,
media: true
})
useEffect(() => { useEffect(() => {
// Проверить параметр post в URL // Проверить параметр post в URL
@ -129,6 +138,14 @@ export default function Feed({ user }) {
} }
const handleCreatePost = () => { const handleCreatePost = () => {
// Проверка: гость не может создавать посты
if (user?.isGuest) {
setAuthReason('Войдите, чтобы публиковать посты')
setShowAuthModal(true)
hapticFeedback('error')
return
}
hapticFeedback('light') hapticFeedback('light')
setShowCreateModal(true) setShowCreateModal(true)
} }
@ -138,6 +155,29 @@ export default function Feed({ user }) {
setShowCreateModal(false) setShowCreateModal(false)
} }
const handleOnboardingAction = (type) => {
hapticFeedback('light')
if (type === 'welcome' || type === 'tags') {
navigate('/profile')
} else if (type === 'media') {
navigate('/media')
}
setOnboardingVisible(prev => ({ ...prev, [type]: false }))
const dismissed = JSON.parse(localStorage.getItem('onboarding_dismissed') || '{}')
dismissed[type] = true
localStorage.setItem('onboarding_dismissed', JSON.stringify(dismissed))
}
const handleOnboardingDismiss = (type) => {
hapticFeedback('light')
setOnboardingVisible(prev => ({ ...prev, [type]: false }))
const dismissed = JSON.parse(localStorage.getItem('onboarding_dismissed') || '{}')
dismissed[type] = true
localStorage.setItem('onboarding_dismissed', JSON.stringify(dismissed))
}
const handleLoadMore = () => { const handleLoadMore = () => {
if (!loading && hasMore) { if (!loading && hasMore) {
loadPosts(page + 1) loadPosts(page + 1)
@ -187,6 +227,23 @@ export default function Feed({ user }) {
{/* Посты */} {/* Посты */}
<div className="feed-content"> <div className="feed-content">
{/* Onboarding посты для новых пользователей и гостей */}
{(user?.isGuest || !user?.onboarding_completed) && onboardingVisible.welcome && (
<OnboardingPost
type="welcome"
onAction={() => handleOnboardingAction('welcome')}
onDismiss={() => handleOnboardingDismiss('welcome')}
/>
)}
{(user?.isGuest || !user?.onboarding_completed) && onboardingVisible.tags && posts.length > 2 && (
<OnboardingPost
type="tags"
onAction={() => handleOnboardingAction('tags')}
onDismiss={() => handleOnboardingDismiss('tags')}
/>
)}
{loading && posts.length === 0 ? ( {loading && posts.length === 0 ? (
<div className="loading-state"> <div className="loading-state">
<div className="spinner" /> <div className="spinner" />
@ -262,6 +319,19 @@ export default function Feed({ user }) {
onPostCreated={handlePostCreated} onPostCreated={handlePostCreated}
/> />
)} )}
{/* Модальное окно авторизации */}
{showAuthModal && (
<AuthModal
reason={authReason}
onClose={() => setShowAuthModal(false)}
onAuth={() => {
setShowAuthModal(false)
// После авторизации перезагружаем страницу
window.location.reload()
}}
/>
)}
</div> </div>
) )
} }

View File

@ -18,8 +18,8 @@
} }
.media-header h1 { .media-header h1 {
font-size: 20px; font-size: 24px;
font-weight: 600; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin: 0; margin: 0;
text-align: left; text-align: left;

View File

@ -49,7 +49,7 @@ export default function Media({ user }) {
return ( return (
<div className="media-page"> <div className="media-page">
<div className="media-header"> <div className="media-header">
<h1>Media</h1> <h1>Медиа</h1>
</div> </div>
<div className="media-grid"> <div className="media-grid">

View File

@ -269,6 +269,9 @@
padding: 16px; padding: 16px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
border: none;
width: 100%;
text-align: left;
} }
.artist-item:active { .artist-item:active {
@ -303,6 +306,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
border: none;
width: 100%;
text-align: left;
} }
.album-item:active { .album-item:active {
@ -423,3 +429,92 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
/* Модальные окна */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease-out;
}
.modal-content {
background: var(--bg-secondary);
border-radius: 20px 20px 0 0;
width: 100%;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-header {
padding: 20px;
border-bottom: 1px solid var(--divider-color);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.modal-header h2 {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.close-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg-primary);
border: none;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.close-btn:hover {
background: var(--divider-color);
}
.close-btn:active {
transform: scale(0.9);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
[data-theme="dark"] .modal-overlay {
background: rgba(0, 0, 0, 0.85);
}
[data-theme="dark"] .modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}

View File

@ -15,13 +15,18 @@ export default function MediaMusic({ user }) {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [searchResults, setSearchResults] = useState({ tracks: [], artists: [], albums: [] }) const [searchResults, setSearchResults] = useState({ tracks: [], artists: [], albums: [] })
const [tracks, setTracks] = useState([]) const [tracks, setTracks] = useState([])
const [topTracks, setTopTracks] = useState([]) // Топ треки по прослушиваниям
const [favorites, setFavorites] = useState([]) const [favorites, setFavorites] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [showUpload, setShowUpload] = useState(false) const [showUpload, setShowUpload] = useState(false)
const [selectedArtist, setSelectedArtist] = useState(null) // Для модалки исполнителя
const [selectedAlbum, setSelectedAlbum] = useState(null) // Для модалки альбома/плейлиста
const [artistTracks, setArtistTracks] = useState([]) // Треки выбранного исполнителя
const [albumTracks, setAlbumTracks] = useState([]) // Треки выбранного альбома
useEffect(() => { useEffect(() => {
if (activeTab === 'browse') { if (activeTab === 'browse') {
loadTracks() loadTopTracks() // Загружаем топ треки вместо всех треков
} else if (activeTab === 'favorites') { } else if (activeTab === 'favorites') {
loadFavorites() loadFavorites()
} }
@ -31,7 +36,9 @@ export default function MediaMusic({ user }) {
try { try {
setLoading(true) setLoading(true)
const data = await getTracks({ limit: 50 }) const data = await getTracks({ limit: 50 })
setTracks(data.tracks || []) // Дедупликация треков по _id
const uniqueTracks = deduplicateTracks(data.tracks || [])
setTracks(uniqueTracks)
} catch (error) { } catch (error) {
console.error('Ошибка загрузки треков:', error) console.error('Ошибка загрузки треков:', error)
} finally { } finally {
@ -39,6 +46,44 @@ export default function MediaMusic({ user }) {
} }
} }
const loadTopTracks = async () => {
try {
setLoading(true)
const data = await getTracks({ limit: 30, sortBy: 'plays' }) // Сортируем по прослушиваниям
// Дедупликация треков
const uniqueTracks = deduplicateTracks(data.tracks || [])
setTopTracks(uniqueTracks)
} catch (error) {
console.error('Ошибка загрузки топ треков:', error)
} finally {
setLoading(false)
}
}
// Функция дедупликации треков по _id
const deduplicateTracks = (tracksList) => {
const seen = new Set()
return tracksList.filter(track => {
if (seen.has(track._id)) {
return false
}
seen.add(track._id)
return true
})
}
// Функция дедупликации плейлистов/альбомов по _id
const deduplicateAlbums = (albumsList) => {
const seen = new Set()
return albumsList.filter(album => {
if (seen.has(album._id)) {
return false
}
seen.add(album._id)
return true
})
}
const loadFavorites = async () => { const loadFavorites = async () => {
try { try {
setLoading(true) setLoading(true)
@ -58,7 +103,13 @@ export default function MediaMusic({ user }) {
setLoading(true) setLoading(true)
hapticFeedback('light') hapticFeedback('light')
const results = await searchMusic(query.trim()) const results = await searchMusic(query.trim())
setSearchResults(results)
// Дедупликация результатов поиска
setSearchResults({
tracks: deduplicateTracks(results.tracks || []),
artists: results.artists || [], // Исполнители уникальны по умолчанию
albums: deduplicateAlbums(results.albums || [])
})
if (results.tracks.length > 0 || results.artists.length > 0 || results.albums.length > 0) { if (results.tracks.length > 0 || results.artists.length > 0 || results.albums.length > 0) {
hapticFeedback('success') hapticFeedback('success')
@ -73,6 +124,42 @@ export default function MediaMusic({ user }) {
} }
} }
const handleArtistClick = async (artist) => {
try {
hapticFeedback('light')
setSelectedArtist(artist)
setLoading(true)
// Загружаем треки исполнителя
const data = await api.get(`/music/artists/${artist._id}/tracks`)
const uniqueTracks = deduplicateTracks(data.tracks || [])
setArtistTracks(uniqueTracks)
setLoading(false)
} catch (error) {
console.error('Ошибка загрузки треков исполнителя:', error)
setLoading(false)
hapticFeedback('error')
}
}
const handleAlbumClick = async (album) => {
try {
hapticFeedback('light')
setSelectedAlbum(album)
setLoading(true)
// Загружаем треки альбома
const data = await api.get(`/music/albums/${album._id}`)
const uniqueTracks = deduplicateTracks(data.tracks || [])
setAlbumTracks(uniqueTracks)
setLoading(false)
} catch (error) {
console.error('Ошибка загрузки треков альбома:', error)
setLoading(false)
hapticFeedback('error')
}
}
const handlePlayTrack = (track, trackList = []) => { const handlePlayTrack = (track, trackList = []) => {
hapticFeedback('light') hapticFeedback('light')
// Передаем трек и очередь в плеер // Передаем трек и очередь в плеер
@ -261,15 +348,19 @@ export default function MediaMusic({ user }) {
{loading ? ( {loading ? (
<div className="loading-state"> <div className="loading-state">
<div className="spinner" /> <div className="spinner" />
<p>Поиск...</p> <p>{query ? 'Поиск...' : 'Загрузка...'}</p>
</div> </div>
) : searchResults.tracks.length === 0 && !query ? ( ) : !query && topTracks.length > 0 ? (
<div className="empty-state"> // Показываем топ треки по прослушиваниям до ввода запроса
<Music size={48} color="var(--text-secondary)" /> <div className="search-results">
<p>Введите запрос для поиска</p> <div className="results-section">
<span>Ищите треки, исполнителей и альбомы</span> <h3>🔥 Топ треки</h3>
<div className="tracks-list">
{topTracks.map(track => renderTrackItem(track, topTracks))}
</div> </div>
) : ( </div>
</div>
) : query && (searchResults.tracks.length > 0 || searchResults.artists.length > 0 || searchResults.albums.length > 0) ? (
<div className="search-results"> <div className="search-results">
{searchResults.tracks.length > 0 && ( {searchResults.tracks.length > 0 && (
<div className="results-section"> <div className="results-section">
@ -285,23 +376,31 @@ export default function MediaMusic({ user }) {
<h3>Исполнители</h3> <h3>Исполнители</h3>
<div className="artists-list"> <div className="artists-list">
{searchResults.artists.map(artist => ( {searchResults.artists.map(artist => (
<div key={artist._id} className="artist-item"> <button
key={artist._id}
className="artist-item"
onClick={() => handleArtistClick(artist)}
>
<div className="artist-name">{artist.name}</div> <div className="artist-name">{artist.name}</div>
<div className="artist-stats"> <div className="artist-stats">
{artist.stats.tracks} треков {artist.stats.tracks} треков
</div> </div>
</div> </button>
))} ))}
</div> </div>
</div> </div>
)} )}
{searchResults.albums.length > 0 && ( {deduplicateAlbums(searchResults.albums || []).length > 0 && (
<div className="results-section"> <div className="results-section">
<h3>Альбомы</h3> <h3>Альбомы</h3>
<div className="albums-list"> <div className="albums-list">
{searchResults.albums.map(album => ( {deduplicateAlbums(searchResults.albums || []).map(album => (
<div key={album._id} className="album-item"> <button
key={album._id}
className="album-item"
onClick={() => handleAlbumClick(album)}
>
<div className="album-cover"> <div className="album-cover">
{album.coverImage ? ( {album.coverImage ? (
<img src={album.coverImage} alt={album.title} /> <img src={album.coverImage} alt={album.title} />
@ -313,7 +412,7 @@ export default function MediaMusic({ user }) {
<div className="album-title">{album.title}</div> <div className="album-title">{album.title}</div>
<div className="album-artist">{album.artist?.name}</div> <div className="album-artist">{album.artist?.name}</div>
</div> </div>
</div> </button>
))} ))}
</div> </div>
</div> </div>
@ -343,6 +442,75 @@ export default function MediaMusic({ user }) {
}} }}
/> />
)} )}
{/* Модалка исполнителя */}
{selectedArtist && (
<div className="modal-overlay" onClick={() => setSelectedArtist(null)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{selectedArtist.name}</h2>
<button className="close-btn" onClick={() => setSelectedArtist(null)}>
<X size={24} />
</button>
</div>
<div className="modal-body">
{loading ? (
<div className="loading-state">
<div className="spinner" />
<p>Загрузка треков...</p>
</div>
) : artistTracks.length === 0 ? (
<div className="empty-state">
<Music size={48} color="var(--text-secondary)" />
<p>Нет треков</p>
</div>
) : (
<div className="tracks-list">
{artistTracks.map(track => renderTrackItem(track, artistTracks))}
</div>
)}
</div>
</div>
</div>
)}
{/* Модалка альбома/плейлиста */}
{selectedAlbum && (
<div className="modal-overlay" onClick={() => setSelectedAlbum(null)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div>
<h2>{selectedAlbum.title}</h2>
{selectedAlbum.artist && (
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginTop: '4px' }}>
{selectedAlbum.artist.name}
</p>
)}
</div>
<button className="close-btn" onClick={() => setSelectedAlbum(null)}>
<X size={24} />
</button>
</div>
<div className="modal-body">
{loading ? (
<div className="loading-state">
<div className="spinner" />
<p>Загрузка треков...</p>
</div>
) : albumTracks.length === 0 ? (
<div className="empty-state">
<Music size={48} color="var(--text-secondary)" />
<p>Нет треков</p>
</div>
) : (
<div className="tracks-list">
{albumTracks.map(track => renderTrackItem(track, albumTracks))}
</div>
)}
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@ -14,7 +14,7 @@
} }
.profile-header h1 { .profile-header h1 {
font-size: 28px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
} }

View File

@ -47,6 +47,7 @@ const TAG_CATEGORIES = {
export default function Profile({ user, setUser }) { export default function Profile({ user, setUser }) {
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [showEditBio, setShowEditBio] = useState(false) const [showEditBio, setShowEditBio] = useState(false)
const [showLinkEmail, setShowLinkEmail] = useState(false) // Модалка привязки email
const [bio, setBio] = useState(user.bio || '') const [bio, setBio] = useState(user.bio || '')
const [settings, setSettings] = useState(normalizeSettings(user.settings)) const [settings, setSettings] = useState(normalizeSettings(user.settings))
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -63,6 +64,9 @@ export default function Profile({ user, setUser }) {
const tagInputRef = useRef(null) const tagInputRef = useRef(null)
const tagSuggestionsRef = useRef(null) const tagSuggestionsRef = useRef(null)
// Для привязки email
const [linkEmailData, setLinkEmailData] = useState({ email: '', password: '' })
const handleSaveBio = async () => { const handleSaveBio = async () => {
try { try {
setSaving(true) setSaving(true)
@ -428,6 +432,24 @@ export default function Profile({ user, setUser }) {
</div> </div>
)} )}
{/* Привязка email (только для Telegram пользователей без email) */}
{user.telegramId && !user.email && (
<div className="link-email-card card">
<div className="link-email-content">
<div className="link-email-icon">
<Info size={20} />
</div>
<div className="link-email-text">
<h3>Привяжите email</h3>
<p>Добавьте email и пароль, чтобы входить с любого устройства</p>
</div>
</div>
<button className="link-email-button" onClick={() => setShowLinkEmail(true)}>
Привязать email
</button>
</div>
)}
<div className="donation-card card"> <div className="donation-card card">
<div className="donation-content"> <div className="donation-content">
<div className="donation-icon"> <div className="donation-icon">

View File

@ -18,16 +18,19 @@ const api = axios.create({
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const initData = window.Telegram?.WebApp?.initData; const initData = window.Telegram?.WebApp?.initData;
const guestId = localStorage.getItem('nakama_guest_id');
console.log('[API] Request interceptor:', { console.log('[API] Request interceptor:', {
url: config.url, url: config.url,
method: config.method, method: config.method,
hasInitData: !!initData, hasInitData: !!initData,
hasGuestId: !!guestId,
initDataLength: initData?.length || 0, initDataLength: initData?.length || 0,
initDataPreview: initData?.substring(0, 50) + '...' initDataPreview: initData?.substring(0, 50) + '...'
}); });
if (initData) { if (initData) {
// Telegram Mini App - используем initData
config.headers = config.headers || {}; config.headers = config.headers || {};
if (!config.headers.Authorization) { if (!config.headers.Authorization) {
config.headers.Authorization = `tma ${initData}`; config.headers.Authorization = `tma ${initData}`;
@ -35,8 +38,13 @@ api.interceptors.request.use((config) => {
if (!config.headers['x-telegram-init-data']) { if (!config.headers['x-telegram-init-data']) {
config.headers['x-telegram-init-data'] = initData; config.headers['x-telegram-init-data'] = initData;
} }
} else if (guestId) {
// Web-версия (гостевой режим) - используем guest_id
config.headers = config.headers || {};
config.headers['x-guest-id'] = guestId;
console.log('[API] Using guest_id for request:', config.url);
} else { } else {
console.warn('[API] No initData available for request:', config.url); console.warn('[API] No auth data available for request:', config.url);
} }
return config; return config;

View File

@ -11,3 +11,4 @@ export function decodeHtmlEntities(str = '') {
} }