Squashed new RBAC
All checks were successful
Deploy on push / deploy (push) Successful in 7s

This commit is contained in:
2025-07-02 22:30:21 +03:00
parent 7585dae0ab
commit 82111ed0f6
100 changed files with 14785 additions and 5888 deletions

View File

@@ -1,54 +1,12 @@
import { Route, Router } from '@solidjs/router'
import { lazy, onMount, Suspense } from 'solid-js'
import { AuthProvider, useAuth } from './context/auth'
// Ленивая загрузка компонентов
const AdminPage = lazy(() => {
console.log('[App] Loading AdminPage component...')
return import('./admin')
})
const LoginPage = lazy(() => {
console.log('[App] Loading LoginPage component...')
return import('./routes/login')
})
/**
* Компонент защищенного маршрута
*/
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 (
<Suspense
fallback={
<div class="loading-screen">
<div class="loading-spinner" />
<div>Загрузка админ-панели...</div>
</div>
}
>
<AdminPage apiUrl={`${location.origin}/graphql`} />
</Suspense>
)
}
import { lazy, onMount } from 'solid-js'
import { AuthProvider } from './context/auth'
import { I18nProvider } from './intl/i18n'
import LoginPage from './routes/login'
const ProtectedRoute = lazy(() =>
import('./ui/ProtectedRoute').then((module) => ({ default: module.ProtectedRoute }))
)
/**
* Корневой компонент приложения
*/
@@ -60,30 +18,18 @@ const App = () => {
})
return (
<AuthProvider>
<div class="app-container">
<Router>
<Route
path="/login"
component={() => (
<Suspense
fallback={
<div class="loading-screen">
<div class="loading-spinner" />
<div>Загрузка страницы входа...</div>
</div>
}
>
<LoginPage />
</Suspense>
)}
/>
<Route path="/" component={ProtectedRoute} />
<Route path="/admin" component={ProtectedRoute} />
<Route path="/admin/:tab" component={ProtectedRoute} />
</Router>
</div>
</AuthProvider>
<I18nProvider>
<AuthProvider>
<div class="app-container">
<Router>
<Route path="/login" component={LoginPage} />
<Route path="/" component={ProtectedRoute} />
<Route path="/admin" component={ProtectedRoute} />
<Route path="/admin/:tab" component={ProtectedRoute} />
</Router>
</div>
</AuthProvider>
</I18nProvider>
)
}

View File

@@ -71,11 +71,12 @@ export const AuthProvider: Component<AuthProviderProps> = (props) => {
const login = async (username: string, password: string) => {
console.log('[AuthProvider] Attempting login...')
try {
const result = await query<{ login: { success: boolean; token?: string } }>(
`${location.origin}/graphql`,
ADMIN_LOGIN_MUTATION,
{ email: username, password }
)
const result = await query<{
login: { success: boolean; token?: string }
}>(`${location.origin}/graphql`, ADMIN_LOGIN_MUTATION, {
email: username,
password
})
if (result?.login?.success) {
console.log('[AuthProvider] Login successful')
@@ -97,22 +98,29 @@ export const AuthProvider: Component<AuthProviderProps> = (props) => {
const logout = async () => {
console.log('[AuthProvider] Attempting logout...')
try {
const result = await query<{ logout: { success: boolean } }>(
// Сначала очищаем токены на клиенте
clearAuthTokens()
setIsAuthenticated(false)
// Затем делаем запрос на сервер
const result = await query<{ logout: { success: boolean; message?: string } }>(
`${location.origin}/graphql`,
ADMIN_LOGOUT_MUTATION
)
console.log('[AuthProvider] Logout response:', result)
if (result?.logout?.success) {
console.log('[AuthProvider] Logout successful')
clearAuthTokens()
setIsAuthenticated(false)
console.log('[AuthProvider] Logout successful:', result.logout.message)
window.location.href = '/login'
} else {
console.warn('[AuthProvider] Logout was not successful:', result?.logout?.message)
// Все равно редиректим на страницу входа
window.location.href = '/login'
}
} catch (error) {
console.error('[AuthProvider] Logout error:', error)
// Даже при ошибке очищаем токены и редиректим
clearAuthTokens()
setIsAuthenticated(false)
// При любой ошибке редиректим на страницу входа
window.location.href = '/login'
}
}

390
panel/context/data.tsx Normal file
View File

@@ -0,0 +1,390 @@
import { createContext, createEffect, createSignal, JSX, onMount, useContext } from 'solid-js'
import {
ADMIN_GET_ROLES_QUERY,
GET_COMMUNITIES_QUERY,
GET_TOPICS_BY_COMMUNITY_QUERY,
GET_TOPICS_QUERY
} from '../graphql/queries'
export interface Community {
id: number
name: string
slug: string
desc?: string
pic?: string
}
export interface Topic {
id: number
slug: string
title: string
body?: string
pic?: string
community: number
parent_ids?: number[]
}
export interface Role {
id: string
name: string
description?: string
}
interface DataContextType {
// Сообщества
communities: () => Community[]
getCommunityById: (id: number) => Community | undefined
getCommunityName: (id: number) => string
selectedCommunity: () => number | null
setSelectedCommunity: (id: number | null) => void
// Топики
topics: () => Topic[]
allTopics: () => Topic[]
getTopicById: (id: number) => Topic | undefined
getTopicTitle: (id: number) => string
loadTopicsByCommunity: (communityId: number) => Promise<Topic[]>
// Роли
roles: () => Role[]
getRoleById: (id: string) => Role | undefined
getRoleName: (id: string) => string
// Общие методы
isLoading: () => boolean
loadData: () => Promise<void>
// biome-ignore lint/suspicious/noExplicitAny: grahphql
queryGraphQL: (query: string, variables?: Record<string, any>) => Promise<any>
}
const DataContext = createContext<DataContextType>({
// Сообщества
communities: () => [],
getCommunityById: () => undefined,
getCommunityName: () => '',
selectedCommunity: () => null,
setSelectedCommunity: () => {},
// Топики
topics: () => [],
allTopics: () => [],
getTopicById: () => undefined,
getTopicTitle: () => '',
loadTopicsByCommunity: async () => [],
// Роли
roles: () => [],
getRoleById: () => undefined,
getRoleName: () => '',
// Общие методы
isLoading: () => false,
loadData: async () => {},
queryGraphQL: async () => {}
})
/**
* Ключ для сохранения выбранного сообщества в localStorage
*/
const COMMUNITY_STORAGE_KEY = 'admin-selected-community'
export function DataProvider(props: { children: JSX.Element }) {
const [communities, setCommunities] = createSignal<Community[]>([])
const [topics, setTopics] = createSignal<Topic[]>([])
const [allTopics, setAllTopics] = createSignal<Topic[]>([])
const [roles, setRoles] = createSignal<Role[]>([])
// Инициализация выбранного сообщества из localStorage
const initialCommunity = (() => {
try {
const stored = localStorage.getItem(COMMUNITY_STORAGE_KEY)
if (stored) {
const communityId = Number.parseInt(stored, 10)
return Number.isNaN(communityId) ? 1 : communityId
}
} catch (e) {
console.warn('[DataProvider] Ошибка при чтении сообщества из localStorage:', e)
}
return 1 // По умолчанию выбираем сообщество с ID 1 (Дискурс)
})()
const [selectedCommunity, setSelectedCommunity] = createSignal<number | null>(initialCommunity)
const [isLoading, setIsLoading] = createSignal(false)
// Сохранение выбранного сообщества в localStorage
const updateSelectedCommunity = (id: number | null) => {
try {
if (id !== null) {
localStorage.setItem(COMMUNITY_STORAGE_KEY, id.toString())
console.log('[DataProvider] Сохранено сообщество в localStorage:', id)
} else {
localStorage.removeItem(COMMUNITY_STORAGE_KEY)
console.log('[DataProvider] Удалено сохраненное сообщество из localStorage')
}
setSelectedCommunity(id)
} catch (e) {
console.error('[DataProvider] Ошибка при сохранении сообщества в localStorage:', e)
setSelectedCommunity(id) // Всё равно обновляем состояние
}
}
// Эффект для загрузки ролей при изменении сообщества
createEffect(() => {
const community = selectedCommunity()
if (community !== null) {
console.log('[DataProvider] Загрузка ролей для сообщества:', community)
loadRoles(community).catch((err) => {
console.warn('Не удалось загрузить роли для сообщества:', err)
})
}
})
// Загрузка данных при монтировании
onMount(() => {
console.log('[DataProvider] Инициализация с сообществом:', initialCommunity)
loadData().catch((err) => {
console.error('Ошибка при начальной загрузке данных:', err)
})
})
// Загрузка сообществ
const loadCommunities = async () => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: GET_COMMUNITIES_QUERY
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
const communitiesData = result.data.get_communities_all || []
setCommunities(communitiesData)
return communitiesData
} catch (error) {
console.error('Ошибка загрузки сообществ:', error)
return []
}
}
// Загрузка всех топиков
const loadTopics = async () => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: GET_TOPICS_QUERY
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
const topicsData = result.data.get_topics_all || []
setTopics(topicsData)
return topicsData
} catch (error) {
console.error('Ошибка загрузки топиков:', error)
return []
}
}
// Загрузка всех топиков сообщества
const loadTopicsByCommunity = async (communityId: number) => {
try {
setIsLoading(true)
// Загружаем все топики сообщества сразу с лимитом 800
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: GET_TOPICS_BY_COMMUNITY_QUERY,
variables: {
community_id: communityId,
limit: 800,
offset: 0
}
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
const allTopicsData = result.data.get_topics_by_community || []
// Сохраняем все данные сразу для отображения
setTopics(allTopicsData)
setAllTopics(allTopicsData)
return allTopicsData
} catch (error) {
console.error('Ошибка загрузки топиков по сообществу:', error)
return []
} finally {
setIsLoading(false)
}
}
// Загрузка ролей для конкретного сообщества
const loadRoles = async (communityId?: number) => {
try {
console.log(
'[DataProvider] Загружаем роли...',
communityId ? `для сообщества ${communityId}` : 'все роли'
)
const variables = communityId ? { community: communityId } : {}
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: ADMIN_GET_ROLES_QUERY,
variables
})
})
const result = await response.json()
console.log('[DataProvider] Ответ от сервера для ролей:', result)
if (result.errors) {
console.warn('Не удалось загрузить роли (возможно не авторизован):', result.errors[0].message)
setRoles([])
return []
}
const rolesData = result.data.adminGetRoles || []
console.log('[DataProvider] Роли успешно загружены:', rolesData)
setRoles(rolesData)
return rolesData
} catch (error) {
console.warn('Ошибка загрузки ролей:', error)
setRoles([])
return []
}
}
// Загрузка всех данных
const loadData = async () => {
setIsLoading(true)
try {
// Загружаем все данные сразу (вызывается только для авторизованных пользователей)
// Роли загружаем в фоне - их отсутствие не должно блокировать интерфейс
await Promise.all([
loadCommunities(),
loadTopics(),
loadRoles(selectedCommunity() || undefined).catch((err) => {
console.warn('Роли недоступны (возможно не хватает прав):', err)
return []
})
])
// selectedCommunity теперь всегда инициализировано со значением 1,
// поэтому дополнительная проверка не нужна
} catch (error) {
console.error('Ошибка загрузки данных:', error)
} finally {
setIsLoading(false)
}
}
// Методы для работы с сообществами
const getCommunityById = (id: number): Community | undefined => {
return communities().find((community) => community.id === id)
}
const getCommunityName = (id: number): string => getCommunityById(id)?.name || ''
const getTopicTitle = (id: number): string => getTopicById(id)?.title || ''
// Методы для работы с топиками
const getTopicById = (id: number): Topic | undefined => {
return topics().find((topic) => topic.id === id)
}
// Методы для работы с ролями
const getRoleById = (id: string): Role | undefined => {
return roles().find((role) => role.id === id)
}
const getRoleName = (id: string): string => {
const role = getRoleById(id)
return role ? role.name : id
}
const value = {
// Сообщества
communities,
getCommunityById,
getCommunityName,
selectedCommunity,
setSelectedCommunity: updateSelectedCommunity,
// Топики
topics,
allTopics,
getTopicById,
getTopicTitle,
loadTopicsByCommunity,
// Роли
roles,
getRoleById,
getRoleName,
// Общие методы
isLoading,
loadData,
// biome-ignore lint/suspicious/noExplicitAny: grahphql
queryGraphQL: async (query: string, variables?: Record<string, any>) => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query,
variables
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
return result.data
} catch (error) {
console.error('Ошибка выполнения GraphQL запроса:', error)
return null
}
}
}
return <DataContext.Provider value={value}>{props.children}</DataContext.Provider>
}
export const useData = () => useContext(DataContext)

150
panel/context/sort.tsx Normal file
View File

@@ -0,0 +1,150 @@
import { createContext, createSignal, ParentComponent, useContext } from 'solid-js'
/**
* Типы полей сортировки для разных вкладок
*/
export type AuthorsSortField = 'id' | 'email' | 'name' | 'created_at' | 'last_seen'
export type ShoutsSortField = 'id' | 'title' | 'slug' | 'created_at' | 'published_at' | 'updated_at'
export type TopicsSortField =
| 'id'
| 'title'
| 'slug'
| 'created_at'
| 'authors'
| 'shouts'
| 'followers'
| 'authors'
export type CommunitiesSortField =
| 'id'
| 'name'
| 'slug'
| 'created_at'
| 'created_by'
| 'shouts'
| 'followers'
| 'authors'
export type CollectionsSortField = 'id' | 'title' | 'slug' | 'created_at' | 'published_at'
export type InvitesSortField = 'inviter_name' | 'author_name' | 'shout_title' | 'status'
/**
* Общий тип для всех полей сортировки
*/
export type SortField =
| AuthorsSortField
| ShoutsSortField
| TopicsSortField
| CommunitiesSortField
| CollectionsSortField
| InvitesSortField
/**
* Направление сортировки
*/
export type SortDirection = 'asc' | 'desc'
/**
* Состояние сортировки
*/
export interface SortState {
field: SortField
direction: SortDirection
}
/**
* Конфигурация сортировки для разных вкладок
*/
export interface TabSortConfig {
allowedFields: SortField[]
defaultField: SortField
defaultDirection: SortDirection
}
/**
* Контекст для управления сортировкой таблиц
*/
interface TableSortContextType {
sortState: () => SortState
setSortState: (state: SortState) => void
handleSort: (field: SortField, allowedFields?: SortField[]) => void
getSortIcon: (field: SortField) => string
isFieldAllowed: (field: SortField, allowedFields?: SortField[]) => boolean
}
/**
* Создаем контекст
*/
const TableSortContext = createContext<TableSortContextType>()
/**
* Провайдер контекста сортировки
*/
export const TableSortProvider: ParentComponent = (props) => {
// Состояние сортировки - по умолчанию сортировка по ID по возрастанию
const [sortState, setSortState] = createSignal<SortState>({
field: 'id',
direction: 'asc'
})
/**
* Проверяет, разрешено ли поле для сортировки
*/
const isFieldAllowed = (field: SortField, allowedFields?: SortField[]) => {
if (!allowedFields) return true
return allowedFields.includes(field)
}
/**
* Обработчик клика по заголовку колонки для сортировки
*/
const handleSort = (field: SortField, allowedFields?: SortField[]) => {
// Проверяем, разрешено ли поле для сортировки
if (!isFieldAllowed(field, allowedFields)) {
console.warn(`Поле ${field} не разрешено для сортировки`)
return
}
const current = sortState()
let newDirection: SortDirection = 'asc'
if (current.field === field) {
// Если кликнули по той же колонке, меняем направление
newDirection = current.direction === 'asc' ? 'desc' : 'asc'
}
const newState = { field, direction: newDirection }
console.log('Изменение сортировки:', { from: current, to: newState })
setSortState(newState)
}
/**
* Получает иконку сортировки для колонки
*/
const getSortIcon = (field: SortField) => {
const current = sortState()
if (current.field !== field) {
return '⇅' // Неактивная сортировка
}
return current.direction === 'asc' ? '▲' : '▼'
}
const contextValue: TableSortContextType = {
sortState,
setSortState,
handleSort,
getSortIcon,
isFieldAllowed
}
return <TableSortContext.Provider value={contextValue}>{props.children}</TableSortContext.Provider>
}
/**
* Хук для использования контекста сортировки
*/
export const useTableSort = () => {
const context = useContext(TableSortContext)
if (!context) {
throw new Error('useTableSort должен использоваться внутри TableSortProvider')
}
return context
}

142
panel/context/sortConfig.ts Normal file
View File

@@ -0,0 +1,142 @@
import type {
AuthorsSortField,
CollectionsSortField,
CommunitiesSortField,
InvitesSortField,
ShoutsSortField,
TabSortConfig,
TopicsSortField
} from './sort'
/**
* Конфигурации сортировки для разных вкладок админ-панели
* Основаны на том, что реально поддерживают резолверы в бэкенде
*/
/**
* Конфигурация сортировки для вкладки "Авторы"
* Основана на резолвере admin_get_users в resolvers/admin.py
*/
export const AUTHORS_SORT_CONFIG: TabSortConfig = {
allowedFields: ['id', 'email', 'name', 'created_at', 'last_seen'] as AuthorsSortField[],
defaultField: 'id' as AuthorsSortField,
defaultDirection: 'asc'
}
/**
* Конфигурация сортировки для вкладки "Публикации"
* Основана на резолвере admin_get_shouts в resolvers/admin.py
*/
export const SHOUTS_SORT_CONFIG: TabSortConfig = {
allowedFields: ['id', 'title', 'slug', 'created_at', 'published_at', 'updated_at'] as ShoutsSortField[],
defaultField: 'id' as ShoutsSortField,
defaultDirection: 'desc' // Новые публикации сначала
}
/**
* Конфигурация сортировки для вкладки "Темы"
* Основана на резолвере get_topics_with_stats в resolvers/topic.py
*/
export const TOPICS_SORT_CONFIG: TabSortConfig = {
allowedFields: [
'id',
'title',
'slug',
'created_at',
'authors',
'shouts',
'followers'
] as TopicsSortField[],
defaultField: 'id' as TopicsSortField,
defaultDirection: 'asc'
}
/**
* Конфигурация сортировки для вкладки "Сообщества"
* Основана на резолвере get_communities_all в resolvers/community.py
*/
export const COMMUNITIES_SORT_CONFIG: TabSortConfig = {
allowedFields: [
'id',
'name',
'slug',
'created_at',
'created_by',
'shouts',
'followers',
'authors'
] as CommunitiesSortField[],
defaultField: 'id' as CommunitiesSortField,
defaultDirection: 'asc'
}
/**
* Конфигурация сортировки для вкладки "Коллекции"
* Основана на резолвере get_collections_all в resolvers/collection.py
*/
export const COLLECTIONS_SORT_CONFIG: TabSortConfig = {
allowedFields: ['id', 'title', 'slug', 'created_at', 'published_at'] as CollectionsSortField[],
defaultField: 'id' as CollectionsSortField,
defaultDirection: 'asc'
}
/**
* Конфигурация сортировки для вкладки "Приглашения"
* Основана на резолвере admin_get_invites в resolvers/admin.py
*/
export const INVITES_SORT_CONFIG: TabSortConfig = {
allowedFields: ['inviter_name', 'author_name', 'shout_title', 'status'] as InvitesSortField[],
defaultField: 'inviter_name' as InvitesSortField,
defaultDirection: 'asc'
}
/**
* Получает конфигурацию сортировки для указанной вкладки
*/
export const getSortConfigForTab = (tab: string): TabSortConfig => {
switch (tab) {
case 'authors':
return AUTHORS_SORT_CONFIG
case 'shouts':
return SHOUTS_SORT_CONFIG
case 'topics':
return TOPICS_SORT_CONFIG
case 'communities':
return COMMUNITIES_SORT_CONFIG
case 'collections':
return COLLECTIONS_SORT_CONFIG
case 'invites':
return INVITES_SORT_CONFIG
default:
// По умолчанию возвращаем конфигурацию авторов
return AUTHORS_SORT_CONFIG
}
}
/**
* Переводы названий полей для отображения пользователю
*/
export const FIELD_LABELS: Record<string, string> = {
// Общие поля
id: 'ID',
title: 'Название',
name: 'Имя',
slug: 'Slug',
created_at: 'Создано',
updated_at: 'Обновлено',
published_at: 'Опубликовано',
created_by: 'Создатель',
shouts: 'Публикации',
followers: 'Подписчики',
authors: 'Авторы',
// Поля авторов
email: 'Email',
last_seen: 'Последний вход',
// Поля приглашений
inviter_name: 'Приглашающий',
author_name: 'Приглашаемый',
shout_title: 'Публикация',
status: 'Статус'
}

View File

@@ -3,6 +3,14 @@ export const ADMIN_LOGIN_MUTATION = `
login(email: $email, password: $password) {
success
token
author {
id
name
email
slug
roles
}
error
}
}
`
@@ -11,6 +19,7 @@ export const ADMIN_LOGOUT_MUTATION = `
mutation AdminLogout {
logout {
success
message
}
}
`

View File

@@ -3,8 +3,8 @@ import { gql } from 'graphql-tag'
// Определяем GraphQL запрос
export const ADMIN_GET_SHOUTS_QUERY: string =
gql`
query AdminGetShouts($limit: Int, $offset: Int, $search: String, $status: String) {
adminGetShouts(limit: $limit, offset: $offset, search: $search, status: $status) {
query AdminGetShouts($limit: Int, $offset: Int, $search: String, $status: String, $community: Int) {
adminGetShouts(limit: $limit, offset: $offset, search: $search, status: $status, community: $community) {
shouts {
id
title
@@ -103,8 +103,8 @@ export const ADMIN_GET_USERS_QUERY: string =
export const ADMIN_GET_ROLES_QUERY: string =
gql`
query AdminGetRoles {
adminGetRoles {
query AdminGetRoles($community: Int) {
adminGetRoles(community: $community) {
id
name
description
@@ -177,6 +177,22 @@ export const GET_TOPICS_QUERY: string =
}
`.loc?.source.body || ''
export const GET_TOPICS_BY_COMMUNITY_QUERY: string =
gql`
query GetTopicsByCommunity($community_id: Int!, $limit: Int, $offset: Int) {
get_topics_by_community(community_id: $community_id, limit: $limit, offset: $offset) {
id
slug
title
body
pic
community
parent_ids
oid
}
}
`.loc?.source.body || ''
export const GET_COLLECTIONS_QUERY: string =
gql`
query GetCollections {
@@ -240,3 +256,65 @@ export const ADMIN_GET_INVITES_QUERY: string =
}
}
`.loc?.source.body || ''
// Запросы для работы с ролями сообществ
export const GET_COMMUNITY_ROLE_SETTINGS_QUERY: string =
gql`
query GetCommunityRoleSettings($community_id: Int!) {
adminGetCommunityRoleSettings(community_id: $community_id) {
default_roles
available_roles
error
}
}
`.loc?.source.body || ''
export const GET_COMMUNITY_ROLES_QUERY: string =
gql`
query GetCommunityRoles($community: Int) {
adminGetRoles(community: $community) {
id
name
description
}
}
`.loc?.source.body || ''
export const UPDATE_COMMUNITY_ROLE_SETTINGS_MUTATION: string =
gql`
mutation UpdateCommunityRoleSettings($community_id: Int!, $default_roles: [String!]!, $available_roles: [String!]!) {
adminUpdateCommunityRoleSettings(
community_id: $community_id,
default_roles: $default_roles,
available_roles: $available_roles
) {
success
error
}
}
`.loc?.source.body || ''
export const CREATE_CUSTOM_ROLE_MUTATION: string =
gql`
mutation CreateCustomRole($role: CustomRoleInput!) {
adminCreateCustomRole(role: $role) {
success
error
role {
id
name
description
}
}
}
`.loc?.source.body || ''
export const DELETE_CUSTOM_ROLE_MUTATION: string =
gql`
mutation DeleteCustomRole($role_id: String!, $community_id: Int!) {
adminDeleteCustomRole(role_id: $role_id, community_id: $community_id) {
success
error
}
}
`.loc?.source.body || ''

6
panel/graphql/types.ts Normal file
View File

@@ -0,0 +1,6 @@
export interface GraphQLContext {
token?: string
userId?: number
roles?: string[]
communityId?: number
}

325
panel/intl/i18n.tsx Normal file
View File

@@ -0,0 +1,325 @@
import {
createContext,
createEffect,
createSignal,
JSX,
onCleanup,
onMount,
ParentComponent,
useContext
} from 'solid-js'
import strings from './strings.json'
/**
* Тип для поддерживаемых языков
*/
export type Language = 'ru' | 'en'
/**
* Ключ для сохранения языка в localStorage
*/
const STORAGE_KEY = 'admin-language'
/**
* Регекс для детекции кириллических символов
*/
const CYRILLIC_REGEX = /[\u0400-\u04FF]/
/**
* Контекст интернационализации
*/
interface I18nContextType {
language: () => Language
setLanguage: (lang: Language) => void
t: (key: string) => string
tr: (text: string) => string
isRussian: () => boolean
}
/**
* Создаем контекст
*/
const I18nContext = createContext<I18nContextType>()
/**
* Функция для перевода строки
*/
const translateString = (text: string, language: Language): string => {
// Если язык русский или строка не содержит кириллицу, возвращаем как есть
if (language === 'ru' || !CYRILLIC_REGEX.test(text)) {
return text
}
// Ищем перевод в словаре
const translation = strings[text as keyof typeof strings]
return translation || text
}
/**
* Автоматический переводчик элементов
* Перехватывает создание JSX элементов и автоматически делает кириллические строки реактивными
*/
const AutoTranslator = (props: { children: JSX.Element; language: () => Language }) => {
let containerRef: HTMLDivElement | undefined
let observer: MutationObserver | undefined
// Кэш для переведенных элементов
const translationCache = new WeakMap<Node, string>()
// Функция для обновления текстового содержимого
const updateTextContent = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
const originalText = node.textContent || ''
// Проверяем, содержит ли кириллицу
if (CYRILLIC_REGEX.test(originalText)) {
const currentLang = props.language()
const translatedText = translateString(originalText, currentLang)
// Обновляем только если текст изменился
if (node.textContent !== translatedText) {
console.log(`📝 Переводим текстовый узел "${originalText}" -> "${translatedText}"`)
node.textContent = translatedText
translationCache.set(node, originalText) // Сохраняем оригинал
}
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element
// Переводим атрибуты
const attributesToTranslate = ['title', 'placeholder', 'alt', 'aria-label', 'data-placeholder']
attributesToTranslate.forEach((attr) => {
const value = element.getAttribute(attr)
if (value && CYRILLIC_REGEX.test(value)) {
const currentLang = props.language()
const translatedValue = translateString(value, currentLang)
if (translatedValue !== value) {
console.log(`📝 Переводим атрибут ${attr}="${value}" -> "${translatedValue}"`)
element.setAttribute(attr, translatedValue)
}
}
})
// Специальная обработка элементов с текстом (кнопки, ссылки, лейблы, заголовки и т.д.)
const textElements = [
'BUTTON',
'A',
'LABEL',
'SPAN',
'DIV',
'P',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'TD',
'TH'
]
if (textElements.includes(element.tagName)) {
// Более приоритетная обработка для кнопок
if (element.tagName === 'BUTTON') {
console.log(`👆 Проверка кнопки: "${element.textContent?.trim()}"`)
}
// Ищем прямые текстовые узлы внутри элемента
const directTextNodes = Array.from(element.childNodes).filter(
(child) => child.nodeType === Node.TEXT_NODE && child.textContent?.trim()
)
// Если есть прямые текстовые узлы, обрабатываем их
directTextNodes.forEach((textNode) => {
const text = textNode.textContent || ''
if (CYRILLIC_REGEX.test(text)) {
const currentLang = props.language()
const translatedText = translateString(text, currentLang)
if (translatedText !== text) {
console.log(`📝 Переводим "${text}" -> "${translatedText}" (${element.tagName})`)
textNode.textContent = translatedText
translationCache.set(textNode, text)
}
}
})
// Дополнительная проверка для кнопок с вложенными элементами
if (element.tagName === 'BUTTON' && directTextNodes.length === 0) {
// Если у кнопки нет прямых текстовых узлов, но есть вложенные элементы
const buttonText = element.textContent?.trim()
if (buttonText && CYRILLIC_REGEX.test(buttonText)) {
console.log(`🔍 Кнопка с вложенными элементами: "${buttonText}"`)
// Проверяем, есть ли у кнопки value атрибут
const valueAttr = element.getAttribute('value')
if (valueAttr && CYRILLIC_REGEX.test(valueAttr)) {
const currentLang = props.language()
const translatedValue = translateString(valueAttr, currentLang)
if (translatedValue !== valueAttr) {
console.log(`📝 Переводим value="${valueAttr}" -> "${translatedValue}"`)
element.setAttribute('value', translatedValue)
}
}
}
}
}
// Рекурсивно обрабатываем дочерние узлы
Array.from(node.childNodes).forEach(updateTextContent)
}
}
// Функция для обновления всего контейнера
const updateAll = () => {
if (containerRef) {
updateTextContent(containerRef)
}
}
// Настройка MutationObserver для отслеживания новых элементов
const setupObserver = () => {
if (!containerRef) return
observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(updateTextContent)
}
})
})
observer.observe(containerRef, {
childList: true,
subtree: true
})
}
// Реагируем на изменения языка
createEffect(() => {
const currentLang = props.language()
console.log('🌐 Язык изменился на:', currentLang)
updateAll() // обновляем все тексты при изменении языка
})
// Инициализация при монтировании
onMount(() => {
if (containerRef) {
updateAll()
setupObserver()
}
})
// Очистка
onCleanup(() => {
if (observer) {
observer.disconnect()
}
})
return (
<div ref={containerRef} style={{ display: 'contents' }}>
{props.children}
</div>
)
}
/**
* Провайдер интернационализации с автоматическим переводом
*/
export const I18nProvider: ParentComponent = (props) => {
const [language, setLanguage] = createSignal<Language>('ru')
/**
* Функция перевода по ключу
*/
const t = (key: string): string => {
const currentLang = language()
if (currentLang === 'ru') {
return key
}
const translation = strings[key as keyof typeof strings]
return translation || key
}
/**
* Реактивная функция перевода - использует текущий язык
*/
const tr = (text: string): string => {
const currentLang = language()
if (currentLang === 'ru' || !CYRILLIC_REGEX.test(text)) {
return text
}
const translation = strings[text as keyof typeof strings]
return translation || text
}
/**
* Проверка, русский ли язык
*/
const isRussian = () => language() === 'ru'
/**
* Загружаем язык из localStorage при инициализации
*/
onMount(() => {
const savedLanguage = localStorage.getItem(STORAGE_KEY) as Language
if (savedLanguage && (savedLanguage === 'ru' || savedLanguage === 'en')) {
setLanguage(savedLanguage)
}
})
/**
* Сохраняем язык в localStorage при изменении и перезагружаем страницу
*/
const handleLanguageChange = (lang: Language) => {
// Сохраняем новый язык
localStorage.setItem(STORAGE_KEY, lang)
// Если язык действительно изменился
if (language() !== lang) {
console.log(`🔄 Перезагрузка страницы после смены языка с ${language()} на ${lang}`)
// Устанавливаем сигнал (хотя это не обязательно при перезагрузке)
setLanguage(lang)
// Перезагружаем страницу для корректного обновления всех DOM элементов
window.location.reload()
} else {
// Если язык не изменился, просто обновляем сигнал
setLanguage(lang)
}
}
const contextValue: I18nContextType = {
language,
setLanguage: handleLanguageChange,
t,
tr,
isRussian
}
return (
<I18nContext.Provider value={contextValue}>
<AutoTranslator language={language}>{props.children}</AutoTranslator>
</I18nContext.Provider>
)
}
/**
* Хук для использования контекста интернационализации
*/
export const useI18n = (): I18nContextType => {
const context = useContext(I18nContext)
if (!context) {
throw new Error('useI18n must be used within I18nProvider')
}
return context
}
/**
* Хук для получения функции перевода
*/
export const useTranslation = () => {
const { t, tr, language, isRussian } = useI18n()
return { t, tr, language: language(), isRussian: isRussian() }
}

234
panel/intl/strings.json Normal file
View File

@@ -0,0 +1,234 @@
{
"Панель администратора": "Admin Panel",
"Выйти": "Logout",
"Авторы": "Authors",
"Публикации": "Publications",
"Темы": "Topics",
"Сообщества": "Communities",
"Коллекции": "Collections",
"Приглашения": "Invites",
"Переменные среды": "Environment Variables",
"Ошибка при выходе": "Logout error",
"Вход в панель администратора": "Admin Panel Login",
"Имя пользователя": "Username",
"Пароль": "Password",
"Войти": "Login",
"Вход...": "Logging in...",
"Ошибка при входе": "Login error",
"Неверные учетные данные": "Invalid credentials",
"ID": "ID",
"Email": "Email",
"Имя": "Name",
"Создан": "Created",
"Создано": "Created",
"Роли": "Roles",
"Загрузка данных...": "Loading data...",
"Нет данных для отображения": "No data to display",
"Данные пользователя успешно обновлены": "User data successfully updated",
"Ошибка обновления данных пользователя": "Error updating user data",
"Заголовок": "Title",
"Слаг": "Slug",
"Статус": "Status",
"Содержимое": "Content",
"Опубликовано": "Published",
"Действия": "Actions",
"Загрузка публикаций...": "Loading publications...",
"Нет публикаций для отображения": "No publications to display",
"Содержимое публикации": "Publication content",
"Введите содержимое публикации...": "Enter publication content...",
"Содержимое публикации обновлено": "Publication content updated",
"Удалена": "Deleted",
"Опубликована": "Published",
"Черновик": "Draft",
"Название": "Title",
"Описание": "Description",
"Создатель": "Creator",
"Подписчики": "Subscribers",
"Сообщество": "Community",
"Все сообщества": "All communities",
"Родители": "Parents",
"Сортировка:": "Sorting:",
"По названию": "By title",
"Загрузка топиков...": "Loading topics...",
"Все": "All",
"Действие": "Action",
"Удалить": "Delete",
"Слить": "Merge",
"Выбрать все": "Select all",
"Подтверждение удаления": "Delete confirmation",
"Топик успешно обновлен": "Topic successfully updated",
"Ошибка обновления топика": "Error updating topic",
"Топик успешно создан": "Topic successfully created",
"Выберите действие и топики": "Select action and topics",
"Топик успешно удален": "Topic successfully deleted",
"Ошибка удаления топика": "Error deleting topic",
"Выберите одну тему для назначения родителя": "Select one topic to assign parent",
"Загрузка сообществ...": "Loading communities...",
"Сообщество успешно создано": "Community successfully created",
"Сообщество успешно обновлено": "Community successfully updated",
"Ошибка создания": "Creation error",
"Ошибка обновления": "Update error",
"Сообщество успешно удалено": "Community successfully deleted",
"Удалить сообщество": "Delete community",
"Загрузка коллекций...": "Loading collections...",
"Коллекция успешно создана": "Collection successfully created",
"Коллекция успешно обновлена": "Collection successfully updated",
"Коллекция успешно удалена": "Collection successfully deleted",
"Удалить коллекцию": "Delete collection",
"Поиск по приглашающему, приглашаемому, публикации...": "Search by inviter, invitee, publication...",
"Все статусы": "All statuses",
"Ожидает ответа": "Pending",
"Принято": "Accepted",
"Отклонено": "Rejected",
"Загрузка приглашений...": "Loading invites...",
"Приглашения не найдены": "No invites found",
"Удалить выбранные приглашения": "Delete selected invites",
"Ожидает": "Pending",
"Удалить приглашение": "Delete invite",
"Приглашение успешно удалено": "Invite successfully deleted",
"Не выбрано ни одного приглашения для удаления": "No invites selected for deletion",
"Подтверждение пакетного удаления": "Bulk delete confirmation",
"Без имени": "No name",
"Загрузка переменных окружения...": "Loading environment variables...",
"Переменные окружения не найдены": "No environment variables found",
"Как добавить переменные?": "How to add variables?",
"Ключ": "Key",
"Значение": "Value",
"не задано": "not set",
"Скопировать": "Copy",
"Скрыть": "Hide",
"Показать": "Show",
"Не удалось обновить переменную": "Failed to update variable",
"Ошибка при обновлении переменной": "Error updating variable",
"Загрузка...": "Loading...",
"Загрузка тем...": "Loading topics...",
"Обновить": "Refresh",
"Отмена": "Cancel",
"Сохранить": "Save",
"Создать": "Create",
"Создать сообщество": "Create community",
"Редактировать": "Edit",
"Поиск": "Search",
"Поиск...": "Search...",
"Управление иерархией тем": "Topic Hierarchy Management",
"Инструкции:": "Instructions:",
"🔍 Найдите тему по названию или прокрутите список": "🔍 Find topic by title or scroll through list",
"# Нажмите на тему, чтобы выбрать её для перемещения (синяя рамка)": "# Click on topic to select it for moving (blue border)",
"📂 Нажмите на другую тему, чтобы сделать её родителем (зеленая рамка)": "📂 Click on another topic to make it parent (green border)",
"🏠 Используйте кнопку \"Сделать корневой\" для перемещения на верхний уровень": "🏠 Use \"Make root\" button to move to top level",
"▶/▼ Раскрывайте/сворачивайте ветки дерева": "▶/▼ Expand/collapse tree branches",
"Поиск темы:": "Search topic:",
"Введите название темы для поиска...": "Enter topic title to search...",
"✅ Найдена тема:": "✅ Found topic:",
"❌ Тема не найдена": "❌ Topic not found",
"Планируемые изменения": "Planned changes",
"станет корневой темой": "will become root topic",
"переместится под тему": "will move under topic",
"Выбрана для перемещения:": "Selected for moving:",
"🏠 Сделать корневой темой": "🏠 Make root topic",
"❌ Отменить выбор": "❌ Cancel selection",
"Сохранить изменения": "Save changes",
"Выбрана тема": "Selected topic",
"для перемещения. Теперь нажмите на новую родительскую тему или используйте \"Сделать корневой\".": "for moving. Now click on new parent topic or use \"Make root\".",
"Нельзя переместить тему в своего потомка": "Cannot move topic to its descendant",
"Нет изменений для сохранения": "No changes to save",
"Назначить родительскую тему": "Assign parent topic",
"Редактируемая тема:": "Editing topic:",
"Текущее расположение:": "Current location:",
"Поиск новой родительской темы:": "Search for new parent topic:",
"Введите название темы...": "Enter topic title...",
"Выберите новую родительскую тему:": "Select new parent topic:",
"Путь:": "Path:",
"Предварительный просмотр:": "Preview:",
"Новое расположение:": "New location:",
"Не найдено подходящих тем по запросу": "No matching topics found for query",
"Нет доступных родительских тем": "No available parent topics",
"Назначение...": "Assigning...",
"Назначить родителя": "Assign parent",
"Неизвестная тема": "Unknown topic",
"Создать тему": "Create topic",
"Слияние тем": "Topic merge",
"Выбор целевой темы": "Target topic selection",
"Выберите целевую тему": "Select target topic",
"Выбор исходных тем для слияния": "Source topics selection for merge",
"Настройки слияния": "Merge settings",
"Сохранить свойства целевой темы": "Keep target topic properties",
"Предпросмотр слияния:": "Merge preview:",
"Целевая тема:": "Target topic:",
"Исходные темы:": "Source topics:",
"шт.": "pcs.",
"Действие:": "Action:",
"Все подписчики, публикации и черновики будут перенесены в целевую": "All subscribers, publications and drafts will be moved to target",
"Выполняется слияние...": "Merging...",
"Слить темы": "Merge topics",
"Невозможно выполнить слияние с текущими настройками": "Cannot perform merge with current settings",
"Автор:": "Author:",
"Просмотры:": "Views:",
"Содержание": "Content",
"PENDING": "PENDING",
"ACCEPTED": "ACCEPTED",
"REJECTED": "REJECTED",
"Текущий статус приглашения": "Current invite status",
"Информация о приглашении": "Invite information",
"Приглашающий:": "Inviter:",
"Приглашаемый:": "Invitee:",
"Публикация:": "Publication:",
"Приглашающий и приглашаемый не могут быть одним и тем же автором": "Inviter and invitee cannot be the same author",
"Создание нового приглашения": "Creating new invite",
"уникальный-идентификатор": "unique-identifier",
"Название коллекции": "Collection title",
"Описание коллекции...": "Collection description...",
"Название сообщества": "Community title",
"Описание сообщества...": "Community description...",
"Создать коллекцию": "Create collection",
"body": "Body",
"Описание топика": "Topic body",
"Введите содержимое топика...": "Enter topic content...",
"Содержимое топика обновлено": "Topic content updated",
"Выберите действие:": "Select action:",
"Установить нового родителя": "Set new parent",
"Выбор родительской темы:": "Parent topic selection:",
"Поиск родительской темы...": "Search parent topic...",
"Иван Иванов": "Ivan Ivanov",
"Системная информация": "System information",
"Дата регистрации:": "Registration date:",
"Последняя активность:": "Last activity:",
"Основные данные": "Basic data",
"Введите значение переменной...": "Enter variable value...",
"Скрыть превью": "Hide preview",
"Показать превью": "Show preview",
"Нажмите для редактирования...": "Click to edit...",
"Поиск по email, имени или ID...": "Search by email, name or ID...",
"Поиск по заголовку, slug или ID...": "Search by title, slug or ID...",
"Введите HTML описание топика...": "Enter HTML topic description...",
"https://example.com/image.jpg": "https://example.com/image.jpg",
"1, 5, 12": "1, 5, 12",
"user@example.com": "user@example.com",
"1": "1",
"2": "2",
"123": "123",
"Введите содержимое media.body...": "Enter media.body content...",
"Поиск по названию, slug или ID...": "Search by title, slug or ID...",
"Дискурс": "Discours"
}

View File

@@ -109,68 +109,99 @@ const CollectionEditModal: Component<CollectionEditModalProps> = (props) => {
return (
<Modal isOpen={props.isOpen} onClose={props.onClose} title={modalTitle()} size="medium">
<div class={styles['modal-content']}>
<div class={styles.modalContent}>
<div class={formStyles.form}>
<div class={formStyles['form-group']}>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
Slug <span style={{ color: 'red' }}>*</span>
</label>
<input
type="text"
value={formData().slug}
onInput={(e) => updateField('slug', e.target.value.toLowerCase())}
class={`${formStyles.input} ${errors().slug ? formStyles.inputError : ''}`}
placeholder="уникальный-идентификатор"
required
/>
<div class={formStyles.fieldHint}>
Используется в URL коллекции. Только латинские буквы, цифры, дефисы и подчеркивания.
</div>
{errors().slug && <div class={formStyles.fieldError}>{errors().slug}</div>}
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>
Название <span style={{ color: 'red' }}>*</span>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>📝</span>
Название
<span class={formStyles.required}>*</span>
</span>
</label>
<input
type="text"
class={`${formStyles.input} ${errors().title ? formStyles.error : ''}`}
value={formData().title}
onInput={(e) => updateField('title', e.target.value)}
class={`${formStyles.input} ${errors().title ? formStyles.inputError : ''}`}
placeholder="Название коллекции"
placeholder="Введите название коллекции"
required
/>
{errors().title && <div class={formStyles.fieldError}>{errors().title}</div>}
{errors().title && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().title}
</div>
)}
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>Описание</label>
<textarea
value={formData().desc}
onInput={(e) => updateField('desc', e.target.value)}
class={formStyles.input}
style={{
'min-height': '80px',
resize: 'vertical'
}}
placeholder="Описание коллекции..."
/>
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>Картинка (URL)</label>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>🔗</span>
Slug
<span class={formStyles.required}>*</span>
</span>
</label>
<input
type="text"
value={formData().pic}
onInput={(e) => updateField('pic', e.target.value)}
class={`${formStyles.input} ${errors().pic ? formStyles.inputError : ''}`}
placeholder="https://example.com/image.jpg"
class={`${formStyles.input} ${errors().slug ? formStyles.error : ''}`}
value={formData().slug}
onInput={(e) => updateField('slug', e.target.value)}
placeholder="collection-slug"
required
/>
{errors().pic && <div class={formStyles.fieldError}>{errors().pic}</div>}
{errors().slug && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().slug}
</div>
)}
</div>
<div class={styles['modal-actions']}>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>📄</span>
Описание
</span>
</label>
<textarea
class={formStyles.textarea}
value={formData().desc}
onInput={(e) => updateField('desc', e.target.value)}
placeholder="Описание коллекции (необязательно)"
rows="4"
/>
</div>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>🖼</span>
URL картинки
</span>
</label>
<input
type="url"
class={`${formStyles.input} ${errors().pic ? formStyles.error : ''}`}
value={formData().pic}
onInput={(e) => updateField('pic', e.target.value)}
placeholder="https://example.com/image.jpg"
/>
{errors().pic && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().pic}
</div>
)}
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
Необязательно. URL изображения для обложки коллекции.
</div>
</div>
<div class={styles.modalActions}>
<Button variant="secondary" onClick={props.onClose}>
Отмена
</Button>

View File

@@ -1,90 +1,151 @@
import { Component, createEffect, createSignal } from 'solid-js'
import { createEffect, createSignal, Show } from 'solid-js'
import { useData } from '../context/data'
import type { Role } from '../graphql/generated/schema'
import {
GET_COMMUNITY_ROLE_SETTINGS_QUERY,
GET_COMMUNITY_ROLES_QUERY,
UPDATE_COMMUNITY_ROLE_SETTINGS_MUTATION
} from '../graphql/queries'
import formStyles from '../styles/Form.module.css'
import styles from '../styles/Modal.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import RoleManager from '../ui/RoleManager'
interface Community {
id: number
slug: string
name: string
slug: string
desc?: string
pic: string
created_at: number
created_by: {
id: number
name: string
email: string
}
stat: {
shouts: number
followers: number
authors: number
}
pic?: string
}
interface CommunityEditModalProps {
isOpen: boolean
community: Community | null // null для создания нового
community: Community | null
onClose: () => void
onSave: (community: Partial<Community>) => void
onSave: (communityData: Partial<Community>) => Promise<void>
}
/**
* Модальное окно для создания и редактирования сообществ
*/
const CommunityEditModal: Component<CommunityEditModalProps> = (props) => {
const [formData, setFormData] = createSignal({
slug: '',
name: '',
desc: '',
pic: ''
})
const [errors, setErrors] = createSignal<Record<string, string>>({})
interface RoleSettings {
default_roles: string[]
available_roles: string[]
}
// Синхронизация с props.community
interface CustomRole {
id: string
name: string
description: string
icon: string
}
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 CommunityEditModal = (props: CommunityEditModalProps) => {
const { queryGraphQL } = useData()
const [formData, setFormData] = createSignal<Partial<Community>>({})
const [roleSettings, setRoleSettings] = createSignal<RoleSettings>({
default_roles: ['reader'],
available_roles: ['reader', 'author', 'artist', 'expert', 'editor', 'admin']
})
const [customRoles, setCustomRoles] = createSignal<CustomRole[]>([])
const [errors, setErrors] = createSignal<Record<string, string>>({})
const [activeTab, setActiveTab] = createSignal<'basic' | 'roles'>('basic')
const [loading, setLoading] = createSignal(false)
// Инициализация формы при открытии
createEffect(() => {
if (props.isOpen) {
if (props.community) {
// Редактирование существующего сообщества
setFormData({
slug: props.community.slug,
name: props.community.name,
name: props.community.name || '',
slug: props.community.slug || '',
desc: props.community.desc || '',
pic: props.community.pic
pic: props.community.pic || ''
})
void loadRoleSettings()
} else {
// Создание нового сообщества
setFormData({
slug: '',
name: '',
desc: '',
pic: ''
setFormData({ name: '', slug: '', desc: '', pic: '' })
setRoleSettings({
default_roles: ['reader'],
available_roles: ['reader', 'author', 'artist', 'expert', 'editor', 'admin']
})
}
setErrors({})
setActiveTab('basic')
setCustomRoles([])
}
})
const validateForm = () => {
const loadRoleSettings = async () => {
if (!props.community?.id) return
try {
const data = await queryGraphQL(GET_COMMUNITY_ROLE_SETTINGS_QUERY, {
community_id: props.community.id
})
if (data?.adminGetCommunityRoleSettings && !data.adminGetCommunityRoleSettings.error) {
setRoleSettings({
default_roles: data.adminGetCommunityRoleSettings.default_roles,
available_roles: data.adminGetCommunityRoleSettings.available_roles
})
}
// Загружаем все роли сообщества для получения произвольных
const rolesData = await queryGraphQL(GET_COMMUNITY_ROLES_QUERY, {
community: props.community.id
})
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: '🔖' // Пока иконки не хранятся в БД
}))
setCustomRoles(customRolesList)
}
} catch (error) {
console.error('Ошибка загрузки настроек ролей:', error)
}
}
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}
const data = formData()
// Валидация slug
if (!data.slug.trim()) {
newErrors.slug = 'Slug обязателен'
} else if (!/^[a-z0-9-_]+$/.test(data.slug)) {
newErrors.slug = 'Slug может содержать только латинские буквы, цифры, дефисы и подчеркивания'
}
// Валидация названия
if (!data.name.trim()) {
if (!data.name?.trim()) {
newErrors.name = 'Название обязательно'
}
// Валидация URL картинки (если указан)
if (data.pic.trim() && !/^https?:\/\/.+/.test(data.pic)) {
newErrors.pic = 'Некорректный URL картинки'
if (!data.slug?.trim()) {
newErrors.slug = 'Слаг обязательный'
} else if (!/^[a-z0-9-]+$/.test(data.slug)) {
newErrors.slug = 'Слаг может содержать только латинские буквы, цифры и дефисы'
}
// Валидация ролей
const roleSet = roleSettings()
if (roleSet.default_roles.length === 0) {
newErrors.roles = 'Должна быть хотя бы одна дефолтная роль'
}
const invalidDefaults = roleSet.default_roles.filter((role) => !roleSet.available_roles.includes(role))
if (invalidDefaults.length > 0) {
newErrors.roles = 'Дефолтные роли должны быть из списка доступных'
}
setErrors(newErrors)
@@ -93,17 +154,39 @@ const CommunityEditModal: Component<CommunityEditModalProps> = (props) => {
const updateField = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Очищаем ошибку для поля при изменении
setErrors((prev) => ({ ...prev, [field]: '' }))
}
const handleSave = () => {
const handleSave = async () => {
if (!validateForm()) {
return
}
const communityData = { ...formData() }
props.onSave(communityData)
setLoading(true)
try {
// Сохраняем основные данные сообщества
await props.onSave(formData())
// Если редактируем существующее сообщество, сохраняем настройки ролей
if (props.community?.id) {
const roleData = await queryGraphQL(UPDATE_COMMUNITY_ROLE_SETTINGS_MUTATION, {
community_id: props.community.id,
default_roles: roleSettings().default_roles,
available_roles: roleSettings().available_roles
})
if (!roleData?.adminUpdateCommunityRoleSettings?.success) {
console.error(
'Ошибка сохранения настроек ролей:',
roleData?.adminUpdateCommunityRoleSettings?.error
)
}
}
} catch (error) {
console.error('Ошибка сохранения:', error)
} finally {
setLoading(false)
}
}
const isCreating = () => props.community === null
@@ -113,76 +196,149 @@ const CommunityEditModal: Component<CommunityEditModalProps> = (props) => {
: `Редактирование сообщества: ${props.community?.name || ''}`
return (
<Modal isOpen={props.isOpen} onClose={props.onClose} title={modalTitle()} size="medium">
<div class={styles['modal-content']}>
<div class={formStyles.form}>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>
Slug <span style={{ color: 'red' }}>*</span>
</label>
<input
type="text"
value={formData().slug}
onInput={(e) => updateField('slug', e.target.value.toLowerCase())}
class={`${formStyles.input} ${errors().slug ? formStyles.inputError : ''}`}
placeholder="уникальный-идентификатор"
required
/>
<div class={formStyles.fieldHint}>
Используется в URL сообщества. Только латинские буквы, цифры, дефисы и подчеркивания.
<Modal isOpen={props.isOpen} onClose={props.onClose} title={modalTitle()} size="large">
<div class={styles.content}>
{/* Табы */}
<div class={formStyles.tabs}>
<button
type="button"
class={`${formStyles.tab} ${activeTab() === 'basic' ? formStyles.active : ''}`}
onClick={() => setActiveTab('basic')}
>
<span class={formStyles.tabIcon}></span>
Основные настройки
</button>
<Show when={!isCreating()}>
<button
type="button"
class={`${formStyles.tab} ${activeTab() === 'roles' ? formStyles.active : ''}`}
onClick={() => setActiveTab('roles')}
>
<span class={formStyles.tabIcon}>👥</span>
Роли и права
</button>
</Show>
</div>
{/* Контент табов */}
<div class={formStyles.content}>
<Show when={activeTab() === 'basic'}>
<div class={formStyles.form}>
<div class={formStyles.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().name ? formStyles.error : ''}`}
value={formData().name || ''}
onInput={(e) => updateField('name', e.currentTarget.value)}
placeholder="Введите название сообщества"
/>
<Show when={errors().name}>
<span class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().name}
</span>
</Show>
</div>
<div class={formStyles.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().slug ? formStyles.error : ''} ${!isCreating() ? formStyles.disabled : ''}`}
value={formData().slug || ''}
onInput={(e) => updateField('slug', e.currentTarget.value)}
placeholder="community-slug"
disabled={!isCreating()}
/>
<Show when={errors().slug}>
<span class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().slug}
</span>
</Show>
<Show when={!isCreating()}>
<span class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
Слаг нельзя изменить после создания
</span>
</Show>
</div>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>📝</span>
Описание
</span>
</label>
<textarea
class={formStyles.textarea}
value={formData().desc || ''}
onInput={(e) => updateField('desc', e.currentTarget.value)}
placeholder="Описание сообщества"
rows={4}
/>
</div>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>🖼</span>
Изображение (URL)
</span>
</label>
<input
type="url"
class={formStyles.input}
value={formData().pic || ''}
onInput={(e) => updateField('pic', e.currentTarget.value)}
placeholder="https://example.com/image.jpg"
/>
</div>
</div>
{errors().slug && <div class={formStyles.fieldError}>{errors().slug}</div>}
</div>
</Show>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>
Название <span style={{ color: 'red' }}>*</span>
</label>
<input
type="text"
value={formData().name}
onInput={(e) => updateField('name', e.target.value)}
class={`${formStyles.input} ${errors().name ? formStyles.inputError : ''}`}
placeholder="Название сообщества"
required
<Show when={activeTab() === 'roles' && !isCreating()}>
<RoleManager
communityId={props.community?.id}
roleSettings={roleSettings()}
onRoleSettingsChange={setRoleSettings}
customRoles={customRoles()}
onCustomRolesChange={setCustomRoles}
/>
{errors().name && <div class={formStyles.fieldError}>{errors().name}</div>}
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>Описание</label>
<textarea
value={formData().desc}
onInput={(e) => updateField('desc', e.target.value)}
class={formStyles.input}
style={{
'min-height': '80px',
resize: 'vertical'
}}
placeholder="Описание сообщества..."
/>
</div>
<Show when={errors().roles}>
<span class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().roles}
</span>
</Show>
</Show>
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>Картинка (URL)</label>
<input
type="text"
value={formData().pic}
onInput={(e) => updateField('pic', e.target.value)}
class={`${formStyles.input} ${errors().pic ? formStyles.inputError : ''}`}
placeholder="https://example.com/image.jpg"
/>
{errors().pic && <div class={formStyles.fieldError}>{errors().pic}</div>}
</div>
<div class={styles['modal-actions']}>
<Button variant="secondary" onClick={props.onClose}>
Отмена
</Button>
<Button variant="primary" onClick={handleSave}>
{isCreating() ? 'Создать' : 'Сохранить'}
</Button>
</div>
<div class={styles.footer}>
<Button variant="secondary" onClick={props.onClose}>
Отмена
</Button>
<Button variant="primary" onClick={handleSave} disabled={loading()}>
<Show when={loading()}>
<span class={formStyles.spinner} />
</Show>
{loading() ? 'Сохранение...' : isCreating() ? 'Создать' : 'Сохранить'}
</Button>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,182 @@
import { Component, createEffect, createSignal, For, Show } from 'solid-js'
import { useData } from '../context/data'
import formStyles from '../styles/Form.module.css'
import styles from '../styles/Modal.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
interface Author {
id: number
name: string
email: string
slug: string
}
interface Community {
id: number
name: string
slug: string
}
interface Role {
id: string
name: string
description?: string
}
interface CommunityRolesModalProps {
isOpen: boolean
author: Author | null
community: Community | null
onClose: () => void
onSave: (authorId: number, communityId: number, roles: string[]) => Promise<void>
}
const CommunityRolesModal: Component<CommunityRolesModalProps> = (props) => {
const { queryGraphQL } = useData()
const [roles, setRoles] = createSignal<Role[]>([])
const [userRoles, setUserRoles] = createSignal<string[]>([])
const [loading, setLoading] = createSignal(false)
// Загружаем доступные роли при открытии модала
createEffect(() => {
if (props.isOpen && props.community) {
void loadRolesData()
}
})
const loadRolesData = async () => {
setLoading(true)
try {
// Получаем доступные роли
const rolesData = await queryGraphQL(
`
query GetRoles($community: Int) {
adminGetRoles(community: $community) {
id
name
description
}
}
`,
{ community: props.community?.id }
)
if (rolesData?.adminGetRoles) {
setRoles(rolesData.adminGetRoles)
}
// Получаем текущие роли пользователя
if (props.author) {
const membersData = await queryGraphQL(
`
query GetCommunityMembers($community_id: Int!) {
adminGetCommunityMembers(community_id: $community_id, limit: 1000) {
members {
id
roles
}
}
}
`,
{ community_id: props.community?.id }
)
const members = membersData?.adminGetCommunityMembers?.members || []
const currentUser = members.find((m: { id: number }) => m.id === props.author?.id)
setUserRoles(currentUser?.roles || [])
}
} catch (error) {
console.error('Ошибка загрузки ролей:', error)
} finally {
setLoading(false)
}
}
const handleRoleToggle = (roleId: string) => {
const currentRoles = userRoles()
if (currentRoles.includes(roleId)) {
setUserRoles(currentRoles.filter((r) => r !== roleId))
} else {
setUserRoles([...currentRoles, roleId])
}
}
const handleSave = async () => {
if (!props.author || !props.community) return
setLoading(true)
try {
await props.onSave(props.author.id, props.community.id, userRoles())
props.onClose()
} catch (error) {
console.error('Ошибка сохранения ролей:', error)
} finally {
setLoading(false)
}
}
return (
<Modal
isOpen={props.isOpen}
onClose={props.onClose}
title={`Роли пользователя: ${props.author?.name || ''}`}
>
<div class={styles.content}>
<Show when={props.community && props.author}>
<div class={formStyles.field}>
<label class={formStyles.label}>
Сообщество: <strong>{props.community?.name}</strong>
</label>
</div>
<div class={formStyles.field}>
<label class={formStyles.label}>
Пользователь: <strong>{props.author?.name}</strong> ({props.author?.email})
</label>
</div>
<div class={formStyles.field}>
<label class={formStyles.label}>Роли:</label>
<Show when={!loading()} fallback={<div>Загрузка ролей...</div>}>
<div class={formStyles.checkboxGroup}>
<For each={roles()}>
{(role) => (
<div class={formStyles.checkboxItem}>
<input
type="checkbox"
id={`role-${role.id}`}
checked={userRoles().includes(role.id)}
onChange={() => handleRoleToggle(role.id)}
class={formStyles.checkbox}
/>
<label for={`role-${role.id}`} class={formStyles.checkboxLabel}>
<div>
<strong>{role.name}</strong>
<Show when={role.description}>
<div class={formStyles.description}>{role.description}</div>
</Show>
</div>
</label>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
<div class={styles.actions}>
<Button variant="secondary" onClick={props.onClose}>
Отмена
</Button>
<Button variant="primary" onClick={handleSave} disabled={loading()}>
{loading() ? 'Сохранение...' : 'Сохранить'}
</Button>
</div>
</div>
</Modal>
)
}
export default CommunityRolesModal

View File

@@ -89,37 +89,46 @@ const EnvVariableModal: Component<EnvVariableModalProps> = (props) => {
onClose={props.onClose}
size="large"
>
<div class={formStyles['modal-wide']}>
<div class={formStyles.modalWide}>
<form class={formStyles.form} onSubmit={(e) => e.preventDefault()}>
<div class={formStyles['form-group']}>
<label class={formStyles['form-label']}>Ключ:</label>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>🔑</span>
Ключ
</span>
</label>
<input
type="text"
value={props.variable.key}
disabled
class={formStyles['form-input-disabled']}
class={`${formStyles.input} ${formStyles.disabled}`}
/>
</div>
<div class={formStyles['form-group']}>
<label class={formStyles['form-label']}>
Значение:
<span class={formStyles['form-label-info']}>
{props.variable.type} {props.variable.isSecret && '(секретное)'}
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>💾</span>
Значение
<span class={formStyles.labelInfo}>
({props.variable.type}
{props.variable.isSecret && ', секретное'})
</span>
</span>
</label>
<Show when={needsTextarea()}>
<div class={formStyles['textarea-container']}>
<div class={formStyles.textareaContainer}>
<textarea
value={value()}
onInput={(e) => setValue(e.currentTarget.value)}
class={formStyles['form-textarea']}
class={formStyles.textarea}
rows={Math.min(Math.max(value().split('\n').length + 2, 4), 15)}
placeholder="Введите значение переменной..."
/>
<Show when={props.variable.type === 'json'}>
<div class={formStyles['textarea-actions']}>
<div class={formStyles.textareaActions}>
<Button
variant="secondary"
size="small"
@@ -146,32 +155,37 @@ const EnvVariableModal: Component<EnvVariableModalProps> = (props) => {
type={props.variable.isSecret ? 'password' : 'text'}
value={value()}
onInput={(e) => setValue(e.currentTarget.value)}
class={formStyles['form-input']}
class={formStyles.input}
placeholder="Введите значение переменной..."
/>
</Show>
</div>
<Show when={showFormatted() && (props.variable.type === 'json' || value().startsWith('{'))}>
<div class={formStyles['form-group']}>
<label class={formStyles['form-label']}>Превью (форматированное):</label>
<div class={formStyles['code-preview-container']}>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>👁</span>
Превью (форматированное)
</span>
</label>
<div class={formStyles.codePreview}>
<TextPreview content={formattedValue()} />
</div>
</div>
</Show>
<Show when={props.variable.description}>
<div class={formStyles['form-help']}>
<div class={formStyles.formHelp}>
<strong>Описание:</strong> {props.variable.description}
</div>
</Show>
<Show when={error()}>
<div class={formStyles['form-error']}>{error()}</div>
<div class={formStyles.formError}>{error()}</div>
</Show>
<div class={formStyles['form-actions']}>
<div class={formStyles.formActions}>
<Button variant="secondary" onClick={props.onClose} disabled={saving()}>
Отменить
</Button>

View File

@@ -1,4 +1,4 @@
import { Component, createEffect, createSignal } from 'solid-js'
import { Component, createEffect, createSignal, Show } from 'solid-js'
import formStyles from '../styles/Form.module.css'
import styles from '../styles/Modal.module.css'
import Button from '../ui/Button'
@@ -123,93 +123,144 @@ const InviteEditModal: Component<InviteEditModalProps> = (props) => {
return (
<Modal isOpen={props.isOpen} onClose={props.onClose} title={modalTitle()} size="medium">
<div class={styles['modal-content']}>
<div class={styles.modalContent}>
<div class={formStyles.form}>
<div class={formStyles['form-group']}>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
ID приглашающего <span style={{ color: 'red' }}>*</span>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>👤</span>
ID приглашающего
<span class={formStyles.required}>*</span>
</span>
</label>
<input
type="number"
value={formData().inviter_id}
onInput={(e) => updateField('inviter_id', Number.parseInt(e.target.value) || 0)}
class={`${formStyles.input} ${errors().inviter_id ? formStyles.inputError : ''}`}
class={`${formStyles.input} ${errors().inviter_id ? formStyles.error : ''} ${!isCreating() ? formStyles.disabled : ''}`}
placeholder="1"
required
disabled={!isCreating()} // При редактировании ID нельзя менять
/>
<div class={formStyles.fieldHint}>ID автора, который отправляет приглашение</div>
{errors().inviter_id && <div class={formStyles.fieldError}>{errors().inviter_id}</div>}
{errors().inviter_id && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().inviter_id}
</div>
)}
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
ID автора, который отправляет приглашение
</div>
</div>
<div class={formStyles['form-group']}>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
ID приглашаемого <span style={{ color: 'red' }}>*</span>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>👥</span>
ID приглашаемого
<span class={formStyles.required}>*</span>
</span>
</label>
<input
type="number"
value={formData().author_id}
onInput={(e) => updateField('author_id', Number.parseInt(e.target.value) || 0)}
class={`${formStyles.input} ${errors().author_id ? formStyles.inputError : ''}`}
class={`${formStyles.input} ${errors().author_id ? formStyles.error : ''} ${!isCreating() ? formStyles.disabled : ''}`}
placeholder="2"
required
disabled={!isCreating()} // При редактировании ID нельзя менять
/>
<div class={formStyles.fieldHint}>ID автора, которого приглашают к сотрудничеству</div>
{errors().author_id && <div class={formStyles.fieldError}>{errors().author_id}</div>}
<Show when={errors().author_id}>
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().author_id}
</div>
</Show>
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
ID автора, которого приглашают к сотрудничеству
</div>
</div>
<div class={formStyles['form-group']}>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
ID публикации <span style={{ color: 'red' }}>*</span>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>📄</span>
ID публикации
<span class={formStyles.required}>*</span>
</span>
</label>
<input
type="number"
value={formData().shout_id}
onInput={(e) => updateField('shout_id', Number.parseInt(e.target.value) || 0)}
class={`${formStyles.input} ${errors().shout_id ? formStyles.inputError : ''}`}
class={`${formStyles.input} ${errors().shout_id ? formStyles.error : ''} ${!isCreating() ? formStyles.disabled : ''}`}
placeholder="123"
required
disabled={!isCreating()} // При редактировании ID нельзя менять
/>
<div class={formStyles.fieldHint}>ID публикации, к которой приглашают на сотрудничество</div>
{errors().shout_id && <div class={formStyles.fieldError}>{errors().shout_id}</div>}
<Show when={errors().shout_id}>
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().shout_id}
</div>
</Show>
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
ID публикации, к которой приглашают на сотрудничество
</div>
</div>
<div class={formStyles['form-group']}>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
Статус <span style={{ color: 'red' }}>*</span>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>📋</span>
Статус
<span class={formStyles.required}>*</span>
</span>
</label>
<select
value={formData().status}
onChange={(e) => updateField('status', e.target.value)}
class={formStyles.input}
class={formStyles.select}
required
>
<option value="PENDING">Ожидает ответа</option>
<option value="ACCEPTED">Принято</option>
<option value="REJECTED">Отклонено</option>
</select>
<div class={formStyles.fieldHint}>Текущий статус приглашения</div>
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
Текущий статус приглашения
</div>
</div>
{/* Информация о связанных объектах при редактировании */}
{!isCreating() && props.invite && (
<div class={formStyles['form-group']}>
<label class={formStyles.label}>Информация о приглашении</label>
<div class={formStyles.fieldHint} style={{ 'margin-bottom': '8px' }}>
<strong>Приглашающий:</strong> {props.invite.inviter.name} ({props.invite.inviter.email})
<Show when={!isCreating() && props.invite}>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}></span>
Информация о приглашении
</span>
</label>
<div class={formStyles.hint} style={{ 'margin-bottom': '8px' }}>
<span class={formStyles.hintIcon}>👤</span>
<strong>Приглашающий:</strong> {props.invite?.inviter.name} ({props.invite?.inviter.email})
</div>
<div class={formStyles.fieldHint} style={{ 'margin-bottom': '8px' }}>
<strong>Приглашаемый:</strong> {props.invite.author.name} ({props.invite.author.email})
<div class={formStyles.hint} style={{ 'margin-bottom': '8px' }}>
<span class={formStyles.hintIcon}>👥</span>
<strong>Приглашаемый:</strong> {props.invite?.author.name} ({props.invite?.author.email})
</div>
<div class={formStyles.fieldHint}>
<strong>Публикация:</strong> {props.invite.shout.title}
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>📄</span>
<strong>Публикация:</strong> {props.invite?.shout.title}
</div>
</div>
)}
</Show>
<div class={styles['modal-actions']}>
<div class={styles.modalActions}>
<Button variant="secondary" onClick={props.onClose}>
Отмена
</Button>

View File

@@ -1,6 +1,6 @@
import { Component, createEffect, createSignal, For } from 'solid-js'
import type { AdminUserInfo } from '../graphql/generated/schema'
import styles from '../styles/Form.module.css'
import formStyles from '../styles/Form.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
@@ -17,87 +17,146 @@ export interface UserEditModalProps {
}) => Promise<void>
}
// Доступные роли в системе (без роли Администратор - она определяется автоматически)
const AVAILABLE_ROLES = [
{ id: 'admin', name: 'Администратор', description: 'Полный доступ к системе' },
{ id: 'editor', name: 'Редактор', description: 'Редактирование публикаций и управление сообществом' },
{
id: 'expert',
name: 'Эксперт',
description: 'Добавление доказательств и опровержений, управление темами'
id: 'Редактор',
name: 'Редактор',
description: 'Редактирование публикаций и управление сообществом',
emoji: '✒️'
},
{ id: 'author', name: 'Автор', description: 'Создание и редактирование своих публикаций' },
{ id: 'reader', name: 'Читатель', description: 'Чтение и комментирование' }
{
id: 'Эксперт',
name: 'Эксперт',
description: 'Добавление доказательств и опровержений, управление темами',
emoji: '🔬'
},
{
id: 'Автор',
name: 'Автор',
description: 'Создание и редактирование своих публикаций',
emoji: '📝'
},
{
id: 'Читатель',
name: 'Читатель',
description: 'Чтение и комментирование',
emoji: '📖'
}
]
const UserEditModal: Component<UserEditModalProps> = (props) => {
const [formData, setFormData] = createSignal({
id: props.user.id,
email: props.user.email || '',
name: props.user.name || '',
slug: props.user.slug || '',
roles: props.user.roles || []
roles: props.user.roles?.filter((role) => role !== 'Администратор') || [] // Исключаем админскую роль из ручного управления
})
const [loading, setLoading] = createSignal(false)
const [errors, setErrors] = createSignal<Record<string, string>>({})
// Сброс формы при открытии модалки
const [errors, setErrors] = createSignal<Record<string, string>>({})
const [loading, setLoading] = createSignal(false)
// Проверяем, является ли пользователь администратором по ролям, которые приходят с сервера
const isAdmin = () => {
return (props.user.roles || []).includes('Администратор')
}
// Получаем информацию о роли по ID
const getRoleInfo = (roleId: string) => {
return AVAILABLE_ROLES.find((role) => role.id === roleId) || { name: roleId, emoji: '🎭' }
}
// Формируем строку с ролями и эмоджи
const getRolesDisplay = () => {
const roles = formData().roles
if (roles.length === 0) {
return isAdmin() ? '🪄 Администратор' : 'Роли не назначены'
}
const roleTexts = roles.map((roleId) => {
const role = getRoleInfo(roleId)
return `${role.emoji} ${role.name}`
})
if (isAdmin()) {
return `🪄 Администратор, ${roleTexts.join(', ')}`
}
return roleTexts.join(', ')
}
// Обновляем форму при изменении пользователя
createEffect(() => {
if (props.isOpen) {
if (props.user) {
setFormData({
id: props.user.id,
email: props.user.email || '',
name: props.user.name || '',
slug: props.user.slug || '',
roles: props.user.roles || []
roles: props.user.roles?.filter((role) => role !== 'Администратор') || [] // Исключаем админскую роль
})
setErrors({})
}
})
const validateForm = () => {
const updateField = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Очищаем ошибку при изменении поля
if (errors()[field]) {
setErrors((prev) => ({ ...prev, [field]: '' }))
}
}
const handleRoleToggle = (roleId: string) => {
setFormData((prev) => {
const currentRoles = prev.roles
const newRoles = currentRoles.includes(roleId)
? currentRoles.filter((r) => r !== roleId)
: [...currentRoles, roleId]
return { ...prev, roles: newRoles }
})
// Очищаем ошибку ролей при изменении
if (errors().roles) {
setErrors((prev) => ({ ...prev, roles: '' }))
}
}
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}
const data = formData()
// Валидация email
// Email
if (!data.email.trim()) {
newErrors.email = 'Email обязателен'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
newErrors.email = 'Некорректный формат email'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email.trim())) {
newErrors.email = 'Неверный формат email'
}
// Валидация имени
// Имя
if (!data.name.trim()) {
newErrors.name = 'Имя обязательно'
} else if (data.name.trim().length < 2) {
newErrors.name = 'Имя должно содержать минимум 2 символа'
}
// Валидация slug
// Slug
if (!data.slug.trim()) {
newErrors.slug = 'Slug обязателен'
} else if (!/^[a-z0-9-_]+$/.test(data.slug)) {
} else if (!/^[a-z0-9_-]+$/.test(data.slug.trim())) {
newErrors.slug = 'Slug может содержать только латинские буквы, цифры, дефисы и подчеркивания'
}
// Валидация ролей
if (data.roles.length === 0) {
newErrors.roles = 'Выберите хотя бы одну роль'
// Роли (админы освобождаются от этого требования)
if (!isAdmin() && data.roles.length === 0) {
newErrors.roles = 'Выберите хотя бы одну роль (или назначьте админский email)'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const updateField = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Очищаем ошибку для поля при изменении
setErrors((prev) => ({ ...prev, [field]: '' }))
}
const handleRoleToggle = (roleId: string) => {
const current = formData().roles
const newRoles = current.includes(roleId) ? current.filter((r) => r !== roleId) : [...current, roleId]
setFormData((prev) => ({ ...prev, roles: newRoles }))
setErrors((prev) => ({ ...prev, roles: '' }))
}
const handleSave = async () => {
if (!validateForm()) {
return
@@ -105,144 +164,184 @@ const UserEditModal: Component<UserEditModalProps> = (props) => {
setLoading(true)
try {
await props.onSave({
id: props.user.id,
email: formData().email,
name: formData().name,
slug: formData().slug,
roles: formData().roles
})
// Отправляем только обычные роли, админская роль определяется на сервере по email
await props.onSave(formData())
props.onClose()
} catch (error) {
console.error('Error saving user:', error)
setErrors({ general: 'Ошибка при сохранении данных пользователя' })
console.error('Ошибка при сохранении пользователя:', error)
setErrors({ general: 'Ошибка при сохранении пользователя' })
} finally {
setLoading(false)
}
}
const formatDate = (timestamp?: number | null) => {
if (!timestamp) return '—'
return new Date(timestamp * 1000).toLocaleString('ru-RU')
}
const footer = (
<>
<Button variant="secondary" onClick={props.onClose} disabled={loading()}>
Отмена
</Button>
<Button variant="primary" onClick={handleSave} loading={loading()} disabled={loading()}>
Сохранить изменения
</Button>
</>
)
return (
<Modal
title={`Редактирование пользователя #${props.user.id}`}
isOpen={props.isOpen}
onClose={props.onClose}
footer={footer}
size="medium"
title={`Редактирование пользователя #${props.user.id}`}
size="large"
>
<div class={styles.form}>
{errors().general && (
<div class={styles.error} style={{ 'margin-bottom': '20px' }}>
{errors().general}
</div>
)}
{/* Информационная секция */}
<div
class={styles.section}
style={{
'margin-bottom': '20px',
padding: '15px',
background: '#f8f9fa',
'border-radius': '8px'
}}
>
<h4 style={{ margin: '0 0 10px 0', color: '#495057' }}>Системная информация</h4>
<div style={{ 'font-size': '14px', color: '#6c757d' }}>
<div class={formStyles.form}>
{/* Компактная системная информация */}
<div class={formStyles.fieldGroup}>
<div
style={{
display: 'grid',
'grid-template-columns': 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '1rem',
padding: '1rem',
background: 'var(--form-bg-light)',
'font-size': '0.875rem',
color: 'var(--form-text-light)'
}}
>
<div>
<strong>ID:</strong> {props.user.id}
</div>
<div>
<strong>Дата регистрации:</strong> {formatDate(props.user.created_at)}
<strong>Регистрация:</strong>{' '}
{props.user.created_at
? new Date(props.user.created_at * 1000).toLocaleDateString('ru-RU')
: '—'}
</div>
<div>
<strong>Последняя активность:</strong> {formatDate(props.user.last_seen)}
<strong>Активность:</strong>{' '}
{props.user.last_seen
? new Date(props.user.last_seen * 1000).toLocaleDateString('ru-RU')
: '—'}
</div>
</div>
</div>
{/* Основные данные */}
<div class={styles.section}>
<h4 style={{ margin: '0 0 15px 0', color: '#495057' }}>Основные данные</h4>
{/* Текущие роли в строку */}
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>🎭</span>
Текущие роли
</span>
</label>
<div
style={{
padding: '0.875rem 1rem',
background: isAdmin() ? 'rgba(245, 158, 11, 0.1)' : 'var(--form-bg-light)',
border: isAdmin() ? '1px solid rgba(245, 158, 11, 0.3)' : '1px solid var(--form-divider)',
'font-size': '0.95rem',
'font-weight': '500',
color: isAdmin() ? '#d97706' : 'var(--form-text)'
}}
>
{getRolesDisplay()}
</div>
</div>
<div class={styles.field}>
<label for="email" class={styles.label}>
Email <span style={{ color: 'red' }}>*</span>
{/* Основные данные в компактной сетке */}
<div
style={{
display: 'grid',
'grid-template-columns': 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '1rem'
}}
>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>📧</span>
Email
<span class={formStyles.required}>*</span>
</span>
</label>
<input
id="email"
type="email"
class={`${styles.input} ${errors().email ? styles.inputError : ''}`}
class={`${formStyles.input} ${errors().email ? formStyles.error : ''}`}
value={formData().email}
onInput={(e) => updateField('email', e.currentTarget.value)}
disabled={loading()}
placeholder="user@example.com"
/>
{errors().email && <div class={styles.fieldError}>{errors().email}</div>}
{errors().email && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().email}
</div>
)}
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
Администраторы определяются автоматически по настройкам сервера
</div>
</div>
<div class={styles.field}>
<label for="name" class={styles.label}>
Имя <span style={{ color: 'red' }}>*</span>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>👤</span>
Имя
<span class={formStyles.required}>*</span>
</span>
</label>
<input
id="name"
type="text"
class={`${styles.input} ${errors().name ? styles.inputError : ''}`}
class={`${formStyles.input} ${errors().name ? formStyles.error : ''}`}
value={formData().name}
onInput={(e) => updateField('name', e.currentTarget.value)}
disabled={loading()}
placeholder="Иван Иванов"
/>
{errors().name && <div class={styles.fieldError}>{errors().name}</div>}
{errors().name && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().name}
</div>
)}
</div>
<div class={styles.field}>
<label for="slug" class={styles.label}>
Slug (URL) <span style={{ color: 'red' }}>*</span>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>🔗</span>
Slug (URL)
<span class={formStyles.required}>*</span>
</span>
</label>
<input
id="slug"
type="text"
class={`${styles.input} ${errors().slug ? styles.inputError : ''}`}
class={`${formStyles.input} ${errors().slug ? formStyles.error : ''}`}
value={formData().slug}
onInput={(e) => updateField('slug', e.currentTarget.value.toLowerCase())}
disabled={loading()}
placeholder="ivan-ivanov"
/>
<div class={styles.fieldHint}>
Используется в URL профиля. Только латинские буквы, цифры, дефисы и подчеркивания.
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
Только латинские буквы, цифры, дефисы и подчеркивания
</div>
{errors().slug && <div class={styles.fieldError}>{errors().slug}</div>}
{errors().slug && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().slug}
</div>
)}
</div>
</div>
{/* Роли */}
<div class={styles.section}>
<h4 style={{ margin: '0 0 15px 0', color: '#495057' }}>
Роли <span style={{ color: 'red' }}>*</span>
</h4>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}></span>
Управление ролями
<span class={formStyles.required} style={{ display: isAdmin() ? 'none' : 'inline' }}>
*
</span>
</span>
</label>
<div class={styles.rolesGrid}>
<div class={formStyles.rolesGrid}>
<For each={AVAILABLE_ROLES}>
{(role) => (
<label
class={`${styles.roleCard} ${formData().roles.includes(role.id) ? styles.roleCardSelected : ''}`}
class={`${formStyles.roleCard} ${formData().roles.includes(role.id) ? formStyles.roleCardSelected : ''}`}
>
<input
type="checkbox"
@@ -251,18 +350,61 @@ const UserEditModal: Component<UserEditModalProps> = (props) => {
disabled={loading()}
style={{ display: 'none' }}
/>
<div class={styles.roleHeader}>
<span class={styles.roleName}>{role.name}</span>
<span class={styles.roleCheckmark}>
<div class={formStyles.roleHeader}>
<span class={formStyles.roleName}>
<span style={{ 'margin-right': '0.5rem', 'font-size': '1.1rem' }}>{role.emoji}</span>
{role.name}
</span>
<span class={formStyles.roleCheckmark}>
{formData().roles.includes(role.id) ? '✓' : ''}
</span>
</div>
<div class={styles.roleDescription}>{role.description}</div>
<div class={formStyles.roleDescription}>{role.description}</div>
</label>
)}
</For>
</div>
{errors().roles && <div class={styles.fieldError}>{errors().roles}</div>}
{!isAdmin() && errors().roles && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().roles}
</div>
)}
<div class={formStyles.hint}>
<span class={formStyles.hintIcon}>💡</span>
{isAdmin()
? 'Администраторы имеют все права автоматически. Дополнительные роли опциональны.'
: 'Выберите роли для пользователя. Минимум одна роль обязательна.'}
</div>
</div>
{/* Общая ошибка */}
{errors().general && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{errors().general}
</div>
)}
{/* Компактные кнопки действий */}
<div
style={{
display: 'flex',
gap: '0.75rem',
'justify-content': 'flex-end',
'margin-top': '1.5rem',
'padding-top': '1rem',
'border-top': '1px solid var(--form-divider)'
}}
>
<Button variant="secondary" onClick={props.onClose} disabled={loading()}>
Отмена
</Button>
<Button variant="primary" onClick={handleSave} loading={loading()}>
Сохранить
</Button>
</div>
</div>
</Modal>

