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} -