2025-06-30 22:20:48 +00:00
import { Component , createSignal , For , Show } from 'solid-js'
2025-07-01 06:32:22 +00:00
import { MERGE_TOPICS_MUTATION } from '../graphql/mutations'
import styles from '../styles/Form.module.css'
2025-06-30 22:20:48 +00:00
import Button from '../ui/Button'
import Modal from '../ui/Modal'
// Типы для топиков
interface Topic {
id : number
title : string
slug : string
2025-07-03 09:15:10 +00:00
body? : string
2025-06-30 22:20:48 +00:00
community : number
2025-07-03 09:15:10 +00:00
parent_ids? : number [ ]
2025-06-30 22:20:48 +00:00
stat ? : {
shouts : number
followers : number
authors : number
comments : number
}
}
interface TopicMergeModalProps {
isOpen : boolean
onClose : ( ) = > void
topics : Topic [ ]
onSuccess : ( message : string ) = > void
onError : ( error : string ) = > void
}
interface MergeStats {
followers_moved : number
publications_moved : number
drafts_moved : number
source_topics_deleted : number
}
2025-07-03 09:15:10 +00:00
interface ValidationErrors {
target? : string
sources? : string
general? : string
}
2025-06-30 22:20:48 +00:00
const TopicMergeModal : Component < TopicMergeModalProps > = ( props ) = > {
const [ targetTopicId , setTargetTopicId ] = createSignal < number | null > ( null )
const [ sourceTopicIds , setSourceTopicIds ] = createSignal < number [ ] > ( [ ] )
const [ preserveTarget , setPreserveTarget ] = createSignal ( true )
const [ loading , setLoading ] = createSignal ( false )
2025-07-03 09:15:10 +00:00
const [ errors , setErrors ] = createSignal < ValidationErrors > ( { } )
const [ searchQuery , setSearchQuery ] = createSignal ( '' )
2025-06-30 22:20:48 +00:00
/ * *
* П о л у ч а е т т о к е н а в т о р и з а ц и и и з localStorage и л и cookie
* /
2025-07-03 09:15:10 +00:00
const getAuthToken = ( ) = > {
return localStorage . getItem ( 'auth_token' ) ||
document . cookie
. split ( '; ' )
. find ( ( row ) = > row . startsWith ( 'auth_token=' ) )
? . split ( '=' ) [ 1 ] || ''
}
/ * *
* В а л и д а ц и я д а н н ы х д л я с л и я н и я
* /
const validateMergeData = ( ) : ValidationErrors = > {
const newErrors : ValidationErrors = { }
const target = targetTopicId ( )
const sources = sourceTopicIds ( )
if ( ! target ) {
newErrors . target = 'Необходимо выбрать целевую тему'
}
if ( sources . length === 0 ) {
newErrors . sources = 'Необходимо выбрать хотя бы одну исходную тему'
} else if ( sources . length > 10 ) {
newErrors . sources = 'Нельзя объединять более 10 тем за раз'
}
// Проверяем что целевая тема не выбрана как исходная
if ( target && sources . includes ( target ) ) {
newErrors . general = 'Целевая тема не может быть в списке исходных тем'
}
// Проверяем что все темы принадлежат одному сообществу
if ( target && sources . length > 0 ) {
const targetTopic = props . topics . find ( ( t ) = > t . id === target )
const sourcesTopics = props . topics . filter ( ( t ) = > sources . includes ( t . id ) )
if ( targetTopic ) {
const targetCommunity = targetTopic . community
const invalidSources = sourcesTopics . filter ( topic = > topic . community !== targetCommunity )
if ( invalidSources . length > 0 ) {
newErrors . general = ` В с е темы должны принадлежать одному сообществу. Темы ${ invalidSources . map ( t = > ` " ${ t . title } " ` ) . join ( ', ' ) } принадлежат другому сообществу `
}
}
}
return newErrors
}
/ * *
* П о л у ч а е т н а з в а н и е с о о б щ е с т в а п о ID
* /
const getCommunityName = ( communityId : number ) : string = > {
// Заглушка - можно добавить запрос к API или кеш сообществ
return ` Сообщество ${ communityId } `
}
/ * *
* П о л у ч и т ь о т ф и л ь т р о в а н н ы й с п и с о к т о п и к о в д л я п о и с к а
* /
const getFilteredTopics = ( topicsList : Topic [ ] ) = > {
const query = searchQuery ( ) . toLowerCase ( ) . trim ( )
if ( ! query ) return topicsList
return topicsList . filter ( topic = >
topic . title ? . toLowerCase ( ) . includes ( query ) ||
topic . slug ? . toLowerCase ( ) . includes ( query )
2025-07-01 06:32:22 +00:00
)
2025-06-30 22:20:48 +00:00
}
2025-07-03 09:15:10 +00:00
/ * *
* О б р а б о т ч и к в ы б о р а ц е л е в о й т е м ы
* /
const handleTargetTopicChange = ( e : Event ) = > {
const target = e . target as HTMLSelectElement
const topicId = target . value ? Number . parseInt ( target . value ) : null
setTargetTopicId ( topicId )
// Убираем выбранную целевую тему из исходных тем
if ( topicId ) {
setSourceTopicIds ( prev = > prev . filter ( id = > id !== topicId ) )
}
// Перевалидация
const newErrors = validateMergeData ( )
setErrors ( newErrors )
}
2025-06-30 22:20:48 +00:00
/ * *
* О б р а б о т ч и к в ы б о р а / с н я т и я в ы б о р а и с х о д н о й т е м ы
* /
const handleSourceTopicToggle = ( topicId : number , checked : boolean ) = > {
if ( checked ) {
2025-07-01 06:32:22 +00:00
setSourceTopicIds ( ( prev ) = > [ . . . prev , topicId ] )
2025-06-30 22:20:48 +00:00
} else {
2025-07-01 06:32:22 +00:00
setSourceTopicIds ( ( prev ) = > prev . filter ( ( id ) = > id !== topicId ) )
2025-06-30 22:20:48 +00:00
}
2025-07-03 09:15:10 +00:00
// Перевалидация
const newErrors = validateMergeData ( )
setErrors ( newErrors )
2025-06-30 22:20:48 +00:00
}
/ * *
* П р о в е р я е т м о ж н о л и в ы п о л н и т ь с л и я н и е
* /
const canMerge = ( ) = > {
2025-07-03 09:15:10 +00:00
const validationErrors = validateMergeData ( )
return Object . keys ( validationErrors ) . length === 0
}
/ * *
* П о л у ч и т ь с т а т и с т и к у д л я п р е д в а р и т е л ь н о г о п р о с м о т р а
* /
const getMergePreview = ( ) = > {
2025-06-30 22:20:48 +00:00
const target = targetTopicId ( )
const sources = sourceTopicIds ( )
2025-07-03 09:15:10 +00:00
if ( ! target || sources . length === 0 ) return null
2025-06-30 22:20:48 +00:00
2025-07-03 09:15:10 +00:00
const targetTopic = props . topics . find ( t = > t . id === target )
const sourceTopics = props . topics . filter ( t = > sources . includes ( t . id ) )
2025-06-30 22:20:48 +00:00
2025-07-03 09:15:10 +00:00
const totalShouts = sourceTopics . reduce ( ( sum , topic ) = > sum + ( topic . stat ? . shouts || 0 ) , 0 )
const totalFollowers = sourceTopics . reduce ( ( sum , topic ) = > sum + ( topic . stat ? . followers || 0 ) , 0 )
const totalAuthors = sourceTopics . reduce ( ( sum , topic ) = > sum + ( topic . stat ? . authors || 0 ) , 0 )
2025-06-30 22:20:48 +00:00
2025-07-03 09:15:10 +00:00
return {
targetTopic ,
sourceTopics ,
totalShouts ,
totalFollowers ,
totalAuthors ,
sourcesCount : sources.length
}
2025-06-30 22:20:48 +00:00
}
/ * *
* В ы п о л н я е т с л и я н и е т о п и к о в
* /
const handleMerge = async ( ) = > {
if ( ! canMerge ( ) ) {
2025-07-03 09:15:10 +00:00
const validationErrors = validateMergeData ( )
setErrors ( validationErrors )
2025-06-30 22:20:48 +00:00
return
}
setLoading ( true )
2025-07-03 09:15:10 +00:00
setErrors ( { } )
2025-06-30 22:20:48 +00:00
try {
2025-07-03 09:15:10 +00:00
const authToken = getAuthToken ( )
2025-06-30 22:20:48 +00:00
const response = await fetch ( '/graphql' , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
Authorization : authToken ? ` Bearer ${ authToken } ` : ''
} ,
body : JSON.stringify ( {
query : MERGE_TOPICS_MUTATION ,
variables : {
merge_input : {
target_topic_id : targetTopicId ( ) ,
source_topic_ids : sourceTopicIds ( ) ,
preserve_target_properties : preserveTarget ( )
}
}
} )
} )
const result = await response . json ( )
if ( result . errors ) {
throw new Error ( result . errors [ 0 ] . message )
}
const mergeResult = result . data . merge_topics
if ( mergeResult . error ) {
throw new Error ( mergeResult . error )
}
const stats = mergeResult . stats as MergeStats
2025-07-01 06:32:22 +00:00
const statsText = stats
? ` (перенесено ${ stats . followers_moved } подписчиков, ${ stats . publications_moved } публикаций, ${ stats . drafts_moved } черновиков, удалено ${ stats . source_topics_deleted } тем) `
: ''
2025-06-30 22:20:48 +00:00
props . onSuccess ( mergeResult . message + statsText )
handleClose ( )
} catch ( error ) {
const errorMessage = ( error as Error ) . message
2025-07-03 09:15:10 +00:00
setErrors ( { general : errorMessage } )
2025-06-30 22:20:48 +00:00
props . onError ( ` Ошибка слияния тем: ${ errorMessage } ` )
} finally {
setLoading ( false )
}
}
/ * *
* З а к р ы в а е т м о д а л к у и с б р а с ы в а е т с о с т о я н и е
* /
const handleClose = ( ) = > {
setTargetTopicId ( null )
setSourceTopicIds ( [ ] )
setPreserveTarget ( true )
2025-07-03 09:15:10 +00:00
setErrors ( { } )
2025-06-30 22:20:48 +00:00
setLoading ( false )
2025-07-03 09:15:10 +00:00
setSearchQuery ( '' )
2025-06-30 22:20:48 +00:00
props . onClose ( )
}
/ * *
* П о л у ч а е т о т ф и л ь т р о в а н н ы й с п и с о к т о п и к о в ( и с к л ю ч а я в ы б р а н н ы е к а к и с х о д н ы е )
* /
const getAvailableTargetTopics = ( ) = > {
const sources = sourceTopicIds ( )
2025-07-01 06:32:22 +00:00
return props . topics . filter ( ( topic ) = > ! sources . includes ( topic . id ) )
2025-06-30 22:20:48 +00:00
}
/ * *
* П о л у ч а е т о т ф и л ь т р о в а н н ы й с п и с о к т о п и к о в ( и с к л ю ч а я ц е л е в у ю т е м у )
* /
const getAvailableSourceTopics = ( ) = > {
const target = targetTopicId ( )
2025-07-01 06:32:22 +00:00
return props . topics . filter ( ( topic ) = > topic . id !== target )
2025-06-30 22:20:48 +00:00
}
2025-07-03 09:15:10 +00:00
const preview = getMergePreview ( )
2025-06-30 22:20:48 +00:00
return (
2025-07-01 06:32:22 +00:00
< Modal isOpen = { props . isOpen } onClose = { handleClose } title = "Слияние тем" size = "large" >
2025-06-30 22:20:48 +00:00
< div class = { styles . form } >
2025-07-03 09:15:10 +00:00
{ /* Общие ошибки */ }
< Show when = { errors ( ) . general } >
< div class = { styles . formError } >
{ errors ( ) . general }
< / div >
< / Show >
{ /* Выбор целевой темы */ }
2025-06-30 22:20:48 +00:00
< div class = { styles . section } >
2025-07-03 09:15:10 +00:00
< h3 class = { styles . sectionTitle } > 🎯 Ц е л е в а я т е м а < / h3 >
< p class = { styles . sectionDescription } >
В ы б е р и т е т е м у , в к о т о р у ю б у д у т с л и т ы о с т а л ь н ы е т е м ы . В с е п о д п и с ч и к и и п у б л и к а ц и и
б у д у т п е р е н е с е н ы в э т у т е м у , а и с х о д н ы е т е м ы б у д у т у д а л е н ы .
2025-06-30 22:20:48 +00:00
< / p >
2025-07-03 09:15:10 +00:00
< div class = { styles . field } >
< label class = { styles . label } >
Ц е л е в а я т е м а :
< select
class = { ` ${ styles . select } ${ errors ( ) . target ? styles . inputError : '' } ` }
value = { targetTopicId ( ) || '' }
onChange = { handleTargetTopicChange }
required
>
< option value = "" > В ы б е р и т е ц е л е в у ю т е м у . . . < / option >
< For each = { getFilteredTopics ( getAvailableTargetTopics ( ) ) } >
{ ( topic ) = > (
< option value = { topic . id } >
{ topic . title } ( { topic . slug } )
{ topic . stat ? ` • ${ topic . stat . shouts } публикаций ` : '' }
< / option >
) }
< / For >
< / select >
< Show when = { errors ( ) . target } >
< div class = { styles . errorMessage } > { errors ( ) . target } < / div >
< / Show >
< / label >
< / div >
2025-06-30 22:20:48 +00:00
< / div >
2025-07-03 09:15:10 +00:00
{ /* Поиск и выбор исходных тем */ }
2025-06-30 22:20:48 +00:00
< div class = { styles . section } >
2025-07-03 09:15:10 +00:00
< h3 class = { styles . sectionTitle } > 📥 И с х о д н ы е т е м ы < / h3 >
< p class = { styles . sectionDescription } >
В ы б е р и т е т е м ы , к о т о р ы е б у д у т с л и т ы в ц е л е в у ю т е м у . В с е и х д а н н ы е б у д у т п е р е н е с е н ы ,
а с а м и т е м ы б у д у т у д а л е н ы .
2025-06-30 22:20:48 +00:00
< / p >
2025-07-03 09:15:10 +00:00
< div class = { styles . field } >
< label class = { styles . label } >
П о и с к т е м :
< input
type = "text"
class = { styles . input }
value = { searchQuery ( ) }
onInput = { ( e ) = > setSearchQuery ( e . currentTarget . value ) }
placeholder = "Введите название или slug для поиска..."
/ >
< / label >
< / div >
< Show when = { errors ( ) . sources } >
< div class = { styles . errorMessage } > { errors ( ) . sources } < / div >
< / Show >
< div class = { styles . availableParents } >
< div class = { styles . sectionHeader } >
< strong > Д о с т у п н ы е т е м ы д л я с л и я н и я : < / strong >
< span class = { styles . hint } >
В ы б р а н о : { sourceTopicIds ( ) . length }
< / span >
< / div >
< div class = { styles . parentsGrid } >
< For each = { getFilteredTopics ( getAvailableSourceTopics ( ) ) } >
2025-06-30 22:20:48 +00:00
{ ( topic ) = > {
const isChecked = ( ) = > sourceTopicIds ( ) . includes ( topic . id )
2025-07-03 09:15:10 +00:00
const isDisabled = ( ) = > targetTopicId ( ) && topic . community !== props . topics . find ( t = > t . id === targetTopicId ( ) ) ? . community
2025-06-30 22:20:48 +00:00
return (
2025-07-03 09:15:10 +00:00
< label
class = { ` ${ styles . parentCheckbox } ${ isDisabled ( ) ? styles . disabled : '' } ` }
title = { isDisabled ( ) ? 'Тема принадлежит другому сообществу' : '' }
>
2025-06-30 22:20:48 +00:00
< input
type = "checkbox"
checked = { isChecked ( ) }
2025-07-03 09:15:10 +00:00
disabled = { isDisabled ( ) || false }
onChange = { ( e ) = > handleSourceTopicToggle ( topic . id , e . currentTarget . checked ) }
2025-06-30 22:20:48 +00:00
/ >
2025-07-03 09:15:10 +00:00
< div class = { styles . parentLabel } >
< div class = { styles . parentTitle } >
{ topic . title }
< / div >
< div class = { styles . parentSlug } > { topic . slug } < / div >
< div class = { styles . parentStats } >
{ getCommunityName ( topic . community ) }
2025-06-30 22:20:48 +00:00
{ topic . stat && (
2025-07-03 09:15:10 +00:00
< >
< span > • { topic . stat . shouts } п у б л и к а ц и й < / span >
< span > • { topic . stat . followers } п о д п и с ч и к о в < / span >
< / >
2025-06-30 22:20:48 +00:00
) }
< / div >
< / div >
< / label >
)
} }
< / For >
< / div >
2025-07-03 09:15:10 +00:00
< Show when = { getFilteredTopics ( getAvailableSourceTopics ( ) ) . length === 0 } >
< div class = { styles . noParents } >
< Show when = { searchQuery ( ) } >
Н е н а й д е н о т е м п о з а п р о с у "{searchQuery()}"
< / Show >
< Show when = { ! searchQuery ( ) } >
Н е т д о с т у п н ы х т е м д л я с л и я н и я
< / Show >
2025-06-30 22:20:48 +00:00
< / div >
2025-07-03 09:15:10 +00:00
< / Show >
< / div >
2025-06-30 22:20:48 +00:00
< / div >
2025-07-03 09:15:10 +00:00
{ /* Предварительный просмотр слияния */ }
< Show when = { preview } >
< div class = { styles . section } >
< h3 class = { styles . sectionTitle } > 📊 П р е д в а р и т е л ь н ы й п р о с м о т р < / h3 >
< div class = { styles . hierarchyPath } >
< div > < strong > Ц е л е в а я т е м а : < / strong > { preview ! . targetTopic ! . title } < / div >
< div class = { styles . pathDisplay } >
< span > С л и я н и е { preview ! . sourcesCount } т е м : < / span >
< For each = { preview ! . sourceTopics } >
{ ( topic ) = > (
< span class = { styles . pathItem } > { topic . title } < / span >
) }
< / For >
< / div >
2025-06-30 22:20:48 +00:00
2025-07-03 09:15:10 +00:00
< div style = "margin-top: 1rem;" >
< strong > О ж и д а е м ы е р е з у л ь т а т ы : < / strong >
< ul style = "margin: 0.5rem 0; padding-left: 1.5rem;" >
< li > Б у д е т п е р е н е с е н о ~ { preview ! . totalShouts } п у б л и к а ц и й < / li >
< li > Б у д е т п е р е н е с е н о ~ { preview ! . totalFollowers } п о д п и с ч и к о в < / li >
< li > Б у д е т о б ъ е д и н е н о ~ { preview ! . totalAuthors } а в т о р о в < / li >
< li > Б у д е т у д а л е н о { preview ! . sourcesCount } и с х о д н ы х т е м < / li >
2025-06-30 22:20:48 +00:00
< / ul >
2025-07-03 09:15:10 +00:00
< / div >
< / div >
2025-06-30 22:20:48 +00:00
< / div >
< / Show >
2025-07-03 09:15:10 +00:00
{ /* Настройки слияния */ }
< div class = { styles . section } >
< h3 class = { styles . sectionTitle } > ⚙ ️ Н а с т р о й к и с л и я н и я < / h3 >
< div class = { styles . field } >
< label class = { styles . parentCheckbox } >
< input
type = "checkbox"
checked = { preserveTarget ( ) }
onChange = { ( e ) = > setPreserveTarget ( e . currentTarget . checked ) }
/ >
< div class = { styles . parentLabel } >
< div class = { styles . parentTitle } >
С о х р а н и т ь с в о й с т в а ц е л е в о й т е м ы
< / div >
< div class = { styles . parentStats } >
Е с л и в к л ю ч е н о , о п и с а н и е и д р у г и е с в о й с т в а ц е л е в о й т е м ы н е б у д у т и з м е н е н ы .
Е с л и в ы к л ю ч е н о , с в о й с т в а м о г у т б ы т ь о б ъ е д и н е н ы с и с х о д н ы м и т е м а м и .
< / div >
< / div >
< / label >
< / div >
< / div >
{ /* Кнопки */ }
< div class = { styles . actions } >
< Button
type = "button"
variant = "secondary"
onClick = { handleClose }
disabled = { loading ( ) }
>
2025-06-30 22:20:48 +00:00
О т м е н а
< / Button >
2025-07-03 09:15:10 +00:00
< Button
type = "button"
variant = "primary"
onClick = { handleMerge }
disabled = { ! canMerge ( ) || loading ( ) }
loading = { loading ( ) }
>
{ loading ( ) ? 'Выполняется слияние...' : ` Слить ${ sourceTopicIds ( ) . length } тем ` }
2025-06-30 22:20:48 +00:00
< / Button >
< / div >
< / div >
< / Modal >
)
}
export default TopicMergeModal