первый коммит

This commit is contained in:
Professional 2025-05-21 22:13:09 +07:00
commit 2b94015b33
100 changed files with 17480 additions and 0 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
JWT_SECRET=djfyfgfthdyxsdrreryrdtufgjyhjfgxghddskfhadkghdsfgsadpioadshjgdifghjzdlczsdflsikdjfsdigjsdvlkjcfghdufghsdjkfugsdfkjsxbnksdfhsdgk

6
.env.development Normal file
View 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
View File

@ -0,0 +1,2 @@
# Переменная окружения для сборки фронтенда в продакшене
VITE_API_BASE_URL=https://goreflex.ru/api

24
.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

30
Dockerfile Normal file
View 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
View 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
View File

@ -0,0 +1,5 @@
node_modules
npm-debug.log
Dockerfile
.git
.gitignore

2
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
.env

29
backend/Dockerfile Normal file
View 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" ]

View 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;

View 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
View 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);

View 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,
};

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

View 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, // <-- Добавляем новый метод
};

View 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,
};

View 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');

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

28
backend/package.json Normal file
View 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"
}
}

View 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;

View 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;
// Конец файла

View 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;

View 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
View 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
View 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
View 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} didnt 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

File diff suppressed because one or more lines are too long

3392
dev-dist/workbox-54d0af47.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

54
docker-compose.yml Normal file
View 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
View 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

Binary file not shown.

7326
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

11
public/favicon.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
public/pwa-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

115
src/App.vue Normal file
View 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
View 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, // Эту функцию нужно будет вызвать при инициализации приложения
};
}

View 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
View 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
View 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
View 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}`);
}
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
}
})