0.5.8-panel-upgrade-community-crud-fix
All checks were successful
Deploy on push / deploy (push) Successful in 6s

This commit is contained in:
2025-06-30 21:25:26 +03:00
parent 9de86c0fae
commit 952b294345
70 changed files with 11345 additions and 2655 deletions

35
panel/ui/Button.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { Component, JSX, splitProps } from 'solid-js'
import styles from '../styles/Button.module.css'
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'small' | 'medium' | 'large'
loading?: boolean
fullWidth?: boolean
}
const Button: Component<ButtonProps> = (props) => {
const [local, rest] = splitProps(props, ['variant', 'size', 'loading', 'fullWidth', 'class', 'children'])
const classes = () => {
const baseClass = styles.button
const variantClass = styles[`button-${local.variant || 'primary'}`]
const sizeClass = styles[`button-${local.size || 'medium'}`]
const loadingClass = local.loading ? styles['button-loading'] : ''
const fullWidthClass = local.fullWidth ? styles['button-full-width'] : ''
const customClass = local.class || ''
return [baseClass, variantClass, sizeClass, loadingClass, fullWidthClass, customClass]
.filter(Boolean)
.join(' ')
}
return (
<button {...rest} class={classes()} disabled={props.disabled || local.loading}>
{local.loading && <span class={styles['loading-spinner']} />}
{local.children}
</button>
)
}
export default Button

103
panel/ui/CodePreview.tsx Normal file
View File

@@ -0,0 +1,103 @@
import Prism from 'prismjs'
import { JSX } from 'solid-js'
import 'prismjs/components/prism-json'
import 'prismjs/components/prism-markup'
import 'prismjs/themes/prism-tomorrow.css'
import styles from '../styles/CodePreview.module.css'
/**
* Определяет язык контента (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> {
content: string
language?: string
maxHeight?: string
}
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')
}
return (
<pre
{...props}
class={`${styles.codePreview} ${props.class || ''}`}
style={`max-height: ${props.maxHeight || '500px'}; overflow-y: auto; ${props.style || ''}`}
>
<code
class={`language-${language()} ${styles.code}`}
innerHTML={Prism.highlight(numberedCode(), Prism.languages[language()], language())}
/>
{props.language && <span class={styles.languageBadge}>{props.language}</span>}
</pre>
)
}
export default CodePreview
export { detectLanguage, formatCode }

View File

@@ -0,0 +1,266 @@
import Prism from 'prismjs'
import { createEffect, createSignal, onMount } from 'solid-js'
import 'prismjs/components/prism-json'
import 'prismjs/components/prism-markup'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-css'
import 'prismjs/themes/prism-tomorrow.css'
import styles from '../styles/CodePreview.module.css'
import { detectLanguage } from './CodePreview'
interface EditableCodePreviewProps {
content: string
onContentChange: (newContent: string) => void
onSave?: (content: string) => void
onCancel?: () => void
language?: string
maxHeight?: string
placeholder?: string
showButtons?: boolean
}
/**
* Редактируемый компонент для кода с подсветкой синтаксиса
*/
const EditableCodePreview = (props: EditableCodePreviewProps) => {
const [isEditing, setIsEditing] = createSignal(false)
const [content, setContent] = createSignal(props.content)
let editorRef: HTMLDivElement | undefined
let highlightRef: HTMLPreElement | undefined
const language = () => 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 syncScroll = () => {
if (editorRef && highlightRef) {
highlightRef.scrollTop = editorRef.scrollTop
highlightRef.scrollLeft = editorRef.scrollLeft
}
}
/**
* Обработчик изменения контента
*/
const handleInput = (e: Event) => {
const target = e.target as HTMLDivElement
const newContent = target.textContent || ''
setContent(newContent)
props.onContentChange(newContent)
updateHighlight()
}
/**
* Обработчик сохранения
*/
const handleSave = () => {
if (props.onSave) {
props.onSave(content())
}
setIsEditing(false)
}
/**
* Обработчик отмены
*/
const handleCancel = () => {
setContent(props.content) // Возвращаем исходный контент
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()
return
}
// Escape для отмены
if (e.key === 'Escape') {
e.preventDefault()
handleCancel()
return
}
// Tab для отступа
if (e.key === 'Tab') {
e.preventDefault()
// const target = e.target as HTMLDivElement
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)
handleInput(e)
}
}
}
// Эффект для обновления контента при изменении props
createEffect(() => {
if (!isEditing()) {
setContent(props.content)
updateHighlight()
}
})
// Эффект для обновления подсветки при изменении контента
createEffect(() => {
content() // Реактивность
updateHighlight()
})
onMount(() => {
updateHighlight()
})
return (
<div class={styles.editableCodeContainer}>
{/* Кнопки управления */}
{props.showButtons !== false && (
<div class={styles.editorControls}>
{!isEditing() ? (
<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>
)}
</div>
)}
{/* Контейнер редактора */}
<div
class={styles.editorWrapper}
style={`max-height: ${props.maxHeight || '70vh'}; ${isEditing() ? 'border: 2px solid #007acc;' : ''}`}
>
{/* Подсветка синтаксиса (фон) */}
<pre
ref={highlightRef}
class={`${styles.syntaxHighlight} language-${language()}`}
style="position: absolute; top: 0; left: 0; pointer-events: none; color: transparent; background: transparent; margin: 0; padding: 12px; font-family: 'Fira Code', monospace; font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; overflow: hidden;"
aria-hidden="true"
/>
{/* Редактируемая область */}
<div
ref={editorRef}
contentEditable={isEditing()}
class={styles.editorArea}
style={`
position: relative;
z-index: 1;
background: ${isEditing() ? 'rgba(0, 0, 0, 0.05)' : 'transparent'};
color: ${isEditing() ? 'rgba(255, 255, 255, 0.9)' : 'transparent'};
margin: 0;
padding: 12px;
font-family: 'Fira Code', monospace;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
overflow-y: auto;
outline: none;
cursor: ${isEditing() ? 'text' : 'default'};
caret-color: ${isEditing() ? '#fff' : 'transparent'};
`}
onInput={handleInput}
onKeyDown={handleKeyDown}
onScroll={syncScroll}
spellcheck={false}
>
{content()}
</div>
{/* Превью для неактивного режима */}
{!isEditing() && (
<pre
class={`${styles.codePreview} language-${language()}`}
style={`
position: absolute;
top: 0;
left: 0;
margin: 0;
padding: 12px;
font-family: 'Fira Code', monospace;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
background: transparent;
cursor: pointer;
`}
onClick={() => setIsEditing(true)}
>
<code
class={`language-${language()}`}
innerHTML={(() => {
try {
return Prism.highlight(content(), Prism.languages[language()], language())
} catch {
return content()
}
})()}
/>
</pre>
)}
</div>
{/* Плейсхолдер */}
{!content() && (
<div
class={styles.placeholder}
onClick={() => setIsEditing(true)}
style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #666; cursor: pointer; font-style: italic;"
>
{props.placeholder || 'Нажмите для редактирования...'}
</div>
)}
{/* Индикатор языка */}
<span class={styles.languageBadge}>{language()}</span>
</div>
)
}
export default EditableCodePreview

48
panel/ui/Modal.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { Component, JSX, Show } from 'solid-js'
import styles from '../styles/Modal.module.css'
export interface ModalProps {
title: string
isOpen: boolean
onClose: () => void
children: JSX.Element
footer?: JSX.Element
size?: 'small' | 'medium' | 'large'
}
const Modal: Component<ModalProps> = (props) => {
const handleBackdropClick = (e: MouseEvent) => {
if (e.target === e.currentTarget) {
props.onClose()
}
}
const modalClasses = () => {
const baseClass = styles.modal
const sizeClass = styles[`modal-${props.size || 'medium'}`]
return [baseClass, sizeClass].join(' ')
}
return (
<Show when={props.isOpen}>
<div class={styles.backdrop} onClick={handleBackdropClick}>
<div class={modalClasses()}>
<div class={styles.header}>
<h2 class={styles.title}>{props.title}</h2>
<button class={styles.close} onClick={props.onClose}>
×
</button>
</div>
<div class={styles.content}>{props.children}</div>
<Show when={props.footer}>
<div class={styles.footer}>{props.footer}</div>
</Show>
</div>
</div>
</Show>
)
}
export default Modal

117
panel/ui/Pagination.tsx Normal file
View File

@@ -0,0 +1,117 @@
import { For } from 'solid-js'
import styles from '../styles/Pagination.module.css'
interface PaginationProps {
currentPage: number
totalPages: number
total: number
limit: number
onPageChange: (page: number) => void
onPerPageChange?: (limit: number) => void
perPageOptions?: number[]
}
const Pagination = (props: PaginationProps) => {
const perPageOptions = props.perPageOptions || [10, 20, 50, 100]
// Генерируем массив страниц для отображения
const pages = () => {
const result: (number | string)[] = []
const maxVisiblePages = 5 // Максимальное количество видимых страниц
// Всегда показываем первую страницу
result.push(1)
// Вычисляем диапазон страниц вокруг текущей
let startPage = Math.max(2, props.currentPage - Math.floor(maxVisiblePages / 2))
const endPage = Math.min(props.totalPages - 1, startPage + maxVisiblePages - 2)
// Корректируем диапазон, если он выходит за границы
if (endPage - startPage < maxVisiblePages - 2) {
startPage = Math.max(2, endPage - maxVisiblePages + 2)
}
// Добавляем многоточие после первой страницы, если нужно
if (startPage > 2) {
result.push('...')
}
// Добавляем страницы из диапазона
for (let i = startPage; i <= endPage; i++) {
result.push(i)
}
// Добавляем многоточие перед последней страницей, если нужно
if (endPage < props.totalPages - 1) {
result.push('...')
}
// Всегда показываем последнюю страницу, если есть больше одной страницы
if (props.totalPages > 1) {
result.push(props.totalPages)
}
return result
}
const startIndex = () => (props.currentPage - 1) * props.limit + 1
const endIndex = () => Math.min(props.currentPage * props.limit, props.total)
return (
<div class={styles.pagination}>
<div class={styles['pagination-info']}>
Показано {startIndex()} - {endIndex()} из {props.total}
</div>
<div class={styles['pagination-controls']}>
<button
class={styles.pageButton}
onClick={() => props.onPageChange(props.currentPage - 1)}
disabled={props.currentPage === 1}
>
</button>
<For each={pages()}>
{(page) => (
<>
{page === '...' ? (
<span class={styles['pagination-ellipsis']}>...</span>
) : (
<button
class={`${styles.pageButton} ${page === props.currentPage ? styles.currentPage : ''}`}
onClick={() => props.onPageChange(Number(page))}
>
{page}
</button>
)}
</>
)}
</For>
<button
class={styles.pageButton}
onClick={() => props.onPageChange(props.currentPage + 1)}
disabled={props.currentPage === props.totalPages}
>
</button>
</div>
{props.onPerPageChange && (
<div class={styles['pagination-per-page']}>
На странице:
<select
class={styles.perPageSelect}
value={props.limit}
onChange={(e) => props.onPerPageChange!(Number(e.target.value))}
>
<For each={perPageOptions}>{(option) => <option value={option}>{option}</option>}</For>
</select>
</div>
)}
</div>
)
}
export default Pagination

64
panel/ui/TextPreview.tsx Normal file
View File

@@ -0,0 +1,64 @@
import { JSX } from 'solid-js'
import styles from '../styles/CodePreview.module.css'
/**
* Компонент для простого просмотра текста без подсветки syntax
* Убирает HTML теги и показывает чистый текст
*/
interface TextPreviewProps extends JSX.HTMLAttributes<HTMLPreElement> {
content: string
maxHeight?: string
showLineNumbers?: boolean
}
/**
* Убирает HTML теги и декодирует HTML entity
*/
function stripHtmlTags(text: string): string {
// Убираем HTML теги
let cleaned = text.replace(/<[^>]*>/g, '')
// Декодируем базовые HTML entity
cleaned = cleaned
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#x27;/g, "'")
.replace(/&nbsp;/g, ' ')
return cleaned.trim()
}
const TextPreview = (props: TextPreviewProps) => {
const cleanedContent = () => stripHtmlTags(props.content)
const contentWithLines = () => {
if (!props.showLineNumbers) return cleanedContent()
const lines = cleanedContent().split('\n')
return lines.map((line, index) => `${(index + 1).toString().padStart(3, ' ')} | ${line}`).join('\n')
}
return (
<pre
{...props}
class={`${styles.codePreview} ${props.class || ''}`}
style={`
max-height: ${props.maxHeight || '60vh'};
overflow-y: auto;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
${props.style || ''}
`}
>
<code class={styles.code}>{contentWithLines()}</code>
</pre>
)
}
export default TextPreview