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', + ` +

Добро пожаловать в Nakama!

+

Нажмите на ссылку ниже, чтобы войти:

+

Войти в 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 ( +
+
e.stopPropagation()}> + + +
+

Войти в Nakama

+

{reason || 'Чтобы публиковать посты и сохранять настройки'}

+ + {!sent ? ( + <> + {/* Telegram авторизация */} + + +
+ или +
+ + {/* Email авторизация */} +
+ setEmail(e.target.value)} + onKeyPress={e => { + if (e.key === 'Enter') { + handleSendMagicLink() + } + }} + className="auth-email-input" + /> + +
+ +

+ Отправим ссылку для входа на email. Регистрация не требуется. +

+ + ) : ( +
+
✉️
+

Проверьте почту

+

Мы отправили ссылку для входа на {email}

+

Ссылка действительна 15 минут

+ +
+ )} +
+
+
+ ) +} + diff --git a/frontend/src/components/LadderButton.css b/frontend/src/components/LadderButton.css index 349f461..5e6d62b 100644 --- a/frontend/src/components/LadderButton.css +++ b/frontend/src/components/LadderButton.css @@ -62,3 +62,4 @@ } + diff --git a/frontend/src/components/Navigation.jsx b/frontend/src/components/Navigation.jsx index 2b0d91c..33dfad0 100644 --- a/frontend/src/components/Navigation.jsx +++ b/frontend/src/components/Navigation.jsx @@ -18,7 +18,7 @@ export default function Navigation() { {({ isActive }) => ( <> - Media + Медиа )} diff --git a/frontend/src/components/OnboardingPost.css b/frontend/src/components/OnboardingPost.css new file mode 100644 index 0000000..dd4dbe1 --- /dev/null +++ b/frontend/src/components/OnboardingPost.css @@ -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); +} + diff --git a/frontend/src/components/OnboardingPost.jsx b/frontend/src/components/OnboardingPost.jsx new file mode 100644 index 0000000..52df19c --- /dev/null +++ b/frontend/src/components/OnboardingPost.jsx @@ -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 ( +
+
+ +
+ +
+

{current.title}

+

{current.text}

+ +
+ + {onDismiss && ( + + )} +
+
+
+ ) +} + diff --git a/frontend/src/components/PostCard.jsx b/frontend/src/components/PostCard.jsx index 687adea..feaf443 100644 --- a/frontend/src/components/PostCard.jsx +++ b/frontend/src/components/PostCard.jsx @@ -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) diff --git a/frontend/src/pages/Feed.jsx b/frontend/src/pages/Feed.jsx index 1bc4e96..d8c0bf3 100644 --- a/frontend/src/pages/Feed.jsx +++ b/frontend/src/pages/Feed.jsx @@ -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 }) { {/* Посты */}
+ {/* Onboarding посты для новых пользователей и гостей */} + {(user?.isGuest || !user?.onboarding_completed) && onboardingVisible.welcome && ( + handleOnboardingAction('welcome')} + onDismiss={() => handleOnboardingDismiss('welcome')} + /> + )} + + {(user?.isGuest || !user?.onboarding_completed) && onboardingVisible.tags && posts.length > 2 && ( + handleOnboardingAction('tags')} + onDismiss={() => handleOnboardingDismiss('tags')} + /> + )} + {loading && posts.length === 0 ? (
@@ -262,6 +319,19 @@ export default function Feed({ user }) { onPostCreated={handlePostCreated} /> )} + + {/* Модальное окно авторизации */} + {showAuthModal && ( + setShowAuthModal(false)} + onAuth={() => { + setShowAuthModal(false) + // После авторизации перезагружаем страницу + window.location.reload() + }} + /> + )}
) } diff --git a/frontend/src/pages/Media.css b/frontend/src/pages/Media.css index e0a921a..c48119b 100644 --- a/frontend/src/pages/Media.css +++ b/frontend/src/pages/Media.css @@ -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; diff --git a/frontend/src/pages/Media.jsx b/frontend/src/pages/Media.jsx index edac762..99933dc 100644 --- a/frontend/src/pages/Media.jsx +++ b/frontend/src/pages/Media.jsx @@ -49,7 +49,7 @@ export default function Media({ user }) { return (
-

Media

+

Медиа

diff --git a/frontend/src/pages/MediaMusic.css b/frontend/src/pages/MediaMusic.css index 64bf1dc..bd5ac0d 100644 --- a/frontend/src/pages/MediaMusic.css +++ b/frontend/src/pages/MediaMusic.css @@ -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); +} + diff --git a/frontend/src/pages/MediaMusic.jsx b/frontend/src/pages/MediaMusic.jsx index dc488cd..9b43256 100644 --- a/frontend/src/pages/MediaMusic.jsx +++ b/frontend/src/pages/MediaMusic.jsx @@ -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 ? (
-

Поиск...

+

{query ? 'Поиск...' : 'Загрузка...'}

- ) : searchResults.tracks.length === 0 && !query ? ( -
- -

Введите запрос для поиска

- Ищите треки, исполнителей и альбомы + ) : !query && topTracks.length > 0 ? ( + // Показываем топ треки по прослушиваниям до ввода запроса +
+
+

🔥 Топ треки

+
+ {topTracks.map(track => renderTrackItem(track, topTracks))} +
+
- ) : ( + ) : query && (searchResults.tracks.length > 0 || searchResults.artists.length > 0 || searchResults.albums.length > 0) ? (
{searchResults.tracks.length > 0 && (
@@ -285,23 +376,31 @@ export default function MediaMusic({ user }) {

Исполнители

{searchResults.artists.map(artist => ( -
+
+ ))}
)} - {searchResults.albums.length > 0 && ( + {deduplicateAlbums(searchResults.albums || []).length > 0 && (

Альбомы

- {searchResults.albums.map(album => ( -
+ {deduplicateAlbums(searchResults.albums || []).map(album => ( +
+ ))}
@@ -343,6 +442,75 @@ export default function MediaMusic({ user }) { }} /> )} + + {/* Модалка исполнителя */} + {selectedArtist && ( +
setSelectedArtist(null)}> +
e.stopPropagation()}> +
+

{selectedArtist.name}

+ +
+
+ {loading ? ( +
+
+

Загрузка треков...

+
+ ) : artistTracks.length === 0 ? ( +
+ +

Нет треков

+
+ ) : ( +
+ {artistTracks.map(track => renderTrackItem(track, artistTracks))} +
+ )} +
+
+
+ )} + + {/* Модалка альбома/плейлиста */} + {selectedAlbum && ( +
setSelectedAlbum(null)}> +
e.stopPropagation()}> +
+
+

{selectedAlbum.title}

+ {selectedAlbum.artist && ( +

+ {selectedAlbum.artist.name} +

+ )} +
+ +
+
+ {loading ? ( +
+
+

Загрузка треков...

+
+ ) : albumTracks.length === 0 ? ( +
+ +

Нет треков

+
+ ) : ( +
+ {albumTracks.map(track => renderTrackItem(track, albumTracks))} +
+ )} +
+
+
+ )}
) } diff --git a/frontend/src/pages/Profile.css b/frontend/src/pages/Profile.css index e920362..ffffdb2 100644 --- a/frontend/src/pages/Profile.css +++ b/frontend/src/pages/Profile.css @@ -14,7 +14,7 @@ } .profile-header h1 { - font-size: 28px; + font-size: 24px; font-weight: 700; color: var(--text-primary); } diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx index ea5fab1..a472341 100644 --- a/frontend/src/pages/Profile.jsx +++ b/frontend/src/pages/Profile.jsx @@ -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) @@ -62,6 +63,9 @@ export default function Profile({ user, setUser }) { const [showTagSuggestions, setShowTagSuggestions] = useState(false) const tagInputRef = useRef(null) const tagSuggestionsRef = useRef(null) + + // Для привязки email + const [linkEmailData, setLinkEmailData] = useState({ email: '', password: '' }) const handleSaveBio = async () => { try { @@ -428,6 +432,24 @@ export default function Profile({ user, setUser }) {
)} + {/* Привязка email (только для Telegram пользователей без email) */} + {user.telegramId && !user.email && ( +
+
+
+ +
+
+

Привяжите email

+

Добавьте email и пароль, чтобы входить с любого устройства

+
+
+ +
+ )} +
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 2df651e..ca92831 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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; diff --git a/frontend/src/utils/htmlEntities.js b/frontend/src/utils/htmlEntities.js index 91b3309..843bff4 100644 --- a/frontend/src/utils/htmlEntities.js +++ b/frontend/src/utils/htmlEntities.js @@ -11,3 +11,4 @@ export function decodeHtmlEntities(str = '') { } +