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
+ Выберите теги, которые вас интересуют. Посты с этими тегами будут показываться в ленте "По интересам". +
+ + {loadingTags ? ( +{showTagInfo.description}
+