From 63ef5c3a95f7172f142d3e314cbab9e8bf4fe06a Mon Sep 17 00:00:00 2001
From: glpshchn <464976@niuitmo.ru>
Date: Mon, 8 Dec 2025 16:45:09 +0300
Subject: [PATCH] Update files
---
backend/routes/tags.js | 10 ++
backend/scripts/initTags.js | 1 +
frontend/src/components/CreatePostModal.css | 10 ++
frontend/src/components/CreatePostModal.jsx | 88 +++++++---
frontend/src/pages/Profile.css | 94 ++++++++++
frontend/src/pages/Profile.jsx | 185 +++++++++++++++++++-
6 files changed, 362 insertions(+), 26 deletions(-)
diff --git a/backend/routes/tags.js b/backend/routes/tags.js
index 961abcd..f58a811 100644
--- a/backend/routes/tags.js
+++ b/backend/routes/tags.js
@@ -150,6 +150,16 @@ router.put('/preferences', authenticate, async (req, res) => {
return res.status(400).json({ error: 'Слишком много тегов' });
}
+ // Проверка формата каждого тега (только буквы, цифры, подчеркивания и дефисы)
+ for (const tag of normalizedTags) {
+ if (tag.length > 50) {
+ return res.status(400).json({ error: `Тег "${tag}" слишком длинный (максимум 50 символов)` });
+ }
+ if (!/^[a-zA-Z0-9_\-]+$/.test(tag)) {
+ return res.status(400).json({ error: `Тег "${tag}" содержит недопустимые символы. Разрешены только буквы, цифры, подчеркивания и дефисы` });
+ }
+ }
+
await User.findByIdAndUpdate(req.user._id, {
preferredTags: normalizedTags
});
diff --git a/backend/scripts/initTags.js b/backend/scripts/initTags.js
index eff0ca3..cf77674 100644
--- a/backend/scripts/initTags.js
+++ b/backend/scripts/initTags.js
@@ -11,6 +11,7 @@ const INITIAL_TAGS = [
// Тематика
{ name: 'furry', category: 'theme', description: 'Furry контент' },
{ name: 'anime', category: 'theme', description: 'Аниме контент' },
+ { name: 'other', category: 'theme', description: 'Другое' },
{ name: 'sci-fi', category: 'theme', description: 'Научная фантастика' },
{ name: 'fantasy', category: 'theme', description: 'Фэнтези' },
{ name: 'irl', category: 'theme', description: 'Реальный мир' },
diff --git a/frontend/src/components/CreatePostModal.css b/frontend/src/components/CreatePostModal.css
index 1258981..5bad7cb 100644
--- a/frontend/src/components/CreatePostModal.css
+++ b/frontend/src/components/CreatePostModal.css
@@ -257,6 +257,16 @@
background: var(--bg-primary);
}
+.tag-suggestion-empty {
+ opacity: 0.7;
+ cursor: default;
+}
+
+.tag-suggestion-empty:hover,
+.tag-suggestion-empty:active {
+ background: transparent;
+}
+
.tag-suggestion-name {
font-size: 15px;
font-weight: 600;
diff --git a/frontend/src/components/CreatePostModal.jsx b/frontend/src/components/CreatePostModal.jsx
index 1b0a936..d6d34f3 100644
--- a/frontend/src/components/CreatePostModal.jsx
+++ b/frontend/src/components/CreatePostModal.jsx
@@ -100,6 +100,24 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
setExternalImages(prev => prev.filter((_, i) => i !== index))
}
+ const handleTagInputChange = (e) => {
+ const value = e.target.value
+ // На мобильных устройствах пробел может быть добавлен до keyDown
+ // Обрабатываем пробел в onChange
+ if (value.endsWith(' ')) {
+ const trimmed = value.trim().toLowerCase()
+ if (trimmed && !selectedTags.includes(trimmed)) {
+ addTag(trimmed)
+ return // Не обновляем tagInput, addTag уже очистит его
+ } else {
+ // Если тег уже добавлен или пустой, просто очищаем пробел
+ setTagInput(value.trim())
+ return
+ }
+ }
+ setTagInput(value)
+ }
+
const handleTagInputKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
@@ -116,11 +134,30 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
}
const addTag = (tag) => {
- if (tag && !selectedTags.includes(tag) && selectedTags.length < 20) {
- setSelectedTags([...selectedTags, tag])
+ // Нормализуем тег: убираем пробелы, приводим к нижнему регистру
+ const normalizedTag = tag.trim().toLowerCase()
+
+ // Проверяем формат тега (только буквы, цифры, подчеркивания и дефисы)
+ if (!normalizedTag || normalizedTag.length === 0) {
+ return
+ }
+
+ // Проверяем формат тега на соответствие валидации бэкенда
+ if (!/^[a-zA-Z0-9_\-]+$/.test(normalizedTag)) {
+ // Если тег содержит недопустимые символы, показываем предупреждение
+ alert('Тег может содержать только буквы, цифры, подчеркивания и дефисы')
+ return
+ }
+
+ if (!selectedTags.includes(normalizedTag) && selectedTags.length < 20) {
+ setSelectedTags([...selectedTags, normalizedTag])
setTagInput('')
setShowTagSuggestions(false)
hapticFeedback('light')
+ } else if (selectedTags.includes(normalizedTag)) {
+ // Если тег уже добавлен, просто очищаем поле ввода
+ setTagInput('')
+ setShowTagSuggestions(false)
}
}
@@ -280,7 +317,7 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
className="tag-input"
placeholder="Введите тег и нажмите Enter или пробел"
value={tagInput}
- onChange={e => setTagInput(e.target.value)}
+ onChange={handleTagInputChange}
onKeyDown={handleTagInputKeyDown}
onFocus={() => {
if (tagInput.trim().length > 0) {
@@ -290,25 +327,36 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
/>
{/* Подсказки тегов */}
- {showTagSuggestions && tagSuggestions.length > 0 && (
+ {showTagSuggestions && (
- {tagSuggestions.map(tag => (
-
handleTagSuggestionClick(tag)}
- >
-
{tag.name}
- {tag.category && (
-
- {TAG_CATEGORIES[tag.category] || tag.category}
+ {tagSuggestions.length > 0 ? (
+ tagSuggestions.map(tag => (
+
handleTagSuggestionClick(tag)}
+ >
+
{tag.name}
+ {tag.category && (
+
+ {TAG_CATEGORIES[tag.category] || tag.category}
+
+ )}
+ {tag.description && (
+
{tag.description}
+ )}
+
+ ))
+ ) : (
+ tagInput.trim().length > 0 && (
+
+
Тег "{tagInput.trim()}" не найден
+
+ Нажмите Enter или пробел, чтобы добавить этот тег
- )}
- {tag.description && (
-
{tag.description}
- )}
-
- ))}
+
+ )
+ )}
)}
diff --git a/frontend/src/pages/Profile.css b/frontend/src/pages/Profile.css
index 9fcb041..e920362 100644
--- a/frontend/src/pages/Profile.css
+++ b/frontend/src/pages/Profile.css
@@ -742,3 +742,97 @@
color: var(--text-secondary);
}
+/* Поле ввода тегов в профиле */
+.tag-input-section {
+ margin-top: 24px;
+ padding-top: 24px;
+ border-top: 1px solid var(--divider-color);
+}
+
+.tag-input-wrapper {
+ position: relative;
+ margin-bottom: 8px;
+}
+
+.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-input:focus {
+ outline: none;
+ border-color: var(--button-accent);
+}
+
+.tag-input-hint {
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin-top: 8px;
+ line-height: 1.4;
+}
+
+.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-empty {
+ opacity: 0.7;
+ cursor: default;
+}
+
+.tag-suggestion-empty:hover,
+.tag-suggestion-empty:active {
+ background: transparent;
+}
+
+.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;
+}
+
diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx
index a9f6624..ea5fab1 100644
--- a/frontend/src/pages/Profile.jsx
+++ b/frontend/src/pages/Profile.jsx
@@ -1,7 +1,7 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, useRef } from 'react'
import { Settings, Heart, Edit2, Shield, UserPlus, Copy, Info, Tag, X } from 'lucide-react'
import { createPortal } from 'react-dom'
-import { updateProfile, getTags, getPreferredTags, updatePreferredTags } from '../utils/api'
+import { updateProfile, getTags, getPreferredTags, updatePreferredTags, autocompleteTags } from '../utils/api'
import { hapticFeedback } from '../utils/telegram'
import ThemeToggle from '../components/ThemeToggle'
import FollowListModal from '../components/FollowListModal'
@@ -57,6 +57,11 @@ export default function Profile({ user, setUser }) {
const [selectedTags, setSelectedTags] = useState([])
const [loadingTags, setLoadingTags] = useState(false)
const [showTagInfo, setShowTagInfo] = useState(null) // { name, description, category }
+ const [tagInput, setTagInput] = useState('')
+ const [tagSuggestions, setTagSuggestions] = useState([])
+ const [showTagSuggestions, setShowTagSuggestions] = useState(false)
+ const tagInputRef = useRef(null)
+ const tagSuggestionsRef = useRef(null)
const handleSaveBio = async () => {
try {
@@ -116,9 +121,53 @@ export default function Profile({ user, setUser }) {
} else {
loadPreferredTags()
}
+ } else {
+ // Сбросить поле ввода при закрытии модалки
+ setTagInput('')
+ setTagSuggestions([])
+ setShowTagSuggestions(false)
}
}, [showTagPreferences])
+ // Автодополнение тегов
+ 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 loadTags = async () => {
try {
setLoadingTags(true)
@@ -145,13 +194,81 @@ export default function Profile({ user, setUser }) {
const toggleTag = (tagName) => {
hapticFeedback('light')
- if (selectedTags.includes(tagName)) {
- setSelectedTags(selectedTags.filter(t => t !== tagName))
+ const normalizedTag = tagName.toLowerCase().trim()
+ if (selectedTags.includes(normalizedTag)) {
+ setSelectedTags(selectedTags.filter(t => t !== normalizedTag))
} else {
- setSelectedTags([...selectedTags, tagName])
+ setSelectedTags([...selectedTags, normalizedTag])
}
}
+ const handleTagInputChange = (e) => {
+ const value = e.target.value
+ // На мобильных устройствах пробел может быть добавлен до keyDown
+ // Обрабатываем пробел в onChange
+ if (value.endsWith(' ')) {
+ const trimmed = value.trim().toLowerCase()
+ if (trimmed && !selectedTags.includes(trimmed)) {
+ addTag(trimmed)
+ return // Не обновляем tagInput, addTag уже очистит его
+ } else {
+ // Если тег уже добавлен или пустой, просто очищаем пробел
+ setTagInput(value.trim())
+ return
+ }
+ }
+ setTagInput(value)
+ }
+
+ 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])
+ }
+ }
+
+ const addTag = (tag) => {
+ // Нормализуем тег: убираем пробелы, приводим к нижнему регистру
+ const normalizedTag = tag.trim().toLowerCase()
+
+ // Проверяем формат тега (только буквы, цифры, подчеркивания и дефисы)
+ if (!normalizedTag || normalizedTag.length === 0) {
+ return
+ }
+
+ // Проверяем формат тега на соответствие валидации бэкенда
+ if (!/^[a-zA-Z0-9_\-]+$/.test(normalizedTag)) {
+ // Если тег содержит недопустимые символы, показываем предупреждение
+ alert('Тег может содержать только буквы, цифры, подчеркивания и дефисы')
+ return
+ }
+
+ if (!selectedTags.includes(normalizedTag) && selectedTags.length < 50) {
+ setSelectedTags([...selectedTags, normalizedTag])
+ setTagInput('')
+ setShowTagSuggestions(false)
+ hapticFeedback('light')
+ } else if (selectedTags.includes(normalizedTag)) {
+ // Если тег уже добавлен, просто очищаем поле ввода
+ setTagInput('')
+ setShowTagSuggestions(false)
+ }
+ }
+
+ const removeTag = (tag) => {
+ setSelectedTags(selectedTags.filter(t => t !== tag))
+ hapticFeedback('light')
+ }
+
+ const handleTagSuggestionClick = (tag) => {
+ addTag(tag.name)
+ }
+
const handleSaveTagPreferences = async () => {
try {
setSaving(true)
@@ -608,6 +725,62 @@ export default function Profile({ user, setUser }) {
})()
)}
+ {/* Поле ввода для ручного добавления тегов */}
+
+
+
{
+ 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}
+ )}
+
+ ))
+ ) : (
+ tagInput.trim().length > 0 && (
+
+
Тег "{tagInput.trim()}" не найден
+
+ Нажмите Enter или пробел, чтобы добавить этот тег
+
+
+ )
+ )}
+
+ )}
+
+
+ Вы можете добавить любой тег вручную, даже если его нет в списке выше
+
+
+
{selectedTags.length > 0 && (
Выбрано: {selectedTags.length}
@@ -615,7 +788,7 @@ export default function Profile({ user, setUser }) {
{selectedTags.map(tag => (
{tag}
-