diff --git a/backend/bots/mainBot.js b/backend/bots/mainBot.js index 3a6abe4..4ca4941 100644 --- a/backend/bots/mainBot.js +++ b/backend/bots/mainBot.js @@ -179,7 +179,192 @@ const handleCommand = async (message) => { // Игнорируем неизвестные команды }; +const handleInlineQuery = async (inlineQuery) => { + if (!TELEGRAM_API) return; + + try { + const query = inlineQuery.query || ''; + const queryId = inlineQuery.id; + + // Формат: "furry tag1 tag2" или "anime tag1 tag2" + const parts = query.trim().split(/\s+/).filter(p => p.length > 0); + + if (parts.length === 0) { + // Если нет запроса, вернуть пустой результат + await axios.post(`${TELEGRAM_API}/answerInlineQuery`, { + inline_query_id: queryId, + results: [] + }); + return; + } + + // Первое слово - источник (furry или anime) + const source = parts[0].toLowerCase(); + const tags = parts.slice(1); + + if (source !== 'furry' && source !== 'anime') { + // Если первый параметр не furry или anime, вернуть пустой результат + await axios.post(`${TELEGRAM_API}/answerInlineQuery`, { + inline_query_id: queryId, + results: [] + }); + return; + } + + if (tags.length === 0) { + // Если нет тегов, вернуть пустой результат + await axios.post(`${TELEGRAM_API}/answerInlineQuery`, { + inline_query_id: queryId, + results: [] + }); + return; + } + + // Объединить теги в строку для поиска + const tagsQuery = tags.join(' '); + + let searchResults = []; + + if (source === 'furry') { + // Поиск через e621 API + try { + const config = require('../config'); + const E621_USER_AGENT = 'NakamaApp/1.0 (by glpshchn00 on e621; Telegram: @glpshchn00)'; + const auth = Buffer.from(`${config.e621Username}:${config.e621ApiKey}`).toString('base64'); + + const response = await axios.get('https://e621.net/posts.json', { + params: { + tags: tagsQuery, + limit: 50 + }, + headers: { + 'User-Agent': E621_USER_AGENT, + 'Authorization': `Basic ${auth}` + }, + timeout: 10000 + }); + + let postsData = []; + if (Array.isArray(response.data)) { + postsData = response.data; + } else if (response.data && Array.isArray(response.data.posts)) { + postsData = response.data.posts; + } else if (response.data && Array.isArray(response.data.data)) { + postsData = response.data.data; + } + + searchResults = postsData + .filter(post => post && post.file && post.file.url) + .slice(0, 50) + .map(post => ({ + id: post.id, + url: post.file.url, + preview: post.preview && post.preview.url ? post.preview.url : post.file.url, + tags: post.tags && post.tags.general ? post.tags.general : [], + source: 'e621' + })); + } catch (error) { + logError('Ошибка поиска e621 для inline query', error); + } + } else if (source === 'anime') { + // Поиск через Gelbooru API + try { + const config = require('../config'); + const response = await axios.get('https://gelbooru.com/index.php', { + params: { + page: 'dapi', + s: 'post', + q: 'index', + json: 1, + tags: tagsQuery, + limit: 50, + api_key: config.gelbooruApiKey, + user_id: config.gelbooruUserId + }, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + }, + timeout: 10000 + }); + + let postsData = []; + if (Array.isArray(response.data)) { + postsData = response.data; + } else if (response.data && response.data.post) { + postsData = Array.isArray(response.data.post) ? response.data.post : [response.data.post]; + } + + searchResults = postsData + .filter(post => post && post.file_url) + .slice(0, 50) + .map(post => ({ + id: post.id, + url: post.file_url, + preview: post.preview_url || post.thumbnail_url || post.file_url, + tags: post.tags ? (typeof post.tags === 'string' ? post.tags.split(' ') : post.tags) : [], + source: 'gelbooru' + })); + } catch (error) { + logError('Ошибка поиска Gelbooru для inline query', error); + } + } + + // Получить username бота + let botUsername = 'NakamaSpaceBot'; + try { + const botInfo = await axios.get(`${TELEGRAM_API}/getMe`); + botUsername = botInfo.data.result.username || 'NakamaSpaceBot'; + } catch (error) { + log('warn', 'Не удалось получить имя бота для inline query', { error: error.message }); + } + + // Преобразовать результаты в InlineQueryResult + const results = searchResults.map((post, index) => { + const tagsStr = Array.isArray(post.tags) ? post.tags.slice(0, 10).join(' ') : ''; + let caption = ''; + + if (tagsStr) { + caption = `Tags: ${tagsStr}\n\nvia @${botUsername}`; + } else { + caption = `via @${botUsername}`; + } + + return { + type: 'photo', + id: `${post.source}_${post.id}_${index}`, + photo_url: post.url, + thumb_url: post.preview || post.url, + caption: caption.substring(0, 1024), + parse_mode: 'HTML' + }; + }); + + await axios.post(`${TELEGRAM_API}/answerInlineQuery`, { + inline_query_id: queryId, + results: results, + cache_time: 300 // 5 минут + }); + } catch (error) { + logError('Ошибка обработки inline query', error); + // Отправить пустой результат при ошибке + try { + await axios.post(`${TELEGRAM_API}/answerInlineQuery`, { + inline_query_id: inlineQuery.id, + results: [] + }); + } catch (e) { + // Игнорировать ошибку отправки пустого результата + } + } +}; + const processUpdate = async (update) => { + // Обработка inline query + if (update.inline_query) { + await handleInlineQuery(update.inline_query); + return; + } + const message = update.message || update.edited_message; if (!message || !message.text) { return; @@ -215,7 +400,7 @@ const pollUpdates = async () => { const response = await axios.get(`${TELEGRAM_API}/getUpdates`, { params: { timeout: 1, - allowed_updates: ['message'] + allowed_updates: ['message', 'inline_query'] } }); @@ -236,7 +421,7 @@ const pollUpdates = async () => { params: { offset, timeout: 30, - allowed_updates: ['message'] + allowed_updates: ['message', 'inline_query'] } }); diff --git a/backend/models/Post.js b/backend/models/Post.js index 4c64452..2225d1c 100644 --- a/backend/models/Post.js +++ b/backend/models/Post.js @@ -39,8 +39,8 @@ const PostSchema = new mongoose.Schema({ images: [String], // Новое поле - массив изображений tags: [{ type: String, - enum: ['furry', 'anime', 'other'], - required: true + lowercase: true, + trim: true }], mentionedUsers: [{ type: mongoose.Schema.Types.ObjectId, diff --git a/backend/models/Report.js b/backend/models/Report.js index 8b0d03a..ff6e77e 100644 --- a/backend/models/Report.js +++ b/backend/models/Report.js @@ -8,14 +8,28 @@ const ReportSchema = new mongoose.Schema({ }, post: { type: mongoose.Schema.Types.ObjectId, - ref: 'Post', - required: true + ref: 'Post' + }, + // Тип репорта: 'post' (жалоба на пост) или 'tag_suggestion' (предложение тега) + type: { + type: String, + enum: ['post', 'tag_suggestion'], + default: 'post' }, reason: { type: String, required: true, maxlength: 500 }, + // Для предложения тега + suggestedTag: { + tagName: String, + category: { + type: String, + enum: ['theme', 'style', 'mood', 'technical'] + }, + description: String + }, status: { type: String, enum: ['pending', 'reviewed', 'resolved', 'dismissed'], diff --git a/backend/models/Tag.js b/backend/models/Tag.js new file mode 100644 index 0000000..c0456aa --- /dev/null +++ b/backend/models/Tag.js @@ -0,0 +1,50 @@ +const mongoose = require('mongoose'); + +const TagSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + unique: true, + lowercase: true, + trim: true, + maxlength: 50 + }, + category: { + type: String, + enum: ['theme', 'style', 'mood', 'technical'], + required: true + }, + description: { + type: String, + maxlength: 200 + }, + // Количество использований (для популярности) + usageCount: { + type: Number, + default: 0 + }, + // Статус тега (approved, pending, rejected) + status: { + type: String, + enum: ['approved', 'pending', 'rejected'], + default: 'approved' + }, + // Кто предложил тег (если был предложен пользователем) + suggestedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + createdAt: { + type: Date, + default: Date.now + } +}); + +// Индексы для быстрого поиска +TagSchema.index({ name: 1 }); +TagSchema.index({ category: 1 }); +TagSchema.index({ status: 1 }); +TagSchema.index({ usageCount: -1 }); + +module.exports = mongoose.model('Tag', TagSchema); + diff --git a/backend/models/User.js b/backend/models/User.js index 649db7d..dc1692f 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -46,6 +46,35 @@ const UserSchema = new mongoose.Schema({ default: 'furry' } }, + // Предпочитаемые теги для ленты по интересам + preferredTags: [{ + type: String, + lowercase: true, + trim: true + }], + // Предложенные пользователем теги (ожидают модерации) + suggestedTags: [{ + tagName: { + type: String, + required: true, + lowercase: true, + trim: true + }, + category: { + type: String, + enum: ['theme', 'style', 'mood', 'technical'] + }, + description: String, + status: { + type: String, + enum: ['pending', 'approved', 'rejected'], + default: 'pending' + }, + createdAt: { + type: Date, + default: Date.now + } + }], lastActiveAt: { type: Date, default: Date.now diff --git a/backend/routes/posts.js b/backend/routes/posts.js index 72ebe8a..46dec41 100644 --- a/backend/routes/posts.js +++ b/backend/routes/posts.js @@ -10,12 +10,14 @@ const { uploadPostImages, cleanupOnError } = require('../middleware/upload'); const { deleteFiles } = require('../utils/minio'); const Post = require('../models/Post'); const Notification = require('../models/Notification'); +const Tag = require('../models/Tag'); +const User = require('../models/User'); const { extractHashtags } = require('../utils/hashtags'); // Получить ленту постов router.get('/', authenticate, async (req, res) => { try { - const { page = 1, limit = 20, tag, userId } = req.query; + const { page = 1, limit = 20, tag, userId, filter = 'all' } = req.query; const query = {}; // Фильтр по тегу @@ -28,13 +30,37 @@ router.get('/', authenticate, async (req, res) => { query.author = userId; } - // Применить whitelist настройки пользователя - if (req.user.settings.whitelist.noFurry) { - query.tags = { $ne: 'furry' }; - } - if (req.user.settings.whitelist.onlyAnime) { - query.tags = 'anime'; + // Фильтры: 'all', 'interests', 'following' + if (filter === 'interests') { + // Лента по интересам - посты с тегами из preferredTags пользователя + const user = await User.findById(req.user._id).select('preferredTags'); + if (user.preferredTags && user.preferredTags.length > 0) { + query.tags = { $in: user.preferredTags }; + } else { + // Если нет предпочитаемых тегов, вернуть пустой результат + return res.json({ + posts: [], + totalPages: 0, + currentPage: page + }); + } + } else if (filter === 'following') { + // Лента подписок - посты от пользователей, на которых подписан + const user = await User.findById(req.user._id).select('following'); + if (user.following && user.following.length > 0) { + query.author = { $in: user.following }; + } else { + // Если нет подписок, вернуть пустой результат + return res.json({ + posts: [], + totalPages: 0, + currentPage: page + }); + } } + // 'all' - все посты, без дополнительных фильтров + + // Применить whitelist настройки пользователя (только NSFW и Homo) if (req.user.settings.whitelist.noNSFW) { query.isNSFW = false; } @@ -153,6 +179,14 @@ router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploa await post.save(); await post.populate('author', 'username firstName lastName photoUrl'); + // Увеличить счетчики использования тегов + if (parsedTags.length > 0) { + await Tag.updateMany( + { name: { $in: parsedTags } }, + { $inc: { usageCount: 1 } } + ); + } + // Начислить баллы за создание поста const { awardPostCreation } = require('../utils/tickets'); await awardPostCreation(req.user._id); diff --git a/backend/routes/tags.js b/backend/routes/tags.js new file mode 100644 index 0000000..349b021 --- /dev/null +++ b/backend/routes/tags.js @@ -0,0 +1,172 @@ +const express = require('express'); +const router = express.Router(); +const Tag = require('../models/Tag'); +const User = require('../models/User'); +const Report = require('../models/Report'); +const { authenticate } = require('../middleware/auth'); +const { logError } = require('../middleware/logger'); + +// Получить все теги по категориям +router.get('/', async (req, res) => { + try { + const { category } = req.query; + const query = { status: 'approved' }; + if (category) { + query.category = category; + } + + const tags = await Tag.find(query) + .sort({ usageCount: -1, name: 1 }) + .select('name category description usageCount'); + + // Группировка по категориям + const grouped = { + theme: [], + style: [], + mood: [], + technical: [] + }; + + tags.forEach(tag => { + if (grouped[tag.category]) { + grouped[tag.category].push(tag); + } + }); + + res.json({ tags: grouped, all: tags }); + } catch (error) { + logError('Ошибка получения тегов', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Автодополнение тегов +router.get('/autocomplete', async (req, res) => { + try { + const { query } = req.query; + + if (!query || query.length < 1) { + return res.json({ tags: [] }); + } + + const searchQuery = query.toLowerCase().trim(); + const tags = await Tag.find({ + status: 'approved', + name: { $regex: `^${searchQuery}`, $options: 'i' } + }) + .sort({ usageCount: -1, name: 1 }) + .limit(20) + .select('name category description'); + + res.json({ tags }); + } catch (error) { + logError('Ошибка автодополнения тегов', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Предложить новый тег +router.post('/suggest', authenticate, async (req, res) => { + try { + const { tagName, category, description } = req.body; + + if (!tagName || !category) { + return res.status(400).json({ error: 'Название тега и категория обязательны' }); + } + + const normalizedTagName = tagName.toLowerCase().trim(); + + // Проверка формата + if (!/^[a-zA-Z0-9_\-]+$/.test(normalizedTagName)) { + return res.status(400).json({ error: 'Недопустимый формат тега' }); + } + + if (normalizedTagName.length > 50) { + return res.status(400).json({ error: 'Тег слишком длинный' }); + } + + // Проверить, существует ли уже такой тег + const existingTag = await Tag.findOne({ name: normalizedTagName }); + if (existingTag) { + if (existingTag.status === 'approved') { + return res.status(400).json({ error: 'Такой тег уже существует' }); + } + if (existingTag.status === 'pending') { + return res.status(400).json({ error: 'Этот тег уже предложен и ожидает модерации' }); + } + } + + // Создать репорт для предложения тега + const report = new Report({ + reporter: req.user._id, + type: 'tag_suggestion', + reason: `Предложение нового тега: ${normalizedTagName}`, + suggestedTag: { + tagName: normalizedTagName, + category, + description: description || '' + }, + status: 'pending' + }); + + await report.save(); + + // Также добавить в suggestedTags пользователя + await User.findByIdAndUpdate(req.user._id, { + $push: { + suggestedTags: { + tagName: normalizedTagName, + category, + description: description || '', + status: 'pending' + } + } + }); + + res.json({ message: 'Тег предложен и отправлен на модерацию' }); + } catch (error) { + logError('Ошибка предложения тега', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Обновить предпочитаемые теги пользователя +router.put('/preferences', authenticate, async (req, res) => { + try { + const { tags } = req.body; + + if (!Array.isArray(tags)) { + return res.status(400).json({ error: 'Теги должны быть массивом' }); + } + + // Валидация тегов + const normalizedTags = tags.map(t => t.toLowerCase().trim()).filter(t => t.length > 0); + + if (normalizedTags.length > 50) { + return res.status(400).json({ error: 'Слишком много тегов' }); + } + + await User.findByIdAndUpdate(req.user._id, { + preferredTags: normalizedTags + }); + + res.json({ message: 'Предпочитаемые теги обновлены' }); + } catch (error) { + logError('Ошибка обновления предпочитаемых тегов', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +// Получить предпочитаемые теги пользователя +router.get('/preferences', authenticate, async (req, res) => { + try { + const user = await User.findById(req.user._id).select('preferredTags'); + res.json({ tags: user.preferredTags || [] }); + } catch (error) { + logError('Ошибка получения предпочитаемых тегов', error); + res.status(500).json({ error: 'Ошибка сервера' }); + } +}); + +module.exports = router; + diff --git a/backend/scripts/initTags.js b/backend/scripts/initTags.js new file mode 100644 index 0000000..eea0696 --- /dev/null +++ b/backend/scripts/initTags.js @@ -0,0 +1,75 @@ +const mongoose = require('mongoose'); +const dotenv = require('dotenv'); +const path = require('path'); + +// Загрузить переменные окружения +dotenv.config({ path: path.join(__dirname, '../.env') }); + +const Tag = require('../models/Tag'); + +const INITIAL_TAGS = [ + // Тематика + { name: 'furry', category: 'theme', description: 'Furry контент' }, + { name: 'anime', category: 'theme', description: 'Аниме контент' }, + { name: 'sci-fi', category: 'theme', description: 'Научная фантастика' }, + { name: 'fantasy', category: 'theme', description: 'Фэнтези' }, + { name: 'irl', category: 'theme', description: 'Реальный мир' }, + { name: 'meme', category: 'theme', description: 'Мемы' }, + { name: 'nsfw', category: 'theme', description: 'Контент 18+' }, + + // Стиль / Формат + { name: 'art', category: 'style', description: 'Арт' }, + { name: 'sketch', category: 'style', description: 'Эскиз' }, + { name: 'pixel', category: 'style', description: 'Пиксель-арт' }, + { name: '3d', category: 'style', description: '3D графика' }, + { name: 'photo', category: 'style', description: 'Фотография' }, + { name: 'cosplay', category: 'style', description: 'Косплей' }, + { name: 'animation', category: 'style', description: 'Анимация' }, + + // Настроение / Сцена + { name: 'cute', category: 'mood', description: 'Милое' }, + { name: 'action', category: 'mood', description: 'Экшн' }, + { name: 'dark', category: 'mood', description: 'Темное' }, + { name: 'humorous', category: 'mood', description: 'Юмор' }, + { name: 'romantic', category: 'mood', description: 'Романтика' }, + + // Размер / Ориентация / Технические + { name: 'vertical', category: 'technical', description: 'Вертикальная ориентация' }, + { name: 'horizontal', category: 'technical', description: 'Горизонтальная ориентация' }, + { name: '4k', category: 'technical', description: '4K разрешение' }, + { name: 'gif', category: 'technical', description: 'GIF формат' }, + { name: 'loop', category: 'technical', description: 'Зацикленное видео' } +]; + +async function initTags() { + try { + const mongoUri = process.env.MONGO_URI || 'mongodb://localhost:27017/nakama'; + await mongoose.connect(mongoUri); + console.log('✅ Подключено к MongoDB'); + + let created = 0; + let skipped = 0; + + for (const tagData of INITIAL_TAGS) { + const existing = await Tag.findOne({ name: tagData.name }); + if (!existing) { + await Tag.create(tagData); + created++; + console.log(`✅ Создан тег: ${tagData.name}`); + } else { + skipped++; + console.log(`⏭️ Тег уже существует: ${tagData.name}`); + } + } + + console.log(`\n📊 Итого: создано ${created}, пропущено ${skipped}`); + await mongoose.connection.close(); + console.log('✅ Готово!'); + } catch (error) { + console.error('❌ Ошибка:', error); + process.exit(1); + } +} + +initTags(); + diff --git a/backend/server.js b/backend/server.js index e9e7d14..2edb11e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -245,6 +245,7 @@ app.use('/api/statistics', require('./routes/statistics')); app.use('/api/bot', require('./routes/bot')); app.use('/api/mod-app', require('./routes/modApp')); app.use('/api/minio', require('./routes/minio-test')); +app.use('/api/tags', require('./routes/tags')); // Базовый роут app.get('/', (req, res) => { diff --git a/frontend/src/components/CreatePostModal.css b/frontend/src/components/CreatePostModal.css index ddeb464..1258981 100644 --- a/frontend/src/components/CreatePostModal.css +++ b/frontend/src/components/CreatePostModal.css @@ -180,6 +180,119 @@ transition: all 0.2s; } +.selected-tags { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.tag-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--button-accent); + color: white; + border-radius: 16px; + font-size: 14px; + font-weight: 500; +} + +.tag-remove { + width: 18px; + height: 18px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); + color: white; + display: flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; + padding: 0; +} + +.tag-input-wrapper { + position: relative; +} + +.tag-input { + width: 100%; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 15px; +} + +.tag-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 4px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + max-height: 200px; + overflow-y: auto; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.tag-suggestion-item { + padding: 12px 16px; + cursor: pointer; + transition: background 0.2s; + border-bottom: 1px solid var(--divider-color); +} + +.tag-suggestion-item:last-child { + border-bottom: none; +} + +.tag-suggestion-item:hover, +.tag-suggestion-item:active { + background: var(--bg-primary); +} + +.tag-suggestion-name { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.tag-suggestion-category { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 2px; +} + +.tag-suggestion-description { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.4; +} + +.tag-hint { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 8px 12px; + background: var(--bg-primary); + border-radius: 8px; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; +} + +.tag-hint svg { + flex-shrink: 0; + margin-top: 2px; +} + .mentioned-users { display: flex; gap: 8px; diff --git a/frontend/src/components/CreatePostModal.jsx b/frontend/src/components/CreatePostModal.jsx index e1b19a6..1b0a936 100644 --- a/frontend/src/components/CreatePostModal.jsx +++ b/frontend/src/components/CreatePostModal.jsx @@ -1,19 +1,23 @@ -import { useState, useRef } from 'react' +import { useState, useRef, useEffect } from 'react' import { createPortal } from 'react-dom' -import { X, Image as ImageIcon, Tag, AtSign } from 'lucide-react' -import { createPost, searchUsers } from '../utils/api' +import { X, Image as ImageIcon, Tag, AtSign, Info } from 'lucide-react' +import { createPost, searchUsers, autocompleteTags, suggestTag } from '../utils/api' import { hapticFeedback } from '../utils/telegram' import './CreatePostModal.css' -const TAGS = [ - { value: 'furry', label: 'Furry', color: '#FF8A33' }, - { value: 'anime', label: 'Anime', color: '#4A90E2' }, - { value: 'other', label: 'Other', color: '#A0A0A0' } -] +const TAG_CATEGORIES = { + theme: 'Тематика', + style: 'Стиль / Формат', + mood: 'Настроение / Сцена', + technical: 'Размер / Ориентация / Технические' +} export default function CreatePostModal({ user, onClose, onPostCreated, initialImage }) { const [content, setContent] = useState('') const [selectedTags, setSelectedTags] = useState([]) + const [tagInput, setTagInput] = useState('') + const [tagSuggestions, setTagSuggestions] = useState([]) + const [showTagSuggestions, setShowTagSuggestions] = useState(false) const [images, setImages] = useState(initialImage ? [initialImage] : []) const [imagePreviews, setImagePreviews] = useState(initialImage ? [initialImage] : []) const [externalImages, setExternalImages] = useState(initialImage ? [initialImage] : []) @@ -25,12 +29,52 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI const [searchResults, setSearchResults] = useState([]) const [mentionedUsers, setMentionedUsers] = useState([]) const fileInputRef = useRef(null) + const tagInputRef = useRef(null) + const tagSuggestionsRef = useRef(null) + + // Автодополнение тегов + useEffect(() => { + const fetchTagSuggestions = async () => { + if (tagInput.trim().length > 0) { + try { + const data = await autocompleteTags(tagInput.trim()) + setTagSuggestions(data.tags || []) + setShowTagSuggestions(true) + } catch (error) { + console.error('Ошибка автодополнения тегов:', error) + setTagSuggestions([]) + } + } else { + setTagSuggestions([]) + setShowTagSuggestions(false) + } + } + + const debounceTimer = setTimeout(fetchTagSuggestions, 300) + return () => clearTimeout(debounceTimer) + }, [tagInput]) + + // Закрыть подсказки при клике вне + useEffect(() => { + const handleClickOutside = (event) => { + if ( + tagSuggestionsRef.current && + !tagSuggestionsRef.current.contains(event.target) && + tagInputRef.current && + !tagInputRef.current.contains(event.target) + ) { + setShowTagSuggestions(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) const handleImageSelect = (e) => { const files = Array.from(e.target.files) if (files.length === 0) return - // Максимум 5 изображений const remainingSlots = 5 - images.length const filesToAdd = files.slice(0, remainingSlots) @@ -56,15 +100,39 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI setExternalImages(prev => prev.filter((_, i) => i !== index)) } - const toggleTag = (tag) => { - hapticFeedback('light') - if (selectedTags.includes(tag)) { - setSelectedTags(selectedTags.filter(t => t !== tag)) - } else { - setSelectedTags([...selectedTags, tag]) + const handleTagInputKeyDown = (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + const trimmed = tagInput.trim().toLowerCase() + if (trimmed && !selectedTags.includes(trimmed)) { + addTag(trimmed) + } + } else if (e.key === 'Backspace' && tagInput === '' && selectedTags.length > 0) { + removeTag(selectedTags[selectedTags.length - 1]) + } else if (e.key === 'ArrowDown' && tagSuggestions.length > 0) { + e.preventDefault() + // Можно добавить навигацию по подсказкам } } + const addTag = (tag) => { + if (tag && !selectedTags.includes(tag) && selectedTags.length < 20) { + setSelectedTags([...selectedTags, tag]) + setTagInput('') + setShowTagSuggestions(false) + hapticFeedback('light') + } + } + + const removeTag = (tag) => { + setSelectedTags(selectedTags.filter(t => t !== tag)) + hapticFeedback('light') + } + + const handleTagSuggestionClick = (tag) => { + addTag(tag.name) + } + const handleUserSearch = async (query) => { setUserSearchQuery(query) if (query.length > 1) { @@ -92,7 +160,7 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI const handleSubmit = async () => { if (selectedTags.length === 0) { - alert('Выберите хотя бы один тег') + alert('Добавьте хотя бы один тег') return } @@ -111,14 +179,12 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI formData.append('isNSFW', isNSFW) formData.append('isHomo', isHomo) - // Добавить загруженные файлы images.forEach((image, index) => { if (image instanceof File) { formData.append('images', image) } }) - // Добавить внешние изображения (из поиска) if (externalImages.length > 0) { formData.append('externalImages', JSON.stringify(externalImages)) } @@ -190,22 +256,66 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
- Теги (обязательно) + Теги (обязательно, минимум 1)
-
- {TAGS.map(tag => ( - - ))} + + {/* Выбранные теги */} + {selectedTags.length > 0 && ( +
+ {selectedTags.map(tag => ( + + {tag} + + + ))} +
+ )} + + {/* Поле ввода тега */} +
+ setTagInput(e.target.value)} + onKeyDown={handleTagInputKeyDown} + onFocus={() => { + if (tagInput.trim().length > 0) { + setShowTagSuggestions(true) + } + }} + /> + + {/* Подсказки тегов */} + {showTagSuggestions && tagSuggestions.length > 0 && ( +
+ {tagSuggestions.map(tag => ( +
handleTagSuggestionClick(tag)} + > +
{tag.name}
+ {tag.category && ( +
+ {TAG_CATEGORIES[tag.category] || tag.category} +
+ )} + {tag.description && ( +
{tag.description}
+ )} +
+ ))} +
+ )} +
+ +
+ + Введите теги через пробел или Enter. Используйте автодополнение для поиска существующих тегов.
@@ -303,4 +413,3 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI document.body ) } - diff --git a/frontend/src/pages/Feed.jsx b/frontend/src/pages/Feed.jsx index e5db88a..40553aa 100644 --- a/frontend/src/pages/Feed.jsx +++ b/frontend/src/pages/Feed.jsx @@ -63,11 +63,10 @@ export default function Feed({ user }) { const loadPosts = async (pageNum = 1) => { try { setLoading(true) - const params = {} - if (filter !== 'all') { - params.tag = filter + const params = { + filter: filter, // 'all', 'interests', 'following' + page: pageNum } - params.page = pageNum const data = await getPosts(params) @@ -119,30 +118,30 @@ export default function Feed({ user }) {
-
diff --git a/frontend/src/pages/Profile.css b/frontend/src/pages/Profile.css index 7312de3..c0585d9 100644 --- a/frontend/src/pages/Profile.css +++ b/frontend/src/pages/Profile.css @@ -111,6 +111,7 @@ .profile-stats { display: flex; + flex-direction: row; align-items: center; gap: 24px; width: 100%; @@ -503,3 +504,227 @@ margin-top: 8px; } +/* Модалка выбора предпочитаемых тегов */ +.tag-preferences-modal { + max-height: 90vh; + overflow-y: auto; +} + +.tag-preferences-hint { + padding: 12px; + background: var(--bg-primary); + border-radius: 8px; + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 20px; + line-height: 1.5; +} + +.tag-category-section { + margin-bottom: 24px; +} + +.tag-category-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 12px; +} + +.tags-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 8px; +} + +.tag-preference-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + background: var(--bg-primary); + border: 2px solid var(--border-color); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.tag-preference-item:hover { + background: var(--bg-secondary); + border-color: var(--button-accent); +} + +.tag-preference-item.selected { + background: var(--button-accent); + border-color: var(--button-accent); + color: white; +} + +.tag-preference-item.selected .tag-preference-name { + color: white; +} + +.tag-preference-item.selected .tag-preference-description { + color: rgba(255, 255, 255, 0.9); +} + +.tag-preference-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.tag-preference-name { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.tag-preference-description { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + user-select: none; +} + +.tag-preference-description:hover { + color: var(--button-accent); +} + +.tag-preference-description svg { + flex-shrink: 0; +} + +.tag-preference-checkbox { + margin-left: 12px; +} + +.tag-preference-checkbox input { + width: 20px; + height: 20px; + cursor: pointer; +} + +.selected-tags-summary { + margin-top: 24px; + padding: 16px; + background: var(--bg-primary); + border-radius: 8px; +} + +.selected-tags-summary strong { + display: block; + margin-bottom: 12px; + font-size: 15px; + color: var(--text-primary); +} + +.selected-tags-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.selected-tag-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--button-accent); + color: white; + border-radius: 16px; + font-size: 14px; + font-weight: 500; +} + +.tag-chip-remove { + width: 18px; + height: 18px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); + color: white; + display: flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; + padding: 0; +} + +.settings-arrow-btn { + background: transparent; + border: none; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.loading-state { + display: flex; + justify-content: center; + align-items: center; + padding: 40px; +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border-color); + border-top-color: var(--button-accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Модалка с информацией о теге */ +.tag-info-modal { + max-width: 400px; + width: 90%; +} + +.tag-info-content { + display: flex; + flex-direction: column; + gap: 16px; +} + +.tag-info-name { + display: flex; + align-items: center; + gap: 8px; + font-size: 20px; + font-weight: 700; + color: var(--text-primary); +} + +.tag-info-category { + font-size: 14px; + color: var(--text-secondary); +} + +.tag-info-category strong { + color: var(--text-primary); +} + +.tag-info-description h3 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.tag-info-description p { + font-size: 14px; + line-height: 1.6; + color: var(--text-secondary); +} + diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx index 54d0300..bc4fdb5 100644 --- a/frontend/src/pages/Profile.jsx +++ b/frontend/src/pages/Profile.jsx @@ -1,7 +1,7 @@ -import { useState } from 'react' -import { Settings, Heart, Edit2, Shield, UserPlus, Copy } from 'lucide-react' +import { useState, useEffect } from 'react' +import { Settings, Heart, Edit2, Shield, UserPlus, Copy, Info, Tag, X } from 'lucide-react' import { createPortal } from 'react-dom' -import { updateProfile } from '../utils/api' +import { updateProfile, getTags, getPreferredTags, updatePreferredTags } from '../utils/api' import { hapticFeedback } from '../utils/telegram' import ThemeToggle from '../components/ThemeToggle' import FollowListModal from '../components/FollowListModal' @@ -37,6 +37,13 @@ const normalizeSettings = (rawSettings = {}) => { } } +const TAG_CATEGORIES = { + theme: 'Тематика', + style: 'Стиль / Формат', + mood: 'Настроение / Сцена', + technical: 'Размер / Ориентация / Технические' +} + export default function Profile({ user, setUser }) { const [showSettings, setShowSettings] = useState(false) const [showEditBio, setShowEditBio] = useState(false) @@ -45,6 +52,11 @@ export default function Profile({ user, setUser }) { const [saving, setSaving] = useState(false) const [showFollowers, setShowFollowers] = useState(false) const [showFollowing, setShowFollowing] = useState(false) + const [showTagPreferences, setShowTagPreferences] = useState(false) + const [allTags, setAllTags] = useState({ theme: [], style: [], mood: [], technical: [] }) + const [selectedTags, setSelectedTags] = useState([]) + const [loadingTags, setLoadingTags] = useState(false) + const [showTagInfo, setShowTagInfo] = useState(null) // { name, description, category } const handleSaveBio = async () => { try { @@ -87,6 +99,73 @@ export default function Profile({ user, setUser }) { window.open(DONATION_URL, '_blank', 'noopener,noreferrer') } + // Загрузить предпочитаемые теги при загрузке компонента + useEffect(() => { + if (user.preferredTags) { + setSelectedTags(user.preferredTags) + } + }, [user.preferredTags]) + + // Загрузить теги при открытии модалки выбора тегов + useEffect(() => { + if (showTagPreferences) { + loadTags() + // Загрузить текущие предпочитаемые теги пользователя + if (user.preferredTags) { + setSelectedTags(user.preferredTags) + } else { + loadPreferredTags() + } + } + }, [showTagPreferences]) + + const loadTags = async () => { + try { + setLoadingTags(true) + const data = await getTags() + setAllTags(data.tags || { theme: [], style: [], mood: [], technical: [] }) + } catch (error) { + console.error('Ошибка загрузки тегов:', error) + } finally { + setLoadingTags(false) + } + } + + const loadPreferredTags = async () => { + try { + const data = await getPreferredTags() + setSelectedTags(data.tags || []) + } catch (error) { + console.error('Ошибка загрузки предпочитаемых тегов:', error) + } + } + + const toggleTag = (tagName) => { + hapticFeedback('light') + if (selectedTags.includes(tagName)) { + setSelectedTags(selectedTags.filter(t => t !== tagName)) + } else { + setSelectedTags([...selectedTags, tagName]) + } + } + + const handleSaveTagPreferences = async () => { + try { + setSaving(true) + hapticFeedback('light') + await updatePreferredTags(selectedTags) + setUser({ ...user, preferredTags: selectedTags }) + setShowTagPreferences(false) + hapticFeedback('success') + } catch (error) { + console.error('Ошибка сохранения тегов:', error) + hapticFeedback('error') + alert('Ошибка сохранения тегов') + } finally { + setSaving(false) + } + } + const updateWhitelistSetting = async (key, value) => { const updatedSettings = normalizeSettings({ ...settings, @@ -289,6 +368,21 @@ export default function Profile({ user, setUser }) {
+ +
setShowTagPreferences(true)} style={{ cursor: 'pointer' }}> +
+
+ + Предпочитаемые теги +
+
+ Выберите теги для ленты по интересам ({selectedTags.length || user.preferredTags?.length || 0} выбрано) +
+
+ +
{/* Модальное окно редактирования bio */} @@ -417,6 +511,133 @@ export default function Profile({ user, setUser }) { onClose={() => setShowFollowing(false)} /> )} + + {/* Модалка выбора предпочитаемых тегов */} + {showTagPreferences && ( +
setShowTagPreferences(false)}> +
e.stopPropagation()}> +
+

Предпочитаемые теги

+ +
+ +
+

+ Выберите теги, которые вас интересуют. Посты с этими тегами будут показываться в ленте "По интересам". +

+ + {loadingTags ? ( +
+
+
+ ) : ( + Object.entries(TAG_CATEGORIES).map(([categoryKey, categoryName]) => { + const categoryTags = allTags[categoryKey] || [] + if (categoryTags.length === 0) return null + + return ( +
+

{categoryName}

+
+ {categoryTags.map(tag => { + const isSelected = selectedTags.includes(tag.name) + return ( +
toggleTag(tag.name)} + > +
+ {tag.name} + {tag.description && ( +
{ + e.stopPropagation() + setShowTagInfo({ + name: tag.name, + description: tag.description, + category: TAG_CATEGORIES[tag.category] || tag.category + }) + hapticFeedback('light') + }} + > + + Нажмите для описания +
+ )} +
+
+ toggleTag(tag.name)} + onClick={(e) => e.stopPropagation()} + /> +
+
+ ) + })} +
+
+ ) + }) + )} + + {selectedTags.length > 0 && ( +
+ Выбрано: {selectedTags.length} +
+ {selectedTags.map(tag => ( + + {tag} + + + ))} +
+
+ )} +
+
+
+ )} + + {/* Модалка с описанием тега */} + {showTagInfo && ( +
setShowTagInfo(null)}> +
e.stopPropagation()}> +
+

