Update files
This commit is contained in:
parent
de3fd9b58c
commit
48a3955c37
|
|
@ -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']
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
// Фильтры: '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
|
||||
});
|
||||
}
|
||||
if (req.user.settings.whitelist.onlyAnime) {
|
||||
query.tags = 'anime';
|
||||
} 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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,13 +100,37 @@ 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) => {
|
||||
|
|
@ -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,23 +256,67 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
|
|||
<div className="tags-section">
|
||||
<div className="section-label">
|
||||
<Tag size={18} />
|
||||
<span>Теги (обязательно)</span>
|
||||
<span>Теги (обязательно, минимум 1)</span>
|
||||
</div>
|
||||
<div className="tags-list">
|
||||
{TAGS.map(tag => (
|
||||
<button
|
||||
key={tag.value}
|
||||
className={`tag-btn ${selectedTags.includes(tag.value) ? 'active' : ''}`}
|
||||
style={{
|
||||
backgroundColor: selectedTags.includes(tag.value) ? tag.color : 'var(--bg-primary)',
|
||||
color: selectedTags.includes(tag.value) ? 'white' : 'var(--text-primary)'
|
||||
}}
|
||||
onClick={() => toggleTag(tag.value)}
|
||||
>
|
||||
{tag.label}
|
||||
|
||||
{/* Выбранные теги */}
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="selected-tags">
|
||||
{selectedTags.map(tag => (
|
||||
<span key={tag} className="tag-chip">
|
||||
{tag}
|
||||
<button onClick={() => removeTag(tag)} className="tag-remove">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Поле ввода тега */}
|
||||
<div className="tag-input-wrapper" ref={tagInputRef}>
|
||||
<input
|
||||
type="text"
|
||||
className="tag-input"
|
||||
placeholder="Введите тег и нажмите Enter или пробел"
|
||||
value={tagInput}
|
||||
onChange={e => setTagInput(e.target.value)}
|
||||
onKeyDown={handleTagInputKeyDown}
|
||||
onFocus={() => {
|
||||
if (tagInput.trim().length > 0) {
|
||||
setShowTagSuggestions(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Подсказки тегов */}
|
||||
{showTagSuggestions && tagSuggestions.length > 0 && (
|
||||
<div className="tag-suggestions" ref={tagSuggestionsRef}>
|
||||
{tagSuggestions.map(tag => (
|
||||
<div
|
||||
key={tag.name}
|
||||
className="tag-suggestion-item"
|
||||
onClick={() => handleTagSuggestionClick(tag)}
|
||||
>
|
||||
<div className="tag-suggestion-name">{tag.name}</div>
|
||||
{tag.category && (
|
||||
<div className="tag-suggestion-category">
|
||||
{TAG_CATEGORIES[tag.category] || tag.category}
|
||||
</div>
|
||||
)}
|
||||
{tag.description && (
|
||||
<div className="tag-suggestion-description">{tag.description}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tag-hint">
|
||||
<Info size={14} />
|
||||
<span>Введите теги через пробел или Enter. Используйте автодополнение для поиска существующих тегов.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Упомянутые пользователи */}
|
||||
|
|
@ -303,4 +413,3 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
|
|||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<div className="feed-filters">
|
||||
<button
|
||||
className={`filter-btn ${filter === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('all')}
|
||||
onClick={() => {
|
||||
setFilter('all')
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
<button
|
||||
className={`filter-btn ${filter === 'furry' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('furry')}
|
||||
style={{ color: filter === 'furry' ? 'var(--tag-furry)' : undefined }}
|
||||
className={`filter-btn ${filter === 'interests' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setFilter('interests')
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
Furry
|
||||
По интересам
|
||||
</button>
|
||||
<button
|
||||
className={`filter-btn ${filter === 'anime' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('anime')}
|
||||
style={{ color: filter === 'anime' ? 'var(--tag-anime)' : undefined }}
|
||||
className={`filter-btn ${filter === 'following' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setFilter('following')
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
Anime
|
||||
</button>
|
||||
<button
|
||||
className={`filter-btn ${filter === 'other' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('other')}
|
||||
style={{ color: filter === 'other' ? 'var(--tag-other)' : undefined }}
|
||||
>
|
||||
Other
|
||||
Подписки
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<span className="toggle-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-item card" onClick={() => setShowTagPreferences(true)} style={{ cursor: 'pointer' }}>
|
||||
<div>
|
||||
<div className="setting-name">
|
||||
<Tag size={16} style={{ display: 'inline', marginRight: '8px', verticalAlign: 'middle' }} />
|
||||
Предпочитаемые теги
|
||||
</div>
|
||||
<div className="setting-desc">
|
||||
Выберите теги для ленты по интересам ({selectedTags.length || user.preferredTags?.length || 0} выбрано)
|
||||
</div>
|
||||
</div>
|
||||
<button className="settings-arrow-btn">
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно редактирования bio */}
|
||||
|
|
@ -417,6 +511,133 @@ export default function Profile({ user, setUser }) {
|
|||
onClose={() => setShowFollowing(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Модалка выбора предпочитаемых тегов */}
|
||||
{showTagPreferences && (
|
||||
<div className="modal-overlay" onClick={() => setShowTagPreferences(false)}>
|
||||
<div className="modal-content tag-preferences-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Предпочитаемые теги</h2>
|
||||
<button
|
||||
className="submit-btn"
|
||||
onClick={handleSaveTagPreferences}
|
||||
disabled={saving || loadingTags}
|
||||
>
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<p className="tag-preferences-hint">
|
||||
Выберите теги, которые вас интересуют. Посты с этими тегами будут показываться в ленте "По интересам".
|
||||
</p>
|
||||
|
||||
{loadingTags ? (
|
||||
<div className="loading-state">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(TAG_CATEGORIES).map(([categoryKey, categoryName]) => {
|
||||
const categoryTags = allTags[categoryKey] || []
|
||||
if (categoryTags.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={categoryKey} className="tag-category-section">
|
||||
<h3 className="tag-category-title">{categoryName}</h3>
|
||||
<div className="tags-grid">
|
||||
{categoryTags.map(tag => {
|
||||
const isSelected = selectedTags.includes(tag.name)
|
||||
return (
|
||||
<div
|
||||
key={tag.name}
|
||||
className={`tag-preference-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => toggleTag(tag.name)}
|
||||
>
|
||||
<div className="tag-preference-content">
|
||||
<span className="tag-preference-name">{tag.name}</span>
|
||||
{tag.description && (
|
||||
<div
|
||||
className="tag-preference-description"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowTagInfo({
|
||||
name: tag.name,
|
||||
description: tag.description,
|
||||
category: TAG_CATEGORIES[tag.category] || tag.category
|
||||
})
|
||||
hapticFeedback('light')
|
||||
}}
|
||||
>
|
||||
<Info size={12} />
|
||||
<span>Нажмите для описания</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="tag-preference-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleTag(tag.name)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="selected-tags-summary">
|
||||
<strong>Выбрано: {selectedTags.length}</strong>
|
||||
<div className="selected-tags-list">
|
||||
{selectedTags.map(tag => (
|
||||
<span key={tag} className="selected-tag-chip">
|
||||
{tag}
|
||||
<button onClick={() => toggleTag(tag)} className="tag-chip-remove">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Модалка с описанием тега */}
|
||||
{showTagInfo && (
|
||||
<div className="modal-overlay" onClick={() => setShowTagInfo(null)}>
|
||||
<div className="modal-content tag-info-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Информация о теге</h2>
|
||||
<button className="close-btn" onClick={() => setShowTagInfo(null)}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="tag-info-content">
|
||||
<div className="tag-info-name">
|
||||
<Tag size={20} />
|
||||
<span>{showTagInfo.name}</span>
|
||||
</div>
|
||||
<div className="tag-info-category">
|
||||
Категория: <strong>{showTagInfo.category}</strong>
|
||||
</div>
|
||||
<div className="tag-info-description">
|
||||
<h3>Описание:</h3>
|
||||
<p>{showTagInfo.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue