From 0af5b0e63822f94fab4a5379a3c5812b07a247ae Mon Sep 17 00:00:00 2001 From: glpshchn <464976@niuitmo.ru> Date: Thu, 1 Jan 2026 20:57:05 +0300 Subject: [PATCH] Update files --- backend/models/Tag.js | 1 + backend/models/User.js | 5 +- backend/routes/auth.js | 354 +++++++++++++++++++++ frontend/src/App.jsx | 102 +++--- frontend/src/components/AuthModal.css | 259 +++++++++++++++ frontend/src/components/AuthModal.jsx | 114 +++++++ frontend/src/components/LadderButton.css | 1 + frontend/src/components/Navigation.jsx | 2 +- frontend/src/components/OnboardingPost.css | 94 ++++++ frontend/src/components/OnboardingPost.jsx | 63 ++++ frontend/src/components/PostCard.jsx | 13 +- frontend/src/pages/Feed.jsx | 70 ++++ frontend/src/pages/Media.css | 4 +- frontend/src/pages/Media.jsx | 2 +- frontend/src/pages/MediaMusic.css | 95 ++++++ frontend/src/pages/MediaMusic.jsx | 200 +++++++++++- frontend/src/pages/Profile.css | 2 +- frontend/src/pages/Profile.jsx | 22 ++ frontend/src/utils/api.js | 10 +- frontend/src/utils/htmlEntities.js | 1 + 20 files changed, 1350 insertions(+), 64 deletions(-) create mode 100644 frontend/src/components/AuthModal.css create mode 100644 frontend/src/components/AuthModal.jsx create mode 100644 frontend/src/components/OnboardingPost.css create mode 100644 frontend/src/components/OnboardingPost.jsx diff --git a/backend/models/Tag.js b/backend/models/Tag.js index c30dd0c..d5bb8cc 100644 --- a/backend/models/Tag.js +++ b/backend/models/Tag.js @@ -49,3 +49,4 @@ TagSchema.index({ usageCount: -1 }); module.exports = mongoose.model('Tag', TagSchema); + diff --git a/backend/models/User.js b/backend/models/User.js index e78b256..1d09409 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -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 diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 6e8e38a..5e2655b 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -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', + ` +
Нажмите на ссылку ниже, чтобы войти:
+ +Ссылка действительна 15 минут.
+Если вы не запрашивали эту ссылку, просто проигнорируйте это письмо.
+ `, + `Войдите в 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; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d48d08d..035d0c2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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) + + if (!userData) { + throw new Error('Не удалось получить данные пользователя. Попробуйте перезагрузить страницу.') + } - console.log('[App] Вызов verifyAuth...') - const userData = await verifyAuth() - console.log('[App] verifyAuth вернул:', userData ? 'данные пользователя' : 'null/undefined', userData) - - 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 + + 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] Запуск в гостевом режиме') + + // Получить или создать 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) + } - // Обработка start_param для открытия конкретного поста - // 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_')) { - 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) diff --git a/frontend/src/components/AuthModal.css b/frontend/src/components/AuthModal.css new file mode 100644 index 0000000..1bf7bf9 --- /dev/null +++ b/frontend/src/components/AuthModal.css @@ -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); +} + diff --git a/frontend/src/components/AuthModal.jsx b/frontend/src/components/AuthModal.jsx new file mode 100644 index 0000000..614203f --- /dev/null +++ b/frontend/src/components/AuthModal.jsx @@ -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 ( +{reason || 'Чтобы публиковать посты и сохранять настройки'}
+ + {!sent ? ( + <> + {/* Telegram авторизация */} + + ++ Отправим ссылку для входа на email. Регистрация не требуется. +
+ > + ) : ( +Мы отправили ссылку для входа на {email}
+Ссылка действительна 15 минут
+ +{current.text}
+ +Поиск...
+{query ? 'Поиск...' : 'Загрузка...'}
Введите запрос для поиска
- Ищите треки, исполнителей и альбомы + ) : !query && topTracks.length > 0 ? ( + // Показываем топ треки по прослушиваниям до ввода запроса +Загрузка треков...
+Нет треков
++ {selectedAlbum.artist.name} +
+ )} +Загрузка треков...
+Нет треков
+Добавьте email и пароль, чтобы входить с любого устройства
+