Update files

This commit is contained in:
glpshchn 2025-12-08 16:45:09 +03:00
parent 0305e87ce5
commit 63ef5c3a95
6 changed files with 362 additions and 26 deletions

View File

@ -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
});

View File

@ -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: 'Реальный мир' },

View File

@ -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;

View File

@ -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,9 +327,10 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
/>
{/* Подсказки тегов */}
{showTagSuggestions && tagSuggestions.length > 0 && (
{showTagSuggestions && (
<div className="tag-suggestions" ref={tagSuggestionsRef}>
{tagSuggestions.map(tag => (
{tagSuggestions.length > 0 ? (
tagSuggestions.map(tag => (
<div
key={tag.name}
className="tag-suggestion-item"
@ -308,7 +346,17 @@ export default function CreatePostModal({ user, onClose, onPostCreated, initialI
<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>

View File

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

View File

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