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