Update files
This commit is contained in:
parent
0305e87ce5
commit
63ef5c3a95
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: 'Реальный мир' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<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}
|
||||
{tagSuggestions.length > 0 ? (
|
||||
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>
|
||||
))
|
||||
) : (
|
||||
tagInput.trim().length > 0 && (
|
||||
<div className="tag-suggestion-item tag-suggestion-empty">
|
||||
<div className="tag-suggestion-name">Тег "{tagInput.trim()}" не найден</div>
|
||||
<div className="tag-suggestion-description">
|
||||
Нажмите Enter или пробел, чтобы добавить этот тег
|
||||
</div>
|
||||
)}
|
||||
{tag.description && (
|
||||
<div className="tag-suggestion-description">{tag.description}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
})()
|
||||
)}
|
||||
|
||||
{/* Поле ввода для ручного добавления тегов */}
|
||||
<div className="tag-input-section">
|
||||
<div className="tag-input-wrapper" ref={tagInputRef}>
|
||||
<input
|
||||
type="text"
|
||||
className="tag-input"
|
||||
placeholder="Введите тег и нажмите Enter или пробел"
|
||||
value={tagInput}
|
||||
onChange={handleTagInputChange}
|
||||
onKeyDown={handleTagInputKeyDown}
|
||||
onFocus={() => {
|
||||
if (tagInput.trim().length > 0) {
|
||||
setShowTagSuggestions(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Подсказки тегов */}
|
||||
{showTagSuggestions && (
|
||||
<div className="tag-suggestions" ref={tagSuggestionsRef}>
|
||||
{tagSuggestions.length > 0 ? (
|
||||
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>
|
||||
))
|
||||
) : (
|
||||
tagInput.trim().length > 0 && (
|
||||
<div className="tag-suggestion-item tag-suggestion-empty">
|
||||
<div className="tag-suggestion-name">Тег "{tagInput.trim()}" не найден</div>
|
||||
<div className="tag-suggestion-description">
|
||||
Нажмите Enter или пробел, чтобы добавить этот тег
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="tag-input-hint">
|
||||
Вы можете добавить любой тег вручную, даже если его нет в списке выше
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="selected-tags-summary">
|
||||
<strong>Выбрано: {selectedTags.length}</strong>
|
||||
|
|
@ -615,7 +788,7 @@ export default function Profile({ user, setUser }) {
|
|||
{selectedTags.map(tag => (
|
||||
<span key={tag} className="selected-tag-chip">
|
||||
{tag}
|
||||
<button onClick={() => toggleTag(tag)} className="tag-chip-remove">
|
||||
<button onClick={() => removeTag(tag)} className="tag-chip-remove">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
|
|
|
|||
Loading…
Reference in New Issue