Update files
This commit is contained in:
parent
200146fe4e
commit
85bc6a1ad9
|
|
@ -0,0 +1,101 @@
|
||||||
|
# 🌐 Настройка для разных доменов (Frontend и API)
|
||||||
|
|
||||||
|
## Ситуация
|
||||||
|
- **Frontend**: `nakama.glpshchn.ru` (старый домен)
|
||||||
|
- **API**: `nkm.guru` (новый домен)
|
||||||
|
|
||||||
|
## ✅ Что нужно сделать
|
||||||
|
|
||||||
|
### 1. Обновить CORS на backend
|
||||||
|
|
||||||
|
В файле `.env` на сервере добавьте оба домена:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Разрешить запросы с обоих доменов
|
||||||
|
CORS_ORIGIN=https://nakama.glpshchn.ru,https://nkm.guru
|
||||||
|
```
|
||||||
|
|
||||||
|
Или если хотите разрешить все домены (менее безопасно, но проще):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Обновить Content Security Policy
|
||||||
|
|
||||||
|
В `backend/middleware/security.js` нужно разрешить подключения к новому API домену:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
connectSrc: ["'self'", "https://api.telegram.org", "https://e621.net", "https://gelbooru.com", "https://nkm.guru"],
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Пересобрать frontend с новым API URL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/nakama/frontend
|
||||||
|
|
||||||
|
# Установить переменную окружения для сборки
|
||||||
|
export VITE_API_URL=https://nkm.guru/api
|
||||||
|
|
||||||
|
# Пересобрать frontend
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Перезапустить nginx
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Перезапустить backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Если используете PM2:
|
||||||
|
pm2 restart nakama-backend
|
||||||
|
|
||||||
|
# Или если используете Docker:
|
||||||
|
docker-compose restart backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Проверка
|
||||||
|
|
||||||
|
После настройки проверьте:
|
||||||
|
|
||||||
|
1. **CORS работает**:
|
||||||
|
```bash
|
||||||
|
curl -H "Origin: https://nakama.glpshchn.ru" \
|
||||||
|
-H "Access-Control-Request-Method: GET" \
|
||||||
|
-H "Access-Control-Request-Headers: Content-Type" \
|
||||||
|
-X OPTIONS \
|
||||||
|
https://nkm.guru/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **В консоли браузера** (F12 → Network):
|
||||||
|
- Запросы должны идти на: `https://nkm.guru/api/...`
|
||||||
|
- Headers должны содержать: `Access-Control-Allow-Origin: https://nakama.glpshchn.ru`
|
||||||
|
|
||||||
|
## ⚠️ Важно
|
||||||
|
|
||||||
|
1. **Cookies**: Если используете cookies для авторизации, убедитесь, что:
|
||||||
|
- `withCredentials: true` в axios (уже настроено)
|
||||||
|
- `credentials: true` в CORS (уже настроено)
|
||||||
|
- Cookies должны быть с правильным `SameSite` и `Secure` флагами
|
||||||
|
|
||||||
|
2. **SSL сертификаты**: Оба домена должны иметь валидные SSL сертификаты
|
||||||
|
|
||||||
|
3. **Telegram Mini App**: Если используете Telegram Mini App, убедитесь, что домен frontend добавлен в настройки бота
|
||||||
|
|
||||||
|
## 🔄 Альтернативное решение: Проксирование через nginx
|
||||||
|
|
||||||
|
Если не хотите настраивать CORS, можно настроить проксирование на старом домене:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# В nginx конфигурации для nakama.glpshchn.ru
|
||||||
|
location /api {
|
||||||
|
proxy_pass https://nkm.guru/api;
|
||||||
|
proxy_set_header Host nkm.guru;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Тогда frontend будет использовать относительный путь `/api`, а nginx будет проксировать запросы на новый домен.
|
||||||
|
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
# 📧 Настройка отправки email
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
Ошибка: `Missing credentials in config` при отправке magic-link email.
|
||||||
|
|
||||||
|
## ✅ Решение
|
||||||
|
|
||||||
|
### Вариант 1: Настроить AWS SES (если используете AWS)
|
||||||
|
|
||||||
|
В файле `.env` на сервере добавьте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EMAIL_PROVIDER=aws
|
||||||
|
AWS_SES_ACCESS_KEY_ID=your_access_key_id
|
||||||
|
AWS_SES_SECRET_ACCESS_KEY=your_secret_access_key
|
||||||
|
AWS_SES_REGION=us-east-1
|
||||||
|
EMAIL_FROM=noreply@nakama.guru
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:**
|
||||||
|
- `EMAIL_FROM` должен быть верифицированным email в AWS SES
|
||||||
|
- Для production нужно выйти из sandbox режима AWS SES
|
||||||
|
|
||||||
|
### Вариант 2: Использовать Yandex SMTP (рекомендуется для начала)
|
||||||
|
|
||||||
|
В файле `.env` на сервере:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EMAIL_PROVIDER=yandex
|
||||||
|
YANDEX_SMTP_HOST=smtp.yandex.ru
|
||||||
|
YANDEX_SMTP_PORT=465
|
||||||
|
YANDEX_SMTP_SECURE=true
|
||||||
|
YANDEX_SMTP_USER=your-email@yandex.ru
|
||||||
|
YANDEX_SMTP_PASSWORD=your_app_password
|
||||||
|
EMAIL_FROM=your-email@yandex.ru
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно для Yandex:**
|
||||||
|
- Используйте **пароль приложения**, а не основной пароль аккаунта
|
||||||
|
- Пароль приложения создается в настройках безопасности Yandex
|
||||||
|
- Порт 465 для SSL, порт 587 для STARTTLS
|
||||||
|
|
||||||
|
### Вариант 3: Использовать любой SMTP сервер
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EMAIL_PROVIDER=smtp
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=your-email@example.com
|
||||||
|
SMTP_PASSWORD=your_password
|
||||||
|
EMAIL_FROM=your-email@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 После настройки
|
||||||
|
|
||||||
|
1. **Перезапустите backend:**
|
||||||
|
```bash
|
||||||
|
pm2 restart nakama-backend
|
||||||
|
# или
|
||||||
|
docker-compose restart backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Проверьте логи:**
|
||||||
|
```bash
|
||||||
|
pm2 logs nakama-backend --lines 50
|
||||||
|
```
|
||||||
|
|
||||||
|
Должно появиться:
|
||||||
|
- `[Email] ✅ AWS SES клиент инициализирован` (для AWS)
|
||||||
|
- или `[Email] ✅ SMTP transporter инициализирован` (для SMTP)
|
||||||
|
|
||||||
|
3. **Проверьте отправку:**
|
||||||
|
- Попробуйте отправить magic-link через форму авторизации
|
||||||
|
- Проверьте логи на наличие ошибок
|
||||||
|
|
||||||
|
## ⚠️ Частые проблемы
|
||||||
|
|
||||||
|
### 1. "Missing credentials"
|
||||||
|
- **Причина:** Не установлены AWS credentials
|
||||||
|
- **Решение:** Установите `AWS_SES_ACCESS_KEY_ID` и `AWS_SES_SECRET_ACCESS_KEY` или используйте `EMAIL_PROVIDER=yandex`
|
||||||
|
|
||||||
|
### 2. "EAUTH" ошибка для Yandex
|
||||||
|
- **Причина:** Используется основной пароль вместо пароля приложения
|
||||||
|
- **Решение:** Создайте пароль приложения в настройках безопасности Yandex
|
||||||
|
|
||||||
|
### 3. "ECONNECTION" ошибка
|
||||||
|
- **Причина:** Неверный хост или порт SMTP
|
||||||
|
- **Решение:** Проверьте `YANDEX_SMTP_HOST` и `YANDEX_SMTP_PORT`
|
||||||
|
|
||||||
|
## 📝 Пример .env для Yandex
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EMAIL_PROVIDER=yandex
|
||||||
|
YANDEX_SMTP_HOST=smtp.yandex.ru
|
||||||
|
YANDEX_SMTP_PORT=465
|
||||||
|
YANDEX_SMTP_SECURE=true
|
||||||
|
YANDEX_SMTP_USER=aaem9848@yandex.ru
|
||||||
|
YANDEX_SMTP_PASSWORD=your_app_password_here
|
||||||
|
EMAIL_FROM=aaem9848@yandex.ru
|
||||||
|
```
|
||||||
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
# 🔄 Инструкция по пересборке frontend после смены домена
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
Vite встраивает переменные окружения (`VITE_API_URL`) в код во время сборки. Если frontend был собран со старым доменом, он будет продолжать использовать старый домен даже после изменения `.env`.
|
||||||
|
|
||||||
|
## ✅ Решение
|
||||||
|
|
||||||
|
### Вариант 1: Пересборка frontend (рекомендуется)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Перейти в директорию frontend
|
||||||
|
cd /var/www/nakama/frontend
|
||||||
|
|
||||||
|
# Установить переменную окружения для сборки
|
||||||
|
export VITE_API_URL=https://nkm.guru/api
|
||||||
|
|
||||||
|
# Пересобрать frontend
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Если используете PM2 или другой процесс-менеджер, перезапустите nginx
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 2: Использование относительного пути (уже исправлено в коде)
|
||||||
|
|
||||||
|
Код уже обновлен так, чтобы в production всегда использовался относительный путь `/api`, который работает с любым доменом. Но если frontend был собран со старым `VITE_API_URL`, нужно пересобрать.
|
||||||
|
|
||||||
|
### Вариант 3: Docker
|
||||||
|
|
||||||
|
Если используете Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/nakama
|
||||||
|
|
||||||
|
# Пересобрать frontend с новым доменом
|
||||||
|
docker-compose build frontend --build-arg VITE_API_URL=https://nkm.guru/api
|
||||||
|
|
||||||
|
# Или установить в .env и пересобрать:
|
||||||
|
# VITE_API_URL=https://nkm.guru/api
|
||||||
|
docker-compose build frontend
|
||||||
|
docker-compose up -d frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Проверка после пересборки
|
||||||
|
|
||||||
|
1. **Очистите кэш браузера** (Ctrl+Shift+Delete или Cmd+Shift+Delete)
|
||||||
|
2. **Или используйте Hard Refresh** (Ctrl+F5 или Cmd+Shift+R)
|
||||||
|
3. **Проверьте в консоли браузера** (F12 → Network):
|
||||||
|
- Запросы должны идти на: `https://nkm.guru/api/...`
|
||||||
|
- НЕ должно быть: `https://nakama.glpshchn.ru/api/...`
|
||||||
|
|
||||||
|
## 📝 Важно
|
||||||
|
|
||||||
|
После пересборки frontend будет использовать относительный путь `/api` в production, что означает:
|
||||||
|
- ✅ Работает с любым доменом автоматически
|
||||||
|
- ✅ Не нужно пересобирать при смене домена
|
||||||
|
- ✅ Использует текущий домен браузера
|
||||||
|
|
||||||
|
## 🚀 Быстрая команда для пересборки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/nakama/frontend && npm run build && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
|
@ -386,6 +386,128 @@ const authenticateJWT = async (req, res, next) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Optional authenticate - позволяет гостям, но устанавливает req.user если есть авторизация
|
||||||
|
const authenticateOptional = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization || '';
|
||||||
|
const guestId = req.headers['x-guest-id'];
|
||||||
|
|
||||||
|
// Если есть guest_id, создаем гостевого пользователя
|
||||||
|
if (guestId && !authHeader) {
|
||||||
|
req.user = {
|
||||||
|
isGuest: true,
|
||||||
|
id: guestId,
|
||||||
|
settings: {
|
||||||
|
whitelist: {
|
||||||
|
noNSFW: true,
|
||||||
|
noHomo: true
|
||||||
|
},
|
||||||
|
searchPreference: 'furry'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пытаемся авторизовать через Telegram или JWT
|
||||||
|
let initDataRaw = null;
|
||||||
|
let token = null;
|
||||||
|
|
||||||
|
if (authHeader.startsWith('tma ')) {
|
||||||
|
initDataRaw = authHeader.slice(4).trim();
|
||||||
|
} else if (authHeader.startsWith('Bearer ')) {
|
||||||
|
token = authHeader.slice(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initDataRaw && !token) {
|
||||||
|
const headerInitData = req.headers['x-telegram-init-data'];
|
||||||
|
if (headerInitData && typeof headerInitData === 'string') {
|
||||||
|
initDataRaw = headerInitData.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если нет данных для авторизации, создаем гостя
|
||||||
|
if (!initDataRaw && !token) {
|
||||||
|
req.user = {
|
||||||
|
isGuest: true,
|
||||||
|
id: guestId || `guest_${Date.now()}`,
|
||||||
|
settings: {
|
||||||
|
whitelist: {
|
||||||
|
noNSFW: true,
|
||||||
|
noHomo: true
|
||||||
|
},
|
||||||
|
searchPreference: 'furry'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пытаемся авторизовать через Telegram
|
||||||
|
if (initDataRaw) {
|
||||||
|
try {
|
||||||
|
const payload = validateAndParseInitData(initDataRaw);
|
||||||
|
const telegramUser = payload.user;
|
||||||
|
const normalizedUser = normalizeTelegramUser(telegramUser);
|
||||||
|
|
||||||
|
let user = await User.findOne({ telegramId: normalizedUser.id.toString() });
|
||||||
|
if (user && !user.banned) {
|
||||||
|
await ensureUserSettings(user);
|
||||||
|
await touchUserActivity(user);
|
||||||
|
req.user = user;
|
||||||
|
req.telegramUser = normalizedUser;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Игнорируем ошибки Telegram авторизации, продолжаем как гость
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пытаемся авторизовать через JWT
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const { verifyAccessToken } = require('../utils/tokens');
|
||||||
|
const payload = verifyAccessToken(token);
|
||||||
|
const user = await User.findById(payload.userId);
|
||||||
|
|
||||||
|
if (user && !user.banned) {
|
||||||
|
req.user = user;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Игнорируем ошибки JWT, продолжаем как гость
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если авторизация не удалась, создаем гостя
|
||||||
|
req.user = {
|
||||||
|
isGuest: true,
|
||||||
|
id: guestId || `guest_${Date.now()}`,
|
||||||
|
settings: {
|
||||||
|
whitelist: {
|
||||||
|
noNSFW: true,
|
||||||
|
noHomo: true
|
||||||
|
},
|
||||||
|
searchPreference: 'furry'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка optional auth:', error);
|
||||||
|
// В случае ошибки создаем гостя
|
||||||
|
req.user = {
|
||||||
|
isGuest: true,
|
||||||
|
id: req.headers['x-guest-id'] || `guest_${Date.now()}`,
|
||||||
|
settings: {
|
||||||
|
whitelist: {
|
||||||
|
noNSFW: true,
|
||||||
|
noHomo: true
|
||||||
|
},
|
||||||
|
searchPreference: 'furry'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Комбинированный middleware: Telegram или JWT
|
// Комбинированный middleware: Telegram или JWT
|
||||||
const authenticateModerationFlexible = async (req, res, next) => {
|
const authenticateModerationFlexible = async (req, res, next) => {
|
||||||
// Попробовать Telegram авторизацию
|
// Попробовать Telegram авторизацию
|
||||||
|
|
@ -400,6 +522,7 @@ const authenticateModerationFlexible = async (req, res, next) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
authenticateOptional,
|
||||||
authenticate,
|
authenticate,
|
||||||
authenticateModeration,
|
authenticateModeration,
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const helmetConfig = helmet({
|
||||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
scriptSrc: ["'self'", "https://telegram.org", "'unsafe-inline'"],
|
scriptSrc: ["'self'", "https://telegram.org", "'unsafe-inline'"],
|
||||||
imgSrc: ["'self'", "data:", "https:", "blob:"],
|
imgSrc: ["'self'", "data:", "https:", "blob:"],
|
||||||
connectSrc: ["'self'", "https://api.telegram.org", "https://e621.net", "https://gelbooru.com"],
|
connectSrc: ["'self'", "https://api.telegram.org", "https://e621.net", "https://gelbooru.com", "https://nkm.guru"],
|
||||||
fontSrc: ["'self'", "data:"],
|
fontSrc: ["'self'", "data:"],
|
||||||
objectSrc: ["'none'"],
|
objectSrc: ["'none'"],
|
||||||
// Запретить использование консоли и eval
|
// Запретить использование консоли и eval
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,11 @@ const UserSchema = new mongoose.Schema({
|
||||||
// Magic-link токены для авторизации
|
// Magic-link токены для авторизации
|
||||||
magicLinkToken: String,
|
magicLinkToken: String,
|
||||||
magicLinkExpires: Date,
|
magicLinkExpires: Date,
|
||||||
|
// Onboarding - отслеживание показа приветствия
|
||||||
|
onboardingCompleted: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
createdAt: {
|
createdAt: {
|
||||||
type: Date,
|
type: Date,
|
||||||
default: Date.now
|
default: Date.now
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@ const respondWithUser = async (user, res) => {
|
||||||
following: populatedUser.following,
|
following: populatedUser.following,
|
||||||
tickets: populatedUser.tickets || 0,
|
tickets: populatedUser.tickets || 0,
|
||||||
settings,
|
settings,
|
||||||
banned: populatedUser.banned
|
banned: populatedUser.banned,
|
||||||
|
onboardingCompleted: populatedUser.onboardingCompleted || false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -454,7 +455,7 @@ router.get('/magic-link/verify', async (req, res) => {
|
||||||
// Установка пароля при регистрации через magic-link
|
// Установка пароля при регистрации через magic-link
|
||||||
router.post('/magic-link/set-password', async (req, res) => {
|
router.post('/magic-link/set-password', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { token, password, username } = req.body;
|
const { token, password, username, firstName } = req.body;
|
||||||
|
|
||||||
if (!token || !password) {
|
if (!token || !password) {
|
||||||
return res.status(400).json({ error: 'Токен и пароль обязательны' });
|
return res.status(400).json({ error: 'Токен и пароль обязательны' });
|
||||||
|
|
@ -465,6 +466,20 @@ router.post('/magic-link/set-password', async (req, res) => {
|
||||||
return res.status(400).json({ error: 'Пароль должен быть от 8 до 24 символов' });
|
return res.status(400).json({ error: 'Пароль должен быть от 8 до 24 символов' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Валидация username (обязателен, нельзя менять после регистрации)
|
||||||
|
if (!username || username.trim().length < 3 || username.trim().length > 20) {
|
||||||
|
return res.status(400).json({ error: 'Юзернейм обязателен и должен быть от 3 до 20 символов' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверить уникальность username (исключая текущего пользователя)
|
||||||
|
const existingUser = await User.findOne({
|
||||||
|
username: username.trim().toLowerCase(),
|
||||||
|
_id: { $ne: user._id }
|
||||||
|
});
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(400).json({ error: 'Этот юзернейм уже занят' });
|
||||||
|
}
|
||||||
|
|
||||||
// Найти пользователя с этим токеном
|
// Найти пользователя с этим токеном
|
||||||
const user = await User.findOne({
|
const user = await User.findOne({
|
||||||
magicLinkToken: token,
|
magicLinkToken: token,
|
||||||
|
|
@ -486,8 +501,14 @@ router.post('/magic-link/set-password', async (req, res) => {
|
||||||
user.magicLinkExpires = undefined;
|
user.magicLinkExpires = undefined;
|
||||||
user.lastActiveAt = new Date();
|
user.lastActiveAt = new Date();
|
||||||
|
|
||||||
if (username && !user.username) {
|
// Устанавливаем username (нельзя менять после регистрации)
|
||||||
user.username = username;
|
if (username) {
|
||||||
|
user.username = username.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем firstName (никнейм, можно менять)
|
||||||
|
if (firstName) {
|
||||||
|
user.firstName = firstName.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
await user.save();
|
await user.save();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { authenticate } = require('../middleware/auth');
|
const { authenticate, authenticateOptional } = require('../middleware/auth');
|
||||||
const { postCreationLimiter, interactionLimiter } = require('../middleware/rateLimiter');
|
const { postCreationLimiter, interactionLimiter } = require('../middleware/rateLimiter');
|
||||||
const { searchLimiter } = require('../middleware/rateLimiter');
|
const { searchLimiter } = require('../middleware/rateLimiter');
|
||||||
const { validatePostContent, validateTags, validateImageUrl } = require('../middleware/validator');
|
const { validatePostContent, validateTags, validateImageUrl } = require('../middleware/validator');
|
||||||
|
|
@ -46,11 +46,12 @@ router.get('/:id', authenticate, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Получить ленту постов
|
// Получить ленту постов (доступно для гостей)
|
||||||
router.get('/', authenticate, async (req, res) => {
|
router.get('/', authenticateOptional, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, limit = 20, tag, userId, filter = 'all' } = req.query;
|
const { page = 1, limit = 20, tag, userId, filter = 'all' } = req.query;
|
||||||
const query = {};
|
const query = {};
|
||||||
|
const isGuest = req.user?.isGuest;
|
||||||
|
|
||||||
// Фильтр по тегу
|
// Фильтр по тегу
|
||||||
if (tag) {
|
if (tag) {
|
||||||
|
|
@ -63,7 +64,7 @@ router.get('/', authenticate, async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фильтры: 'all', 'interests', 'following'
|
// Фильтры: 'all', 'interests', 'following'
|
||||||
if (filter === 'interests') {
|
if (filter === 'interests' && !isGuest) {
|
||||||
// Лента по интересам - посты с тегами из preferredTags пользователя
|
// Лента по интересам - посты с тегами из preferredTags пользователя
|
||||||
const user = await User.findById(req.user._id).select('preferredTags');
|
const user = await User.findById(req.user._id).select('preferredTags');
|
||||||
if (user.preferredTags && user.preferredTags.length > 0) {
|
if (user.preferredTags && user.preferredTags.length > 0) {
|
||||||
|
|
@ -76,7 +77,7 @@ router.get('/', authenticate, async (req, res) => {
|
||||||
currentPage: page
|
currentPage: page
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (filter === 'following') {
|
} else if (filter === 'following' && !isGuest) {
|
||||||
// Лента подписок - посты от пользователей, на которых подписан
|
// Лента подписок - посты от пользователей, на которых подписан
|
||||||
const user = await User.findById(req.user._id).select('following');
|
const user = await User.findById(req.user._id).select('following');
|
||||||
if (user.following && user.following.length > 0) {
|
if (user.following && user.following.length > 0) {
|
||||||
|
|
@ -90,13 +91,14 @@ router.get('/', authenticate, async (req, res) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Для гостей или filter === 'all' - показываем все посты без дополнительных фильтров
|
||||||
// 'all' - все посты, без дополнительных фильтров
|
// 'all' - все посты, без дополнительных фильтров
|
||||||
|
|
||||||
// Применить whitelist настройки пользователя (только NSFW и Homo)
|
// Применить whitelist настройки пользователя (только NSFW и Homo)
|
||||||
if (req.user.settings.whitelist.noNSFW) {
|
if (req.user?.settings?.whitelist?.noNSFW) {
|
||||||
query.isNSFW = false;
|
query.isNSFW = false;
|
||||||
}
|
}
|
||||||
if (req.user.settings.whitelist.noHomo) {
|
if (req.user?.settings?.whitelist?.noHomo) {
|
||||||
// Скрывать только посты, помеченные как гомосексуальные.
|
// Скрывать только посты, помеченные как гомосексуальные.
|
||||||
// Посты без флага (старые) остаются видимыми.
|
// Посты без флага (старые) остаются видимыми.
|
||||||
query.isHomo = { $ne: true };
|
query.isHomo = { $ne: true };
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { authenticate } = require('../middleware/auth');
|
const { authenticate } = require('../middleware/auth');
|
||||||
|
const { uploadAvatar } = require('../middleware/upload');
|
||||||
const User = require('../models/User');
|
const User = require('../models/User');
|
||||||
const Post = require('../models/Post');
|
const Post = require('../models/Post');
|
||||||
const Notification = require('../models/Notification');
|
const Notification = require('../models/Notification');
|
||||||
|
|
@ -125,14 +126,31 @@ router.post('/:id/follow', authenticate, async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Обновить профиль
|
// Обновить профиль
|
||||||
router.put('/profile', authenticate, async (req, res) => {
|
router.put('/profile', authenticate, uploadAvatar, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { bio, settings } = req.body;
|
const { bio, settings, firstName, lastName, onboardingCompleted } = req.body;
|
||||||
|
|
||||||
|
// Обработка загруженной аватарки
|
||||||
|
if (req.uploadedFiles && req.uploadedFiles.length > 0) {
|
||||||
|
req.user.photoUrl = req.uploadedFiles[0];
|
||||||
|
}
|
||||||
|
|
||||||
if (bio !== undefined) {
|
if (bio !== undefined) {
|
||||||
req.user.bio = bio;
|
req.user.bio = bio;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (firstName !== undefined) {
|
||||||
|
req.user.firstName = firstName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastName !== undefined) {
|
||||||
|
req.user.lastName = lastName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onboardingCompleted !== undefined) {
|
||||||
|
req.user.onboardingCompleted = onboardingCompleted;
|
||||||
|
}
|
||||||
|
|
||||||
if (settings) {
|
if (settings) {
|
||||||
req.user.settings = req.user.settings || {};
|
req.user.settings = req.user.settings || {};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,18 @@ const initializeEmailService = () => {
|
||||||
const emailProvider = process.env.EMAIL_PROVIDER || 'aws'; // aws, yandex, smtp
|
const emailProvider = process.env.EMAIL_PROVIDER || 'aws'; // aws, yandex, smtp
|
||||||
|
|
||||||
if (emailProvider === 'aws' && config.email?.aws) {
|
if (emailProvider === 'aws' && config.email?.aws) {
|
||||||
|
const accessKeyId = config.email.aws.accessKeyId;
|
||||||
|
const secretAccessKey = config.email.aws.secretAccessKey;
|
||||||
|
|
||||||
|
// Проверка наличия credentials
|
||||||
|
if (!accessKeyId || !secretAccessKey) {
|
||||||
|
console.error('[Email] ❌ AWS SES credentials не установлены!');
|
||||||
|
console.error('[Email] Установите AWS_SES_ACCESS_KEY_ID и AWS_SES_SECRET_ACCESS_KEY в .env');
|
||||||
|
console.error('[Email] Или используйте EMAIL_PROVIDER=yandex или EMAIL_PROVIDER=smtp');
|
||||||
|
sesClient = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const awsRegion = config.email.aws.region || 'us-east-1';
|
const awsRegion = config.email.aws.region || 'us-east-1';
|
||||||
const validAWSRegions = [
|
const validAWSRegions = [
|
||||||
'us-east-1', 'us-east-2', 'us-west-1', 'us-west-2',
|
'us-east-1', 'us-east-2', 'us-west-1', 'us-west-2',
|
||||||
|
|
@ -26,8 +38,8 @@ const initializeEmailService = () => {
|
||||||
(isYandexCloud ? 'https://postbox.cloud.yandex.net' : null);
|
(isYandexCloud ? 'https://postbox.cloud.yandex.net' : null);
|
||||||
|
|
||||||
const sesConfig = {
|
const sesConfig = {
|
||||||
accessKeyId: config.email.aws.accessKeyId,
|
accessKeyId: accessKeyId,
|
||||||
secretAccessKey: config.email.aws.secretAccessKey,
|
secretAccessKey: secretAccessKey,
|
||||||
region: awsRegion
|
region: awsRegion
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -38,17 +50,15 @@ const initializeEmailService = () => {
|
||||||
sesConfig.isYandexCloud = true;
|
sesConfig.isYandexCloud = true;
|
||||||
console.log(`[Email] Используется Yandex Cloud Postbox с endpoint: ${endpointUrl}`);
|
console.log(`[Email] Используется Yandex Cloud Postbox с endpoint: ${endpointUrl}`);
|
||||||
// Не создаем SES клиент для Yandex Cloud, будем использовать прямые HTTP запросы
|
// Не создаем SES клиент для Yandex Cloud, будем использовать прямые HTTP запросы
|
||||||
|
sesClient = { config: sesConfig, isYandexCloud: true };
|
||||||
} else if (!validAWSRegions.includes(awsRegion)) {
|
} else if (!validAWSRegions.includes(awsRegion)) {
|
||||||
console.warn(`[Email] Невалидный регион AWS SES: ${awsRegion}. Используется us-east-1`);
|
console.warn(`[Email] Невалидный регион AWS SES: ${awsRegion}. Используется us-east-1`);
|
||||||
sesConfig.region = 'us-east-1';
|
sesConfig.region = 'us-east-1';
|
||||||
sesClient = new AWS.SES(sesConfig);
|
sesClient = new AWS.SES(sesConfig);
|
||||||
|
console.log('[Email] ✅ AWS SES клиент инициализирован');
|
||||||
} else {
|
} else {
|
||||||
sesClient = new AWS.SES(sesConfig);
|
sesClient = new AWS.SES(sesConfig);
|
||||||
}
|
console.log('[Email] ✅ AWS SES клиент инициализирован');
|
||||||
|
|
||||||
// Сохраняем конфигурацию для Yandex Cloud
|
|
||||||
if (endpointUrl) {
|
|
||||||
sesClient = { config: sesConfig, isYandexCloud: true };
|
|
||||||
}
|
}
|
||||||
} else if (emailProvider === 'yandex' || emailProvider === 'smtp') {
|
} else if (emailProvider === 'yandex' || emailProvider === 'smtp') {
|
||||||
const emailConfig = config.email?.[emailProvider] || config.email?.smtp || {};
|
const emailConfig = config.email?.[emailProvider] || config.email?.smtp || {};
|
||||||
|
|
@ -141,7 +151,12 @@ const sendEmail = async (to, subject, html, text) => {
|
||||||
const emailProvider = process.env.EMAIL_PROVIDER || 'aws';
|
const emailProvider = process.env.EMAIL_PROVIDER || 'aws';
|
||||||
const fromEmail = process.env.EMAIL_FROM || config.email?.from || 'noreply@nakama.guru';
|
const fromEmail = process.env.EMAIL_FROM || config.email?.from || 'noreply@nakama.guru';
|
||||||
|
|
||||||
if (emailProvider === 'aws' && sesClient) {
|
if (emailProvider === 'aws') {
|
||||||
|
if (!sesClient) {
|
||||||
|
const errorMsg = 'AWS SES не инициализирован. Проверьте AWS_SES_ACCESS_KEY_ID и AWS_SES_SECRET_ACCESS_KEY в .env файле. Или используйте EMAIL_PROVIDER=yandex или EMAIL_PROVIDER=smtp';
|
||||||
|
console.error('[Email] ❌', errorMsg);
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
// Проверка на Yandex Cloud Postbox
|
// Проверка на Yandex Cloud Postbox
|
||||||
if (sesClient.isYandexCloud) {
|
if (sesClient.isYandexCloud) {
|
||||||
// Yandex Cloud Postbox использует SESv2 API - используем прямой HTTP запрос
|
// Yandex Cloud Postbox использует SESv2 API - используем прямой HTTP запрос
|
||||||
|
|
@ -246,7 +261,11 @@ const sendEmail = async (to, subject, html, text) => {
|
||||||
console.error('Ошибка отправки email:', error);
|
console.error('Ошибка отправки email:', error);
|
||||||
|
|
||||||
// Более информативные сообщения об ошибках
|
// Более информативные сообщения об ошибках
|
||||||
if (error.code === 'EAUTH') {
|
if (error.code === 'CredentialsError' || (error.message && error.message.includes('Missing credentials'))) {
|
||||||
|
const errorMsg = 'AWS SES credentials не настроены. Установите AWS_SES_ACCESS_KEY_ID и AWS_SES_SECRET_ACCESS_KEY в .env файле. Или используйте EMAIL_PROVIDER=yandex или EMAIL_PROVIDER=smtp';
|
||||||
|
console.error('[Email] ❌', errorMsg);
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
} else if (error.code === 'EAUTH') {
|
||||||
throw new Error('Неверные учетные данные SMTP. Проверьте YANDEX_SMTP_USER и YANDEX_SMTP_PASSWORD в .env файле. Для Yandex используйте пароль приложения, а не основной пароль.');
|
throw new Error('Неверные учетные данные SMTP. Проверьте YANDEX_SMTP_USER и YANDEX_SMTP_PASSWORD в .env файле. Для Yandex используйте пароль приложения, а не основной пароль.');
|
||||||
} else if (error.code === 'ECONNECTION') {
|
} else if (error.code === 'ECONNECTION') {
|
||||||
throw new Error('Не удалось подключиться к SMTP серверу. Проверьте YANDEX_SMTP_HOST и YANDEX_SMTP_PORT.');
|
throw new Error('Не удалось подключиться к SMTP серверу. Проверьте YANDEX_SMTP_HOST и YANDEX_SMTP_PORT.');
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import Profile from './pages/Profile'
|
||||||
import UserProfile from './pages/UserProfile'
|
import UserProfile from './pages/UserProfile'
|
||||||
import CommentsPage from './pages/CommentsPage'
|
import CommentsPage from './pages/CommentsPage'
|
||||||
import PostMenuPage from './pages/PostMenuPage'
|
import PostMenuPage from './pages/PostMenuPage'
|
||||||
|
import VerifyEmail from './pages/VerifyEmail'
|
||||||
import MiniPlayer from './components/MiniPlayer'
|
import MiniPlayer from './components/MiniPlayer'
|
||||||
import FullPlayer from './components/FullPlayer'
|
import FullPlayer from './components/FullPlayer'
|
||||||
import './styles/index.css'
|
import './styles/index.css'
|
||||||
|
|
@ -222,7 +223,7 @@ function AppContent() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Layout user={user} />}>
|
<Route path="/" element={<Layout user={user} />}>
|
||||||
<Route index element={<Navigate to="/feed" replace />} />
|
<Route index element={<Navigate to="/feed" replace />} />
|
||||||
<Route path="feed" element={<Feed user={user} />} />
|
<Route path="feed" element={<Feed user={user} setUser={setUser} />} />
|
||||||
<Route path="media" element={<Media user={user} />} />
|
<Route path="media" element={<Media user={user} />} />
|
||||||
<Route path="media/furry" element={<MediaFurry user={user} />} />
|
<Route path="media/furry" element={<MediaFurry user={user} />} />
|
||||||
<Route path="media/anime" element={<MediaAnime user={user} />} />
|
<Route path="media/anime" element={<MediaAnime user={user} />} />
|
||||||
|
|
@ -233,6 +234,7 @@ function AppContent() {
|
||||||
<Route path="post/:postId/comments" element={<CommentsPage user={user} />} />
|
<Route path="post/:postId/comments" element={<CommentsPage user={user} />} />
|
||||||
<Route path="post/:postId/menu" element={<PostMenuPage user={user} />} />
|
<Route path="post/:postId/menu" element={<PostMenuPage user={user} />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="auth/verify" element={<VerifyEmail />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,9 @@ export default function FullPlayer() {
|
||||||
<img
|
<img
|
||||||
src={currentTrack.coverImage.startsWith('http')
|
src={currentTrack.coverImage.startsWith('http')
|
||||||
? currentTrack.coverImage
|
? currentTrack.coverImage
|
||||||
: (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + currentTrack.coverImage
|
: (import.meta.env.DEV
|
||||||
|
? (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '')
|
||||||
|
: (import.meta.env.VITE_API_URL || 'https://nkm.guru/api').replace('/api', '')) + currentTrack.coverImage
|
||||||
}
|
}
|
||||||
alt={currentTrack.title}
|
alt={currentTrack.title}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,9 @@ export default function MiniPlayer() {
|
||||||
<img
|
<img
|
||||||
src={currentTrack.coverImage.startsWith('http')
|
src={currentTrack.coverImage.startsWith('http')
|
||||||
? currentTrack.coverImage
|
? currentTrack.coverImage
|
||||||
: (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + currentTrack.coverImage
|
: (import.meta.env.DEV
|
||||||
|
? (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '')
|
||||||
|
: (import.meta.env.VITE_API_URL || 'https://nkm.guru/api').replace('/api', '')) + currentTrack.coverImage
|
||||||
}
|
}
|
||||||
alt={currentTrack.title}
|
alt={currentTrack.title}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,9 @@ export default function MusicAttachment({ track, onRemove, showRemove = false })
|
||||||
<img
|
<img
|
||||||
src={track.coverImage.startsWith('http')
|
src={track.coverImage.startsWith('http')
|
||||||
? track.coverImage
|
? track.coverImage
|
||||||
: (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + track.coverImage
|
: (import.meta.env.DEV
|
||||||
|
? (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '')
|
||||||
|
: (import.meta.env.VITE_API_URL || 'https://nkm.guru/api').replace('/api', '')) + track.coverImage
|
||||||
}
|
}
|
||||||
alt={track.title}
|
alt={track.title}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,9 @@ export default function MusicPickerModal({ onClose, onSelect }) {
|
||||||
<img
|
<img
|
||||||
src={track.coverImage.startsWith('http')
|
src={track.coverImage.startsWith('http')
|
||||||
? track.coverImage
|
? track.coverImage
|
||||||
: (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + track.coverImage
|
: (import.meta.env.DEV
|
||||||
|
? (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '')
|
||||||
|
: (import.meta.env.VITE_API_URL || 'https://nkm.guru/api').replace('/api', '')) + track.coverImage
|
||||||
}
|
}
|
||||||
alt={track.title}
|
alt={track.title}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -113,8 +113,10 @@ export const MusicPlayerProvider = ({ children }) => {
|
||||||
|
|
||||||
// Если относительный URL (локальное хранилище), добавить базовый URL
|
// Если относительный URL (локальное хранилище), добавить базовый URL
|
||||||
if (audioUrl.startsWith('/uploads/')) {
|
if (audioUrl.startsWith('/uploads/')) {
|
||||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'
|
const baseUrl = import.meta.env.DEV
|
||||||
audioUrl = apiUrl.replace('/api', '') + audioUrl
|
? (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '')
|
||||||
|
: (import.meta.env.VITE_API_URL || 'https://nkm.guru/api').replace('/api', '')
|
||||||
|
audioUrl = baseUrl + audioUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
// Убедиться что URL валидный
|
// Убедиться что URL валидный
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,39 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 2px 8px var(--shadow-md);
|
box-shadow: 0 2px 8px var(--shadow-md);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-btn svg {
|
.create-btn svg {
|
||||||
stroke: white;
|
stroke: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.create-btn.auth-btn {
|
||||||
|
width: auto;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--button-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn.auth-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .create-btn {
|
[data-theme="dark"] .create-btn {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
|
|
@ -45,6 +72,11 @@
|
||||||
stroke: #000000;
|
stroke: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .create-btn.auth-btn {
|
||||||
|
background: var(--button-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.feed-filters {
|
.feed-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useSearchParams, useNavigate } from 'react-router-dom'
|
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||||
import { getPosts, getPost } from '../utils/api'
|
import { getPosts, getPost, updateProfile } from '../utils/api'
|
||||||
import PostCard from '../components/PostCard'
|
import PostCard from '../components/PostCard'
|
||||||
import CreatePostModal from '../components/CreatePostModal'
|
import CreatePostModal from '../components/CreatePostModal'
|
||||||
import OnboardingPost from '../components/OnboardingPost'
|
import OnboardingPost from '../components/OnboardingPost'
|
||||||
|
|
@ -9,7 +9,7 @@ import { Plus, Settings } from 'lucide-react'
|
||||||
import { hapticFeedback } from '../utils/telegram'
|
import { hapticFeedback } from '../utils/telegram'
|
||||||
import './Feed.css'
|
import './Feed.css'
|
||||||
|
|
||||||
export default function Feed({ user }) {
|
export default function Feed({ user, setUser }) {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [posts, setPosts] = useState([])
|
const [posts, setPosts] = useState([])
|
||||||
|
|
@ -21,12 +21,22 @@ export default function Feed({ user }) {
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
const [highlightPostId, setHighlightPostId] = useState(null)
|
const [highlightPostId, setHighlightPostId] = useState(null)
|
||||||
|
// Проверяем, показывалось ли приветствие ранее - только 1 раз (из БД)
|
||||||
const [onboardingVisible, setOnboardingVisible] = useState({
|
const [onboardingVisible, setOnboardingVisible] = useState({
|
||||||
welcome: true,
|
welcome: !user?.onboardingCompleted && (user?.isGuest || !user?.onboardingCompleted),
|
||||||
tags: true,
|
tags: false, // Удаляем второй пост
|
||||||
media: true
|
media: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Обновляем onboarding при изменении user (из БД)
|
||||||
|
setOnboardingVisible({
|
||||||
|
welcome: !user?.onboardingCompleted && (user?.isGuest || !user?.onboardingCompleted),
|
||||||
|
tags: false,
|
||||||
|
media: false
|
||||||
|
})
|
||||||
|
}, [user])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Проверить параметр post в URL
|
// Проверить параметр post в URL
|
||||||
const postId = searchParams.get('post')
|
const postId = searchParams.get('post')
|
||||||
|
|
@ -155,7 +165,7 @@ export default function Feed({ user }) {
|
||||||
setShowCreateModal(false)
|
setShowCreateModal(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOnboardingAction = (type) => {
|
const handleOnboardingAction = async (type) => {
|
||||||
hapticFeedback('light')
|
hapticFeedback('light')
|
||||||
|
|
||||||
if (type === 'welcome' || type === 'tags') {
|
if (type === 'welcome' || type === 'tags') {
|
||||||
|
|
@ -164,18 +174,39 @@ export default function Feed({ user }) {
|
||||||
navigate('/media')
|
navigate('/media')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сохраняем в БД, если пользователь авторизован
|
||||||
|
if (type === 'welcome' && !user?.isGuest && user?.id) {
|
||||||
|
try {
|
||||||
|
await updateProfile({ onboardingCompleted: true })
|
||||||
|
// Обновляем локальное состояние пользователя
|
||||||
|
if (setUser) {
|
||||||
|
setUser({ ...user, onboardingCompleted: true })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка сохранения onboarding:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setOnboardingVisible(prev => ({ ...prev, [type]: false }))
|
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) => {
|
const handleOnboardingDismiss = async (type) => {
|
||||||
hapticFeedback('light')
|
hapticFeedback('light')
|
||||||
|
|
||||||
|
// Сохраняем в БД, если пользователь авторизован
|
||||||
|
if (type === 'welcome' && !user?.isGuest && user?.id) {
|
||||||
|
try {
|
||||||
|
await updateProfile({ onboardingCompleted: true })
|
||||||
|
// Обновляем локальное состояние пользователя
|
||||||
|
if (setUser) {
|
||||||
|
setUser({ ...user, onboardingCompleted: true })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка сохранения onboarding:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setOnboardingVisible(prev => ({ ...prev, [type]: false }))
|
setOnboardingVisible(prev => ({ ...prev, [type]: false }))
|
||||||
const dismissed = JSON.parse(localStorage.getItem('onboarding_dismissed') || '{}')
|
|
||||||
dismissed[type] = true
|
|
||||||
localStorage.setItem('onboarding_dismissed', JSON.stringify(dismissed))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
|
|
@ -189,9 +220,23 @@ export default function Feed({ user }) {
|
||||||
{/* Хедер */}
|
{/* Хедер */}
|
||||||
<div className="feed-header">
|
<div className="feed-header">
|
||||||
<h1>Nakama</h1>
|
<h1>Nakama</h1>
|
||||||
<button className="create-btn" onClick={handleCreatePost}>
|
{user?.isGuest ? (
|
||||||
<Plus size={20} />
|
<button
|
||||||
</button>
|
className="create-btn auth-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setAuthReason('Войдите, чтобы публиковать посты')
|
||||||
|
setShowAuthModal(true)
|
||||||
|
hapticFeedback('light')
|
||||||
|
}}
|
||||||
|
title="Войти"
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="create-btn" onClick={handleCreatePost}>
|
||||||
|
<Plus size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Фильтры */}
|
{/* Фильтры */}
|
||||||
|
|
@ -227,7 +272,7 @@ export default function Feed({ user }) {
|
||||||
|
|
||||||
{/* Посты */}
|
{/* Посты */}
|
||||||
<div className="feed-content">
|
<div className="feed-content">
|
||||||
{/* Onboarding посты для новых пользователей и гостей */}
|
{/* Onboarding пост "Добро пожаловать" - показывается только 1 раз */}
|
||||||
{(user?.isGuest || !user?.onboarding_completed) && onboardingVisible.welcome && (
|
{(user?.isGuest || !user?.onboarding_completed) && onboardingVisible.welcome && (
|
||||||
<OnboardingPost
|
<OnboardingPost
|
||||||
type="welcome"
|
type="welcome"
|
||||||
|
|
@ -236,14 +281,6 @@ export default function Feed({ user }) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(user?.isGuest || !user?.onboarding_completed) && onboardingVisible.tags && posts.length > 2 && (
|
|
||||||
<OnboardingPost
|
|
||||||
type="tags"
|
|
||||||
onAction={() => handleOnboardingAction('tags')}
|
|
||||||
onDismiss={() => handleOnboardingDismiss('tags')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && posts.length === 0 ? (
|
{loading && posts.length === 0 ? (
|
||||||
<div className="loading-state">
|
<div className="loading-state">
|
||||||
<div className="spinner" />
|
<div className="spinner" />
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,9 @@ export default function MediaMusic({ user }) {
|
||||||
<img
|
<img
|
||||||
src={track.coverImage.startsWith('http')
|
src={track.coverImage.startsWith('http')
|
||||||
? track.coverImage
|
? track.coverImage
|
||||||
: (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '') + track.coverImage
|
: (import.meta.env.DEV
|
||||||
|
? (import.meta.env.VITE_API_URL || 'http://localhost:3000/api').replace('/api', '')
|
||||||
|
: (import.meta.env.VITE_API_URL || 'https://nkm.guru/api').replace('/api', '')) + track.coverImage
|
||||||
}
|
}
|
||||||
alt={track.title}
|
alt={track.title}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -746,3 +746,255 @@
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Привязка email */
|
||||||
|
.link-email-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-email-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-email-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(0, 122, 255, 0.12);
|
||||||
|
color: #007AFF;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-email-text h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-email-text p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-email-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--button-accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-email-button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-email-button:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Модалка привязки email */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--button-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 59, 48, 0.1);
|
||||||
|
color: #FF3B30;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--button-accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Загрузка аватарки */
|
||||||
|
.avatar-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--button-accent);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid var(--bg-primary);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-btn:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Settings, Heart, Edit2, Shield, UserPlus, Copy, Info, Tag, X } from 'lucide-react'
|
import { Settings, Heart, Edit2, Shield, UserPlus, Copy, Info, Tag, X } from 'lucide-react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { updateProfile, getTags, getPreferredTags, updatePreferredTags, autocompleteTags } from '../utils/api'
|
import { updateProfile, getTags, getPreferredTags, updatePreferredTags, autocompleteTags, linkEmail } from '../utils/api'
|
||||||
import { hapticFeedback } from '../utils/telegram'
|
import { hapticFeedback } from '../utils/telegram'
|
||||||
import ThemeToggle from '../components/ThemeToggle'
|
import ThemeToggle from '../components/ThemeToggle'
|
||||||
import FollowListModal from '../components/FollowListModal'
|
import FollowListModal from '../components/FollowListModal'
|
||||||
|
|
@ -66,6 +66,39 @@ export default function Profile({ user, setUser }) {
|
||||||
|
|
||||||
// Для привязки email
|
// Для привязки email
|
||||||
const [linkEmailData, setLinkEmailData] = useState({ email: '', password: '' })
|
const [linkEmailData, setLinkEmailData] = useState({ email: '', password: '' })
|
||||||
|
const [linkEmailLoading, setLinkEmailLoading] = useState(false)
|
||||||
|
const [linkEmailError, setLinkEmailError] = useState('')
|
||||||
|
|
||||||
|
const handleLinkEmail = async () => {
|
||||||
|
if (!linkEmailData.email || !linkEmailData.password) {
|
||||||
|
setLinkEmailError('Заполните все поля')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkEmailData.password.length < 8) {
|
||||||
|
setLinkEmailError('Пароль должен быть не менее 8 символов')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLinkEmailLoading(true)
|
||||||
|
setLinkEmailError('')
|
||||||
|
hapticFeedback('light')
|
||||||
|
|
||||||
|
const result = await linkEmail(linkEmailData.email, linkEmailData.password)
|
||||||
|
|
||||||
|
setUser({ ...user, email: linkEmailData.email })
|
||||||
|
setShowLinkEmail(false)
|
||||||
|
setLinkEmailData({ email: '', password: '' })
|
||||||
|
hapticFeedback('success')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка привязки email:', error)
|
||||||
|
setLinkEmailError(error.response?.data?.error || 'Ошибка привязки email')
|
||||||
|
hapticFeedback('error')
|
||||||
|
} finally {
|
||||||
|
setLinkEmailLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSaveBio = async () => {
|
const handleSaveBio = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -331,11 +364,45 @@ export default function Profile({ user, setUser }) {
|
||||||
|
|
||||||
{/* Информация о пользователе */}
|
{/* Информация о пользователе */}
|
||||||
<div className="profile-info card">
|
<div className="profile-info card">
|
||||||
<img
|
<div className="avatar-wrapper">
|
||||||
src={user.photoUrl || '/default-avatar.png'}
|
<img
|
||||||
alt={user.username || user.firstName || 'User'}
|
src={user.photoUrl || '/default-avatar.png'}
|
||||||
className="profile-avatar"
|
alt={user.username || user.firstName || 'User'}
|
||||||
/>
|
className="profile-avatar"
|
||||||
|
/>
|
||||||
|
{/* Кнопка загрузки аватарки только для веб версии (без telegramId) */}
|
||||||
|
{!user.telegramId && (
|
||||||
|
<label className="avatar-upload-btn">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
// Проверка размера (макс 5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('Файл слишком большой. Максимальный размер: 5MB')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
hapticFeedback('light')
|
||||||
|
const result = await updateProfile({ avatar: file })
|
||||||
|
setUser({ ...user, photoUrl: result.user.photoUrl })
|
||||||
|
hapticFeedback('success')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки аватарки:', error)
|
||||||
|
alert(error.response?.data?.error || 'Ошибка загрузки аватарки')
|
||||||
|
hapticFeedback('error')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="profile-details">
|
<div className="profile-details">
|
||||||
<h2 className="profile-name">
|
<h2 className="profile-name">
|
||||||
|
|
@ -812,6 +879,79 @@ export default function Profile({ user, setUser }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Модалка привязки email */}
|
||||||
|
{showLinkEmail && (
|
||||||
|
<div className="modal-overlay" onClick={() => {
|
||||||
|
setShowLinkEmail(false)
|
||||||
|
setLinkEmailError('')
|
||||||
|
}}>
|
||||||
|
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Привязать email</h2>
|
||||||
|
<button className="close-btn" onClick={() => {
|
||||||
|
setShowLinkEmail(false)
|
||||||
|
setLinkEmailError('')
|
||||||
|
}}>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
value={linkEmailData.email}
|
||||||
|
onChange={e => {
|
||||||
|
setLinkEmailData({ ...linkEmailData, email: e.target.value })
|
||||||
|
setLinkEmailError('')
|
||||||
|
}}
|
||||||
|
className="form-input"
|
||||||
|
disabled={linkEmailLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Пароль</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Минимум 8 символов"
|
||||||
|
value={linkEmailData.password}
|
||||||
|
onChange={e => {
|
||||||
|
setLinkEmailData({ ...linkEmailData, password: e.target.value })
|
||||||
|
setLinkEmailError('')
|
||||||
|
}}
|
||||||
|
className="form-input"
|
||||||
|
disabled={linkEmailLoading}
|
||||||
|
/>
|
||||||
|
<p className="form-hint">Пароль должен быть от 8 до 24 символов</p>
|
||||||
|
</div>
|
||||||
|
{linkEmailError && (
|
||||||
|
<div className="error-message">{linkEmailError}</div>
|
||||||
|
)}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowLinkEmail(false)
|
||||||
|
setLinkEmailError('')
|
||||||
|
}}
|
||||||
|
disabled={linkEmailLoading}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={handleLinkEmail}
|
||||||
|
disabled={linkEmailLoading || !linkEmailData.email || !linkEmailData.password}
|
||||||
|
>
|
||||||
|
{linkEmailLoading ? 'Привязка...' : 'Привязать'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
.verify-email-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-email-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-email-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-email-card h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-email-subtitle {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid var(--divider-color);
|
||||||
|
border-top-color: var(--button-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #FF3B30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state p {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--button-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 59, 48, 0.1);
|
||||||
|
color: #FF3B30;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--button-accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { verifyMagicLink, setPassword } from '../utils/api'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import './VerifyEmail.css'
|
||||||
|
|
||||||
|
export default function VerifyEmail() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const token = searchParams.get('token')
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [requiresPassword, setRequiresPassword] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
username: '',
|
||||||
|
firstName: ''
|
||||||
|
})
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setError('Токен не указан')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkToken = async () => {
|
||||||
|
try {
|
||||||
|
const result = await verifyMagicLink(token)
|
||||||
|
if (result.requiresPassword) {
|
||||||
|
setRequiresPassword(true)
|
||||||
|
} else {
|
||||||
|
// Уже авторизован, перенаправляем
|
||||||
|
window.location.href = '/feed'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Неверная или устаревшая ссылка')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkToken()
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if (!formData.password || formData.password.length < 8) {
|
||||||
|
setError('Пароль должен быть не менее 8 символов')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
setError('Пароли не совпадают')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.username || formData.username.trim().length < 3) {
|
||||||
|
setError('Юзернейм должен быть не менее 3 символов')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.firstName || formData.firstName.trim().length < 1) {
|
||||||
|
setError('Никнейм обязателен')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true)
|
||||||
|
await setPassword(
|
||||||
|
token,
|
||||||
|
formData.password,
|
||||||
|
formData.username.trim().toLowerCase(),
|
||||||
|
formData.firstName.trim()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Успешная регистрация, перенаправляем
|
||||||
|
window.location.href = '/feed'
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Ошибка регистрации')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="verify-email-page">
|
||||||
|
<div className="verify-email-container">
|
||||||
|
<div className="spinner" />
|
||||||
|
<p>Проверка ссылки...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !requiresPassword) {
|
||||||
|
return (
|
||||||
|
<div className="verify-email-page">
|
||||||
|
<div className="verify-email-container">
|
||||||
|
<div className="error-state">
|
||||||
|
<h2>Ошибка</h2>
|
||||||
|
<p>{error}</p>
|
||||||
|
<button className="btn-primary" onClick={() => navigate('/feed')}>
|
||||||
|
На главную
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresPassword) {
|
||||||
|
return (
|
||||||
|
<div className="verify-email-page">
|
||||||
|
<div className="verify-email-container">
|
||||||
|
<div className="verify-email-card">
|
||||||
|
<h2>Завершение регистрации</h2>
|
||||||
|
<p className="verify-email-subtitle">Установите пароль и заполните данные профиля</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Юзернейм *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={e => {
|
||||||
|
setFormData({ ...formData, username: e.target.value.replace(/[^a-z0-9_]/gi, '').toLowerCase() })
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
<p className="form-hint">Только латинские буквы, цифры и _. Нельзя изменить после регистрации</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Никнейм *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Ваше имя"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={e => {
|
||||||
|
setFormData({ ...formData, firstName: e.target.value })
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
required
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
<p className="form-hint">Можно изменить в настройках профиля</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Пароль *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Минимум 8 символов"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={e => {
|
||||||
|
setFormData({ ...formData, password: e.target.value })
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
maxLength={24}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Подтвердите пароль *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Повторите пароль"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={e => {
|
||||||
|
setFormData({ ...formData, confirmPassword: e.target.value })
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={submitting || !formData.password || !formData.username || !formData.firstName}
|
||||||
|
>
|
||||||
|
{submitting ? 'Регистрация...' : 'Завершить регистрацию'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
// API URL из переменных окружения
|
// API URL из переменных окружения
|
||||||
const API_URL = import.meta.env.VITE_API_URL || (
|
// Если frontend и API на разных доменах, используем абсолютный URL
|
||||||
import.meta.env.DEV
|
const API_URL = import.meta.env.DEV
|
||||||
? 'http://localhost:3000/api'
|
? (import.meta.env.VITE_API_URL || 'http://localhost:3000/api')
|
||||||
: '/api' // Для production используем относительный путь
|
: (import.meta.env.VITE_API_URL || 'https://nkm.guru/api') // Для production используем новый домен API
|
||||||
)
|
|
||||||
|
|
||||||
// Создать инстанс axios с настройками
|
// Создать инстанс axios с настройками
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
|
|
@ -125,8 +124,8 @@ export const verifyMagicLink = async (token) => {
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setPassword = async (token, password, username) => {
|
export const setPassword = async (token, password, username, firstName) => {
|
||||||
const response = await api.post('/auth/magic-link/set-password', { token, password, username })
|
const response = await api.post('/auth/magic-link/set-password', { token, password, username, firstName })
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,6 +223,24 @@ export const unfollowUser = async (userId) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateProfile = async (data) => {
|
export const updateProfile = async (data) => {
|
||||||
|
// Если есть файл аватарки, отправляем как FormData
|
||||||
|
if (data.avatar instanceof File) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('avatar', data.avatar)
|
||||||
|
if (data.bio !== undefined) formData.append('bio', data.bio)
|
||||||
|
if (data.firstName !== undefined) formData.append('firstName', data.firstName)
|
||||||
|
if (data.lastName !== undefined) formData.append('lastName', data.lastName)
|
||||||
|
if (data.settings) formData.append('settings', JSON.stringify(data.settings))
|
||||||
|
|
||||||
|
const response = await api.put('/users/profile', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычный JSON запрос
|
||||||
const response = await api.put('/users/profile', data)
|
const response = await api.put('/users/profile', data)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue