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);
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ const mongoose = require('mongoose');
|
|||
const UserSchema = new mongoose.Schema({
|
||||
telegramId: {
|
||||
type: String,
|
||||
required: true,
|
||||
sparse: true, // Разрешаем null для web-пользователей
|
||||
unique: true
|
||||
},
|
||||
username: {
|
||||
|
|
@ -128,6 +128,9 @@ const UserSchema = new mongoose.Schema({
|
|||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// Magic-link токены для авторизации
|
||||
magicLinkToken: String,
|
||||
magicLinkExpires: Date,
|
||||
createdAt: {
|
||||
type: Date,
|
||||
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 пользователей)
|
||||
router.post('/session', async (req, res) => {
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -50,53 +50,75 @@ function AppContent() {
|
|||
initTelegramApp()
|
||||
|
||||
const tg = window.Telegram?.WebApp
|
||||
const isTelegramMiniApp = !!(tg && tg.initData)
|
||||
|
||||
if (!tg) {
|
||||
throw new Error('Откройте приложение из Telegram.')
|
||||
}
|
||||
console.log('[App] Режим:', isTelegramMiniApp ? 'Telegram Mini App' : 'Web (гостевой)')
|
||||
|
||||
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.expand?.()
|
||||
|
||||
tg.disableVerticalSwipes?.()
|
||||
tg.expand?.()
|
||||
console.log('[App] Вызов verifyAuth...')
|
||||
const userData = await verifyAuth()
|
||||
console.log('[App] verifyAuth вернул:', userData ? 'данные пользователя' : 'null/undefined', userData)
|
||||
|
||||
console.log('[App] Вызов verifyAuth...')
|
||||
const userData = await verifyAuth()
|
||||
console.log('[App] verifyAuth вернул:', userData ? 'данные пользователя' : 'null/undefined', userData)
|
||||
if (!userData) {
|
||||
throw new Error('Не удалось получить данные пользователя. Попробуйте перезагрузить страницу.')
|
||||
}
|
||||
|
||||
if (!userData) {
|
||||
throw new Error('Не удалось получить данные пользователя. Попробуйте перезагрузить страницу.')
|
||||
}
|
||||
setUser(userData)
|
||||
setError(null)
|
||||
console.log('[App] Пользователь установлен, ID:', userData.id || userData._id)
|
||||
|
||||
setUser(userData)
|
||||
setError(null)
|
||||
console.log('[App] Пользователь установлен, ID:', userData.id || userData._id)
|
||||
// Запустить проверку initData только после успешной загрузки
|
||||
startInitDataChecker()
|
||||
|
||||
// Запустить проверку initData только после успешной загрузки
|
||||
startInitDataChecker()
|
||||
// Обработка start_param для открытия конкретного поста
|
||||
const startParam = tg?.startParam || tg?.initDataUnsafe?.start_param || tg?.initDataUnsafe?.startParam
|
||||
|
||||
// Обработка start_param для открытия конкретного поста
|
||||
// startParam может быть в разных местах в зависимости от способа открытия
|
||||
const startParam = tg?.startParam || tg?.initDataUnsafe?.start_param || tg?.initDataUnsafe?.startParam
|
||||
if (!startParamProcessed.current && startParam?.startsWith('post_')) {
|
||||
startParamProcessed.current = true
|
||||
const postId = startParam.replace('post_', '')
|
||||
console.log('[App] Открытие поста из start_param:', postId)
|
||||
setTimeout(() => {
|
||||
navigate(`/feed?post=${postId}`, { replace: true })
|
||||
}, 200)
|
||||
}
|
||||
} else {
|
||||
// Web-версия - гостевой режим
|
||||
console.log('[App] Запуск в гостевом режиме')
|
||||
|
||||
console.log('[App] Проверка start_param:', {
|
||||
startParam: tg?.startParam,
|
||||
initDataUnsafe_start_param: tg?.initDataUnsafe?.start_param,
|
||||
initDataUnsafe_startParam: tg?.initDataUnsafe?.startParam,
|
||||
final: startParam
|
||||
})
|
||||
// Получить или создать 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)
|
||||
}
|
||||
|
||||
if (!startParamProcessed.current && startParam?.startsWith('post_')) {
|
||||
startParamProcessed.current = true
|
||||
const postId = startParam.replace('post_', '')
|
||||
console.log('[App] Открытие поста из start_param:', postId)
|
||||
setTimeout(() => {
|
||||
navigate(`/feed?post=${postId}`, { replace: true })
|
||||
}, 200)
|
||||
// Создаем объект гостевого пользователя
|
||||
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) {
|
||||
console.error('[App] Ошибка инициализации:', err)
|
||||
|
|
@ -106,7 +128,7 @@ function AppContent() {
|
|||
status: err.response?.status
|
||||
})
|
||||
setError(err?.response?.data?.error || err.message || 'Ошибка авторизации')
|
||||
setUser(null) // Явно сбросить user при ошибке
|
||||
setUser(null)
|
||||
} finally {
|
||||
setLoading(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 }) => (
|
||||
<>
|
||||
<Layers size={24} strokeWidth={isActive ? 2.5 : 2} />
|
||||
<span>Media</span>
|
||||
<span>Медиа</span>
|
||||
</>
|
||||
)}
|
||||
</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'
|
||||
}
|
||||
|
||||
export default function PostCard({ post, currentUser, onUpdate }) {
|
||||
export default function PostCard({ post, currentUser, onUpdate, onAuthRequired }) {
|
||||
const navigate = useNavigate()
|
||||
const [liked, setLiked] = useState(post.likes?.includes(currentUser.id) || false)
|
||||
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 [menuButtonPosition, setMenuButtonPosition] = useState(null)
|
||||
|
||||
const isGuest = currentUser?.isGuest === true
|
||||
|
||||
// Проверка на существование автора
|
||||
if (!post.author) {
|
||||
console.warn('[PostCard] Post without author:', post._id)
|
||||
|
|
@ -51,6 +53,15 @@ export default function PostCard({ post, currentUser, onUpdate }) {
|
|||
})
|
||||
|
||||
const handleLike = async () => {
|
||||
// Проверка: гость не может лайкать
|
||||
if (isGuest) {
|
||||
if (onAuthRequired) {
|
||||
onAuthRequired('Войдите, чтобы лайкать посты')
|
||||
}
|
||||
hapticFeedback('error')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
hapticFeedback('light')
|
||||
const result = await likePost(post._id)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { useSearchParams, useNavigate } from 'react-router-dom'
|
|||
import { getPosts, getPost } from '../utils/api'
|
||||
import PostCard from '../components/PostCard'
|
||||
import CreatePostModal from '../components/CreatePostModal'
|
||||
import OnboardingPost from '../components/OnboardingPost'
|
||||
import AuthModal from '../components/AuthModal'
|
||||
import { Plus, Settings } from 'lucide-react'
|
||||
import { hapticFeedback } from '../utils/telegram'
|
||||
import './Feed.css'
|
||||
|
|
@ -13,10 +15,17 @@ export default function Feed({ user }) {
|
|||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showAuthModal, setShowAuthModal] = useState(false) // Модалка авторизации
|
||||
const [authReason, setAuthReason] = useState('') // Причина показа модалки
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [highlightPostId, setHighlightPostId] = useState(null)
|
||||
const [onboardingVisible, setOnboardingVisible] = useState({
|
||||
welcome: true,
|
||||
tags: true,
|
||||
media: true
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Проверить параметр post в URL
|
||||
|
|
@ -129,6 +138,14 @@ export default function Feed({ user }) {
|
|||
}
|
||||
|
||||
const handleCreatePost = () => {
|
||||
// Проверка: гость не может создавать посты
|
||||
if (user?.isGuest) {
|
||||
setAuthReason('Войдите, чтобы публиковать посты')
|
||||
setShowAuthModal(true)
|
||||
hapticFeedback('error')
|
||||
return
|
||||
}
|
||||
|
||||
hapticFeedback('light')
|
||||
setShowCreateModal(true)
|
||||
}
|
||||
|
|
@ -138,6 +155,29 @@ export default function Feed({ user }) {
|
|||
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 = () => {
|
||||
if (!loading && hasMore) {
|
||||
loadPosts(page + 1)
|
||||
|
|
@ -187,6 +227,23 @@ export default function Feed({ user }) {
|
|||
|
||||
{/* Посты */}
|
||||
<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 ? (
|
||||
<div className="loading-state">
|
||||
<div className="spinner" />
|
||||
|
|
@ -262,6 +319,19 @@ export default function Feed({ user }) {
|
|||
onPostCreated={handlePostCreated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Модальное окно авторизации */}
|
||||
{showAuthModal && (
|
||||
<AuthModal
|
||||
reason={authReason}
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
onAuth={() => {
|
||||
setShowAuthModal(false)
|
||||
// После авторизации перезагружаем страницу
|
||||
window.location.reload()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@
|
|||
}
|
||||
|
||||
.media-header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export default function Media({ user }) {
|
|||
return (
|
||||
<div className="media-page">
|
||||
<div className="media-header">
|
||||
<h1>Media</h1>
|
||||
<h1>Медиа</h1>
|
||||
</div>
|
||||
|
||||
<div className="media-grid">
|
||||
|
|
|
|||
|
|
@ -269,6 +269,9 @@
|
|||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.artist-item:active {
|
||||
|
|
@ -303,6 +306,9 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.album-item:active {
|
||||
|
|
@ -423,3 +429,92 @@
|
|||
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 [searchResults, setSearchResults] = useState({ tracks: [], artists: [], albums: [] })
|
||||
const [tracks, setTracks] = useState([])
|
||||
const [topTracks, setTopTracks] = useState([]) // Топ треки по прослушиваниям
|
||||
const [favorites, setFavorites] = useState([])
|
||||
const [loading, setLoading] = 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(() => {
|
||||
if (activeTab === 'browse') {
|
||||
loadTracks()
|
||||
loadTopTracks() // Загружаем топ треки вместо всех треков
|
||||
} else if (activeTab === 'favorites') {
|
||||
loadFavorites()
|
||||
}
|
||||
|
|
@ -31,7 +36,9 @@ export default function MediaMusic({ user }) {
|
|||
try {
|
||||
setLoading(true)
|
||||
const data = await getTracks({ limit: 50 })
|
||||
setTracks(data.tracks || [])
|
||||
// Дедупликация треков по _id
|
||||
const uniqueTracks = deduplicateTracks(data.tracks || [])
|
||||
setTracks(uniqueTracks)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки треков:', error)
|
||||
} 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 () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
|
@ -58,7 +103,13 @@ export default function MediaMusic({ user }) {
|
|||
setLoading(true)
|
||||
hapticFeedback('light')
|
||||
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) {
|
||||
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 = []) => {
|
||||
hapticFeedback('light')
|
||||
// Передаем трек и очередь в плеер
|
||||
|
|
@ -261,15 +348,19 @@ export default function MediaMusic({ user }) {
|
|||
{loading ? (
|
||||
<div className="loading-state">
|
||||
<div className="spinner" />
|
||||
<p>Поиск...</p>
|
||||
<p>{query ? 'Поиск...' : 'Загрузка...'}</p>
|
||||
</div>
|
||||
) : searchResults.tracks.length === 0 && !query ? (
|
||||
<div className="empty-state">
|
||||
<Music size={48} color="var(--text-secondary)" />
|
||||
<p>Введите запрос для поиска</p>
|
||||
<span>Ищите треки, исполнителей и альбомы</span>
|
||||
) : !query && topTracks.length > 0 ? (
|
||||
// Показываем топ треки по прослушиваниям до ввода запроса
|
||||
<div className="search-results">
|
||||
<div className="results-section">
|
||||
<h3>🔥 Топ треки</h3>
|
||||
<div className="tracks-list">
|
||||
{topTracks.map(track => renderTrackItem(track, topTracks))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : query && (searchResults.tracks.length > 0 || searchResults.artists.length > 0 || searchResults.albums.length > 0) ? (
|
||||
<div className="search-results">
|
||||
{searchResults.tracks.length > 0 && (
|
||||
<div className="results-section">
|
||||
|
|
@ -285,23 +376,31 @@ export default function MediaMusic({ user }) {
|
|||
<h3>Исполнители</h3>
|
||||
<div className="artists-list">
|
||||
{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-stats">
|
||||
{artist.stats.tracks} треков
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResults.albums.length > 0 && (
|
||||
{deduplicateAlbums(searchResults.albums || []).length > 0 && (
|
||||
<div className="results-section">
|
||||
<h3>Альбомы</h3>
|
||||
<div className="albums-list">
|
||||
{searchResults.albums.map(album => (
|
||||
<div key={album._id} className="album-item">
|
||||
{deduplicateAlbums(searchResults.albums || []).map(album => (
|
||||
<button
|
||||
key={album._id}
|
||||
className="album-item"
|
||||
onClick={() => handleAlbumClick(album)}
|
||||
>
|
||||
<div className="album-cover">
|
||||
{album.coverImage ? (
|
||||
<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-artist">{album.artist?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
}
|
||||
|
||||
.profile-header h1 {
|
||||
font-size: 28px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ const TAG_CATEGORIES = {
|
|||
export default function Profile({ user, setUser }) {
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showEditBio, setShowEditBio] = useState(false)
|
||||
const [showLinkEmail, setShowLinkEmail] = useState(false) // Модалка привязки email
|
||||
const [bio, setBio] = useState(user.bio || '')
|
||||
const [settings, setSettings] = useState(normalizeSettings(user.settings))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
|
@ -63,6 +64,9 @@ export default function Profile({ user, setUser }) {
|
|||
const tagInputRef = useRef(null)
|
||||
const tagSuggestionsRef = useRef(null)
|
||||
|
||||
// Для привязки email
|
||||
const [linkEmailData, setLinkEmailData] = useState({ email: '', password: '' })
|
||||
|
||||
const handleSaveBio = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
|
|
@ -428,6 +432,24 @@ export default function Profile({ user, setUser }) {
|
|||
</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-content">
|
||||
<div className="donation-icon">
|
||||
|
|
|
|||
|
|
@ -18,16 +18,19 @@ const api = axios.create({
|
|||
|
||||
api.interceptors.request.use((config) => {
|
||||
const initData = window.Telegram?.WebApp?.initData;
|
||||
const guestId = localStorage.getItem('nakama_guest_id');
|
||||
|
||||
console.log('[API] Request interceptor:', {
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
hasInitData: !!initData,
|
||||
hasGuestId: !!guestId,
|
||||
initDataLength: initData?.length || 0,
|
||||
initDataPreview: initData?.substring(0, 50) + '...'
|
||||
});
|
||||
|
||||
if (initData) {
|
||||
// Telegram Mini App - используем initData
|
||||
config.headers = config.headers || {};
|
||||
if (!config.headers.Authorization) {
|
||||
config.headers.Authorization = `tma ${initData}`;
|
||||
|
|
@ -35,8 +38,13 @@ api.interceptors.request.use((config) => {
|
|||
if (!config.headers['x-telegram-init-data']) {
|
||||
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 {
|
||||
console.warn('[API] No initData available for request:', config.url);
|
||||
console.warn('[API] No auth data available for request:', config.url);
|
||||
}
|
||||
|
||||
return config;
|
||||
|
|
|
|||
|
|
@ -11,3 +11,4 @@ export function decodeHtmlEntities(str = '') {
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue