From daed73c30f7fa1f552b697e85bfd25c9d8bdd236 Mon Sep 17 00:00:00 2001 From: glpshchn <464976@niuitmo.ru> Date: Sun, 7 Dec 2025 05:20:45 +0300 Subject: [PATCH] Update files --- backend/middleware/auth.js | 63 ++- backend/middleware/logger.js | 10 +- backend/models/TicketActivity.js | 76 ++++ backend/models/User.js | 14 + backend/routes/auth.js | 3 + backend/routes/posts.js | 38 +- backend/routes/users.js | 68 +++ backend/utils/moscowTime.js | 79 ++++ backend/utils/tickets.js | 221 ++++++++++ frontend/src/App.jsx | 2 + frontend/src/components/LadderButton.css | 63 +++ frontend/src/components/LadderButton.jsx | 26 ++ frontend/src/components/Layout.jsx | 2 + frontend/src/components/PostMenu.jsx | 2 +- frontend/src/pages/MonthlyLadder.css | 521 +++++++++++++++++++++++ frontend/src/pages/MonthlyLadder.jsx | 276 ++++++++++++ frontend/src/pages/UserProfile.css | 5 + frontend/src/utils/api.js | 6 + 18 files changed, 1449 insertions(+), 26 deletions(-) create mode 100644 backend/models/TicketActivity.js create mode 100644 backend/utils/moscowTime.js create mode 100644 backend/utils/tickets.js create mode 100644 frontend/src/components/LadderButton.css create mode 100644 frontend/src/components/LadderButton.jsx create mode 100644 frontend/src/pages/MonthlyLadder.css create mode 100644 frontend/src/pages/MonthlyLadder.jsx diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index cb06616..1d20791 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -202,22 +202,17 @@ const authenticate = async (req, res, next) => { // (см. routes/posts.js) } else { // Для существующих пользователей тоже можно установить referredBy, - // если они еще не создали пост и пришли по реферальной ссылке - if (startParam && !user.referredBy) { + // если они еще не были засчитаны как реферал и пришли по реферальной ссылке + if (startParam && !user.referredBy && !user.referralCounted) { const normalizedStartParam = startParam.toLowerCase(); if (normalizedStartParam.startsWith('ref_')) { const referrer = await User.findOne({ referralCode: { $regex: new RegExp(`^${startParam}$`, 'i') } }); if (referrer) { - // Проверяем, создал ли пользователь уже посты - const Post = require('../models/Post'); - const userPostsCount = await Post.countDocuments({ author: user._id }); - if (userPostsCount === 0) { - // Пользователь еще не создал посты, можно установить referredBy - user.referredBy = referrer._id; - await user.save(); - } + // Пользователь еще не был засчитан как реферал, можно установить referredBy + user.referredBy = referrer._id; + await user.save(); } } } @@ -254,6 +249,54 @@ const authenticate = async (req, res, next) => { await ensureUserSettings(user); await touchUserActivity(user); + // Реферальная система: отслеживание входов в разные дни + // Останавливаем отслеживание после засчета реферала, чтобы не заполнять БД + if (user.referredBy && !user.referralCounted) { + // Инициализировать loginDates если его нет + if (!user.loginDates) { + user.loginDates = []; + } + + // Получить уникальные даты из существующего массива (только даты, без времени по московскому времени) + const { getMoscowStartOfDay } = require('../utils/moscowTime'); + const uniqueDates = new Set(); + user.loginDates.forEach(date => { + const dateObj = getMoscowStartOfDay(new Date(date)); + uniqueDates.add(dateObj.getTime()); + }); + + // Если уже есть 2 уникальные даты, сразу засчитать реферал без добавления новой даты + if (uniqueDates.size >= 2) { + const User = require('../models/User'); + await User.findByIdAndUpdate(user.referredBy, { + $inc: { referralsCount: 1 } + }); + + // Начислить баллы за реферала + const { awardReferral } = require('../utils/tickets'); + await awardReferral(user.referredBy); + + user.referralCounted = true; + // Очистить loginDates после засчета, чтобы не хранить лишние данные + user.loginDates = []; + await user.save(); + } else { + // Если еще нет 2 уникальных дат, добавить сегодняшнюю дату по московскому времени (если её нет) + const { getMoscowDate } = require('../utils/moscowTime'); + const today = getMoscowDate(); + const todayTime = today.getTime(); + + // Проверить, есть ли уже сегодняшняя дата + const todayExists = uniqueDates.has(todayTime); + + // Если сегодняшней даты нет, добавить её + if (!todayExists) { + user.loginDates.push(today); + await user.save(); + } + } + } + req.user = user; req.telegramUser = normalizedUser; next(); diff --git a/backend/middleware/logger.js b/backend/middleware/logger.js index 83784a1..f606081 100644 --- a/backend/middleware/logger.js +++ b/backend/middleware/logger.js @@ -1,5 +1,7 @@ const fs = require('fs'); const path = require('path'); +const { formatMoscowTime, getMoscowDate } = require('../utils/moscowTime'); + // Создать директорию для логов если её нет const logsDir = path.join(__dirname, '../logs'); if (!fs.existsSync(logsDir)) { @@ -7,7 +9,9 @@ if (!fs.existsSync(logsDir)) { } const getDatePrefix = () => { - return new Date().toISOString().slice(0, 10); + // Используем московское время для имен файлов логов + const moscowDate = getMoscowDate(); + return moscowDate.toISOString().slice(0, 10); }; const appendLog = (fileName, message) => { @@ -45,7 +49,7 @@ const levelEmojis = { // Функция для логирования const log = (level, message, data = {}) => { - const timestamp = new Date().toISOString(); + const timestamp = formatMoscowTime(); // Используем московское время const emoji = levelEmojis[level] || '📋'; const logMessage = `[${timestamp}] ${emoji} [${level.toUpperCase()}] ${message}`; const serializedData = Object.keys(data).length ? ` ${JSON.stringify(data, null, 2)}` : ''; @@ -154,7 +158,7 @@ const logSecurityEvent = (type, req, details = {}) => { log('warn', 'Security event', securityData); // В production можно отправить уведомление - const securityMessage = `[${new Date().toISOString()}] [SECURITY] ${type}: ${JSON.stringify(securityData)}`; + const securityMessage = `[${formatMoscowTime()}] [SECURITY] ${type}: ${JSON.stringify(securityData)}`; appendLog(`security-${getDatePrefix()}.log`, securityMessage); }; diff --git a/backend/models/TicketActivity.js b/backend/models/TicketActivity.js new file mode 100644 index 0000000..99c1340 --- /dev/null +++ b/backend/models/TicketActivity.js @@ -0,0 +1,76 @@ +const mongoose = require('mongoose'); + +const TicketActivitySchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + date: { + type: Date, + required: true, + index: true + }, + // Счетчики для разных типов активности + postsCreated: { + type: Number, + default: 0 + }, + likesGiven: { + type: Number, + default: 0 + }, + likesReceived: { + type: Number, + default: 0 + }, + commentsWritten: { + type: Number, + default: 0 + }, + commentsReceived: { + type: Number, + default: 0 + }, + referralsCounted: { + type: Number, + default: 0 + }, + // Для отслеживания баллов с реакций на арты (по постам) + // Используем объект вместо Map для совместимости с Mongoose + artReactionsPoints: { + type: mongoose.Schema.Types.Mixed, + default: {} + }, + createdAt: { + type: Date, + default: Date.now + } +}, { + timestamps: true +}); + +// Индекс для быстрого поиска по пользователю и дате +TicketActivitySchema.index({ user: 1, date: 1 }, { unique: true }); + +// Метод для получения или создания активности за сегодня (по московскому времени) +TicketActivitySchema.statics.getOrCreateToday = async function(userId) { + const { getMoscowDate } = require('../utils/moscowTime'); + const today = getMoscowDate(); + + let activity = await this.findOne({ user: userId, date: today }); + + if (!activity) { + activity = new this({ + user: userId, + date: today + }); + await activity.save(); + } + + return activity; +}; + +module.exports = mongoose.model('TicketActivity', TicketActivitySchema); + diff --git a/backend/models/User.js b/backend/models/User.js index fbbdba5..649db7d 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -69,6 +69,20 @@ const UserSchema = new mongoose.Schema({ type: Number, default: 0 }, + // Массив дат входа (для реферальной системы) + loginDates: [{ + type: Date + }], + // Флаг, что реферал уже засчитан + referralCounted: { + type: Boolean, + default: false + }, + // Билеты для Monthly Ladder + tickets: { + type: Number, + default: 0 + }, createdAt: { type: Date, default: Date.now diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 4256095..25ff301 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -59,6 +59,7 @@ const respondWithUser = async (user, res) => { following: populatedUser.following, referralCode: populatedUser.referralCode, referralsCount: populatedUser.referralsCount || 0, + tickets: populatedUser.tickets || 0, settings, banned: populatedUser.banned } @@ -238,6 +239,7 @@ router.post('/oauth', strictAuthLimiter, async (req, res) => { followingCount: populatedUser.following.length, referralCode: populatedUser.referralCode, referralsCount: populatedUser.referralsCount || 0, + tickets: populatedUser.tickets || 0, settings, banned: populatedUser.banned } @@ -300,6 +302,7 @@ router.post('/session', async (req, res) => { followingCount: populatedUser.following.length, referralCode: populatedUser.referralCode, referralsCount: populatedUser.referralsCount || 0, + tickets: populatedUser.tickets || 0, settings, banned: populatedUser.banned } diff --git a/backend/routes/posts.js b/backend/routes/posts.js index 16abb0b..62679c1 100644 --- a/backend/routes/posts.js +++ b/backend/routes/posts.js @@ -153,16 +153,9 @@ router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploa await post.save(); await post.populate('author', 'username firstName lastName photoUrl'); - // Проверка первого поста для реферальной системы - // Счетчик рефералов увеличивается только когда приглашенный пользователь создал первый пост - const userPostsCount = await Post.countDocuments({ author: req.user._id }); - if (userPostsCount === 1 && req.user.referredBy) { - // Это первый пост пользователя, который был приглашен по реферальной ссылке - const User = require('../models/User'); - await User.findByIdAndUpdate(req.user.referredBy, { - $inc: { referralsCount: 1 } - }); - } + // Начислить баллы за создание поста + const { awardPostCreation } = require('../utils/tickets'); + await awardPostCreation(req.user._id); // Создать уведомления для упомянутых пользователей if (post.mentionedUsers.length > 0) { @@ -213,8 +206,17 @@ router.post('/:id/like', authenticate, interactionLimiter, async (req, res) => { // Добавить лайк post.likes.push(req.user._id); - // Создать уведомление + // Начислить баллы + const { awardLikeGiven, awardLikeReceived } = require('../utils/tickets'); + + // Баллы тому, кто ставит лайк + await awardLikeGiven(req.user._id); + + // Баллы автору поста (если это не свой пост) if (!post.author.equals(req.user._id)) { + await awardLikeReceived(post.author, req.user._id); + + // Создать уведомление const notification = new Notification({ recipient: post.author, sender: req.user._id, @@ -256,8 +258,20 @@ router.post('/:id/comment', authenticate, interactionLimiter, async (req, res) = await post.save(); await post.populate('comments.author', 'username firstName lastName photoUrl'); - // Создать уведомление + // Начислить баллы за комментарий + const { awardCommentWritten, awardCommentReceived } = require('../utils/tickets'); + const commentLength = content.trim().length; + + // Баллы тому, кто пишет комментарий (только если >= 10 символов) + if (commentLength >= 10) { + await awardCommentWritten(req.user._id, commentLength); + } + + // Баллы автору поста (если это не свой пост) if (!post.author.equals(req.user._id)) { + await awardCommentReceived(post.author, req.user._id); + + // Создать уведомление const notification = new Notification({ recipient: post.author, sender: req.user._id, diff --git a/backend/routes/users.js b/backend/routes/users.js index 6e19187..a057887 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -204,5 +204,73 @@ router.get('/search/:query', authenticate, async (req, res) => { } }); +// Получить топ пользователей по билетам (Monthly Ladder) +router.get('/ladder/top', authenticate, async (req, res) => { + try { + const { limit = 5 } = req.query; + const limitNum = parseInt(limit); + + // Получить топ пользователей по билетам (исключаем glpshchn00) + // Берем немного больше, чтобы гарантировать топ 5 после фильтрации + const topUsers = await User.find({ + banned: { $ne: true }, + username: { $ne: 'glpshchn00' } + }) + .select('username firstName lastName photoUrl tickets') + .sort({ tickets: -1 }) + .limit(limitNum) + .lean(); + + // Найти позицию текущего пользователя (исключаем glpshchn00) + const userTickets = req.user.tickets || 0; + const userRank = await User.countDocuments({ + tickets: { $gt: userTickets }, + banned: { $ne: true }, + username: { $ne: 'glpshchn00' } + }) + 1; + + // Исключить glpshchn00 из отображения + const isExcludedUser = req.user.username === 'glpshchn00'; + + // Проверить, есть ли текущий пользователь в топе + const currentUserInTop = !isExcludedUser && topUsers.some(u => u._id.toString() === req.user._id.toString()); + + // Если пользователь не в топе и не исключен, добавить его отдельно + let currentUserData = null; + if (!isExcludedUser && !currentUserInTop) { + currentUserData = { + _id: req.user._id, + username: req.user.username, + firstName: req.user.firstName, + lastName: req.user.lastName, + photoUrl: req.user.photoUrl, + tickets: req.user.tickets || 0, + rank: userRank + }; + } else if (!isExcludedUser && currentUserInTop) { + // Если в топе, добавить rank к существующему пользователю + topUsers.forEach((user, index) => { + if (user._id.toString() === req.user._id.toString()) { + user.rank = index + 1; + } + }); + } + + // Добавить rank к топ пользователям + topUsers.forEach((user, index) => { + user.rank = index + 1; + }); + + res.json({ + topUsers, + currentUser: currentUserData, + currentUserRank: isExcludedUser ? null : (currentUserInTop ? topUsers.find(u => u._id.toString() === req.user._id.toString())?.rank : userRank) + }); + } catch (error) { + console.error('Ошибка получения топа:', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + module.exports = router; diff --git a/backend/utils/moscowTime.js b/backend/utils/moscowTime.js new file mode 100644 index 0000000..bc94de3 --- /dev/null +++ b/backend/utils/moscowTime.js @@ -0,0 +1,79 @@ +/** + * Утилиты для работы с московским временем (MSK, UTC+3) + */ + +/** + * Получить текущее московское время + */ +function getMoscowTime() { + const now = new Date(); + // Москва = UTC+3 + // Получаем UTC время и добавляем 3 часа + const utcTime = now.getTime() + (now.getTimezoneOffset() * 60 * 1000); + const moscowOffset = 3 * 60 * 60 * 1000; // 3 часа в миллисекундах + return new Date(utcTime + moscowOffset); +} + +/** + * Получить начало дня по московскому времени + */ +function getMoscowStartOfDay(date = null) { + const moscowTime = date ? new Date(date.getTime()) : getMoscowTime(); + moscowTime.setHours(0, 0, 0, 0); + return moscowTime; +} + +/** + * Получить текущую дату по московскому времени (начало дня) + */ +function getMoscowDate() { + return getMoscowStartOfDay(); +} + +/** + * Форматировать дату в московское время для логов + */ +function formatMoscowTime(date = null) { + const moscowTime = date ? new Date(date.getTime()) : getMoscowTime(); + const year = moscowTime.getFullYear(); + const month = String(moscowTime.getMonth() + 1).padStart(2, '0'); + const day = String(moscowTime.getDate()).padStart(2, '0'); + const hours = String(moscowTime.getHours()).padStart(2, '0'); + const minutes = String(moscowTime.getMinutes()).padStart(2, '0'); + const seconds = String(moscowTime.getSeconds()).padStart(2, '0'); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} MSK`; +} + +/** + * Проверить, является ли дата сегодняшней по московскому времени + */ +function isMoscowToday(date) { + const today = getMoscowStartOfDay(); + const checkDate = getMoscowStartOfDay(date); + return today.getTime() === checkDate.getTime(); +} + +/** + * Получить новогоднюю дату по московскому времени (1 января следующего года, 00:00 MSK) + */ +function getNewYearMoscow() { + const now = getMoscowTime(); + const year = now.getFullYear() + 1; + // Создаем дату 1 января следующего года в московском времени + // Используем UTC и вычитаем смещение для получения правильного UTC времени + const moscowNewYear = new Date(Date.UTC(year, 0, 1, 0, 0, 0, 0)); + // Вычитаем 3 часа, чтобы получить UTC время, которое при добавлении 3 часов даст 00:00 MSK + const moscowOffset = 3 * 60 * 60 * 1000; + return new Date(moscowNewYear.getTime() - moscowOffset); +} + +module.exports = { + getMoscowTime, + getMoscowStartOfDay, + getMoscowDate, + formatMoscowTime, + isMoscowToday, + getNewYearMoscow +}; + diff --git a/backend/utils/tickets.js b/backend/utils/tickets.js new file mode 100644 index 0000000..f956e06 --- /dev/null +++ b/backend/utils/tickets.js @@ -0,0 +1,221 @@ +const User = require('../models/User'); +const TicketActivity = require('../models/TicketActivity'); +const Post = require('../models/Post'); +const { getMoscowDate, formatMoscowTime } = require('./moscowTime'); + +/** + * Проверяет, можно ли начислить баллы с учетом лимитов и антифрод правил + */ +async function canAwardTickets(userId, actionType, options = {}) { + // Используем московское время для определения дня + const today = getMoscowDate(); + + const activity = await TicketActivity.getOrCreateToday(userId); + + // Проверка лимитов в зависимости от типа действия + switch (actionType) { + case 'post_created': + return activity.postsCreated < 5; + + case 'like_given': + return activity.likesGiven < 50; + + case 'like_received': + return activity.likesReceived < 100; + + case 'comment_written': + // Проверка длины комментария (антифрод) + if (options.commentLength && options.commentLength < 10) { + return false; + } + return activity.commentsWritten < 20; + + case 'comment_received': + return true; // Нет лимита на получение комментариев + + case 'referral': + return activity.referralsCounted < 3; + + case 'art_reaction': + // Проверка лимита на реакцию на арт (100 баллов в сутки с одного арта) + const postId = options.postId; + if (!postId) return false; + + const postIdStr = postId.toString(); + const currentPoints = (activity.artReactionsPoints && activity.artReactionsPoints[postIdStr]) || 0; + const newPoints = currentPoints + (options.points || 0); + return newPoints <= 100; + + default: + return false; + } +} + +/** + * Проверяет возраст аккаунта (антифрод - аккаунты младше 24 часов не считаются) + */ +async function isAccountOldEnough(userId) { + const user = await User.findById(userId); + if (!user) return false; + + const accountAge = Date.now() - new Date(user.createdAt).getTime(); + const hours24 = 24 * 60 * 60 * 1000; + + return accountAge >= hours24; +} + +/** + * Начисляет баллы пользователю с проверкой лимитов и антифрод + */ +async function awardTickets(userId, points, actionType, options = {}) { + try { + // Антифрод: проверка возраста аккаунта для входящих реакций + if (actionType === 'like_received' || actionType === 'comment_received') { + const senderId = options.senderId; + if (senderId) { + const senderOldEnough = await isAccountOldEnough(senderId); + if (!senderOldEnough) { + console.log(`[Tickets ${formatMoscowTime()}] Пропуск начисления: аккаунт отправителя младше 24 часов (${senderId})`); + return { success: false, reason: 'account_too_new' }; + } + } + } + + // Проверка возможности начисления + const canAward = await canAwardTickets(userId, actionType, options); + if (!canAward) { + console.log(`[Tickets ${formatMoscowTime()}] Лимит достигнут для ${actionType} пользователя ${userId}`); + return { success: false, reason: 'limit_reached' }; + } + + // Получить активность за сегодня + const activity = await TicketActivity.getOrCreateToday(userId); + + // Обновить счетчики активности + switch (actionType) { + case 'post_created': + activity.postsCreated += 1; + break; + case 'like_given': + activity.likesGiven += 1; + break; + case 'like_received': + activity.likesReceived += 1; + break; + case 'comment_written': + activity.commentsWritten += 1; + break; + case 'comment_received': + activity.commentsReceived += 1; + break; + case 'referral': + activity.referralsCounted += 1; + break; + case 'art_reaction': + const postId = options.postId; + if (postId) { + if (!activity.artReactionsPoints) { + activity.artReactionsPoints = {}; + } + const postIdStr = postId.toString(); + const currentPoints = activity.artReactionsPoints[postIdStr] || 0; + activity.artReactionsPoints[postIdStr] = currentPoints + points; + activity.markModified('artReactionsPoints'); + } + break; + } + + await activity.save(); + + // Начислить баллы пользователю + await User.findByIdAndUpdate(userId, { + $inc: { tickets: points } + }); + + console.log(`[Tickets ${formatMoscowTime()}] Начислено ${points} баллов пользователю ${userId} за ${actionType}`); + + return { success: true, points }; + } catch (error) { + console.error(`[Tickets] Ошибка начисления баллов:`, error); + return { success: false, reason: 'error', error: error.message }; + } +} + +/** + * Начисляет баллы за создание поста + */ +async function awardPostCreation(userId) { + return await awardTickets(userId, 15, 'post_created'); +} + +/** + * Начисляет баллы за лайк (тому, кто ставит) + */ +async function awardLikeGiven(userId) { + return await awardTickets(userId, 1, 'like_given'); +} + +/** + * Начисляет баллы за полученный лайк (автору поста) + */ +async function awardLikeReceived(authorId, likerId) { + return await awardTickets(authorId, 2, 'like_received', { senderId: likerId }); +} + +/** + * Начисляет баллы за написанный комментарий + */ +async function awardCommentWritten(userId, commentLength) { + return await awardTickets(userId, 4, 'comment_written', { commentLength }); +} + +/** + * Начисляет баллы за полученный комментарий (автору поста) + */ +async function awardCommentReceived(authorId, commenterId) { + return await awardTickets(authorId, 6, 'comment_received', { senderId: commenterId }); +} + +/** + * Начисляет баллы за реферала + */ +async function awardReferral(userId) { + return await awardTickets(userId, 100, 'referral'); +} + +/** + * Начисляет баллы за реакцию на арт (лайк) + */ +async function awardArtLike(authorId, likerId, postId) { + return await awardTickets(authorId, 8, 'art_reaction', { + senderId: likerId, + postId, + points: 8 + }); +} + +/** + * Начисляет баллы за комментарий под артом + */ +async function awardArtComment(authorId, commenterId, postId) { + return await awardTickets(authorId, 12, 'art_reaction', { + senderId: commenterId, + postId, + points: 12 + }); +} + +module.exports = { + awardTickets, + awardPostCreation, + awardLikeGiven, + awardLikeReceived, + awardCommentWritten, + awardCommentReceived, + awardReferral, + awardArtLike, + awardArtComment, + canAwardTickets, + isAccountOldEnough +}; + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0793ccc..8e1047b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,7 @@ import Profile from './pages/Profile' import UserProfile from './pages/UserProfile' import CommentsPage from './pages/CommentsPage' import PostMenuPage from './pages/PostMenuPage' +import MonthlyLadder from './pages/MonthlyLadder' import './styles/index.css' function AppContent() { @@ -201,6 +202,7 @@ function AppContent() { } /> } /> } /> + } /> ) diff --git a/frontend/src/components/LadderButton.css b/frontend/src/components/LadderButton.css new file mode 100644 index 0000000..b17ab3d --- /dev/null +++ b/frontend/src/components/LadderButton.css @@ -0,0 +1,63 @@ +.ladder-button { + position: fixed; + bottom: 100px; + right: 16px; + width: 56px; + height: 56px; + border-radius: 50%; + background: linear-gradient(135deg, #FFD700, #FFA500, #FF6347); + border: 3px solid rgba(255, 255, 255, 0.3); + box-shadow: 0 4px 20px rgba(255, 215, 0, 0.4), + 0 0 30px rgba(255, 165, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 100; + transition: all 0.3s ease; + animation: float 3s ease-in-out infinite; +} + +.ladder-button:active { + transform: scale(0.9); +} + +.ladder-button:hover { + box-shadow: 0 6px 30px rgba(255, 215, 0, 0.6), + 0 0 40px rgba(255, 165, 0, 0.5); + transform: translateY(-2px); +} + +@keyframes float { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + +.gift-icon { + color: #fff; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)); + animation: rotate 4s ease-in-out infinite; +} + +@keyframes rotate { + 0%, 100% { + transform: rotate(0deg); + } + 25% { + transform: rotate(-10deg); + } + 75% { + transform: rotate(10deg); + } +} + +/* Адаптация для темной темы */ +[data-theme="dark"] .ladder-button { + box-shadow: 0 4px 20px rgba(255, 215, 0, 0.5), + 0 0 30px rgba(255, 165, 0, 0.4); +} + diff --git a/frontend/src/components/LadderButton.jsx b/frontend/src/components/LadderButton.jsx new file mode 100644 index 0000000..aaff99d --- /dev/null +++ b/frontend/src/components/LadderButton.jsx @@ -0,0 +1,26 @@ +import { useNavigate, useLocation } from 'react-router-dom' +import { Gift } from 'lucide-react' +import { hapticFeedback } from '../utils/telegram' +import './LadderButton.css' + +export default function LadderButton() { + const navigate = useNavigate() + const location = useLocation() + + // Скрыть кнопку на странице ladder + if (location.pathname === '/ladder') { + return null + } + + const handleClick = () => { + hapticFeedback('light') + navigate('/ladder') + } + + return ( + + ) +} + diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index efb18d0..1ffe886 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -1,5 +1,6 @@ import { Outlet } from 'react-router-dom' import Navigation from './Navigation' +import LadderButton from './LadderButton' import './Layout.css' export default function Layout({ user }) { @@ -9,6 +10,7 @@ export default function Layout({ user }) { + ) } diff --git a/frontend/src/components/PostMenu.jsx b/frontend/src/components/PostMenu.jsx index 7160f66..0045304 100644 --- a/frontend/src/components/PostMenu.jsx +++ b/frontend/src/components/PostMenu.jsx @@ -163,7 +163,7 @@ export default function PostMenu({ post, currentUser, onClose, onDelete, onUpdat if (!buttonPosition) return {} const menuWidth = 160 // Примерная ширина меню - const padding = 8 // Отступ от края экрана + const padding = 16 // Отступ от края экрана const buttonCenterX = buttonPosition.left + (buttonPosition.right - buttonPosition.left) / 2 const windowWidth = window.innerWidth diff --git a/frontend/src/pages/MonthlyLadder.css b/frontend/src/pages/MonthlyLadder.css new file mode 100644 index 0000000..f8f76da --- /dev/null +++ b/frontend/src/pages/MonthlyLadder.css @@ -0,0 +1,521 @@ +.ladder-page { + min-height: 100vh; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); + position: relative; + overflow-x: hidden; + padding-bottom: 80px; +} + +/* Новогодние снежинки */ +.new-year-decorations { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 1; +} + +.snowflake { + position: absolute; + color: rgba(255, 255, 255, 0.8); + font-size: 20px; + animation: fall linear infinite; + animation-duration: 10s; +} + +.snowflake:nth-child(1) { + left: 10%; + animation-delay: 0s; + animation-duration: 8s; +} + +.snowflake:nth-child(2) { + left: 30%; + animation-delay: 2s; + animation-duration: 12s; +} + +.snowflake:nth-child(3) { + left: 50%; + animation-delay: 4s; + animation-duration: 10s; +} + +.snowflake:nth-child(4) { + left: 70%; + animation-delay: 1s; + animation-duration: 9s; +} + +.snowflake:nth-child(5) { + left: 85%; + animation-delay: 3s; + animation-duration: 11s; +} + +.snowflake:nth-child(6) { + left: 20%; + animation-delay: 5s; + animation-duration: 13s; +} + +@keyframes fall { + 0% { + transform: translateY(-100vh) rotate(0deg); + opacity: 1; + } + 100% { + transform: translateY(100vh) rotate(360deg); + opacity: 0; + } +} + +.ladder-header { + position: sticky; + top: 0; + background: rgba(26, 26, 46, 0.95); + backdrop-filter: blur(10px); + padding: 16px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + z-index: 10; +} + +.ladder-header h1 { + font-size: 20px; + font-weight: 700; + background: linear-gradient(135deg, #FFD700, #FFA500, #FF6347); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.back-btn { + width: 44px; + height: 44px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; +} + +/* Карточка отсчета */ +.countdown-card { + margin: 16px; + background: linear-gradient(135deg, rgba(255, 215, 0, 0.15), rgba(255, 165, 0, 0.15)); + border: 2px solid rgba(255, 215, 0, 0.3); + backdrop-filter: blur(10px); + position: relative; + overflow: hidden; +} + +.countdown-card::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(255, 215, 0, 0.1) 0%, transparent 70%); + animation: shimmer 3s ease-in-out infinite; +} + +@keyframes shimmer { + 0%, 100% { + transform: rotate(0deg); + } + 50% { + transform: rotate(180deg); + } +} + +.countdown-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + position: relative; + z-index: 1; +} + +.countdown-title h2 { + font-size: 20px; + font-weight: 700; + color: #FFD700; + text-shadow: 0 0 10px rgba(255, 215, 0, 0.5); +} + +.gift-icon { + color: #FF6347; + animation: bounce 2s ease-in-out infinite; +} + +@keyframes bounce { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + +.countdown-timer { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin: 20px 0; + position: relative; + z-index: 1; +} + +.countdown-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.countdown-value { + font-size: 32px; + font-weight: 700; + color: #FFD700; + text-shadow: 0 0 15px rgba(255, 215, 0, 0.8); + min-width: 60px; + text-align: center; +} + +.countdown-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); + text-transform: uppercase; +} + +.countdown-separator { + font-size: 24px; + color: #FFD700; + font-weight: 700; + margin: 0 4px; +} + +.countdown-slogan { + text-align: center; + color: rgba(255, 255, 255, 0.9); + font-size: 14px; + font-style: italic; + margin-top: 16px; + position: relative; + z-index: 1; +} + +/* Топ пользователей */ +.ladder-top { + margin: 16px; + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.ladder-top-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.ladder-top-header h2 { + font-size: 20px; + font-weight: 700; + background: linear-gradient(135deg, #FFD700, #FFA500); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.info-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: rgba(255, 215, 0, 0.2); + border: 1px solid rgba(255, 215, 0, 0.3); + border-radius: 20px; + color: #FFD700; + font-size: 13px; + cursor: pointer; + transition: all 0.3s; +} + +.info-btn:active { + transform: scale(0.95); + background: rgba(255, 215, 0, 0.3); +} + +.top-users-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.top-user-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.3s; +} + +.top-user-item.current-user { + background: rgba(255, 215, 0, 0.15); + border-color: rgba(255, 215, 0, 0.4); + box-shadow: 0 0 20px rgba(255, 215, 0, 0.3); +} + +.user-rank { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex-shrink: 0; +} + +.rank-icon { + filter: drop-shadow(0 0 8px currentColor); +} + +.rank-icon.gold { + color: #FFD700; +} + +.rank-icon.silver { + color: #C0C0C0; +} + +.rank-icon.bronze { + color: #CD7F32; +} + +.rank-number { + font-size: 20px; + font-weight: 700; + color: rgba(255, 255, 255, 0.8); +} + +.user-avatar { + width: 50px; + height: 50px; + border-radius: 50%; + object-fit: cover; + border: 2px solid rgba(255, 215, 0, 0.3); + flex-shrink: 0; +} + +.top-user-item.current-user .user-avatar { + border-color: #FFD700; + box-shadow: 0 0 15px rgba(255, 215, 0, 0.5); +} + +.user-info { + flex: 1; + min-width: 0; +} + +.user-name { + font-size: 16px; + font-weight: 600; + color: #fff; + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.user-tickets { + font-size: 14px; + color: #FFD700; + font-weight: 500; +} + +.current-badge { + color: #FFD700; + filter: drop-shadow(0 0 4px #FFD700); + animation: twinkle 2s ease-in-out infinite; +} + +@keyframes twinkle { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Карточка текущего пользователя */ +.current-user-card { + margin: 16px; + background: rgba(255, 215, 0, 0.1); + border: 1px solid rgba(255, 215, 0, 0.3); +} + +.current-user-card h3 { + font-size: 18px; + font-weight: 600; + color: #FFD700; + margin-bottom: 12px; +} + +.current-user-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; +} + +/* Модальное окно с информацией */ +.info-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; + animation: fadeIn 0.3s; +} + +.info-modal { + max-width: 500px; + max-height: 80vh; + overflow-y: auto; + background: linear-gradient(135deg, #1a1a2e, #16213e); + border: 2px solid rgba(255, 215, 0, 0.3); + position: relative; +} + +.info-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: sticky; + top: 0; + background: rgba(26, 26, 46, 0.95); + backdrop-filter: blur(10px); + z-index: 10; +} + +.info-modal-header h2 { + font-size: 20px; + font-weight: 700; + color: #FFD700; +} + +.close-btn { + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + color: #fff; + border: none; + font-size: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + line-height: 1; +} + +.info-modal-content { + padding: 16px; +} + +.info-section { + margin-bottom: 20px; + padding-bottom: 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.info-section:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.info-section h3 { + font-size: 16px; + font-weight: 600; + color: #FFD700; + margin-bottom: 8px; +} + +.info-section p { + font-size: 14px; + color: rgba(255, 255, 255, 0.9); + line-height: 1.6; + margin-bottom: 4px; +} + +.info-limit { + color: rgba(255, 255, 255, 0.6); + font-size: 13px; + font-style: italic; +} + +.info-section.anti-fraud { + background: rgba(255, 69, 0, 0.1); + padding: 12px; + border-radius: 8px; + border: 1px solid rgba(255, 69, 0, 0.3); +} + +.info-section.anti-fraud h3 { + color: #FF6347; +} + +.loading-state { + display: flex; + justify-content: center; + align-items: center; + padding: 40px; +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid rgba(255, 215, 0, 0.2); + border-top-color: #FFD700; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Темная тема - дополнительные стили */ +[data-theme="dark"] .ladder-page { + background: linear-gradient(135deg, #000000 0%, #1a1a2e 50%, #16213e 100%); +} + diff --git a/frontend/src/pages/MonthlyLadder.jsx b/frontend/src/pages/MonthlyLadder.jsx new file mode 100644 index 0000000..3167683 --- /dev/null +++ b/frontend/src/pages/MonthlyLadder.jsx @@ -0,0 +1,276 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { ChevronLeft, Info, Gift, Trophy, Star } from 'lucide-react' +import { getLadderTop } from '../utils/api' +import { hapticFeedback } from '../utils/telegram' +import './MonthlyLadder.css' + +export default function MonthlyLadder({ user }) { + const navigate = useNavigate() + const [topUsers, setTopUsers] = useState([]) + const [currentUser, setCurrentUser] = useState(null) + const [currentUserRank, setCurrentUserRank] = useState(null) + const [loading, setLoading] = useState(true) + const [showInfo, setShowInfo] = useState(false) + const [timeLeft, setTimeLeft] = useState({ days: 0, hours: 0, minutes: 0, seconds: 0 }) + + useEffect(() => { + loadLadder() + updateCountdown() + const interval = setInterval(updateCountdown, 1000) + return () => clearInterval(interval) + }, []) + + const updateCountdown = () => { + // Получить текущее московское время + const getMoscowTime = () => { + const now = new Date() + // Москва = UTC+3 + const moscowOffset = 3 * 60 * 60 * 1000 // 3 часа в миллисекундах + const utcTime = now.getTime() + (now.getTimezoneOffset() * 60 * 1000) + return new Date(utcTime + moscowOffset) + } + + // Получить новогоднюю дату по московскому времени (1 января следующего года, 00:00 MSK) + const getNewYearMoscow = () => { + const moscowNow = getMoscowTime() + const year = moscowNow.getFullYear() + 1 + // Создаем дату 1 января следующего года в UTC, затем вычитаем смещение + const moscowNewYear = new Date(Date.UTC(year, 0, 1, 0, 0, 0, 0)) + const moscowOffset = 3 * 60 * 60 * 1000 + return new Date(moscowNewYear.getTime() - moscowOffset) + } + + const now = getMoscowTime() + const newYear = getNewYearMoscow() + const diff = newYear.getTime() - now.getTime() + + const days = Math.floor(diff / (1000 * 60 * 60 * 24)) + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)) + const seconds = Math.floor((diff % (1000 * 60)) / 1000) + + setTimeLeft({ days, hours, minutes, seconds }) + } + + const loadLadder = async () => { + try { + setLoading(true) + const data = await getLadderTop(5) + setTopUsers(data.topUsers || []) + setCurrentUser(data.currentUser) + setCurrentUserRank(data.currentUserRank) + } catch (error) { + console.error('Ошибка загрузки ладдера:', error) + } finally { + setLoading(false) + } + } + + const getRankIcon = (rank) => { + switch (rank) { + case 1: + return + case 2: + return + case 3: + return + default: + return {rank} + } + } + + const formatTickets = (tickets) => { + return tickets?.toLocaleString('ru-RU') || '0' + } + + return ( +
+ {/* Хедер */} +
+ +

Monthly Ladder

+
+
+ + {/* Новогодний декор */} +
+
❄️
+
❄️
+
❄️
+
❄️
+
❄️
+
❄️
+
+ + {/* Отсчет до нового года */} +
+
+ +

До Нового Года

+
+
+
+ {timeLeft.days} + дней +
+
:
+
+ {String(timeLeft.hours).padStart(2, '0')} + часов +
+
:
+
+ {String(timeLeft.minutes).padStart(2, '0')} + минут +
+
:
+
+ {String(timeLeft.seconds).padStart(2, '0')} + секунд +
+
+

Ваши посты, ваши арты, ваша слава. Остальное потом.

+
+ + {/* Топ 5 пользователей */} +
+
+

Топ 5

+ +
+ + {loading ? ( +
+
+
+ ) : ( +
+ {topUsers.map((topUser, index) => { + const isCurrentUser = user && (topUser._id === user.id || topUser._id?.toString() === user.id?.toString()) + return ( +
+
+ {getRankIcon(topUser.rank)} +
+ {topUser.username} +
+
+ {topUser.firstName || topUser.username} + {isCurrentUser && } +
+
+ {formatTickets(topUser.tickets)} билетов +
+
+
+ ) + })} +
+ )} +
+ + {/* Текущий пользователь (если не в топе) */} + {currentUser && currentUserRank > 5 && ( +
+

Ваша позиция

+
+
+ {currentUserRank} +
+ {currentUser.username} +
+
+ {currentUser.firstName || currentUser.username} + +
+
+ {formatTickets(currentUser.tickets)} билетов +
+
+
+
+ )} + + {/* Модальное окно с информацией */} + {showInfo && ( +
setShowInfo(false)}> +
e.stopPropagation()}> +
+

За что начисляются баллы

+ +
+
+
+

1. Посты

+

+15 баллов за создание поста

+

Лимит: 5 постов в день

+
+ +
+

2. Лайки

+

Ставишь лайки: +1 балл за лайк

+

Лимит: 50 в день

+

Получаешь лайки: +2 балла за лайк под твоей записью

+

Лимит учёта: 100 лайков в день

+
+ +
+

3. Комментарии

+

Пишешь комментарии: +4 балла за комментарий длиной 10+ символов

+

Лимит: 20 комментариев в день

+

Получаешь комментарии: +6 баллов за комментарий под твоим постом

+
+ +
+

4. Рефералы

+

+100 баллов за одного валидного реферала

+

Лимит: 3 реферала в день

+
+ +
+

5. Ваше творчество (арты)

+

Публикация: +40 баллов за арт, прошедший модерацию

+

Лимит: 1 арт в день / 5 в неделю

+

Реакции на арт:

+

+8 баллов за лайк под артом

+

+12 баллов за комментарий под артом (1 комментарий от одного человека в сутки)

+

Лимит: до 100 баллов в сутки с реакций на один арт

+
+ +
+

🛡️ Антифрод

+

Лайки/комменты от аккаунтов младше 24 часов не считаем

+

Комменты <10 символов = 0 баллов

+

Ограничение на баллы по входящим реакциям, чтобы боты не устроили ферму

+
+
+
+
+ )} +
+ ) +} + diff --git a/frontend/src/pages/UserProfile.css b/frontend/src/pages/UserProfile.css index e18d797..d49da5a 100644 --- a/frontend/src/pages/UserProfile.css +++ b/frontend/src/pages/UserProfile.css @@ -110,6 +110,11 @@ border: 1px solid var(--border-color); } +/* Темная тема: кнопка подписки имеет белый фон, текст должен быть черным */ +[data-theme="dark"] .follow-btn { + color: #000000; +} + .user-posts { padding: 16px; display: flex; diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 0aeda17..8b482ae 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -186,6 +186,12 @@ export const searchUsers = async (query) => { return response.data.users } +// Ladder API +export const getLadderTop = async (limit = 5) => { + const response = await api.get('/users/ladder/top', { params: { limit } }) + return response.data +} + // Notifications API export const getNotifications = async (params = {}) => { const response = await api.get('/notifications', { params })