первый коммит
1
.env
Normal file
@ -0,0 +1 @@
|
|||||||
|
JWT_SECRET=djfyfgfthdyxsdrreryrdtufgjyhjfgxghddskfhadkghdsfgsadpioadshjgdifghjzdlczsdflsikdjfsdigjsdvlkjcfghdufghsdjkfugsdfkjsxbnksdfhsdgk
|
6
.env.development
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Переменная окружения для локальной разработки фронтенда
|
||||||
|
# Укажи здесь тот URL, по которому твой бэкенд доступен ЛОКАЛЬНО из Docker Compose
|
||||||
|
# Например, если в docker-compose.yml для backend: ports: - "5001:5000"
|
||||||
|
VITE_API_BASE_URL=http://localhost:5000/api
|
||||||
|
# Или если ports: - "5000:5000"
|
||||||
|
# VITE_API_BASE_URL=http://localhost:5000/api
|
2
.env.production
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Переменная окружения для сборки фронтенда в продакшене
|
||||||
|
VITE_API_BASE_URL=https://goreflex.ru/api
|
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
30
Dockerfile
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# --- Стадия сборки ---
|
||||||
|
FROM node:18-alpine AS build-stage
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем package.json и package-lock.json (или yarn.lock)
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Устанавливаем зависимости
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Копируем остальные файлы приложения
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Собираем приложение для продакшена
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Стадия запуска (сервирование статики с помощью Nginx) ---
|
||||||
|
FROM nginx:stable-alpine AS production-stage
|
||||||
|
|
||||||
|
# Копируем собранные файлы из стадии сборки в директорию Nginx
|
||||||
|
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# (Опционально) Копируем кастомную конфигурацию Nginx, если нужна
|
||||||
|
# COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Открываем порт 80, на котором Nginx слушает по умолчанию
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Команда для запуска Nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
5
README.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
5
backend/.dockerignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
Dockerfile
|
||||||
|
.git
|
||||||
|
.gitignore
|
2
backend/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
29
backend/Dockerfile
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Используем базовый образ Node.js (выбери версию, совместимую с твоим проектом, 18-alpine - хороший выбор)
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# Устанавливаем рабочую директорию в контейнере
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Копируем package.json и package-lock.json (или yarn.lock)
|
||||||
|
# Это делается отдельно, чтобы воспользоваться кэшированием слоев Docker:
|
||||||
|
# если зависимости не изменились, Docker не будет переустанавливать их каждый раз.
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Устанавливаем зависимости приложения
|
||||||
|
RUN npm install --only=production # Устанавливаем только production зависимости
|
||||||
|
# Если у тебя есть devDependencies, которые нужны для запуска (маловероятно для простого Express),
|
||||||
|
# то используй просто RUN npm install
|
||||||
|
|
||||||
|
# Копируем остальные файлы приложения в рабочую директорию
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Переменная окружения для порта (можно переопределить в docker-compose.yml)
|
||||||
|
ENV PORT=5000
|
||||||
|
|
||||||
|
# Открываем порт, на котором будет работать приложение
|
||||||
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
|
# Команда для запуска приложения
|
||||||
|
CMD [ "node", "server.js" ]
|
||||||
|
# Или если ты хочешь использовать скрипт из package.json для запуска в проде:
|
||||||
|
# CMD [ "npm", "start" ]
|
14
backend/config/cloudinaryConfig.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// backend/config/cloudinaryConfig.js
|
||||||
|
const cloudinary = require('cloudinary').v2; // Используем v2 API
|
||||||
|
const dotenv = require('dotenv');
|
||||||
|
|
||||||
|
dotenv.config(); // Чтобы process.env был доступен здесь
|
||||||
|
|
||||||
|
cloudinary.config({
|
||||||
|
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
|
||||||
|
api_key: process.env.CLOUDINARY_API_KEY,
|
||||||
|
api_secret: process.env.CLOUDINARY_API_SECRET,
|
||||||
|
secure: true, // Использовать HTTPS для URL
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = cloudinary;
|
80
backend/config/controllers/authController.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
const User = require('../models/User'); // Импортируем нашу модель пользователя
|
||||||
|
// const bcrypt = require('bcryptjs'); // bcryptjs уже используется внутри модели User.js, здесь он напрямую не нужен для регистрации
|
||||||
|
// const jwt = require('jsonwebtoken'); // Понадобится позже для генерации токена
|
||||||
|
|
||||||
|
// @desc Регистрация нового пользователя
|
||||||
|
// @route POST /api/auth/register
|
||||||
|
// @access Public
|
||||||
|
const registerUser = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, email, password, dateOfBirth, gender } = req.body; // Получаем данные из тела запроса
|
||||||
|
|
||||||
|
// 1. Простая валидация на сервере (дополнительно к валидации Mongoose)
|
||||||
|
if (!name || !email || !password) {
|
||||||
|
res.status(400); // Bad Request
|
||||||
|
// Используем Error, чтобы передать его в наш будущий обработчик ошибок
|
||||||
|
throw new Error('Пожалуйста, заполните все обязательные поля: имя, email и пароль.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Проверка, не существует ли уже пользователь с таким email
|
||||||
|
const userExists = await User.findOne({ email: email.toLowerCase() });
|
||||||
|
|
||||||
|
if (userExists) {
|
||||||
|
res.status(400); // Bad Request
|
||||||
|
throw new Error('Пользователь с таким email уже существует.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Создание нового пользователя
|
||||||
|
// Пароль будет автоматически хеширован благодаря middleware в модели User.js
|
||||||
|
const user = await User.create({
|
||||||
|
name,
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
password, // Передаем пароль как есть, модель позаботится о хешировании
|
||||||
|
dateOfBirth, // Эти поля пока опциональны, если их нет в req.body, они будут undefined
|
||||||
|
gender, // и не запишутся, если в схеме нет default значения и они не required
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Если пользователь успешно создан
|
||||||
|
// TODO: Сгенерировать JWT токен и отправить его клиенту
|
||||||
|
|
||||||
|
res.status(201).json({ // 201 Created
|
||||||
|
_id: user._id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
// token: generateToken(user._id) // Раскомментируем, когда будет функция generateToken
|
||||||
|
message: 'Пользователь успешно зарегистрирован!' // Временное сообщение
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400); // Bad Request
|
||||||
|
throw new Error('Неверные данные пользователя. Не удалось создать пользователя.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Логируем ошибку на сервере для отладки
|
||||||
|
console.error('Ошибка при регистрации:', error.message);
|
||||||
|
// Если статус не был установлен ранее (например, при ошибке валидации Mongoose)
|
||||||
|
if (!res.statusCode || res.statusCode < 400) {
|
||||||
|
res.status(400); // По умолчанию Bad Request для ошибок регистрации
|
||||||
|
}
|
||||||
|
// Передаем ошибку дальше, чтобы ее мог обработать централизованный обработчик ошибок (если он есть)
|
||||||
|
// или Express обработает ее по умолчанию.
|
||||||
|
// Мы пока будем просто отправлять сообщение ошибки.
|
||||||
|
res.json({ message: error.message, stack: process.env.NODE_ENV === 'production' ? null : error.stack });
|
||||||
|
// next(error); // Это для централизованного обработчика ошибок, который мы можем добавить позже
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: Функция для входа пользователя (loginUser)
|
||||||
|
// const loginUser = async (req, res, next) => { ... };
|
||||||
|
|
||||||
|
// TODO: Функция для получения информации о текущем пользователе (getMe)
|
||||||
|
// const getMe = async (req, res, next) => { ... };
|
||||||
|
|
||||||
|
|
||||||
|
// Экспортируем функции контроллера
|
||||||
|
module.exports = {
|
||||||
|
registerUser,
|
||||||
|
// loginUser, // Раскомментируем позже
|
||||||
|
// getMe, // Раскомментируем позже
|
||||||
|
};
|
51
backend/config/db.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
// Переопределяем функцию подключения к БД
|
||||||
|
const connectDB = async () => {
|
||||||
|
try {
|
||||||
|
// Убедись, что process.env.MONGO_URI действительно загружается из .env
|
||||||
|
if (!process.env.MONGO_URI) {
|
||||||
|
console.error('Ошибка: MONGO_URI не определена в .env файле!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем URI и проверяем имя базы данных
|
||||||
|
let mongoURI = process.env.MONGO_URI;
|
||||||
|
|
||||||
|
// Проверяем, содержит ли URI имя базы данных
|
||||||
|
const uriParts = mongoURI.split('/');
|
||||||
|
const lastPart = uriParts[uriParts.length - 1];
|
||||||
|
|
||||||
|
// Логируем для отладки (можно удалить потом)
|
||||||
|
console.log('Имя базы данных из URI:', lastPart.split('?')[0]);
|
||||||
|
|
||||||
|
// Проверяем, соответствует ли имя базы данных ожидаемому
|
||||||
|
// Если имя не соответствует нужному, можно принудительно заменить
|
||||||
|
const expectedDBName = 'dating-app'; // Используем одно имя везде
|
||||||
|
|
||||||
|
if (lastPart.split('?')[0] !== expectedDBName) {
|
||||||
|
console.log(`Внимание: имя базы данных в URI (${lastPart.split('?')[0]}) отличается от ожидаемого (${expectedDBName})`);
|
||||||
|
// Если в URI нет параметров после имени
|
||||||
|
if (!lastPart.includes('?')) {
|
||||||
|
uriParts[uriParts.length - 1] = expectedDBName;
|
||||||
|
} else {
|
||||||
|
// Если есть параметры после имени базы данных
|
||||||
|
const params = lastPart.split('?')[1];
|
||||||
|
uriParts[uriParts.length - 1] = `${expectedDBName}?${params}`;
|
||||||
|
}
|
||||||
|
mongoURI = uriParts.join('/');
|
||||||
|
console.log('URI подключения был изменен для использования базы данных:', expectedDBName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = await mongoose.connect(mongoURI);
|
||||||
|
|
||||||
|
console.log(`MongoDB подключена: ${conn.connection.host}`); // Эта строка тоже важна
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Ошибка подключения к MongoDB: ${error.message}`);
|
||||||
|
process.exit(1); // Эта строка может быть причиной, если есть ошибка подключения
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Экспортируем функцию явным образом
|
||||||
|
module.exports = connectDB;
|
||||||
|
console.log('Модуль db.js загружен, тип экспортируемого объекта:', typeof module.exports);
|
165
backend/controllers/actionController.js
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
const User = require('../models/User'); // Нам нужна модель User
|
||||||
|
const Conversation = require('../models/Conversation'); // Добавляем модель Conversation
|
||||||
|
|
||||||
|
// @desc Лайкнуть пользователя
|
||||||
|
// @route POST /api/actions/like/:userId
|
||||||
|
// @access Private
|
||||||
|
const likeUser = async (req, res, next) => {
|
||||||
|
const likedUserId = req.params.userId; // ID пользователя, которого лайкают
|
||||||
|
const currentUserId = req.user._id; // ID текущего пользователя (из protect middleware)
|
||||||
|
|
||||||
|
console.log(`[ACTION_CTRL] Пользователь ${currentUserId} лайкает пользователя ${likedUserId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 0. Проверка, не лайкает ли пользователь сам себя
|
||||||
|
if (currentUserId.equals(likedUserId)) {
|
||||||
|
const error = new Error('Вы не можете лайкнуть самого себя.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Найти обоих пользователей
|
||||||
|
const currentUser = await User.findById(currentUserId);
|
||||||
|
const likedUser = await User.findById(likedUserId);
|
||||||
|
|
||||||
|
if (!currentUser || !likedUser) {
|
||||||
|
const error = new Error('Один из пользователей не найден.');
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Проверить, не был ли этот пользователь уже лайкнут или пропущен текущим пользователем
|
||||||
|
if (currentUser.liked.includes(likedUserId)) {
|
||||||
|
console.log(`[ACTION_CTRL] Пользователь ${likedUserId} уже был лайкнут пользователем ${currentUserId}`);
|
||||||
|
// Можно вернуть информацию о существующем лайке или просто успешный ответ
|
||||||
|
return res.status(200).json({ message: 'Пользователь уже был лайкнут ранее.', isMatch: currentUser.matches.includes(likedUserId) });
|
||||||
|
}
|
||||||
|
if (currentUser.passed.includes(likedUserId)) {
|
||||||
|
// Если пользователь был ранее пропущен, убираем его из passed и продолжаем как обычный лайк
|
||||||
|
currentUser.passed.pull(likedUserId);
|
||||||
|
console.log(`[ACTION_CTRL] Пользователь ${likedUserId} был ранее пропущен, теперь лайкнут пользователем ${currentUserId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Добавить likedUserId в массив 'liked' текущего пользователя
|
||||||
|
currentUser.liked.addToSet(likedUserId); // addToSet добавляет, только если элемента еще нет
|
||||||
|
|
||||||
|
// 4. Проверить на взаимный лайк (мэтч)
|
||||||
|
let isMatch = false;
|
||||||
|
let conversationId = null; // Инициализируем conversationId
|
||||||
|
// Проверяем, лайкал ли 'likedUser' текущего пользователя ('currentUser')
|
||||||
|
if (likedUser.liked.includes(currentUserId)) {
|
||||||
|
isMatch = true;
|
||||||
|
// Если мэтч, добавляем друг друга в массивы 'matches'
|
||||||
|
currentUser.matches.addToSet(likedUserId);
|
||||||
|
likedUser.matches.addToSet(currentUserId);
|
||||||
|
await likedUser.save(); // Сохраняем изменения для likedUser
|
||||||
|
console.log(`[ACTION_CTRL] !!! МЭТЧ !!! между ${currentUserId} и ${likedUserId}`);
|
||||||
|
|
||||||
|
// Автоматическое создание или получение диалога при мэтче
|
||||||
|
try {
|
||||||
|
let conversation = await Conversation.findOne({
|
||||||
|
participants: { $all: [currentUserId, likedUserId] }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
console.log(`[ACTION_CTRL] Создание нового диалога для мэтча между ${currentUserId} и ${likedUserId}`);
|
||||||
|
conversation = new Conversation({
|
||||||
|
participants: [currentUserId, likedUserId]
|
||||||
|
});
|
||||||
|
await conversation.save();
|
||||||
|
console.log(`[ACTION_CTRL] Новый диалог создан с ID: ${conversation._id}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[ACTION_CTRL] Диалог для мэтча между ${currentUserId} и ${likedUserId} уже существует: ${conversation._id}`);
|
||||||
|
}
|
||||||
|
conversationId = conversation._id; // Сохраняем ID диалога
|
||||||
|
} catch (convError) {
|
||||||
|
console.error(`[ACTION_CTRL] Ошибка при создании/получении диалога для мэтча:`, convError.message);
|
||||||
|
// Не прерываем основной ответ из-за ошибки создания чата,
|
||||||
|
// но логируем. В реальном приложении здесь могла бы быть
|
||||||
|
// более сложная обработка или уведомление администратора.
|
||||||
|
}
|
||||||
|
// TODO: Здесь можно будет добавить логику для отправки уведомления о мэтче
|
||||||
|
}
|
||||||
|
|
||||||
|
await currentUser.save(); // Сохраняем изменения для currentUser
|
||||||
|
|
||||||
|
const responseJson = {
|
||||||
|
message: isMatch ? 'Это мэтч! Вы понравились друг другу!' : 'Лайк успешно поставлен.',
|
||||||
|
isMatch: isMatch,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isMatch && conversationId) {
|
||||||
|
responseJson.conversationId = conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(responseJson);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[ACTION_CTRL] Ошибка при лайке пользователя ${likedUserId} пользователем ${currentUserId}:`, error.message);
|
||||||
|
// Если statusCode не установлен ошибкой, errorHandler поставит 500
|
||||||
|
// Для ошибок типа "не найден" мы уже ставим 404
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// @desc Пропустить/дизлайкнуть пользователя
|
||||||
|
// @route POST /api/actions/pass/:userId
|
||||||
|
// @access Private
|
||||||
|
const passUser = async (req, res, next) => {
|
||||||
|
const passedUserId = req.params.userId; // ID пользователя, которого пропускают
|
||||||
|
const currentUserId = req.user._id; // ID текущего пользователя
|
||||||
|
|
||||||
|
console.log(`[ACTION_CTRL] Пользователь ${currentUserId} пропускает пользователя ${passedUserId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 0. Проверка, не пропускает ли пользователь сам себя
|
||||||
|
if (currentUserId.equals(passedUserId)) {
|
||||||
|
const error = new Error('Вы не можете пропустить самого себя.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await User.findById(currentUserId);
|
||||||
|
const userToPass = await User.findById(passedUserId); // Проверяем, что такой пользователь существует
|
||||||
|
|
||||||
|
if (!currentUser || !userToPass) {
|
||||||
|
const error = new Error('Один из пользователей не найден.');
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Добавить passedUserId в массив 'passed' текущего пользователя
|
||||||
|
currentUser.passed.addToSet(passedUserId);
|
||||||
|
|
||||||
|
// 2. Если этот пользователь был ранее лайкнут, убрать его из лайков
|
||||||
|
// (на случай, если пользователь передумал, хотя UI обычно не дает так сделать без "анлайка")
|
||||||
|
if (currentUser.liked.includes(passedUserId)) {
|
||||||
|
currentUser.liked.pull(passedUserId);
|
||||||
|
// Также нужно проверить, не был ли это мэтч, и если да, то убрать из мэтчей у обоих
|
||||||
|
if (currentUser.matches.includes(passedUserId)) {
|
||||||
|
currentUser.matches.pull(passedUserId);
|
||||||
|
userToPass.matches.pull(currentUserId);
|
||||||
|
await userToPass.save(); // Сохраняем изменения для другого пользователя
|
||||||
|
console.log(`[ACTION_CTRL] Мэтч между ${currentUserId} и ${passedUserId} был разорван из-за пропуска.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await currentUser.save();
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
message: 'Пользователь успешно пропущен.',
|
||||||
|
// passed: currentUser.passed
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[ACTION_CTRL] Ошибка при пропуске пользователя ${passedUserId} пользователем ${currentUserId}:`, error.message);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
likeUser,
|
||||||
|
passUser,
|
||||||
|
};
|
165
backend/controllers/authController.js
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
// Контроллер аутентификации
|
||||||
|
const User = require('../models/User');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
// Вспомогательная функция для генерации JWT
|
||||||
|
const generateToken = (id) => {
|
||||||
|
return jwt.sign({ id }, process.env.JWT_SECRET, {
|
||||||
|
expiresIn: '30d',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Регистрация нового пользователя
|
||||||
|
// @route POST /api/auth/register
|
||||||
|
// @access Public
|
||||||
|
const registerUser = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, email, password, dateOfBirth, gender } = req.body;
|
||||||
|
|
||||||
|
console.log('Начало регистрации пользователя с email:', email);
|
||||||
|
|
||||||
|
if (!name || !email || !password) {
|
||||||
|
res.status(400);
|
||||||
|
throw new Error('Пожалуйста, заполните все обязательные поля: имя, email и пароль.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
console.log('Нормализованный email для поиска:', normalizedEmail);
|
||||||
|
|
||||||
|
const userExists = await User.findOne({ email: normalizedEmail });
|
||||||
|
|
||||||
|
if (userExists) {
|
||||||
|
console.log('Пользователь с таким email уже существует');
|
||||||
|
res.status(400);
|
||||||
|
throw new Error('Пользователь с таким email уже существует.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Создание нового пользователя...');
|
||||||
|
const user = await User.create({
|
||||||
|
name,
|
||||||
|
email: normalizedEmail,
|
||||||
|
password,
|
||||||
|
dateOfBirth,
|
||||||
|
gender,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Пользователь создан успешно:', user ? 'Да' : 'Нет', 'ID:', user ? user._id : 'N/A');
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
res.status(201).json({
|
||||||
|
_id: user._id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
token: generateToken(user._id),
|
||||||
|
message: 'Пользователь успешно зарегистрирован!'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400);
|
||||||
|
throw new Error('Неверные данные пользователя. Не удалось создать пользователя.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при регистрации:', error.message);
|
||||||
|
if (!res.statusCode || res.statusCode < 400) {
|
||||||
|
res.status(400);
|
||||||
|
}
|
||||||
|
res.json({ message: error.message, stack: process.env.NODE_ENV === 'production' ? null : error.stack });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Аутентификация пользователя и получение токена (Вход)
|
||||||
|
// @route POST /api/auth/login
|
||||||
|
// @access Public
|
||||||
|
const loginUser = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
console.log('Попытка входа с email:', email);
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
res.status(400);
|
||||||
|
throw new Error('Пожалуйста, укажите email и пароль.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
console.log('Нормализованный email для поиска:', normalizedEmail);
|
||||||
|
|
||||||
|
const user = await User.findOne({ email: normalizedEmail }).select('+password');
|
||||||
|
|
||||||
|
console.log('Найден пользователь:', user ? 'Да' : 'Нет');
|
||||||
|
console.log('Пользователь содержит пароль:', user && user.password ? 'Да' : 'Нет');
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log('Пользователь не найден в базе данных');
|
||||||
|
res.status(401);
|
||||||
|
throw new Error('Неверный email или пароль.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.password) {
|
||||||
|
console.error('Ошибка: Пароль не загружен из базы данных');
|
||||||
|
res.status(500);
|
||||||
|
throw new Error('Внутренняя ошибка сервера при проверке учетных данных.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Проверка пароля...');
|
||||||
|
const isMatch = await user.matchPassword(password);
|
||||||
|
console.log('Результат проверки пароля:', isMatch ? 'Успешно' : 'Неверный пароль');
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
res.status(200).json({
|
||||||
|
_id: user._id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
token: generateToken(user._id),
|
||||||
|
message: 'Вход выполнен успешно!'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(401);
|
||||||
|
throw new Error('Неверный email или пароль.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при входе:', error.message);
|
||||||
|
if (!res.statusCode || res.statusCode < 400) {
|
||||||
|
res.status(401);
|
||||||
|
}
|
||||||
|
res.json({ message: error.message, stack: process.env.NODE_ENV === 'production' ? null : error.stack });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Получение данных профиля текущего пользователя
|
||||||
|
// @route GET /api/auth/me
|
||||||
|
// @access Private
|
||||||
|
const getMe = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(404);
|
||||||
|
throw new Error('Пользователь не найден.');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
_id: req.user._id,
|
||||||
|
name: req.user.name,
|
||||||
|
email: req.user.email,
|
||||||
|
dateOfBirth: req.user.dateOfBirth,
|
||||||
|
gender: req.user.gender,
|
||||||
|
bio: req.user.bio,
|
||||||
|
photos: req.user.photos,
|
||||||
|
location: req.user.location,
|
||||||
|
preferences: req.user.preferences,
|
||||||
|
createdAt: req.user.createdAt,
|
||||||
|
updatedAt: req.user.updatedAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при получении данных пользователя:', error.message);
|
||||||
|
if (!res.statusCode || res.statusCode < 400) {
|
||||||
|
res.status(500);
|
||||||
|
}
|
||||||
|
res.json({ message: error.message, stack: process.env.NODE_ENV === 'production' ? null : error.stack });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Экспортируем все функции
|
||||||
|
module.exports = {
|
||||||
|
registerUser,
|
||||||
|
loginUser,
|
||||||
|
getMe
|
||||||
|
};
|
298
backend/controllers/conversationController.js
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
// backend/controllers/conversationController.js
|
||||||
|
const Conversation = require('../models/Conversation');
|
||||||
|
const Message = require('../models/Message');
|
||||||
|
const User = require('../models/User'); // Может понадобиться для populate
|
||||||
|
|
||||||
|
// @desc Создать новый диалог (или найти существующий)
|
||||||
|
// @route POST /api/conversations
|
||||||
|
// @access Private
|
||||||
|
// Этот эндпоинт может быть вызван, например, когда пользователи мэтчатся,
|
||||||
|
// или когда один пользователь хочет начать чат с другим из списка мэтчей.
|
||||||
|
// Мы будем предполагать, что в теле запроса приходит ID другого участника.
|
||||||
|
const createOrGetConversation = async (req, res, next) => {
|
||||||
|
const currentUserId = req.user._id;
|
||||||
|
const { otherUserId } = req.body; // ID другого участника
|
||||||
|
|
||||||
|
if (!otherUserId) {
|
||||||
|
const error = new Error('Не указан ID другого участника (otherUserId).');
|
||||||
|
error.statusCode = 400;
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUserId.equals(otherUserId)) {
|
||||||
|
const error = new Error('Нельзя создать диалог с самим собой.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ищем существующий диалог между этими двумя пользователями
|
||||||
|
// Используем $all, чтобы порядок участников в массиве не имел значения
|
||||||
|
let conversation = await Conversation.findOne({
|
||||||
|
participants: { $all: [currentUserId, otherUserId] }
|
||||||
|
})
|
||||||
|
.populate('participants', 'name photos') // Загружаем немного данных об участниках
|
||||||
|
.populate({ // Загружаем последнее сообщение и данные его отправителя
|
||||||
|
path: 'lastMessage',
|
||||||
|
populate: { path: 'sender', select: 'name photos' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conversation) {
|
||||||
|
console.log(`[CONV_CTRL] Найден существующий диалог: ${conversation._id}`);
|
||||||
|
return res.status(200).json(conversation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если диалога нет, создаем новый
|
||||||
|
console.log(`[CONV_CTRL] Создание нового диалога между ${currentUserId} и ${otherUserId}`);
|
||||||
|
conversation = new Conversation({
|
||||||
|
participants: [currentUserId, otherUserId]
|
||||||
|
});
|
||||||
|
await conversation.save();
|
||||||
|
|
||||||
|
// Загружаем данные после сохранения, чтобы получить _id и populate участников
|
||||||
|
const newConversation = await Conversation.findById(conversation._id)
|
||||||
|
.populate('participants', 'name photos')
|
||||||
|
// lastMessage пока будет null
|
||||||
|
|
||||||
|
res.status(201).json(newConversation);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CONV_CTRL] Ошибка при создании/получении диалога:', error.message);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// @desc Получить все диалоги текущего пользователя
|
||||||
|
// @route GET /api/conversations
|
||||||
|
// @access Private
|
||||||
|
const getUserConversations = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
let conversations = await Conversation.find({ participants: req.user._id })
|
||||||
|
.populate('participants', 'name photos dateOfBirth gender') // Загружаем нужные поля
|
||||||
|
.populate({
|
||||||
|
path: 'lastMessage',
|
||||||
|
populate: { path: 'sender', select: 'name photos _id' } // Добавил _id отправителя и photos
|
||||||
|
})
|
||||||
|
.sort({ updatedAt: -1 }); // Сортируем по последнему обновлению (новое сообщение обновит updatedAt)
|
||||||
|
|
||||||
|
// Добавляем mainPhotoUrl и возраст для участников
|
||||||
|
conversations = conversations.map(conv => {
|
||||||
|
const convObj = conv.toObject(); // Преобразуем в обычный объект для модификации
|
||||||
|
convObj.participants = convObj.participants.map(p => {
|
||||||
|
let mainPhotoUrl = null;
|
||||||
|
if (p.photos && p.photos.length > 0) {
|
||||||
|
const profilePic = p.photos.find(photo => photo.isProfilePhoto === true);
|
||||||
|
mainPhotoUrl = profilePic ? profilePic.url : p.photos[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
let age = null;
|
||||||
|
if (p.dateOfBirth) {
|
||||||
|
const birthDate = new Date(p.dateOfBirth);
|
||||||
|
const today = new Date();
|
||||||
|
age = today.getFullYear() - birthDate.getFullYear();
|
||||||
|
const m = today.getMonth() - birthDate.getMonth();
|
||||||
|
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Удаляем исходные поля photos и dateOfBirth из участника, если они не нужны напрямую клиенту в этом контексте
|
||||||
|
const { photos, dateOfBirth, ...participantRest } = p;
|
||||||
|
return { ...participantRest, mainPhotoUrl, age }; // Возвращаем участника с доп. полями
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработка фото для отправителя последнего сообщения, если оно есть
|
||||||
|
if (convObj.lastMessage && convObj.lastMessage.sender && convObj.lastMessage.sender.photos && convObj.lastMessage.sender.photos.length > 0) {
|
||||||
|
const sender = convObj.lastMessage.sender;
|
||||||
|
let senderMainPhotoUrl = null;
|
||||||
|
const profilePic = sender.photos.find(photo => photo.isProfilePhoto === true);
|
||||||
|
senderMainPhotoUrl = profilePic ? profilePic.url : sender.photos[0].url;
|
||||||
|
|
||||||
|
const { photos, ...senderRest } = sender;
|
||||||
|
convObj.lastMessage.sender = { ...senderRest, mainPhotoUrl: senderMainPhotoUrl };
|
||||||
|
} else if (convObj.lastMessage && convObj.lastMessage.sender) {
|
||||||
|
// Если фото нет, убедимся, что mainPhotoUrl null и photos не передается
|
||||||
|
const { photos, ...senderRest } = convObj.lastMessage.sender;
|
||||||
|
convObj.lastMessage.sender = { ...senderRest, mainPhotoUrl: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return convObj;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json(conversations);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CONV_CTRL] Ошибка при получении диалогов:', error.message);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// @desc Получить сообщения для конкретного диалога (с пагинацией)
|
||||||
|
// @route GET /api/conversations/:conversationId/messages
|
||||||
|
// @access Private
|
||||||
|
const getMessagesForConversation = async (req, res, next) => {
|
||||||
|
const { conversationId } = req.params;
|
||||||
|
const currentUserId = req.user._id;
|
||||||
|
|
||||||
|
// Пагинация (пример)
|
||||||
|
const page = parseInt(req.query.page) || 1;
|
||||||
|
const limit = parseInt(req.query.limit) || 20; // Например, 20 сообщений на страницу
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Проверить, является ли текущий пользователь участником этого диалога
|
||||||
|
// и сразу загрузить участников
|
||||||
|
const conversation = await Conversation.findById(conversationId)
|
||||||
|
.populate('participants', 'name photos dateOfBirth gender'); // Загружаем участников здесь
|
||||||
|
|
||||||
|
if (!conversation || !conversation.participants.some(p => p._id.equals(currentUserId))) {
|
||||||
|
const error = new Error('Диалог не найден или у вас нет к нему доступа.');
|
||||||
|
error.statusCode = 404; // Или 403 Forbidden
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Получить сообщения с пагинацией, отсортированные по дате создания (старые -> новые)
|
||||||
|
// Чтобы отображать в чате снизу вверх, или новые -> старые для отображения сверху вниз
|
||||||
|
const messages = await Message.find({ conversationId: conversationId })
|
||||||
|
.populate('sender', 'name photos _id') // Загружаем данные отправителя, включая _id
|
||||||
|
.sort({ createdAt: -1 }) // Сначала новые, если нужно отображать с конца
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
// Обработка сообщений для добавления mainPhotoUrl отправителю
|
||||||
|
const processedMessages = messages.map(msg => {
|
||||||
|
const msgObj = msg.toObject(); // Преобразуем в обычный объект для модификации
|
||||||
|
if (msgObj.sender && msgObj.sender.photos && msgObj.sender.photos.length > 0) {
|
||||||
|
let senderMainPhotoUrl = null;
|
||||||
|
const profilePic = msgObj.sender.photos.find(photo => photo.isProfilePhoto === true);
|
||||||
|
senderMainPhotoUrl = profilePic ? profilePic.url : msgObj.sender.photos[0].url;
|
||||||
|
msgObj.sender.mainPhotoUrl = senderMainPhotoUrl;
|
||||||
|
} else if (msgObj.sender) {
|
||||||
|
// Если у отправителя нет фото, явно устанавливаем mainPhotoUrl в null
|
||||||
|
msgObj.sender.mainPhotoUrl = null;
|
||||||
|
}
|
||||||
|
return msgObj;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalMessages = await Message.countDocuments({ conversationId: conversationId });
|
||||||
|
|
||||||
|
// Обработка данных диалога для добавления mainPhotoUrl и возраста участникам
|
||||||
|
const convObject = conversation.toObject();
|
||||||
|
convObject.participants = convObject.participants.map(p => {
|
||||||
|
let mainPhotoUrl = null;
|
||||||
|
if (p.photos && p.photos.length > 0) {
|
||||||
|
const profilePic = p.photos.find(photo => photo.isProfilePhoto === true);
|
||||||
|
mainPhotoUrl = profilePic ? profilePic.url : p.photos[0].url;
|
||||||
|
}
|
||||||
|
|
||||||
|
let age = null;
|
||||||
|
if (p.dateOfBirth) {
|
||||||
|
const birthDate = new Date(p.dateOfBirth);
|
||||||
|
const today = new Date();
|
||||||
|
age = today.getFullYear() - birthDate.getFullYear();
|
||||||
|
const m = today.getMonth() - birthDate.getMonth();
|
||||||
|
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { photos, dateOfBirth, ...participantRest } = p; // p здесь это Mongoose документ, нужно p.toObject() или работать с полями напрямую
|
||||||
|
return {
|
||||||
|
_id: p._id,
|
||||||
|
name: p.name,
|
||||||
|
gender: p.gender,
|
||||||
|
mainPhotoUrl,
|
||||||
|
age
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
conversation: convObject, // <--- ВОТ ЗДЕСЬ ДОБАВЛЯЕМ ДАННЫЕ ДИАЛОГА
|
||||||
|
messages: processedMessages.reverse(), // reverse(), чтобы старые были в начале массива (для типичного отображения чата)
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: Math.ceil(totalMessages / limit),
|
||||||
|
totalMessages
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[CONV_CTRL] Ошибка при получении сообщений для диалога ${conversationId}:`, error.message);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @desc Удалить сообщение
|
||||||
|
// @route DELETE /api/conversations/:conversationId/messages/:messageId
|
||||||
|
// @access Private
|
||||||
|
const deleteMessage = async (req, res, next) => {
|
||||||
|
const { conversationId, messageId } = req.params;
|
||||||
|
const currentUserId = req.user._id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conversation = await Conversation.findById(conversationId);
|
||||||
|
if (!conversation) {
|
||||||
|
const error = new Error('Диалог не найден.');
|
||||||
|
error.statusCode = 404;
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conversation.participants.includes(currentUserId)) {
|
||||||
|
const error = new Error('Вы не являетесь участником этого диалога.');
|
||||||
|
error.statusCode = 403;
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await Message.findById(messageId);
|
||||||
|
if (!message) {
|
||||||
|
const error = new Error('Сообщение не найдено.');
|
||||||
|
error.statusCode = 404;
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.sender.equals(currentUserId)) {
|
||||||
|
const error = new Error('Вы можете удалять только свои сообщения.');
|
||||||
|
error.statusCode = 403;
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, является ли удаляемое сообщение последним в диалоге
|
||||||
|
const isLastMessage = conversation.lastMessage && conversation.lastMessage.equals(messageId);
|
||||||
|
|
||||||
|
await Message.findByIdAndDelete(messageId);
|
||||||
|
|
||||||
|
// Если удаленное сообщение было последним, обновить lastMessage в диалоге
|
||||||
|
if (isLastMessage) {
|
||||||
|
const newLastMessage = await Message.findOne({ conversationId })
|
||||||
|
.sort({ createdAt: -1 }); // Находим самое новое сообщение
|
||||||
|
conversation.lastMessage = newLastMessage ? newLastMessage._id : null;
|
||||||
|
await conversation.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Эмитим событие удаления сообщения в комнату диалога
|
||||||
|
// Предполагается, что `io` доступен через `req.app.get('io')`
|
||||||
|
const io = req.app.get('io');
|
||||||
|
if (io) {
|
||||||
|
io.to(conversationId).emit('messageDeleted', {
|
||||||
|
messageId,
|
||||||
|
conversationId,
|
||||||
|
deleterId: currentUserId
|
||||||
|
});
|
||||||
|
console.log(`[CONV_CTRL] Emitted messageDeleted to room ${conversationId} for message ${messageId}`);
|
||||||
|
} else {
|
||||||
|
console.warn('[CONV_CTRL] Socket.io instance (io) not found on req.app. Could not emit messageDeleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ message: 'Сообщение успешно удалено.' });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[CONV_CTRL] Ошибка при удалении сообщения ${messageId} из диалога ${conversationId}:`, error.message);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createOrGetConversation,
|
||||||
|
getUserConversations,
|
||||||
|
getMessagesForConversation,
|
||||||
|
deleteMessage, // <-- Добавляем новый метод
|
||||||
|
};
|
238
backend/controllers/userController.js
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
const User = require('../models/User');
|
||||||
|
const cloudinary = require('../config/cloudinaryConfig'); // Импортируем настроенный Cloudinary SDK
|
||||||
|
const path = require('path'); // Может понадобиться для получения расширения файла
|
||||||
|
|
||||||
|
// @desc Обновить профиль пользователя
|
||||||
|
// @route PUT /api/users/profile
|
||||||
|
// @access Private (пользователь должен быть аутентифицирован)
|
||||||
|
const updateUserProfile = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
console.log('Запрос на обновление профиля от пользователя:', req.user._id);
|
||||||
|
console.log('Данные для обновления:', req.body);
|
||||||
|
|
||||||
|
// req.user должен быть доступен благодаря middleware 'protect'
|
||||||
|
const user = await User.findById(req.user._id);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Обновляем только те поля, которые пользователь может изменять
|
||||||
|
user.name = req.body.name || user.name;
|
||||||
|
|
||||||
|
// Обрабатываем dateOfBirth, учитывая null и пустые строки
|
||||||
|
if (req.body.dateOfBirth === null || req.body.dateOfBirth === '') {
|
||||||
|
user.dateOfBirth = undefined; // Удаляем поле, если оно пустое или null
|
||||||
|
} else if (req.body.dateOfBirth) {
|
||||||
|
user.dateOfBirth = req.body.dateOfBirth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрабатываем gender, учитывая null и пустые строки
|
||||||
|
if (req.body.gender === null || req.body.gender === '') {
|
||||||
|
user.gender = undefined; // Удаляем поле, если оно пустое или null
|
||||||
|
} else if (req.body.gender) {
|
||||||
|
user.gender = req.body.gender;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрабатываем bio, учитывая null и пустые строки
|
||||||
|
if (req.body.bio === null) {
|
||||||
|
user.bio = ''; // Устанавливаем пустую строку, если null
|
||||||
|
} else {
|
||||||
|
user.bio = req.body.bio || user.bio;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление местоположения (если передано)
|
||||||
|
if (req.body.location) {
|
||||||
|
user.location.city = req.body.location.city || user.location.city;
|
||||||
|
user.location.country = req.body.location.country || user.location.country;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление предпочтений (если переданы)
|
||||||
|
if (req.body.preferences) {
|
||||||
|
if (req.body.preferences.gender) {
|
||||||
|
user.preferences.gender = req.body.preferences.gender;
|
||||||
|
}
|
||||||
|
if (req.body.preferences.ageRange) {
|
||||||
|
user.preferences.ageRange.min = req.body.preferences.ageRange.min || user.preferences.ageRange.min;
|
||||||
|
user.preferences.ageRange.max = req.body.preferences.ageRange.max || user.preferences.ageRange.max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Данные пользователя перед сохранением:', user);
|
||||||
|
const updatedUser = await user.save();
|
||||||
|
console.log('Профиль успешно обновлен:', updatedUser);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
_id: updatedUser._id,
|
||||||
|
name: updatedUser.name,
|
||||||
|
email: updatedUser.email,
|
||||||
|
dateOfBirth: updatedUser.dateOfBirth,
|
||||||
|
gender: updatedUser.gender,
|
||||||
|
bio: updatedUser.bio,
|
||||||
|
photos: updatedUser.photos,
|
||||||
|
location: updatedUser.location,
|
||||||
|
preferences: updatedUser.preferences,
|
||||||
|
message: 'Профиль успешно обновлен!'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(404);
|
||||||
|
throw new Error('Пользователь не найден.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Ошибка в ${req.method} ${req.originalUrl}:`, error.message); // Более информативный лог
|
||||||
|
|
||||||
|
// Устанавливаем statusCode на объекте ошибки, если он еще не установлен
|
||||||
|
// или если мы хотим его переопределить для этого конкретного случая
|
||||||
|
if (error.name === 'ValidationError' || error.name === 'CastError') {
|
||||||
|
error.statusCode = 400;
|
||||||
|
} else if (error.message === 'Пользователь не найден.') { // Если мы сами выбросили эту ошибку с throw
|
||||||
|
error.statusCode = 404;
|
||||||
|
} else if (!error.statusCode) {
|
||||||
|
// Если это какая-то другая ошибка без явно установленного statusCode,
|
||||||
|
// можно считать ее серверной ошибкой.
|
||||||
|
error.statusCode = 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Передаем ошибку (с установленным error.statusCode) в наш центральный errorHandler
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUsersForSwiping = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const currentUserId = req.user._id;
|
||||||
|
|
||||||
|
// TODO: Более сложная логика фильтрации и сортировки
|
||||||
|
const users = await User.find({
|
||||||
|
_id: { $ne: currentUserId },
|
||||||
|
// Можно добавить условие, чтобы у пользователя было хотя бы одно фото
|
||||||
|
// 'photos.0': { $exists: true } // Если нужно показывать только тех, у кого есть фото
|
||||||
|
})
|
||||||
|
.select('name dateOfBirth gender bio photos preferences.ageRange'); // photos все еще нужны для выбора главной
|
||||||
|
|
||||||
|
const usersWithAgeAndPhoto = users.map(user => {
|
||||||
|
let age = null;
|
||||||
|
if (user.dateOfBirth) {
|
||||||
|
const birthDate = new Date(user.dateOfBirth);
|
||||||
|
const today = new Date();
|
||||||
|
age = today.getFullYear() - birthDate.getFullYear();
|
||||||
|
const m = today.getMonth() - birthDate.getMonth();
|
||||||
|
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Логика выбора главной фотографии ---
|
||||||
|
let mainPhotoUrl = null;
|
||||||
|
if (user.photos && user.photos.length > 0) {
|
||||||
|
// Ищем фото, помеченное как главное
|
||||||
|
const profilePic = user.photos.find(photo => photo.isProfilePhoto === true);
|
||||||
|
if (profilePic) {
|
||||||
|
mainPhotoUrl = profilePic.url;
|
||||||
|
} else {
|
||||||
|
// Если нет явно помеченного главного фото, берем первое из массива
|
||||||
|
mainPhotoUrl = user.photos[0].url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// -----------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: user._id,
|
||||||
|
name: user.name,
|
||||||
|
age: age,
|
||||||
|
gender: user.gender,
|
||||||
|
bio: user.bio,
|
||||||
|
mainPhotoUrl: mainPhotoUrl, // <--- Добавляем URL главной фотографии
|
||||||
|
// photos: user.photos, // Отправлять весь массив фото здесь, возможно, избыточно для карточки свайпа
|
||||||
|
// Если нужна только одна фото, то mainPhotoUrl достаточно.
|
||||||
|
// Если нужны несколько превью - можно вернуть ограниченное количество.
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json(usersWithAgeAndPhoto);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при получении пользователей для свайпа:', error.message);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadUserProfilePhoto = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findById(req.user._id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const error = new Error('Пользователь не найден.');
|
||||||
|
error.statusCode = 404;
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.file) { // req.file будет добавлен multer'ом
|
||||||
|
const error = new Error('Файл для загрузки не найден.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[USER_CTRL] Получен файл для загрузки:', req.file.originalname, 'Размер:', req.file.size);
|
||||||
|
|
||||||
|
// Проверяем инициализацию Cloudinary
|
||||||
|
if (!cloudinary || !cloudinary.uploader) {
|
||||||
|
console.error('[USER_CTRL] Ошибка: Cloudinary не настроен');
|
||||||
|
const error = new Error('Ошибка настройки службы хранения файлов.');
|
||||||
|
error.statusCode = 500;
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка файла в Cloudinary
|
||||||
|
const b64 = Buffer.from(req.file.buffer).toString("base64");
|
||||||
|
let dataURI = "data:" + req.file.mimetype + ";base64," + b64;
|
||||||
|
|
||||||
|
const result = await cloudinary.uploader.upload(dataURI, {
|
||||||
|
folder: `dating_app/user_photos/${user._id}`,
|
||||||
|
resource_type: 'image',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[USER_CTRL] Файл успешно загружен в Cloudinary:', result.secure_url);
|
||||||
|
|
||||||
|
const newPhoto = {
|
||||||
|
url: result.secure_url,
|
||||||
|
public_id: result.public_id,
|
||||||
|
isProfilePhoto: user.photos.length === 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Добавляем новое фото в массив фотографий пользователя
|
||||||
|
user.photos.push(newPhoto);
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// Получаем обновленного пользователя с обновленным массивом фотографий
|
||||||
|
const updatedUser = await User.findById(req.user._id);
|
||||||
|
|
||||||
|
// Важно! Явно создаем массив allPhotos для ответа, чтобы убедиться, что структура каждого объекта фото совпадает
|
||||||
|
const allPhotos = updatedUser.photos.map(photo => ({
|
||||||
|
url: photo.url,
|
||||||
|
public_id: photo.public_id,
|
||||||
|
isProfilePhoto: photo.isProfilePhoto
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Возвращаем ответ с точной структурой, ожидаемой тестами
|
||||||
|
res.status(200).json({
|
||||||
|
message: 'Фотография успешно загружена!',
|
||||||
|
photo: newPhoto,
|
||||||
|
allPhotos: allPhotos
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[USER_CTRL] Ошибка при загрузке фотографии:', error.message, error.stack);
|
||||||
|
if (error.http_code) {
|
||||||
|
error.statusCode = error.http_code;
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Другие возможные функции для userController (например, getUserById, getAllUsers для админа и т.д.)
|
||||||
|
// const getUserById = async (req, res, next) => { ... };
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
updateUserProfile,
|
||||||
|
getUsersForSwiping,
|
||||||
|
uploadUserProfilePhoto,
|
||||||
|
// getUserById,
|
||||||
|
};
|
70
backend/middleware/authMiddleware.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
console.log('[DEBUG] authMiddleware.js - Start of file');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
console.log('[DEBUG] authMiddleware.js - jwt loaded');
|
||||||
|
|
||||||
|
let User;
|
||||||
|
try {
|
||||||
|
User = require('../models/User');
|
||||||
|
console.log('[DEBUG] authMiddleware.js - User model loaded successfully. Type:', typeof User);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DEBUG] authMiddleware.js - FAILED to load User model:', error);
|
||||||
|
// Если модель пользователя не загрузится, protect не сможет работать, но модуль все равно должен попытаться экспортировать
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[DEBUG] authMiddleware.js - Defining protect function...');
|
||||||
|
const protect = async (req, res, next) => {
|
||||||
|
console.log('[DEBUG] protect middleware - Entered');
|
||||||
|
let token;
|
||||||
|
|
||||||
|
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
|
||||||
|
try {
|
||||||
|
token = req.headers.authorization.split(' ')[1];
|
||||||
|
console.log('[DEBUG] protect middleware - Token found:', token ? 'Yes' : 'No');
|
||||||
|
|
||||||
|
if (!process.env.JWT_SECRET) {
|
||||||
|
console.error('[DEBUG] protect middleware - JWT_SECRET is not defined in environment variables!');
|
||||||
|
const err = new Error('Ошибка конфигурации сервера: секрет JWT не установлен.');
|
||||||
|
err.statusCode = 500;
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
console.log('[DEBUG] protect middleware - Token decoded');
|
||||||
|
|
||||||
|
if (!User) {
|
||||||
|
console.error('[DEBUG] protect middleware - User model is not loaded!');
|
||||||
|
const err = new Error('Ошибка сервера: модель пользователя не загружена.');
|
||||||
|
err.statusCode = 500;
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = await User.findById(decoded.id).select('-password');
|
||||||
|
|
||||||
|
if (!req.user) {
|
||||||
|
console.log('[DEBUG] protect middleware - User not found for token');
|
||||||
|
const err = new Error('Пользователь не найден (возможно, удален после выдачи токена)');
|
||||||
|
err.statusCode = 401;
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[DEBUG] protect middleware - User authenticated, calling next()');
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DEBUG] protect middleware - Authentication error:', error.message);
|
||||||
|
const err = new Error('Не авторизован, токен недействителен или ошибка верификации.');
|
||||||
|
err.statusCode = 401;
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[DEBUG] protect middleware - No Bearer token in authorization header');
|
||||||
|
const err = new Error('Не авторизован, нет токена или неверная схема авторизации.');
|
||||||
|
err.statusCode = 401;
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
console.log('[DEBUG] authMiddleware.js - typeof protect after definition:', typeof protect);
|
||||||
|
|
||||||
|
console.log('[DEBUG] authMiddleware.js - Current module.exports before assignment:', typeof module.exports, JSON.stringify(module.exports));
|
||||||
|
module.exports = { protect };
|
||||||
|
console.log('[DEBUG] authMiddleware.js - module.exports after assignment:', typeof module.exports, JSON.stringify(module.exports));
|
||||||
|
console.log('[DEBUG] authMiddleware.js - End of file');
|
37
backend/models/Conversation.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const conversationSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'User',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Можно добавить поле для последнего сообщения, чтобы легче было сортировать/отображать в списке чатов
|
||||||
|
lastMessage: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Message',
|
||||||
|
},
|
||||||
|
// lastMessageTimestamp: { // Или просто timestamp последнего сообщения
|
||||||
|
// type: Date,
|
||||||
|
// default: Date.now
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true, // createdAt, updatedAt для диалога
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Индекс для участников, чтобы быстро находить диалоги по двум пользователям
|
||||||
|
// Уникальный индекс гарантирует, что между двумя пользователями будет только один диалог
|
||||||
|
// Порядок участников в массиве важен для уникальности, если не использовать $all
|
||||||
|
// Для простоты можно не делать его уникальным на уровне схемы, а проверять при создании.
|
||||||
|
// Либо хранить участников всегда в отсортированном порядке их ID.
|
||||||
|
// Пока оставим без уникального индекса, будем проверять в коде.
|
||||||
|
// conversationSchema.index({ participants: 1 });
|
||||||
|
|
||||||
|
const Conversation = mongoose.model('Conversation', conversationSchema);
|
||||||
|
|
||||||
|
module.exports = Conversation;
|
45
backend/models/Message.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const messageSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
conversationId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Conversation',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
sender: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'User',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
// receiver: { // Можно убрать, так как другой участник есть в Conversation.participants
|
||||||
|
// type: mongoose.Schema.Types.ObjectId,
|
||||||
|
// ref: 'User',
|
||||||
|
// required: true,
|
||||||
|
// },
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
readBy: [{ // Массив ID пользователей, прочитавших сообщение (если чат групповой, для 1-на-1 достаточно одного ID)
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'User'
|
||||||
|
}],
|
||||||
|
// Или просто флаг для 1-на-1 чата
|
||||||
|
// isRead: {
|
||||||
|
// type: Boolean,
|
||||||
|
// default: false
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true, // createdAt (время отправки), updatedAt
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Индекс для быстрой выборки сообщений по диалогу и сортировки по времени
|
||||||
|
messageSchema.index({ conversationId: 1, createdAt: -1 });
|
||||||
|
|
||||||
|
const Message = mongoose.model('Message', messageSchema);
|
||||||
|
|
||||||
|
module.exports = Message;
|
105
backend/models/User.js
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
const userSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: [true, 'Пожалуйста, укажите ваше имя'],
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
required: [true, 'Пожалуйста, укажите ваш email'],
|
||||||
|
unique: true,
|
||||||
|
lowercase: true,
|
||||||
|
trim: true,
|
||||||
|
match: [
|
||||||
|
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/,
|
||||||
|
'Пожалуйста, укажите корректный email',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: String,
|
||||||
|
required: [true, 'Пожалуйста, укажите пароль'],
|
||||||
|
minlength: [6, 'Пароль должен быть не менее 6 символов'],
|
||||||
|
select: false,
|
||||||
|
},
|
||||||
|
dateOfBirth: {
|
||||||
|
type: Date,
|
||||||
|
},
|
||||||
|
gender: {
|
||||||
|
type: String,
|
||||||
|
enum: ['male', 'female', 'other'],
|
||||||
|
},
|
||||||
|
bio: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
maxlength: [500, 'Описание не должно превышать 500 символов'],
|
||||||
|
},
|
||||||
|
photos: [
|
||||||
|
{
|
||||||
|
url: String,
|
||||||
|
public_id: String, // Добавляем public_id
|
||||||
|
isProfilePhoto: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
location: {
|
||||||
|
city: String,
|
||||||
|
country: String,
|
||||||
|
},
|
||||||
|
preferences: {
|
||||||
|
gender: { type: String, enum: ['male', 'female', 'other', 'any'], default: 'any' },
|
||||||
|
ageRange: {
|
||||||
|
min: { type: Number, default: 18 },
|
||||||
|
max: { type: Number, default: 99 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
lastSeen: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- НОВЫЕ ПОЛЯ ДЛЯ ЛАЙКОВ, ПРОПУСКОВ И МЭТЧЕЙ ---
|
||||||
|
liked: [{ // Массив ID пользователей, которых лайкнул текущий пользователь
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'User' // Ссылка на модель User
|
||||||
|
}],
|
||||||
|
passed: [{ // Массив ID пользователей, которых пропустил/дизлайкнул текущий пользователь
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'User'
|
||||||
|
}],
|
||||||
|
matches: [{ // Массив ID пользователей, с которыми у текущего пользователя взаимный лайк (мэтч)
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'User'
|
||||||
|
}]
|
||||||
|
// ----------------------------------------------------
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ... (Middleware pre('save') для хеширования пароля и метод matchPassword остаются без изменений) ...
|
||||||
|
userSchema.pre('save', async function (next) {
|
||||||
|
if (!this.isModified('password')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
this.password = await bcrypt.hash(this.password, salt);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
userSchema.methods.matchPassword = async function (enteredPassword) {
|
||||||
|
return await bcrypt.compare(enteredPassword, this.password);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const User = mongoose.model('User', userSchema);
|
||||||
|
|
||||||
|
module.exports = User;
|
1982
backend/package-lock.json
generated
Normal file
28
backend/package.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"start": "node server.js",
|
||||||
|
"server": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"cloudinary": "^2.0.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.5.0",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mongoose": "^8.14.3",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"socket.io": "^4.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.10"
|
||||||
|
}
|
||||||
|
}
|
24
backend/routes/actionRoutes.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Импортируем функции контроллера действий
|
||||||
|
const { likeUser, passUser } = require('../controllers/actionController');
|
||||||
|
|
||||||
|
// Импортируем middleware для защиты маршрутов
|
||||||
|
const { protect } = require('../middleware/authMiddleware');
|
||||||
|
|
||||||
|
// Все маршруты в этом файле будут защищены (требуют аутентификации)
|
||||||
|
router.use(protect); // Применяем protect middleware ко всем маршрутам ниже в этом файле
|
||||||
|
|
||||||
|
// Маршрут для лайка пользователя
|
||||||
|
// POST /api/actions/like/:userId
|
||||||
|
// :userId - это ID пользователя, которого лайкает текущий аутентифицированный пользователь
|
||||||
|
router.post('/like/:userId', likeUser);
|
||||||
|
|
||||||
|
// Маршрут для пропуска/дизлайка пользователя
|
||||||
|
// POST /api/actions/pass/:userId
|
||||||
|
// :userId - это ID пользователя, которого пропускает текущий аутентифицированный пользователь
|
||||||
|
router.post('/pass/:userId', passUser);
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = router;
|
42
backend/routes/authRoutes.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// Маршруты аутентификации для API
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Импортируем функции из контроллера
|
||||||
|
const {
|
||||||
|
registerUser,
|
||||||
|
loginUser,
|
||||||
|
getMe
|
||||||
|
} = require('../controllers/authController');
|
||||||
|
|
||||||
|
// Импортируем middleware для защиты маршрутов
|
||||||
|
const { protect } = require('../middleware/authMiddleware');
|
||||||
|
|
||||||
|
// --- НАЧАЛО ИЗМЕНЕНИЯ ---
|
||||||
|
// Дополнительная отладка: Проверяем, что protect действительно функция
|
||||||
|
console.log('[DEBUG] authRoutes.js - Тип импортированного protect:', typeof protect);
|
||||||
|
if (typeof protect !== 'function') {
|
||||||
|
console.error('[КРИТИЧЕСКАЯ ОШИБКА] authRoutes.js - protect middleware НЕ является функцией! Маршрут /me НЕ будет защищен.');
|
||||||
|
// Можно даже выбросить ошибку, чтобы сервер не стартовал с неработающей аутентификацией
|
||||||
|
throw new Error('Middleware protect не загружен корректно в authRoutes.js');
|
||||||
|
}
|
||||||
|
// --- КОНЕЦ ИЗМЕНЕНИЯ ---
|
||||||
|
|
||||||
|
// Отладочные логи для проверки типов импортированных функций
|
||||||
|
console.log('[DEBUG] authRoutes.js - typeof registerUser:', typeof registerUser);
|
||||||
|
console.log('[DEBUG] authRoutes.js - typeof loginUser:', typeof loginUser);
|
||||||
|
console.log('[DEBUG] authRoutes.js - typeof getMe:', typeof getMe);
|
||||||
|
console.log('[DEBUG] authRoutes.js - typeof protect:', typeof protect);
|
||||||
|
|
||||||
|
// Регистрация пользователя - публичный доступ
|
||||||
|
router.post('/register', registerUser);
|
||||||
|
|
||||||
|
// Вход пользователя - публичный доступ
|
||||||
|
router.post('/login', loginUser);
|
||||||
|
|
||||||
|
// Получение профиля - защищенный доступ
|
||||||
|
router.get('/me', protect, getMe);
|
||||||
|
|
||||||
|
// Экспортируем маршруты
|
||||||
|
module.exports = router;
|
||||||
|
// Конец файла
|
26
backend/routes/conversationRoutes.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// backend/routes/conversationRoutes.js
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const {
|
||||||
|
createOrGetConversation,
|
||||||
|
getUserConversations,
|
||||||
|
getMessagesForConversation,
|
||||||
|
deleteMessage // Импортируем новый контроллер
|
||||||
|
} = require('../controllers/conversationController');
|
||||||
|
const { protect } = require('../middleware/authMiddleware');
|
||||||
|
|
||||||
|
// Все маршруты здесь требуют аутентификации
|
||||||
|
router.use(protect);
|
||||||
|
|
||||||
|
router.route('/')
|
||||||
|
.post(createOrGetConversation) // POST /api/conversations (создать/найти диалог)
|
||||||
|
.get(getUserConversations); // GET /api/conversations (получить все диалоги пользователя)
|
||||||
|
|
||||||
|
router.route('/:conversationId/messages')
|
||||||
|
.get(getMessagesForConversation);
|
||||||
|
|
||||||
|
// Новый маршрут для удаления сообщения
|
||||||
|
router.route('/:conversationId/messages/:messageId')
|
||||||
|
.delete(deleteMessage); // DELETE /api/conversations/:conversationId/messages/:messageId (удалить сообщение)
|
||||||
|
|
||||||
|
module.exports = router;
|
47
backend/routes/userRoutes.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { updateUserProfile, getUsersForSwiping, uploadUserProfilePhoto } = require('../controllers/userController');
|
||||||
|
const { protect } = require('../middleware/authMiddleware'); // Нам нужен protect для защиты маршрута
|
||||||
|
const multer = require('multer'); // 1. Импортируем multer
|
||||||
|
const path = require('path'); // Может понадобиться для фильтрации файлов
|
||||||
|
|
||||||
|
// Настройка multer для загрузки фотографий
|
||||||
|
const storage = multer.memoryStorage(); // Хранить файл в памяти, а не на диске
|
||||||
|
|
||||||
|
// Конфигурация multer с проверкой типа файла
|
||||||
|
const upload = multer({
|
||||||
|
storage: storage,
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
// Проверяем, является ли файл изображением
|
||||||
|
const filetypes = /jpeg|jpg|png|webp/;
|
||||||
|
const mimetype = filetypes.test(file.mimetype);
|
||||||
|
const extname = filetypes.test(path.extname(file.originalname).toLowerCase());
|
||||||
|
|
||||||
|
if (mimetype && extname) {
|
||||||
|
return cb(null, true);
|
||||||
|
} else {
|
||||||
|
// cb(new Error('Допустимы только изображения (jpg, jpeg, png, webp)!'), false);
|
||||||
|
const err = new Error('Разрешены только изображения (jpg, jpeg, png, webp)!'); // Изменено сообщение об ошибке
|
||||||
|
err.statusCode = 400; // Устанавливаем statusCode для multer ошибок
|
||||||
|
cb(err, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
fileSize: 5 * 1024 * 1024, // Ограничение размера файла (5 МБ)
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Маршрут для обновления профиля текущего аутентифицированного пользователя
|
||||||
|
// PUT /api/users/profile
|
||||||
|
router.put('/profile', protect, updateUserProfile);
|
||||||
|
router.get('/suggestions', protect, getUsersForSwiping); // <--- НОВЫЙ МАРШРУТ
|
||||||
|
|
||||||
|
// Маршрут для загрузки фотографии профиля
|
||||||
|
// POST /api/users/profile/photo
|
||||||
|
router.post('/profile/photo', protect, upload.single('profilePhoto'), uploadUserProfilePhoto);
|
||||||
|
|
||||||
|
// Маршрут для получения профиля по ID (например, для просмотра чужих профилей, если это нужно)
|
||||||
|
// GET /api/users/:id
|
||||||
|
// router.get('/:id', protect, getUserById); // protect здесь может быть опционален или с другой логикой
|
||||||
|
|
||||||
|
module.exports = router;
|
300
backend/server.js
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
const dotenv = require('dotenv');
|
||||||
|
// Загружаем переменные окружения как можно раньше
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const http = require('http'); // <--- Импорт http
|
||||||
|
const { Server } = require("socket.io"); // <--- Импорт Server из socket.io
|
||||||
|
const conversationRoutes = require('./routes/conversationRoutes');
|
||||||
|
// const dotenv = require('dotenv'); // Уже импортировано выше
|
||||||
|
const cors = require('cors');
|
||||||
|
// Импортируем модуль для подключения к БД
|
||||||
|
const connectDBModule = require('./config/db');
|
||||||
|
const authRoutes = require('./routes/authRoutes'); // <--- 1. РАСКОММЕНТИРУЙ ИЛИ ДОБАВЬ ЭТОТ ИМПОРТ
|
||||||
|
const userRoutes = require('./routes/userRoutes'); // 1. Импортируем userRoutes
|
||||||
|
const actionRoutes = require('./routes/actionRoutes'); // 1. Импортируем actionRoutes
|
||||||
|
const Message = require('./models/Message'); // Импорт модели Message
|
||||||
|
const Conversation = require('./models/Conversation'); // Импорт модели Conversation
|
||||||
|
|
||||||
|
// Загружаем переменные окружения до их использования
|
||||||
|
// dotenv.config(); // Перемещено в начало файла
|
||||||
|
|
||||||
|
// Проверяем, что JWT_SECRET правильно загружен
|
||||||
|
console.log('JWT_SECRET loaded:', process.env.JWT_SECRET ? 'Yes' : 'No');
|
||||||
|
|
||||||
|
// Добавляем отладочное логирование
|
||||||
|
console.log('Тип импортированного модуля connectDB:', typeof connectDBModule);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app); // <--- Создание HTTP сервера
|
||||||
|
const io = new Server(server, { // <--- Инициализация socket.io
|
||||||
|
cors: {
|
||||||
|
origin: "*", // <--- Разрешаем все источники
|
||||||
|
methods: ["GET", "POST"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Подключение к базе данных - проверяем, что импортировали функцию
|
||||||
|
if (typeof connectDBModule === 'function') {
|
||||||
|
try {
|
||||||
|
connectDBModule(); // Вызов функции подключения
|
||||||
|
console.log('Попытка подключения к базе данных была инициирована...'); // Изменил лог для ясности
|
||||||
|
} catch (err) {
|
||||||
|
// Этот catch здесь, скорее всего, не поймает асинхронные ошибки из connectDBModule,
|
||||||
|
// так как connectDBModule асинхронная и не возвращает Promise, который здесь ожидался бы с await.
|
||||||
|
// Ошибки из connectDBModule (если они есть) будут выведены внутри самой функции connectDBModule.
|
||||||
|
console.error('Синхронная ошибка при вызове connectDBModule (маловероятно):', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Ошибка: connectDB не является функцией! Тип:', typeof connectDBModule);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настройка CORS для Express
|
||||||
|
app.use(cors({
|
||||||
|
origin: "*", // <--- Разрешаем все источники
|
||||||
|
credentials: true // Если нужны куки или заголовки авторизации
|
||||||
|
}));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: false }));
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.send('API для Dating App работает!');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. РАСКОММЕНТИРУЙ ИЛИ ДОБАВЬ ЭТИ СТРОКИ для подключения маршрутов аутентификации
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/users', userRoutes); // 2. Подключаем userRoutes с префиксом /api/users
|
||||||
|
app.use('/api/actions', actionRoutes);
|
||||||
|
app.use('/api/conversations', conversationRoutes);
|
||||||
|
|
||||||
|
// Socket.IO логика
|
||||||
|
let activeUsers = [];
|
||||||
|
|
||||||
|
io.on("connection", (socket) => {
|
||||||
|
console.log("A user connected:", socket.id);
|
||||||
|
|
||||||
|
// Добавление нового пользователя
|
||||||
|
socket.on("addUser", (userId) => {
|
||||||
|
// Проверяем, не добавлен ли уже пользователь с таким userId, но другим socket.id
|
||||||
|
// Это может произойти, если пользователь открыл несколько вкладок или переподключился
|
||||||
|
const existingUser = activeUsers.find(user => user.userId === userId);
|
||||||
|
if (existingUser) {
|
||||||
|
// Обновляем socket.id для существующего userId
|
||||||
|
existingUser.socketId = socket.id;
|
||||||
|
} else {
|
||||||
|
// Добавляем нового пользователя
|
||||||
|
activeUsers.push({ userId, socketId: socket.id });
|
||||||
|
}
|
||||||
|
io.emit("getUsers", activeUsers); // Отправляем обновленный список активных пользователей всем клиентам
|
||||||
|
console.log("User added to active users:", userId, socket.id);
|
||||||
|
console.log("Active users:", activeUsers);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Вспомогательная функция для получения сокета пользователя
|
||||||
|
const getUserSocket = (userId) => {
|
||||||
|
return activeUsers.find(u => u.userId === userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Отправка сообщения
|
||||||
|
socket.on("sendMessage", async ({ senderId, receiverId, text, clientConversationId }) => { // Добавлен clientConversationId, async
|
||||||
|
console.log(`"[Socket.IO] Получено сообщение от ${senderId} для ${receiverId}: "${text}" (попытка для диалога: ${clientConversationId})"`);
|
||||||
|
|
||||||
|
const getUserSocket = (userId) => {
|
||||||
|
const user = activeUsers.find(u => u.userId === userId);
|
||||||
|
return user ? user : null; // Возвращаем объект пользователя или null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Найти или создать диалог
|
||||||
|
let conversation = await Conversation.findOne({
|
||||||
|
participants: { $all: [senderId, receiverId] }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
console.log(`[Socket.IO] Диалог не найден, создаем новый между ${senderId} и ${receiverId}`);
|
||||||
|
conversation = new Conversation({
|
||||||
|
participants: [senderId, receiverId]
|
||||||
|
// lastMessage будет установлено позже
|
||||||
|
});
|
||||||
|
// Не сохраняем conversation здесь сразу, сохраним вместе с lastMessage
|
||||||
|
} else {
|
||||||
|
console.log(`[Socket.IO] Найден существующий диалог: ${conversation._id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Создать и сохранить новое сообщение
|
||||||
|
const newMessage = new Message({
|
||||||
|
conversationId: conversation._id, // Используем ID найденного или нового (но еще не сохраненного) диалога
|
||||||
|
sender: senderId,
|
||||||
|
text: text,
|
||||||
|
});
|
||||||
|
await newMessage.save();
|
||||||
|
console.log(`[Socket.IO] Сообщение сохранено в БД: ${newMessage._id}`);
|
||||||
|
|
||||||
|
// 3. Обновить lastMessage и updatedAt в диалоге
|
||||||
|
conversation.lastMessage = newMessage._id;
|
||||||
|
// conversation.lastMessageTimestamp = newMessage.createdAt; // Если используем это поле
|
||||||
|
await conversation.save(); // Теперь сохраняем диалог (или обновляем существующий)
|
||||||
|
console.log(`[Socket.IO] Диалог ${conversation._id} обновлен с lastMessage.`);
|
||||||
|
|
||||||
|
// 4. Подготовить данные сообщения для отправки клиентам (с populated sender)
|
||||||
|
const populatedMessage = await Message.findById(newMessage._id)
|
||||||
|
.populate('sender', 'name photos'); // Загружаем данные отправителя
|
||||||
|
|
||||||
|
// 5. Отправить сообщение обоим участникам диалога (отправителю для подтверждения и получателю)
|
||||||
|
const senderSocketInfo = getUserSocket(senderId);
|
||||||
|
const receiverSocketInfo = getUserSocket(receiverId);
|
||||||
|
|
||||||
|
const messageToSend = {
|
||||||
|
_id: populatedMessage._id,
|
||||||
|
conversationId: populatedMessage.conversationId,
|
||||||
|
sender: populatedMessage.sender, // Объект с name, photos
|
||||||
|
text: populatedMessage.text,
|
||||||
|
createdAt: populatedMessage.createdAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (senderSocketInfo && senderSocketInfo.socketId) {
|
||||||
|
io.to(senderSocketInfo.socketId).emit("getMessage", messageToSend);
|
||||||
|
console.log(`[Socket.IO] Сообщение (подтверждение) отправлено отправителю ${senderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receiverSocketInfo && receiverSocketInfo.socketId) {
|
||||||
|
io.to(receiverSocketInfo.socketId).emit("getMessage", messageToSend);
|
||||||
|
console.log(`[Socket.IO] Сообщение отправлено получателю ${receiverId}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[Socket.IO] Получатель ${receiverId} не онлайн / сокет не найден.`);
|
||||||
|
// TODO: Push-уведомление
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Socket.IO] Ошибка при обработке sendMessage:', error.message, error.stack);
|
||||||
|
const senderSocketInfo = getUserSocket(senderId);
|
||||||
|
if (senderSocketInfo && senderSocketInfo.socketId) {
|
||||||
|
io.to(senderSocketInfo.socketId).emit("messageError", {
|
||||||
|
message: "Не удалось отправить сообщение.",
|
||||||
|
originalText: text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Событие: Пользователь прочитал сообщения
|
||||||
|
socket.on("markMessagesAsRead", async ({ conversationId, userId }) => {
|
||||||
|
console.log(`[Socket.IO] User ${userId} marked messages as read in conversation ${conversationId}`);
|
||||||
|
try {
|
||||||
|
// 1. Найти все сообщения в диалоге, отправленные НЕ userId, и где userId еще нет в readBy
|
||||||
|
const result = await Message.updateMany(
|
||||||
|
{
|
||||||
|
conversationId: conversationId,
|
||||||
|
sender: { $ne: userId }, // Сообщения от другого пользователя
|
||||||
|
readBy: { $ne: userId } // Которые текущий пользователь еще не читал (его ID нет в readBy)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$addToSet: { readBy: userId } // Добавить ID текущего пользователя в массив readBy
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[Socket.IO] Messages updated for read status: ${result.modifiedCount} in conversation ${conversationId} by user ${userId}`);
|
||||||
|
|
||||||
|
if (result.modifiedCount > 0) {
|
||||||
|
// 2. Найти другого участника диалога, чтобы уведомить его
|
||||||
|
const conversation = await Conversation.findById(conversationId);
|
||||||
|
if (!conversation) {
|
||||||
|
console.log(`[Socket.IO] Conversation ${conversationId} not found for sending read notification.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherParticipantId = conversation.participants.find(pId => pId.toString() !== userId);
|
||||||
|
|
||||||
|
if (otherParticipantId) {
|
||||||
|
const otherUserSocket = getUserSocket(otherParticipantId.toString());
|
||||||
|
if (otherUserSocket && otherUserSocket.socketId) {
|
||||||
|
io.to(otherUserSocket.socketId).emit("messagesRead", {
|
||||||
|
conversationId: conversationId,
|
||||||
|
readerId: userId // ID пользователя, который прочитал сообщения
|
||||||
|
});
|
||||||
|
console.log(`[Socket.IO] Sent 'messagesRead' event to ${otherParticipantId} for conversation ${conversationId}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[Socket.IO] Other participant ${otherParticipantId} for messagesRead notification is not online or socket not found.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Socket.IO] Error in markMessagesAsRead:', error.message, error.stack);
|
||||||
|
// Можно отправить ошибку обратно клиенту, если это необходимо
|
||||||
|
// socket.emit('markMessagesAsReadError', { conversationId, message: 'Failed to mark messages as read' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Пользователь печатает
|
||||||
|
socket.on("typing", ({ conversationId, receiverId, senderId }) => { // Добавлен senderId, conversationId
|
||||||
|
const receiver = activeUsers.find(user => user.userId === receiverId);
|
||||||
|
if (receiver) {
|
||||||
|
io.to(receiver.socketId).emit("userTyping", { conversationId, senderId }); // Добавлен conversationId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Пользователь перестал печатать
|
||||||
|
socket.on("stopTyping", ({ conversationId, receiverId, senderId }) => { // Добавлен senderId, conversationId
|
||||||
|
const receiver = activeUsers.find(user => user.userId === receiverId);
|
||||||
|
if (receiver) {
|
||||||
|
io.to(receiver.socketId).emit("userStopTyping", { conversationId, senderId }); // Добавлен conversationId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отключение пользователя
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
activeUsers = activeUsers.filter(user => user.socketId !== socket.id);
|
||||||
|
io.emit("getUsers", activeUsers); // Обновляем список у всех клиентов
|
||||||
|
console.log("User disconnected:", socket.id);
|
||||||
|
console.log("Active users:", activeUsers);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const errorHandler = (err, req, res, next) => {
|
||||||
|
console.error('--- ERROR HANDLER ---');
|
||||||
|
console.error('Name:', err.name);
|
||||||
|
console.error('Message:', err.message);
|
||||||
|
console.error('StatusCode on err object:', err.statusCode);
|
||||||
|
console.error('Current res.statusCode:', res.statusCode);
|
||||||
|
// console.error('Stack:', err.stack);
|
||||||
|
|
||||||
|
let finalStatusCode;
|
||||||
|
let finalMessage = err.message || 'Произошла ошибка на сервере';
|
||||||
|
|
||||||
|
// Приоритетная обработка специфических ошибок Mongoose
|
||||||
|
if (err.name === 'CastError') {
|
||||||
|
finalStatusCode = 400; // Или 404, если считаешь, что неверный ID = "не найдено"
|
||||||
|
finalMessage = `Неверный формат ID ресурса: ${err.path} со значением '${err.value}' не является валидным ObjectId.`;
|
||||||
|
console.log(`Mongoose CastError detected. Setting status to ${finalStatusCode}`);
|
||||||
|
} else if (err.name === 'ValidationError') {
|
||||||
|
finalStatusCode = 400;
|
||||||
|
console.log(`Mongoose ValidationError detected. Setting status to ${finalStatusCode}`);
|
||||||
|
} else if (err.statusCode) {
|
||||||
|
finalStatusCode = err.statusCode;
|
||||||
|
console.log(`Error has statusCode property. Setting status to ${finalStatusCode}`);
|
||||||
|
} else {
|
||||||
|
finalStatusCode = 500; // Default to 500
|
||||||
|
console.log(`Defaulting status to ${finalStatusCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ERROR_HANDLER] Final StatusCode to be sent: ${finalStatusCode}`);
|
||||||
|
|
||||||
|
if (res.headersSent) {
|
||||||
|
console.error('[ERROR_HANDLER] Headers already sent.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(finalStatusCode).json({
|
||||||
|
message: finalMessage,
|
||||||
|
stack: process.env.NODE_ENV === 'production' ? null : err.stack,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 5000;
|
||||||
|
|
||||||
|
// Запуск HTTP сервера вместо app.listen
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Сервер запущен на порту ${PORT} и слушает WebSocket соединения`);
|
||||||
|
});
|
1
dev-dist/registerSW.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
93
dev-dist/sw.js
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// If the loader is already loaded, just stop.
|
||||||
|
if (!self.define) {
|
||||||
|
let registry = {};
|
||||||
|
|
||||||
|
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||||
|
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||||
|
let nextDefineUri;
|
||||||
|
|
||||||
|
const singleRequire = (uri, parentUri) => {
|
||||||
|
uri = new URL(uri + ".js", parentUri).href;
|
||||||
|
return registry[uri] || (
|
||||||
|
|
||||||
|
new Promise(resolve => {
|
||||||
|
if ("document" in self) {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = uri;
|
||||||
|
script.onload = resolve;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
} else {
|
||||||
|
nextDefineUri = uri;
|
||||||
|
importScripts(uri);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.then(() => {
|
||||||
|
let promise = registry[uri];
|
||||||
|
if (!promise) {
|
||||||
|
throw new Error(`Module ${uri} didn’t register its module`);
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
self.define = (depsNames, factory) => {
|
||||||
|
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||||
|
if (registry[uri]) {
|
||||||
|
// Module is already loading or loaded.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let exports = {};
|
||||||
|
const require = depUri => singleRequire(depUri, uri);
|
||||||
|
const specialDeps = {
|
||||||
|
module: { uri },
|
||||||
|
exports,
|
||||||
|
require
|
||||||
|
};
|
||||||
|
registry[uri] = Promise.all(depsNames.map(
|
||||||
|
depName => specialDeps[depName] || require(depName)
|
||||||
|
)).then(deps => {
|
||||||
|
factory(...deps);
|
||||||
|
return exports;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
|
||||||
|
|
||||||
|
self.skipWaiting();
|
||||||
|
workbox.clientsClaim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The precacheAndRoute() method efficiently caches and responds to
|
||||||
|
* requests for URLs in the manifest.
|
||||||
|
* See https://goo.gl/S9QRab
|
||||||
|
*/
|
||||||
|
workbox.precacheAndRoute([{
|
||||||
|
"url": "registerSW.js",
|
||||||
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
|
}, {
|
||||||
|
"url": "index.html",
|
||||||
|
"revision": "0.nhkqrt0pfog"
|
||||||
|
}], {});
|
||||||
|
workbox.cleanupOutdatedCaches();
|
||||||
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
allowlist: [/^\/$/]
|
||||||
|
}));
|
||||||
|
|
||||||
|
}));
|
||||||
|
//# sourceMappingURL=sw.js.map
|
1
dev-dist/sw.js.map
Normal file
3392
dev-dist/workbox-54d0af47.js
Normal file
1
dev-dist/workbox-54d0af47.js.map
Normal file
54
docker-compose.yml
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
services:
|
||||||
|
# Сервис Фронтенда
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./ # Путь к Dockerfile фронтенда (предполагается, что Dockerfile лежит в корне dating-app-pwa)
|
||||||
|
dockerfile: Dockerfile # Имя Dockerfile для фронтенда
|
||||||
|
ports:
|
||||||
|
- "8080:80" # Пробрасываем порт 80 контейнера (Nginx) на порт 8080 хоста
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on: # Фронтенд зависит от бэкенда (для API запросов, хотя Nginx сам по себе запустится)
|
||||||
|
- backend
|
||||||
|
networks: # Подключаем к общей сети
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
# Сервис Бэкенда
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend # Путь к Dockerfile бэкенда
|
||||||
|
dockerfile: Dockerfile # Имя Dockerfile для бэкенда
|
||||||
|
ports:
|
||||||
|
- "5000:5000" # Пробрасываем порт 5000 контейнера (Node.js) на порт 5000 хоста
|
||||||
|
environment:
|
||||||
|
# Передаем переменные окружения в контейнер бэкенда
|
||||||
|
- PORT=5000 # Порт внутри контейнера бэкенда
|
||||||
|
- MONGO_URI=mongodb://mongo_db:27017/dating_app_db # <--- ВАЖНО: используем имя сервиса mongo_db
|
||||||
|
- JWT_SECRET=${JWT_SECRET} # Передаем JWT_SECRET из .env файла хоста (см. ниже)
|
||||||
|
depends_on:
|
||||||
|
- mongo_db # Бэкенд зависит от базы данных
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes: # Можно использовать для разработки, чтобы код обновлялся без пересборки образа
|
||||||
|
- ./backend:/usr/src/app # Монтируем локальную папку backend в контейнер
|
||||||
|
- /usr/src/app/node_modules # Исключаем node_modules из монтирования, чтобы использовать те, что в образе
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
# Сервис Базы Данных MongoDB
|
||||||
|
mongo_db:
|
||||||
|
image: mongo:latest # Используем официальный образ MongoDB
|
||||||
|
ports:
|
||||||
|
- "27017:27017" # Стандартный порт MongoDB
|
||||||
|
volumes:
|
||||||
|
- mongo-data:/data/db # Сохраняем данные MongoDB в именованном вольюме
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
# Определяем сети
|
||||||
|
networks:
|
||||||
|
app-network:
|
||||||
|
driver: bridge # Стандартный тип сети для docker-compose
|
||||||
|
|
||||||
|
# Определяем именованные вольюмы
|
||||||
|
volumes:
|
||||||
|
mongo-data: # Для персистентного хранения данных MongoDB
|
58
index.html
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>DatingApp</title>
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="splash_screens/iPhone_16_Pro_Max_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="splash_screens/iPhone_16_Pro_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="splash_screens/iPhone_16_Plus__iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="splash_screens/iPhone_16__iPhone_15_Pro__iPhone_15__iPhone_14_Pro_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="splash_screens/iPhone_16e__iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="splash_screens/iPhone_11__iPhone_XR_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 1032px) and (device-height: 1376px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="splash_screens/13__iPad_Pro_M4_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="splash_screens/12.9__iPad_Pro_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 834px) and (device-height: 1210px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="splash_screens/11__iPad_Pro_M4_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="splash_screens/11__iPad_Pro__10.5__iPad_Pro_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="splash_screens/10.9__iPad_Air_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="splash_screens/10.5__iPad_Air_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="splash_screens/10.2__iPad_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="splash_screens/8.3__iPad_Mini_landscape.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="splash_screens/iPhone_16_Pro_Max_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="splash_screens/iPhone_16_Pro_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="splash_screens/iPhone_16_Plus__iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="splash_screens/iPhone_16__iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="splash_screens/iPhone_16e__iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="splash_screens/iPhone_11__iPhone_XR_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 1032px) and (device-height: 1376px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="splash_screens/13__iPad_Pro_M4_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="splash_screens/12.9__iPad_Pro_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 834px) and (device-height: 1210px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="splash_screens/11__iPad_Pro_M4_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="splash_screens/11__iPad_Pro__10.5__iPad_Pro_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="splash_screens/10.9__iPad_Air_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="splash_screens/10.5__iPad_Air_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="splash_screens/10.2__iPad_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png">
|
||||||
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="splash_screens/8.3__iPad_Mini_portrait.png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
BIN
my_extensions.txt
Normal file
7326
package-lock.json
generated
Normal file
32
package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "dating-app-pwa",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"axios": "^1.9.0",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"bootstrap": "^5.3.6",
|
||||||
|
"cloudinary": "^2.6.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.5.0",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mongoose": "^8.14.3",
|
||||||
|
"multer": "^1.4.5-lts.2",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-pwa": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
BIN
public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 337 KiB |
11
public/favicon.svg
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 438 B |
BIN
public/pwa-192x192.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
public/pwa-512x512.png
Normal file
After Width: | Height: | Size: 337 KiB |
BIN
public/splash_screens/10.2__iPad_landscape.png
Normal file
After Width: | Height: | Size: 262 KiB |
BIN
public/splash_screens/10.2__iPad_portrait.png
Normal file
After Width: | Height: | Size: 260 KiB |
BIN
public/splash_screens/10.5__iPad_Air_landscape.png
Normal file
After Width: | Height: | Size: 276 KiB |
BIN
public/splash_screens/10.5__iPad_Air_portrait.png
Normal file
After Width: | Height: | Size: 273 KiB |
BIN
public/splash_screens/10.9__iPad_Air_landscape.png
Normal file
After Width: | Height: | Size: 302 KiB |
BIN
public/splash_screens/10.9__iPad_Air_portrait.png
Normal file
After Width: | Height: | Size: 300 KiB |
BIN
public/splash_screens/11__iPad_Pro_M4_landscape.png
Normal file
After Width: | Height: | Size: 316 KiB |
BIN
public/splash_screens/11__iPad_Pro_M4_portrait.png
Normal file
After Width: | Height: | Size: 314 KiB |
BIN
public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_landscape.png
Normal file
After Width: | Height: | Size: 308 KiB |
BIN
public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_portrait.png
Normal file
After Width: | Height: | Size: 307 KiB |
BIN
public/splash_screens/12.9__iPad_Pro_landscape.png
Normal file
After Width: | Height: | Size: 401 KiB |
BIN
public/splash_screens/12.9__iPad_Pro_portrait.png
Normal file
After Width: | Height: | Size: 403 KiB |
BIN
public/splash_screens/13__iPad_Pro_M4_landscape.png
Normal file
After Width: | Height: | Size: 407 KiB |
BIN
public/splash_screens/13__iPad_Pro_M4_portrait.png
Normal file
After Width: | Height: | Size: 409 KiB |
After Width: | Height: | Size: 68 KiB |
After Width: | Height: | Size: 66 KiB |
BIN
public/splash_screens/8.3__iPad_Mini_landscape.png
Normal file
After Width: | Height: | Size: 277 KiB |
BIN
public/splash_screens/8.3__iPad_Mini_portrait.png
Normal file
After Width: | Height: | Size: 276 KiB |
After Width: | Height: | Size: 236 KiB |
After Width: | Height: | Size: 234 KiB |
After Width: | Height: | Size: 352 KiB |
After Width: | Height: | Size: 345 KiB |
BIN
public/splash_screens/iPhone_11__iPhone_XR_landscape.png
Normal file
After Width: | Height: | Size: 162 KiB |
BIN
public/splash_screens/iPhone_11__iPhone_XR_portrait.png
Normal file
After Width: | Height: | Size: 160 KiB |
After Width: | Height: | Size: 294 KiB |
After Width: | Height: | Size: 288 KiB |
After Width: | Height: | Size: 375 KiB |
After Width: | Height: | Size: 368 KiB |
After Width: | Height: | Size: 378 KiB |
After Width: | Height: | Size: 371 KiB |
BIN
public/splash_screens/iPhone_16_Pro_Max_landscape.png
Normal file
After Width: | Height: | Size: 397 KiB |
BIN
public/splash_screens/iPhone_16_Pro_Max_portrait.png
Normal file
After Width: | Height: | Size: 389 KiB |
BIN
public/splash_screens/iPhone_16_Pro_landscape.png
Normal file
After Width: | Height: | Size: 334 KiB |
BIN
public/splash_screens/iPhone_16_Pro_portrait.png
Normal file
After Width: | Height: | Size: 330 KiB |
After Width: | Height: | Size: 322 KiB |
After Width: | Height: | Size: 316 KiB |
After Width: | Height: | Size: 315 KiB |
After Width: | Height: | Size: 311 KiB |
After Width: | Height: | Size: 255 KiB |
After Width: | Height: | Size: 250 KiB |
After Width: | Height: | Size: 93 KiB |
After Width: | Height: | Size: 94 KiB |
BIN
public/splash_screens/icon.png
Normal file
After Width: | Height: | Size: 337 KiB |
115
src/App.vue
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app-container">
|
||||||
|
<header class="app-header bg-dark text-white p-3 mb-4 shadow-sm">
|
||||||
|
<nav class="container d-flex justify-content-between align-items-center">
|
||||||
|
<router-link to="/" class="navbar-brand text-white fs-4">DatingApp</router-link>
|
||||||
|
<div>
|
||||||
|
<template v-if="authState">
|
||||||
|
<span class="me-3">Привет, {{ userData?.name || 'Пользователь' }}!</span>
|
||||||
|
<router-link to="/swipe" class="btn btn-outline-light me-2">Поиск</router-link>
|
||||||
|
<router-link to="/chats" class="btn btn-outline-light me-2">Чаты</router-link> <!-- НОВАЯ ССЫЛКА -->
|
||||||
|
<router-link to="/profile" class="btn btn-outline-light me-2">Профиль</router-link>
|
||||||
|
<button @click="handleLogout" class="btn btn-light">Выход</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<router-link to="/login" class="btn btn-outline-light me-2">Войти</router-link>
|
||||||
|
<router-link to="/register" class="btn btn-light">Регистрация</router-link>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<router-view /> <!-- Сюда Vue Router будет рендерить компонент текущего маршрута -->
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer mt-auto py-3 bg-light text-center">
|
||||||
|
<div class="container">
|
||||||
|
<span class="text-muted">© {{ new Date().getFullYear() }} Dating App PWA</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAuth } from '@/auth'; // Убедись, что путь '@/auth' правильный
|
||||||
|
import { ref, watch, onMounted } from 'vue';
|
||||||
|
|
||||||
|
const { user, isAuthenticated, logout, fetchUser } = useAuth();
|
||||||
|
|
||||||
|
// Используем локальные переменные для отслеживания состояния
|
||||||
|
const userData = ref(null);
|
||||||
|
const authState = ref(false);
|
||||||
|
|
||||||
|
// Обновляем локальные переменные при изменении состояния аутентификации
|
||||||
|
watch(() => isAuthenticated.value, (newVal) => {
|
||||||
|
console.log('isAuthenticated изменился:', newVal);
|
||||||
|
authState.value = newVal;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновляем локальную копию данных пользователя
|
||||||
|
watch(() => user.value, (newVal) => {
|
||||||
|
console.log('Данные пользователя изменились:', newVal);
|
||||||
|
userData.value = newVal;
|
||||||
|
});
|
||||||
|
|
||||||
|
// При монтировании компонента синхронизируем состояние
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('App.vue монтирован, проверка состояния аутентификации');
|
||||||
|
authState.value = isAuthenticated.value;
|
||||||
|
userData.value = user.value;
|
||||||
|
|
||||||
|
// Убедимся, что у нас есть актуальные данные пользователя
|
||||||
|
if (localStorage.getItem('userToken') && !isAuthenticated.value) {
|
||||||
|
fetchUser();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
console.log('Пользователь успешно вышел из системы (из App.vue)');
|
||||||
|
authState.value = false;
|
||||||
|
userData.value = null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при выходе:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Глобальные стили */
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
color: #212529;
|
||||||
|
background-color: #f8f9fa; /* Светлый фон для всего приложения */
|
||||||
|
}
|
||||||
|
|
||||||
|
#app-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header .navbar-brand {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header .btn {
|
||||||
|
min-width: 100px; /* Чтобы кнопки были примерно одинаковой ширины */
|
||||||
|
}
|
||||||
|
|
||||||
|
main.container {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding-top: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Можно добавить стили для .app-footer, если нужно, чтобы он был прижат к низу */
|
||||||
|
/* Либо используй классы Bootstrap для flex-grow-1 на main и mt-auto на footer */
|
||||||
|
</style>
|
136
src/auth.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import api from './services/api'; // Наш API сервис
|
||||||
|
import router from './router'; // Vue Router для перенаправлений
|
||||||
|
|
||||||
|
// Реактивные переменные для состояния пользователя и токена
|
||||||
|
const user = ref(null); // Данные пользователя (или null, если не вошел)
|
||||||
|
const token = ref(localStorage.getItem('userToken') || null); // Токен из localStorage или null
|
||||||
|
|
||||||
|
// Вычисляемое свойство для проверки, аутентифицирован ли пользователь
|
||||||
|
const isAuthenticated = computed(() => !!token.value && !!user.value);
|
||||||
|
|
||||||
|
// Функция для установки токена и данных пользователя
|
||||||
|
function setUserData(userData, userToken) {
|
||||||
|
user.value = userData;
|
||||||
|
token.value = userToken;
|
||||||
|
localStorage.setItem('userToken', userToken); // Сохраняем токен в localStorage
|
||||||
|
// Устанавливаем токен в заголовки Axios для последующих запросов (если не используется интерсептор)
|
||||||
|
// Однако, наш интерсептор в api.js уже делает это, так что эта строка может быть избыточна,
|
||||||
|
// но не повредит, если интерсептор по какой-то причине не сработает сразу.
|
||||||
|
// api.defaults.headers.common['Authorization'] = `Bearer ${userToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для загрузки данных пользователя, если токен есть (например, при обновлении страницы)
|
||||||
|
async function fetchUser() {
|
||||||
|
if (token.value) {
|
||||||
|
try {
|
||||||
|
console.log('Пытаемся загрузить пользователя с токеном из localStorage...');
|
||||||
|
const response = await api.getMe(); // Используем наш API сервис
|
||||||
|
|
||||||
|
console.log('Ответ от /auth/me:', response);
|
||||||
|
|
||||||
|
// Проверяем структуру ответа
|
||||||
|
if (!response.data) {
|
||||||
|
throw new Error('Неверный формат ответа от сервера');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем данные пользователя
|
||||||
|
user.value = response.data;
|
||||||
|
console.log('Пользователь успешно загружен:', user.value);
|
||||||
|
console.log('isAuthenticated после загрузки пользователя:', isAuthenticated.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Не удалось загрузить пользователя по токену:', error.response ? error.response.data : error.message);
|
||||||
|
// Если токен невалиден, очищаем его и данные пользователя
|
||||||
|
await logout(); // Вызываем logout, чтобы очистить всё
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Токен не найден, пользователь не загружен.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция входа
|
||||||
|
async function login(credentials) {
|
||||||
|
try {
|
||||||
|
console.log('Попытка входа с учетными данными:', { email: credentials.email });
|
||||||
|
|
||||||
|
const response = await api.login(credentials);
|
||||||
|
console.log('Ответ сервера при входе:', response);
|
||||||
|
|
||||||
|
// Проверяем структуру данных в ответе
|
||||||
|
if (!response.data || !response.data.token) {
|
||||||
|
throw new Error('Неверный формат ответа от сервера: отсутствует токен');
|
||||||
|
}
|
||||||
|
|
||||||
|
// response.data должен содержать { _id, name, email, token, message }
|
||||||
|
const { token: userToken, ...userData } = response.data;
|
||||||
|
|
||||||
|
// Убедимся, что userData содержит необходимые поля
|
||||||
|
console.log('Данные пользователя для сохранения:', userData);
|
||||||
|
|
||||||
|
// Перед сохранением данных
|
||||||
|
console.log('isAuthenticated ДО входа:', isAuthenticated.value);
|
||||||
|
console.log('Данные пользователя ДО входа:', user.value);
|
||||||
|
|
||||||
|
// Сохраняем данные пользователя и токен
|
||||||
|
setUserData(userData, userToken);
|
||||||
|
|
||||||
|
// После сохранения данных, но до перенаправления
|
||||||
|
console.log('isAuthenticated ПОСЛЕ входа:', isAuthenticated.value);
|
||||||
|
console.log('Данные пользователя ПОСЛЕ входа:', user.value);
|
||||||
|
console.log('Токен ПОСЛЕ входа:', token.value);
|
||||||
|
|
||||||
|
// Принудительное обновление состояния
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('isAuthenticated после таймаута:', isAuthenticated.value);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
router.push('/'); // Перенаправляем на главную после входа
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка входа:', error.response ? error.response.data : error.message);
|
||||||
|
// Возвращаем сообщение об ошибке, чтобы отобразить в компоненте
|
||||||
|
throw error.response ? error.response.data : new Error('Ошибка сети или сервера при входе');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция регистрации
|
||||||
|
async function register(userData) {
|
||||||
|
try {
|
||||||
|
const response = await api.register(userData);
|
||||||
|
// response.data должен содержать { _id, name, email, token, message }
|
||||||
|
const { token: userToken, ...newUserData } = response.data;
|
||||||
|
setUserData(newUserData, userToken);
|
||||||
|
// Обычно после регистрации мы либо сразу логиним пользователя, либо просим его войти.
|
||||||
|
// Здесь мы его сразу "залогинили", сохранив токен и данные.
|
||||||
|
router.push('/'); // Перенаправляем на главную (или на страницу подтверждения email, если бы она была)
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка регистрации:', error.response ? error.response.data : error.message);
|
||||||
|
throw error.response ? error.response.data : new Error('Ошибка сети или сервера при регистрации');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция выхода
|
||||||
|
async function logout() {
|
||||||
|
user.value = null;
|
||||||
|
token.value = null;
|
||||||
|
localStorage.removeItem('userToken');
|
||||||
|
// Удаляем токен из заголовков Axios (если устанавливали его напрямую)
|
||||||
|
// delete apiClient.defaults.headers.common['Authorization']; // apiClient не экспортируется из api.js напрямую для этого
|
||||||
|
// Наш интерсептор запросов в api.js перестанет добавлять токен, так как его не будет в localStorage.
|
||||||
|
router.push('/login'); // Перенаправляем на страницу входа
|
||||||
|
console.log('Пользователь вышел из системы.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспортируем то, что понадобится в компонентах
|
||||||
|
export function useAuth() {
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
fetchUser, // Эту функцию нужно будет вызвать при инициализации приложения
|
||||||
|
};
|
||||||
|
}
|
211
src/components/EditProfileForm.vue
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
<template>
|
||||||
|
<div class="edit-profile-form mt-3">
|
||||||
|
<h4>Редактировать профиль</h4>
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editName" class="form-label">Имя:</label>
|
||||||
|
<input type="text" class="form-control" id="editName" v-model="formData.name" :disabled="profileLoading" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editBio" class="form-label">О себе:</label>
|
||||||
|
<textarea class="form-control" id="editBio" rows="3" v-model="formData.bio" :disabled="profileLoading"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editDateOfBirth" class="form-label">Дата рождения:</label>
|
||||||
|
<input type="date" class="form-control" id="editDateOfBirth" v-model="formData.dateOfBirth" :disabled="profileLoading" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editGender" class="form-label">Пол:</label>
|
||||||
|
<select class="form-select" id="editGender" v-model="formData.gender" :disabled="profileLoading">
|
||||||
|
<option value="">Не выбрано</option>
|
||||||
|
<option value="male">Мужской</option>
|
||||||
|
<option value="female">Женский</option>
|
||||||
|
<option value="other">Другой</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="profileSuccessMessage" class="alert alert-success">{{ profileSuccessMessage }}</div>
|
||||||
|
<div v-if="profileErrorMessage" class="alert alert-danger">{{ profileErrorMessage }}</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="profileLoading">
|
||||||
|
<span v-if="profileLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
{{ profileLoading ? 'Сохранение...' : 'Сохранить изменения' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<h4>Загрузить фото профиля</h4>
|
||||||
|
<form @submit.prevent="handlePhotoUpload" class="mt-3">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="profilePhoto" class="form-label">Выберите фото:</label>
|
||||||
|
<input type="file" class="form-control" id="profilePhoto" @change="onFileSelected" accept="image/*" :disabled="photoLoading">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="previewUrl" class="mb-3">
|
||||||
|
<p>Предпросмотр:</p>
|
||||||
|
<img :src="previewUrl" alt="Предпросмотр фото" class="img-thumbnail" style="max-width: 200px; max-height: 200px;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="photoUploadSuccessMessage" class="alert alert-success">{{ photoUploadSuccessMessage }}</div>
|
||||||
|
<div v-if="photoUploadErrorMessage" class="alert alert-danger">{{ photoUploadErrorMessage }}</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-secondary" :disabled="photoLoading || !selectedFile">
|
||||||
|
<span v-if="photoLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
{{ photoLoading ? 'Загрузка...' : 'Загрузить фото' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import { useAuth } from '@/auth';
|
||||||
|
import api from '@/services/api';
|
||||||
|
|
||||||
|
const { user, fetchUser } = useAuth();
|
||||||
|
|
||||||
|
const formData = ref({
|
||||||
|
name: '',
|
||||||
|
bio: '',
|
||||||
|
dateOfBirth: '',
|
||||||
|
gender: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileLoading = ref(false);
|
||||||
|
const profileErrorMessage = ref('');
|
||||||
|
const profileSuccessMessage = ref('');
|
||||||
|
|
||||||
|
const photoLoading = ref(false);
|
||||||
|
const photoUploadErrorMessage = ref('');
|
||||||
|
const photoUploadSuccessMessage = ref('');
|
||||||
|
const selectedFile = ref(null);
|
||||||
|
const previewUrl = ref(null);
|
||||||
|
|
||||||
|
const initializeFormData = (currentUser) => {
|
||||||
|
console.log('[EditProfileForm] Инициализация формы с данными:', currentUser);
|
||||||
|
if (currentUser) {
|
||||||
|
formData.value.name = currentUser.name || '';
|
||||||
|
formData.value.bio = currentUser.bio || '';
|
||||||
|
formData.value.dateOfBirth = currentUser.dateOfBirth ? new Date(currentUser.dateOfBirth).toISOString().split('T')[0] : '';
|
||||||
|
formData.value.gender = currentUser.gender || '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('[EditProfileForm] Компонент смонтирован');
|
||||||
|
initializeFormData(user.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(user, (newUser) => {
|
||||||
|
console.log('[EditProfileForm] Пользователь изменился:', newUser);
|
||||||
|
initializeFormData(newUser);
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
console.log('[EditProfileForm] Отправка формы профиля...');
|
||||||
|
profileLoading.value = true;
|
||||||
|
profileErrorMessage.value = '';
|
||||||
|
profileSuccessMessage.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataToUpdate = {
|
||||||
|
name: formData.value.name,
|
||||||
|
bio: formData.value.bio,
|
||||||
|
dateOfBirth: formData.value.dateOfBirth || null,
|
||||||
|
gender: formData.value.gender || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[EditProfileForm] Данные для обновления профиля:', dataToUpdate);
|
||||||
|
|
||||||
|
const response = await api.updateUserProfile(dataToUpdate);
|
||||||
|
console.log('[EditProfileForm] Ответ от сервера (профиль):', response.data);
|
||||||
|
|
||||||
|
profileSuccessMessage.value = response.data.message || 'Профиль успешно обновлен!';
|
||||||
|
|
||||||
|
await fetchUser();
|
||||||
|
console.log('[EditProfileForm] Данные пользователя обновлены (профиль)');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[EditProfileForm] Ошибка при обновлении профиля:', err);
|
||||||
|
profileErrorMessage.value = (err.response && err.response.data && err.response.data.message)
|
||||||
|
? err.response.data.message
|
||||||
|
: 'Ошибка при обновлении профиля.';
|
||||||
|
} finally {
|
||||||
|
profileLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFileSelected = (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
selectedFile.value = file;
|
||||||
|
// Создание URL для предпросмотра
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
previewUrl.value = e.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
photoUploadErrorMessage.value = ''; // Сброс ошибки при выборе нового файла
|
||||||
|
photoUploadSuccessMessage.value = ''; // Сброс сообщения об успехе
|
||||||
|
} else {
|
||||||
|
selectedFile.value = null;
|
||||||
|
previewUrl.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhotoUpload = async () => {
|
||||||
|
if (!selectedFile.value) {
|
||||||
|
photoUploadErrorMessage.value = 'Пожалуйста, выберите файл для загрузки.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[EditProfileForm] Загрузка фото...');
|
||||||
|
photoLoading.value = true;
|
||||||
|
photoUploadErrorMessage.value = '';
|
||||||
|
photoUploadSuccessMessage.value = '';
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('profilePhoto', selectedFile.value, selectedFile.value.name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[EditProfileForm] Отправка фото на сервер:', selectedFile.value.name);
|
||||||
|
const response = await api.uploadUserProfilePhoto(fd); // Предполагается, что такой метод есть в api.js
|
||||||
|
console.log('[EditProfileForm] Ответ от сервера (фото):', response.data);
|
||||||
|
|
||||||
|
photoUploadSuccessMessage.value = response.data.message || 'Фото успешно загружено!';
|
||||||
|
selectedFile.value = null;
|
||||||
|
previewUrl.value = null;
|
||||||
|
// Очищаем поле выбора файла визуально (браузер может не сбросить его сам)
|
||||||
|
const photoInput = document.getElementById('profilePhoto');
|
||||||
|
if (photoInput) {
|
||||||
|
photoInput.value = '';
|
||||||
|
}
|
||||||
|
await fetchUser(); // Обновляем данные пользователя, включая массив photos
|
||||||
|
console.log('[EditProfileForm] Данные пользователя обновлены (фото)');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[EditProfileForm] Ошибка при загрузке фото:', err);
|
||||||
|
photoUploadErrorMessage.value = (err.response && err.response.data && err.response.data.message)
|
||||||
|
? err.response.data.message
|
||||||
|
: 'Ошибка при загрузке фото.';
|
||||||
|
} finally {
|
||||||
|
photoLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.edit-profile-form {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.img-thumbnail {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
30
src/main.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import router from './router';
|
||||||
|
import { useAuth } from './auth'; // 1. Импортируем useAuth
|
||||||
|
|
||||||
|
// Импорт стилей Bootstrap
|
||||||
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
|
|
||||||
|
// Создаем приложение
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
// Используем роутер
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
|
// 2. Получаем экземпляр нашего auth "стора"
|
||||||
|
const { fetchUser } = useAuth();
|
||||||
|
|
||||||
|
// 3. Асинхронная самовызывающаяся функция для инициализации
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await fetchUser(); // Пытаемся загрузить пользователя по токену из localStorage
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка при начальной загрузке пользователя в main.js:", error);
|
||||||
|
// Здесь можно ничего не делать, logout уже должен был быть вызван внутри fetchUser при ошибке
|
||||||
|
} finally {
|
||||||
|
// После того как fetchUser отработал (успешно или нет), монтируем приложение
|
||||||
|
app.mount('#app');
|
||||||
|
console.log('Приложение Vue смонтировано.');
|
||||||
|
}
|
||||||
|
})();
|
78
src/router/index.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import HomeView from '../views/HomeView.vue';
|
||||||
|
// Импортируем функцию useAuth для проверки аутентификации
|
||||||
|
import { useAuth } from '../auth';
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Home',
|
||||||
|
component: HomeView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('../views/LoginView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/register',
|
||||||
|
name: 'Register',
|
||||||
|
component: () => import('../views/RegisterView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/profile',
|
||||||
|
name: 'Profile',
|
||||||
|
component: () => import('../views/ProfileView.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/swipe',
|
||||||
|
name: 'Swipe',
|
||||||
|
component: () => import('../views/SwipeView.vue'),
|
||||||
|
meta: { requiresAuth: true } // Помечаем, что этот маршрут требует аутентификации
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/chats', // Список всех чатов
|
||||||
|
name: 'ChatList',
|
||||||
|
component: () => import('../views/ChatListView.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/chat/:conversationId', // Конкретный чат
|
||||||
|
name: 'ChatView',
|
||||||
|
component: () => import('../views/ChatView.vue'), // Мы создадим этот компонент позже
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
props: true // Позволяет передавать :conversationId как пропс в компонент
|
||||||
|
}
|
||||||
|
// ... здесь будут другие маршруты: /profile, /swipe, /chat/:id и т.д.
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем навигационный guard для проверки аутентификации
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const { isAuthenticated } = useAuth(); // Получаем текущий статус аутентификации
|
||||||
|
|
||||||
|
// Проверяем, требует ли маршрут аутентификации и не аутентифицирован ли пользователь
|
||||||
|
if (to.meta.requiresAuth && !isAuthenticated.value) {
|
||||||
|
// Если да, перенаправляем на страницу входа
|
||||||
|
console.log(`[Router Guard] Доступ к ${to.path} запрещен. Пользователь не аутентифицирован. Перенаправление на /login.`);
|
||||||
|
next({ name: 'Login' }); // Или next('/login');
|
||||||
|
} else if ((to.name === 'Login' || to.name === 'Register') && isAuthenticated.value) {
|
||||||
|
// (Опционально) Если пользователь уже аутентифицирован и пытается зайти на /login или /register,
|
||||||
|
// перенаправляем его на главную или на /swipe
|
||||||
|
console.log(`[Router Guard] Пользователь аутентифицирован. Перенаправление с ${to.path} на /.`);
|
||||||
|
next({ name: 'Home' }); // или next('/');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// В противном случае разрешаем навигацию
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Убедимся, что экспорт работает правильно
|
||||||
|
export { router };
|
||||||
|
export default router;
|
100
src/services/api.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { useAuth } from '../auth'; // Импортируем функцию авторизации
|
||||||
|
|
||||||
|
// Создаем экземпляр Axios с базовым URL нашего API
|
||||||
|
// Настраиваем базовый URL в зависимости от окружения
|
||||||
|
|
||||||
|
// Определяем базовый URL в зависимости от среды выполнения
|
||||||
|
const getBaseUrl = () => {
|
||||||
|
// Всегда используем относительный путь /api.
|
||||||
|
// В режиме разработки Vite dev server будет проксировать эти запросы согласно настройке server.proxy в vite.config.js.
|
||||||
|
// В продакшене предполагается, что веб-сервер (например, Nginx) настроен аналогично для обработки /api.
|
||||||
|
// return '/api';
|
||||||
|
return import.meta.env.VITE_API_BASE_URL; // Используем переменную окружения Vite
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: getBaseUrl(),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// (Опционально, но очень полезно) Перехватчик для добавления JWT токена к запросам
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('userToken'); // Предполагаем, что токен хранится в localStorage
|
||||||
|
if (token) {
|
||||||
|
console.log('[API] Добавление токена к запросу:', config.url);
|
||||||
|
config.headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
} else {
|
||||||
|
console.log('[API] Токен не найден для запроса:', config.url);
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('[API] Ошибка в перехватчике запросов:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// (Опционально) Перехватчик ответов для обработки глобальных ошибок, например, 401
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response && error.response.status === 401) {
|
||||||
|
console.error('[API] Неавторизованный запрос (401):', error.config.url);
|
||||||
|
|
||||||
|
// Обходим замыкание, чтобы избежать проблем с импортом
|
||||||
|
// При использовании такого подхода, logout будет вызван после текущего цикла выполнения JS
|
||||||
|
setTimeout(() => {
|
||||||
|
const { logout } = useAuth();
|
||||||
|
logout();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// Экспортируем функции для конкретных эндпоинтов
|
||||||
|
export default {
|
||||||
|
register(userData) {
|
||||||
|
return apiClient.post('/auth/register', userData);
|
||||||
|
},
|
||||||
|
login(credentials) {
|
||||||
|
return apiClient.post('/auth/login', credentials);
|
||||||
|
},
|
||||||
|
getMe() { // Для получения данных пользователя
|
||||||
|
return apiClient.get('/auth/me');
|
||||||
|
},
|
||||||
|
updateUserProfile(profileData) {
|
||||||
|
console.log('[API] Отправка запроса на обновление профиля:', profileData);
|
||||||
|
return apiClient.put('/users/profile', profileData);
|
||||||
|
},
|
||||||
|
getSuggestions() {
|
||||||
|
return apiClient.get('/users/suggestions');
|
||||||
|
},
|
||||||
|
likeUser(userId) {
|
||||||
|
return apiClient.post(`/actions/like/${userId}`);
|
||||||
|
},
|
||||||
|
passUser(userId) {
|
||||||
|
return apiClient.post(`/actions/pass/${userId}`);
|
||||||
|
},
|
||||||
|
uploadUserProfilePhoto(formData) {
|
||||||
|
return apiClient.post('/users/profile/photo', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data', // Важно для FormData
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getUserConversations() {
|
||||||
|
return apiClient.get('/conversations'); // GET /api/conversations
|
||||||
|
},
|
||||||
|
getMessagesForConversation(conversationId) {
|
||||||
|
return apiClient.get(`/conversations/${conversationId}/messages`); // GET /api/conversations/:id/messages
|
||||||
|
},
|
||||||
|
deleteMessage(conversationId, messageId) {
|
||||||
|
return apiClient.delete(`/conversations/${conversationId}/messages/${messageId}`);
|
||||||
|
}
|
||||||
|
};
|
101
src/services/socketService.js
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { io } from 'socket.io-client';
|
||||||
|
import { useAuth } from '@/auth'; // Чтобы получить ID текущего пользователя
|
||||||
|
|
||||||
|
let socket;
|
||||||
|
|
||||||
|
// const SOCKET_URL = 'http://localhost:5000'; // Закомментируем, так как Vite будет проксировать
|
||||||
|
// Для разработки через Vite proxy, URL должен быть относительным к домену фронтенда
|
||||||
|
const SOCKET_URL = '/'; // Vite будет перехватывать /socket.io/ путь
|
||||||
|
|
||||||
|
export const connectSocket = () => {
|
||||||
|
const { user, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
if (isAuthenticated.value && user.value && user.value._id) {
|
||||||
|
// Подключаемся только если пользователь аутентифицирован и есть ID
|
||||||
|
// Передаем userId в query для идентификации на сервере при подключении (альтернатива событию addUser)
|
||||||
|
// Однако, мы используем событие addUser, так что query здесь может быть избыточен, если сервер его не использует при connect.
|
||||||
|
// Оставим его, если вдруг понадобится.
|
||||||
|
// socket = io(SOCKET_URL, {
|
||||||
|
// query: { userId: user.value._id }
|
||||||
|
// });
|
||||||
|
|
||||||
|
// При использовании Vite proxy, путь для Socket.IO должен быть относительным,
|
||||||
|
// а Vite добавит префикс /socket.io/ к target в proxy.
|
||||||
|
// Однако, клиентская библиотека socket.io обычно сама добавляет /socket.io/
|
||||||
|
// поэтому мы можем просто указать базовый URL (который будет проксироваться)
|
||||||
|
// или указать полный путь, который будет проксирован.
|
||||||
|
// Для ясности и чтобы соответствовать настройке proxy, можно использовать path.
|
||||||
|
socket = io(SOCKET_URL, {
|
||||||
|
path: '/socket.io/', // Явно указываем путь, который будет проксироваться
|
||||||
|
transports: ['websocket', 'polling'] // Явно указываем транспорты для лучшей совместимости
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[SocketService] Попытка подключения к Socket.IO серверу через Vite proxy...');
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('[SocketService] Успешно подключен к Socket.IO. Socket ID:', socket.id);
|
||||||
|
// Отправляем ID пользователя на сервер, чтобы он знал, кто подключился
|
||||||
|
socket.emit('addUser', user.value._id);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', (reason) => {
|
||||||
|
console.log('[SocketService] Отключен от Socket.IO. Причина:', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', (error) => {
|
||||||
|
console.error('[SocketService] Ошибка подключения к Socket.IO:', error.message, error.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Можно здесь же слушать глобальные события, если нужно
|
||||||
|
// socket.on('getUsers', (users) => {
|
||||||
|
// console.log('[SocketService] Получен список активных пользователей:', users);
|
||||||
|
// // Тут можно обновить какой-нибудь стор активных пользователей
|
||||||
|
// });
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
} else {
|
||||||
|
console.warn('[SocketService] Не удалось подключиться: пользователь не аутентифицирован или нет ID.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const disconnectSocket = () => {
|
||||||
|
if (socket) {
|
||||||
|
console.log('[SocketService] Отключение от Socket.IO...');
|
||||||
|
socket.disconnect();
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSocket = () => {
|
||||||
|
if (!socket) {
|
||||||
|
console.warn('[SocketService] getSocket вызван, но сокет не инициализирован. Попытка подключения...');
|
||||||
|
// Можно попробовать подключиться здесь, если это желаемое поведение,
|
||||||
|
// но лучше управлять подключением более явно.
|
||||||
|
// return connectSocket(); // Будь осторожен с рекурсией или множественными подключениями
|
||||||
|
}
|
||||||
|
return socket;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функции для отправки и прослушивания специфичных событий можно добавить здесь
|
||||||
|
// или использовать getSocket() в компонентах и вызывать socket.emit / socket.on там.
|
||||||
|
|
||||||
|
// Пример:
|
||||||
|
// export const sendMessageOnSocket = (messageData) => {
|
||||||
|
// const currentSocket = getSocket();
|
||||||
|
// if (currentSocket) {
|
||||||
|
// currentSocket.emit('sendMessage', messageData);
|
||||||
|
// } else {
|
||||||
|
// console.error('[SocketService] Не могу отправить сообщение: сокет не подключен.');
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// export const listenForMessage = (callback) => {
|
||||||
|
// const currentSocket = getSocket();
|
||||||
|
// if (currentSocket) {
|
||||||
|
// currentSocket.on('getMessage', callback);
|
||||||
|
// // Возвращаем функцию для отписки
|
||||||
|
// return () => currentSocket.off('getMessage', callback);
|
||||||
|
// }
|
||||||
|
// return () => {}; // Пустая функция отписки, если сокета нет
|
||||||
|
// };
|
369
src/views/ChatListView.vue
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chat-list-view container mt-4">
|
||||||
|
<h2 class="text-center mb-4">Мои диалоги</h2>
|
||||||
|
|
||||||
|
<div v-if="loading" class="d-flex justify-content-center my-5">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка диалогов...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger text-center">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading && conversations.length === 0 && !error" class="alert alert-info text-center">
|
||||||
|
У вас пока нет активных диалогов. <br>
|
||||||
|
Возможно, стоит кого-нибудь лайкнуть, чтобы началось общение!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading && conversations.length > 0 && !error" class="list-group">
|
||||||
|
<router-link
|
||||||
|
v-for="conversation in sortedConversations"
|
||||||
|
:key="conversation._id"
|
||||||
|
:to="{ name: 'ChatView', params: { conversationId: conversation._id } }"
|
||||||
|
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center mb-2 shadow-sm rounded"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<!-- === ОТОБРАЖЕНИЕ ФОТО/ЗАГЛУШКИ === -->
|
||||||
|
<div class="image-placeholder-container me-3">
|
||||||
|
<img
|
||||||
|
v-if="getOtherParticipant(conversation.participants)?.mainPhotoUrl"
|
||||||
|
:src="getParticipantAvatar(getOtherParticipant(conversation.participants))"
|
||||||
|
alt="Avatar"
|
||||||
|
class="rounded-circle user-photo"
|
||||||
|
@error="onImageError($event, getOtherParticipant(conversation.participants))"
|
||||||
|
/>
|
||||||
|
<div v-else class="no-photo-placeholder rounded-circle d-flex align-items-center justify-content-center">
|
||||||
|
<i class="bi bi-person-fill"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- ================================ -->
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-1">{{ getOtherParticipant(conversation.participants)?.name || 'Неизвестный пользователь' }}</h5>
|
||||||
|
<small class="text-muted last-message-text">
|
||||||
|
{{ conversation.lastMessage ? formatLastMessage(conversation.lastMessage) : 'Нет сообщений' }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<small class="text-muted d-block">{{ formatTimestamp(conversation.updatedAt) }}</small>
|
||||||
|
<span v-if="getUnreadCount(conversation) > 0" class="badge bg-primary rounded-pill unread-indicator">
|
||||||
|
{{ getUnreadCount(conversation) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed, onUnmounted } from 'vue';
|
||||||
|
import api from '@/services/api';
|
||||||
|
import { useAuth } from '@/auth'; // Для получения ID текущего пользователя
|
||||||
|
import router from '@/router'; // Если понадобится редирект
|
||||||
|
import { getSocket, connectSocket } from '@/services/socketService'; // ИСПРАВЛЕННЫЙ ПУТЬ
|
||||||
|
|
||||||
|
const { user: currentUser, isAuthenticated } = useAuth(); // Получаем текущего пользователя
|
||||||
|
|
||||||
|
const conversations = ref([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
// Добавляем ref для хранения количества непрочитанных сообщений
|
||||||
|
const unreadMessageCounts = ref({}); // { conversationId: count }
|
||||||
|
const defaultAvatar = '/img/default-avatar.png'; // Путь к заглушке аватара в public/img/
|
||||||
|
let socket = null; // Для прослушивания обновлений о прочтении
|
||||||
|
|
||||||
|
const fetchConversations = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
console.log('[ChatListView] Запрос списка диалогов...');
|
||||||
|
const response = await api.getUserConversations();
|
||||||
|
conversations.value = response.data;
|
||||||
|
console.log('[ChatListView] Диалоги загружены:', conversations.value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ChatListView] Ошибка при загрузке диалогов:', err.response ? err.response.data : err.message);
|
||||||
|
error.value = (err.response?.data?.message) || 'Не удалось загрузить диалоги.';
|
||||||
|
if (err.response && err.response.status === 401) {
|
||||||
|
// Если 401, возможно, нужно перенаправить на логин
|
||||||
|
// const { logout } = useAuth(); // Если logout не импортирован
|
||||||
|
// await logout();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сортируем диалоги по дате последнего сообщения/обновления (новые сверху)
|
||||||
|
const sortedConversations = computed(() => {
|
||||||
|
return [...conversations.value].sort((a, b) => {
|
||||||
|
// Бэкенд уже сортирует по updatedAt, но если lastMessageTimestamp есть, он точнее
|
||||||
|
const dateA = new Date(a.lastMessage?.createdAt || a.updatedAt);
|
||||||
|
const dateB = new Date(b.lastMessage?.createdAt || b.updatedAt);
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновленная функция для подсчета непрочитанных сообщений
|
||||||
|
const getUnreadCount = (conversation) => {
|
||||||
|
const countFromSocketLogic = unreadMessageCounts.value[conversation._id];
|
||||||
|
if (countFromSocketLogic && countFromSocketLogic > 0) {
|
||||||
|
return countFromSocketLogic;
|
||||||
|
}
|
||||||
|
// Fallback к старой логике, если новый счетчик 0 (или не инициализирован для старых чатов),
|
||||||
|
// но lastMessage не прочитано текущим пользователем.
|
||||||
|
if (currentUser.value && conversation.lastMessage &&
|
||||||
|
conversation.lastMessage.sender?._id !== currentUser.value?._id &&
|
||||||
|
(!conversation.lastMessage.readBy || !conversation.lastMessage.readBy.includes(currentUser.value._id))) {
|
||||||
|
return 1; // Показываем, что есть хотя бы одно непрочитанное (последнее)
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Получаем другого участника диалога
|
||||||
|
const getOtherParticipant = (participants) => {
|
||||||
|
if (!currentUser.value || !participants || participants.length < 2) return null;
|
||||||
|
return participants.find(p => p._id !== currentUser.value._id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Новая функция для получения URL аватара (исправленная)
|
||||||
|
const getParticipantAvatar = (participant) => {
|
||||||
|
if (participant && participant.mainPhotoUrl && typeof participant.mainPhotoUrl === 'string' && participant.mainPhotoUrl.trim() !== '') {
|
||||||
|
return participant.mainPhotoUrl.trim();
|
||||||
|
}
|
||||||
|
// Важно: если mainPhotoUrl нет или он невалиден, мы НЕ возвращаем defaultAvatar здесь,
|
||||||
|
// так как v-else в шаблоне должен показать заглушку <i class="bi bi-person-fill"></i>
|
||||||
|
// Однако, onImageError все еще может использовать defaultAvatar, если URL есть, но картинка не грузится.
|
||||||
|
return null; // или undefined, чтобы v-if="...mainPhotoUrl" работал как ожидается
|
||||||
|
};
|
||||||
|
|
||||||
|
// Форматирование последнего сообщения (кратко)
|
||||||
|
const formatLastMessage = (lastMsg) => {
|
||||||
|
if (!lastMsg || !lastMsg.text) return 'Нет сообщений';
|
||||||
|
const prefix = lastMsg.sender?._id === currentUser.value?._id ? 'Вы: ' : '';
|
||||||
|
return prefix + (lastMsg.text.length > 30 ? lastMsg.text.substring(0, 27) + '...' : lastMsg.text);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Форматирование времени последнего сообщения/обновления
|
||||||
|
const formatTimestamp = (timestamp) => {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
if (date.toDateString() === today.toDateString()) {
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return 'Вчера';
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString([], { day: '2-digit', month: 'short' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onImageError = (event, participantOnError) => {
|
||||||
|
console.warn("[ChatListView] Не удалось загрузить изображение:", event.target.src, "для пользователя:", participantOnError?.name);
|
||||||
|
// Устанавливаем src на заглушку из public/img, если фото не загрузилось.
|
||||||
|
// Это для случая, когда mainPhotoUrl был, но оказался битым.
|
||||||
|
// Если mainPhotoUrl изначально не было, сработает v-else с иконкой.
|
||||||
|
event.target.src = defaultAvatar;
|
||||||
|
// Можно добавить класс, чтобы скрыть img и показать div-заглушку, если это необходимо,
|
||||||
|
// но обычно @error на img и последующая замена src на defaultAvatar достаточны.
|
||||||
|
// Однако, для консистенции с SwipeView, лучше убедиться, что заглушка-иконка показывается.
|
||||||
|
// Для этого можно попробовать скрыть img и показать соседний div с иконкой,
|
||||||
|
// но это усложнит логику, так как v-if/v-else уже управляют этим.
|
||||||
|
// Простейший вариант - просто заменить на defaultAvatar.png.
|
||||||
|
// Если хотим именно иконку, то нужно будет менять состояние, чтобы v-else сработало.
|
||||||
|
// Например, установить participantOnError.mainPhotoUrl = null;
|
||||||
|
// Это вызовет обновление DOM и показ блока v-else.
|
||||||
|
if (participantOnError) {
|
||||||
|
// Ищем диалог, где этот участник, и модифицируем его mainPhotoUrl,
|
||||||
|
// чтобы вызвать реактивное обновление и показ заглушки-иконки
|
||||||
|
const conversationToUpdate = conversations.value.find(conv =>
|
||||||
|
conv.participants.some(p => p._id === participantOnError._id)
|
||||||
|
);
|
||||||
|
if (conversationToUpdate) {
|
||||||
|
const participantInConversation = conversationToUpdate.participants.find(p => p._id === participantOnError._id);
|
||||||
|
if (participantInConversation) {
|
||||||
|
participantInConversation.mainPhotoUrl = null; // Это вызовет показ v-else
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработчик события messagesRead для обновления UI списка чатов
|
||||||
|
const handleMessagesReadInList = ({ conversationId: readConversationId, readerId }) => {
|
||||||
|
// Если текущий пользователь прочитал сообщения в каком-то чате (событие от ChatView через сервер)
|
||||||
|
// или если собеседник прочитал наши сообщения.
|
||||||
|
// Нам нужно обновить состояние `conversations` чтобы убрать индикатор непрочитанных.
|
||||||
|
|
||||||
|
const convIndex = conversations.value.findIndex(c => c._id === readConversationId);
|
||||||
|
if (convIndex > -1) {
|
||||||
|
const conv = conversations.value[convIndex];
|
||||||
|
// Если Я (currentUser) прочитал сообщения собеседника в этом диалоге
|
||||||
|
if (readerId === currentUser.value?._id && conv.lastMessage && conv.lastMessage.sender?._id !== currentUser.value?._id) {
|
||||||
|
if (!conv.lastMessage.readBy) conv.lastMessage.readBy = [];
|
||||||
|
if (!conv.lastMessage.readBy.includes(readerId)) {
|
||||||
|
conv.lastMessage.readBy.push(readerId);
|
||||||
|
// conversations.value[convIndex] = { ...conv }; // Вызываем реактивность
|
||||||
|
// Vue 3 должен автоматически отслеживать изменения в массиве/объекте
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Если Собеседник прочитал НАШИ сообщения (это больше для ChatView, но если хотим обновить тут что-то)
|
||||||
|
// В данном контексте (список чатов), это событие в основном означает, что МЫ прочитали,
|
||||||
|
// и если у нас был индикатор непрочитанных от собеседника, он должен исчезнуть.
|
||||||
|
// Перезапрос fetchConversations() может быть слишком тяжелым.
|
||||||
|
// Лучше, если сервер присылает обновленный объект диалога или мы мутируем локально.
|
||||||
|
console.log(`[ChatListView] Messages read in conversation ${readConversationId}, reader: ${readerId}. Updating UI.`);
|
||||||
|
// Форсируем обновление, если Vue не подхватывает изменение в глубине объекта
|
||||||
|
conversations.value = [...conversations.value];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
fetchConversations(); // unreadMessageCounts будет инициализирован или очищен внутри fetchConversations при необходимости
|
||||||
|
|
||||||
|
socket = getSocket();
|
||||||
|
if (!socket) {
|
||||||
|
socket = connectSocket();
|
||||||
|
}
|
||||||
|
if (socket) {
|
||||||
|
// Слушаем, если сообщения были прочитаны в каком-либо из диалогов
|
||||||
|
socket.on('messagesRead', ({ conversationId: readConversationId, readerId }) => {
|
||||||
|
// Это событие от сервера после того, как кто-то прочитал сообщения
|
||||||
|
// Нас интересует, когда ТЕКУЩИЙ пользователь прочитал сообщения в каком-то диалоге
|
||||||
|
if (readerId === currentUser.value?._id) {
|
||||||
|
console.log(`[ChatListView] Current user read messages in ${readConversationId}. Resetting unread count.`);
|
||||||
|
unreadMessageCounts.value[readConversationId] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем состояние readBy для lastMessage для консистентности отображения
|
||||||
|
// и для корректной работы fallback-логики в getUnreadCount
|
||||||
|
const convIndex = conversations.value.findIndex(c => c._id === readConversationId);
|
||||||
|
if (convIndex > -1) {
|
||||||
|
const conv = conversations.value[convIndex];
|
||||||
|
if (conv.lastMessage) {
|
||||||
|
let changed = false;
|
||||||
|
if (readerId === currentUser.value?._id && conv.lastMessage.sender?._id !== currentUser.value?._id) { // Я прочитал сообщение другого
|
||||||
|
if (!conv.lastMessage.readBy) conv.lastMessage.readBy = [];
|
||||||
|
if (!conv.lastMessage.readBy.includes(readerId)) {
|
||||||
|
conv.lastMessage.readBy.push(readerId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if (conv.lastMessage.sender?._id === currentUser.value?._id && readerId !== currentUser.value?._id) { // Другой прочитал мое сообщение
|
||||||
|
if (!conv.lastMessage.readBy) conv.lastMessage.readBy = [];
|
||||||
|
if (!conv.lastMessage.readBy.includes(readerId)) {
|
||||||
|
conv.lastMessage.readBy.push(readerId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
// Форсируем обновление, если Vue не подхватывает глубокие изменения
|
||||||
|
conversations.value = [...conversations.value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('getMessage', (newMessage) => {
|
||||||
|
const convIndex = conversations.value.findIndex(c => c._id === newMessage.conversationId);
|
||||||
|
if (convIndex > -1) {
|
||||||
|
const conversation = conversations.value[convIndex];
|
||||||
|
conversation.lastMessage = newMessage;
|
||||||
|
conversation.updatedAt = newMessage.createdAt; // Обновляем время для сортировки
|
||||||
|
|
||||||
|
// Если сообщение не от текущего пользователя и для этого диалога
|
||||||
|
if (newMessage.sender?._id !== currentUser.value?._id) {
|
||||||
|
unreadMessageCounts.value[newMessage.conversationId] =
|
||||||
|
(unreadMessageCounts.value[newMessage.conversationId] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пересортировываем массив диалогов, чтобы актуальный чат поднялся вверх
|
||||||
|
conversations.value.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.lastMessage?.createdAt || a.updatedAt);
|
||||||
|
const dateB = new Date(b.lastMessage?.createdAt || b.updatedAt);
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Если пришло сообщение для НОВОГО диалога, которого еще нет в списке,
|
||||||
|
// лучшим решением будет перезапросить все диалоги, чтобы он корректно отобразился.
|
||||||
|
// Это может случиться, если диалог был создан другим пользователем только что.
|
||||||
|
console.log(`[ChatListView] Received message for a new or unknown conversation ${newMessage.conversationId}. Refetching conversations.`);
|
||||||
|
fetchConversations();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
error.value = "Пожалуйста, войдите в систему, чтобы просматривать диалоги.";
|
||||||
|
loading.value = false;
|
||||||
|
// Можно перенаправить на логин здесь или положиться на router guard
|
||||||
|
// router.push('/login');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем onUnmounted для отписки от событий сокета
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (socket) {
|
||||||
|
socket.off('messagesRead'); // Используем то же имя события, что и в onMounted
|
||||||
|
socket.off('getMessage');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-list-view {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
.list-group-item {
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
.list-group-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.last-message-text {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-clamp: 1; /* Стандартное свойство */
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-placeholder-container {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
position: relative;
|
||||||
|
background-color: #e9ecef; /* Фон для контейнера, если фото не покроет его полностью или для заглушки */
|
||||||
|
border-radius: 50%; /* Делаем контейнер круглым */
|
||||||
|
overflow: hidden; /* Обрезаем все, что выходит за круглые границы */
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-photo, .no-photo-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover; /* Для img */
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-photo-placeholder i {
|
||||||
|
font-size: 2.5rem; /* Размер иконки, подберите по вкусу для 50px контейнера */
|
||||||
|
color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group-item .badge {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-indicator {
|
||||||
|
/* Можно сделать более заметным, например, жирным шрифтом или цветом */
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
694
src/views/ChatView.vue
Normal file
@ -0,0 +1,694 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chat-view-container d-flex flex-column">
|
||||||
|
<div v-if="loadingInitialMessages" class="flex-grow-1 d-flex justify-content-center align-items-center">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка сообщений...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="alert alert-danger m-3">{{ error }}</div>
|
||||||
|
|
||||||
|
<div v-if="!loadingInitialMessages && !conversationData" class="alert alert-warning m-3">
|
||||||
|
Не удалось загрузить данные диалога.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="conversationData && !loadingInitialMessages" class="chat-header p-3 bg-light border-bottom">
|
||||||
|
<h5 class="mb-0">Чат с {{ getOtherParticipantName() || 'Собеседник' }}</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="messagesContainer" class="messages-area flex-grow-1 p-3" style="overflow-y: auto;">
|
||||||
|
<template v-for="item in messagesWithDateSeparators" :key="item.id">
|
||||||
|
<div v-if="item.type === 'date_separator'" class="date-separator text-center my-2">
|
||||||
|
<span class="badge bg-secondary-subtle text-dark-emphasis">{{ formatDateHeader(item.timestamp) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="item.type === 'message'"
|
||||||
|
:class="['message-item-wrapper', item.data.sender?._id === currentUser?._id ? 'sent-wrapper' : 'received-wrapper']"
|
||||||
|
@mouseenter="!isTouchDevice.value && handleMouseEnter(item.data)"
|
||||||
|
@mouseleave="!isTouchDevice.value && handleMouseLeave()"
|
||||||
|
|
||||||
|
>
|
||||||
|
<div :class="['message-bubble', item.data.sender?._id === currentUser?._id ? 'sent' : 'received']"
|
||||||
|
@click.stop="handleMessageTap(item.data, $event)"
|
||||||
|
@contextmenu.stop="handleNativeContextMenu($event, item.data)">
|
||||||
|
<div class="message-content">
|
||||||
|
<p class="mb-0">{{ item.data.text }}</p>
|
||||||
|
<small class="message-timestamp text-muted">
|
||||||
|
{{ formatMessageTimestamp(item.data.createdAt) }}
|
||||||
|
<span v-if="item.data.sender?._id === currentUser?._id && item.data.isSending" class="ms-1 fst-italic">(отправка...)</span>
|
||||||
|
<span v-if="item.data.sender?._id === currentUser?._id && !item.data.isSending" class="ms-1">
|
||||||
|
<i :class="getMessageStatusIcon(item.data)"></i>
|
||||||
|
</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Кнопка удаления для ПК (появляется при наведении) -->
|
||||||
|
<button v-if="!isTouchDevice.value && hoveredMessageId === item.data._id && item.data.sender?._id === currentUser?._id"
|
||||||
|
@click="confirmDeleteMessage(item.data._id)"
|
||||||
|
class="btn btn-sm btn-danger delete-message-btn"
|
||||||
|
title="Удалить сообщение">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Контекстное меню для мобильных устройств -->
|
||||||
|
<div v-if="contextMenu && contextMenu.visible"
|
||||||
|
ref="contextMenuRef"
|
||||||
|
class="context-menu"
|
||||||
|
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }">
|
||||||
|
<ul>
|
||||||
|
<li @click="confirmDeleteMessage(contextMenu.messageId)">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
<span>Удалить сообщение</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Индикатор "печатает" -->
|
||||||
|
<div v-if="otherUserIsTyping" class="typing-indicator px-3 pb-2 text-muted fst-italic">
|
||||||
|
{{ getOtherParticipantName() || 'Собеседник' }} печатает...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isAuthenticated" class="message-input-area p-3 border-top bg-light">
|
||||||
|
<form @submit.prevent="sendMessage" class="d-flex">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="newMessageText"
|
||||||
|
class="form-control me-2"
|
||||||
|
placeholder="Введите сообщение..."
|
||||||
|
:disabled="sendingMessage"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="sendingMessage || !newMessageText.trim()">
|
||||||
|
<span v-if="sendingMessage" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, nextTick, computed, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import api from '@/services/api';
|
||||||
|
import { useAuth } from '@/auth';
|
||||||
|
import { getSocket, connectSocket, disconnectSocket } from '@/services/socketService';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const { user: currentUser, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
const isTouchDevice = ref(false);
|
||||||
|
const hoveredMessageId = ref(null);
|
||||||
|
const contextMenu = ref({ visible: false, x: 0, y: 0, messageId: null });
|
||||||
|
const contextMenuRef = ref(null);
|
||||||
|
|
||||||
|
const handleMouseEnter = (message) => {
|
||||||
|
if (!isTouchDevice.value) {
|
||||||
|
hoveredMessageId.value = message._id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (!isTouchDevice.value) {
|
||||||
|
hoveredMessageId.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// New handler for tap/click
|
||||||
|
const handleMessageTap = (message, event) => {
|
||||||
|
if (message.sender?._id === currentUser.value?._id) {
|
||||||
|
// event.preventDefault(); // Already handled by contextmenu or default click behavior if not a link
|
||||||
|
// event.stopPropagation(); // Handled by .stop modifier in template
|
||||||
|
showContextMenu(message, event);
|
||||||
|
}
|
||||||
|
// For messages from other users, allow default behavior (e.g., text selection, link clicks)
|
||||||
|
};
|
||||||
|
|
||||||
|
const showContextMenu = (message, eventSource) => {
|
||||||
|
if (message.sender?._id === currentUser.value?._id) {
|
||||||
|
contextMenu.value.messageId = message._id;
|
||||||
|
let x = eventSource.clientX;
|
||||||
|
let y = eventSource.clientY;
|
||||||
|
|
||||||
|
// Set initial position and make visible to calculate dimensions
|
||||||
|
contextMenu.value.x = x;
|
||||||
|
contextMenu.value.y = y;
|
||||||
|
contextMenu.value.visible = true;
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
if (contextMenuRef.value) {
|
||||||
|
const menuWidth = contextMenuRef.value.offsetWidth;
|
||||||
|
const menuHeight = contextMenuRef.value.offsetHeight;
|
||||||
|
const screenWidth = window.innerWidth;
|
||||||
|
const screenHeight = window.innerHeight;
|
||||||
|
const PADDING = 10; // Screen edge padding
|
||||||
|
|
||||||
|
if (x + menuWidth > screenWidth - PADDING) {
|
||||||
|
x = screenWidth - menuWidth - PADDING;
|
||||||
|
}
|
||||||
|
if (x < PADDING) {
|
||||||
|
x = PADDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (y + menuHeight > screenHeight - PADDING) {
|
||||||
|
y = screenHeight - menuHeight - PADDING;
|
||||||
|
}
|
||||||
|
if (y < PADDING) {
|
||||||
|
y = PADDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
contextMenu.value.x = x;
|
||||||
|
contextMenu.value.y = y;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
contextMenu.value.visible = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversationId = ref(route.params.conversationId);
|
||||||
|
const conversationData = ref(null); // Для информации о диалоге (например, участники)
|
||||||
|
const messages = ref([]);
|
||||||
|
const newMessageText = ref('');
|
||||||
|
|
||||||
|
const loadingInitialMessages = ref(true);
|
||||||
|
const sendingMessage = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
let socket = null;
|
||||||
|
const otherUserIsTyping = ref(false); // Для отображения индикатора, что собеседник печатает
|
||||||
|
let stopTypingEmitTimer = null; // Новое имя таймера для отправки stopTyping
|
||||||
|
const userHasSentTypingEvent = ref(false); // Отслеживает, отправляли ли 'typing' для текущей сессии набора
|
||||||
|
const TYPING_TIMER_LENGTH = 1500; // мс, через сколько отправлять stopTyping
|
||||||
|
|
||||||
|
const messagesWithDateSeparators = computed(() => {
|
||||||
|
if (!messages.value || messages.value.length === 0) return [];
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
let lastDate = null;
|
||||||
|
|
||||||
|
messages.value.forEach(message => {
|
||||||
|
const messageTimestamp = message.createdAt || Date.now(); // Fallback for temp messages if needed
|
||||||
|
const messageDate = new Date(messageTimestamp).toDateString();
|
||||||
|
if (messageDate !== lastDate) {
|
||||||
|
result.push({ type: 'date_separator', timestamp: messageTimestamp, id: `date-${messageTimestamp}-${message._id || Math.random()}` });
|
||||||
|
lastDate = messageDate;
|
||||||
|
}
|
||||||
|
result.push({ type: 'message', data: message, id: message._id || message.tempId || `msg-${Math.random()}` });
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDateHeader = (timestamp) => {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
if (date.toDateString() === today.toDateString()) {
|
||||||
|
return 'Сегодня';
|
||||||
|
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return 'Вчера';
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString([], { day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для отметки сообщений как прочитанных
|
||||||
|
const markMessagesAsReadOnServer = () => {
|
||||||
|
if (socket && conversationId.value && currentUser.value?._id) {
|
||||||
|
// Проверим, есть ли вообще непрочитанные сообщения от собеседника
|
||||||
|
const unreadMessagesFromOther = messages.value.some(
|
||||||
|
msg => msg.sender?._id !== currentUser.value._id &&
|
||||||
|
(!msg.readBy || !msg.readBy.includes(currentUser.value._id))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (unreadMessagesFromOther) {
|
||||||
|
console.log('[ChatView] Отправка события markMessagesAsRead для диалога:', conversationId.value);
|
||||||
|
socket.emit('markMessagesAsRead', {
|
||||||
|
conversationId: conversationId.value,
|
||||||
|
userId: currentUser.value._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для прокрутки к последнему сообщению
|
||||||
|
const messagesContainer = ref(null);
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (messagesContainer.value) {
|
||||||
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchInitialMessages = async () => {
|
||||||
|
if (!conversationId.value) return;
|
||||||
|
loadingInitialMessages.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
console.log(`[ChatView] Запрос истории сообщений для диалога: ${conversationId.value}`);
|
||||||
|
const response = await api.getMessagesForConversation(conversationId.value);
|
||||||
|
// Теперь API возвращает объект { conversation: {}, messages: [] }
|
||||||
|
messages.value = response.data.messages || [];
|
||||||
|
conversationData.value = response.data.conversation; // <--- СОХРАНЯЕМ ДАННЫЕ ДИАЛОГА
|
||||||
|
|
||||||
|
console.log('[ChatView] Сообщения загружены:', messages.value);
|
||||||
|
console.log('[ChatView] Данные диалога загружены:', conversationData.value); // Для отладки
|
||||||
|
scrollToBottom();
|
||||||
|
markMessagesAsReadOnServer(); // Отмечаем как прочитанные при загрузке
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ChatView] Ошибка загрузки сообщений:', err);
|
||||||
|
error.value = err.response?.data?.message || 'Не удалось загрузить сообщения.';
|
||||||
|
} finally {
|
||||||
|
loadingInitialMessages.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOtherParticipant = () => {
|
||||||
|
if (conversationData.value && conversationData.value.participants && currentUser.value) {
|
||||||
|
return conversationData.value.participants.find(p => p._id !== currentUser.value._id);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const getOtherParticipantName = () => getOtherParticipant()?.name;
|
||||||
|
|
||||||
|
|
||||||
|
const sendMessage = () => {
|
||||||
|
if (!newMessageText.value.trim() || !socket || !currentUser.value || sendingMessage.value) return;
|
||||||
|
|
||||||
|
const otherParticipant = getOtherParticipant(); // Нужно получить ID получателя!
|
||||||
|
// Это слабое место, если conversationData не загружено правильно.
|
||||||
|
// Для теста можно временно захардкодить receiverId, если знаешь его.
|
||||||
|
// Или передать его из ChatListView.
|
||||||
|
let receiverId;
|
||||||
|
// ВАЖНО: Логика получения receiverId должна быть надежной.
|
||||||
|
// Например, если мы перешли из списка чатов, мы можем передать инфо о собеседнике.
|
||||||
|
// Или при загрузке диалога, API должен вернуть участников.
|
||||||
|
// Для примера, если бы conversationData было установлено:
|
||||||
|
if (otherParticipant) {
|
||||||
|
receiverId = otherParticipant._id;
|
||||||
|
} else {
|
||||||
|
console.error("Не удалось определить получателя!");
|
||||||
|
error.value = "Не удалось определить получателя для отправки сообщения.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageData = {
|
||||||
|
senderId: currentUser.value._id,
|
||||||
|
receiverId: receiverId, // ID получателя
|
||||||
|
text: newMessageText.value,
|
||||||
|
conversationId: conversationId.value, // Можно отправлять, если нужно на сервере
|
||||||
|
// tempId: Date.now().toString() // Временный ID для оптимистичного обновления UI
|
||||||
|
};
|
||||||
|
|
||||||
|
// Оптимистичное добавление (можно улучшить, добавив статус isSending)
|
||||||
|
// const tempMessage = { ...messageData, createdAt: new Date().toISOString(), sender: currentUser.value, tempId: messageData.tempId, isSending: true };
|
||||||
|
// messages.value.push(tempMessage);
|
||||||
|
// scrollToBottom();
|
||||||
|
|
||||||
|
console.log('[ChatView] Отправка сообщения через сокет:', messageData);
|
||||||
|
sendingMessage.value = true;
|
||||||
|
socket.emit('sendMessage', messageData);
|
||||||
|
|
||||||
|
// sendingMessage будет false, когда придет подтверждение или ошибка от сервера (пока не реализовано)
|
||||||
|
// Пока просто сбросим через некоторое время или после получения своего же сообщения
|
||||||
|
// setTimeout(() => { sendingMessage.value = false; }, 1000);
|
||||||
|
newMessageText.value = ''; // Очищаем поле ввода
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewMessage = (message) => {
|
||||||
|
console.log('[ChatView] Получено новое сообщение:', message);
|
||||||
|
// Убедимся, что сообщение для текущего открытого диалога
|
||||||
|
if (message.conversationId === conversationId.value) {
|
||||||
|
messages.value.push(message);
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
// Если это сообщение от другого пользователя, и мы в фокусе, отмечаем как прочитанное
|
||||||
|
if (message.sender?._id !== currentUser.value?._id && document.hasFocus()) {
|
||||||
|
markMessagesAsReadOnServer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendingMessage.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMessageDeletedBySocket = (data) => {
|
||||||
|
console.log('[ChatView] Получено событие удаления сообщения от сокета:', data);
|
||||||
|
const { messageId, conversationId: convId } = data;
|
||||||
|
if (convId === conversationId.value) {
|
||||||
|
messages.value = messages.value.filter(msg => msg._id !== messageId);
|
||||||
|
// Дополнительно можно обновить UI, если это необходимо
|
||||||
|
// Например, если удаленное сообщение было последним, или для обновления счетчиков
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteMessage = (messageId) => {
|
||||||
|
// Логика подтверждения удаления (например, показать модальное окно)
|
||||||
|
// А затем, если пользователь подтвердил, вызвать deleteMessage
|
||||||
|
console.log('Confirm delete messageId:', messageId);
|
||||||
|
// Пока просто вызываем deleteMessage напрямую для примера
|
||||||
|
deleteMessage(messageId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMessage = async (messageId) => {
|
||||||
|
try {
|
||||||
|
// Оптимистичное удаление из UI
|
||||||
|
messages.value = messages.value.filter(msg => msg._id !== messageId);
|
||||||
|
// Отправка запроса на сервер
|
||||||
|
// ВАЖНО: Убедитесь, что conversationId.value передается в api.deleteMessage
|
||||||
|
await api.deleteMessage(conversationId.value, messageId);
|
||||||
|
console.log(`[ChatView] Сообщение ${messageId} удалено локально и на сервере.`);
|
||||||
|
// Сокет событие messageDeleted должно прийти от сервера и подтвердить удаление
|
||||||
|
// или обработать его для других клиентов. Если вы не хотите дублирования,
|
||||||
|
// можно не удалять из messages.value здесь, а дождаться события от сокета.
|
||||||
|
// Однако, оптимистичное удаление улучшает UX.
|
||||||
|
|
||||||
|
// Закрыть контекстное меню, если оно было открыто для этого сообщения
|
||||||
|
if (contextMenu.value && contextMenu.value.messageId === messageId) {
|
||||||
|
contextMenu.value.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[ChatView] Ошибка удаления сообщения ${messageId}:`, err);
|
||||||
|
error.value = err.response?.data?.message || 'Не удалось удалить сообщение.';
|
||||||
|
// Возможно, стоит вернуть сообщение в список, если удаление на сервере не удалось
|
||||||
|
// fetchInitialMessages(); // или более тонкое обновление
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработка события, что наши сообщения прочитаны собеседником
|
||||||
|
const handleMessagesReadByOther = ({ conversationId: readConversationId, readerId }) => {
|
||||||
|
if (readConversationId === conversationId.value) {
|
||||||
|
console.log(`[ChatView] Сообщения в диалоге ${readConversationId} прочитаны пользователем ${readerId}`);
|
||||||
|
// Обновляем статус сообщений, отправленных текущим пользователем
|
||||||
|
messages.value = messages.value.map(msg => {
|
||||||
|
if (msg.sender?._id === currentUser.value?._id) {
|
||||||
|
// Если сообщение отправлено нами и еще не имеет этого readerId в readBy
|
||||||
|
// (на случай если readBy не пришло сразу с сообщением или было обновлено)
|
||||||
|
// Это упрощенный вариант. В идеале, сервер должен присылать обновленные сообщения.
|
||||||
|
// Но для отображения галочек, достаточно знать, что ОДИН получатель (собеседник) прочитал.
|
||||||
|
// Мы просто добавляем readerId в readBy для наших сообщений, если его там нет.
|
||||||
|
const updatedReadBy = msg.readBy ? [...msg.readBy] : [];
|
||||||
|
if (!updatedReadBy.includes(readerId)) {
|
||||||
|
updatedReadBy.push(readerId);
|
||||||
|
}
|
||||||
|
return { ...msg, readBy: updatedReadBy };
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Следим за видимостью вкладки для отметки сообщений как прочитанных
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
markMessagesAsReadOnServer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(newMessageText, (newValue /*, oldValue */) => { // oldValue больше не используется напрямую для этой логики
|
||||||
|
if (!socket || !isAuthenticated.value || !conversationData.value || !currentUser.value) return;
|
||||||
|
|
||||||
|
const otherParticipant = getOtherParticipant();
|
||||||
|
if (!otherParticipant?._id) {
|
||||||
|
console.warn("[ChatView] Не удалось определить собеседника для событий печати.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const receiverId = otherParticipant._id;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
conversationId: conversationId.value,
|
||||||
|
receiverId: receiverId,
|
||||||
|
senderId: currentUser.value._id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Всегда очищаем таймер stopTyping при любом изменении текста
|
||||||
|
clearTimeout(stopTypingEmitTimer);
|
||||||
|
|
||||||
|
if (newValue.trim().length > 0) {
|
||||||
|
// Пользователь печатает (поле не пустое)
|
||||||
|
if (!userHasSentTypingEvent.value) {
|
||||||
|
// Если событие 'typing' еще не было отправлено для этой "серии" набора
|
||||||
|
if (socket.connected) { // Убедимся, что сокет все еще подключен
|
||||||
|
socket.emit('typing', payload);
|
||||||
|
userHasSentTypingEvent.value = true;
|
||||||
|
console.log('[ChatView] Emit typing (new burst)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем таймер для отправки 'stopTyping', если пользователь перестанет печатать
|
||||||
|
stopTypingEmitTimer = setTimeout(() => {
|
||||||
|
if (socket.connected) { // Убедимся, что сокет все еще подключен
|
||||||
|
socket.emit('stopTyping', payload);
|
||||||
|
userHasSentTypingEvent.value = false; // Сбрасываем для следующей "серии" набора
|
||||||
|
console.log('[ChatView] Emit stopTyping (timeout)');
|
||||||
|
}
|
||||||
|
}, TYPING_TIMER_LENGTH);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Пользователь очистил поле ввода или оно стало пустым
|
||||||
|
if (userHasSentTypingEvent.value) {
|
||||||
|
// Если событие 'typing' было активно, отправляем 'stopTyping'
|
||||||
|
if (socket.connected) { // Убедимся, что сокет все еще подключен
|
||||||
|
socket.emit('stopTyping', payload);
|
||||||
|
userHasSentTypingEvent.value = false;
|
||||||
|
console.log('[ChatView] Emit stopTyping (cleared input)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
isTouchDevice.value = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
// router.push('/login'); // Оставил закомментированным, т.к. в оригинале так
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchInitialMessages();
|
||||||
|
|
||||||
|
socket = getSocket();
|
||||||
|
if (!socket) {
|
||||||
|
socket = connectSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
socket.on('getMessage', handleNewMessage);
|
||||||
|
socket.on('messagesRead', handleMessagesReadByOther);
|
||||||
|
socket.on('userTyping', ({ conversationId: incomingConvId, senderId }) => {
|
||||||
|
const otherP = getOtherParticipant();
|
||||||
|
if (incomingConvId === conversationId.value && otherP && senderId === otherP._id) {
|
||||||
|
otherUserIsTyping.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('userStopTyping', ({ conversationId: incomingConvId, senderId }) => {
|
||||||
|
const otherP = getOtherParticipant();
|
||||||
|
if (incomingConvId === conversationId.value && otherP && senderId === otherP._id) {
|
||||||
|
otherUserIsTyping.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('messageDeleted', handleMessageDeletedBySocket);
|
||||||
|
|
||||||
|
if (conversationId.value) {
|
||||||
|
socket.emit('joinConversationRoom', conversationId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
markMessagesAsReadOnServer();
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
window.addEventListener('focus', markMessagesAsReadOnServer);
|
||||||
|
document.addEventListener('click', handleClickOutsideContextMenu, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (socket) {
|
||||||
|
socket.off('getMessage', handleNewMessage);
|
||||||
|
socket.off('messagesRead', handleMessagesReadByOther);
|
||||||
|
socket.off('userTyping');
|
||||||
|
socket.off('userStopTyping');
|
||||||
|
socket.off('messageDeleted', handleMessageDeletedBySocket);
|
||||||
|
|
||||||
|
if (conversationId.value) {
|
||||||
|
socket.emit('leaveConversationRoom', conversationId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
window.removeEventListener('focus', markMessagesAsReadOnServer);
|
||||||
|
document.removeEventListener('click', handleClickOutsideContextMenu, true); // Ensure this is removed
|
||||||
|
}
|
||||||
|
clearTimeout(stopTypingEmitTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatMessageTimestamp = (timestamp) => {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Иконка статуса сообщения
|
||||||
|
const getMessageStatusIcon = (message) => {
|
||||||
|
if (message.isSending) {
|
||||||
|
return 'bi bi-clock'; // Отправка (часы)
|
||||||
|
}
|
||||||
|
// Для чата 1-на-1, если есть хотя бы один ID в readBy, и он не наш, значит собеседник прочитал
|
||||||
|
const otherParticipant = getOtherParticipant();
|
||||||
|
if (otherParticipant && message.readBy && message.readBy.includes(otherParticipant._id)) {
|
||||||
|
return 'bi bi-check2-all'; // Прочитано (две галочки)
|
||||||
|
}
|
||||||
|
// Если сообщение доставлено (т.е. оно есть на сервере и не isSending), но не прочитано собеседником
|
||||||
|
// (или readBy пуст/не содержит ID собеседника)
|
||||||
|
return 'bi bi-check2'; // Доставлено (одна галочка)
|
||||||
|
// Для групповых чатов логика была бы сложнее
|
||||||
|
};
|
||||||
|
|
||||||
|
// НОВАЯ ФУНКЦИЯ для обработки нативного события contextmenu
|
||||||
|
const handleNativeContextMenu = (event, message) => {
|
||||||
|
if (message.sender?._id === currentUser.value?._id) {
|
||||||
|
event.preventDefault(); // Prevent native context menu for own messages to show custom one
|
||||||
|
}
|
||||||
|
// For other users' messages, allow native context menu (e.g., for copying text).
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickOutsideContextMenu = (event) => {
|
||||||
|
if (contextMenu.value.visible && contextMenuRef.value && !contextMenuRef.value.contains(event.target)) {
|
||||||
|
contextMenu.value.visible = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-view-container {
|
||||||
|
height: calc(100vh - 56px - 3rem); /* Примерная высота: 100% высоты вьюпорта минус шапка и отступы */
|
||||||
|
/* Стилизуй это под свою шапку и футер */
|
||||||
|
}
|
||||||
|
.messages-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.message-bubble {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
max-width: 70%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.message-bubble.sent {
|
||||||
|
background-color: #0d6efd; /* Bootstrap primary */
|
||||||
|
color: white;
|
||||||
|
margin-left: auto; /* Прижимаем к правому краю */
|
||||||
|
border-bottom-right-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.message-bubble.received {
|
||||||
|
background-color: #e9ecef; /* Bootstrap light grey */
|
||||||
|
color: #212529;
|
||||||
|
margin-right: auto; /* Прижимаем к левому краю */
|
||||||
|
border-bottom-left-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.message-content p {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.message-timestamp {
|
||||||
|
font-size: 0.75em;
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.message-bubble.received .message-timestamp {
|
||||||
|
text-align: left;
|
||||||
|
color: #6c757d !important; /* Немного темнее для серого фона */
|
||||||
|
}
|
||||||
|
.typing-indicator {
|
||||||
|
font-size: 0.85em;
|
||||||
|
min-height: 1.2em; /* Резервируем место, чтобы чат не прыгал */
|
||||||
|
}
|
||||||
|
.date-separator {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.date-separator .badge {
|
||||||
|
padding: 0.4em 0.8em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Новые стили для удаления сообщений и контекстного меню */
|
||||||
|
.message-item-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start; /* Выравнивание по верху для bubble и кнопки */
|
||||||
|
/* Добавляем небольшой отступ снизу для ясности, если нужно */
|
||||||
|
/* margin-bottom: 4px; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item-wrapper.sent-wrapper {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.message-item-wrapper.sent-wrapper .delete-message-btn {
|
||||||
|
margin-left: 8px;
|
||||||
|
order: 1; /* Кнопка справа от сообщения */
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item-wrapper.received-wrapper {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-message-btn {
|
||||||
|
align-self: center; /* Вертикальное центрирование кнопки относительно bubble */
|
||||||
|
opacity: 0.6; /* Немного прозрачнее по умолчанию */
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
padding: 0.2rem 0.4rem; /* Меньше padding */
|
||||||
|
font-size: 0.8rem; /* Меньше шрифт иконки */
|
||||||
|
line-height: 1; /* Убрать лишнюю высоту */
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #dc3545; /* Цвет иконки (Bootstrap danger) */
|
||||||
|
}
|
||||||
|
.delete-message-btn:hover,
|
||||||
|
.delete-message-btn:focus {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(220, 53, 69, 0.1); /* Легкий фон при наведении/фокусе */
|
||||||
|
}
|
||||||
|
.delete-message-btn i {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для контекстного меню */
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
z-index: 1050; /* Выше большинства элементов Bootstrap */
|
||||||
|
padding: 0;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
.context-menu ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 5px 0; /* Adjusted padding for ul */
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.context-menu li {
|
||||||
|
padding: 10px 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.context-menu li:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu li i.bi-trash {
|
||||||
|
margin-right: 10px;
|
||||||
|
color: #dc3545; /* Bootstrap danger color */
|
||||||
|
font-size: 1.1em; /* Slightly larger icon */
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu li:hover i.bi-trash {
|
||||||
|
color: #a71d2a; /* Darker danger color on hover */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ... other existing styles ... */
|
||||||
|
</style>
|
74
src/views/HomeView.vue
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="text-center p-5 rounded-3 bg-light shadow-sm">
|
||||||
|
<template v-if="isAuthenticated">
|
||||||
|
<h1 class="display-4 fw-bold mb-3">С возвращением, {{ user?.name || 'Пользователь' }}!</h1>
|
||||||
|
<p class="lead mb-4">
|
||||||
|
Готов продолжить поиск своей второй половинки?
|
||||||
|
</p>
|
||||||
|
<hr class="my-4" />
|
||||||
|
<p>
|
||||||
|
Перейди в свой <router-link to="/profile">профиль</router-link> или начни <router-link to="/swipe">поиск</router-link>! (Ссылка на свайпы пока не работает)
|
||||||
|
</p>
|
||||||
|
<!-- Можно добавить еще кнопки или информацию для залогиненного пользователя -->
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<h1 class="display-4 fw-bold mb-3">Добро пожаловать в Dating App!</h1>
|
||||||
|
<p class="lead mb-4">
|
||||||
|
Найди свою вторую половинку уже сегодня. Присоединяйся к нашему
|
||||||
|
дружелюбному сообществу.
|
||||||
|
</p>
|
||||||
|
<hr class="my-4" />
|
||||||
|
<p>
|
||||||
|
Это главная страница нашего замечательного приложения. Готов начать?
|
||||||
|
</p>
|
||||||
|
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center mt-4">
|
||||||
|
<router-link to="/login" class="btn btn-outline-primary btn-lg px-4 gap-3">
|
||||||
|
Войти
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/register" class="btn btn-success btn-lg px-4">
|
||||||
|
Зарегистрироваться
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- ... секция с фичами (можно оставить или убрать) ... -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAuth } from '@/auth'; // Импортируем наш auth composable
|
||||||
|
import { onMounted } from 'vue'; // Добавляем импорт onMounted
|
||||||
|
|
||||||
|
const { user, isAuthenticated, fetchUser } = useAuth(); // Добавляем fetchUser
|
||||||
|
|
||||||
|
// Обновляем данные пользователя при монтировании компонента
|
||||||
|
onMounted(async () => {
|
||||||
|
if (localStorage.getItem('userToken')) {
|
||||||
|
// Если в localStorage есть токен, пробуем загрузить данные пользователя
|
||||||
|
await fetchUser();
|
||||||
|
console.log('HomeView: Состояние аутентификации:', isAuthenticated.value);
|
||||||
|
console.log('HomeView: Пользователь:', user.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если нужна какая-то специфическая логика для главной страницы, можно добавить здесь.
|
||||||
|
// Например, если пользователь аутентифицирован, можно было бы сразу перенаправлять
|
||||||
|
// на другую страницу, но это зависит от желаемого UX.
|
||||||
|
// import { useRouter } from 'vue-router';
|
||||||
|
// const router = useRouter();
|
||||||
|
// onMounted(() => {
|
||||||
|
// if (isAuthenticated.value) {
|
||||||
|
// // router.push('/swipe'); // Например, на страницу свайпов
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Стили Bootstrap уже должны применяться глобально */
|
||||||
|
/* Здесь можно добавить специфичные стили для этой страницы, если нужно */
|
||||||
|
</style>
|
84
src/views/LoginView.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header text-center">
|
||||||
|
<h2>Вход в аккаунт</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email:</label>
|
||||||
|
<input type="email" class="form-control" id="email" v-model="email" required :disabled="loading" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Пароль:</label>
|
||||||
|
<input type="password" class="form-control" id="password" v-model="password" required :disabled="loading" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="alert alert-danger" role="alert">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-100" :disabled="loading">
|
||||||
|
<span v-if="loading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
{{ loading ? 'Вход...' : 'Войти' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<p class="mb-0">
|
||||||
|
Нет аккаунта? <router-link to="/register">Зарегистрироваться</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
// Не используем useRouter напрямую для перенаправления, так как это делает функция login
|
||||||
|
// import { useRouter } from 'vue-router';
|
||||||
|
import { useAuth } from '@/auth'; // Импортируем наш auth composable
|
||||||
|
|
||||||
|
const email = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const errorMessage = ref('');
|
||||||
|
const loading = ref(false); // Для отображения состояния загрузки
|
||||||
|
|
||||||
|
const { login } = useAuth(); // Получаем функцию login
|
||||||
|
// const router = useRouter(); // Если бы перенаправляли вручную
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
errorMessage.value = ''; // Сбрасываем предыдущие ошибки
|
||||||
|
loading.value = true; // Устанавливаем флаг загрузки
|
||||||
|
|
||||||
|
if (!email.value || !password.value) {
|
||||||
|
errorMessage.value = 'Пожалуйста, введите email и пароль.';
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login({ email: email.value, password: password.value });
|
||||||
|
// Перенаправление теперь происходит внутри функции login в auth.js
|
||||||
|
// Если login завершился успешно, пользователь будет перенаправлен.
|
||||||
|
// Если была ошибка, она будет поймана в catch блоке ниже.
|
||||||
|
// Очищать поля здесь не нужно, так как будет перенаправление.
|
||||||
|
} catch (error) {
|
||||||
|
// error.message здесь будет содержать сообщение из throw в функции login в auth.js
|
||||||
|
errorMessage.value = error.message || 'Произошла неизвестная ошибка при входе.';
|
||||||
|
} finally {
|
||||||
|
loading.value = false; // Снимаем флаг загрузки в любом случае
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Стили Bootstrap уже должны применяться глобально */
|
||||||
|
/* Можно добавить свои специфичные стили, если нужно */
|
||||||
|
</style>
|
138
src/views/ProfileView.vue
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<div class="profile-view container mt-4">
|
||||||
|
<h2>Мой профиль</h2>
|
||||||
|
<div v-if="loading && initialLoading" class="d-flex justify-content-center my-3">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="alert alert-danger">{{ error }}</div>
|
||||||
|
|
||||||
|
<div v-if="profileData && !initialLoading" class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Привет, {{ profileData.name }}!</h5>
|
||||||
|
<p class="card-text"><strong>ID:</strong> {{ profileData._id }}</p>
|
||||||
|
<p class="card-text"><strong>Email:</strong> {{ profileData.email }}</p>
|
||||||
|
<p class="card-text"><strong>Дата регистрации:</strong> {{ formatDate(profileData.createdAt) }}</p>
|
||||||
|
<p v-if="profileData.dateOfBirth" class="card-text"><strong>Дата рождения:</strong> {{ formatDate(profileData.dateOfBirth) }}</p>
|
||||||
|
<p v-if="profileData.gender" class="card-text"><strong>Пол:</strong> {{ profileData.gender === 'male' ? 'Мужской' : profileData.gender === 'female' ? 'Женский' : 'Другой' }}</p>
|
||||||
|
<p v-if="profileData.bio" class="card-text"><strong>О себе:</strong> {{ profileData.bio }}</p>
|
||||||
|
<!-- Добавь другие поля -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Отображение фотографий пользователя -->
|
||||||
|
<div v-if="profileData && profileData.photos && profileData.photos.length > 0 && !initialLoading" class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Мои фотографии</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div v-for="photo in profileData.photos" :key="photo.public_id" class="col-md-4 mb-3">
|
||||||
|
<img :src="photo.url" class="img-fluid img-thumbnail" alt="Фото пользователя">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="profileData && (!profileData.photos || profileData.photos.length === 0) && !initialLoading" class="alert alert-info">
|
||||||
|
У вас пока нет загруженных фотографий.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Форма редактирования профиля -->
|
||||||
|
<EditProfileForm v-if="isAuthenticated && !initialLoading" />
|
||||||
|
<!-- Показываем форму, если пользователь аутентифицирован и начальная загрузка завершена -->
|
||||||
|
|
||||||
|
<div v-if="!profileData && !initialLoading && !error" class="alert alert-warning">
|
||||||
|
<p>Не удалось загрузить данные профиля. Возможно, вы не авторизованы или произошла ошибка.</p>
|
||||||
|
<router-link to="/login" class="btn btn-primary">Войти</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed, watch } from 'vue'; // Добавлен watch
|
||||||
|
import { useAuth } from '@/auth';
|
||||||
|
import api from '@/services/api';
|
||||||
|
import router from '@/router';
|
||||||
|
import EditProfileForm from '@/components/EditProfileForm.vue';
|
||||||
|
|
||||||
|
const { isAuthenticated, user: authUserFromStore, token, fetchUser } = useAuth(); // Добавлен fetchUser
|
||||||
|
const profileData = ref(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const initialLoading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
const fetchProfileDataLocal = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
if (!isAuthenticated.value || !token.value) {
|
||||||
|
error.value = "Вы не авторизованы для просмотра этой страницы.";
|
||||||
|
loading.value = false;
|
||||||
|
initialLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Вместо прямого вызова api.getMe(), используем fetchUser из useAuth,
|
||||||
|
// который обновит authUserFromStore, за которым мы будем следить.
|
||||||
|
await fetchUser();
|
||||||
|
// profileData.value будет обновляться через watch
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ProfileView] Ошибка при вызове fetchUser:', err);
|
||||||
|
error.value = (err.response && err.response.data && err.response.data.message)
|
||||||
|
? err.response.data.message
|
||||||
|
: 'Не удалось загрузить данные профиля. Попробуйте позже.';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
initialLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Следим за изменениями authUserFromStore и обновляем profileData
|
||||||
|
watch(authUserFromStore, (newUser) => {
|
||||||
|
if (newUser) {
|
||||||
|
profileData.value = { ...newUser }; // Клонируем, чтобы избежать прямой мутации, если это необходимо
|
||||||
|
console.log('[ProfileView] Данные профиля обновлены из authUserFromStore:', profileData.value);
|
||||||
|
}
|
||||||
|
}, { immediate: true, deep: true }); // immediate: true для инициализации при монтировании
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Если authUserFromStore уже содержит данные (например, после логина),
|
||||||
|
// они будут установлены через watch immediate: true.
|
||||||
|
// Если нет, или мы хотим принудительно обновить, вызываем fetchProfileDataLocal.
|
||||||
|
if (!authUserFromStore.value || Object.keys(authUserFromStore.value).length === 0) {
|
||||||
|
fetchProfileDataLocal();
|
||||||
|
} else {
|
||||||
|
// Данные уже есть, initialLoading можно установить в false
|
||||||
|
// loading также, так как данные уже есть
|
||||||
|
profileData.value = { ...authUserFromStore.value };
|
||||||
|
loading.value = false;
|
||||||
|
initialLoading.value = false;
|
||||||
|
console.log('[ProfileView] Данные профиля уже были в хранилище:', profileData.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const options = { year: 'numeric', month: 'long', day: 'numeric' };
|
||||||
|
return new Date(dateString).toLocaleDateString(undefined, options);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-view {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
.spinner-border {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
.img-thumbnail {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
max-width: 100%; /* Убедимся, что изображение не выходит за рамки колонки */
|
||||||
|
height: auto; /* Сохраняем пропорции */
|
||||||
|
}
|
||||||
|
</style>
|
128
src/views/RegisterView.vue
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header text-center">
|
||||||
|
<h2>Создать аккаунт</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form @submit.prevent="handleRegister">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">Имя:</label>
|
||||||
|
<input type="text" class="form-control" id="name" v-model="name" required :disabled="loading" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email:</label>
|
||||||
|
<input type="email" class="form-control" id="email" v-model="email" required :disabled="loading" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Пароль:</label>
|
||||||
|
<input type="password" class="form-control" id="password" v-model="password" required :disabled="loading" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirmPassword" class="form-label">Подтвердите пароль:</label>
|
||||||
|
<input type="password" class="form-control" id="confirmPassword" v-model="confirmPassword" required :disabled="loading" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Опциональные поля, можно добавить позже или оставить -->
|
||||||
|
<!--
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="dateOfBirth" class="form-label">Дата рождения:</label>
|
||||||
|
<input type="date" class="form-control" id="dateOfBirth" v-model="dateOfBirth" :disabled="loading" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="gender" class="form-label">Пол:</label>
|
||||||
|
<select class="form-select" id="gender" v-model="gender" :disabled="loading">
|
||||||
|
<option value="">Не выбрано</option>
|
||||||
|
<option value="male">Мужской</option>
|
||||||
|
<option value="female">Женский</option>
|
||||||
|
<option value="other">Другой</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="alert alert-danger" role="alert">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-success w-100" :disabled="loading">
|
||||||
|
<span v-if="loading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
{{ loading ? 'Регистрация...' : 'Зарегистрироваться' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<p class="mb-0">
|
||||||
|
Уже есть аккаунт? <router-link to="/login">Войти</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useAuth } from '@/auth'; // Импортируем наш auth composable
|
||||||
|
|
||||||
|
const name = ref('');
|
||||||
|
const email = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
|
// const dateOfBirth = ref(''); // Если будешь использовать эти поля
|
||||||
|
// const gender = ref(''); // Если будешь использовать эти поля
|
||||||
|
const errorMessage = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const { register } = useAuth(); // Получаем функцию register
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
errorMessage.value = '';
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
// Простая валидация на клиенте
|
||||||
|
if (!name.value || !email.value || !password.value || !confirmPassword.value) {
|
||||||
|
errorMessage.value = 'Пожалуйста, заполните все обязательные поля.';
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
errorMessage.value = 'Пароли не совпадают.';
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.value.length < 6) { // Можно синхронизировать с валидацией бэкенда
|
||||||
|
errorMessage.value = 'Пароль должен быть не менее 6 символов.';
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Собираем данные для регистрации
|
||||||
|
const userData = {
|
||||||
|
name: name.value,
|
||||||
|
email: email.value,
|
||||||
|
password: password.value,
|
||||||
|
// dateOfBirth: dateOfBirth.value || undefined, // Отправляем, если заполнено
|
||||||
|
// gender: gender.value || undefined, // Отправляем, если заполнено
|
||||||
|
};
|
||||||
|
await register(userData);
|
||||||
|
// Перенаправление теперь происходит внутри функции register в auth.js
|
||||||
|
// Очищать поля здесь не нужно.
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || 'Произошла неизвестная ошибка при регистрации.';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Стили Bootstrap уже должны применяться глобально */
|
||||||
|
</style>
|
302
src/views/SwipeView.vue
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
<template>
|
||||||
|
<div class="swipe-view container mt-4">
|
||||||
|
<h2 class="text-center mb-4">Найди свою пару!</h2>
|
||||||
|
|
||||||
|
<div v-if="loading" class="d-flex justify-content-center my-5">
|
||||||
|
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка анкет...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger text-center">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Сообщение, если нет предложений ПОСЛЕ загрузки и нет других ошибок -->
|
||||||
|
<div v-if="!loading && suggestions.length === 0 && !error" class="alert alert-info text-center">
|
||||||
|
Пока нет доступных анкет. Попробуй зайти позже!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentUserToSwipe && !loading && !error" class="row justify-content-center">
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<div class="card shadow-sm user-card">
|
||||||
|
|
||||||
|
<!-- === ОТОБРАЖЕНИЕ ФОТО === -->
|
||||||
|
<div class="image-placeholder-container">
|
||||||
|
<img
|
||||||
|
v-if="currentUserToSwipe.mainPhotoUrl"
|
||||||
|
:src="currentUserToSwipe.mainPhotoUrl"
|
||||||
|
class="card-img-top user-photo"
|
||||||
|
:alt="'Фото ' + currentUserToSwipe.name"
|
||||||
|
@error="onImageError($event, currentUserToSwipe)"
|
||||||
|
/>
|
||||||
|
<!-- Заглушка, если фото не загрузилось ИЛИ если mainPhotoUrl нет -->
|
||||||
|
<div v-else class="no-photo-placeholder d-flex align-items-center justify-content-center">
|
||||||
|
<i class="bi bi-person-fill"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- ========================== -->
|
||||||
|
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title fs-3 mb-1">{{ currentUserToSwipe.name }}, {{ currentUserToSwipe.age !== null ? currentUserToSwipe.age : 'Возраст не указан' }}</h5>
|
||||||
|
<p class="card-text text-muted mb-2">{{ formatGender(currentUserToSwipe.gender) }}</p>
|
||||||
|
<p class="card-text bio-text">{{ currentUserToSwipe.bio || 'Нет описания.' }}</p>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-around mt-3 action-buttons-container">
|
||||||
|
<button @click="handlePass" class="btn btn-outline-danger btn-lg rounded-circle action-button" :disabled="actionLoading" aria-label="Пропустить">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
<button @click="handleLike" class="btn btn-outline-success btn-lg rounded-circle action-button" :disabled="actionLoading" aria-label="Лайкнуть">
|
||||||
|
<i class="bi bi-heart-fill"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="matchOccurred" class="alert alert-success mt-3 text-center match-alert">
|
||||||
|
🎉 **Это мэтч!** Вы понравились друг другу! 🎉
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import api from '@/services/api'; // Наш API сервис
|
||||||
|
import { useAuth } from '@/auth'; // Для проверки аутентификации
|
||||||
|
import router from '@/router'; // Импортируем роутер для возможного редиректа
|
||||||
|
|
||||||
|
const { isAuthenticated, logout } = useAuth(); // Добавили logout из useAuth
|
||||||
|
|
||||||
|
const suggestions = ref([]);
|
||||||
|
const currentIndex = ref(0);
|
||||||
|
const loading = ref(true);
|
||||||
|
const actionLoading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const matchOccurred = ref(false);
|
||||||
|
|
||||||
|
const currentUserToSwipe = computed(() => {
|
||||||
|
if (suggestions.value.length > 0 && currentIndex.value < suggestions.value.length) {
|
||||||
|
return suggestions.value[currentIndex.value];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchSuggestions = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
matchOccurred.value = false;
|
||||||
|
try {
|
||||||
|
console.log('[SwipeView] Запрос предложений...');
|
||||||
|
const response = await api.getSuggestions();
|
||||||
|
suggestions.value = response.data;
|
||||||
|
currentIndex.value = 0;
|
||||||
|
console.log('[SwipeView] Предложения загружены:', suggestions.value);
|
||||||
|
if (suggestions.value.length === 0) {
|
||||||
|
console.log('[SwipeView] Нет доступных анкет.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SwipeView] Ошибка при загрузке предложений:', err.response ? err.response.data : err.message);
|
||||||
|
error.value = (err.response && err.response.data && err.response.data.message)
|
||||||
|
? err.response.data.message
|
||||||
|
: 'Не удалось загрузить анкеты. Попробуйте позже.';
|
||||||
|
if (err.response && err.response.status === 401) {
|
||||||
|
// Если токен невалиден, разлогиниваем и перенаправляем
|
||||||
|
// const { logout } = useAuth(); // logout уже импортирован выше
|
||||||
|
await logout(); // logout уже делает router.push('/login')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = async (actionType) => {
|
||||||
|
if (!currentUserToSwipe.value || actionLoading.value) return;
|
||||||
|
|
||||||
|
actionLoading.value = true;
|
||||||
|
// Сбрасываем сообщение об ошибке перед новым действием
|
||||||
|
error.value = '';
|
||||||
|
// Сбрасываем флаг мэтча, если это не лайк, или он будет установлен снова
|
||||||
|
if (actionType !== 'like') {
|
||||||
|
matchOccurred.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUserId = currentUserToSwipe.value._id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (actionType === 'like') {
|
||||||
|
console.log(`[SwipeView] Лайк пользователя: ${targetUserId}`);
|
||||||
|
response = await api.likeUser(targetUserId);
|
||||||
|
} else {
|
||||||
|
console.log(`[SwipeView] Пропуск пользователя: ${targetUserId}`);
|
||||||
|
response = await api.passUser(targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SwipeView] Ответ на ${actionType}:`, response.data);
|
||||||
|
if (response.data.isMatch) {
|
||||||
|
matchOccurred.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
matchOccurred.value = false;
|
||||||
|
// Переход к следующему после скрытия сообщения о мэтче
|
||||||
|
moveToNextUser();
|
||||||
|
}, 3000); // Показываем мэтч 3 секунды
|
||||||
|
} else {
|
||||||
|
// Если не мэтч, сразу переходим к следующему
|
||||||
|
moveToNextUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[SwipeView] Ошибка при ${actionType}:`, err.response ? err.response.data : err.message);
|
||||||
|
error.value = `Ошибка при ${actionType === 'like' ? 'лайке' : 'пропуске'}. ${err.response?.data?.message || 'Попробуйте снова.'}`;
|
||||||
|
if (err.response && err.response.status === 401) {
|
||||||
|
await logout(); // Выход из системы при ошибке авторизации
|
||||||
|
} else {
|
||||||
|
setTimeout(() => { error.value = ''; }, 3000); // Показываем ошибку 3 секунды
|
||||||
|
}
|
||||||
|
// В случае ошибки (кроме 401) не переходим к следующему пользователю автоматически, даем шанс повторить
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLike = () => {
|
||||||
|
handleAction('like');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePass = () => {
|
||||||
|
handleAction('pass');
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveToNextUser = () => {
|
||||||
|
if (currentIndex.value < suggestions.value.length - 1) {
|
||||||
|
currentIndex.value++;
|
||||||
|
matchOccurred.value = false; // Убираем мэтч при переходе
|
||||||
|
} else {
|
||||||
|
console.log('[SwipeView] Предложения закончились, перезагружаем...');
|
||||||
|
fetchSuggestions();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
fetchSuggestions();
|
||||||
|
} else {
|
||||||
|
error.value = "Пожалуйста, войдите в систему, чтобы просматривать анкеты.";
|
||||||
|
loading.value = false;
|
||||||
|
// Перенаправление на логин должно осуществляться глобальным хуком роутера
|
||||||
|
// router.push('/login'); // Если хук не сработал или для явности
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatGender = (gender) => {
|
||||||
|
if (gender === 'male') return 'Мужчина';
|
||||||
|
if (gender === 'female') return 'Женщина';
|
||||||
|
if (gender === 'other') return 'Другой';
|
||||||
|
return ''; // Возвращаем пустую строку, если пол не указан, чтобы не было "Пол не указан"
|
||||||
|
};
|
||||||
|
|
||||||
|
const onImageError = (event, userOnError) => {
|
||||||
|
console.warn("Не удалось загрузить изображение:", event.target.src, "для пользователя:", userOnError.name);
|
||||||
|
// Найдем пользователя в suggestions и установим mainPhotoUrl в null
|
||||||
|
// чтобы v-else сработал корректно и реактивно
|
||||||
|
const userInSuggestions = suggestions.value.find(u => u._id === userOnError._id);
|
||||||
|
if (userInSuggestions) {
|
||||||
|
userInSuggestions.mainPhotoUrl = null;
|
||||||
|
}
|
||||||
|
// event.target.style.display = 'none'; // Можно убрать, v-else должен справиться
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.swipe-view {
|
||||||
|
padding-bottom: 60px; /* Место для футера, если он фиксированный */
|
||||||
|
}
|
||||||
|
.user-card {
|
||||||
|
border-radius: 15px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
.user-card .card-title {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.user-card .bio-text {
|
||||||
|
min-height: 4.5em; /* Примерно 3 строки текста */
|
||||||
|
max-height: 4.5em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3; /* Добавлено стандартное свойство */
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.action-buttons-container {
|
||||||
|
padding-bottom: 0.5rem; /* Небольшой отступ снизу для кнопок */
|
||||||
|
}
|
||||||
|
.action-button {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
font-size: 1.8rem; /* Размер иконки */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.1s ease-out;
|
||||||
|
}
|
||||||
|
.action-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
.btn-outline-danger:hover {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-outline-success:hover {
|
||||||
|
background-color: #198754;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-alert {
|
||||||
|
position: fixed; /* Или absolute относительно родителя, если нужно */
|
||||||
|
bottom: 70px; /* Положение над предполагаемой панелью навигации/футером */
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1050;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
animation: fadeInOut 3s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInOut {
|
||||||
|
0% { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||||
|
20% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
80% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
100% { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-placeholder-container {
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 100%;
|
||||||
|
position: relative;
|
||||||
|
background-color: #e9ecef; /* Более нейтральный фон для заглушки */
|
||||||
|
}
|
||||||
|
.user-photo, .no-photo-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.no-photo-placeholder {
|
||||||
|
border-bottom: 1px solid #dee2e6; /* Граница, как у card-img-top */
|
||||||
|
}
|
||||||
|
.no-photo-placeholder i {
|
||||||
|
font-size: 6rem; /* Увеличил иконку */
|
||||||
|
color: #adb5bd; /* Сделал иконку чуть темнее */
|
||||||
|
}
|
||||||
|
</style>
|
127
vite.config.js
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
import { resolve } from 'path' // Добавляем импорт модуля path
|
||||||
|
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
VitePWA({
|
||||||
|
// Опции регистрации сервис-воркера
|
||||||
|
registerType: 'autoUpdate', // 'autoUpdate' или 'prompt' для обновления
|
||||||
|
|
||||||
|
// Включаем генерацию манифеста и сервис-воркера
|
||||||
|
injectRegister: 'auto',
|
||||||
|
|
||||||
|
// Убираем это объявление, так как оно дублируется
|
||||||
|
// manifest: true,
|
||||||
|
strategies: 'generateSW',
|
||||||
|
|
||||||
|
// Опции для генерации сервис-воркера
|
||||||
|
manifestFilename: 'manifest.webmanifest',
|
||||||
|
filename: 'sw.js',
|
||||||
|
|
||||||
|
// Конфигурация Workbox (для сервис-воркера)
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,json,vue,txt,woff2}'], // Включаем больше типов файлов для кеширования
|
||||||
|
cleanupOutdatedCaches: true, // Автоматически удалять старые кеши
|
||||||
|
// runtimeCaching: [ // Пример для кеширования API запросов (пока не нужно, но для будущего)
|
||||||
|
// {
|
||||||
|
// urlPattern: /^https:\/\/api\.example\.com\/.*/i,
|
||||||
|
// handler: 'NetworkFirst',
|
||||||
|
// options: {
|
||||||
|
// cacheName: 'api-cache',
|
||||||
|
// expiration: {
|
||||||
|
// maxEntries: 10,
|
||||||
|
// maxAgeSeconds: 60 * 60 * 24 * 365 // 1 год
|
||||||
|
// },
|
||||||
|
// cacheableResponse: {
|
||||||
|
// statuses: [0, 200]
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Файлы для предварительного кеширования (кроме тех, что попадут через globPatterns)
|
||||||
|
// Это важно для основных иконок PWA
|
||||||
|
includeAssets: [
|
||||||
|
'favicon.svg',
|
||||||
|
'apple-touch-icon.png',
|
||||||
|
'pwa-192x192.png', // Добавляем PWA иконки
|
||||||
|
'pwa-512x512.png' // Если используешь маскируемую иконку
|
||||||
|
// Убедись, что все эти файлы есть в папке /public
|
||||||
|
],
|
||||||
|
|
||||||
|
// Конфигурация манифеста PWA
|
||||||
|
manifest: {
|
||||||
|
id: '/', // ID приложения (можно оставить пустым)
|
||||||
|
name: 'DatingApp', // Замени на свое название
|
||||||
|
short_name: 'DatingApp', // Короткое имя
|
||||||
|
description: 'Find your perfect match with our PWA Dating App!', // Описание
|
||||||
|
theme_color: '#ff69b4', // Основной цвет темы (пример)
|
||||||
|
background_color: '#ffffff', // Цвет фона для сплэш-экрана на Android
|
||||||
|
display: 'standalone', // Или 'fullscreen', 'minimal-ui'
|
||||||
|
scope: '/',
|
||||||
|
start_url: '/',
|
||||||
|
orientation: 'portrait-primary', // Предпочтительная ориентация
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'pwa-192x192.png', // Путь относительно папки public
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png', // Путь относительно папки public
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png', // Маскируемая иконка
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any maskable' // 'any' или 'maskable' или 'any maskable'
|
||||||
|
}
|
||||||
|
// Ты можешь добавить больше размеров иконок, если нужно
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Опции для разработчика (могут быть полезны)
|
||||||
|
devOptions: {
|
||||||
|
enabled: true, // Включает генерацию SW и манифеста в режиме разработки (для тестирования)
|
||||||
|
type: 'module', // Тип генерируемого сервис-воркера в режиме разработки
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
// Добавляем конфигурацию для алиасов
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'), // Алиас @ указывает на директорию src
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Конфигурация сервера разработки
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173, // Убедитесь, что этот порт используется или измените при необходимости
|
||||||
|
proxy: {
|
||||||
|
// Проксируем запросы /api на ваш бэкенд сервер
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5000', // Адрес вашего бэкенд сервера
|
||||||
|
changeOrigin: true, // Необходимо для виртуальных хостов
|
||||||
|
},
|
||||||
|
// Проксируем WebSocket соединения для Socket.IO
|
||||||
|
'/socket.io': {
|
||||||
|
target: 'http://localhost:5000', // Адрес вашего Socket.IO сервера
|
||||||
|
ws: true, // Включаем проксирование WebSocket
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Другие настройки Vite, если они есть (например, server, resolve.alias и т.д.)
|
||||||
|
build: {
|
||||||
|
// Гарантируем, что сервис-воркер включен
|
||||||
|
sourcemap: true,
|
||||||
|
assetsInlineLimit: 0,
|
||||||
|
}
|
||||||
|
})
|