This commit is contained in:
parent
5d766b7601
commit
3826797317
|
@ -117,7 +117,7 @@ python -m granian main:app --interface asgi
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
**Biome 2.0.6** for linting and formatting • **120 char** lines • **Type hints** required • **Docstrings** for public methods
|
**Biome 2.1.2** for linting and formatting • **120 char** lines • **Type hints** required • **Docstrings** for public methods
|
||||||
|
|
||||||
### 🔍 GraphQL Development
|
### 🔍 GraphQL Development
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.1.2/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
|
@ -27,7 +27,7 @@
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 2,
|
"indentWidth": 2,
|
||||||
"lineWidth": 108,
|
"lineWidth": 108,
|
||||||
"includes": ["**", "!src/graphql/schema", "!gen", "!panel/graphql/generated"]
|
"includes": ["**", "!panel/graphql/generated"]
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|
2106
package-lock.json
generated
2106
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
@ -4,7 +4,6 @@ import {
|
||||||
ADMIN_GET_ROLES_QUERY,
|
ADMIN_GET_ROLES_QUERY,
|
||||||
ADMIN_GET_TOPICS_QUERY,
|
ADMIN_GET_TOPICS_QUERY,
|
||||||
GET_COMMUNITIES_QUERY,
|
GET_COMMUNITIES_QUERY,
|
||||||
GET_TOPICS_BY_COMMUNITY_QUERY,
|
|
||||||
GET_TOPICS_QUERY
|
GET_TOPICS_QUERY
|
||||||
} from '../graphql/queries'
|
} from '../graphql/queries'
|
||||||
|
|
||||||
|
|
|
@ -167,10 +167,7 @@ const CollectionEditModal: Component<CollectionEditModalProps> = (props) => {
|
||||||
Описание
|
Описание
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<HTMLEditor
|
<HTMLEditor value={formData().desc} onInput={(value) => updateField('desc', value)} />
|
||||||
value={formData().desc}
|
|
||||||
onInput={(value) => updateField('desc', value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={formStyles.fieldGroup}>
|
<div class={formStyles.fieldGroup}>
|
||||||
|
|
|
@ -9,9 +9,9 @@ import {
|
||||||
import formStyles from '../styles/Form.module.css'
|
import formStyles from '../styles/Form.module.css'
|
||||||
import styles from '../styles/Modal.module.css'
|
import styles from '../styles/Modal.module.css'
|
||||||
import Button from '../ui/Button'
|
import Button from '../ui/Button'
|
||||||
|
import HTMLEditor from '../ui/HTMLEditor'
|
||||||
import Modal from '../ui/Modal'
|
import Modal from '../ui/Modal'
|
||||||
import RoleManager from '../ui/RoleManager'
|
import RoleManager from '../ui/RoleManager'
|
||||||
import HTMLEditor from '../ui/HTMLEditor'
|
|
||||||
|
|
||||||
interface Community {
|
interface Community {
|
||||||
id: number
|
id: number
|
||||||
|
@ -285,10 +285,7 @@ const CommunityEditModal = (props: CommunityEditModalProps) => {
|
||||||
Описание
|
Описание
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<HTMLEditor
|
<HTMLEditor value={formData().desc || ''} onInput={(value) => updateField('desc', value)} />
|
||||||
value={formData().desc || ''}
|
|
||||||
onInput={(value) => updateField('desc', value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={formStyles.fieldGroup}>
|
<div class={formStyles.fieldGroup}>
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { Component, createSignal, createEffect } from 'solid-js'
|
import { Component, createEffect, createSignal } from 'solid-js'
|
||||||
import styles from '../styles/Modal.module.css'
|
import styles from '../styles/Modal.module.css'
|
||||||
import Button from '../ui/Button'
|
import Button from '../ui/Button'
|
||||||
import Modal from '../ui/Modal'
|
|
||||||
import HTMLEditor from '../ui/HTMLEditor'
|
import HTMLEditor from '../ui/HTMLEditor'
|
||||||
|
import Modal from '../ui/Modal'
|
||||||
|
|
||||||
interface ReactionEditModalProps {
|
interface ReactionEditModalProps {
|
||||||
reaction: {
|
reaction: {
|
||||||
|
@ -64,7 +64,7 @@ const ReactionEditModal: Component<ReactionEditModalProps> = (props) => {
|
||||||
|
|
||||||
const updateData: { id: number; body?: string; deleted_at?: number } = {
|
const updateData: { id: number; body?: string; deleted_at?: number } = {
|
||||||
id: props.reaction.id,
|
id: props.reaction.id,
|
||||||
body: body(),
|
body: body()
|
||||||
}
|
}
|
||||||
|
|
||||||
await props.onSave(updateData)
|
await props.onSave(updateData)
|
||||||
|
@ -116,20 +116,11 @@ const ReactionEditModal: Component<ReactionEditModalProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={props.isOpen} onClose={props.onClose} title="Редактирование реакции">
|
<Modal isOpen={props.isOpen} onClose={props.onClose} title="Редактирование реакции">
|
||||||
<div class={styles['modal-content']}>
|
<div class={styles['modal-content']}>
|
||||||
{error() && (
|
{error() && <div class={styles['error-message']}>{error()}</div>}
|
||||||
<div class={styles['error-message']}>
|
|
||||||
{error()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div class={styles['form-group']}>
|
<div class={styles['form-group']}>
|
||||||
<label class={styles['form-label']}>ID реакции:</label>
|
<label class={styles['form-label']}>ID реакции:</label>
|
||||||
<input
|
<input type="text" value={props.reaction.id} disabled class={styles['form-input']} />
|
||||||
type="text"
|
|
||||||
value={props.reaction.id}
|
|
||||||
disabled
|
|
||||||
class={styles['form-input']}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={styles['form-group']}>
|
<div class={styles['form-group']}>
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { createEffect, createSignal, For, Show, on } from 'solid-js'
|
import { createEffect, createSignal, on, Show } from 'solid-js'
|
||||||
import { Topic, useData } from '../context/data'
|
import { Topic, useData } from '../context/data'
|
||||||
import { query } from '../graphql'
|
import { query } from '../graphql'
|
||||||
import { ADMIN_UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
|
import { ADMIN_UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
|
||||||
import styles from '../styles/Form.module.css'
|
import styles from '../styles/Form.module.css'
|
||||||
|
import HTMLEditor from '../ui/HTMLEditor'
|
||||||
import Modal from '../ui/Modal'
|
import Modal from '../ui/Modal'
|
||||||
import TopicPillsCloud, { type TopicPill } from '../ui/TopicPillsCloud'
|
import TopicPillsCloud, { type TopicPill } from '../ui/TopicPillsCloud'
|
||||||
import HTMLEditor from '../ui/HTMLEditor'
|
|
||||||
|
|
||||||
interface TopicEditModalProps {
|
interface TopicEditModalProps {
|
||||||
topic: Topic
|
topic: Topic
|
||||||
|
@ -16,7 +16,7 @@ interface TopicEditModalProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopicEditModal(props: TopicEditModalProps) {
|
export default function TopicEditModal(props: TopicEditModalProps) {
|
||||||
const { communities, topics, getCommunityName, selectedCommunity } = useData()
|
const { topics, getCommunityName, selectedCommunity } = useData()
|
||||||
|
|
||||||
// Состояние формы
|
// Состояние формы
|
||||||
const [formData, setFormData] = createSignal({
|
const [formData, setFormData] = createSignal({
|
||||||
|
@ -50,11 +50,16 @@ export default function TopicEditModal(props: TopicEditModalProps) {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Обновление доступных родителей при изменении сообщества в форме
|
// Обновление доступных родителей при изменении сообщества в форме
|
||||||
createEffect(on(() => formData().community, (communityId) => {
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => formData().community,
|
||||||
|
(communityId) => {
|
||||||
if (communityId > 0) {
|
if (communityId > 0) {
|
||||||
updateAvailableParents(communityId)
|
updateAvailableParents(communityId)
|
||||||
}
|
}
|
||||||
}))
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
// Обновление доступных родителей при смене сообщества
|
// Обновление доступных родителей при смене сообщества
|
||||||
const updateAvailableParents = (communityId: number, excludeTopicId?: number) => {
|
const updateAvailableParents = (communityId: number, excludeTopicId?: number) => {
|
||||||
|
@ -73,12 +78,12 @@ export default function TopicEditModal(props: TopicEditModalProps) {
|
||||||
* Преобразование Topic в TopicPill для компонента TopicPillsCloud
|
* Преобразование Topic в TopicPill для компонента TopicPillsCloud
|
||||||
*/
|
*/
|
||||||
const convertTopicsToTopicPills = (topics: Topic[]): TopicPill[] => {
|
const convertTopicsToTopicPills = (topics: Topic[]): TopicPill[] => {
|
||||||
return topics.map(topic => ({
|
return topics.map((topic) => ({
|
||||||
id: topic.id.toString(),
|
id: topic.id.toString(),
|
||||||
title: topic.title || '',
|
title: topic.title || '',
|
||||||
slug: topic.slug || '',
|
slug: topic.slug || '',
|
||||||
community: getCommunityName(topic.community),
|
community: getCommunityName(topic.community),
|
||||||
parent_ids: (topic.parent_ids || []).map(id => id.toString()),
|
parent_ids: (topic.parent_ids || []).map((id) => id.toString())
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,7 +91,7 @@ export default function TopicEditModal(props: TopicEditModalProps) {
|
||||||
* Обработка изменения выбора родительских топиков из таблеточек
|
* Обработка изменения выбора родительских топиков из таблеточек
|
||||||
*/
|
*/
|
||||||
const handleParentSelectionChange = (selectedIds: string[]) => {
|
const handleParentSelectionChange = (selectedIds: string[]) => {
|
||||||
const parentIds = selectedIds.map(id => Number.parseInt(id))
|
const parentIds = selectedIds.map((id) => Number.parseInt(id))
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
parent_ids: parentIds
|
parent_ids: parentIds
|
||||||
|
@ -219,10 +224,7 @@ export default function TopicEditModal(props: TopicEditModalProps) {
|
||||||
<div class={styles.field}>
|
<div class={styles.field}>
|
||||||
<label class={styles.label}>
|
<label class={styles.label}>
|
||||||
Описание:
|
Описание:
|
||||||
<HTMLEditor
|
<HTMLEditor value={formData().body} onInput={(value) => handleFieldChange('body', value)} />
|
||||||
value={formData().body}
|
|
||||||
onInput={(value) => handleFieldChange('body', value)}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -234,18 +236,16 @@ export default function TopicEditModal(props: TopicEditModalProps) {
|
||||||
<div class={styles.field}>
|
<div class={styles.field}>
|
||||||
<TopicPillsCloud
|
<TopicPillsCloud
|
||||||
topics={convertTopicsToTopicPills(availableParents())}
|
topics={convertTopicsToTopicPills(availableParents())}
|
||||||
selectedTopics={formData().parent_ids.map(id => id.toString())}
|
selectedTopics={formData().parent_ids.map((id) => id.toString())}
|
||||||
onSelectionChange={handleParentSelectionChange}
|
onSelectionChange={handleParentSelectionChange}
|
||||||
excludeTopics={[formData().id.toString()]}
|
excludeTopics={[formData().id.toString()]}
|
||||||
showSearch={true}
|
showSearch={true}
|
||||||
searchPlaceholder="Задайте родительские темы..."
|
searchPlaceholder="Задайте родительские темы..."
|
||||||
hideSelectedInHeader={true}
|
hideSelectedInHeader={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -53,11 +53,14 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||||
* Получает токен авторизации из localStorage или cookie
|
* Получает токен авторизации из localStorage или cookie
|
||||||
*/
|
*/
|
||||||
const getAuthToken = () => {
|
const getAuthToken = () => {
|
||||||
return localStorage.getItem('auth_token') ||
|
return (
|
||||||
|
localStorage.getItem('auth_token') ||
|
||||||
document.cookie
|
document.cookie
|
||||||
.split('; ')
|
.split('; ')
|
||||||
.find((row) => row.startsWith('auth_token='))
|
.find((row) => row.startsWith('auth_token='))
|
||||||
?.split('=')[1] || ''
|
?.split('=')[1] ||
|
||||||
|
''
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -91,10 +94,10 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||||
|
|
||||||
if (targetTopic) {
|
if (targetTopic) {
|
||||||
const targetCommunity = targetTopic.community
|
const targetCommunity = targetTopic.community
|
||||||
const invalidSources = sourcesTopics.filter(topic => topic.community !== targetCommunity)
|
const invalidSources = sourcesTopics.filter((topic) => topic.community !== targetCommunity)
|
||||||
|
|
||||||
if (invalidSources.length > 0) {
|
if (invalidSources.length > 0) {
|
||||||
newErrors.general = `Все темы должны принадлежать одному сообществу. Темы ${invalidSources.map(t => `"${t.title}"`).join(', ')} принадлежат другому сообществу`
|
newErrors.general = `Все темы должны принадлежать одному сообществу. Темы ${invalidSources.map((t) => `"${t.title}"`).join(', ')} принадлежат другому сообществу`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -117,9 +120,8 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||||
const query = searchQuery().toLowerCase().trim()
|
const query = searchQuery().toLowerCase().trim()
|
||||||
if (!query) return topicsList
|
if (!query) return topicsList
|
||||||
|
|
||||||
return topicsList.filter(topic =>
|
return topicsList.filter(
|
||||||
topic.title?.toLowerCase().includes(query) ||
|
(topic) => topic.title?.toLowerCase().includes(query) || topic.slug?.toLowerCase().includes(query)
|
||||||
topic.slug?.toLowerCase().includes(query)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -133,7 +135,7 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||||
|
|
||||||
// Убираем выбранную целевую тему из исходных тем
|
// Убираем выбранную целевую тему из исходных тем
|
||||||
if (topicId) {
|
if (topicId) {
|
||||||
setSourceTopicIds(prev => prev.filter(id => id !== topicId))
|
setSourceTopicIds((prev) => prev.filter((id) => id !== topicId))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Перевалидация
|
// Перевалидация
|
||||||
|
@ -173,8 +175,8 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||||
|
|
||||||
if (!target || sources.length === 0) return null
|
if (!target || sources.length === 0) return null
|
||||||
|
|
||||||
const targetTopic = props.topics.find(t => t.id === target)
|
const targetTopic = props.topics.find((t) => t.id === target)
|
||||||
const sourceTopics = props.topics.filter(t => sources.includes(t.id))
|
const sourceTopics = props.topics.filter((t) => sources.includes(t.id))
|
||||||
|
|
||||||
const totalShouts = sourceTopics.reduce((sum, topic) => sum + (topic.stat?.shouts || 0), 0)
|
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 totalFollowers = sourceTopics.reduce((sum, topic) => sum + (topic.stat?.followers || 0), 0)
|
||||||
|
@ -286,20 +288,17 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={props.isOpen} onClose={handleClose} title="Слияние тем" size="large">
|
<Modal isOpen={props.isOpen} onClose={handleClose} title="Слияние тем" size="large">
|
||||||
<div class={styles.form}>
|
<div class={styles.form}>
|
||||||
|
|
||||||
{/* Общие ошибки */}
|
{/* Общие ошибки */}
|
||||||
<Show when={errors().general}>
|
<Show when={errors().general}>
|
||||||
<div class={styles.formError}>
|
<div class={styles.formError}>{errors().general}</div>
|
||||||
{errors().general}
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Выбор целевой темы */}
|
{/* Выбор целевой темы */}
|
||||||
<div class={styles.section}>
|
<div class={styles.section}>
|
||||||
<h3 class={styles.sectionTitle}>🎯 Целевая тема</h3>
|
<h3 class={styles.sectionTitle}>🎯 Целевая тема</h3>
|
||||||
<p class={styles.sectionDescription}>
|
<p class={styles.sectionDescription}>
|
||||||
Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации
|
Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации будут
|
||||||
будут перенесены в эту тему, а исходные темы будут удалены.
|
перенесены в эту тему, а исходные темы будут удалены.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class={styles.field}>
|
<div class={styles.field}>
|
||||||
|
@ -315,8 +314,7 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||||
<For each={getFilteredTopics(getAvailableTargetTopics())}>
|
<For each={getFilteredTopics(getAvailableTargetTopics())}>
|
||||||
{(topic) => (
|
{(topic) => (
|
||||||
<option value={topic.id}>
|
<option value={topic.id}>
|
||||||
{topic.title} ({topic.slug})
|
{topic.title} ({topic.slug}){topic.stat ? ` • ${topic.stat.shouts} публикаций` : ''}
|
||||||
{topic.stat ? ` • ${topic.stat.shouts} публикаций` : ''}
|
|
||||||
</option>
|
</option>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
@ -332,8 +330,8 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||||
<div class={styles.section}>
|
<div class={styles.section}>
|
||||||
<h3 class={styles.sectionTitle}>📥 Исходные темы</h3>
|
<h3 class={styles.sectionTitle}>📥 Исходные темы</h3>
|
||||||
<p class={styles.sectionDescription}>
|
<p class={styles.sectionDescription}>
|
||||||
Выберите темы, которые будут слиты в целевую тему. Все их данные будут перенесены,
|
Выберите темы, которые будут слиты в целевую тему. Все их данные будут перенесены, а сами темы
|
||||||
а сами темы будут удалены.
|
будут удалены.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class={styles.field}>
|
<div class={styles.field}>
|
||||||
|
@ -356,16 +354,16 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||||
<div class={styles.availableParents}>
|
<div class={styles.availableParents}>
|
||||||
<div class={styles.sectionHeader}>
|
<div class={styles.sectionHeader}>
|
||||||
<strong>Доступные темы для слияния:</strong>
|
<strong>Доступные темы для слияния:</strong>
|
||||||
<span class={styles.hint}>
|
<span class={styles.hint}>Выбрано: {sourceTopicIds().length}</span>
|
||||||
Выбрано: {sourceTopicIds().length}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={styles.parentsGrid}>
|
<div class={styles.parentsGrid}>
|
||||||
<For each={getFilteredTopics(getAvailableSourceTopics())}>
|
<For each={getFilteredTopics(getAvailableSourceTopics())}>
|
||||||
{(topic) => {
|
{(topic) => {
|
||||||
const isChecked = () => sourceTopicIds().includes(topic.id)
|
const isChecked = () => sourceTopicIds().includes(topic.id)
|
||||||
const isDisabled = () => targetTopicId() && topic.community !== props.topics.find(t => t.id === targetTopicId())?.community
|
const isDisabled = () =>
|
||||||
|
targetTopicId() &&
|
||||||
|
topic.community !== props.topics.find((t) => t.id === targetTopicId())?.community
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
|
@ -379,9 +377,7 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||||
onChange={(e) => handleSourceTopicToggle(topic.id, e.currentTarget.checked)}
|
onChange={(e) => handleSourceTopicToggle(topic.id, e.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
<div class={styles.parentLabel}>
|
<div class={styles.parentLabel}>
|
||||||
<div class={styles.parentTitle}>
|
<div class={styles.parentTitle}>{topic.title}</div>
|
||||||
{topic.title}
|
|
||||||
</div>
|
|
||||||
<div class={styles.parentSlug}>{topic.slug}</div>
|
<div class={styles.parentSlug}>{topic.slug}</div>
|
||||||
<div class={styles.parentStats}>
|
<div class={styles.parentStats}>
|
||||||
{getCommunityName(topic.community)}
|
{getCommunityName(topic.community)}
|
||||||
|
@ -401,12 +397,8 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||||
|
|
||||||
<Show when={getFilteredTopics(getAvailableSourceTopics()).length === 0}>
|
<Show when={getFilteredTopics(getAvailableSourceTopics()).length === 0}>
|
||||||
<div class={styles.noParents}>
|
<div class={styles.noParents}>
|
||||||
<Show when={searchQuery()}>
|
<Show when={searchQuery()}>Не найдено тем по запросу "{searchQuery()}"</Show>
|
||||||
Не найдено тем по запросу "{searchQuery()}"
|
<Show when={!searchQuery()}>Нет доступных тем для слияния</Show>
|
||||||
</Show>
|
|
||||||
<Show when={!searchQuery()}>
|
|
||||||
Нет доступных тем для слияния
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
@ -418,13 +410,13 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||||
<h3 class={styles.sectionTitle}>📊 Предварительный просмотр</h3>
|
<h3 class={styles.sectionTitle}>📊 Предварительный просмотр</h3>
|
||||||
|
|
||||||
<div class={styles.hierarchyPath}>
|
<div class={styles.hierarchyPath}>
|
||||||
<div><strong>Целевая тема:</strong> {preview!.targetTopic!.title}</div>
|
<div>
|
||||||
|
<strong>Целевая тема:</strong> {preview!.targetTopic!.title}
|
||||||
|
</div>
|
||||||
<div class={styles.pathDisplay}>
|
<div class={styles.pathDisplay}>
|
||||||
<span>Слияние {preview!.sourcesCount} тем:</span>
|
<span>Слияние {preview!.sourcesCount} тем:</span>
|
||||||
<For each={preview!.sourceTopics}>
|
<For each={preview!.sourceTopics}>
|
||||||
{(topic) => (
|
{(topic) => <span class={styles.pathItem}>{topic.title}</span>}
|
||||||
<span class={styles.pathItem}>{topic.title}</span>
|
|
||||||
)}
|
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -453,12 +445,10 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||||
onChange={(e) => setPreserveTarget(e.currentTarget.checked)}
|
onChange={(e) => setPreserveTarget(e.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
<div class={styles.parentLabel}>
|
<div class={styles.parentLabel}>
|
||||||
<div class={styles.parentTitle}>
|
<div class={styles.parentTitle}>Сохранить свойства целевой темы</div>
|
||||||
Сохранить свойства целевой темы
|
|
||||||
</div>
|
|
||||||
<div class={styles.parentStats}>
|
<div class={styles.parentStats}>
|
||||||
Если включено, описание и другие свойства целевой темы не будут изменены.
|
Если включено, описание и другие свойства целевой темы не будут изменены. Если выключено,
|
||||||
Если выключено, свойства могут быть объединены с исходными темами.
|
свойства могут быть объединены с исходными темами.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
@ -467,12 +457,7 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||||
|
|
||||||
{/* Кнопки */}
|
{/* Кнопки */}
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
<Button
|
<Button type="button" variant="secondary" onClick={handleClose} disabled={loading()}>
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={loading()}
|
|
||||||
>
|
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -87,7 +87,7 @@ const LoginPage = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div class={formStyles.actions} style={{ 'margin': 'auto' }}>
|
<div class={formStyles.actions} style={{ margin: 'auto' }}>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import { Component, createSignal, createEffect, For, onMount, Show } from 'solid-js'
|
import { Component, createEffect, createSignal, For, onMount, Show } from 'solid-js'
|
||||||
import { query } from '../graphql'
|
import { query } from '../graphql'
|
||||||
import type { Query } from '../graphql/generated/schema'
|
import {
|
||||||
import { ADMIN_DELETE_REACTION_MUTATION, ADMIN_RESTORE_REACTION_MUTATION, ADMIN_UPDATE_REACTION_MUTATION } from '../graphql/mutations'
|
ADMIN_DELETE_REACTION_MUTATION,
|
||||||
|
ADMIN_RESTORE_REACTION_MUTATION,
|
||||||
|
ADMIN_UPDATE_REACTION_MUTATION
|
||||||
|
} from '../graphql/mutations'
|
||||||
import { ADMIN_GET_REACTIONS_QUERY } from '../graphql/queries'
|
import { ADMIN_GET_REACTIONS_QUERY } from '../graphql/queries'
|
||||||
import ReactionEditModal from '../modals/ReactionEditModal'
|
import ReactionEditModal from '../modals/ReactionEditModal'
|
||||||
import styles from '../styles/Admin.module.css'
|
import styles from '../styles/Admin.module.css'
|
||||||
|
@ -85,24 +88,22 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
|
||||||
const query_value = searchQuery().trim()
|
const query_value = searchQuery().trim()
|
||||||
const isShoutId = /^\d+$/.test(query_value) // Проверяем, состоит ли запрос только из цифр
|
const isShoutId = /^\d+$/.test(query_value) // Проверяем, состоит ли запрос только из цифр
|
||||||
|
|
||||||
const data = await query<{ adminGetReactions: {
|
const data = await query<{
|
||||||
|
adminGetReactions: {
|
||||||
reactions: AdminReaction[]
|
reactions: AdminReaction[]
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
perPage: number
|
perPage: number
|
||||||
totalPages: number
|
totalPages: number
|
||||||
} }>(
|
}
|
||||||
`${location.origin}/graphql`,
|
}>(`${location.origin}/graphql`, ADMIN_GET_REACTIONS_QUERY, {
|
||||||
ADMIN_GET_REACTIONS_QUERY,
|
|
||||||
{
|
|
||||||
search: isShoutId ? '' : query_value, // Если это ID, не передаем в обычный поиск
|
search: isShoutId ? '' : query_value, // Если это ID, не передаем в обычный поиск
|
||||||
kind: kindFilter() || undefined,
|
kind: kindFilter() || undefined,
|
||||||
shout_id: isShoutId ? parseInt(query_value) : undefined, // Если это ID, передаем в shout_id
|
shout_id: isShoutId ? Number.parseInt(query_value) : undefined, // Если это ID, передаем в shout_id
|
||||||
status: showDeletedOnly() ? 'deleted' : 'all',
|
status: showDeletedOnly() ? 'deleted' : 'all',
|
||||||
limit: pagination().limit,
|
limit: pagination().limit,
|
||||||
offset: (pagination().page - 1) * pagination().limit
|
offset: (pagination().page - 1) * pagination().limit
|
||||||
}
|
})
|
||||||
)
|
|
||||||
if (data?.adminGetReactions?.reactions) {
|
if (data?.adminGetReactions?.reactions) {
|
||||||
console.log('[ReactionsRoute] Reactions loaded:', data.adminGetReactions.reactions.length)
|
console.log('[ReactionsRoute] Reactions loaded:', data.adminGetReactions.reactions.length)
|
||||||
setReactions(data.adminGetReactions.reactions as AdminReaction[])
|
setReactions(data.adminGetReactions.reactions as AdminReaction[])
|
||||||
|
@ -417,7 +418,9 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
|
||||||
</td>
|
</td>
|
||||||
<td class={styles['body-cell']}>
|
<td class={styles['body-cell']}>
|
||||||
<div class={styles['body-preview']}>
|
<div class={styles['body-preview']}>
|
||||||
{reaction.body ? reaction.body.substring(0, 100) + (reaction.body.length > 100 ? '...' : '') : '-'}
|
{reaction.body
|
||||||
|
? reaction.body.substring(0, 100) + (reaction.body.length > 100 ? '...' : '')
|
||||||
|
: '-'}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
@ -441,12 +444,20 @@ const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
|
||||||
<td>
|
<td>
|
||||||
<div class={styles['actions-cell']} onClick={(e) => e.stopPropagation()}>
|
<div class={styles['actions-cell']} onClick={(e) => e.stopPropagation()}>
|
||||||
<Show when={reaction.deleted_at}>
|
<Show when={reaction.deleted_at}>
|
||||||
<Button variant="primary" size="small" onClick={() => restoreReaction(reaction.id)}>
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => restoreReaction(reaction.id)}
|
||||||
|
>
|
||||||
Восстановить
|
Восстановить
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!reaction.deleted_at}>
|
<Show when={!reaction.deleted_at}>
|
||||||
<Button variant="danger" size="small" onClick={() => deleteReaction(reaction.id)}>
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="small"
|
||||||
|
onClick={() => deleteReaction(reaction.id)}
|
||||||
|
>
|
||||||
Удалить
|
Удалить
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
@ -273,7 +273,10 @@ const ShoutsRoute = (props: ShoutsRouteProps) => {
|
||||||
{(author) => (
|
{(author) => (
|
||||||
<Show when={author}>
|
<Show when={author}>
|
||||||
{(safeAuthor) => (
|
{(safeAuthor) => (
|
||||||
<span class={styles['author-badge']} title={formatAuthorTooltip(safeAuthor()!)}>
|
<span
|
||||||
|
class={styles['author-badge']}
|
||||||
|
title={formatAuthorTooltip(safeAuthor()!)}
|
||||||
|
>
|
||||||
{safeAuthor()?.name || safeAuthor()?.email || `ID:${safeAuthor()?.id}`}
|
{safeAuthor()?.name || safeAuthor()?.email || `ID:${safeAuthor()?.id}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
@ -392,10 +395,7 @@ const ShoutsRoute = (props: ShoutsRouteProps) => {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div style="padding: 1rem;">
|
<div style="padding: 1rem;">
|
||||||
<HTMLEditor
|
<HTMLEditor value={selectedShoutBody()} onInput={(value) => setSelectedShoutBody(value)} />
|
||||||
value={selectedShoutBody()}
|
|
||||||
onInput={(value) => setSelectedShoutBody(value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
@ -428,10 +428,8 @@ const ShoutsRoute = (props: ShoutsRouteProps) => {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div style="padding: 1rem;">
|
<div style="padding: 1rem;">
|
||||||
<HTMLEditor
|
<HTMLEditor value={selectedMediaBody()} onInput={(value) => setSelectedMediaBody(value)} />
|
||||||
value={selectedMediaBody()}
|
gjl
|
||||||
onInput={(value) => setSelectedMediaBody(value)}
|
|
||||||
/>gjl
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -141,7 +141,7 @@ export const Topics = (props: TopicsProps) => {
|
||||||
const currentTopics = contextTopics()
|
const currentTopics = contextTopics()
|
||||||
console.log('[TopicsRoute] Current topics count:', currentTopics.length)
|
console.log('[TopicsRoute] Current topics count:', currentTopics.length)
|
||||||
|
|
||||||
const updatedTopics = currentTopics.map(topic =>
|
const updatedTopics = currentTopics.map((topic) =>
|
||||||
topic.id === updatedTopic.id ? updatedTopic : topic
|
topic.id === updatedTopic.id ? updatedTopic : topic
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -219,9 +219,7 @@ export const Topics = (props: TopicsProps) => {
|
||||||
<td class={styles.tableCell} title={topic.slug}>
|
<td class={styles.tableCell} title={topic.slug}>
|
||||||
{truncateText(topic.slug, 30)}
|
{truncateText(topic.slug, 30)}
|
||||||
</td>
|
</td>
|
||||||
<td class={styles.tableCell}>
|
<td class={styles.tableCell}>{renderParentTopics(topic.parent_ids)}</td>
|
||||||
{renderParentTopics(topic.parent_ids)}
|
|
||||||
</td>
|
|
||||||
<td class={styles.tableCell}>
|
<td class={styles.tableCell}>
|
||||||
{topic.body ? (
|
{topic.body ? (
|
||||||
<span style="color: #666;">{truncateText(topic.body.replace(/<[^>]*>/g, ''), 60)}</span>
|
<span style="color: #666;">{truncateText(topic.body.replace(/<[^>]*>/g, ''), 60)}</span>
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
* @module HTMLEditor
|
* @module HTMLEditor
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createEffect, onMount, untrack, createSignal } from 'solid-js'
|
|
||||||
import Prism from 'prismjs'
|
import Prism from 'prismjs'
|
||||||
|
import { createEffect, createSignal, onMount, untrack } from 'solid-js'
|
||||||
import 'prismjs/components/prism-markup'
|
import 'prismjs/components/prism-markup'
|
||||||
import 'prismjs/themes/prism.css'
|
import 'prismjs/themes/prism.css'
|
||||||
import styles from '../styles/Form.module.css'
|
import styles from '../styles/Form.module.css'
|
||||||
|
@ -69,7 +69,7 @@ const HTMLEditor = (props: HTMLEditorProps) => {
|
||||||
const range = document.createRange()
|
const range = document.createRange()
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
const codeElement = editorElement?.querySelector('code')
|
const codeElement = editorElement?.querySelector('code')
|
||||||
if (codeElement && codeElement.firstChild) {
|
if (codeElement?.firstChild) {
|
||||||
range.setStart(codeElement.firstChild, codeElement.firstChild.textContent?.length || 0)
|
range.setStart(codeElement.firstChild, codeElement.firstChild.textContent?.length || 0)
|
||||||
range.setEnd(codeElement.firstChild, codeElement.firstChild.textContent?.length || 0)
|
range.setEnd(codeElement.firstChild, codeElement.firstChild.textContent?.length || 0)
|
||||||
selection?.removeAllRanges()
|
selection?.removeAllRanges()
|
||||||
|
@ -112,16 +112,12 @@ const HTMLEditor = (props: HTMLEditorProps) => {
|
||||||
savedRange = range.cloneRange()
|
savedRange = range.cloneRange()
|
||||||
|
|
||||||
// Вычисляем общий offset относительно всего текстового содержимого
|
// Вычисляем общий offset относительно всего текстового содержимого
|
||||||
const walker = document.createTreeWalker(
|
const walker = document.createTreeWalker(editorElement, NodeFilter.SHOW_TEXT, null)
|
||||||
editorElement,
|
|
||||||
NodeFilter.SHOW_TEXT,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
|
|
||||||
let node
|
let node: Node | null = null
|
||||||
let totalOffset = 0
|
let totalOffset = 0
|
||||||
|
|
||||||
while (node = walker.nextNode()) {
|
while ((node = walker.nextNode())) {
|
||||||
if (node === range.startContainer) {
|
if (node === range.startContainer) {
|
||||||
cursorOffset = totalOffset + range.startOffset
|
cursorOffset = totalOffset + range.startOffset
|
||||||
break
|
break
|
||||||
|
@ -147,17 +143,13 @@ const HTMLEditor = (props: HTMLEditorProps) => {
|
||||||
forceHighlight(codeElement)
|
forceHighlight(codeElement)
|
||||||
|
|
||||||
// Восстанавливаем позицию курсора только если элемент в фокусе
|
// Восстанавливаем позицию курсора только если элемент в фокусе
|
||||||
if (cursorOffset > 0 && document.activeElement === editorElement) {
|
if (savedRange && document.activeElement === editorElement) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const walker = document.createTreeWalker(
|
const walker = document.createTreeWalker(codeElement, NodeFilter.SHOW_TEXT, null)
|
||||||
codeElement,
|
|
||||||
NodeFilter.SHOW_TEXT,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
let currentOffset = 0
|
let currentOffset = 0
|
||||||
let node
|
let node: Node | null = null
|
||||||
|
|
||||||
while (node = walker.nextNode()) {
|
while ((node = walker.nextNode())) {
|
||||||
const nodeLength = node.textContent?.length || 0
|
const nodeLength = node.textContent?.length || 0
|
||||||
if (currentOffset + nodeLength >= cursorOffset) {
|
if (currentOffset + nodeLength >= cursorOffset) {
|
||||||
try {
|
try {
|
||||||
|
@ -169,8 +161,13 @@ const HTMLEditor = (props: HTMLEditorProps) => {
|
||||||
range.setEnd(node, targetOffset)
|
range.setEnd(node, targetOffset)
|
||||||
newSelection?.removeAllRanges()
|
newSelection?.removeAllRanges()
|
||||||
newSelection?.addRange(range)
|
newSelection?.addRange(range)
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
// Игнорируем ошибки позиционирования курсора
|
// Используем savedRange как резервный вариант
|
||||||
|
if (savedRange) {
|
||||||
|
const newSelection = window.getSelection()
|
||||||
|
newSelection?.removeAllRanges()
|
||||||
|
newSelection?.addRange(savedRange)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -253,7 +250,7 @@ const HTMLEditor = (props: HTMLEditorProps) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
processNode(editorElement)
|
processNode(editorElement)
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
// В случае ошибки возвращаем базовый textContent
|
// В случае ошибки возвращаем базовый textContent
|
||||||
return editorElement.textContent || ''
|
return editorElement.textContent || ''
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* @module TopicPillsCloud
|
* @module TopicPillsCloud
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, createMemo, For, Show } from 'solid-js'
|
import { createMemo, createSignal, For, Show } from 'solid-js'
|
||||||
import styles from '../styles/Form.module.css'
|
import styles from '../styles/Form.module.css'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -60,15 +60,14 @@ const TopicPillsCloud = (props: TopicPillsCloudProps) => {
|
||||||
|
|
||||||
// Исключаем запрещенные топики
|
// Исключаем запрещенные топики
|
||||||
if (props.excludeTopics?.length) {
|
if (props.excludeTopics?.length) {
|
||||||
topics = topics.filter(topic => !props.excludeTopics!.includes(topic.id))
|
topics = topics.filter((topic) => !props.excludeTopics!.includes(topic.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фильтруем по поисковому запросу
|
// Фильтруем по поисковому запросу
|
||||||
const query = searchQuery().toLowerCase().trim()
|
const query = searchQuery().toLowerCase().trim()
|
||||||
if (query) {
|
if (query) {
|
||||||
topics = topics.filter(topic =>
|
topics = topics.filter(
|
||||||
topic.title.toLowerCase().includes(query) ||
|
(topic) => topic.title.toLowerCase().includes(query) || topic.slug.toLowerCase().includes(query)
|
||||||
topic.slug.toLowerCase().includes(query)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,13 +134,11 @@ const TopicPillsCloud = (props: TopicPillsCloudProps) => {
|
||||||
return topic.parent_ids?.length || 0
|
return topic.parent_ids?.length || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить выбранные топики как объекты
|
* Получить выбранные топики как объекты
|
||||||
*/
|
*/
|
||||||
const selectedTopicObjects = createMemo(() => {
|
const selectedTopicObjects = createMemo(() => {
|
||||||
return props.topics.filter(topic => props.selectedTopics.includes(topic.id))
|
return props.topics.filter((topic) => props.selectedTopics.includes(topic.id))
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -212,18 +209,14 @@ const TopicPillsCloud = (props: TopicPillsCloudProps) => {
|
||||||
type="button"
|
type="button"
|
||||||
class={`${styles.topicPill} ${
|
class={`${styles.topicPill} ${
|
||||||
selected ? styles.pillSelected : ''
|
selected ? styles.pillSelected : ''
|
||||||
} ${disabled ? styles.pillDisabled : ''} ${
|
} ${disabled ? styles.pillDisabled : ''} ${depth > 0 ? styles.pillNested : ''}`}
|
||||||
depth > 0 ? styles.pillNested : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => !disabled && handleTopicClick(topic.id)}
|
onClick={() => !disabled && handleTopicClick(topic.id)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
title={`${topic.title} (${topic.slug})`}
|
title={`${topic.title} (${topic.slug})`}
|
||||||
data-depth={depth}
|
data-depth={depth}
|
||||||
>
|
>
|
||||||
<Show when={depth > 0}>
|
<Show when={depth > 0}>
|
||||||
<span class={styles.pillDepthIndicator}>
|
<span class={styles.pillDepthIndicator}>{' '.repeat(depth)}└</span>
|
||||||
{' '.repeat(depth)}└
|
|
||||||
</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
<span class={styles.pillTitle}>{topic.title}</span>
|
<span class={styles.pillTitle}>{topic.title}</span>
|
||||||
<Show when={selected}>
|
<Show when={selected}>
|
||||||
|
@ -237,10 +230,7 @@ const TopicPillsCloud = (props: TopicPillsCloudProps) => {
|
||||||
|
|
||||||
<Show when={filteredTopics().length === 0}>
|
<Show when={filteredTopics().length === 0}>
|
||||||
<div class={styles.emptyState}>
|
<div class={styles.emptyState}>
|
||||||
<Show
|
<Show when={searchQuery().trim()} fallback={<span>Нет доступных топиков</span>}>
|
||||||
when={searchQuery().trim()}
|
|
||||||
fallback={<span>Нет доступных топиков</span>}
|
|
||||||
>
|
|
||||||
<span>Ничего не найдено по запросу "{searchQuery()}"</span>
|
<span>Ничего не найдено по запросу "{searchQuery()}"</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,3 +4,4 @@ pytest-asyncio
|
||||||
pytest-cov
|
pytest-cov
|
||||||
mypy
|
mypy
|
||||||
ruff
|
ruff
|
||||||
|
pre-commit
|
||||||
|
|
Loading…
Reference in New Issue
Block a user