Update files
This commit is contained in:
parent
a2aedc95af
commit
0af5b0e638
|
|
@ -49,3 +49,4 @@ TagSchema.index({ usageCount: -1 });
|
||||||
module.exports = mongoose.model('Tag', TagSchema);
|
module.exports = mongoose.model('Tag', TagSchema);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -62,3 +62,4 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,4 @@ export function decodeHtmlEntities(str = '') {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue