This commit is contained in:
@@ -1,101 +1,102 @@
|
||||
import Prism from 'prismjs'
|
||||
import { JSX } from 'solid-js'
|
||||
import 'prismjs/components/prism-json'
|
||||
import 'prismjs/components/prism-markup'
|
||||
import { createMemo, JSX, Show } from 'solid-js'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
|
||||
import styles from '../styles/CodePreview.module.css'
|
||||
import { detectLanguage, formatCode, highlightCode } from '../utils/codeHelpers'
|
||||
|
||||
/**
|
||||
* Определяет язык контента (html или json)
|
||||
*/
|
||||
function detectLanguage(content: string): string {
|
||||
try {
|
||||
JSON.parse(content)
|
||||
return 'json'
|
||||
} catch {
|
||||
if (/<[^>]*>/g.test(content)) {
|
||||
return 'markup'
|
||||
}
|
||||
}
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует XML/HTML с отступами
|
||||
*/
|
||||
function prettyFormatXML(xml: string): string {
|
||||
let formatted = ''
|
||||
const reg = /(>)(<)(\/*)/g
|
||||
const res = xml.replace(reg, '$1\r\n$2$3')
|
||||
let pad = 0
|
||||
res.split('\r\n').forEach((node) => {
|
||||
let indent = 0
|
||||
if (node.match(/.+<\/\w[^>]*>$/)) {
|
||||
indent = 0
|
||||
} else if (node.match(/^<\//)) {
|
||||
if (pad !== 0) pad -= 2
|
||||
} else if (node.match(/^<\w([^>]*[^/])?>.*$/)) {
|
||||
indent = 2
|
||||
} else {
|
||||
indent = 0
|
||||
}
|
||||
formatted += `${' '.repeat(pad)}${node}\r\n`
|
||||
pad += indent
|
||||
})
|
||||
return formatted.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует и подсвечивает код
|
||||
*/
|
||||
function formatCode(content: string): string {
|
||||
const language = detectLanguage(content)
|
||||
|
||||
if (language === 'json') {
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(content), null, 2)
|
||||
return Prism.highlight(formatted, Prism.languages[language], language)
|
||||
} catch {
|
||||
return content
|
||||
}
|
||||
} else if (language === 'markup') {
|
||||
const formatted = prettyFormatXML(content)
|
||||
return Prism.highlight(formatted, Prism.languages[language], language)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
interface CodePreviewProps extends JSX.HTMLAttributes<HTMLPreElement> {
|
||||
interface CodePreviewProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
content: string
|
||||
language?: string
|
||||
maxHeight?: string
|
||||
showLineNumbers?: boolean
|
||||
autoFormat?: boolean
|
||||
editable?: boolean
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент для отображения кода с подсветкой синтаксиса
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <CodePreview
|
||||
* content='{"key": "value"}'
|
||||
* language="json"
|
||||
* showLineNumbers={true}
|
||||
* editable={true}
|
||||
* onEdit={() => setIsEditing(true)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const CodePreview = (props: CodePreviewProps) => {
|
||||
const language = () => props.language || detectLanguage(props.content)
|
||||
// const formattedCode = () => formatCode(props.content)
|
||||
|
||||
const numberedCode = () => {
|
||||
const lines = props.content.split('\n')
|
||||
return lines
|
||||
.map((line, index) => `<span class="${styles.lineNumber}">${index + 1}</span>${line}`)
|
||||
.join('\n')
|
||||
}
|
||||
// Реактивные вычисления
|
||||
const language = createMemo(() => props.language || detectLanguage(props.content))
|
||||
const formattedContent = createMemo(() =>
|
||||
props.autoFormat ? formatCode(props.content, language()) : props.content
|
||||
)
|
||||
const highlightedCode = createMemo(() => highlightCode(formattedContent(), language()))
|
||||
const isEmpty = createMemo(() => !props.content?.trim())
|
||||
|
||||
return (
|
||||
<pre
|
||||
{...props}
|
||||
class={`${styles.codePreview} ${props.class || ''}`}
|
||||
style={`max-height: ${props.maxHeight || '500px'}; overflow-y: auto; ${props.style || ''}`}
|
||||
<div
|
||||
class={`${styles.codePreview} ${props.editable ? styles.codePreviewContainer : ''} ${props.class || ''}`}
|
||||
style={`max-height: ${props.maxHeight || '500px'}; ${props.style || ''}`}
|
||||
onClick={props.editable ? props.onEdit : undefined}
|
||||
role={props.editable ? 'button' : 'presentation'}
|
||||
tabindex={props.editable ? 0 : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (props.editable && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault()
|
||||
props.onEdit?.()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<code
|
||||
class={`language-${language()} ${styles.code}`}
|
||||
innerHTML={Prism.highlight(numberedCode(), Prism.languages[language()], language())}
|
||||
/>
|
||||
{props.language && <span class={styles.languageBadge}>{props.language}</span>}
|
||||
</pre>
|
||||
<div class={styles.codeContainer}>
|
||||
{/* Область кода */}
|
||||
<div class={styles.codeArea}>
|
||||
<Show
|
||||
when={!isEmpty()}
|
||||
fallback={
|
||||
<div class={`${styles.placeholder} ${props.editable ? styles.placeholderClickable : ''}`}>
|
||||
{props.editable ? 'Нажмите для редактирования...' : 'Нет содержимого'}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<pre class={styles.codePreviewContent}>
|
||||
<code class={`language-${language()}`} innerHTML={highlightedCode()} />
|
||||
</pre>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Индикаторы */}
|
||||
<div class={styles.controlsLeft}>
|
||||
<span class={styles.languageBadge}>{language()}</span>
|
||||
|
||||
<Show when={props.editable}>
|
||||
<div class={styles.statusIndicator}>
|
||||
<div class={`${styles.statusDot} ${styles.idle}`} />
|
||||
<span>Только чтение</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Кнопка редактирования */}
|
||||
<Show when={props.editable && !isEmpty()}>
|
||||
<div class={styles.controlsRight}>
|
||||
<button
|
||||
class={styles.editButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.onEdit?.()
|
||||
}}
|
||||
title="Редактировать код"
|
||||
>
|
||||
✏️ Редактировать
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
77
panel/ui/CommunitySelector.tsx
Normal file
77
panel/ui/CommunitySelector.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { createEffect, For, Show } from 'solid-js'
|
||||
import { useData } from '../context/data'
|
||||
import styles from '../styles/Admin.module.css'
|
||||
|
||||
/**
|
||||
* Компонент выбора сообщества
|
||||
*
|
||||
* Особенности:
|
||||
* - Сохраняет выбранное сообщество в localStorage
|
||||
* - По умолчанию выбрано сообщество с ID 1 (Дискурс)
|
||||
* - При изменении автоматически загружает темы выбранного сообщества
|
||||
*/
|
||||
const CommunitySelector = () => {
|
||||
const { communities, selectedCommunity, setSelectedCommunity, loadTopicsByCommunity, isLoading } =
|
||||
useData()
|
||||
|
||||
// Отладочное логирование состояния
|
||||
createEffect(() => {
|
||||
const current = selectedCommunity()
|
||||
const allCommunities = communities()
|
||||
console.log('[CommunitySelector] Состояние:', {
|
||||
selectedId: current,
|
||||
selectedName: allCommunities.find((c) => c.id === current)?.name,
|
||||
totalCommunities: allCommunities.length
|
||||
})
|
||||
})
|
||||
|
||||
// Загружаем темы при изменении выбранного сообщества
|
||||
createEffect(() => {
|
||||
const communityId = selectedCommunity()
|
||||
if (communityId !== null) {
|
||||
console.log('[CommunitySelector] Загрузка тем для сообщества:', communityId)
|
||||
loadTopicsByCommunity(communityId)
|
||||
}
|
||||
})
|
||||
|
||||
// Обработчик изменения выбранного сообщества
|
||||
const handleCommunityChange = (event: Event) => {
|
||||
const select = event.target as HTMLSelectElement
|
||||
const value = select.value
|
||||
|
||||
if (value === '') {
|
||||
setSelectedCommunity(null)
|
||||
} else {
|
||||
const communityId = Number.parseInt(value, 10)
|
||||
if (!Number.isNaN(communityId)) {
|
||||
setSelectedCommunity(communityId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={styles['community-selector']}>
|
||||
<select
|
||||
id="community-select"
|
||||
value={selectedCommunity()?.toString() || ''}
|
||||
onChange={handleCommunityChange}
|
||||
disabled={isLoading()}
|
||||
class={selectedCommunity() !== null ? styles['community-selected'] : ''}
|
||||
>
|
||||
<option value="">Все сообщества</option>
|
||||
<For each={communities()}>
|
||||
{(community) => (
|
||||
<option value={community.id.toString()}>
|
||||
{community.name} {community.id === 1 ? '(По умолчанию)' : ''}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
<Show when={isLoading()}>
|
||||
<span class={styles['loading-indicator']}>Загрузка...</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommunitySelector
|
@@ -1,13 +1,14 @@
|
||||
import Prism from 'prismjs'
|
||||
import { createEffect, createSignal, onMount, Show } from 'solid-js'
|
||||
import 'prismjs/components/prism-json'
|
||||
import 'prismjs/components/prism-markup'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/components/prism-css'
|
||||
import { createEffect, createMemo, createSignal, Show } from 'solid-js'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
|
||||
import styles from '../styles/CodePreview.module.css'
|
||||
import { detectLanguage } from './CodePreview'
|
||||
import {
|
||||
DEFAULT_EDITOR_CONFIG,
|
||||
detectLanguage,
|
||||
formatCode,
|
||||
handleTabKey,
|
||||
highlightCode
|
||||
} from '../utils/codeHelpers'
|
||||
|
||||
interface EditableCodePreviewProps {
|
||||
content: string
|
||||
@@ -18,202 +19,98 @@ interface EditableCodePreviewProps {
|
||||
maxHeight?: string
|
||||
placeholder?: string
|
||||
showButtons?: boolean
|
||||
autoFormat?: boolean
|
||||
readOnly?: boolean
|
||||
theme?: 'dark' | 'light' | 'highContrast'
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует HTML контент для лучшего отображения
|
||||
* Убирает лишние пробелы и делает разметку красивой
|
||||
*/
|
||||
const formatHtmlContent = (html: string): string => {
|
||||
if (!html || typeof html !== 'string') return ''
|
||||
|
||||
// Удаляем лишние пробелы между тегами
|
||||
const formatted = html
|
||||
.replace(/>\s+</g, '><') // Убираем пробелы между тегами
|
||||
.replace(/\s+/g, ' ') // Множественные пробелы в одиночные
|
||||
.trim() // Убираем пробелы в начале и конце
|
||||
|
||||
// Добавляем отступы для лучшего отображения
|
||||
const indent = ' '
|
||||
let indentLevel = 0
|
||||
const lines: string[] = []
|
||||
|
||||
// Разбиваем на токены (теги и текст)
|
||||
const tokens = formatted.match(/<[^>]+>|[^<]+/g) || []
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.startsWith('<')) {
|
||||
if (token.startsWith('</')) {
|
||||
// Закрывающий тег - уменьшаем отступ
|
||||
indentLevel = Math.max(0, indentLevel - 1)
|
||||
lines.push(indent.repeat(indentLevel) + token)
|
||||
} else if (token.endsWith('/>')) {
|
||||
// Самозакрывающийся тег
|
||||
lines.push(indent.repeat(indentLevel) + token)
|
||||
} else {
|
||||
// Открывающий тег - добавляем отступ
|
||||
lines.push(indent.repeat(indentLevel) + token)
|
||||
indentLevel++
|
||||
}
|
||||
} else {
|
||||
// Текстовое содержимое
|
||||
const trimmed = token.trim()
|
||||
if (trimmed) {
|
||||
lines.push(indent.repeat(indentLevel) + trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует номера строк для текста
|
||||
*/
|
||||
const generateLineNumbers = (text: string): string[] => {
|
||||
if (!text) return ['1']
|
||||
const lines = text.split('\n')
|
||||
return lines.map((_, index) => String(index + 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Редактируемый компонент для кода с подсветкой синтаксиса
|
||||
* Современный редактор кода с подсветкой синтаксиса и удобными возможностями редактирования
|
||||
*
|
||||
* Возможности:
|
||||
* - Подсветка синтаксиса в реальном времени
|
||||
* - Номера строк с синхронизацией скролла
|
||||
* - Автоформатирование кода
|
||||
* - Горячие клавиши (Ctrl+Enter для сохранения, Esc для отмены)
|
||||
* - Обработка Tab для отступов
|
||||
* - Сохранение позиции курсора
|
||||
* - Адаптивный дизайн
|
||||
* - Поддержка тем оформления
|
||||
*/
|
||||
const EditableCodePreview = (props: EditableCodePreviewProps) => {
|
||||
// Состояние компонента
|
||||
const [isEditing, setIsEditing] = createSignal(false)
|
||||
const [content, setContent] = createSignal(props.content)
|
||||
let editorRef: HTMLDivElement | undefined
|
||||
const [isSaving, setIsSaving] = createSignal(false)
|
||||
const [hasChanges, setHasChanges] = createSignal(false)
|
||||
|
||||
// Ссылки на DOM элементы
|
||||
let editorRef: HTMLTextAreaElement | undefined
|
||||
let highlightRef: HTMLPreElement | undefined
|
||||
let lineNumbersRef: HTMLDivElement | undefined
|
||||
|
||||
const language = () => props.language || detectLanguage(content())
|
||||
// Реактивные вычисления
|
||||
const language = createMemo(() => props.language || detectLanguage(content()))
|
||||
|
||||
/**
|
||||
* Обновляет подсветку синтаксиса
|
||||
*/
|
||||
const updateHighlight = () => {
|
||||
if (!highlightRef) return
|
||||
|
||||
const code = content() || ''
|
||||
const lang = language()
|
||||
|
||||
try {
|
||||
if (Prism.languages[lang]) {
|
||||
highlightRef.innerHTML = Prism.highlight(code, Prism.languages[lang], lang)
|
||||
} else {
|
||||
highlightRef.textContent = code
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error highlighting code:', e)
|
||||
highlightRef.textContent = code
|
||||
// Контент для отображения (отформатированный в режиме просмотра, исходный в режиме редактирования)
|
||||
const displayContent = createMemo(() => {
|
||||
if (isEditing()) {
|
||||
return content() // В режиме редактирования показываем исходный код
|
||||
}
|
||||
}
|
||||
return props.autoFormat ? formatCode(content(), language()) : content() // В режиме просмотра - форматированный
|
||||
})
|
||||
|
||||
const isEmpty = createMemo(() => !content()?.trim())
|
||||
const status = createMemo(() => {
|
||||
if (isSaving()) return 'saving'
|
||||
if (isEditing()) return 'editing'
|
||||
return 'idle'
|
||||
})
|
||||
|
||||
/**
|
||||
* Обновляет номера строк
|
||||
*/
|
||||
const updateLineNumbers = () => {
|
||||
if (!lineNumbersRef) return
|
||||
|
||||
const lineNumbers = generateLineNumbers(content())
|
||||
lineNumbersRef.innerHTML = lineNumbers
|
||||
.map((num) => `<div class="${styles.lineNumber}">${num}</div>`)
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизирует скролл между редактором и подсветкой
|
||||
* Синхронизирует скролл подсветки синтаксиса с textarea
|
||||
*/
|
||||
const syncScroll = () => {
|
||||
if (editorRef && highlightRef) {
|
||||
highlightRef.scrollTop = editorRef.scrollTop
|
||||
highlightRef.scrollLeft = editorRef.scrollLeft
|
||||
}
|
||||
if (editorRef && lineNumbersRef) {
|
||||
lineNumbersRef.scrollTop = editorRef.scrollTop
|
||||
if (!editorRef) return
|
||||
|
||||
const scrollTop = editorRef.scrollTop
|
||||
const scrollLeft = editorRef.scrollLeft
|
||||
|
||||
// Синхронизируем только подсветку синтаксиса в режиме редактирования
|
||||
if (highlightRef && isEditing()) {
|
||||
highlightRef.scrollTop = scrollTop
|
||||
highlightRef.scrollLeft = scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует элементы номеров строк для CSS счетчика
|
||||
*/
|
||||
const generateLineElements = createMemo(() => {
|
||||
const lines = displayContent().split('\n')
|
||||
return lines.map((_, _index) => <div class={styles.lineNumberItem} />)
|
||||
})
|
||||
|
||||
/**
|
||||
* Обработчик изменения контента
|
||||
*/
|
||||
const handleInput = (e: Event) => {
|
||||
const target = e.target as HTMLDivElement
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
const newContent = target.value
|
||||
|
||||
// Сохраняем текущую позицию курсора
|
||||
const selection = window.getSelection()
|
||||
let caretOffset = 0
|
||||
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0)
|
||||
const preCaretRange = range.cloneRange()
|
||||
preCaretRange.selectNodeContents(target)
|
||||
preCaretRange.setEnd(range.endContainer, range.endOffset)
|
||||
caretOffset = preCaretRange.toString().length
|
||||
}
|
||||
|
||||
const newContent = target.textContent || ''
|
||||
setContent(newContent)
|
||||
setHasChanges(newContent !== props.content)
|
||||
props.onContentChange(newContent)
|
||||
updateHighlight()
|
||||
updateLineNumbers()
|
||||
|
||||
// Восстанавливаем позицию курсора после обновления
|
||||
requestAnimationFrame(() => {
|
||||
if (target && selection) {
|
||||
try {
|
||||
const textNode = target.firstChild
|
||||
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||
const range = document.createRange()
|
||||
const safeOffset = Math.min(caretOffset, textNode.textContent?.length || 0)
|
||||
range.setStart(textNode, safeOffset)
|
||||
range.setEnd(textNode, safeOffset)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not restore caret position:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик сохранения
|
||||
*/
|
||||
const handleSave = () => {
|
||||
if (props.onSave) {
|
||||
props.onSave(content())
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик отмены
|
||||
*/
|
||||
const handleCancel = () => {
|
||||
const originalContent = props.content
|
||||
setContent(originalContent) // Возвращаем исходный контент
|
||||
|
||||
// Обновляем содержимое редактируемой области
|
||||
if (editorRef) {
|
||||
editorRef.textContent = originalContent
|
||||
}
|
||||
|
||||
if (props.onCancel) {
|
||||
props.onCancel()
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик клавиш
|
||||
* Обработчик горячих клавиш
|
||||
*/
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ctrl+Enter или Cmd+Enter для сохранения
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
void handleSave()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -224,183 +121,261 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
|
||||
return
|
||||
}
|
||||
|
||||
// Tab для отступа
|
||||
if (e.key === 'Tab') {
|
||||
// Ctrl+Shift+F для форматирования
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'f') {
|
||||
e.preventDefault()
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0)
|
||||
range.deleteContents()
|
||||
range.insertNode(document.createTextNode(' ')) // Два пробела
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
handleFormat()
|
||||
return
|
||||
}
|
||||
|
||||
// Tab для отступов
|
||||
if (handleTabKey(e)) {
|
||||
// Обновляем контент после вставки отступа
|
||||
setTimeout(() => {
|
||||
const _target = e.target as HTMLTextAreaElement
|
||||
handleInput(e)
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирование кода
|
||||
*/
|
||||
const handleFormat = () => {
|
||||
if (!props.autoFormat) return
|
||||
|
||||
const formatted = formatCode(content(), language())
|
||||
if (formatted !== content()) {
|
||||
setContent(formatted)
|
||||
setHasChanges(true)
|
||||
props.onContentChange(formatted)
|
||||
|
||||
// Обновляем textarea
|
||||
if (editorRef) {
|
||||
editorRef.value = formatted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение изменений
|
||||
*/
|
||||
const handleSave = async () => {
|
||||
if (!props.onSave || isSaving()) return
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await props.onSave(content())
|
||||
setHasChanges(false)
|
||||
setIsEditing(false)
|
||||
} catch (error) {
|
||||
console.error('Ошибка при сохранении:', error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отмена изменений
|
||||
*/
|
||||
const handleCancel = () => {
|
||||
const originalContent = props.content
|
||||
setContent(originalContent)
|
||||
setHasChanges(false)
|
||||
|
||||
// Обновляем textarea
|
||||
if (editorRef) {
|
||||
editorRef.value = originalContent
|
||||
}
|
||||
|
||||
if (props.onCancel) {
|
||||
props.onCancel()
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Переход в режим редактирования
|
||||
*/
|
||||
const startEditing = () => {
|
||||
if (props.readOnly) return
|
||||
|
||||
// Форматируем контент при переходе в режим редактирования, если автоформатирование включено
|
||||
if (props.autoFormat) {
|
||||
const formatted = formatCode(content(), language())
|
||||
if (formatted !== content()) {
|
||||
setContent(formatted)
|
||||
props.onContentChange(formatted)
|
||||
}
|
||||
}
|
||||
|
||||
setIsEditing(true)
|
||||
|
||||
// Фокус на editor после рендера
|
||||
setTimeout(() => {
|
||||
if (editorRef) {
|
||||
editorRef.focus()
|
||||
// Устанавливаем курсор в конец
|
||||
editorRef.setSelectionRange(editorRef.value.length, editorRef.value.length)
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
// Эффект для обновления контента при изменении props
|
||||
createEffect(() => {
|
||||
if (!isEditing()) {
|
||||
const formattedContent =
|
||||
language() === 'markup' || language() === 'html' ? formatHtmlContent(props.content) : props.content
|
||||
setContent(formattedContent)
|
||||
updateHighlight()
|
||||
updateLineNumbers()
|
||||
setContent(props.content)
|
||||
setHasChanges(false)
|
||||
}
|
||||
})
|
||||
|
||||
// Эффект для обновления подсветки при изменении контента
|
||||
// Эффект для синхронизации textarea с content
|
||||
createEffect(() => {
|
||||
content() // Реактивность
|
||||
updateHighlight()
|
||||
updateLineNumbers()
|
||||
})
|
||||
|
||||
// Эффект для синхронизации редактируемой области с content
|
||||
createEffect(() => {
|
||||
if (editorRef) {
|
||||
const currentContent = content()
|
||||
if (editorRef.textContent !== currentContent) {
|
||||
// Сохраняем позицию курсора
|
||||
const selection = window.getSelection()
|
||||
let caretOffset = 0
|
||||
|
||||
if (selection && selection.rangeCount > 0 && isEditing()) {
|
||||
const range = selection.getRangeAt(0)
|
||||
const preCaretRange = range.cloneRange()
|
||||
preCaretRange.selectNodeContents(editorRef)
|
||||
preCaretRange.setEnd(range.endContainer, range.endOffset)
|
||||
caretOffset = preCaretRange.toString().length
|
||||
}
|
||||
|
||||
editorRef.textContent = currentContent
|
||||
|
||||
// Восстанавливаем курсор только в режиме редактирования
|
||||
if (isEditing() && selection) {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
const textNode = editorRef?.firstChild
|
||||
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||
const range = document.createRange()
|
||||
const safeOffset = Math.min(caretOffset, textNode.textContent?.length || 0)
|
||||
range.setStart(textNode, safeOffset)
|
||||
range.setEnd(textNode, safeOffset)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not restore caret position:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (editorRef && editorRef.value !== content()) {
|
||||
editorRef.value = content()
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const formattedContent =
|
||||
language() === 'markup' || language() === 'html' ? formatHtmlContent(props.content) : props.content
|
||||
setContent(formattedContent)
|
||||
updateHighlight()
|
||||
updateLineNumbers()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class={styles.editableCodeContainer}>
|
||||
{/* Контейнер редактора - увеличиваем размер */}
|
||||
<div
|
||||
class={`${styles.editorWrapper} ${isEditing() ? styles.editorWrapperEditing : ''}`}
|
||||
style="height: 100%;"
|
||||
>
|
||||
{/* Номера строк */}
|
||||
<div ref={lineNumbersRef} class={styles.lineNumbersContainer} />
|
||||
<div class={`${styles.editableCodeContainer} ${styles[props.theme || 'darkTheme']}`}>
|
||||
{/* Основной контейнер редактора */}
|
||||
<div class={`${styles.editorContainer} ${isEditing() ? styles.editing : ''}`}>
|
||||
{/* Область кода */}
|
||||
<div class={styles.codeArea}>
|
||||
{/* Контейнер для кода со скроллом */}
|
||||
<div class={styles.codeContentWrapper}>
|
||||
{/* Контейнер для самого кода */}
|
||||
<div class={styles.codeTextWrapper}>
|
||||
{/* Нумерация строк внутри скроллящегося контейнера */}
|
||||
<div ref={lineNumbersRef} class={styles.lineNumbers}>
|
||||
{generateLineElements()}
|
||||
</div>
|
||||
{/* Подсветка синтаксиса в режиме редактирования */}
|
||||
<Show when={isEditing()}>
|
||||
<pre
|
||||
ref={highlightRef}
|
||||
class={styles.syntaxHighlight}
|
||||
aria-hidden="true"
|
||||
innerHTML={highlightCode(displayContent(), language())}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* Подсветка синтаксиса (фон) - только в режиме редактирования */}
|
||||
<Show when={isEditing()}>
|
||||
<pre
|
||||
ref={highlightRef}
|
||||
class={`${styles.syntaxHighlight} language-${language()}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* Редактируемая область */}
|
||||
<div
|
||||
ref={(el) => {
|
||||
editorRef = el
|
||||
// Синхронизируем содержимое при создании элемента
|
||||
if (el && el.textContent !== content()) {
|
||||
el.textContent = content()
|
||||
}
|
||||
}}
|
||||
contentEditable={isEditing()}
|
||||
class={`${styles.editorArea} ${isEditing() ? styles.editorAreaEditing : styles.editorAreaViewing}`}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={syncScroll}
|
||||
spellcheck={false}
|
||||
/>
|
||||
|
||||
{/* Превью для неактивного режима */}
|
||||
<Show when={!isEditing()}>
|
||||
<pre
|
||||
class={`${styles.codePreviewContainer} language-${language()}`}
|
||||
onClick={() => setIsEditing(true)}
|
||||
onScroll={(e) => {
|
||||
// Синхронизируем номера строк при скролле в режиме просмотра
|
||||
if (lineNumbersRef) {
|
||||
lineNumbersRef.scrollTop = (e.target as HTMLElement).scrollTop
|
||||
}
|
||||
}}
|
||||
>
|
||||
<code
|
||||
class={`language-${language()}`}
|
||||
innerHTML={(() => {
|
||||
try {
|
||||
return Prism.highlight(content(), Prism.languages[language()], language())
|
||||
} catch {
|
||||
return content()
|
||||
{/* Режим просмотра или редактирования */}
|
||||
<Show
|
||||
when={isEditing()}
|
||||
fallback={
|
||||
<Show
|
||||
when={!isEmpty()}
|
||||
fallback={
|
||||
<div class={styles.placeholderClickable} onClick={startEditing}>
|
||||
{props.placeholder || 'Нажмите для редактирования...'}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<pre
|
||||
class={styles.codePreviewContent}
|
||||
onClick={startEditing}
|
||||
innerHTML={highlightCode(displayContent(), language())}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
})()}
|
||||
/>
|
||||
</pre>
|
||||
</Show>
|
||||
>
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
class={styles.editorTextarea}
|
||||
value={content()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={syncScroll}
|
||||
placeholder={props.placeholder || 'Введите код...'}
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
wrap="off"
|
||||
style={`
|
||||
font-family: ${DEFAULT_EDITOR_CONFIG.fontFamily};
|
||||
font-size: ${DEFAULT_EDITOR_CONFIG.fontSize}px;
|
||||
line-height: ${DEFAULT_EDITOR_CONFIG.lineHeight};
|
||||
tab-size: ${DEFAULT_EDITOR_CONFIG.tabSize};
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
caret-color: var(--code-text);
|
||||
`}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Индикатор языка */}
|
||||
<span class={styles.languageBadge}>{language()}</span>
|
||||
{/* Панель управления */}
|
||||
<div class={styles.controls}>
|
||||
{/* Левая часть - информация */}
|
||||
<div class={styles.controlsLeft}>
|
||||
<span class={styles.languageBadge}>{language()}</span>
|
||||
|
||||
{/* Плейсхолдер */}
|
||||
<Show when={!content()}>
|
||||
<div class={styles.placeholder} onClick={() => setIsEditing(true)}>
|
||||
{props.placeholder || 'Нажмите для редактирования...'}
|
||||
</div>
|
||||
</Show>
|
||||
<div class={styles.statusIndicator}>
|
||||
<div class={`${styles.statusDot} ${styles[status()]}`} />
|
||||
<span>
|
||||
{status() === 'saving' && 'Сохранение...'}
|
||||
{status() === 'editing' && 'Редактирование'}
|
||||
{status() === 'idle' && (hasChanges() ? 'Есть изменения' : 'Сохранено')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Кнопки управления внизу */}
|
||||
<Show when={props.showButtons}>
|
||||
<div class={styles.editorControls}>
|
||||
<Show
|
||||
when={isEditing()}
|
||||
fallback={
|
||||
<button class={styles.editButton} onClick={() => setIsEditing(true)}>
|
||||
✏️ Редактировать
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div class={styles.editingControls}>
|
||||
<button class={styles.saveButton} onClick={handleSave}>
|
||||
💾 Сохранить (Ctrl+Enter)
|
||||
</button>
|
||||
<button class={styles.cancelButton} onClick={handleCancel}>
|
||||
❌ Отмена (Esc)
|
||||
</button>
|
||||
</div>
|
||||
<Show when={hasChanges()}>
|
||||
<span style="color: var(--code-warning); font-size: 11px;">●</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Правая часть - кнопки */}
|
||||
<Show when={props.showButtons !== false}>
|
||||
<div class={styles.controlsRight}>
|
||||
<Show
|
||||
when={!isEditing()}
|
||||
fallback={
|
||||
<div class={`${styles.editingControls} ${styles.fadeIn}`}>
|
||||
<Show when={props.autoFormat}>
|
||||
<button
|
||||
class={styles.formatButton}
|
||||
onClick={handleFormat}
|
||||
disabled={isSaving()}
|
||||
title="Форматировать код (Ctrl+Shift+F)"
|
||||
>
|
||||
🎨 Форматировать
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
class={styles.saveButton}
|
||||
onClick={handleSave}
|
||||
disabled={isSaving() || !hasChanges()}
|
||||
title="Сохранить изменения (Ctrl+Enter)"
|
||||
>
|
||||
{isSaving() ? '⏳ Сохранение...' : '💾 Сохранить'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={styles.cancelButton}
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving()}
|
||||
title="Отменить изменения (Esc)"
|
||||
>
|
||||
❌ Отмена
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={!props.readOnly}>
|
||||
<button class={styles.editButton} onClick={startEditing} title="Редактировать код">
|
||||
✏️ Редактировать
|
||||
</button>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
49
panel/ui/LanguageSwitcher.tsx
Normal file
49
panel/ui/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Component, createSignal } from 'solid-js'
|
||||
import { Language, useI18n } from '../intl/i18n'
|
||||
import styles from '../styles/Button.module.css'
|
||||
|
||||
/**
|
||||
* Компонент переключателя языков
|
||||
*/
|
||||
const LanguageSwitcher: Component = () => {
|
||||
const { setLanguage, isRussian, language } = useI18n()
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
|
||||
/**
|
||||
* Переключает язык между русским и английским
|
||||
*/
|
||||
const toggleLanguage = () => {
|
||||
const currentLang = language()
|
||||
const newLanguage: Language = isRussian() ? 'en' : 'ru'
|
||||
console.log('Переключение языка:', { from: currentLang, to: newLanguage })
|
||||
|
||||
// Показываем индикатор загрузки
|
||||
setIsLoading(true)
|
||||
|
||||
// Небольшая задержка для отображения индикатора
|
||||
setTimeout(() => {
|
||||
setLanguage(newLanguage)
|
||||
// Примечание: страница будет перезагружена, поэтому нет необходимости сбрасывать isLoading
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
class={`${styles.button} ${styles.secondary} ${styles.small} ${styles['language-button']}`}
|
||||
onClick={toggleLanguage}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
toggleLanguage()
|
||||
}
|
||||
}}
|
||||
title={isRussian() ? 'Switch to English' : 'Переключить на русский'}
|
||||
aria-label={isRussian() ? 'Switch to English' : 'Переключить на русский'}
|
||||
disabled={isLoading()}
|
||||
>
|
||||
{isLoading() ? <span class={styles['language-loader']} /> : isRussian() ? 'EN' : 'RU'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default LanguageSwitcher
|
@@ -12,7 +12,7 @@ interface PaginationProps {
|
||||
}
|
||||
|
||||
const Pagination = (props: PaginationProps) => {
|
||||
const perPageOptions = props.perPageOptions || [10, 20, 50, 100]
|
||||
const perPageOptions = props.perPageOptions || [20, 50, 100, 200]
|
||||
|
||||
// Генерируем массив страниц для отображения
|
||||
const pages = () => {
|
||||
|
36
panel/ui/ProtectedRoute.tsx
Normal file
36
panel/ui/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useAuth } from '../context/auth'
|
||||
import { DataProvider } from '../context/data'
|
||||
import { TableSortProvider } from '../context/sort'
|
||||
import AdminPage from '../routes/admin'
|
||||
|
||||
/**
|
||||
* Компонент защищенного маршрута
|
||||
*/
|
||||
export const ProtectedRoute = () => {
|
||||
console.log('[ProtectedRoute] Checking authentication...')
|
||||
const auth = useAuth()
|
||||
const authenticated = auth.isAuthenticated()
|
||||
console.log(
|
||||
`[ProtectedRoute] Authentication state: ${authenticated ? 'authenticated' : 'not authenticated'}`
|
||||
)
|
||||
|
||||
if (!authenticated) {
|
||||
console.log('[ProtectedRoute] Not authenticated, redirecting to login...')
|
||||
// Используем window.location.href для редиректа
|
||||
window.location.href = '/login'
|
||||
return (
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner" />
|
||||
<div>Проверка авторизации...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataProvider>
|
||||
<TableSortProvider>
|
||||
<AdminPage apiUrl={`${location.origin}/graphql`} />
|
||||
</TableSortProvider>
|
||||
</DataProvider>
|
||||
)
|
||||
}
|
413
panel/ui/RoleManager.tsx
Normal file
413
panel/ui/RoleManager.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import { createSignal, For, onMount, Show } from 'solid-js'
|
||||
import { useData } from '../context/data'
|
||||
import {
|
||||
CREATE_CUSTOM_ROLE_MUTATION,
|
||||
DELETE_CUSTOM_ROLE_MUTATION,
|
||||
GET_COMMUNITY_ROLES_QUERY
|
||||
} from '../graphql/queries'
|
||||
import formStyles from '../styles/Form.module.css'
|
||||
import styles from '../styles/RoleManager.module.css'
|
||||
|
||||
interface Role {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface RoleSettings {
|
||||
default_roles: string[]
|
||||
available_roles: string[]
|
||||
}
|
||||
|
||||
interface RoleManagerProps {
|
||||
communityId?: number
|
||||
roleSettings: RoleSettings
|
||||
onRoleSettingsChange: (settings: RoleSettings) => void
|
||||
customRoles: Role[]
|
||||
onCustomRolesChange: (roles: Role[]) => void
|
||||
}
|
||||
|
||||
const STANDARD_ROLES = [
|
||||
{ id: 'reader', name: 'Читатель', description: 'Может читать и комментировать', icon: '👁️' },
|
||||
{ id: 'author', name: 'Автор', description: 'Может создавать публикации', icon: '✍️' },
|
||||
{ id: 'artist', name: 'Художник', description: 'Может быть credited artist', icon: '🎨' },
|
||||
{ id: 'expert', name: 'Эксперт', description: 'Может добавлять доказательства', icon: '🧠' },
|
||||
{ id: 'editor', name: 'Редактор', description: 'Может модерировать контент', icon: '📝' },
|
||||
{ id: 'admin', name: 'Администратор', description: 'Полные права', icon: '👑' }
|
||||
]
|
||||
|
||||
const RoleManager = (props: RoleManagerProps) => {
|
||||
const { queryGraphQL } = useData()
|
||||
const [showAddRole, setShowAddRole] = createSignal(false)
|
||||
const [newRole, setNewRole] = createSignal<Role>({ id: '', name: '', description: '', icon: '🔖' })
|
||||
const [errors, setErrors] = createSignal<Record<string, string>>({})
|
||||
|
||||
// Загружаем роли при монтировании компонента
|
||||
onMount(async () => {
|
||||
if (props.communityId) {
|
||||
try {
|
||||
const rolesData = await queryGraphQL(GET_COMMUNITY_ROLES_QUERY, {
|
||||
community: props.communityId
|
||||
})
|
||||
|
||||
if (rolesData?.adminGetRoles) {
|
||||
const standardRoleIds = STANDARD_ROLES.map((r) => r.id)
|
||||
const customRolesList = rolesData.adminGetRoles
|
||||
.filter((role: Role) => !standardRoleIds.includes(role.id))
|
||||
.map((role: Role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
description: role.description || '',
|
||||
icon: '🔖'
|
||||
}))
|
||||
props.onCustomRolesChange(customRolesList)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки ролей:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const getAllRoles = () => [...STANDARD_ROLES, ...props.customRoles]
|
||||
|
||||
const isRoleDisabled = (roleId: string) => roleId === 'admin'
|
||||
|
||||
const validateNewRole = (): boolean => {
|
||||
const role = newRole()
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!role.id.trim()) {
|
||||
newErrors.newRoleId = 'ID роли обязательно'
|
||||
} else if (!/^[a-z0-9_-]+$/.test(role.id)) {
|
||||
newErrors.newRoleId = 'ID может содержать только латинские буквы, цифры, дефисы и подчеркивания'
|
||||
} else if (getAllRoles().some((r) => r.id === role.id)) {
|
||||
newErrors.newRoleId = 'Роль с таким ID уже существует'
|
||||
}
|
||||
|
||||
if (!role.name.trim()) {
|
||||
newErrors.newRoleName = 'Название роли обязательно'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const addCustomRole = async () => {
|
||||
if (!validateNewRole()) return
|
||||
|
||||
const role = newRole()
|
||||
|
||||
if (props.communityId) {
|
||||
try {
|
||||
const result = await queryGraphQL(CREATE_CUSTOM_ROLE_MUTATION, {
|
||||
role: {
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
icon: role.icon,
|
||||
community_id: props.communityId
|
||||
}
|
||||
})
|
||||
|
||||
if (result?.adminCreateCustomRole?.success) {
|
||||
props.onCustomRolesChange([...props.customRoles, role])
|
||||
|
||||
props.onRoleSettingsChange({
|
||||
...props.roleSettings,
|
||||
available_roles: [...props.roleSettings.available_roles, role.id]
|
||||
})
|
||||
|
||||
resetNewRoleForm()
|
||||
} else {
|
||||
setErrors({ newRoleId: result?.adminCreateCustomRole?.error || 'Ошибка создания роли' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка создания роли:', error)
|
||||
setErrors({ newRoleId: 'Ошибка создания роли' })
|
||||
}
|
||||
} else {
|
||||
props.onCustomRolesChange([...props.customRoles, role])
|
||||
props.onRoleSettingsChange({
|
||||
...props.roleSettings,
|
||||
available_roles: [...props.roleSettings.available_roles, role.id]
|
||||
})
|
||||
resetNewRoleForm()
|
||||
}
|
||||
}
|
||||
|
||||
const removeCustomRole = async (roleId: string) => {
|
||||
if (props.communityId) {
|
||||
try {
|
||||
const result = await queryGraphQL(DELETE_CUSTOM_ROLE_MUTATION, {
|
||||
role_id: roleId,
|
||||
community_id: props.communityId
|
||||
})
|
||||
|
||||
if (result?.adminDeleteCustomRole?.success) {
|
||||
updateRolesAfterRemoval(roleId)
|
||||
} else {
|
||||
console.error('Ошибка удаления роли:', result?.adminDeleteCustomRole?.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления роли:', error)
|
||||
}
|
||||
} else {
|
||||
updateRolesAfterRemoval(roleId)
|
||||
}
|
||||
}
|
||||
|
||||
const updateRolesAfterRemoval = (roleId: string) => {
|
||||
props.onCustomRolesChange(props.customRoles.filter((r) => r.id !== roleId))
|
||||
props.onRoleSettingsChange({
|
||||
available_roles: props.roleSettings.available_roles.filter((r) => r !== roleId),
|
||||
default_roles: props.roleSettings.default_roles.filter((r) => r !== roleId)
|
||||
})
|
||||
}
|
||||
|
||||
const resetNewRoleForm = () => {
|
||||
setNewRole({ id: '', name: '', description: '', icon: '🔖' })
|
||||
setShowAddRole(false)
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
const toggleAvailableRole = (roleId: string) => {
|
||||
if (isRoleDisabled(roleId)) return
|
||||
|
||||
const current = props.roleSettings
|
||||
const newAvailable = current.available_roles.includes(roleId)
|
||||
? current.available_roles.filter((r) => r !== roleId)
|
||||
: [...current.available_roles, roleId]
|
||||
|
||||
const newDefault = newAvailable.includes(roleId)
|
||||
? current.default_roles
|
||||
: current.default_roles.filter((r) => r !== roleId)
|
||||
|
||||
props.onRoleSettingsChange({
|
||||
available_roles: newAvailable,
|
||||
default_roles: newDefault
|
||||
})
|
||||
}
|
||||
|
||||
const toggleDefaultRole = (roleId: string) => {
|
||||
if (isRoleDisabled(roleId)) return
|
||||
|
||||
const current = props.roleSettings
|
||||
const newDefault = current.default_roles.includes(roleId)
|
||||
? current.default_roles.filter((r) => r !== roleId)
|
||||
: [...current.default_roles, roleId]
|
||||
|
||||
props.onRoleSettingsChange({
|
||||
...current,
|
||||
default_roles: newDefault
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={styles.roleManager}>
|
||||
{/* Доступные роли */}
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3 class={styles.sectionTitle}>
|
||||
<span class={styles.icon}>🎭</span>
|
||||
Доступные роли в сообществе
|
||||
</h3>
|
||||
</div>
|
||||
<p class={styles.sectionDescription}>
|
||||
Выберите роли, которые могут быть назначены в этом сообществе
|
||||
</p>
|
||||
|
||||
<div class={styles.rolesGrid}>
|
||||
<For each={getAllRoles()}>
|
||||
{(role) => (
|
||||
<div
|
||||
class={`${styles.roleCard} ${props.roleSettings.available_roles.includes(role.id) ? styles.selected : ''} ${isRoleDisabled(role.id) ? styles.disabled : ''}`}
|
||||
onClick={() => !isRoleDisabled(role.id) && toggleAvailableRole(role.id)}
|
||||
>
|
||||
<div class={styles.roleHeader}>
|
||||
<span class={styles.roleIcon}>{role.icon}</span>
|
||||
<div class={styles.roleActions}>
|
||||
<Show when={props.customRoles.some((r) => r.id === role.id)}>
|
||||
<button
|
||||
type="button"
|
||||
class={styles.removeButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void removeCustomRole(role.id)
|
||||
}}
|
||||
>
|
||||
❌
|
||||
</button>
|
||||
</Show>
|
||||
<div class={styles.checkbox}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.roleSettings.available_roles.includes(role.id)}
|
||||
disabled={isRoleDisabled(role.id)}
|
||||
onChange={() => toggleAvailableRole(role.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class={styles.roleContent}>
|
||||
<div class={styles.roleName}>{role.name}</div>
|
||||
<div class={styles.roleDescription}>{role.description}</div>
|
||||
<Show when={isRoleDisabled(role.id)}>
|
||||
<div class={styles.disabledNote}>Системная роль</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class={styles.addRoleForm}>
|
||||
{/* Форма добавления новой роли */}
|
||||
<Show
|
||||
when={showAddRole()}
|
||||
fallback={
|
||||
<button type="button" class={styles.addButton} onClick={() => setShowAddRole(true)}>
|
||||
<span>➕</span>
|
||||
Добавить роль
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<h4 class={styles.addRoleTitle}>Добавить новую роль</h4>
|
||||
|
||||
<div class={styles.addRoleFields}>
|
||||
<div class={styles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>🆔</span>
|
||||
ID роли
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class={`${formStyles.input} ${errors().newRoleId ? formStyles.error : ''}`}
|
||||
value={newRole().id}
|
||||
onInput={(e) => setNewRole((prev) => ({ ...prev, id: e.currentTarget.value }))}
|
||||
placeholder="my_custom_role"
|
||||
/>
|
||||
<Show when={errors().newRoleId}>
|
||||
<span class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().newRoleId}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={styles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>📝</span>
|
||||
Название
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class={`${formStyles.input} ${errors().newRoleName ? formStyles.error : ''}`}
|
||||
value={newRole().name}
|
||||
onInput={(e) => setNewRole((prev) => ({ ...prev, name: e.currentTarget.value }))}
|
||||
placeholder="Моя роль"
|
||||
/>
|
||||
<Show when={errors().newRoleName}>
|
||||
<span class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().newRoleName}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={styles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>📄</span>
|
||||
Описание
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class={formStyles.input}
|
||||
value={newRole().description}
|
||||
onInput={(e) => setNewRole((prev) => ({ ...prev, description: e.currentTarget.value }))}
|
||||
placeholder="Описание роли"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={styles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>🎭</span>
|
||||
Иконка
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class={formStyles.input}
|
||||
value={newRole().icon}
|
||||
onInput={(e) => setNewRole((prev) => ({ ...prev, icon: e.currentTarget.value }))}
|
||||
placeholder="🔖"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.addRoleActions}>
|
||||
<button type="button" class={styles.cancelButton} onClick={resetNewRoleForm}>
|
||||
Отмена
|
||||
</button>
|
||||
<button type="button" class={styles.primaryButton} onClick={addCustomRole}>
|
||||
Добавить роль
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Дефолтные роли */}
|
||||
<div class={styles.section}>
|
||||
<h3 class={styles.sectionTitle}>
|
||||
<span class={styles.icon}>⭐</span>
|
||||
Дефолтные роли для новых пользователей
|
||||
<span class={styles.required}>*</span>
|
||||
</h3>
|
||||
<p class={styles.sectionDescription}>
|
||||
Роли, которые автоматически назначаются при вступлении в сообщество
|
||||
</p>
|
||||
|
||||
<div class={styles.rolesGrid}>
|
||||
<For each={getAllRoles().filter((role) => props.roleSettings.available_roles.includes(role.id))}>
|
||||
{(role) => (
|
||||
<div
|
||||
class={`${styles.roleCard} ${props.roleSettings.default_roles.includes(role.id) ? styles.selected : ''} ${isRoleDisabled(role.id) ? styles.disabled : ''}`}
|
||||
onClick={() => !isRoleDisabled(role.id) && toggleDefaultRole(role.id)}
|
||||
>
|
||||
<div class={styles.roleHeader}>
|
||||
<span class={styles.roleIcon}>{role.icon}</span>
|
||||
<div class={styles.checkbox}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.roleSettings.default_roles.includes(role.id)}
|
||||
disabled={isRoleDisabled(role.id)}
|
||||
onChange={() => toggleDefaultRole(role.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class={styles.roleContent}>
|
||||
<div class={styles.roleName}>{role.name}</div>
|
||||
<Show when={isRoleDisabled(role.id)}>
|
||||
<div class={styles.disabledNote}>Системная роль</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RoleManager
|
61
panel/ui/SortableHeader.tsx
Normal file
61
panel/ui/SortableHeader.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Component, JSX, Show } from 'solid-js'
|
||||
import { SortField, useTableSort } from '../context/sort'
|
||||
import { useI18n } from '../intl/i18n'
|
||||
import styles from '../styles/Table.module.css'
|
||||
|
||||
/**
|
||||
* Свойства компонента SortableHeader
|
||||
*/
|
||||
interface SortableHeaderProps {
|
||||
field: SortField
|
||||
children: JSX.Element
|
||||
allowedFields?: SortField[]
|
||||
class?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент сортируемого заголовка таблицы
|
||||
* Отображает заголовок с возможностью сортировки при клике
|
||||
*/
|
||||
const SortableHeader: Component<SortableHeaderProps> = (props) => {
|
||||
const { handleSort, getSortIcon, sortState, isFieldAllowed } = useTableSort()
|
||||
const { tr } = useI18n()
|
||||
|
||||
const isActive = () => sortState().field === props.field
|
||||
const isAllowed = () => isFieldAllowed(props.field, props.allowedFields)
|
||||
|
||||
const handleClick = () => {
|
||||
if (isAllowed()) {
|
||||
handleSort(props.field, props.allowedFields)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<th
|
||||
class={`${styles.sortableHeader} ${props.class || ''} ${!isAllowed() ? styles.disabledHeader : ''}`}
|
||||
data-active={isActive()}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && isAllowed()) {
|
||||
e.preventDefault()
|
||||
handleClick()
|
||||
}
|
||||
}}
|
||||
tabindex={isAllowed() ? 0 : -1}
|
||||
data-sort={isActive() ? (sortState().direction === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
style={{
|
||||
cursor: isAllowed() ? 'pointer' : 'not-allowed',
|
||||
opacity: isAllowed() ? 1 : 0.6
|
||||
}}
|
||||
>
|
||||
<span class={styles.headerContent}>
|
||||
{typeof props.children === 'string' ? tr(props.children as string) : props.children}
|
||||
<Show when={isAllowed()}>
|
||||
<span class={styles.sortIcon}>{getSortIcon(props.field)}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
export default SortableHeader
|
58
panel/ui/TableControls.tsx
Normal file
58
panel/ui/TableControls.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { JSX, Show } from 'solid-js'
|
||||
import styles from '../styles/Table.module.css'
|
||||
|
||||
export interface TableControlsProps {
|
||||
onRefresh?: () => void
|
||||
isLoading?: boolean
|
||||
children?: JSX.Element
|
||||
actions?: JSX.Element
|
||||
searchValue?: string
|
||||
onSearchChange?: (value: string) => void
|
||||
onSearch?: () => void
|
||||
searchPlaceholder?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент для унифицированного управления таблицами
|
||||
* Содержит элементы управления сортировкой, фильтрацией и действиями
|
||||
*/
|
||||
const TableControls = (props: TableControlsProps) => {
|
||||
return (
|
||||
<div class={styles.tableControls}>
|
||||
<div class={styles.controlsContainer}>
|
||||
{/* Поиск и действия в одной строке */}
|
||||
<Show when={props.onSearchChange}>
|
||||
<div class={styles.searchContainer}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={props.searchPlaceholder}
|
||||
value={props.searchValue || ''}
|
||||
onInput={(e) => props.onSearchChange?.(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && props.onSearch) {
|
||||
props.onSearch()
|
||||
}
|
||||
}}
|
||||
class={styles.searchInput}
|
||||
/>
|
||||
<Show when={props.onSearch}>
|
||||
<button class={styles.searchButton} onClick={props.onSearch}>
|
||||
Поиск
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Действия справа от поиска */}
|
||||
<Show when={props.actions}>
|
||||
<div class={styles.controlsRight}>{props.actions}</div>
|
||||
</Show>
|
||||
|
||||
{/* Дополнительные элементы управления */}
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TableControls
|
Reference in New Issue
Block a user