View File

@@ -1,8 +1,8 @@
import { Component, For } from 'solid-js'
import type { AdminShoutInfo, Maybe, Topic } from '../graphql/generated/schema'
import styles from '../styles/Modal.module.css'
import CodePreview from '../ui/CodePreview'
import Modal from '../ui/Modal'
import TextPreview from '../ui/TextPreview'
export interface ShoutBodyModalProps {
shout: AdminShoutInfo
@@ -41,7 +41,7 @@ const ShoutBodyModal: Component<ShoutBodyModalProps> = (props) => {
<div class={styles['shout-content']}>
<h3>Содержание</h3>
<div class={styles['content-preview']}>
<TextPreview content={props.shout.body || ''} maxHeight="85vh" />
<CodePreview content={props.shout.body || ''} maxHeight="85vh" language="html" autoFormat />
</div>
</div>
</div>

View File

@@ -1,185 +1,346 @@
import { Component, createEffect, createSignal } from 'solid-js'
import formStyles from '../styles/Form.module.css'
import styles from '../styles/Modal.module.css'
import Button from '../ui/Button'
import { createEffect, createSignal, For, Show } from 'solid-js'
import { Topic, useData } from '../context/data'
import styles from '../styles/Form.module.css'
import modalStyles from '../styles/Modal.module.css'
import EditableCodePreview from '../ui/EditableCodePreview'
import Modal from '../ui/Modal'
interface Topic {
id: number
slug: string
title: string
body?: string
pic?: string
community: number
parent_ids?: number[]
}
interface TopicEditModalProps {
topic: Topic
isOpen: boolean
topic: Topic | null
onClose: () => void
onSave: (topic: Topic) => void
onSave: (updatedTopic: Topic) => void
onError?: (message: string) => void
}
/**
* Модальное окно для редактирования топиков
*/
const TopicEditModal: Component<TopicEditModalProps> = (props) => {
const [formData, setFormData] = createSignal<Topic>({
export default function TopicEditModal(props: TopicEditModalProps) {
const { communities, topics, getCommunityName, selectedCommunity } = useData()
// Состояние формы
const [formData, setFormData] = createSignal({
id: 0,
slug: '',
title: '',
slug: '',
body: '',
pic: '',
community: 0,
parent_ids: []
parent_ids: [] as number[]
})
const [parentIdsText, setParentIdsText] = createSignal('')
let bodyRef: HTMLDivElement | undefined
// Состояние для выбора родителей
const [availableParents, setAvailableParents] = createSignal<Topic[]>([])
const [parentSearch, setParentSearch] = createSignal('')
// Синхронизация с props.topic
// Состояние для редактирования body
const [showBodyEditor, setShowBodyEditor] = createSignal(false)
const [bodyContent, setBodyContent] = createSignal('')
const [saving, setSaving] = createSignal(false)
// Инициализация формы при открытии
createEffect(() => {
if (props.topic) {
setFormData({ ...props.topic })
setParentIdsText(props.topic.parent_ids?.join(', ') || '')
// Устанавливаем содержимое в contenteditable div
if (bodyRef) {
bodyRef.innerHTML = props.topic.body || ''
}
if (props.isOpen && props.topic) {
console.log('[TopicEditModal] Initializing with topic:', props.topic)
setFormData({
id: props.topic.id,
title: props.topic.title || '',
slug: props.topic.slug || '',
body: props.topic.body || '',
community: selectedCommunity() || 0,
parent_ids: props.topic.parent_ids || []
})
setBodyContent(props.topic.body || '')
updateAvailableParents(selectedCommunity() || 0)
}
})
const handleSave = () => {
// Парсим parent_ids из строки
const parentIds = parentIdsText()
.split(',')
.map((id) => Number.parseInt(id.trim()))
.filter((id) => !Number.isNaN(id))
// Обновление доступных родителей при смене сообщества
const updateAvailableParents = (communityId: number) => {
const allTopics = topics()
const currentTopicId = formData().id
const updatedTopic = {
...formData(),
parent_ids: parentIds.length > 0 ? parentIds : undefined
}
// Фильтруем топики того же сообщества, исключая текущий топик
const filteredTopics = allTopics.filter(
(topic) => topic.community === communityId && topic.id !== currentTopicId
)
props.onSave(updatedTopic)
setAvailableParents(filteredTopics)
}
const handleBodyInput = (e: Event) => {
const target = e.target as HTMLDivElement
setFormData((prev) => ({ ...prev, body: target.innerHTML }))
// Фильтрация родителей по поиску
const filteredParents = () => {
const search = parentSearch().toLowerCase()
if (!search) return availableParents()
return availableParents().filter(
(topic) => topic.title?.toLowerCase().includes(search) || topic.slug?.toLowerCase().includes(search)
)
}
// Обработка изменения сообщества
const handleCommunityChange = (e: Event) => {
const target = e.target as HTMLSelectElement
const communityId = Number.parseInt(target.value)
setFormData((prev) => ({
...prev,
community: communityId,
parent_ids: [] // Сбрасываем родителей при смене сообщества
}))
updateAvailableParents(communityId)
}
// Обработка изменения родителей
const handleParentToggle = (parentId: number) => {
setFormData((prev) => ({
...prev,
parent_ids: prev.parent_ids.includes(parentId)
? prev.parent_ids.filter((id) => id !== parentId)
: [...prev.parent_ids, parentId]
}))
}
// Обработка изменения полей формы
const handleFieldChange = (field: string, value: string) => {
setFormData((prev) => ({
...prev,
[field]: value
}))
}
// Открытие редактора body
const handleOpenBodyEditor = () => {
setBodyContent(formData().body)
setShowBodyEditor(true)
}
// Сохранение body из редактора
const handleBodySave = (content: string) => {
setFormData((prev) => ({
...prev,
body: content
}))
setBodyContent(content)
setShowBodyEditor(false)
}
// Получение пути до корня для топика
const getTopicPath = (topicId: number): string => {
const topic = topics().find((t) => t.id === topicId)
if (!topic) return 'Неизвестный топик'
const community = getCommunityName(topic.community)
return `${community}${topic.title}`
}
// Сохранение изменений
const handleSave = async () => {
try {
setSaving(true)
const updatedTopic = {
...props.topic,
...formData()
}
console.log('[TopicEditModal] Saving topic:', updatedTopic)
// TODO: Здесь должен быть вызов API для сохранения
// await updateTopic(updatedTopic)
props.onSave(updatedTopic)
props.onClose()
} catch (error) {
console.error('[TopicEditModal] Error saving topic:', error)
props.onError?.(error instanceof Error ? error.message : 'Ошибка сохранения топика')
} finally {
setSaving(false)
}
}
return (
<Modal
isOpen={props.isOpen}
onClose={props.onClose}
title={`Редактирование топика: ${props.topic?.title || ''}`}
>
<div class={styles['modal-content']}>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>ID</label>
<input
type="text"
value={formData().id}
disabled
class={formStyles.input}
style={{ background: '#f5f5f5', cursor: 'not-allowed' }}
/>
</div>
<>
<Modal
isOpen={props.isOpen && !showBodyEditor()}
onClose={props.onClose}
title="Редактирование топика"
size="large"
>
<div class={styles.form}>
{/* Основная информация */}
<div class={styles.section}>
<h3>Основная информация</h3>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>Slug</label>
<input
type="text"
value={formData().slug}
onInput={(e) => setFormData((prev) => ({ ...prev, slug: e.target.value }))}
class={formStyles.input}
required
/>
</div>
<div class={styles.field}>
<label class={styles.label}>
Название:
<input
type="text"
class={styles.input}
value={formData().title}
onInput={(e) => handleFieldChange('title', e.currentTarget.value)}
placeholder="Введите название топика..."
/>
</label>
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>Название</label>
<input
type="text"
value={formData().title}
onInput={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
class={formStyles.input}
/>
</div>
<div class={styles.field}>
<label class={styles.label}>
Slug:
<input
type="text"
class={styles.input}
value={formData().slug}
onInput={(e) => handleFieldChange('slug', e.currentTarget.value)}
placeholder="Введите slug топика..."
/>
</label>
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>Описание (HTML)</label>
<div
ref={bodyRef}
contentEditable
onInput={handleBodyInput}
class={formStyles.input}
style={{
'min-height': '120px',
'font-family': 'Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
'font-size': '13px',
'line-height': '1.4',
'white-space': 'pre-wrap',
'overflow-wrap': 'break-word'
}}
data-placeholder="Введите HTML описание топика..."
/>
</div>
<div class={styles.field}>
<label class={styles.label}>
Сообщество:
<select class={styles.select} value={formData().community} onChange={handleCommunityChange}>
<option value={0}>Выберите сообщество</option>
<For each={communities()}>
{(community) => <option value={community.id}>{community.name}</option>}
</For>
</select>
</label>
</div>
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>Картинка (URL)</label>
<input
type="text"
value={formData().pic || ''}
onInput={(e) => setFormData((prev) => ({ ...prev, pic: e.target.value }))}
class={formStyles.input}
placeholder="https://example.com/image.jpg"
/>
</div>
{/* Содержимое */}
<div class={styles.section}>
<h3>Содержимое</h3>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>Сообщество (ID)</label>
<input
type="number"
value={formData().community}
onInput={(e) =>
setFormData((prev) => ({ ...prev, community: Number.parseInt(e.target.value) || 0 }))
}
class={formStyles.input}
min="0"
/>
</div>
<div class={styles.field}>
<label class={styles.label}>Body:</label>
<div class={styles.bodyPreview} onClick={handleOpenBodyEditor}>
<Show when={formData().body}>
<div class={styles.bodyContent}>
{formData().body.length > 200
? `${formData().body.substring(0, 200)}...`
: formData().body}
</div>
</Show>
<Show when={!formData().body}>
<div class={styles.bodyPlaceholder}>Нет содержимого. Нажмите для редактирования.</div>
</Show>
<div class={styles.bodyHint}> Кликните для редактирования в полноэкранном редакторе</div>
</div>
</div>
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>
Родительские топики (ID через запятую)
<small style={{ display: 'block', color: '#666', 'margin-top': '4px' }}>
Например: 1, 5, 12
</small>
</label>
<input
type="text"
value={parentIdsText()}
onInput={(e) => setParentIdsText(e.target.value)}
class={formStyles.input}
placeholder="1, 5, 12"
/>
</div>
{/* Родительские топики */}
<Show when={formData().community > 0}>
<div class={styles.section}>
<h3>Родительские топики</h3>
<div class={styles['modal-actions']}>
<Button variant="secondary" onClick={props.onClose}>
Отмена
</Button>
<Button variant="primary" onClick={handleSave}>
Сохранить
</Button>
<div class={styles.field}>
<label class={styles.label}>
Поиск родителей:
<input
type="text"
class={styles.input}
value={parentSearch()}
onInput={(e) => setParentSearch(e.currentTarget.value)}
placeholder="Введите название для поиска..."
/>
</label>
</div>
<Show when={formData().parent_ids.length > 0}>
<div class={styles.selectedParents}>
<strong>Выбранные родители:</strong>
<ul class={styles.parentsList}>
<For each={formData().parent_ids}>
{(parentId) => (
<li class={styles.parentItem}>
<span>{getTopicPath(parentId)}</span>
<button
type="button"
class={styles.removeButton}
onClick={() => handleParentToggle(parentId)}
>
</button>
</li>
)}
</For>
</ul>
</div>
</Show>
<div class={styles.availableParents}>
<strong>Доступные родители:</strong>
<div class={styles.parentsGrid}>
<For each={filteredParents()}>
{(parent) => (
<label class={styles.parentCheckbox}>
<input
type="checkbox"
checked={formData().parent_ids.includes(parent.id)}
onChange={() => handleParentToggle(parent.id)}
/>
<span class={styles.parentLabel}>
<strong>{parent.title}</strong>
<br />
<small>{parent.slug}</small>
</span>
</label>
)}
</For>
</div>
<Show when={filteredParents().length === 0}>
<div class={styles.noParents}>
<Show when={parentSearch()}>Не найдено топиков по запросу "{parentSearch()}"</Show>
<Show when={!parentSearch()}>Нет доступных родительских топиков в этом сообществе</Show>
</div>
</Show>
</div>
</div>
</Show>
{/* Кнопки */}
<div class={modalStyles.modalActions}>
<button
type="button"
class={`${styles.button} ${styles.buttonSecondary}`}
onClick={props.onClose}
disabled={saving()}
>
Отмена
</button>
<button
type="button"
class={`${styles.button} ${styles.buttonPrimary}`}
onClick={handleSave}
disabled={saving() || !formData().title || !formData().slug || formData().community === 0}
>
{saving() ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
</div>
</Modal>
</Modal>
{/* Редактор body */}
<Modal
isOpen={showBodyEditor()}
onClose={() => setShowBodyEditor(false)}
title="Редактирование содержимого топика"
size="large"
>
<EditableCodePreview
content={bodyContent()}
maxHeight="85vh"
onContentChange={setBodyContent}
onSave={handleBodySave}
onCancel={() => setShowBodyEditor(false)}
placeholder="Введите содержимое топика..."
/>
</Modal>
</>
)
}
export default TopicEditModal

View File

@@ -1,4 +1,4 @@
import { Component, createSignal, For, JSX, Show } from 'solid-js'
import { createSignal, For, JSX, Show } from 'solid-js'
import styles from '../styles/Form.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
@@ -262,7 +262,13 @@ const TopicHierarchyModal = (props: TopicHierarchyModalProps) => {
'background-color': isSelected ? '#e3f2fd' : isTarget ? '#d4edda' : 'transparent'
}}
>
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px' }}>
<div
style={{
display: 'flex',
'align-items': 'center',
gap: '8px'
}}
>
<Show when={hasChildren}>
<button
onClick={(e) => {

View File

@@ -5,18 +5,20 @@
import { useNavigate, useParams } from '@solidjs/router'
import { Component, createEffect, createSignal, onMount, Show } from 'solid-js'
import publyLogo from './assets/publy.svg?url'
import { logout } from './context/auth'
import publyLogo from '../assets/publy.svg?url'
import { logout } from '../context/auth'
import styles from '../styles/Admin.module.css'
import Button from '../ui/Button'
import CommunitySelector from '../ui/CommunitySelector'
import LanguageSwitcher from '../ui/LanguageSwitcher'
// Прямой импорт компонентов вместо ленивой загрузки
import AuthorsRoute from './routes/authors'
import CollectionsRoute from './routes/collections'
import CommunitiesRoute from './routes/communities'
import EnvRoute from './routes/env'
import InvitesRoute from './routes/invites'
import ShoutsRoute from './routes/shouts'
import TopicsRoute from './routes/topics'
import styles from './styles/Admin.module.css'
import Button from './ui/Button'
import AuthorsRoute from './authors'
import CollectionsRoute from './collections'
import CommunitiesRoute from './communities'
import EnvRoute from './env'
import InvitesRoute from './invites'
import ShoutsRoute from './shouts'
import { Topics as TopicsRoute } from './topics'
/**
* Интерфейс свойств компонента AdminPage
@@ -57,13 +59,6 @@ const AdminPage: Component<AdminPageProps> = (props) => {
console.log('[AdminPage] Updated currentTab to:', newTab)
})
// Определяем активную вкладку
const activeTab = () => {
const tab = currentTab()
console.log('[AdminPage] activeTab() returning:', tab)
return tab
}
/**
* Обрабатывает выход из системы
*/
@@ -103,52 +98,59 @@ const AdminPage: Component<AdminPageProps> = (props) => {
<div class={styles['header-container']}>
<div class={styles['header-left']}>
<img src={publyLogo} alt="Logo" class={styles.logo} />
<h1>Панель администратора</h1>
<h1>
Панель администратора
<span class={styles['version-badge']}>v{__APP_VERSION__}</span>
</h1>
</div>
<div class={styles['header-right']}>
<CommunitySelector />
<LanguageSwitcher />
<button class={styles['logout-button']} onClick={handleLogout}>
Выйти
</button>
</div>
<button class={styles['logout-button']} onClick={handleLogout}>
Выйти
</button>
</div>
<nav class={styles['admin-tabs']}>
<Button
variant={activeTab() === 'authors' ? 'primary' : 'secondary'}
variant={currentTab() === 'authors' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/authors')}
>
Авторы
</Button>
<Button
variant={activeTab() === 'shouts' ? 'primary' : 'secondary'}
variant={currentTab() === 'shouts' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/shouts')}
>
Публикации
</Button>
<Button
variant={activeTab() === 'topics' ? 'primary' : 'secondary'}
variant={currentTab() === 'topics' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/topics')}
>
Темы
</Button>
<Button
variant={activeTab() === 'communities' ? 'primary' : 'secondary'}
variant={currentTab() === 'communities' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/communities')}
>
Сообщества
</Button>
<Button
variant={activeTab() === 'collections' ? 'primary' : 'secondary'}
variant={currentTab() === 'collections' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/collections')}
>
Коллекции
</Button>
<Button
variant={activeTab() === 'invites' ? 'primary' : 'secondary'}
variant={currentTab() === 'invites' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/invites')}
>
Приглашения
</Button>
<Button
variant={activeTab() === 'env' ? 'primary' : 'secondary'}
variant={currentTab() === 'env' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/env')}
>
Переменные среды
@@ -166,31 +168,31 @@ const AdminPage: Component<AdminPageProps> = (props) => {
</Show>
{/* Используем Show компоненты для каждой вкладки */}
<Show when={activeTab() === 'authors'}>
<Show when={currentTab() === 'authors'}>
<AuthorsRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={activeTab() === 'shouts'}>
<Show when={currentTab() === 'shouts'}>
<ShoutsRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={activeTab() === 'topics'}>
<Show when={currentTab() === 'topics'}>
<TopicsRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={activeTab() === 'communities'}>
<Show when={currentTab() === 'communities'}>
<CommunitiesRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={activeTab() === 'collections'}>
<Show when={currentTab() === 'collections'}>
<CollectionsRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={activeTab() === 'invites'}>
<Show when={currentTab() === 'invites'}>
<InvitesRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={activeTab() === 'env'}>
<Show when={currentTab() === 'env'}>
<EnvRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
</main>

View File

@@ -1,4 +1,6 @@
import { Component, createSignal, For, onMount, Show } from 'solid-js'
import type { AuthorsSortField } from '../context/sort'
import { AUTHORS_SORT_CONFIG } from '../context/sortConfig'
import { query } from '../graphql'
import type { Query, AdminUserInfo as User } from '../graphql/generated/schema'
import { ADMIN_UPDATE_USER_MUTATION } from '../graphql/mutations'
@@ -6,6 +8,8 @@ import { ADMIN_GET_USERS_QUERY } from '../graphql/queries'
import UserEditModal from '../modals/RolesModal'
import styles from '../styles/Admin.module.css'
import Pagination from '../ui/Pagination'
import SortableHeader from '../ui/SortableHeader'
import TableControls from '../ui/TableControls'
import { formatDateRelative } from '../utils/date'
export interface AuthorsRouteProps {
@@ -28,7 +32,7 @@ const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
totalPages: number
}>({
page: 1,
limit: 10,
limit: 20,
total: 0,
totalPages: 1
})
@@ -63,7 +67,7 @@ const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
}
} catch (error) {
console.error('[AuthorsRoute] Failed to load authors:', error)
props.onError?.(error instanceof Error ? error.message : 'Failed to load authors')
props.onError?.(error instanceof Error ? error.message : 'Не удалось загрузить список пользователей')
} finally {
setLoading(false)
}
@@ -131,9 +135,8 @@ const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
}
// Search handlers
function handleSearchChange(e: Event) {
const input = e.target as HTMLInputElement
setSearchQuery(input.value)
function handleSearchChange(value: string) {
setSearchQuery(value)
}
function handleSearch() {
@@ -141,13 +144,6 @@ const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
void loadUsers()
}
function handleSearchKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
handleSearch()
}
}
// Load authors on mount
onMount(() => {
console.log('[AuthorsRoute] Component mounted, loading authors...')
@@ -155,34 +151,40 @@ const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
})
/**
* Компонент для отображения роли с иконкой
* Компонент для отображения роли с эмоджи и тултипом
*/
const RoleBadge: Component<{ role: string }> = (props) => {
const getRoleIcon = (role: string): string => {
switch (role.toLowerCase()) {
switch (role.toLowerCase().trim()) {
case 'администратор':
case 'admin':
return '👑'
return '🪄'
case 'редактор':
case 'editor':
return ''
return ''
case 'эксперт':
case 'expert':
return '🎓'
return '🔬'
case 'автор':
case 'author':
return '📝'
case 'читатель':
case 'reader':
return '👤'
return '📖'
case 'banned':
case 'заблокирован':
return '🚫'
case 'verified':
case 'проверен':
return '✓'
default:
return '👤'
return '🎭'
}
}
return (
<span class="role-badge" title={props.role}>
<span class="role-icon">{getRoleIcon(props.role)}</span>
<span class="role-name">{props.role}</span>
<span title={props.role} style={{ 'margin-right': '0.25rem' }}>
{getRoleIcon(props.role)}
</span>
)
}
@@ -198,57 +200,67 @@ const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
</Show>
<Show when={!loading() && authors().length > 0}>
<div class={styles['authors-controls']}>
<div class={styles['search-container']}>
<div class={styles['search-input-group']}>
<input
type="text"
placeholder="Поиск по email, имени или ID..."
value={searchQuery()}
onInput={handleSearchChange}
onKeyDown={handleSearchKeyDown}
class={styles['search-input']}
/>
<button class={styles['search-button']} onClick={handleSearch}>
Поиск
</button>
</div>
</div>
</div>
<TableControls
searchValue={searchQuery()}
onSearchChange={handleSearchChange}
onSearch={handleSearch}
searchPlaceholder="Поиск по email, имени или ID..."
isLoading={loading()}
/>
<div class={styles['authors-list']}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Имя</th>
<th>Создан</th>
<SortableHeader
field={'id' as AuthorsSortField}
allowedFields={AUTHORS_SORT_CONFIG.allowedFields}
>
ID
</SortableHeader>
<SortableHeader
field={'email' as AuthorsSortField}
allowedFields={AUTHORS_SORT_CONFIG.allowedFields}
>
Email
</SortableHeader>
<SortableHeader
field={'name' as AuthorsSortField}
allowedFields={AUTHORS_SORT_CONFIG.allowedFields}
>
Имя
</SortableHeader>
<SortableHeader
field={'created_at' as AuthorsSortField}
allowedFields={AUTHORS_SORT_CONFIG.allowedFields}
>
Создан
</SortableHeader>
<th>Роли</th>
</tr>
</thead>
<tbody>
<For each={authors()}>
{(user) => (
<tr>
<tr
onClick={() => {
setSelectedUser(user)
setShowEditModal(true)
}}
>
<td>{user.id}</td>
<td>{user.email}</td>
<td>{user.name || '-'}</td>
<td>{formatDateRelative(user.created_at || Date.now())}</td>
<td>{formatDateRelative(user.created_at || Date.now())()}</td>
<td class={styles['roles-cell']}>
<div class={styles['roles-container']}>
<For each={Array.from(user.roles || []).filter(Boolean)}>
{(role) => <RoleBadge role={role} />}
</For>
<div
class={styles['role-badge edit-role-badge']}
onClick={() => {
setSelectedUser(user)
setShowEditModal(true)
}}
>
<span class={styles['role-icon']}>🎭</span>
</div>
{/* Показываем сообщение если ролей нет */}
{(!user.roles || user.roles.length === 0) && (
<span style="color: #999; font-size: 0.875rem;">Нет ролей</span>
)}
</div>
</td>
</tr>

View File

@@ -9,6 +9,7 @@ import CollectionEditModal from '../modals/CollectionEditModal'
import styles from '../styles/Table.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import TableControls from '../ui/TableControls'
/**
* Интерфейс для коллекции
@@ -39,12 +40,20 @@ interface CollectionsRouteProps {
*/
const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
const [collections, setCollections] = createSignal<Collection[]>([])
const [filteredCollections, setFilteredCollections] = createSignal<Collection[]>([])
const [loading, setLoading] = createSignal(false)
const [editModal, setEditModal] = createSignal<{ show: boolean; collection: Collection | null }>({
const [searchQuery, setSearchQuery] = createSignal('')
const [editModal, setEditModal] = createSignal<{
show: boolean
collection: Collection | null
}>({
show: false,
collection: null
})
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; collection: Collection | null }>({
const [deleteModal, setDeleteModal] = createSignal<{
show: boolean
collection: Collection | null
}>({
show: false,
collection: null
})
@@ -72,7 +81,9 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
throw new Error(result.errors[0].message)
}
setCollections(result.data.get_collections_all || [])
const allCollections = result.data.get_collections_all || []
setCollections(allCollections)
filterCollections(allCollections, searchQuery())
} catch (error) {
props.onError(`Ошибка загрузки коллекций: ${(error as Error).message}`)
} finally {
@@ -80,6 +91,42 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
}
}
/**
* Фильтрует коллекции по поисковому запросу
*/
const filterCollections = (allCollections: Collection[], query: string) => {
if (!query) {
setFilteredCollections(allCollections)
return
}
const lowerQuery = query.toLowerCase()
const filtered = allCollections.filter(
(collection) =>
collection.title.toLowerCase().includes(lowerQuery) ||
collection.slug.toLowerCase().includes(lowerQuery) ||
collection.id.toString().includes(lowerQuery) ||
collection.desc?.toLowerCase().includes(lowerQuery)
)
setFilteredCollections(filtered)
}
/**
* Обрабатывает изменение поискового запроса
*/
const handleSearchChange = (value: string) => {
setSearchQuery(value)
filterCollections(collections(), value)
}
/**
* Обработчик поиска - применяет текущий поисковый запрос
*/
const handleSearch = () => {
filterCollections(collections(), searchQuery())
console.log('[CollectionsRoute] Search triggered with query:', searchQuery())
}
/**
* Форматирует дату
*/
@@ -179,20 +226,23 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
// Загружаем коллекции при монтировании компонента
onMount(() => {
void loadCollections()
setFilteredCollections(collections())
})
return (
<div class={styles.container}>
<div class={styles.header}>
<div style={{ display: 'flex', gap: '10px' }}>
<Button onClick={openCreateModal} variant="primary">
<TableControls
isLoading={loading()}
searchValue={searchQuery()}
onSearchChange={handleSearchChange}
onSearch={handleSearch}
searchPlaceholder="Поиск по названию, slug или ID..."
actions={
<button class={`${styles.button} ${styles.primary}`} onClick={openCreateModal}>
Создать коллекцию
</Button>
<Button onClick={loadCollections} disabled={loading()}>
{loading() ? 'Загрузка...' : 'Обновить'}
</Button>
</div>
</div>
</button>
}
/>
<Show
when={!loading()}
@@ -218,7 +268,7 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
</tr>
</thead>
<tbody>
<For each={collections()}>
<For each={filteredCollections()}>
{(collection) => (
<tr
onClick={() => openEditModal(collection)}

View File

@@ -1,4 +1,6 @@
import { Component, createSignal, For, onMount, Show } from 'solid-js'
import { Component, createEffect, createSignal, For, on, onMount, Show, untrack } from 'solid-js'
import { useTableSort } from '../context/sort'
import { COMMUNITIES_SORT_CONFIG } from '../context/sortConfig'
import {
CREATE_COMMUNITY_MUTATION,
DELETE_COMMUNITY_MUTATION,
@@ -9,6 +11,8 @@ import CommunityEditModal from '../modals/CommunityEditModal'
import styles from '../styles/Table.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import SortableHeader from '../ui/SortableHeader'
import TableControls from '../ui/TableControls'
/**
* Интерфейс для сообщества (используем локальный интерфейс для совместимости)
@@ -43,11 +47,18 @@ interface CommunitiesRouteProps {
const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
const [communities, setCommunities] = createSignal<Community[]>([])
const [loading, setLoading] = createSignal(false)
const [editModal, setEditModal] = createSignal<{ show: boolean; community: Community | null }>({
const { sortState } = useTableSort()
const [editModal, setEditModal] = createSignal<{
show: boolean
community: Community | null
}>({
show: false,
community: null
})
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; community: Community | null }>({
const [deleteModal, setDeleteModal] = createSignal<{
show: boolean
community: Community | null
}>({
show: false,
community: null
})
@@ -61,6 +72,8 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
const loadCommunities = async () => {
setLoading(true)
try {
// Загружаем все сообщества без параметров сортировки
// Сортировка будет выполнена на клиенте
const response = await fetch('/graphql', {
method: 'POST',
headers: {
@@ -77,7 +90,10 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
throw new Error(result.errors[0].message)
}
setCommunities(result.data.get_communities_all || [])
// Получаем данные и сортируем их на клиенте
const communitiesData = result.data.get_communities_all || []
const sortedCommunities = sortCommunities(communitiesData)
setCommunities(sortedCommunities)
} catch (error) {
props.onError(`Ошибка загрузки сообществ: ${(error as Error).message}`)
} finally {
@@ -92,6 +108,51 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
return new Date(timestamp * 1000).toLocaleDateString('ru-RU')
}
/**
* Сортирует сообщества на клиенте в соответствии с текущим состоянием сортировки
*/
const sortCommunities = (communities: Community[]): Community[] => {
const { field, direction } = sortState()
return [...communities].sort((a, b) => {
let comparison = 0
switch (field) {
case 'id':
comparison = a.id - b.id
break
case 'name':
comparison = (a.name || '').localeCompare(b.name || '', 'ru')
break
case 'slug':
comparison = (a.slug || '').localeCompare(b.slug || '', 'ru')
break
case 'created_at':
comparison = a.created_at - b.created_at
break
case 'created_by': {
const aName = a.created_by?.name || a.created_by?.email || ''
const bName = b.created_by?.name || b.created_by?.email || ''
comparison = aName.localeCompare(bName, 'ru')
break
}
case 'shouts':
comparison = (a.stat?.shouts || 0) - (b.stat?.shouts || 0)
break
case 'followers':
comparison = (a.stat?.followers || 0) - (b.stat?.followers || 0)
break
case 'authors':
comparison = (a.stat?.authors || 0) - (b.stat?.authors || 0)
break
default:
comparison = a.id - b.id
}
return direction === 'desc' ? -comparison : comparison
})
}
/**
* Открывает модалку создания
*/
@@ -181,6 +242,26 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
}
}
// Пересортировка при изменении состояния сортировки
createEffect(
on([sortState], () => {
if (communities().length > 0) {
// Используем untrack для предотвращения бесконечной рекурсии
const currentCommunities = untrack(() => communities())
const sortedCommunities = sortCommunities(currentCommunities)
// Сравниваем текущий порядок с отсортированным, чтобы избежать лишних обновлений
const needsUpdate =
JSON.stringify(currentCommunities.map((c: Community) => c.id)) !==
JSON.stringify(sortedCommunities.map((c: Community) => c.id))
if (needsUpdate) {
setCommunities(sortedCommunities)
}
}
})
)
// Загружаем сообщества при монтировании компонента
onMount(() => {
void loadCommunities()
@@ -188,14 +269,15 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
return (
<div class={styles.container}>
<div class={styles.header}>
<Button onClick={loadCommunities} disabled={loading()}>
{loading() ? 'Загрузка...' : 'Обновить'}
</Button>
<Button variant="primary" onClick={openCreateModal}>
Создать сообщество
</Button>
</div>
<TableControls
onRefresh={loadCommunities}
isLoading={loading()}
actions={
<Button variant="primary" onClick={openCreateModal}>
Создать сообщество
</Button>
}
/>
<Show
when={!loading()}
@@ -209,15 +291,29 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
<table class={styles.table}>
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Slug</th>
<SortableHeader field="id" allowedFields={COMMUNITIES_SORT_CONFIG.allowedFields}>
ID
</SortableHeader>
<SortableHeader field="name" allowedFields={COMMUNITIES_SORT_CONFIG.allowedFields}>
Название
</SortableHeader>
<SortableHeader field="slug" allowedFields={COMMUNITIES_SORT_CONFIG.allowedFields}>
Slug
</SortableHeader>
<th>Описание</th>
<th>Создатель</th>
<th>Публикации</th>
<th>Подписчики</th>
<SortableHeader field="created_by" allowedFields={COMMUNITIES_SORT_CONFIG.allowedFields}>
Создатель
</SortableHeader>
<SortableHeader field="shouts" allowedFields={COMMUNITIES_SORT_CONFIG.allowedFields}>
Публикации
</SortableHeader>
<SortableHeader field="followers" allowedFields={COMMUNITIES_SORT_CONFIG.allowedFields}>
Подписчики
</SortableHeader>
<th>Авторы</th>
<th>Создано</th>
<SortableHeader field="created_at" allowedFields={COMMUNITIES_SORT_CONFIG.allowedFields}>
Создано
</SortableHeader>
<th>Действия</th>
</tr>
</thead>

View File

@@ -5,6 +5,7 @@ import styles from '../styles/Table.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import Pagination from '../ui/Pagination'
import TableControls from '../ui/TableControls'
import { getAuthTokenFromCookie } from '../utils/auth'
/**
@@ -59,7 +60,7 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
const [statusFilter, setStatusFilter] = createSignal('all')
const [pagination, setPagination] = createSignal({
page: 1,
perPage: 10,
perPage: 20,
total: 0,
totalPages: 1
})
@@ -69,18 +70,26 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
const [selectAll, setSelectAll] = createSignal(false)
// Состояние для модального окна подтверждения удаления
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; invite: Invite | null }>({
const [deleteModal, setDeleteModal] = createSignal<{
show: boolean
invite: Invite | null
}>({
show: false,
invite: null
})
// Состояние для модального окна подтверждения пакетного удаления
const [batchDeleteModal, setBatchDeleteModal] = createSignal<{ show: boolean }>({
const [batchDeleteModal, setBatchDeleteModal] = createSignal<{
show: boolean
}>({
show: false
})
// Добавляю состояние сортировки
const [sortState, setSortState] = createSignal<SortState>({ field: null, direction: 'asc' })
const [sortState, setSortState] = createSignal<SortState>({
field: null,
direction: 'asc'
})
/**
* Загружает список приглашений с учетом фильтров и пагинации
@@ -122,7 +131,7 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
setInvites(data.invites || [])
setPagination({
page: data.page || 1,
perPage: data.perPage || 10,
perPage: data.perPage || 20,
total: data.total || 0,
totalPages: data.totalPages || 1
})
@@ -353,68 +362,49 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
return (
<div class={styles.container}>
{/* Новая компактная панель поиска и фильтров */}
<div class={styles.searchSection}>
<div class={styles.searchRow}>
<input
type="text"
placeholder="Поиск по приглашающему, приглашаемому, публикации..."
value={search()}
onInput={(e) => setSearch(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
class={styles.fullWidthSearch}
/>
</div>
<div class={styles.filtersRow}>
<select
value={statusFilter()}
onChange={(e) => handleStatusFilterChange(e.target.value)}
class={styles.statusFilter}
>
<option value="all">Все статусы</option>
<option value="pending">Ожидает ответа</option>
<option value="accepted">Принято</option>
<option value="rejected">Отклонено</option>
</select>
<Button onClick={handleSearch} disabled={loading()}>
🔍 Поиск
</Button>
<Button onClick={() => loadInvites(pagination().page)} disabled={loading()}>
{loading() ? 'Загрузка...' : '🔄 Обновить'}
</Button>
</div>
</div>
{/* Панель пакетных действий */}
<Show when={!loading() && invites().length > 0}>
<div class={styles['batch-actions']}>
<div class={styles['select-all-container']}>
<input
type="checkbox"
id="select-all"
checked={selectAll()}
onChange={(e) => handleSelectAll(e.target.checked)}
class={styles.checkbox}
/>
<label for="select-all" class={styles['select-all-label']}>
Выбрать все
</label>
</div>
<TableControls
searchValue={search()}
onSearchChange={(value) => setSearch(value)}
onSearch={handleSearch}
searchPlaceholder="Поиск по приглашающему, приглашаемому, публикации..."
isLoading={loading()}
actions={
<Show when={getSelectedCount() > 0}>
<div class={styles['selected-count']}>Выбрано: {getSelectedCount()}</div>
<button
class={styles['batch-delete-button']}
class={`${styles.button} ${styles.danger}`}
onClick={() => setBatchDeleteModal({ show: true })}
title="Удалить выбранные приглашения"
>
Удалить выбранные
Удалить выбранные ({getSelectedCount()})
</button>
</Show>
}
>
<select
value={statusFilter()}
onChange={(e) => handleStatusFilterChange(e.target.value)}
class={styles.statusFilter}
>
<option value="all">Все статусы</option>
<option value="pending">Ожидает ответа</option>
<option value="accepted">Принято</option>
<option value="rejected">Отклонено</option>
</select>
</TableControls>
{/* Панель выбора всех */}
<Show when={!loading() && invites().length > 0}>
<div class={styles['select-all-container']} style={{ 'margin-bottom': '10px' }}>
<input
type="checkbox"
id="select-all"
checked={selectAll()}
onChange={(e) => handleSelectAll(e.target.checked)}
class={styles.checkbox}
/>
<label for="select-all" class={styles['select-all-label']}>
Выбрать все
</label>
</div>
</Show>

View File

@@ -7,8 +7,10 @@ import { useNavigate } from '@solidjs/router'
import { createSignal, onMount } from 'solid-js'
import publyLogo from '../assets/publy.svg?url'
import { useAuth } from '../context/auth'
import formStyles from '../styles/Form.module.css'
import styles from '../styles/Login.module.css'
import Button from '../ui/Button'
import LanguageSwitcher from '../ui/LanguageSwitcher'
/**
* Компонент страницы входа
@@ -48,40 +50,72 @@ const LoginPage = () => {
return (
<div class={styles['login-container']}>
<form class={styles['login-form']} onSubmit={handleSubmit}>
<img src={publyLogo} alt="Logo" class={styles['login-logo']} />
<h1>Вход в панель администратора</h1>
<div class={styles['login-header']}>
<LanguageSwitcher />
</div>
<div class={styles['login-form-container']}>
<form class={formStyles.form} onSubmit={handleSubmit}>
<img src={publyLogo} alt="Logo" class={styles['login-logo']} />
<h1 class={formStyles.title}>Вход в админ панель</h1>
{error() && <div class={styles['error-message']}>{error()}</div>}
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>📧</span>
Email
<span class={formStyles.required}>*</span>
</span>
</label>
<input
type="email"
value={username()}
onInput={(e) => setUsername(e.currentTarget.value)}
placeholder="admin@discours.io"
required
class={`${formStyles.input} ${error() ? formStyles.error : ''}`}
disabled={loading()}
/>
</div>
<div class={styles['form-group']}>
<label for="username">Имя пользователя</label>
<input
id="username"
type="text"
value={username()}
onInput={(e) => setUsername(e.currentTarget.value)}
disabled={loading()}
required
/>
</div>
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>🔒</span>
Пароль
<span class={formStyles.required}>*</span>
</span>
</label>
<input
type="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
placeholder="••••••••"
required
class={`${formStyles.input} ${error() ? formStyles.error : ''}`}
disabled={loading()}
/>
</div>
<div class={styles['form-group']}>
<label for="password">Пароль</label>
<input
id="password"
type="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
disabled={loading()}
required
/>
</div>
{error() && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{error()}
</div>
)}
<Button type="submit" variant="primary" disabled={loading()} loading={loading()}>
{loading() ? 'Вход...' : 'Войти'}
</Button>
</form>
<div class={formStyles.actions}>
<Button
variant="primary"
type="submit"
loading={loading()}
disabled={loading() || !username() || !password()}
onClick={handleSubmit}
>
Войти
</Button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,4 +1,7 @@
import { Component, createSignal, For, onMount, Show } from 'solid-js'
import { createEffect, createSignal, For, on, onMount, Show, untrack } from 'solid-js'
import { useData } from '../context/data'
import { useTableSort } from '../context/sort'
import { SHOUTS_SORT_CONFIG } from '../context/sortConfig'
import { query } from '../graphql'
import type { Query, AdminShoutInfo as Shout } from '../graphql/generated/schema'
import { ADMIN_GET_SHOUTS_QUERY } from '../graphql/queries'
@@ -6,6 +9,8 @@ import styles from '../styles/Admin.module.css'
import EditableCodePreview from '../ui/EditableCodePreview'
import Modal from '../ui/Modal'
import Pagination from '../ui/Pagination'
import SortableHeader from '../ui/SortableHeader'
import TableControls from '../ui/TableControls'
import { formatDateRelative } from '../utils/date'
export interface ShoutsRouteProps {
@@ -13,13 +18,15 @@ export interface ShoutsRouteProps {
onSuccess?: (message: string) => void
}
const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
const ShoutsRoute = (props: ShoutsRouteProps) => {
const [shouts, setShouts] = createSignal<Shout[]>([])
const [loading, setLoading] = createSignal(true)
const [showBodyModal, setShowBodyModal] = createSignal(false)
const [selectedShoutBody, setSelectedShoutBody] = createSignal<string>('')
const [showMediaBodyModal, setShowMediaBodyModal] = createSignal(false)
const [selectedMediaBody, setSelectedMediaBody] = createSignal<string>('')
const { sortState } = useTableSort()
const { selectedCommunity } = useData()
// Pagination state
const [pagination, setPagination] = createSignal<{
@@ -43,16 +50,38 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
async function loadShouts() {
try {
setLoading(true)
// Подготавливаем параметры запроса
const variables: {
limit: number
offset: number
search?: string
community?: number
} = {
limit: pagination().limit,
offset: (pagination().page - 1) * pagination().limit
}
// Добавляем поиск если есть
if (searchQuery().trim()) {
variables.search = searchQuery().trim()
}
// Добавляем фильтр по сообществу если выбрано
const communityFilter = selectedCommunity()
if (communityFilter !== null) {
variables.community = communityFilter
}
const result = await query<{ adminGetShouts: Query['adminGetShouts'] }>(
`${location.origin}/graphql`,
ADMIN_GET_SHOUTS_QUERY,
{
limit: pagination().limit,
offset: (pagination().page - 1) * pagination().limit
}
variables
)
if (result?.adminGetShouts?.shouts) {
setShouts(result.adminGetShouts.shouts)
// Применяем сортировку на клиенте
const sortedShouts = sortShouts(result.adminGetShouts.shouts)
setShouts(sortedShouts)
setPagination((prev) => ({
...prev,
total: result.adminGetShouts.total || 0,
@@ -83,23 +112,80 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
void loadShouts()
}
// Helper functions
function getShoutStatus(shout: Shout): string {
if (shout.deleted_at) return '🗑️'
if (shout.published_at) return '✅'
return '📝'
/**
* Сортирует публикации на клиенте
*/
function sortShouts(shoutsData: Shout[]): Shout[] {
const { field, direction } = sortState()
return [...shoutsData].sort((a, b) => {
let comparison = 0
switch (field) {
case 'id':
comparison = Number(a.id) - Number(b.id)
break
case 'title':
comparison = (a.title || '').localeCompare(b.title || '', 'ru')
break
case 'slug':
comparison = (a.slug || '').localeCompare(b.slug || '', 'ru')
break
case 'created_at':
comparison = (a.created_at || 0) - (b.created_at || 0)
break
case 'published_at':
comparison = (a.published_at || 0) - (b.published_at || 0)
break
case 'updated_at':
comparison = (a.updated_at || 0) - (b.updated_at || 0)
break
default:
comparison = Number(a.id) - Number(b.id)
}
return direction === 'desc' ? -comparison : comparison
})
}
// Пересортировка при изменении состояния сортировки
createEffect(
on([sortState], () => {
if (shouts().length > 0) {
// Используем untrack для предотвращения бесконечной рекурсии
const currentShouts = untrack(() => shouts())
const sortedShouts = sortShouts(currentShouts)
// Сравниваем текущий порядок с отсортированным, чтобы избежать лишних обновлений
const needsUpdate =
JSON.stringify(currentShouts.map((s: Shout) => s.id)) !==
JSON.stringify(sortedShouts.map((s: Shout) => s.id))
if (needsUpdate) {
setShouts(sortedShouts)
}
}
})
)
// Перезагрузка при изменении выбранного сообщества
createEffect(
on([selectedCommunity], () => {
void loadShouts()
})
)
// Helper functions
function getShoutStatusTitle(shout: Shout): string {
if (shout.deleted_at) return 'Удалена'
if (shout.published_at) return 'Опубликована'
return 'Черновик'
}
function getShoutStatusClass(shout: Shout): string {
if (shout.deleted_at) return 'status-deleted'
if (shout.published_at) return 'status-published'
return 'status-draft'
function getShoutStatusBackgroundColor(shout: Shout): string {
if (shout.deleted_at) return '#fee2e2' // Пастельный красный
if (shout.published_at) return '#d1fae5' // Пастельный зеленый
return '#fef3c7' // Пастельный желтый для черновиков
}
function truncateText(text: string, maxLength = 100): string {
@@ -118,39 +204,33 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
</Show>
<Show when={!loading() && shouts().length > 0}>
<div class={styles['shouts-controls']}>
<div class={styles['search-container']}>
<div class={styles['search-input-group']}>
<input
type="text"
placeholder="Поиск по заголовку, slug или ID..."
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void loadShouts()
}
}}
class={styles['search-input']}
/>
<button class={styles['search-button']} onClick={() => void loadShouts()}>
Поиск
</button>
</div>
</div>
</div>
<TableControls
onRefresh={loadShouts}
isLoading={loading()}
searchValue={searchQuery()}
onSearchChange={(value) => setSearchQuery(value)}
onSearch={() => void loadShouts()}
/>
<div class={styles['shouts-list']}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Заголовок</th>
<th>Slug</th>
<th>Статус</th>
<SortableHeader field="id" allowedFields={SHOUTS_SORT_CONFIG.allowedFields}>
ID
</SortableHeader>
<SortableHeader field="title" allowedFields={SHOUTS_SORT_CONFIG.allowedFields}>
Заголовок
</SortableHeader>
<SortableHeader field="slug" allowedFields={SHOUTS_SORT_CONFIG.allowedFields}>
Slug
</SortableHeader>
<th>Авторы</th>
<th>Темы</th>
<th>Создан</th>
<SortableHeader field="created_at" allowedFields={SHOUTS_SORT_CONFIG.allowedFields}>
Создан
</SortableHeader>
<th>Содержимое</th>
<th>Media</th>
</tr>
@@ -159,17 +239,18 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
<For each={shouts()}>
{(shout) => (
<tr>
<td>{shout.id}</td>
<td
style={{
'background-color': getShoutStatusBackgroundColor(shout),
padding: '8px 12px',
'border-radius': '4px'
}}
title={getShoutStatusTitle(shout)}
>
{shout.id}
</td>
<td title={shout.title}>{truncateText(shout.title, 50)}</td>
<td title={shout.slug}>{truncateText(shout.slug, 30)}</td>
<td>
<span
class={`${styles['status-badge']} ${getShoutStatusClass(shout)}`}
title={getShoutStatusTitle(shout)}
>
{getShoutStatus(shout)}
</span>
</td>
<td>
<Show when={shout.authors?.length}>
<div class={styles['authors-list']}>
@@ -210,7 +291,8 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
<span class={styles['no-data']}>-</span>
</Show>
</td>
<td>{formatDateRelative(shout.created_at)}</td>
<td>{formatDateRelative(shout.created_at)()}</td>
<td
class={styles['body-cell']}
onClick={() => {
@@ -227,20 +309,17 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
<For each={shout.media}>
{(mediaItem, idx) => (
<div style="display: flex; align-items: center; gap: 6px;">
<span class={styles['media-count']}>
{mediaItem?.title || `media[${idx()}]`}
</span>
<Show when={mediaItem?.body}>
<button
class={styles['edit-button']}
style="padding: 2px 8px; font-size: 12px;"
title="Показать содержимое body"
style="padding: 4px; font-size: 14px; min-width: 24px; border-radius: 4px;"
onClick={() => {
setSelectedMediaBody(mediaItem?.body || '')
setShowMediaBodyModal(true)
}}
title={mediaItem?.title || idx().toString()}
>
👁 body
👁
</button>
</Show>
</div>
@@ -278,6 +357,8 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
<EditableCodePreview
content={selectedShoutBody()}
maxHeight="85vh"
language="html"
autoFormat={true}
onContentChange={(newContent) => {
setSelectedShoutBody(newContent)
}}
@@ -302,6 +383,8 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
<EditableCodePreview
content={selectedMediaBody()}
maxHeight="85vh"
language="html"
autoFormat={true}
onContentChange={(newContent) => {
setSelectedMediaBody(newContent)
}}

View File

@@ -1,679 +1,250 @@
/**
* Компонент управления топиками
* @module TopicsRoute
*/
import { Component, createEffect, createSignal, For, JSX, on, onMount, Show, untrack } from 'solid-js'
import { query } from '../graphql'
import type { Query } from '../graphql/generated/schema'
import { CREATE_TOPIC_MUTATION, DELETE_TOPIC_MUTATION, UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
import { GET_TOPICS_QUERY } from '../graphql/queries'
import { createEffect, createSignal, For, on, Show } from 'solid-js'
import { Topic, useData } from '../context/data'
import { useTableSort } from '../context/sort'
import { TOPICS_SORT_CONFIG } from '../context/sortConfig'
import TopicEditModal from '../modals/TopicEditModal'
import TopicMergeModal from '../modals/TopicMergeModal'
import TopicSimpleParentModal from '../modals/TopicSimpleParentModal'
import adminStyles from '../styles/Admin.module.css'
import styles from '../styles/Table.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import SortableHeader from '../ui/SortableHeader'
import TableControls from '../ui/TableControls'
/**
* Интерфейс топика
*/
interface Topic {
id: number
slug: string
title: string
body?: string
pic?: string
community: number
parent_ids?: number[]
children?: Topic[]
level?: number
interface TopicsProps {
onError?: (message: string) => void
onSuccess?: (message: string) => void
}
/**
* Интерфейс свойств компонента
*/
interface TopicsRouteProps {
onError: (error: string) => void
onSuccess: (message: string) => void
}
export const Topics = (props: TopicsProps) => {
const { selectedCommunity, loadTopicsByCommunity, topics: contextTopics } = useData()
/**
* Компонент управления топиками
*/
const TopicsRoute: Component<TopicsRouteProps> = (props) => {
const [rawTopics, setRawTopics] = createSignal<Topic[]>([])
const [topics, setTopics] = createSignal<Topic[]>([])
// Состояние поиска
const [searchQuery, setSearchQuery] = createSignal('')
// Состояние загрузки
const [loading, setLoading] = createSignal(false)
const [sortBy, setSortBy] = createSignal<'id' | 'title'>('id')
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc')
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; topic: Topic | null }>({
show: false,
topic: null
})
const [editModal, setEditModal] = createSignal<{ show: boolean; topic: Topic | null }>({
show: false,
topic: null
})
const [createModal, setCreateModal] = createSignal<{ show: boolean }>({
show: false
})
const [selectedTopics, setSelectedTopics] = createSignal<number[]>([])
const [groupAction, setGroupAction] = createSignal<'delete' | 'merge' | ''>('')
const [mergeModal, setMergeModal] = createSignal<{ show: boolean }>({
show: false
})
const [simpleParentModal, setSimpleParentModal] = createSignal<{ show: boolean; topic: Topic | null }>({
show: false,
topic: null
})
// Модальное окно для редактирования топика
const [showEditModal, setShowEditModal] = createSignal(false)
const [selectedTopic, setSelectedTopic] = createSignal<Topic | undefined>(undefined)
// Сортировка
const { sortState } = useTableSort()
/**
* Загружает список всех топиков
* Загрузка топиков для сообщества
*/
const loadTopics = async () => {
setLoading(true)
try {
const data = await query<{ get_topics_all: Query['get_topics_all'] }>(
`${location.origin}/graphql`,
GET_TOPICS_QUERY
)
async function loadTopicsForCommunity() {
const community = selectedCommunity()
// selectedCommunity теперь всегда число (по умолчанию 1)
if (data?.get_topics_all) {
// Строим иерархическую структуру
const validTopics = data.get_topics_all.filter((topic): topic is Topic => topic !== null)
setRawTopics(validTopics)
}
console.log('[TopicsRoute] Loading all topics for community...')
try {
setLoading(true)
// Загружаем все топики сообщества
await loadTopicsByCommunity(community!)
console.log('[TopicsRoute] All topics loaded')
} catch (error) {
props.onError(`Ошибка загрузки топиков: ${(error as Error).message}`)
console.error('[TopicsRoute] Failed to load topics:', error)
props.onError?.(error instanceof Error ? error.message : 'Не удалось загрузить список топиков')
} finally {
setLoading(false)
}
}
// Пересортировка при изменении rawTopics или параметров сортировки
createEffect(
on([rawTopics, sortBy, sortDirection], () => {
const rawData = rawTopics()
const sort = sortBy()
const direction = sortDirection()
if (rawData.length > 0) {
// Используем untrack для чтения buildHierarchy без дополнительных зависимостей
const hierarchicalTopics = untrack(() => buildHierarchy(rawData, sort, direction))
setTopics(hierarchicalTopics)
} else {
setTopics([])
}
})
)
// Загружаем топики при монтировании компонента
onMount(() => {
void loadTopics()
})
/**
* Строит иерархическую структуру топиков
* Обработчик поиска - применяет поисковый запрос
*/
const buildHierarchy = (
flatTopics: Topic[],
sortField?: 'id' | 'title',
sortDir?: 'asc' | 'desc'
): Topic[] => {
const topicMap = new Map<number, Topic>()
const rootTopics: Topic[] = []
// Создаем карту всех топиков
flatTopics.forEach((topic) => {
topicMap.set(topic.id, { ...topic, children: [], level: 0 })
})
// Строим иерархию
flatTopics.forEach((topic) => {
const currentTopic = topicMap.get(topic.id)!
if (!topic.parent_ids || topic.parent_ids.length === 0) {
// Корневой топик
rootTopics.push(currentTopic)
} else {
// Находим родителя и добавляем как дочерний
const parentId = topic.parent_ids[topic.parent_ids.length - 1]
const parent = topicMap.get(parentId)
if (parent) {
currentTopic.level = (parent.level || 0) + 1
parent.children!.push(currentTopic)
} else {
// Если родитель не найден, добавляем как корневой
rootTopics.push(currentTopic)
}
}
})
return sortTopics(rootTopics, sortField, sortDir)
const handleSearch = () => {
// Поиск осуществляется через filteredTopics(), которая реагирует на searchQuery()
// Дополнительная логика поиска здесь не нужна, но можно добавить аналитику
console.log('[TopicsRoute] Search triggered with query:', searchQuery())
}
/**
* Сортирует топики рекурсивно
* Фильтрация топиков по поисковому запросу
*/
const sortTopics = (topics: Topic[], sortField?: 'id' | 'title', sortDir?: 'asc' | 'desc'): Topic[] => {
const field = sortField || sortBy()
const direction = sortDir || sortDirection()
const filteredTopics = () => {
const topics = contextTopics()
const query = searchQuery().toLowerCase()
const sortedTopics = topics.sort((a, b) => {
if (!query) return topics
return topics.filter(
(topic) =>
topic.title?.toLowerCase().includes(query) ||
topic.slug?.toLowerCase().includes(query) ||
topic.id.toString().includes(query)
)
}
/**
* Сортировка топиков на клиенте
*/
const sortedTopics = () => {
const topics = filteredTopics()
const { field, direction } = sortState()
return [...topics].sort((a, b) => {
let comparison = 0
if (field === 'title') {
comparison = (a.title || '').localeCompare(b.title || '', 'ru')
} else {
comparison = a.id - b.id
switch (field) {
case 'id':
comparison = a.id - b.id
break
case 'title':
comparison = (a.title || '').localeCompare(b.title || '', 'ru')
break
case 'slug':
comparison = (a.slug || '').localeCompare(b.slug || '', 'ru')
break
default:
comparison = a.id - b.id
}
return direction === 'desc' ? -comparison : comparison
})
// Рекурсивно сортируем дочерние элементы
sortedTopics.forEach((topic) => {
if (topic.children && topic.children.length > 0) {
topic.children = sortTopics(topic.children, field, direction)
}
})
return sortedTopics
}
/**
* Обрезает текст до указанной длины
*/
// Загрузка при смене сообщества
createEffect(
on(selectedCommunity, (updatedCommunity) => {
if (updatedCommunity) {
// selectedCommunity теперь всегда число, поэтому всегда загружаем
void loadTopicsForCommunity()
}
})
)
const truncateText = (text: string, maxLength = 100): string => {
if (!text) return '—'
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text
if (!text || text.length <= maxLength) return text
return `${text.substring(0, maxLength)}...`
}
/**
* Рекурсивно отображает топики с отступами для иерархии
* Открытие модального окна редактирования топика
*/
const renderTopics = (topics: Topic[]): JSX.Element[] => {
const result: JSX.Element[] = []
topics.forEach((topic) => {
const isSelected = selectedTopics().includes(topic.id)
result.push(
<tr class={styles['clickable-row']}>
<td>{topic.id}</td>
<td
style={{ 'padding-left': `${(topic.level || 0) * 20}px`, cursor: 'pointer' }}
onClick={() => setEditModal({ show: true, topic })}
>
{topic.level! > 0 && '└─ '}
{topic.title}
</td>
<td onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
{topic.slug}
</td>
<td onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
<div
style={{
'max-width': '200px',
overflow: 'hidden',
'text-overflow': 'ellipsis',
'white-space': 'nowrap'
}}
title={topic.body}
>
{truncateText(topic.body?.replace(/<[^>]*>/g, '') || '', 100)}
</div>
</td>
<td onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
{topic.community}
</td>
<td onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
{topic.parent_ids?.join(', ') || '—'}
</td>
<td onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
e.stopPropagation()
handleTopicSelect(topic.id, e.target.checked)
}}
style={{ cursor: 'pointer' }}
/>
</td>
</tr>
)
if (topic.children && topic.children.length > 0) {
result.push(...renderTopics(topic.children))
}
})
return result
const handleTopicEdit = (topic: Topic) => {
console.log('[TopicsRoute] Opening edit modal for topic:', topic)
setSelectedTopic(topic)
setShowEditModal(true)
}
/**
* Обновляет топик
* Сохранение изменений топика
*/
const updateTopic = async (updatedTopic: Topic) => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: UPDATE_TOPIC_MUTATION,
variables: { topic_input: updatedTopic }
})
})
const handleTopicSave = (updatedTopic: Topic) => {
console.log('[TopicsRoute] Saving topic:', updatedTopic)
const result = await response.json()
// TODO: добавить логику сохранения изменений в базу данных
// await updateTopic(updatedTopic)
if (result.errors) {
throw new Error(result.errors[0].message)
}
props.onSuccess?.('Топик успешно обновлён')
if (result.data.update_topic.success) {
props.onSuccess('Топик успешно обновлен')
setEditModal({ show: false, topic: null })
await loadTopics() // Перезагружаем список
} else {
throw new Error(result.data.update_topic.message || 'Ошибка обновления топика')
}
} catch (error) {
props.onError(`Ошибка обновления топика: ${(error as Error).message}`)
}
// Обновляем локальные данные (пока что просто перезагружаем)
void loadTopicsForCommunity()
}
/**
* Создает новый топик
* Обработка ошибок из модального окна
*/
const createTopic = async (newTopic: Topic) => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: CREATE_TOPIC_MUTATION,
variables: { topic_input: newTopic }
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
if (result.data.create_topic.error) {
throw new Error(result.data.create_topic.error)
}
props.onSuccess('Топик успешно создан')
setCreateModal({ show: false })
await loadTopics() // Перезагружаем список
} catch (error) {
props.onError(`Ошибка создания топика: ${(error as Error).message}`)
}
const handleTopicError = (message: string) => {
props.onError?.(message)
}
/**
* Обработчик выбора/снятия выбора топика
* Рендер строки топика
*/
const handleTopicSelect = (topicId: number, checked: boolean) => {
if (checked) {
setSelectedTopics((prev) => [...prev, topicId])
} else {
setSelectedTopics((prev) => prev.filter((id) => id !== topicId))
}
}
/**
* Обработчик выбора/снятия выбора всех топиков
*/
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allTopicIds = rawTopics().map((topic) => topic.id)
setSelectedTopics(allTopicIds)
} else {
setSelectedTopics([])
}
}
/**
* Проверяет выбраны ли все топики
*/
const isAllSelected = () => {
const allIds = rawTopics().map((topic) => topic.id)
const selected = selectedTopics()
return allIds.length > 0 && allIds.every((id) => selected.includes(id))
}
/**
* Проверяет выбран ли хотя бы один топик
*/
const hasSelectedTopics = () => selectedTopics().length > 0
/**
* Выполняет групповое действие
*/
const executeGroupAction = () => {
const action = groupAction()
const selected = selectedTopics()
if (!action || selected.length === 0) {
props.onError('Выберите действие и топики')
return
}
if (action === 'delete') {
// Групповое удаление
const selectedTopicsData = rawTopics().filter((t) => selected.includes(t.id))
setDeleteModal({ show: true, topic: selectedTopicsData[0] }) // Используем первый для отображения
} else if (action === 'merge') {
// Слияние топиков
if (selected.length < 2) {
props.onError('Для слияния нужно выбрать минимум 2 темы')
return
}
setMergeModal({ show: true })
}
}
/**
* Групповое удаление выбранных топиков
*/
const deleteSelectedTopics = async () => {
const selected = selectedTopics()
if (selected.length === 0) return
try {
// Удаляем по одному (можно оптимизировать пакетным удалением)
for (const topicId of selected) {
await deleteTopic(topicId)
}
setSelectedTopics([])
setGroupAction('')
props.onSuccess(`Успешно удалено ${selected.length} тем`)
} catch (error) {
props.onError(`Ошибка группового удаления: ${(error as Error).message}`)
}
}
/**
* Удаляет топик
*/
const deleteTopic = async (topicId: number) => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: DELETE_TOPIC_MUTATION,
variables: { id: topicId }
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
if (result.data.delete_topic_by_id.success) {
props.onSuccess('Топик успешно удален')
setDeleteModal({ show: false, topic: null })
await loadTopics() // Перезагружаем список
} else {
throw new Error(result.data.delete_topic_by_id.message || 'Ошибка удаления топика')
}
} catch (error) {
props.onError(`Ошибка удаления топика: ${(error as Error).message}`)
}
}
const renderTopicRow = (topic: Topic) => (
<tr
class={styles.tableRow}
onClick={() => handleTopicEdit(topic)}
style="cursor: pointer;"
title="Нажмите для редактирования топика"
>
<td class={styles.tableCell}>{topic.id}</td>
<td class={styles.tableCell}>
<strong title={topic.title}>{truncateText(topic.title, 50)}</strong>
</td>
<td class={styles.tableCell} title={topic.slug}>
{truncateText(topic.slug, 30)}
</td>
<td class={styles.tableCell}>
{topic.body ? (
<span style="color: #666;">{truncateText(topic.body.replace(/<[^>]*>/g, ''), 60)}</span>
) : (
<span style="color: #999; font-style: italic;">Нет содержимого</span>
)}
</td>
</tr>
)
return (
<div class={styles.container}>
<div class={styles.header}>
<div style={{ display: 'flex', gap: '12px', 'align-items': 'center' }}>
<div style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}>
<label style={{ 'font-size': '14px', color: '#666' }}>Сортировка:</label>
<select
value={sortBy()}
onInput={(e) => setSortBy(e.target.value as 'id' | 'title')}
style={{
padding: '4px 8px',
border: '1px solid #ddd',
'border-radius': '4px',
'font-size': '14px'
}}
>
<option value="id">По ID</option>
<option value="title">По названию</option>
</select>
<select
value={sortDirection()}
onInput={(e) => setSortDirection(e.target.value as 'asc' | 'desc')}
style={{
padding: '4px 8px',
border: '1px solid #ddd',
'border-radius': '4px',
'font-size': '14px'
}}
>
<option value="asc"> По возрастанию</option>
<option value="desc"> По убыванию</option>
</select>
</div>
<Button onClick={loadTopics} disabled={loading()}>
{loading() ? 'Загрузка...' : 'Обновить'}
</Button>
<Button variant="primary" onClick={() => setCreateModal({ show: true })}>
Создать тему
</Button>
<Button
variant="secondary"
onClick={() => {
if (selectedTopics().length === 1) {
const selectedTopic = rawTopics().find((t) => t.id === selectedTopics()[0])
if (selectedTopic) {
setSimpleParentModal({ show: true, topic: selectedTopic })
}
} else {
props.onError('Выберите одну тему для назначения родителя')
}
}}
>
🏠 Назначить родителя
</Button>
</div>
</div>
<div class={adminStyles.pageContainer}>
<TableControls
searchValue={searchQuery()}
onSearchChange={setSearchQuery}
onSearch={handleSearch}
searchPlaceholder="Поиск по названию, slug или ID..."
isLoading={loading()}
onRefresh={loadTopicsForCommunity}
/>
<Show
when={!loading()}
fallback={
<div class="loading-screen">
<div class="loading-spinner" />
<div>Загрузка топиков...</div>
</div>
}
>
<div class={styles.tableContainer}>
<table class={styles.table}>
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Slug</th>
<th>Описание</th>
<th>Сообщество</th>
<th>Родители</th>
<th>
<div
style={{
display: 'flex',
'align-items': 'center',
gap: '8px',
'flex-direction': 'column'
}}
>
<div style={{ display: 'flex', 'align-items': 'center', gap: '4px' }}>
<input
type="checkbox"
checked={isAllSelected()}
onChange={(e) => handleSelectAll(e.target.checked)}
style={{ cursor: 'pointer' }}
title="Выбрать все"
/>
<span style={{ 'font-size': '12px' }}>Все</span>
</div>
<Show when={hasSelectedTopics()}>
<div style={{ display: 'flex', gap: '4px', 'align-items': 'center' }}>
<select
value={groupAction()}
onChange={(e) => setGroupAction(e.target.value as 'delete' | 'merge' | '')}
style={{
padding: '2px 4px',
'font-size': '11px',
border: '1px solid #ddd',
'border-radius': '3px'
}}
>
<option value="">Действие</option>
<option value="delete">Удалить</option>
<option value="merge">Слить</option>
</select>
<button
onClick={executeGroupAction}
disabled={!groupAction()}
style={{
padding: '2px 6px',
'font-size': '11px',
background: groupAction() ? '#007bff' : '#ccc',
color: 'white',
border: 'none',
'border-radius': '3px',
cursor: groupAction() ? 'pointer' : 'not-allowed'
}}
>
</button>
</div>
</Show>
</div>
</th>
<tr class={styles.tableHeader}>
<SortableHeader field="id" allowedFields={TOPICS_SORT_CONFIG.allowedFields}>
ID
</SortableHeader>
<SortableHeader field="title" allowedFields={TOPICS_SORT_CONFIG.allowedFields}>
Название
</SortableHeader>
<SortableHeader field="slug" allowedFields={TOPICS_SORT_CONFIG.allowedFields}>
Slug
</SortableHeader>
<th class={styles.tableHeaderCell}>Body</th>
</tr>
</thead>
<tbody>
<For each={renderTopics(topics())}>{(row) => row}</For>
<Show when={loading()}>
<tr>
<td colspan="4" class={styles.loadingCell}>
Загрузка...
</td>
</tr>
</Show>
<Show when={!loading() && sortedTopics().length === 0}>
<tr>
<td colspan="4" class={styles.emptyCell}>
Нет топиков
</td>
</tr>
</Show>
<Show when={!loading()}>
<For each={sortedTopics()}>{renderTopicRow}</For>
</Show>
</tbody>
</table>
</Show>
</div>
{/* Модальное окно создания */}
<div class={styles.tableFooter}>
<span class={styles.resultsInfo}>
<span>Всего</span>: {sortedTopics().length}
</span>
</div>
{/* Модальное окно для редактирования топика */}
<TopicEditModal
isOpen={createModal().show}
topic={null}
onClose={() => setCreateModal({ show: false })}
onSave={createTopic}
/>
{/* Модальное окно редактирования */}
<TopicEditModal
isOpen={editModal().show}
topic={editModal().topic}
onClose={() => setEditModal({ show: false, topic: null })}
onSave={updateTopic}
/>
{/* Модальное окно подтверждения удаления */}
<Modal
isOpen={deleteModal().show}
onClose={() => setDeleteModal({ show: false, topic: null })}
title="Подтверждение удаления"
>
<div>
<Show when={selectedTopics().length > 1}>
<p>
Вы уверены, что хотите удалить <strong>{selectedTopics().length}</strong> выбранных тем?
</p>
<p class={styles['warning-text']}>
Это действие нельзя отменить. Все дочерние топики также будут удалены.
</p>
<div class={styles['modal-actions']}>
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, topic: null })}>
Отмена
</Button>
<Button variant="danger" onClick={deleteSelectedTopics}>
Удалить {selectedTopics().length} тем
</Button>
</div>
</Show>
<Show when={selectedTopics().length <= 1}>
<p>
Вы уверены, что хотите удалить топик "<strong>{deleteModal().topic?.title}</strong>"?
</p>
<p class={styles['warning-text']}>
Это действие нельзя отменить. Все дочерние топики также будут удалены.
</p>
<div class={styles['modal-actions']}>
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, topic: null })}>
Отмена
</Button>
<Button
variant="danger"
onClick={() => {
if (deleteModal().topic) {
void deleteTopic(deleteModal().topic!.id)
}
}}
>
Удалить
</Button>
</div>
</Show>
</div>
</Modal>
{/* Модальное окно слияния тем */}
<TopicMergeModal
isOpen={mergeModal().show}
isOpen={showEditModal()}
topic={selectedTopic()!}
onClose={() => {
setMergeModal({ show: false })
setSelectedTopics([])
setGroupAction('')
setShowEditModal(false)
setSelectedTopic(undefined)
}}
topics={rawTopics().filter((topic) => selectedTopics().includes(topic.id))}
onSuccess={(message) => {
props.onSuccess(message)
setSelectedTopics([])
setGroupAction('')
void loadTopics()
}}
onError={props.onError}
/>
{/* Модальное окно назначения родителя */}
<TopicSimpleParentModal
isOpen={simpleParentModal().show}
onClose={() => setSimpleParentModal({ show: false, topic: null })}
topic={simpleParentModal().topic}
allTopics={rawTopics()}
onSuccess={(message) => {
props.onSuccess(message)
setSimpleParentModal({ show: false, topic: null })
void loadTopics() // Перезагружаем данные
}}
onError={props.onError}
onSave={handleTopicSave}
onError={handleTopicError}
/>
</div>
)
}
export default TopicsRoute

File diff suppressed because it is too large Load Diff

View File

@@ -1,544 +1,679 @@
/* Admin Panel Layout */
.admin-panel {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: var(--background-color);
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: var(--background-color);
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background-color: var(--header-background);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 2rem;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
display: flex;
align-items: center;
gap: 1rem;
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.community-selector {
display: flex;
align-items: center;
gap: 8px;
}
.community-selector select {
padding: 6px 10px;
border-radius: 4px;
border: 1px solid var(--border-color);
background-color: white;
min-width: 180px;
transition: all 0.2s ease;
}
.community-selector select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
/* Стиль для выбранного сообщества */
.community-selected {
border-color: #10b981 !important;
background-color: #f0fdf4 !important;
font-weight: 500;
}
.community-badge {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.loading-indicator {
font-size: 0.8rem;
color: #666;
font-style: italic;
}
.logo {
height: 2rem;
width: auto;
height: 2rem;
width: auto;
}
.header-container h1 {
margin: 0;
color: var(--text-color);
font-size: 1.5rem;
margin: 0;
color: var(--text-color);
font-size: 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.version-badge {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
padding: 2px 8px;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.5px;
box-shadow: 0 1px 3px rgba(16, 185, 129, 0.3);
white-space: nowrap;
}
.logout-button {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: transparent;
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: transparent;
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
}
.logout-button:hover {
background-color: var(--hover-color);
background-color: var(--hover-color);
}
.admin-tabs {
display: flex;
gap: 1rem;
padding: 1rem 2rem;
background-color: var(--header-background);
border-bottom: 1px solid var(--border-color);
display: flex;
gap: 1rem;
padding: 0.75rem 2rem;
}
main {
flex: 1;
padding: 1.5rem 3rem;
background-color: var(--background-color);
max-width: 1400px;
margin: 0 auto;
width: 100%;
flex: 1;
padding: 1rem 3rem;
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
/* Common Styles */
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
color: var(--text-color-light);
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
color: var(--text-color-light);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #6b7280;
max-width: 600px;
margin: 0 auto;
text-align: center;
padding: 60px 20px;
color: #6b7280;
max-width: 600px;
margin: 0 auto;
}
.empty-state h3 {
color: #374151;
margin-bottom: 16px;
font-size: 1.5rem;
color: #374151;
margin-bottom: 16px;
font-size: 1.5rem;
}
.empty-state p {
font-size: 1rem;
line-height: 1.6;
margin-bottom: 0;
font-size: 1rem;
line-height: 1.6;
margin-bottom: 0;
}
.empty-state code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
font-size: 0.9em;
color: #1f2937;
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-family: "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", monospace;
font-size: 0.9em;
color: #1f2937;
}
.empty-state details {
text-align: left;
text-align: left;
}
.empty-state summary:hover {
color: #3b82f6;
color: #3b82f6;
}
.empty-state pre {
text-align: left;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
text-align: left;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
font-family: "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", monospace;
}
.error-message {
margin: 1rem 2rem;
padding: 1rem;
background-color: var(--error-color-light);
color: var(--error-color-dark);
border-radius: 4px;
border: 1px solid var(--error-color);
margin: 1rem 2rem;
padding: 1rem;
background-color: var(--error-color-light);
color: var(--error-color-dark);
border-radius: 4px;
border: 1px solid var(--error-color);
}
.success-message {
margin: 1rem 2rem;
padding: 1rem;
background-color: var(--success-color-light);
color: var(--success-color-dark);
border-radius: 4px;
border: 1px solid var(--success-color);
margin: 1rem 2rem;
padding: 1rem;
background-color: var(--success-color-light);
color: var(--success-color-dark);
border-radius: 4px;
border: 1px solid var(--success-color);
}
/* Users Route Styles */
.authors-container {
padding: 1.5rem;
background-color: var(--background-color);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-sm);
padding: 0;
}
.authors-controls {
margin-bottom: 1rem;
width: 100%;
margin-bottom: 1rem;
width: 100%;
}
.search-container {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
width: 100%;
display: flex;
gap: 1rem;
margin-bottom: 1rem;
width: 100%;
}
.search-input-group {
display: flex;
gap: 0.5rem;
flex: 1;
display: flex;
gap: 0.5rem;
flex: 1;
}
.search-input {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
font-size: var(--font-size-sm);
color: var(--text-color);
background-color: var(--background-color);
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
font-size: var(--font-size-sm);
color: var(--text-color);
background-color: var(--background-color);
}
.search-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color-light);
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color-light);
}
.search-button {
padding: 0.5rem 1rem;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
padding: 0.5rem 1rem;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.search-button:hover {
background-color: var(--primary-color-dark);
background-color: var(--primary-color-dark);
}
.authors-list {
overflow-x: auto;
overflow-x: auto;
}
.authors-list table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
min-width: 800px;
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
min-width: 800px;
}
.authors-list th,
.authors-list td {
padding: 1.2rem 1.5rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
padding: 1.2rem 1.5rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.authors-list th {
background-color: var(--header-background);
color: var(--text-color);
font-weight: var(--font-weight-medium);
white-space: nowrap;
background-color: var(--header-background);
color: var(--text-color);
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.authors-list tr:hover {
background-color: var(--hover-color);
}
.roles-cell {
min-width: 200px;
min-width: 200px;
}
.roles-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.role-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background-color: var(--secondary-color-light);
border-radius: var(--border-radius-sm);
font-size: var(--font-size-sm);
color: var(--text-color);
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background-color: var(--secondary-color-light);
border-radius: var(--border-radius-sm);
font-size: var(--font-size-sm);
color: var(--text-color);
}
.role-icon {
font-size: var(--font-size-base);
font-size: var(--font-size-base);
}
.edit-role-badge {
cursor: pointer;
background-color: var(--primary-color-light);
color: var(--primary-color);
transition: all var(--transition-fast);
cursor: pointer;
background-color: var(--primary-color-light);
color: var(--primary-color);
transition: all var(--transition-fast);
}
.edit-role-badge:hover {
background-color: var(--primary-color);
color: white;
background-color: var(--primary-color);
color: white;
}
/* Shouts Route Styles */
.shouts-container {
padding: 2rem;
padding: 0;
}
.shouts-controls {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.status-filter select {
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: white;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: white;
}
.shouts-list {
background-color: white;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.35rem;
border-radius: 6px;
font-size: 1.1rem;
width: 32px;
height: 32px;
text-align: center;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.35rem;
border-radius: 6px;
font-size: 1.1rem;
width: 32px;
height: 32px;
text-align: center;
}
.status-badge.status-published {
background-color: var(--success-color-light);
color: var(--success-color-dark);
background-color: var(--success-color-light);
color: var(--success-color-dark);
}
.status-badge.status-draft {
background-color: var(--warning-color-light);
color: var(--warning-color-dark);
background-color: var(--warning-color-light);
color: var(--warning-color-dark);
}
.status-badge.status-deleted {
background-color: var(--error-color-light);
color: var(--error-color-dark);
background-color: var(--error-color-light);
color: var(--error-color-dark);
}
.authors-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 0;
}
.author-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
background-color: var(--success-color-light);
color: var(--success-color-dark);
margin: 0.25rem;
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
background-color: var(--success-color-light);
color: var(--success-color-dark);
margin: 0;
white-space: nowrap;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.topics-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 0;
}
.topic-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
background-color: var(--info-color-light);
color: var(--info-color-dark);
margin: 0.25rem;
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
background-color: var(--info-color-light);
color: var(--info-color-dark);
margin: 0;
white-space: nowrap;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
}
.community-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
background-color: var(--primary-color-light);
color: var(--primary-color-dark);
margin: 0.25rem;
cursor: pointer;
}
.community-badge:hover {
background-color: var(--primary-color);
color: white;
}
.body-cell {
cursor: pointer;
cursor: pointer;
}
.body-cell:hover {
background-color: var(--hover-color);
background-color: var(--hover-color);
}
.no-data {
color: var(--text-color-light);
font-style: italic;
color: var(--text-color-light);
font-style: italic;
}
/* Компактная кнопка для медиа body */
.edit-button {
background: #f3f4f6;
border: 1px solid #d1d5db;
color: #374151;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.edit-button:hover {
background: #e5e7eb;
border-color: #9ca3af;
transform: scale(1.05);
}
/* Environment Variables Route Styles */
.env-variables-container {
padding: 1.5rem 0;
max-width: none;
padding: 1.5rem 0;
max-width: none;
}
.env-sections {
display: flex;
flex-direction: column;
gap: 2rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
.env-section {
background-color: white;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 2rem;
background-color: white;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 2rem;
}
.section-name {
margin: 0 0 1rem;
color: var(--text-color);
font-size: 1.25rem;
margin: 0 0 1rem;
color: var(--text-color);
font-size: 1.25rem;
}
.section-description {
margin: 0 0 1.5rem;
color: var(--text-color-light);
margin: 0 0 1.5rem;
color: var(--text-color-light);
}
.variables-list {
overflow-x: auto;
margin: 0 -1rem;
overflow-x: auto;
margin: 0 -1rem;
}
.empty-value {
color: var(--text-color-light);
font-style: italic;
color: var(--text-color-light);
font-style: italic;
}
.actions {
display: flex;
gap: 0.5rem;
display: flex;
gap: 0.5rem;
}
/* Table Styles */
table {
width: 100%;
border-collapse: collapse;
min-width: 900px;
table-layout: fixed; /* Фиксированная ширина столбцов */
width: 100%;
border-collapse: collapse;
min-width: 900px;
table-layout: fixed; /* Фиксированная ширина столбцов */
}
th {
text-align: left;
padding: 0.8rem 1rem;
border-bottom: 2px solid var(--border-color);
color: var(--text-color);
font-weight: 600;
font-size: 0.8rem;
white-space: nowrap; /* Заголовки не переносятся */
overflow: hidden;
text-transform: uppercase;
letter-spacing: 0.5px;
text-align: left;
padding: 0.8rem 1rem;
color: var(--text-color);
font-weight: 600;
font-size: 0.8rem;
white-space: nowrap; /* Заголовки не переносятся */
overflow: hidden;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
padding: 0.8rem 1rem;
border-bottom: 1px solid var(--border-color);
color: var(--text-color);
font-size: 0.85rem;
line-height: 1.4;
word-wrap: break-word; /* Перенос длинных слов */
white-space: normal; /* Разрешаем перенос строк */
vertical-align: top; /* Выравнивание по верхнему краю */
padding: 0.8rem 1rem;
color: var(--text-color);
font-size: 0.85rem;
line-height: 1.4;
word-wrap: break-word; /* Перенос длинных слов */
white-space: normal; /* Разрешаем перенос строк */
vertical-align: top; /* Выравнивание по верхнему краю */
}
/* Специальные стили для колонок публикаций */
.shouts-list th:nth-child(1) { width: 4%; } /* ID */
.shouts-list th:nth-child(2) { width: 24%; } /* ЗАГОЛОВОК */
.shouts-list th:nth-child(3) { width: 14%; } /* SLUG */
.shouts-list th:nth-child(4) { width: 8%; } /* СТАТУС */
.shouts-list th:nth-child(5) { width: 10%; } /* АВТОРЫ */
.shouts-list th:nth-child(6) { width: 10%; } /* ТЕМЫ */
.shouts-list th:nth-child(7) { width: 10%; } /* СОЗДАН */
.shouts-list th:nth-child(8) { width: 10%; } /* СОДЕРЖИМОЕ */
.shouts-list th:nth-child(9) { width: 10%; } /* MEDIA */
/* Специальные стили для колонок публикаций (после удаления колонки "Статус") */
.shouts-list th:nth-child(1) {
width: 5%;
} /* ID */
.shouts-list th:nth-child(2) {
width: 22%;
} /* ЗАГОЛОВОК */
.shouts-list th:nth-child(3) {
width: 12%;
} /* SLUG */
.shouts-list th:nth-child(4) {
width: 15%;
} /* АВТОРЫ */
.shouts-list th:nth-child(5) {
width: 15%;
} /* ТЕМЫ */
.shouts-list th:nth-child(6) {
width: 10%;
} /* СОЗДАН */
.shouts-list th:nth-child(7) {
width: 16%;
} /* СОДЕРЖИМОЕ */
.shouts-list th:nth-child(8) {
width: 5%;
} /* MEDIA */
/* Компактные стили для колонки ID */
.shouts-list th:nth-child(1),
.shouts-list td:nth-child(1) {
padding: 0.6rem 0.4rem !important;
font-size: 0.7rem !important;
text-align: center;
font-weight: 600;
padding: 0.6rem 0.4rem !important;
font-size: 0.7rem !important;
text-align: center;
font-weight: 600;
}
.shouts-list td:nth-child(8) { /* Колонка содержимого */
max-width: 200px;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
/* Колонки авторов и тем - больше места для бейджей */
.shouts-list td:nth-child(4) {
/* Колонка авторов */
padding: 0.5rem;
vertical-align: top;
}
tr:hover {
background-color: var(--hover-color);
.shouts-list td:nth-child(5) {
/* Колонка тем */
padding: 0.5rem;
vertical-align: top;
}
.shouts-list td:nth-child(7) {
/* Колонка содержимого */
max-width: 250px;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
/* Responsive Styles */
@media (max-width: 1024px) {
.header-container {
padding: 1rem;
}
.header-container {
padding: 1rem;
}
.admin-tabs {
padding: 1rem;
flex-wrap: wrap;
}
.admin-tabs {
padding: 1rem;
flex-wrap: wrap;
}
main {
padding: 1rem 2rem;
}
main {
padding: 1rem 2rem;
}
.authors-container,
.shouts-container,
.env-variables-container {
padding: 1rem;
}
.authors-container,
.shouts-container,
.env-variables-container {
padding: 0;
}
.search-input-group {
flex-direction: column;
}
.search-input-group {
flex-direction: column;
}
.search-button {
width: 100%;
}
.search-button {
width: 100%;
}
.shouts-controls {
flex-direction: column;
gap: 1rem;
}
.shouts-controls {
flex-direction: column;
gap: 1rem;
}
.status-filter {
width: 100%;
}
.status-filter {
width: 100%;
}
.status-filter select {
width: 100%;
}
.status-filter select {
width: 100%;
}
}
/* Responsive Design */
@media (max-width: 640px) {
.header-container {
padding: 1rem;
flex-direction: column;
gap: 1rem;
text-align: center;
}
.header-container {
padding: 1rem;
flex-direction: column;
gap: 1rem;
text-align: center;
}
.header-left {
flex-direction: column;
}
.header-left {
flex-direction: column;
}
main {
padding: 1rem;
}
main {
padding: 1rem;
}
.authors-list {
margin: 0 -1rem;
}
.authors-list {
margin: 0 -1rem;
}
.authors-list table {
font-size: var(--font-size-sm);
min-width: 600px;
}
.authors-list table {
font-size: var(--font-size-sm);
min-width: 600px;
}
.authors-list th,
.authors-list td {
padding: 0.8rem 1rem;
}
.authors-list th,
.authors-list td {
padding: 0.8rem 1rem;
}
th, td {
padding: 0.8rem 1rem;
}
th,
td {
padding: 0.8rem 1rem;
}
table {
min-width: 600px;
}
table {
min-width: 600px;
}
.search-container {
flex-direction: column;
}
.search-container {
flex-direction: column;
}
.search-input-group {
flex-direction: column;
}
.search-input-group {
flex-direction: column;
}
}

View File

@@ -1,94 +1,155 @@
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: none;
border-radius: var(--border-radius-md);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all var(--transition-fast);
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: none;
border-radius: var(--border-radius-md, 8px);
font-weight: var(--font-weight-medium, 500);
cursor: pointer;
transition: all var(--transition-fast, 0.2s ease);
position: relative;
user-select: none;
outline: none;
/* Default size */
padding: 0.75rem 1.5rem;
font-size: var(--font-size-base, 1rem);
}
/* Variants */
.button-primary {
background-color: var(--primary-color);
color: white;
background-color: var(--primary-color);
color: white;
}
.button-primary:hover:not(:disabled) {
background-color: var(--primary-color-dark);
background-color: var(--primary-color-dark);
}
.button-secondary {
background-color: var(--secondary-color-light);
color: var(--secondary-color-dark);
background-color: var(--secondary-color-light, #f8f9fa);
color: var(--secondary-color-dark, #6c757d);
border: 1px solid var(--border-color, #dee2e6);
}
.button-secondary:hover:not(:disabled) {
background-color: var(--secondary-color);
color: white;
background-color: var(--secondary-color, #6c757d);
color: white;
border-color: var(--secondary-color, #6c757d);
transform: translateY(-1px);
}
.button-secondary:active:not(:disabled) {
transform: translateY(0);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}
.button-danger {
background-color: var(--error-color);
color: white;
background-color: var(--error-color);
color: white;
}
.button-danger:hover:not(:disabled) {
background-color: var(--error-color-dark);
background-color: var(--error-color-dark);
}
/* Sizes */
.small {
padding: 0.5rem 1rem;
font-size: var(--font-size-sm, 0.875rem);
}
.medium {
padding: 0.75rem 1.5rem;
font-size: var(--font-size-base, 1rem);
}
.large {
padding: 1rem 2rem;
font-size: var(--font-size-lg, 1.125rem);
}
/* Legacy support */
.button-small {
padding: 0.5rem 1rem;
font-size: var(--font-size-sm);
padding: 0.5rem 1rem;
font-size: var(--font-size-sm, 0.875rem);
}
.button-medium {
padding: 0.75rem 1.5rem;
font-size: var(--font-size-base);
padding: 0.75rem 1.5rem;
font-size: var(--font-size-base, 1rem);
}
.button-large {
padding: 1rem 2rem;
font-size: var(--font-size-lg);
padding: 1rem 2rem;
font-size: var(--font-size-lg, 1.125rem);
}
/* States */
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
opacity: 0.6;
cursor: not-allowed;
}
.button-loading {
color: transparent;
color: transparent;
}
.button-full-width {
width: 100%;
width: 100%;
}
/* Loading Spinner */
.loading-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 1.25em;
height: 1.25em;
border: 2px solid currentColor;
border-radius: 50%;
border-right-color: transparent;
animation: spin 0.75s linear infinite;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 1.25em;
height: 1.25em;
border: 2px solid currentColor;
border-radius: 50%;
border-right-color: transparent;
animation: spin 0.75s linear infinite;
}
/* Индикатор загрузки языка */
.language-loader {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s linear infinite;
}
/* Стили для кнопки переключения языка */
.language-button {
min-width: 52px;
font-weight: 600;
transition: all 0.2s ease;
position: relative;
}
@keyframes spin {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
/* Исправление для индикатора языка */
.language-loader {
transform: none;
}
@keyframes spin-simple {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.language-loader {
animation: spin-simple 1s linear infinite;
}

View File

@@ -1,248 +1,541 @@
/* ========== ОБЩИЕ ПЕРЕМЕННЫЕ ========== */
:root {
--code-bg: #1e1e1e;
--code-editor-bg: #2d2d2d;
--code-text: #d4d4d4;
--code-line-numbers: #858585;
--code-line-numbers-bg: #252526;
--code-border: rgba(255, 255, 255, 0.1);
--code-accent: #007acc;
--code-success: #4caf50;
--code-error: #f44336;
--code-warning: #ff9800;
--code-font-family: "JetBrains Mono", "Fira Code", "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Consolas", monospace;
--code-font-size: 13px;
--code-line-height: 1.5;
--code-tab-size: 2;
--line-numbers-width: 50px;
--code-padding: 12px;
/* Цвета для подсветки синтаксиса */
--syntax-html-tag: #569cd6;
--syntax-html-bracket: #808080;
--syntax-html-attr-name: #92c5f7;
--syntax-html-attr-value: #ce9178;
--syntax-json-key: #92c5f7;
--syntax-json-string: #ce9178;
--syntax-json-number: #b5cea8;
--syntax-json-boolean: #569cd6;
}
/* ========== БАЗОВЫЕ СТИЛИ ========== */
.codeBase {
font-family: var(--code-font-family);
font-size: var(--code-font-size);
line-height: var(--code-line-height);
tab-size: var(--code-tab-size);
background-color: var(--code-editor-bg);
color: var(--code-text);
border-radius: 6px;
overflow: hidden;
}
.codeContainer {
position: relative;
display: flex;
min-height: 200px;
max-height: 70vh;
border: 1px solid var(--code-border);
}
/* ========== ОБЛАСТЬ КОДА ========== */
.codeArea {
flex: 1;
position: relative;
overflow: hidden;
}
/* Контейнер для кода с относительным позиционированием и скроллом */
.codeContentWrapper {
position: relative;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
line-break: anywhere;
word-break: break-all;
display: flex;
background: var(--code-editor-bg);
}
/* ========== НУМЕРАЦИЯ СТРОК НА CSS ========== */
.lineNumbers {
flex-shrink: 0;
width: var(--line-numbers-width);
background: var(--code-line-numbers-bg);
border-right: 1px solid var(--code-border);
color: var(--code-line-numbers);
font-family: var(--code-font-family);
font-size: var(--code-font-size);
line-height: var(--code-line-height);
padding: var(--code-padding) 0;
user-select: none;
pointer-events: none;
box-sizing: border-box;
counter-reset: line-counter;
position: sticky;
left: 0;
z-index: 3;
}
.lineNumbers::before {
content: '';
white-space: pre-line;
counter-reset: line-counter;
}
.lineNumberItem {
display: block;
padding: 0 8px;
text-align: right;
counter-increment: line-counter;
min-height: calc(var(--code-line-height) * 1em);
box-sizing: border-box;
}
.lineNumberItem::before {
content: counter(line-counter);
}
/* Контейнер для текста кода (textarea и подсветка) */
.codeTextWrapper {
flex: 1;
position: relative;
min-width: 0;
}
.codeContent {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: var(--code-padding);
margin: 0;
border: none;
outline: none;
background: transparent;
color: inherit;
font: inherit;
resize: none;
white-space: pre-wrap;
word-break: break-word;
overflow-x: hidden;
overflow-y: auto;
z-index: 2;
box-sizing: border-box;
}
/* ========== ТОЛЬКО ПРОСМОТР ========== */
.codePreview {
position: relative;
padding-left: 24px !important;
background-color: #2d2d2d;
color: #f8f8f2;
tab-size: 2;
line-height: 1.4;
overflow: hidden;
font-size: 12px;
}
.lineNumber {
display: block;
padding: 0 2px;
text-align: right;
color: #555;
background: #1e1e1e;
user-select: none;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 9px;
line-height: 1.4;
min-height: 12.6px; /* 9px * 1.4 line-height */
border-right: 1px solid rgba(255, 255, 255, 0.1);
opacity: 0.7;
pointer-events: none;
}
.lineNumbersContainer {
position: absolute;
left: 0;
top: 0;
width: 24px;
height: 100%;
background: #1e1e1e;
border-right: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
user-select: none;
padding: 8px 2px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 9px;
line-height: 1.4;
text-align: right;
}
.lineNumbersContainer .lineNumber {
border-right: none;
}
.code {
display: block;
overflow-x: auto;
}
.languageBadge {
position: absolute;
top: 8px;
right: 8px;
font-size: 0.7em;
background-color: rgba(0,0,0,0.7);
color: #fff;
padding: 2px 6px;
border-radius: 4px;
z-index: 10;
}
/* Стили для EditableCodePreview */
.editableCodeContainer {
position: relative;
background-color: #2d2d2d;
overflow: hidden;
height: 100%;
display: flex;
flex-direction: column;
}
.editorControls {
display: flex;
justify-content: flex-end;
padding: 8px 12px;
background-color: #1e1e1e;
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: none;
order: 2; /* Перемещаем вниз */
}
.editingControls {
display: flex;
gap: 8px;
}
.editButton {
background: rgba(0, 122, 204, 0.8);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
}
.editButton:hover {
background: rgba(0, 122, 204, 1);
}
.saveButton {
background: rgba(40, 167, 69, 0.8);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
}
.saveButton:hover {
background: rgba(40, 167, 69, 1);
}
.cancelButton {
background: rgba(220, 53, 69, 0.8);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
}
.cancelButton:hover {
background: rgba(220, 53, 69, 1);
}
.editorWrapper {
position: relative;
overflow: hidden;
background-color: #2d2d2d;
transition: border 0.2s;
flex: 1;
order: 1; /* Основной контент вверху */
}
.syntaxHighlight {
position: absolute;
top: 0;
left: 24px;
right: 0;
bottom: 0;
pointer-events: none;
color: transparent;
background: transparent;
margin: 0;
padding: 8px 8px;
width: 100%;
height: 100%;
tab-size: 2;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
overflow: hidden;
z-index: 0;
}
.editorArea {
position: absolute;
top: 0;
left: 24px;
right: 0;
bottom: 0;
z-index: 1;
margin: 0;
padding: 8px 8px;
resize: none;
border: none;
width: 100%;
height: 100%;
tab-size: 2;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
overflow-y: auto;
outline: none;
}
.editorArea:focus {
outline: none;
}
.editorAreaEditing {
background: rgba(0, 0, 0, 0.02);
color: rgba(255, 255, 255, 0.9);
cursor: text;
caret-color: #fff;
}
.editorAreaViewing {
background: transparent;
color: transparent;
cursor: default;
caret-color: transparent;
}
.editorWrapperEditing {
border: 2px solid #007acc;
composes: codeBase;
}
.codePreviewContainer {
position: absolute;
top: 0;
left: 24px;
right: 0;
bottom: 0;
margin: 0;
padding: 8px 8px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
background: transparent;
cursor: pointer;
overflow-y: auto;
z-index: 2;
composes: codeContainer;
cursor: pointer;
transition: border-color 0.2s ease;
}
.placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #666;
cursor: pointer;
font-style: italic;
font-size: 14px;
pointer-events: none;
user-select: none;
.codePreviewContainer:hover {
border-color: var(--code-accent);
}
.placeholder {
pointer-events: none;
user-select: none;
.codePreviewContent {
composes: codeContent;
cursor: pointer;
}
/* ========== РЕДАКТИРУЕМЫЙ РЕЖИМ ========== */
.editableCodeContainer {
composes: codeBase;
display: flex;
flex-direction: column;
height: 100%;
overflow-x: hidden;
}
.editorContainer {
composes: codeContainer;
flex: 1;
min-height: 300px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
overflow-x: hidden;
}
.editorContainer.editing {
border-color: var(--code-accent);
box-shadow: 0 0 0 1px var(--code-accent);
}
.syntaxHighlight {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: var(--code-padding);
margin: 0;
color: transparent;
background: transparent;
pointer-events: none;
white-space: pre-wrap;
word-break: break-word;
overflow-x: hidden;
overflow-y: auto;
z-index: 1;
box-sizing: border-box;
}
.editorTextarea {
composes: codeContent;
background: rgba(255, 255, 255, 0.02);
caret-color: var(--code-text);
z-index: 2;
white-space: pre-wrap;
word-break: break-word;
overflow-x: hidden;
overflow-y: auto;
}
.editorTextarea:focus {
background: rgba(255, 255, 255, 0.05);
}
.editorTextarea::placeholder {
color: var(--code-line-numbers);
opacity: 0.7;
}
/* ========== ЭЛЕМЕНТЫ УПРАВЛЕНИЯ ========== */
.controls {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: var(--code-line-numbers-bg);
border-top: 1px solid var(--code-border);
}
.controlsLeft {
display: flex;
align-items: center;
gap: 12px;
}
.controlsRight {
display: flex;
align-items: center;
gap: 8px;
}
/* ========== КНОПКИ ========== */
.button {
padding: 6px 12px;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.editButton {
composes: button;
background: var(--code-accent);
color: white;
}
.editButton:hover:not(:disabled) {
background: #1976d2;
transform: translateY(-1px);
}
.saveButton {
composes: button;
background: var(--code-success);
color: white;
}
.saveButton:hover:not(:disabled) {
background: #388e3c;
transform: translateY(-1px);
}
.cancelButton {
composes: button;
background: var(--code-error);
color: white;
}
.cancelButton:hover:not(:disabled) {
background: #d32f2f;
transform: translateY(-1px);
}
.formatButton {
composes: button;
background: var(--code-warning);
color: white;
}
.formatButton:hover:not(:disabled) {
background: #f57c00;
transform: translateY(-1px);
}
/* ========== ИНДИКАТОРЫ ========== */
.languageBadge {
font-size: 11px;
padding: 2px 6px;
background: rgba(0, 0, 0, 0.6);
color: var(--code-text);
border-radius: 3px;
font-family: var(--code-font-family);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.statusIndicator {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--code-line-numbers);
}
.statusDot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.statusDot.idle {
background: var(--code-line-numbers);
}
.statusDot.editing {
background: var(--code-warning);
}
.statusDot.saving {
background: var(--code-success);
animation: pulse 1s infinite;
}
/* ========== ПЛЕЙСХОЛДЕР ========== */
.placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--code-line-numbers);
font-style: italic;
text-align: center;
pointer-events: none;
user-select: none;
z-index: 1;
}
.placeholderClickable {
composes: placeholder;
pointer-events: auto;
cursor: pointer;
padding: var(--code-padding);
margin: 0;
border: none;
background: transparent;
transition: all 0.2s ease;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: flex-start;
justify-content: flex-start;
text-align: left;
transform: none;
font-family: var(--code-font-family);
font-size: var(--code-font-size);
line-height: var(--code-line-height);
}
.placeholderClickable:hover {
color: var(--code-text);
border-color: var(--code-accent);
background: rgba(255, 255, 255, 0.05);
}
/* ========== АНИМАЦИИ ========== */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.fadeIn {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ========== АДАПТИВНОСТЬ ========== */
@media (max-width: 768px) {
:root {
--line-numbers-width: 40px;
--code-padding: 8px;
--code-font-size: 12px;
}
.controls {
flex-direction: column;
gap: 8px;
align-items: stretch;
}
.controlsLeft,
.controlsRight {
justify-content: center;
}
}
/* ========== ACCESSIBILITY ========== */
@media (prefers-reduced-motion: reduce) {
.button,
.placeholderClickable,
.editorContainer {
transition: none;
}
.statusDot.saving {
animation: none;
}
}
/* ========== ТЕМНАЯ ТЕМА (по умолчанию) ========== */
.darkTheme {
/* Переменные уже установлены для темной темы */
}
/* ========== СВЕТЛАЯ ТЕМА ========== */
.lightTheme {
--code-bg: #ffffff;
--code-editor-bg: #fafafa;
--code-text: #333333;
--code-line-numbers: #999999;
--code-line-numbers-bg: #f5f5f5;
--code-border: rgba(0, 0, 0, 0.1);
}
/* ========== ВЫСОКОКОНТРАСТНАЯ ТЕМА ========== */
.highContrastTheme {
--code-bg: #000000;
--code-editor-bg: #000000;
--code-text: #ffffff;
--code-line-numbers: #ffffff;
--code-line-numbers-bg: #000000;
--code-border: #ffffff;
--code-accent: #00ffff;
}
/* ========== SCROLLBAR ========== */
.codeContent::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.codeContent::-webkit-scrollbar-track {
background: var(--code-line-numbers-bg);
}
.codeContent::-webkit-scrollbar-thumb {
background: var(--code-line-numbers);
border-radius: 4px;
}
.codeContent::-webkit-scrollbar-thumb:hover {
background: var(--code-text);
}
/* ========== LEGACY SUPPORT ========== */
.codePreview {
/* Обратная совместимость */
position: relative;
padding-left: var(--line-numbers-width) !important;
}
.lineNumber {
/* Обратная совместимость */
display: inline-block;
width: var(--line-numbers-width);
margin-left: calc(-1 * var(--line-numbers-width));
padding: 0 8px;
text-align: right;
user-select: none;
pointer-events: none;
box-sizing: border-box;
}
.codeLine {
display: block;
position: relative;
min-height: calc(var(--code-line-height) * 1em);
}
/* ========== ПОДСВЕТКА СИНТАКСИСА ========== */
/* HTML теги */
:global(.html-tag) {
color: var(--syntax-html-tag);
font-weight: 500;
}
:global(.html-bracket) {
color: var(--syntax-html-bracket);
}
:global(.html-attr-name) {
color: var(--syntax-html-attr-name);
}
:global(.html-attr-value) {
color: var(--syntax-html-attr-value);
}
/* JSON подсветка */
:global(.json-key) {
color: var(--syntax-json-key);
}
:global(.json-string) {
color: var(--syntax-json-string);
}
:global(.json-number) {
color: var(--syntax-json-number);
}
:global(.json-boolean) {
color: var(--syntax-json-boolean);
font-weight: 500;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,101 +1,102 @@
/* Global CSS Variables */
:root {
/* Colors */
--primary-color: #2563eb;
--primary-color-light: #dbeafe;
--primary-color-dark: #1e40af;
/* Colors */
--primary-color: #2563eb;
--primary-color-light: #dbeafe;
--primary-color-dark: #1e40af;
--secondary-color: #4b5563;
--secondary-color-light: #f3f4f6;
--secondary-color-dark: #1f2937;
--secondary-color: #4b5563;
--secondary-color-light: #f3f4f6;
--secondary-color-dark: #1f2937;
--success-color: #059669;
--success-color-light: #d1fae5;
--success-color-dark: #065f46;
--success-color: #059669;
--success-color-light: #d1fae5;
--success-color-dark: #065f46;
--warning-color: #d97706;
--warning-color-light: #fef3c7;
--warning-color-dark: #92400e;
--warning-color: #d97706;
--warning-color-light: #fef3c7;
--warning-color-dark: #92400e;
--error-color: #dc2626;
--error-color-light: #fee2e2;
--error-color-dark: #991b1b;
--error-color: #dc2626;
--error-color-light: #fee2e2;
--error-color-dark: #991b1b;
--info-color: #0284c7;
--info-color-light: #e0f2fe;
--info-color-dark: #075985;
--info-color: #0284c7;
--info-color-light: #e0f2fe;
--info-color-dark: #075985;
/* Text Colors */
--text-color: #111827;
--text-color-light: #6b7280;
--text-color-lighter: #9ca3af;
/* Text Colors */
--text-color: #111827;
--text-color-light: #6b7280;
--text-color-lighter: #9ca3af;
/* Background Colors */
--background-color: #ffffff;
--header-background: #f9fafb;
--hover-color: #f3f4f6;
/* Background Colors */
--background-color: #ffffff;
--header-background: #f9fafb;
--hover-color: #f3f4f6;
/* Border Colors */
--border-color: #e5e7eb;
/* Border Colors */
--border-color: #e5e7eb;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
/* Border Radius */
--border-radius-sm: 0.25rem;
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
/* Border Radius */
--border-radius-sm: 0.25rem;
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
/* Box Shadow */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Box Shadow */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg:
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Font Sizes */
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
/* Font Sizes */
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
/* Font Weights */
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Font Weights */
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Line Heights */
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
/* Line Heights */
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
/* Transitions */
--transition-fast: 150ms;
--transition-normal: 200ms;
--transition-slow: 300ms;
/* Transitions */
--transition-fast: 150ms;
--transition-normal: 200ms;
--transition-slow: 300ms;
/* Z-Index */
--z-index-dropdown: 1000;
--z-index-sticky: 1020;
--z-index-fixed: 1030;
--z-index-modal-backdrop: 1040;
--z-index-modal: 1050;
--z-index-popover: 1060;
--z-index-tooltip: 1070;
/* Z-Index */
--z-index-dropdown: 1000;
--z-index-sticky: 1020;
--z-index-fixed: 1030;
--z-index-modal-backdrop: 1040;
--z-index-modal: 1050;
--z-index-popover: 1060;
--z-index-tooltip: 1070;
/* Dark Mode Colors */
--dark-bg-color: #1f2937;
--dark-bg-color-dark: #111827;
--dark-hover-bg: #374151;
--dark-disabled-bg: #4b5563;
--dark-text-color: #f9fafb;
--dark-text-color-light: #d1d5db;
--dark-text-color-lighter: #9ca3af;
--dark-border-color: #374151;
--dark-border-color-dark: #4b5563;
/* Dark Mode Colors */
--dark-bg-color: #1f2937;
--dark-bg-color-dark: #111827;
--dark-hover-bg: #374151;
--dark-disabled-bg: #4b5563;
--dark-text-color: #f9fafb;
--dark-text-color-light: #d1d5db;
--dark-text-color-lighter: #9ca3af;
--dark-border-color: #374151;
--dark-border-color-dark: #4b5563;
}

View File

@@ -1,78 +1,97 @@
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: var(--background-color);
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: var(--background-color);
}
.login-header {
display: flex;
justify-content: flex-end;
padding: 1rem 2rem;
}
.login-form-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}
.login-form {
width: 100%;
max-width: 400px;
padding: 2rem;
background-color: white;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 400px;
padding: 2rem;
background-color: white;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-lg);
}
.login-logo {
display: block;
margin: 0 auto 1.5rem;
height: 3rem;
width: auto;
}
.login-form h1 {
margin: 0 0 2rem;
color: var(--text-color);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
text-align: center;
margin: 0 0 2rem;
color: var(--text-color);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
text-align: center;
}
.form-group {
margin-bottom: 1.5rem;
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-color);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
display: block;
margin-bottom: 0.5rem;
color: var(--text-color);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
font-size: var(--font-size-base);
color: var(--text-color);
transition: border-color var(--transition-fast);
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
font-size: var(--font-size-base);
color: var(--text-color);
transition: border-color var(--transition-fast);
}
.form-group input:focus {
outline: none;
border-color: var(--primary-color);
outline: none;
border-color: var(--primary-color);
}
.form-group input:disabled {
background-color: var(--secondary-color-light);
cursor: not-allowed;
background-color: var(--secondary-color-light);
cursor: not-allowed;
}
.error-message {
margin-bottom: 1.5rem;
padding: 1rem;
background-color: var(--error-color-light);
color: var(--error-color-dark);
border-radius: var(--border-radius-md);
font-size: var(--font-size-sm);
margin-bottom: 1.5rem;
padding: 1rem;
background-color: var(--error-color-light);
color: var(--error-color-dark);
border-radius: var(--border-radius-md);
font-size: var(--font-size-sm);
}
/* Responsive Design */
@media (max-width: 480px) {
.login-form {
margin: 1rem;
padding: 1.5rem;
}
.login-form {
margin: 1rem;
padding: 1.5rem;
}
.login-form h1 {
font-size: var(--font-size-xl);
margin-bottom: 1.5rem;
}
.login-form h1 {
font-size: var(--font-size-xl);
margin-bottom: 1.5rem;
}
}

View File

@@ -1,230 +1,376 @@
.backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
backdrop-filter: blur(8px);
animation: backdropFadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal {
background-color: white;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
max-height: 95vh;
width: 100%;
animation: modal-appear 0.2s ease-out;
background-color: white;
border-radius: var(--border-radius-lg);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
max-height: 95vh;
width: 100%;
animation: modalSlideIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: center bottom;
}
/* Modal Sizes */
.modal-small {
max-width: 400px;
max-width: 400px;
}
.modal-medium {
max-width: 600px;
max-width: 600px;
}
.modal-large {
max-width: 1200px;
width: 95vw;
height: 85vh;
max-height: 85vh;
max-width: 1200px;
width: 95vw;
height: 85vh;
max-height: 85vh;
}
.modal-large .content {
flex: 1;
overflow: hidden; /* Убираем скролл модального окна, пусть EditableCodePreview управляет */
padding: 0; /* Убираем padding чтобы EditableCodePreview занял всю площадь */
flex: 1;
overflow: hidden;
padding: 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(248, 249, 250, 0.8));
backdrop-filter: blur(10px);
}
.title {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--text-color);
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--text-color);
opacity: 0;
animation: titleSlideIn 0.5s cubic-bezier(0.4, 0, 0.2, 1) 0.1s forwards;
}
.close {
background: none;
border: none;
font-size: var(--font-size-2xl);
color: var(--text-color-light);
cursor: pointer;
padding: 0;
line-height: 1;
transition: color var(--transition-fast);
background: none;
border: none;
font-size: var(--font-size-2xl);
color: var(--text-color-light);
cursor: pointer;
padding: 0.5rem;
line-height: 1;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
opacity: 0;
animation: closeButtonSlideIn 0.4s cubic-bezier(0.4, 0, 0.2, 1) 0.2s forwards;
}
.close:hover {
color: var(--text-color);
color: var(--text-color);
background: rgba(0, 0, 0, 0.05);
transform: scale(1.1) rotate(90deg);
}
.close:active {
transform: scale(0.95) rotate(90deg);
}
.content {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
padding: 1.5rem;
overflow-y: auto;
flex: 1;
opacity: 0;
animation: contentSlideIn 0.5s cubic-bezier(0.4, 0, 0.2, 1) 0.2s forwards;
}
.footer {
padding: 1.5rem;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 1.5rem;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 1rem;
background: linear-gradient(135deg, rgba(248, 249, 250, 0.8), rgba(255, 255, 255, 0.9));
backdrop-filter: blur(10px);
opacity: 0;
animation: footerSlideIn 0.5s cubic-bezier(0.4, 0, 0.2, 1) 0.3s forwards;
}
@keyframes modal-appear {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
/* Улучшенные анимации */
@keyframes backdropFadeIn {
from {
opacity: 0;
backdrop-filter: blur(0px);
}
to {
opacity: 1;
backdrop-filter: blur(8px);
}
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(60px) scale(0.9);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
}
@keyframes titleSlideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes closeButtonSlideIn {
from {
opacity: 0;
transform: translateX(20px) scale(0.8);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes contentSlideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes footerSlideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Анимация закрытия */
.backdrop.closing {
animation: backdropFadeOut 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal.closing {
animation: modalSlideOut 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes backdropFadeOut {
from {
opacity: 1;
backdrop-filter: blur(8px);
}
to {
opacity: 0;
backdrop-filter: blur(0px);
}
}
@keyframes modalSlideOut {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(60px) scale(0.9);
}
}
/* Responsive Design */
@media (max-width: 640px) {
.backdrop {
padding: 0.5rem;
}
.backdrop {
padding: 0.5rem;
}
.modal {
max-height: 100vh;
border-radius: 0;
}
.modal {
max-height: 100vh;
border-radius: 1rem 1rem 0 0;
animation: modalSlideInMobile 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-small,
.modal-medium,
.modal-large {
max-width: none;
}
.modal-small,
.modal-medium,
.modal-large {
max-width: none;
}
.header {
padding: 1rem;
}
.header {
padding: 1rem;
}
.content {
padding: 1rem;
}
.content {
padding: 1rem;
}
.footer {
padding: 1rem;
}
.footer {
padding: 1rem;
}
}
@keyframes modalSlideInMobile {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Адаптивность для больших модальных окон */
@media (max-width: 768px) {
.modal-large {
width: 95vw;
max-width: none;
margin: 20px;
min-height: auto;
max-height: 90vh;
}
.modal-large {
width: 95vw;
max-width: none;
margin: 20px;
min-height: auto;
max-height: 90vh;
}
.modal-large .content {
max-height: 80vh;
}
.modal-large .content {
max-height: 80vh;
}
}
/* Role Modal Specific Styles */
/* Улучшенные стили для специфических модальных окон */
.roles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.role-option {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
border: 2px solid var(--border-color);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: var(--surface-color);
}
.role-option:hover {
background-color: var(--hover-bg);
background-color: var(--hover-bg);
border-color: var(--primary-color);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.role-name {
font-weight: 500;
color: var(--text-color);
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.25rem;
}
.role-description {
font-size: 0.875rem;
color: var(--text-color-light);
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--text-color-light);
line-height: 1.4;
}
/* Environment Variable Modal Specific Styles */
/* Environment Variable Modal */
.env-variable-form {
display: flex;
flex-direction: column;
gap: 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 500;
color: var(--text-color);
font-weight: 600;
color: var(--text-color);
font-size: 0.875rem;
}
.form-group input {
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 1rem;
background-color: var(--bg-color);
color: var(--text-color);
padding: 0.75rem;
border: 2px solid var(--border-color);
border-radius: 0.5rem;
font-size: 0.875rem;
background-color: var(--bg-color);
color: var(--text-color);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.form-group input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 4px rgba(0, 123, 255, 0.1);
transform: translateY(-1px);
}
.form-group input:disabled {
background-color: var(--disabled-bg);
cursor: not-allowed;
background-color: var(--disabled-bg);
cursor: not-allowed;
opacity: 0.7;
}
.description {
font-size: 0.875rem;
color: var(--text-color-light);
padding: 0.5rem;
background-color: var(--hover-bg);
border-radius: 4px;
font-size: 0.875rem;
color: var(--text-color-light);
padding: 0.75rem;
background-color: var(--hover-bg);
border-radius: 0.5rem;
border-left: 4px solid var(--primary-color);
}
/* Body Preview Modal Specific Styles */
/* Body Preview Modal */
.body-preview {
width: 100%;
min-height: 200px;
max-height: calc(90vh - 200px);
overflow-y: auto;
background-color: var(--code-bg);
border-radius: 4px;
font-family: monospace;
width: 100%;
min-height: 200px;
max-height: calc(90vh - 200px);
overflow-y: auto;
background-color: var(--code-bg);
border-radius: 0.5rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
border: 1px solid var(--border-color);
}

View File

@@ -1,114 +1,114 @@
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin: 1rem 0;
padding: 1rem;
background-color: var(--background-color);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-sm);
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin: 1rem 0;
padding: 1rem;
background-color: var(--background-color);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-sm);
}
.pagination-info {
color: var(--text-color-light);
font-size: var(--font-size-sm);
color: var(--text-color-light);
font-size: var(--font-size-sm);
}
.pagination-controls {
display: flex;
align-items: center;
gap: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.pagination-ellipsis {
color: var(--text-color-light);
padding: 0 0.5rem;
color: var(--text-color-light);
padding: 0 0.5rem;
}
.pagination-per-page {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-color-light);
font-size: var(--font-size-sm);
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-color-light);
font-size: var(--font-size-sm);
}
.pageButton {
background-color: var(--background-color);
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 0.5rem 1rem;
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
font-size: var(--font-size-sm);
background-color: var(--background-color);
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 0.5rem 1rem;
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
font-size: var(--font-size-sm);
}
.pageButton:hover:not(:disabled) {
background-color: var(--hover-color);
border-color: var(--primary-color);
color: var(--primary-color);
background-color: var(--hover-color);
border-color: var(--primary-color);
color: var(--primary-color);
}
.pageButton:disabled {
background-color: var(--secondary-color-light);
color: var(--text-color-light);
cursor: not-allowed;
background-color: var(--secondary-color-light);
color: var(--text-color-light);
cursor: not-allowed;
}
.currentPage {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
font-weight: var(--font-weight-medium);
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
font-weight: var(--font-weight-medium);
}
.currentPage:hover {
background-color: var(--primary-color-dark);
background-color: var(--primary-color-dark);
}
.perPageSelect {
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background-color: var(--background-color);
color: var(--text-color);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all var(--transition-fast);
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background-color: var(--background-color);
color: var(--text-color);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.perPageSelect:hover {
border-color: var(--primary-color);
border-color: var(--primary-color);
}
.perPageSelect:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color-light);
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color-light);
}
/* Responsive Design */
@media (max-width: 640px) {
.pagination {
flex-direction: column;
gap: 0.5rem;
}
.pagination {
flex-direction: column;
gap: 0.5rem;
}
.pageButton {
padding: 0.25rem 0.5rem;
}
.pageButton {
padding: 0.25rem 0.5rem;
}
.pagination-controls {
order: 2;
}
.pagination-controls {
order: 2;
}
.pagination-info {
order: 1;
}
.pagination-info {
order: 1;
}
.pagination-per-page {
order: 3;
}
.pagination-per-page {
order: 3;
}
}

View File

@@ -0,0 +1,368 @@
/* ==============================
МЕНЕДЖЕР РОЛЕЙ - ЕДИНООБРАЗНЫЙ ДИЗАЙН
============================== */
/* Основной контейнер */
.roleManager {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* ==============================
СЕКЦИИ
============================== */
.section {
background: #ffffff;
border-radius: 12px;
padding: 1.5rem;
border: 1px solid #e5e7eb;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.sectionTitle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 600;
color: #374151;
margin: 0;
}
.sectionDescription {
color: #6b7280;
font-size: 0.875rem;
margin: 0 0 1.25rem 0;
line-height: 1.4;
}
.icon {
font-size: 1.1rem;
}
.required {
color: #ef4444;
font-weight: 600;
}
/* ==============================
КНОПКА ДОБАВЛЕНИЯ
============================== */
.addButton {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
border: none;
max-width: 24em;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.addButton:hover {
background: #2563eb;
transform: translateY(-1px);
}
/* ==============================
СЕТКА РОЛЕЙ
============================== */
.rolesGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
/* ==============================
КАРТОЧКИ РОЛЕЙ - ЕДИНООБРАЗНЫЙ ДИЗАЙН
============================== */
.roleCard {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
display: flex;
flex-direction: column;
min-height: 120px;
}
/* Состояния карточек - ОДИНАКОВЫЕ ДЛЯ ВСЕХ */
.roleCard:hover:not(.disabled) {
border-color: #3b82f6;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
transform: translateY(-2px);
}
.roleCard.selected {
border-color: #3b82f6;
background: #f0f9ff;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
}
/* Заблокированные роли (администратор) */
.roleCard.disabled {
opacity: 0.75;
cursor: not-allowed;
background: #f9fafb;
border-color: #d1d5db;
}
.roleCard.disabled::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
45deg,
transparent,
transparent 8px,
rgba(156, 163, 175, 0.1) 8px,
rgba(156, 163, 175, 0.1) 16px
);
border-radius: inherit;
pointer-events: none;
}
/* ==============================
СОДЕРЖИМОЕ КАРТОЧЕК
============================== */
.roleHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.roleIcon {
font-size: 1.5rem;
opacity: 0.8;
}
.roleActions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.removeButton {
background: none;
border: none;
cursor: pointer;
font-size: 0.75rem;
opacity: 0.6;
transition: all 0.2s ease;
padding: 0.25rem;
border-radius: 4px;
color: #ef4444;
}
.removeButton:hover {
opacity: 1;
background: rgba(239, 68, 68, 0.1);
}
.roleContent {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.roleName {
font-size: 0.925rem;
font-weight: 600;
color: #374151;
margin: 0;
line-height: 1.3;
}
.roleDescription {
font-size: 0.8rem;
color: #6b7280;
margin: 0;
line-height: 1.4;
flex: 1;
}
.disabledNote {
font-size: 0.75rem;
color: #9ca3af;
font-style: italic;
margin-top: 0.25rem;
}
/* ==============================
ЧЕКБОКСЫ - ЕДИНООБРАЗНЫЕ
============================== */
.checkbox {
display: flex;
align-items: center;
margin-top: auto;
}
.checkbox input {
width: 18px;
height: 18px;
margin: 0;
cursor: pointer;
accent-color: #3b82f6;
}
.checkbox input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* ==============================
ФОРМА ДОБАВЛЕНИЯ РОЛИ
============================== */
.addRoleForm {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 1.5rem;
margin-top: 1rem;
}
.addRoleTitle {
font-size: 1rem;
font-weight: 600;
color: #374151;
margin: 0 0 1rem 0;
}
.addRoleFields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.fieldGroup {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.fieldLabel {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.fieldInput {
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.875rem;
transition: border-color 0.2s ease;
}
.fieldInput:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.fieldInput.error {
border-color: #ef4444;
}
.fieldError {
font-size: 0.75rem;
color: #ef4444;
margin-top: 0.25rem;
}
.addRoleActions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.cancelButton,
.primaryButton {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.cancelButton {
background: #f3f4f6;
color: #374151;
}
.cancelButton:hover {
background: #e5e7eb;
}
.primaryButton {
background: #3b82f6;
color: white;
}
.primaryButton:hover {
background: #2563eb;
transform: translateY(-1px);
}
/* ==============================
АДАПТИВНОСТЬ
============================== */
@media (max-width: 768px) {
.sectionHeader {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.rolesGrid {
grid-template-columns: 1fr;
}
.addRoleFields {
grid-template-columns: 1fr;
}
.section {
padding: 1rem;
}
.addRoleActions {
flex-direction: column;
}
.cancelButton,
.primaryButton {
width: 100%;
}
}

View File

@@ -1,420 +1,561 @@
.table-container {
width: 100%;
overflow-x: auto;
margin: 1rem 0;
width: 100%;
overflow-x: auto;
margin: 0.5rem 0;
}
.table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--border-color);
background-color: var(--bg-color);
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.table th {
background-color: var(--bg-color-dark);
font-weight: 600;
color: var(--text-color);
white-space: nowrap;
}
.table tr:hover {
background-color: var(--hover-bg);
background-color: var(--bg-color-dark);
font-weight: 600;
color: var(--text-color);
white-space: nowrap;
}
.table td {
color: var(--text-color);
color: var(--text-color);
}
.badge-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
line-height: 1;
white-space: nowrap;
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
line-height: 1;
white-space: nowrap;
}
.role-badge {
background-color: var(--primary-color-light);
color: var(--primary-color-dark);
background-color: var(--primary-color-light);
color: var(--primary-color-dark);
}
.author-badge {
background-color: var(--success-color-light);
color: var(--success-color-dark);
background-color: var(--success-color-light);
color: var(--success-color-dark);
}
.topic-badge {
background-color: var(--info-color-light);
color: var(--info-color-dark);
background-color: var(--info-color-light);
color: var(--info-color-dark);
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.table-empty {
text-align: center;
padding: 2rem;
color: var(--text-color-light);
text-align: center;
padding: 2rem;
color: var(--text-color-light);
}
.table-loading {
position: relative;
min-height: 200px;
position: relative;
min-height: 200px;
}
.table-loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 2rem;
height: 2rem;
margin: -1rem;
border: 2px solid var(--primary-color);
border-right-color: transparent;
border-radius: 50%;
animation: table-loading 0.75s linear infinite;
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 2rem;
height: 2rem;
margin: -1rem;
border: 2px solid var(--primary-color);
border-right-color: transparent;
border-radius: 50%;
animation: table-loading 0.75s linear infinite;
}
@keyframes table-loading {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Базовые стили для таблицы и контейнера */
.container {
padding: 20px;
padding: 10px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
/* Стили для TableControls */
.tableControls {
margin-bottom: 15px;
}
.controlsContainer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: nowrap;
}
.controlsRight {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.searchContainer {
display: flex;
align-items: center;
flex: 1;
max-width: 400px;
}
.searchInput {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px 0 0 4px;
font-size: 14px;
color: #333;
flex-grow: 1;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
transition: border-color 0.2s ease;
border-right: none;
}
.searchInput:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2);
}
.searchInput::placeholder {
color: #aaa;
}
.searchButton {
padding: 8px 16px;
background-color: #4f46e5;
color: white;
border: none;
border-radius: 0 4px 4px 0;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s ease;
white-space: nowrap;
}
.searchButton:hover {
background-color: #4338ca;
}
.table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 8px;
overflow: hidden;
width: 100%;
border-collapse: collapse;
margin: 0;
font-size: 14px;
}
.table th,
.table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #ddd;
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.table th {
background-color: #f8f9fa;
font-weight: 600;
color: #333;
background-color: #f8f9fa;
font-weight: 600;
color: #333;
}
.table tbody tr {
transition: background-color 0.2s ease;
transition: background-color 0.2s ease;
}
.table tbody tr:hover {
background-color: #f5f5f5;
/* Стили для кнопок */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
user-select: none;
outline: none;
padding: 0.75rem 1.5rem;
font-size: 1rem;
background-color: #f8f9fa;
color: #6c757d;
border: 1px solid #dee2e6;
}
.table tbody tr:nth-child(even) {
background-color: #f9f9f9;
.button:hover:not(:disabled) {
background-color: #6c757d;
color: white;
border-color: #6c757d;
transform: translateY(-1px);
}
.button:active:not(:disabled) {
transform: translateY(0);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.primary {
background-color: #4f46e5;
color: white;
border: none;
}
.primary:hover:not(:disabled) {
background-color: #4338ca;
}
.secondary {
background-color: #f8f9fa;
color: #6c757d;
border: 1px solid #dee2e6;
}
.secondary:hover:not(:disabled) {
background-color: #6c757d;
color: white;
}
.danger {
background-color: #dc3545;
color: white;
border: none;
}
.danger:hover:not(:disabled) {
background-color: #c82333;
}
/* Стили для действий */
.action-button {
font-size: 12px;
padding: 6px 12px;
margin: 0 2px;
font-size: 12px;
padding: 6px 12px;
margin: 0 2px;
}
/* Стили для предупреждающих сообщений */
.warning-text {
color: #e74c3c;
font-weight: 500;
margin: 10px 0;
font-size: 14px;
color: #e74c3c;
font-weight: 500;
margin: 10px 0;
font-size: 14px;
}
/* Стили для модальных действий */
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #eee;
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.clickable-row:hover {
background-color: #f8f9fa;
transition: background-color 0.2s ease;
background-color: #f8f9fa;
transition: background-color 0.2s ease;
}
.delete-button {
background: none;
border: none;
color: #6c757d;
font-size: 18px;
font-weight: bold;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s ease;
line-height: 1;
min-width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: #6c757d;
font-size: 18px;
font-weight: bold;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s ease;
line-height: 1;
min-width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.delete-button:hover {
background-color: #dc3545;
color: white;
transform: scale(1.1);
background-color: #dc3545;
color: white;
transform: scale(1.1);
}
.delete-button:active {
transform: scale(0.95);
transform: scale(0.95);
}
/* Стили для чекбоксов и пакетного удаления */
.checkbox-column {
width: 40px;
text-align: center;
width: 40px;
text-align: center;
}
.checkbox {
cursor: pointer;
width: 18px;
height: 18px;
cursor: pointer;
width: 18px;
height: 18px;
}
.batch-actions {
display: flex;
gap: 10px;
margin-bottom: 10px;
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.selected-count {
display: flex;
align-items: center;
font-size: 14px;
color: #666;
margin-right: 10px;
display: flex;
align-items: center;
font-size: 14px;
color: #666;
margin-right: 10px;
}
.select-all-container {
display: flex;
align-items: center;
margin-right: 15px;
display: flex;
align-items: center;
margin-right: 15px;
}
.select-all-label {
margin-left: 5px;
font-size: 14px;
cursor: pointer;
margin-left: 5px;
font-size: 14px;
cursor: pointer;
}
/* Кнопка пакетного удаления */
.batch-delete-button {
background-color: #dc3545;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: background-color 0.2s;
background-color: #dc3545;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: background-color 0.2s;
}
.batch-delete-button:hover {
background-color: #c82333;
background-color: #c82333;
}
.batch-delete-button:disabled {
background-color: #e9a8ae;
cursor: not-allowed;
background-color: #e9a8ae;
cursor: not-allowed;
}
/* Новые стили для улучшенной панели поиска */
.searchSection {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
}
.searchRow {
margin-bottom: 12px;
margin-bottom: 12px;
}
.fullWidthSearch {
width: 100%;
padding: 12px 16px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 14px;
background: white;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
width: 100%;
padding: 12px 16px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 14px;
background: white;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.fullWidthSearch:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.fullWidthSearch::placeholder {
color: #6c757d;
font-style: italic;
color: #6c757d;
font-style: italic;
}
.filtersRow {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.statusFilter {
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
background: white;
color: #495057;
cursor: pointer;
min-width: 140px;
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
background: white;
color: #495057;
cursor: pointer;
min-width: 140px;
height: 38px;
}
.statusFilter:focus {
outline: none;
border-color: #4f46e5;
outline: none;
border-color: #4f46e5;
}
/* Стили для сортируемых заголовков */
.sortableHeader {
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
position: relative;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
position: relative;
background-color: var(--bg-color-dark, #f8f9fa);
}
.sortableHeader:hover {
background-color: #e9ecef !important;
background-color: #e9ecef !important;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.sortableHeader:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.headerContent {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8px;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8px;
padding: 0.25rem 0;
}
.sortIcon {
font-size: 12px;
color: #6c757d;
margin-left: auto;
min-width: 16px;
text-align: center;
opacity: 0.7;
transition: opacity 0.2s ease;
font-size: 14px;
color: #6c757d;
margin-left: auto;
min-width: 18px;
text-align: center;
opacity: 0.6;
transition: all 0.2s ease;
font-weight: bold;
}
.sortableHeader:hover .sortIcon {
opacity: 1;
opacity: 1;
color: #495057;
transform: scale(1.1);
}
.sortableHeader[data-active="true"] .sortIcon {
color: #4f46e5;
opacity: 1;
font-weight: bold;
color: #4f46e5;
opacity: 1;
font-weight: bold;
transform: scale(1.2);
}
.disabledHeader {
cursor: not-allowed;
opacity: 0.6;
}
.disabledHeader:hover {
background-color: var(--bg-color-dark, #f8f9fa) !important;
transform: none;
box-shadow: none;
}
/* Улучшенные адаптивные стили */
@media (max-width: 768px) {
.filtersRow {
flex-direction: column;
align-items: stretch;
}
.filtersRow {
flex-direction: column;
align-items: stretch;
}
.statusFilter {
min-width: auto;
}
.statusFilter {
min-width: auto;
}
.headerContent {
font-size: 12px;
}
.headerContent {
font-size: 12px;
}
.sortIcon {
font-size: 10px;
}
.sortIcon {
font-size: 10px;
}
}
@media (max-width: 480px) {
.searchSection {
padding: 12px;
}
.searchSection {
padding: 12px;
}
.fullWidthSearch {
padding: 10px 12px;
font-size: 13px;
}
.fullWidthSearch {
padding: 10px 12px;
font-size: 13px;
}
.filtersRow {
gap: 8px;
}
.filtersRow {
gap: 8px;
}
}
/* Улучшения существующих стилей */
.controls {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.searchInput {
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
min-width: 200px;
flex: 1;
/* Стиль для ячейки с body топика */
.bodyCell {
background-color: #f8f9fa;
transition: background-color 0.2s ease;
}

View File

@@ -1,72 +1,158 @@
/* Utility classes for consistent styling */
.flex {
display: flex;
display: flex;
}
.flexCol {
flex-direction: column;
flex-direction: column;
}
.itemsCenter {
align-items: center;
align-items: center;
}
.justifyCenter {
justify-content: center;
justify-content: center;
}
.justifyBetween {
justify-content: space-between;
justify-content: space-between;
}
.gap1 { gap: 4px; }
.gap2 { gap: 8px; }
.gap3 { gap: 12px; }
.gap4 { gap: 16px; }
.gap5 { gap: 20px; }
.gap1 {
gap: 4px;
}
.gap2 {
gap: 8px;
}
.gap3 {
gap: 12px;
}
.gap4 {
gap: 16px;
}
.gap5 {
gap: 20px;
}
.m0 { margin: 0; }
.mt1 { margin-top: 4px; }
.mt2 { margin-top: 8px; }
.mt3 { margin-top: 12px; }
.mt4 { margin-top: 16px; }
.mt5 { margin-top: 20px; }
.m0 {
margin: 0;
}
.mt1 {
margin-top: 4px;
}
.mt2 {
margin-top: 8px;
}
.mt3 {
margin-top: 12px;
}
.mt4 {
margin-top: 16px;
}
.mt5 {
margin-top: 20px;
}
.mb1 { margin-bottom: 4px; }
.mb2 { margin-bottom: 8px; }
.mb3 { margin-bottom: 12px; }
.mb4 { margin-bottom: 16px; }
.mb5 { margin-bottom: 20px; }
.mb1 {
margin-bottom: 4px;
}
.mb2 {
margin-bottom: 8px;
}
.mb3 {
margin-bottom: 12px;
}
.mb4 {
margin-bottom: 16px;
}
.mb5 {
margin-bottom: 20px;
}
.p0 { padding: 0; }
.p1 { padding: 4px; }
.p2 { padding: 8px; }
.p3 { padding: 12px; }
.p4 { padding: 16px; }
.p5 { padding: 20px; }
.p0 {
padding: 0;
}
.p1 {
padding: 4px;
}
.p2 {
padding: 8px;
}
.p3 {
padding: 12px;
}
.p4 {
padding: 16px;
}
.p5 {
padding: 20px;
}
.textXs { font-size: 12px; }
.textSm { font-size: 14px; }
.textBase { font-size: 16px; }
.textLg { font-size: 18px; }
.textXl { font-size: 20px; }
.text2Xl { font-size: 24px; }
.textXs {
font-size: 12px;
}
.textSm {
font-size: 14px;
}
.textBase {
font-size: 16px;
}
.textLg {
font-size: 18px;
}
.textXl {
font-size: 20px;
}
.text2Xl {
font-size: 24px;
}
.fontNormal { font-weight: 400; }
.fontMedium { font-weight: 500; }
.fontSemibold { font-weight: 600; }
.fontBold { font-weight: 700; }
.fontNormal {
font-weight: 400;
}
.fontMedium {
font-weight: 500;
}
.fontSemibold {
font-weight: 600;
}
.fontBold {
font-weight: 700;
}
.textPrimary { color: var(--primary-color); }
.textSecondary { color: var(--text-secondary); }
.textMuted { color: var(--text-muted); }
.textSuccess { color: var(--success-color); }
.textDanger { color: var(--danger-color); }
.textWarning { color: var(--warning-color); }
.textPrimary {
color: var(--primary-color);
}
.textSecondary {
color: var(--text-secondary);
}
.textMuted {
color: var(--text-muted);
}
.textSuccess {
color: var(--success-color);
}
.textDanger {
color: var(--danger-color);
}
.textWarning {
color: var(--warning-color);
}
.bgWhite { background-color: var(--bg-color); }
.bgCard { background-color: var(--card-bg); }
.bgSuccessLight { background-color: var(--success-light); }
.bgDangerLight { background-color: var(--danger-light); }
.bgWarningLight { background-color: var(--warning-light); }
.bgWhite {
background-color: var(--bg-color);
}
.bgCard {
background-color: var(--card-bg);
}
.bgSuccessLight {
background-color: var(--success-light);
}
.bgDangerLight {
background-color: var(--danger-light);
}
.bgWarningLight {
background-color: var(--warning-light);
}

View File

@@ -1,4 +1,4 @@
declare module '*.module.css' {
const styles: { [key: string]: string }
export default styles
declare module "*.module.css" {
const styles: { [key: string]: string };
export default styles;
}

20
panel/types/svg.d.ts vendored
View File

@@ -1,15 +1,15 @@
declare module '*.svg' {
const content: string
export default content
declare module "*.svg" {
const content: string;
export default content;
}
declare module '*.svg?component' {
import type { Component } from 'solid-js'
const component: Component
export default component
declare module "*.svg?component" {
import type { Component } from "solid-js";
const component: Component;
export default component;
}
declare module '*.svg?url' {
const url: string
export default url
declare module "*.svg?url" {
const url: string;
export default url;
}

View File

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

View 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

View File

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

View 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

View File

@@ -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 = () => {

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

View 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

View 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

360
panel/utils/codeHelpers.ts Normal file
View File

@@ -0,0 +1,360 @@
// Prism.js временно отключен для упрощения загрузки
/**
* Определяет язык контента (html, json, javascript, css или plaintext)
*/
export function detectLanguage(content: string): string {
if (!content?.trim()) return ''
try {
JSON.parse(content)
return 'json'
} catch {
// HTML/XML detection
if (/<[^>]*>/g.test(content)) {
return 'html'
}
// CSS detection
if (/\{[^}]*\}/.test(content) && /[#.]\w+|@\w+/.test(content)) {
return 'css'
}
// JavaScript detection
if (/\b(function|const|let|var|class|import|export)\b/.test(content)) {
return 'javascript'
}
}
return ''
}
/**
* Форматирует XML/HTML с отступами используя DOMParser
*/
export function formatXML(xml: string): string {
if (!xml?.trim()) return ''
try {
// Пытаемся распарсить как HTML
const parser = new DOMParser()
let doc: Document
// Оборачиваем в корневой элемент, если это фрагмент
const wrappedXml =
xml.trim().startsWith('<html') || xml.trim().startsWith('<!DOCTYPE') ? xml : `<div>${xml}</div>`
doc = parser.parseFromString(wrappedXml, 'text/html')
// Проверяем на ошибки парсинга
const parserError = doc.querySelector('parsererror')
if (parserError) {
// Если HTML парсинг не удался, пытаемся как XML
doc = parser.parseFromString(wrappedXml, 'application/xml')
const xmlError = doc.querySelector('parsererror')
if (xmlError) {
// Если и XML не удался, возвращаем исходный код
return xml
}
}
// Извлекаем содержимое body или корневого элемента
const body = doc.body || doc.documentElement
const rootElement = xml.trim().startsWith('<div>') ? body.firstChild : body
if (!rootElement) return xml
// Форматируем рекурсивно
return formatNode(rootElement as Element, 0)
} catch (error) {
// В случае ошибки возвращаем исходный код
console.warn('XML formatting failed:', error)
return xml
}
}
/**
* Рекурсивно форматирует узел DOM
*/
function formatNode(node: Node, indentLevel: number): string {
const indentSize = 2
const indent = ' '.repeat(indentLevel * indentSize)
const childIndent = ' '.repeat((indentLevel + 1) * indentSize)
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent?.trim()
return text ? text : ''
}
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element
const tagName = element.tagName.toLowerCase()
const attributes = Array.from(element.attributes)
.map((attr) => `${attr.name}="${attr.value}"`)
.join(' ')
const openTag = attributes ? `<${tagName} ${attributes}>` : `<${tagName}>`
const closeTag = `</${tagName}>`
// Самозакрывающиеся теги
if (isSelfClosingTag(`<${tagName}>`)) {
return `${indent}${openTag.replace('>', ' />')}`
}
// Если нет дочерних элементов
if (element.childNodes.length === 0) {
return `${indent}${openTag}${closeTag}`
}
// Если только один текстовый узел
if (element.childNodes.length === 1 && element.firstChild?.nodeType === Node.TEXT_NODE) {
const text = element.firstChild.textContent?.trim()
if (text && text.length < 80) {
// Короткий текст на одной строке
return `${indent}${openTag}${text}${closeTag}`
}
}
// Многострочный элемент
let result = `${indent}${openTag}\n`
for (const child of Array.from(element.childNodes)) {
const childFormatted = formatNode(child, indentLevel + 1)
if (childFormatted) {
if (child.nodeType === Node.TEXT_NODE) {
result += `${childIndent}${childFormatted}\n`
} else {
result += `${childFormatted}\n`
}
}
}
result += `${indent}${closeTag}`
return result
}
return ''
}
/**
* Проверяет, является ли тег самозакрывающимся
*/
function isSelfClosingTag(line: string): boolean {
const selfClosingTags = [
'br',
'hr',
'img',
'input',
'meta',
'link',
'area',
'base',
'col',
'embed',
'source',
'track',
'wbr'
]
const tagMatch = line.match(/<(\w+)/)
if (tagMatch) {
const tagName = tagMatch[1].toLowerCase()
return selfClosingTags.includes(tagName)
}
return false
}
/**
* Форматирует JSON с отступами
*/
export function formatJSON(json: string): string {
try {
return JSON.stringify(JSON.parse(json), null, 2)
} catch {
return json
}
}
/**
* Форматирует код в зависимости от языка
*/
export function formatCode(content: string, language?: string): string {
if (!content?.trim()) return ''
const lang = language || detectLanguage(content)
switch (lang) {
case 'json':
return formatJSON(content)
case 'markup':
case 'html':
return formatXML(content)
default:
return content
}
}
/**
* Подсвечивает синтаксис кода с использованием простых правил CSS
*/
export function highlightCode(content: string, language?: string): string {
if (!content?.trim()) return ''
const lang = language || detectLanguage(content)
if (lang === 'html' || lang === 'markup') {
return highlightHTML(content)
}
if (lang === 'json') {
return highlightJSON(content)
}
// Для других языков возвращаем исходный код
return escapeHtml(content)
}
/**
* Простая подсветка HTML с использованием CSS классов
*/
function highlightHTML(html: string): string {
let highlighted = escapeHtml(html)
// Подсвечиваем теги
highlighted = highlighted.replace(
/(&lt;\/?)([a-zA-Z][a-zA-Z0-9]*)(.*?)(&gt;)/g,
'$1<span class="html-tag">$2</span><span class="html-attr">$3</span>$4'
)
// Подсвечиваем атрибуты
highlighted = highlighted.replace(
/(\s)([a-zA-Z-]+)(=)(&quot;.*?&quot;)/g,
'$1<span class="html-attr-name">$2</span>$3<span class="html-attr-value">$4</span>'
)
// Подсвечиваем сами теги
highlighted = highlighted.replace(
/(&lt;\/?)([^&]*?)(&gt;)/g,
'<span class="html-bracket">$1</span>$2<span class="html-bracket">$3</span>'
)
return highlighted
}
/**
* Простая подсветка JSON
*/
function highlightJSON(json: string): string {
let highlighted = escapeHtml(json)
// Подсвечиваем строки
highlighted = highlighted.replace(/(&quot;.*?&quot;)(?=\s*:)/g, '<span class="json-key">$1</span>')
highlighted = highlighted.replace(/:\s*(&quot;.*?&quot;)/g, ': <span class="json-string">$1</span>')
// Подсвечиваем числа
highlighted = highlighted.replace(/:\s*(-?\d+\.?\d*)/g, ': <span class="json-number">$1</span>')
// Подсвечиваем boolean и null
highlighted = highlighted.replace(/:\s*(true|false|null)/g, ': <span class="json-boolean">$1</span>')
return highlighted
}
/**
* Экранирует HTML символы
*/
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/**
* Обработчик Tab в редакторе - вставляет отступ вместо смены фокуса
*/
export function handleTabKey(event: KeyboardEvent): boolean {
if (event.key !== 'Tab') return false
event.preventDefault()
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return true
const range = selection.getRangeAt(0)
const indent = event.shiftKey ? '' : ' ' // Shift+Tab для unindent (пока просто не добавляем)
if (!event.shiftKey) {
range.deleteContents()
range.insertNode(document.createTextNode(indent))
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
}
return true
}
/**
* Сохраняет и восстанавливает позицию курсора в contentEditable элементе
*/
export class CaretManager {
private element: HTMLElement
private offset = 0
constructor(element: HTMLElement) {
this.element = element
}
savePosition(): void {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
const preCaretRange = range.cloneRange()
preCaretRange.selectNodeContents(this.element)
preCaretRange.setEnd(range.endContainer, range.endOffset)
this.offset = preCaretRange.toString().length
}
restorePosition(): void {
const selection = window.getSelection()
if (!selection) return
try {
const textNode = this.element.firstChild
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const range = document.createRange()
const safeOffset = Math.min(this.offset, 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)
}
}
}
/**
* Настройки по умолчанию для редактора кода
*/
export const DEFAULT_EDITOR_CONFIG = {
fontSize: 13,
lineHeight: 1.5,
tabSize: 2,
fontFamily:
'"JetBrains Mono", "Fira Code", "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Consolas", monospace',
theme: 'dark',
showLineNumbers: true,
autoFormat: true,
keyBindings: {
save: ['Ctrl+Enter', 'Cmd+Enter'],
cancel: ['Escape'],
tab: ['Tab'],
format: ['Ctrl+Shift+F', 'Cmd+Shift+F']
}
} as const

View File

@@ -1,46 +1,82 @@
import { createMemo } from 'solid-js'
import { useI18n } from '../intl/i18n'
export type Language = 'ru' | 'en'
/**
* Форматирование даты в формате "X дней назад"
* Форматирование даты в формате "X дней назад" с поддержкой многоязычности
* @param timestamp - Временная метка
* @param language - Язык для форматирования ('ru' | 'en')
* @returns Форматированная строка с относительной датой
*/
export function formatDateRelative(timestamp?: number): string {
if (!timestamp) return 'Н'
export function formatDateRelativeStatic(timestamp?: number, language: Language = 'ru'): string {
if (!timestamp) return ''
const now = Math.floor(Date.now() / 1000)
const diff = now - timestamp
// Меньше минуты
if (diff < 60) {
return 'только что'
return language === 'ru' ? 'только что' : 'just now'
}
// Меньше часа
if (diff < 3600) {
const minutes = Math.floor(diff / 60)
return `${minutes} ${getMinutesForm(minutes)} назад`
if (language === 'ru') {
return `${minutes} ${getMinutesForm(minutes)} назад`
} else {
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`
}
}
// Меньше суток
if (diff < 86400) {
const hours = Math.floor(diff / 3600)
return `${hours} ${getHoursForm(hours)} назад`
if (language === 'ru') {
return `${hours} ${getHoursForm(hours)} назад`
} else {
return `${hours} hour${hours !== 1 ? 's' : ''} ago`
}
}
// Меньше 30 дней
if (diff < 2592000) {
const days = Math.floor(diff / 86400)
return `${days} ${getDaysForm(days)} назад`
if (language === 'ru') {
return `${days} ${getDaysForm(days)} назад`
} else {
return `${days} day${days !== 1 ? 's' : ''} ago`
}
}
// Меньше года
if (diff < 31536000) {
const months = Math.floor(diff / 2592000)
return `${months} ${getMonthsForm(months)} назад`
if (language === 'ru') {
return `${months} ${getMonthsForm(months)} назад`
} else {
return `${months} month${months !== 1 ? 's' : ''} ago`
}
}
// Больше года
const years = Math.floor(diff / 31536000)
return `${years} ${getYearsForm(years)} назад`
if (language === 'ru') {
return `${years} ${getYearsForm(years)} назад`
} else {
return `${years} year${years !== 1 ? 's' : ''} ago`
}
}
/**
* Реактивная версия форматирования даты, которая автоматически обновляется при смене языка
* @param timestamp - Временная метка
* @returns Реактивный сигнал с форматированной строкой
*/
export function formatDateRelative(timestamp?: number) {
const { language } = useI18n()
return createMemo(() => formatDateRelativeStatic(timestamp, language()))
}
/**