Информация о теге

+ +
+
+
+
+ + {showTagInfo.name} +
+
+ Категория: {showTagInfo.category} +
+
+

Описание:

+

{showTagInfo.description}

+
+
+
+
+
+ )}
) } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 8b482ae..5146cf7 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -107,7 +107,13 @@ export const verifyAuth = async () => { // Авторизация через Telegram OAuth (Login Widget) // Posts API export const getPosts = async (params = {}) => { - const response = await api.get('/posts', { params }) + // Поддержка старого формата для обратной совместимости + const queryParams = { ...params } + if (params.tag && !params.filter) { + // Старый формат с tag -> новый формат с filter + queryParams.filter = 'all' + } + const response = await api.get('/posts', { params: queryParams }) return response.data } @@ -256,6 +262,33 @@ export const banUser = async (userId, banned, days) => { } // Bot API +// Теги +export const getTags = async (category) => { + const params = category ? { category } : {} + const res = await api.get('/tags', { params }) + return res.data +} + +export const autocompleteTags = async (query) => { + const res = await api.get('/tags/autocomplete', { params: { query } }) + return res.data +} + +export const suggestTag = async (tagName, category, description) => { + const res = await api.post('/tags/suggest', { tagName, category, description }) + return res.data +} + +export const updatePreferredTags = async (tags) => { + const res = await api.put('/tags/preferences', { tags }) + return res.data +} + +export const getPreferredTags = async () => { + const res = await api.get('/tags/preferences') + return res.data +} + export const sendPhotoToTelegram = async (photoUrl) => { const telegramUser = window.Telegram?.WebApp?.initDataUnsafe?.user if (!telegramUser || !telegramUser.id) {