Update files

This commit is contained in:
glpshchn 2026-01-01 22:39:12 +03:00
parent 200146fe4e
commit 85bc6a1ad9
24 changed files with 1370 additions and 69 deletions

101
CROSS_DOMAIN_SETUP.md Normal file
View File

@ -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 будет проксировать запросы на новый домен.

102
EMAIL_SETUP.md Normal file
View File

@ -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
```

64
REBUILD_FRONTEND.md Normal file
View File

@ -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
```

View File

@ -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
const authenticateModerationFlexible = async (req, res, next) => {
// Попробовать Telegram авторизацию
@ -400,6 +522,7 @@ const authenticateModerationFlexible = async (req, res, next) => {
};
module.exports = {
authenticateOptional,
authenticate,
authenticateModeration,
authenticateJWT,

View File

@ -13,7 +13,7 @@ const helmetConfig = helmet({
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'", "https://telegram.org", "'unsafe-inline'"],
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:"],
objectSrc: ["'none'"],
// Запретить использование консоли и eval

View File

@ -108,6 +108,11 @@ const UserSchema = new mongoose.Schema({
// Magic-link токены для авторизации
magicLinkToken: String,
magicLinkExpires: Date,
// Onboarding - отслеживание показа приветствия
onboardingCompleted: {
type: Boolean,
default: false
},
createdAt: {
type: Date,
default: Date.now

View File

@ -59,7 +59,8 @@ const respondWithUser = async (user, res) => {
following: populatedUser.following,
tickets: populatedUser.tickets || 0,
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
router.post('/magic-link/set-password', async (req, res) => {
try {
const { token, password, username } = req.body;
const { token, password, username, firstName } = req.body;
if (!token || !password) {
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 символов' });
}
// Валидация 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({
magicLinkToken: token,
@ -486,8 +501,14 @@ router.post('/magic-link/set-password', async (req, res) => {
user.magicLinkExpires = undefined;
user.lastActiveAt = new Date();
if (username && !user.username) {
user.username = username;
// Устанавливаем username (нельзя менять после регистрации)
if (username) {
user.username = username.trim().toLowerCase();
}
// Устанавливаем firstName (никнейм, можно менять)
if (firstName) {
user.firstName = firstName.trim();
}
await user.save();

View File

@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const { authenticate } = require('../middleware/auth');
const { authenticate, authenticateOptional } = require('../middleware/auth');
const { postCreationLimiter, interactionLimiter } = require('../middleware/rateLimiter');
const { searchLimiter } = require('../middleware/rateLimiter');
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 {
const { page = 1, limit = 20, tag, userId, filter = 'all' } = req.query;
const query = {};
const isGuest = req.user?.isGuest;
// Фильтр по тегу
if (tag) {
@ -63,7 +64,7 @@ router.get('/', authenticate, async (req, res) => {
}
// Фильтры: 'all', 'interests', 'following'
if (filter === 'interests') {
if (filter === 'interests' && !isGuest) {
// Лента по интересам - посты с тегами из preferredTags пользователя
const user = await User.findById(req.user._id).select('preferredTags');
if (user.preferredTags && user.preferredTags.length > 0) {
@ -76,7 +77,7 @@ router.get('/', authenticate, async (req, res) => {
currentPage: page
});
}
} else if (filter === 'following') {
} else if (filter === 'following' && !isGuest) {
// Лента подписок - посты от пользователей, на которых подписан
const user = await User.findById(req.user._id).select('following');
if (user.following && user.following.length > 0) {
@ -90,13 +91,14 @@ router.get('/', authenticate, async (req, res) => {
});
}
}
// Для гостей или filter === 'all' - показываем все посты без дополнительных фильтров
// 'all' - все посты, без дополнительных фильтров
// Применить whitelist настройки пользователя (только NSFW и Homo)
if (req.user.settings.whitelist.noNSFW) {
if (req.user?.settings?.whitelist?.noNSFW) {
query.isNSFW = false;
}
if (req.user.settings.whitelist.noHomo) {
if (req.user?.settings?.whitelist?.noHomo) {
// Скрывать только посты, помеченные как гомосексуальные.
// Посты без флага (старые) остаются видимыми.
query.isHomo = { $ne: true };

View File

@ -1,6 +1,7 @@
const express = require('express');
const router = express.Router();
const { authenticate } = require('../middleware/auth');
const { uploadAvatar } = require('../middleware/upload');
const User = require('../models/User');
const Post = require('../models/Post');
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 {
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) {
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) {
req.user.settings = req.user.settings || {};

View File

@ -12,6 +12,18 @@ const initializeEmailService = () => {
const emailProvider = process.env.EMAIL_PROVIDER || 'aws'; // aws, yandex, smtp
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 validAWSRegions = [
'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);
const sesConfig = {
accessKeyId: config.email.aws.accessKeyId,
secretAccessKey: config.email.aws.secretAccessKey,
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey,
region: awsRegion
};
@ -38,17 +50,15 @@ const initializeEmailService = () => {
sesConfig.isYandexCloud = true;
console.log(`[Email] Используется Yandex Cloud Postbox с endpoint: ${endpointUrl}`);
// Не создаем SES клиент для Yandex Cloud, будем использовать прямые HTTP запросы
sesClient = { config: sesConfig, isYandexCloud: true };
} else if (!validAWSRegions.includes(awsRegion)) {
console.warn(`[Email] Невалидный регион AWS SES: ${awsRegion}. Используется us-east-1`);
sesConfig.region = 'us-east-1';
sesClient = new AWS.SES(sesConfig);
console.log('[Email] ✅ AWS SES клиент инициализирован');
} else {
sesClient = new AWS.SES(sesConfig);
}
// Сохраняем конфигурацию для Yandex Cloud
if (endpointUrl) {
sesClient = { config: sesConfig, isYandexCloud: true };
console.log('[Email] ✅ AWS SES клиент инициализирован');
}
} else if (emailProvider === 'yandex' || emailProvider === '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 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
if (sesClient.isYandexCloud) {
// Yandex Cloud Postbox использует SESv2 API - используем прямой HTTP запрос
@ -246,7 +261,11 @@ const sendEmail = async (to, subject, html, text) => {
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 используйте пароль приложения, а не основной пароль.');
} else if (error.code === 'ECONNECTION') {
throw new Error('Не удалось подключиться к SMTP серверу. Проверьте YANDEX_SMTP_HOST и YANDEX_SMTP_PORT.');

View File

@ -16,6 +16,7 @@ import Profile from './pages/Profile'
import UserProfile from './pages/UserProfile'
import CommentsPage from './pages/CommentsPage'
import PostMenuPage from './pages/PostMenuPage'
import VerifyEmail from './pages/VerifyEmail'
import MiniPlayer from './components/MiniPlayer'
import FullPlayer from './components/FullPlayer'
import './styles/index.css'
@ -222,7 +223,7 @@ function AppContent() {
<Routes>
<Route path="/" element={<Layout user={user} />}>
<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/furry" element={<MediaFurry 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/menu" element={<PostMenuPage user={user} />} />
</Route>
<Route path="auth/verify" element={<VerifyEmail />} />
</Routes>
)
}

View File

@ -141,7 +141,9 @@ export default function FullPlayer() {
<img
src={currentTrack.coverImage.startsWith('http')
? 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}
onError={(e) => {

View File

@ -48,7 +48,9 @@ export default function MiniPlayer() {
<img
src={currentTrack.coverImage.startsWith('http')
? 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}
onError={(e) => {

View File

@ -27,7 +27,9 @@ export default function MusicAttachment({ track, onRemove, showRemove = false })
<img
src={track.coverImage.startsWith('http')
? 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}
onError={(e) => {

View File

@ -64,7 +64,9 @@ export default function MusicPickerModal({ onClose, onSelect }) {
<img
src={track.coverImage.startsWith('http')
? 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}
onError={(e) => {

View File

@ -113,8 +113,10 @@ export const MusicPlayerProvider = ({ children }) => {
// Если относительный URL (локальное хранилище), добавить базовый URL
if (audioUrl.startsWith('/uploads/')) {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'
audioUrl = apiUrl.replace('/api', '') + audioUrl
const baseUrl = 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', '')
audioUrl = baseUrl + audioUrl
}
// Убедиться что URL валидный

View File

@ -30,12 +30,39 @@
align-items: center;
justify-content: center;
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 {
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 {
background: #FFFFFF;
color: #000000;
@ -45,6 +72,11 @@
stroke: #000000;
}
[data-theme="dark"] .create-btn.auth-btn {
background: var(--button-accent);
color: white;
}
.feed-filters {
display: flex;
gap: 8px;

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
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 CreatePostModal from '../components/CreatePostModal'
import OnboardingPost from '../components/OnboardingPost'
@ -9,7 +9,7 @@ import { Plus, Settings } from 'lucide-react'
import { hapticFeedback } from '../utils/telegram'
import './Feed.css'
export default function Feed({ user }) {
export default function Feed({ user, setUser }) {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [posts, setPosts] = useState([])
@ -21,12 +21,22 @@ export default function Feed({ user }) {
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [highlightPostId, setHighlightPostId] = useState(null)
// Проверяем, показывалось ли приветствие ранее - только 1 раз (из БД)
const [onboardingVisible, setOnboardingVisible] = useState({
welcome: true,
tags: true,
media: true
welcome: !user?.onboardingCompleted && (user?.isGuest || !user?.onboardingCompleted),
tags: false, // Удаляем второй пост
media: false
})
useEffect(() => {
// Обновляем onboarding при изменении user (из БД)
setOnboardingVisible({
welcome: !user?.onboardingCompleted && (user?.isGuest || !user?.onboardingCompleted),
tags: false,
media: false
})
}, [user])
useEffect(() => {
// Проверить параметр post в URL
const postId = searchParams.get('post')
@ -155,7 +165,7 @@ export default function Feed({ user }) {
setShowCreateModal(false)
}
const handleOnboardingAction = (type) => {
const handleOnboardingAction = async (type) => {
hapticFeedback('light')
if (type === 'welcome' || type === 'tags') {
@ -164,18 +174,39 @@ export default function Feed({ user }) {
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 }))
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')
// Сохраняем в БД, если пользователь авторизован
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 }))
const dismissed = JSON.parse(localStorage.getItem('onboarding_dismissed') || '{}')
dismissed[type] = true
localStorage.setItem('onboarding_dismissed', JSON.stringify(dismissed))
}
const handleLoadMore = () => {
@ -189,9 +220,23 @@ export default function Feed({ user }) {
{/* Хедер */}
<div className="feed-header">
<h1>Nakama</h1>
<button className="create-btn" onClick={handleCreatePost}>
<Plus size={20} />
</button>
{user?.isGuest ? (
<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>
{/* Фильтры */}
@ -227,7 +272,7 @@ export default function Feed({ user }) {
{/* Посты */}
<div className="feed-content">
{/* Onboarding посты для новых пользователей и гостей */}
{/* Onboarding пост "Добро пожаловать" - показывается только 1 раз */}
{(user?.isGuest || !user?.onboarding_completed) && onboardingVisible.welcome && (
<OnboardingPost
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 ? (
<div className="loading-state">
<div className="spinner" />

View File

@ -224,7 +224,9 @@ export default function MediaMusic({ user }) {
<img
src={track.coverImage.startsWith('http')
? 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}
onError={(e) => {

View File

@ -746,3 +746,255 @@
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);
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import { Settings, Heart, Edit2, Shield, UserPlus, Copy, Info, Tag, X } from 'lucide-react'
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 ThemeToggle from '../components/ThemeToggle'
import FollowListModal from '../components/FollowListModal'
@ -66,6 +66,39 @@ export default function Profile({ user, setUser }) {
// Для привязки email
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 () => {
try {
@ -331,11 +364,45 @@ export default function Profile({ user, setUser }) {
{/* Информация о пользователе */}
<div className="profile-info card">
<img
src={user.photoUrl || '/default-avatar.png'}
alt={user.username || user.firstName || 'User'}
className="profile-avatar"
/>
<div className="avatar-wrapper">
<img
src={user.photoUrl || '/default-avatar.png'}
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">
<h2 className="profile-name">
@ -812,6 +879,79 @@ export default function Profile({ user, setUser }) {
</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>
)
}

View File

@ -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;
}

View File

@ -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
}

View File

@ -1,11 +1,10 @@
import axios from 'axios'
// API URL из переменных окружения
const API_URL = import.meta.env.VITE_API_URL || (
import.meta.env.DEV
? 'http://localhost:3000/api'
: '/api' // Для production используем относительный путь
)
// Если frontend и API на разных доменах, используем абсолютный URL
const API_URL = import.meta.env.DEV
? (import.meta.env.VITE_API_URL || 'http://localhost:3000/api')
: (import.meta.env.VITE_API_URL || 'https://nkm.guru/api') // Для production используем новый домен API
// Создать инстанс axios с настройками
const api = axios.create({
@ -125,8 +124,8 @@ export const verifyMagicLink = async (token) => {
return response.data
}
export const setPassword = async (token, password, username) => {
const response = await api.post('/auth/magic-link/set-password', { token, password, username })
export const setPassword = async (token, password, username, firstName) => {
const response = await api.post('/auth/magic-link/set-password', { token, password, username, firstName })
return response.data
}
@ -224,6 +223,24 @@ export const unfollowUser = async (userId) => {
}
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)
return response.data
}