0.5.8-panel-upgrade-community-crud-fix
All checks were successful
Deploy on push / deploy (push) Successful in 6s
All checks were successful
Deploy on push / deploy (push) Successful in 6s
This commit is contained in:
170
panel/App.tsx
170
panel/App.tsx
@@ -1,105 +1,89 @@
|
||||
import { Component, Show, Suspense, createSignal, lazy, onMount, createEffect } from 'solid-js'
|
||||
import { isAuthenticated, getAuthTokenFromCookie } from './auth'
|
||||
import { Route, Router } from '@solidjs/router'
|
||||
import { lazy, onMount, Suspense } from 'solid-js'
|
||||
import { AuthProvider, useAuth } from './context/auth'
|
||||
|
||||
// Ленивая загрузка компонентов
|
||||
const AdminPage = lazy(() => import('./admin'))
|
||||
const LoginPage = lazy(() => import('./login'))
|
||||
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 App: Component = () => {
|
||||
const [authenticated, setAuthenticated] = createSignal<boolean | null>(null)
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [checkingAuth, setCheckingAuth] = createSignal(true)
|
||||
const ProtectedRoute = () => {
|
||||
console.log('[ProtectedRoute] Checking authentication...')
|
||||
const auth = useAuth()
|
||||
const authenticated = auth.isAuthenticated()
|
||||
console.log(
|
||||
`[ProtectedRoute] Authentication state: ${authenticated ? 'authenticated' : 'not authenticated'}`
|
||||
)
|
||||
|
||||
// Проверяем авторизацию при монтировании
|
||||
onMount(() => {
|
||||
checkAuthentication()
|
||||
})
|
||||
|
||||
// Периодическая проверка авторизации
|
||||
createEffect(() => {
|
||||
const authCheckInterval = setInterval(() => {
|
||||
// Перепроверяем статус авторизации каждые 60 секунд
|
||||
if (!checkingAuth()) {
|
||||
const authed = isAuthenticated()
|
||||
if (!authed && authenticated()) {
|
||||
console.log('Сессия истекла, требуется повторная авторизация')
|
||||
setAuthenticated(false)
|
||||
}
|
||||
}
|
||||
}, 60000)
|
||||
|
||||
return () => clearInterval(authCheckInterval)
|
||||
})
|
||||
|
||||
// Функция проверки авторизации
|
||||
const checkAuthentication = async () => {
|
||||
setCheckingAuth(true)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// Проверяем состояние авторизации
|
||||
const authed = isAuthenticated()
|
||||
|
||||
// Если токен есть, но он невалидный, авторизация не удалась
|
||||
if (authed) {
|
||||
const token = getAuthTokenFromCookie() || localStorage.getItem('auth_token')
|
||||
if (!token || token.length < 10) {
|
||||
setAuthenticated(false)
|
||||
} else {
|
||||
setAuthenticated(true)
|
||||
}
|
||||
} else {
|
||||
setAuthenticated(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при проверке авторизации:', error)
|
||||
setAuthenticated(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setCheckingAuth(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Обработчик успешной авторизации
|
||||
const handleLoginSuccess = () => {
|
||||
setAuthenticated(true)
|
||||
}
|
||||
|
||||
// Обработчик выхода из системы
|
||||
const handleLogout = () => {
|
||||
setAuthenticated(false)
|
||||
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 (
|
||||
<div class="app-container">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner" />
|
||||
<h2>Загрузка компонентов...</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={!loading()}
|
||||
fallback={
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner" />
|
||||
<h2>Проверка авторизации...</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{authenticated() ? (
|
||||
<AdminPage apiUrl={`${location.origin}/graphql`} onLogout={handleLogout} />
|
||||
) : (
|
||||
<LoginPage onLoginSuccess={handleLoginSuccess} />
|
||||
)}
|
||||
</Show>
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner" />
|
||||
<div>Загрузка админ-панели...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AdminPage apiUrl={`${location.origin}/graphql`} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Корневой компонент приложения
|
||||
*/
|
||||
const App = () => {
|
||||
console.log('[App] Initializing root component...')
|
||||
|
||||
onMount(() => {
|
||||
console.log('[App] Root component mounted')
|
||||
})
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
|
1734
panel/admin.tsx
1734
panel/admin.tsx
File diff suppressed because it is too large
Load Diff
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
177
panel/auth.ts
177
panel/auth.ts
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* Модуль авторизации
|
||||
* @module auth
|
||||
*/
|
||||
|
||||
// Экспортируем константы для использования в других модулях
|
||||
export const AUTH_TOKEN_KEY = 'auth_token'
|
||||
export const CSRF_TOKEN_KEY = 'csrf_token'
|
||||
|
||||
/**
|
||||
* Интерфейс для учетных данных
|
||||
*/
|
||||
export interface Credentials {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для результата авторизации
|
||||
*/
|
||||
export interface LoginResult {
|
||||
success: boolean
|
||||
token?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для ответа API при логине
|
||||
*/
|
||||
interface LoginResponse {
|
||||
login: LoginResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает токен авторизации из cookie
|
||||
* @returns Токен или пустую строку, если токен не найден
|
||||
*/
|
||||
export function getAuthTokenFromCookie(): string {
|
||||
const cookieItems = document.cookie.split(';')
|
||||
for (const item of cookieItems) {
|
||||
const [name, value] = item.trim().split('=')
|
||||
if (name === AUTH_TOKEN_KEY) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает CSRF-токен из cookie
|
||||
* @returns CSRF-токен или пустую строку, если токен не найден
|
||||
*/
|
||||
export function getCsrfTokenFromCookie(): string {
|
||||
const cookieItems = document.cookie.split(';')
|
||||
for (const item of cookieItems) {
|
||||
const [name, value] = item.trim().split('=')
|
||||
if (name === CSRF_TOKEN_KEY) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, авторизован ли пользователь
|
||||
* @returns Статус авторизации
|
||||
*/
|
||||
export function isAuthenticated(): boolean {
|
||||
// Проверяем наличие cookie auth_token
|
||||
const cookieToken = getAuthTokenFromCookie()
|
||||
const hasCookie = !!cookieToken && cookieToken.length > 10
|
||||
|
||||
// Проверяем наличие токена в localStorage
|
||||
const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
|
||||
const hasLocalToken = !!localToken && localToken.length > 10
|
||||
|
||||
// Пользователь авторизован, если есть cookie или токен в localStorage
|
||||
return hasCookie || hasLocalToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет выход из системы
|
||||
* @param callback - Функция обратного вызова после выхода
|
||||
*/
|
||||
export function logout(callback?: () => void): void {
|
||||
// Очищаем токен из localStorage
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||
|
||||
// Для удаления cookie устанавливаем ей истекшее время жизни
|
||||
document.cookie = `${AUTH_TOKEN_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
|
||||
|
||||
// Дополнительно пытаемся сделать запрос на сервер для удаления серверных сессий
|
||||
try {
|
||||
fetch('/auth/logout', {
|
||||
method: 'POST', // Используем POST вместо GET для операций изменения состояния
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCsrfTokenFromCookie() // Добавляем CSRF токен если он есть
|
||||
}
|
||||
}).catch((e) => {
|
||||
console.error('Ошибка при запросе на выход:', e)
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Ошибка при выходе:', e)
|
||||
}
|
||||
|
||||
// Вызываем функцию обратного вызова после очистки токенов
|
||||
if (callback) callback()
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет вход в систему используя GraphQL-запрос
|
||||
* @param credentials - Учетные данные
|
||||
* @returns Результат авторизации
|
||||
*/
|
||||
export async function login(credentials: Credentials): Promise<boolean> {
|
||||
try {
|
||||
console.log('Отправка запроса авторизации через GraphQL')
|
||||
|
||||
const response = await fetch(`${location.origin}/graphql`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-Token': getCsrfTokenFromCookie() // Добавляем CSRF токен если он есть
|
||||
},
|
||||
credentials: 'include', // Важно для обработки cookies
|
||||
body: JSON.stringify({
|
||||
query: `
|
||||
mutation Login($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
success
|
||||
token
|
||||
error
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
email: credentials.email,
|
||||
password: credentials.password
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Ошибка HTTP:', response.status, errorText)
|
||||
throw new Error(`HTTP error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('Результат авторизации:', result)
|
||||
|
||||
if (result?.data?.login?.success) {
|
||||
// Проверяем, установил ли сервер cookie
|
||||
const cookieToken = getAuthTokenFromCookie()
|
||||
const hasCookie = !!cookieToken && cookieToken.length > 10
|
||||
|
||||
// Если cookie не установлена, но есть токен в ответе, сохраняем его в localStorage
|
||||
if (!hasCookie && result.data.login.token) {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, result.data.login.token)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
throw new Error(result.errors[0].message || 'Ошибка авторизации')
|
||||
}
|
||||
|
||||
throw new Error(result?.data?.login?.error || 'Неизвестная ошибка авторизации')
|
||||
} catch (error) {
|
||||
console.error('Ошибка при входе:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
150
panel/context/auth.tsx
Normal file
150
panel/context/auth.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Component, createContext, createSignal, JSX, useContext } from 'solid-js'
|
||||
import { query } from '../graphql'
|
||||
import { ADMIN_LOGIN_MUTATION, ADMIN_LOGOUT_MUTATION } from '../graphql/mutations'
|
||||
import {
|
||||
AUTH_TOKEN_KEY,
|
||||
CSRF_TOKEN_KEY,
|
||||
checkAuthStatus,
|
||||
clearAuthTokens,
|
||||
getAuthTokenFromCookie,
|
||||
getCsrfTokenFromCookie,
|
||||
saveAuthToken
|
||||
} from '../utils/auth'
|
||||
/**
|
||||
* Модуль авторизации
|
||||
* @module auth
|
||||
*/
|
||||
|
||||
/**
|
||||
* Интерфейс для учетных данных
|
||||
*/
|
||||
export interface Credentials {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для результата авторизации
|
||||
*/
|
||||
export interface LoginResult {
|
||||
success: boolean
|
||||
token?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Экспортируем утилитарные функции для обратной совместимости
|
||||
export {
|
||||
AUTH_TOKEN_KEY,
|
||||
CSRF_TOKEN_KEY,
|
||||
getAuthTokenFromCookie,
|
||||
getCsrfTokenFromCookie,
|
||||
checkAuthStatus,
|
||||
clearAuthTokens,
|
||||
saveAuthToken
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: () => boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
isAuthenticated: () => false,
|
||||
login: async () => {},
|
||||
logout: async () => {}
|
||||
})
|
||||
|
||||
export const useAuth = () => useContext(AuthContext)
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export const AuthProvider: Component<AuthProviderProps> = (props) => {
|
||||
console.log('[AuthProvider] Initializing...')
|
||||
const [isAuthenticated, setIsAuthenticated] = createSignal(checkAuthStatus())
|
||||
console.log(
|
||||
`[AuthProvider] Initial auth state: ${isAuthenticated() ? 'authenticated' : 'not authenticated'}`
|
||||
)
|
||||
|
||||
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 }
|
||||
)
|
||||
|
||||
if (result?.login?.success) {
|
||||
console.log('[AuthProvider] Login successful')
|
||||
if (result.login.token) {
|
||||
saveAuthToken(result.login.token)
|
||||
}
|
||||
setIsAuthenticated(true)
|
||||
// Убираем window.location.href - пусть роутер сам обрабатывает навигацию
|
||||
} else {
|
||||
console.error('[AuthProvider] Login failed')
|
||||
throw new Error('Неверные учетные данные')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthProvider] Login error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
console.log('[AuthProvider] Attempting logout...')
|
||||
try {
|
||||
const result = await query<{ logout: { success: boolean } }>(
|
||||
`${location.origin}/graphql`,
|
||||
ADMIN_LOGOUT_MUTATION
|
||||
)
|
||||
|
||||
if (result?.logout?.success) {
|
||||
console.log('[AuthProvider] Logout successful')
|
||||
clearAuthTokens()
|
||||
setIsAuthenticated(false)
|
||||
window.location.href = '/login'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthProvider] Logout error:', error)
|
||||
// Даже при ошибке очищаем токены и редиректим
|
||||
clearAuthTokens()
|
||||
setIsAuthenticated(false)
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
const value: AuthContextType = {
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout
|
||||
}
|
||||
|
||||
console.log('[AuthProvider] Rendering provider with context')
|
||||
return <AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
// Export the logout function for direct use
|
||||
export const logout = async () => {
|
||||
console.log('[Auth] Executing standalone logout...')
|
||||
try {
|
||||
const result = await query<{ logout: { success: boolean } }>(
|
||||
`${location.origin}/graphql`,
|
||||
ADMIN_LOGOUT_MUTATION
|
||||
)
|
||||
console.log('[Auth] Standalone logout result:', result)
|
||||
if (result?.logout?.success) {
|
||||
clearAuthTokens()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('[Auth] Standalone logout error:', error)
|
||||
// Даже при ошибке очищаем токены
|
||||
clearAuthTokens()
|
||||
throw error
|
||||
}
|
||||
}
|
208
panel/graphql.ts
208
panel/graphql.ts
@@ -1,208 +0,0 @@
|
||||
/**
|
||||
* API-клиент для работы с GraphQL
|
||||
* @module api
|
||||
*/
|
||||
|
||||
import { AUTH_TOKEN_KEY, CSRF_TOKEN_KEY, getAuthTokenFromCookie, getCsrfTokenFromCookie } from './auth'
|
||||
|
||||
/**
|
||||
* Тип для произвольных данных GraphQL
|
||||
*/
|
||||
type GraphQLData = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Обрабатывает ошибки от API
|
||||
* @param response - Ответ от сервера
|
||||
* @returns Обработанный текст ошибки
|
||||
*/
|
||||
async function handleApiError(response: Response): Promise<string> {
|
||||
try {
|
||||
const contentType = response.headers.get('content-type')
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
const errorData = await response.json()
|
||||
|
||||
// Проверяем GraphQL ошибки
|
||||
if (errorData.errors && errorData.errors.length > 0) {
|
||||
return errorData.errors[0].message
|
||||
}
|
||||
|
||||
// Проверяем сообщение об ошибке
|
||||
if (errorData.error || errorData.message) {
|
||||
return errorData.error || errorData.message
|
||||
}
|
||||
}
|
||||
|
||||
// Если не JSON или нет структурированной ошибки, читаем как текст
|
||||
const errorText = await response.text()
|
||||
return `Ошибка сервера: ${response.status} ${response.statusText}. ${errorText.substring(0, 100)}...`
|
||||
} catch (_e) {
|
||||
// Если не можем прочитать ответ
|
||||
return `Ошибка сервера: ${response.status} ${response.statusText}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет наличие ошибок авторизации в ответе GraphQL
|
||||
* @param errors - Массив ошибок GraphQL
|
||||
* @returns true если есть ошибки авторизации
|
||||
*/
|
||||
function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: string } }>): boolean {
|
||||
return errors.some(
|
||||
(error) =>
|
||||
(error.message &&
|
||||
(error.message.toLowerCase().includes('unauthorized') ||
|
||||
error.message.toLowerCase().includes('авторизации') ||
|
||||
error.message.toLowerCase().includes('authentication') ||
|
||||
error.message.toLowerCase().includes('unauthenticated') ||
|
||||
error.message.toLowerCase().includes('token'))) ||
|
||||
error.extensions?.code === 'UNAUTHENTICATED' ||
|
||||
error.extensions?.code === 'FORBIDDEN'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготавливает URL для GraphQL запроса
|
||||
* @param url - URL или путь для запроса
|
||||
* @returns Полный URL для запроса
|
||||
*/
|
||||
function prepareUrl(url: string): string {
|
||||
// В режиме локальной разработки всегда используем /graphql
|
||||
if (location.hostname === 'localhost') {
|
||||
return `${location.origin}/graphql`
|
||||
}
|
||||
|
||||
// Если это относительный путь, добавляем к нему origin
|
||||
if (url.startsWith('/')) {
|
||||
return `${location.origin}${url}`
|
||||
}
|
||||
// Если это уже полный URL, используем как есть
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает заголовки для GraphQL запроса с учетом авторизации и CSRF
|
||||
* @returns Объект с заголовками
|
||||
*/
|
||||
function getRequestHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
// Проверяем наличие токена в localStorage
|
||||
const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
|
||||
|
||||
// Проверяем наличие токена в cookie
|
||||
const cookieToken = getAuthTokenFromCookie()
|
||||
|
||||
// Используем токен из localStorage или cookie
|
||||
const token = localToken || cookieToken
|
||||
|
||||
// Если есть токен, добавляем его в заголовок Authorization с префиксом Bearer
|
||||
if (token && token.length > 10) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
console.debug('Отправка запроса с токеном авторизации')
|
||||
}
|
||||
|
||||
// Добавляем CSRF-токен, если он есть
|
||||
const csrfToken = getCsrfTokenFromCookie()
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken
|
||||
console.debug('Добавлен CSRF-токен в запрос')
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет GraphQL запрос
|
||||
* @param url - URL для запроса
|
||||
* @param query - GraphQL запрос
|
||||
* @param variables - Переменные запроса
|
||||
* @returns Результат запроса
|
||||
*/
|
||||
export async function query<T = GraphQLData>(
|
||||
url: string,
|
||||
query: string,
|
||||
variables: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
try {
|
||||
// Получаем все необходимые заголовки для запроса
|
||||
const headers = getRequestHeaders()
|
||||
|
||||
// Подготавливаем полный URL
|
||||
const fullUrl = prepareUrl(url)
|
||||
console.debug('Отправка GraphQL запроса на:', fullUrl)
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
// Важно: credentials: 'include' - для передачи cookies с запросом
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables
|
||||
})
|
||||
})
|
||||
|
||||
// Проверяем статус ответа
|
||||
if (!response.ok) {
|
||||
const errorMessage = await handleApiError(response)
|
||||
console.error('Ошибка API:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorMessage
|
||||
})
|
||||
|
||||
// Если получен 401 Unauthorized или 403 Forbidden, перенаправляем на страницу входа
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||
window.location.href = '/'
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
// Проверяем, что ответ содержит JSON
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (!contentType?.includes('application/json')) {
|
||||
const text = await response.text()
|
||||
throw new Error(`Неверный формат ответа: ${text.substring(0, 100)}...`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.errors) {
|
||||
// Проверяем ошибки на признаки проблем с авторизацией
|
||||
if (hasAuthErrors(result.errors)) {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||
window.location.href = '/'
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
throw new Error(result.errors[0].message)
|
||||
}
|
||||
|
||||
return result.data as T
|
||||
} catch (error) {
|
||||
console.error('API Error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет GraphQL мутацию
|
||||
* @param url - URL для запроса
|
||||
* @param mutation - GraphQL мутация
|
||||
* @param variables - Переменные мутации
|
||||
* @returns Результат мутации
|
||||
*/
|
||||
export function mutate<T = GraphQLData>(
|
||||
url: string,
|
||||
mutation: string,
|
||||
variables: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
return query<T>(url, mutation, variables)
|
||||
}
|
139
panel/graphql/index.ts
Normal file
139
panel/graphql/index.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* API-клиент для работы с GraphQL
|
||||
* @module api
|
||||
*/
|
||||
|
||||
import {
|
||||
AUTH_TOKEN_KEY,
|
||||
clearAuthTokens,
|
||||
getAuthTokenFromCookie,
|
||||
getCsrfTokenFromCookie
|
||||
} from '../utils/auth'
|
||||
|
||||
/**
|
||||
* Тип для произвольных данных GraphQL
|
||||
*/
|
||||
type GraphQLData = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Возвращает заголовки для GraphQL запроса с учетом авторизации и CSRF
|
||||
* @returns Объект с заголовками
|
||||
*/
|
||||
function getRequestHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
}
|
||||
|
||||
// Проверяем наличие токена в localStorage
|
||||
const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
|
||||
|
||||
// Проверяем наличие токена в cookie
|
||||
const cookieToken = getAuthTokenFromCookie()
|
||||
|
||||
// Используем токен из localStorage или cookie
|
||||
const token = localToken || cookieToken
|
||||
|
||||
// Если есть токен, добавляем его в заголовок Authorization с префиксом Bearer
|
||||
if (token && token.length > 10) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
console.debug('Отправка запроса с токеном авторизации')
|
||||
}
|
||||
|
||||
// Добавляем CSRF-токен, если он есть
|
||||
const csrfToken = getCsrfTokenFromCookie()
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken
|
||||
console.debug('Добавлен CSRF-токен в запрос')
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет GraphQL запрос
|
||||
* @param endpoint - URL эндпоинта GraphQL
|
||||
* @param query - GraphQL запрос
|
||||
* @param variables - Переменные запроса
|
||||
* @returns Результат запроса
|
||||
*/
|
||||
export async function query<T = unknown>(
|
||||
endpoint: string,
|
||||
query: string,
|
||||
variables?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
try {
|
||||
console.log(`[GraphQL] Making request to ${endpoint}`)
|
||||
console.log(`[GraphQL] Query: ${query.substring(0, 100)}...`)
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables
|
||||
})
|
||||
})
|
||||
|
||||
console.log(`[GraphQL] Response status: ${response.status}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
console.log('[GraphQL] Unauthorized response, clearing auth tokens')
|
||||
clearAuthTokens()
|
||||
// Перенаправляем на страницу входа только если мы не на ней
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
const errorText = await response.text()
|
||||
throw new Error(`HTTP error: ${response.status} ${errorText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('[GraphQL] Response received:', result)
|
||||
|
||||
if (result.errors) {
|
||||
// Проверяем ошибки авторизации
|
||||
const hasUnauthorized = result.errors.some(
|
||||
(error: { message?: string }) =>
|
||||
error.message?.toLowerCase().includes('unauthorized') ||
|
||||
error.message?.toLowerCase().includes('please login')
|
||||
)
|
||||
|
||||
if (hasUnauthorized) {
|
||||
console.log('[GraphQL] Unauthorized error in response, clearing auth tokens')
|
||||
clearAuthTokens()
|
||||
// Перенаправляем на страницу входа только если мы не на ней
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
// Handle GraphQL errors
|
||||
const errorMessage = result.errors.map((e: { message?: string }) => e.message).join(', ')
|
||||
throw new Error(`GraphQL error: ${errorMessage}`)
|
||||
}
|
||||
|
||||
return result.data
|
||||
} catch (error) {
|
||||
console.error('[GraphQL] Query error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет GraphQL мутацию
|
||||
* @param url - URL для запроса
|
||||
* @param mutation - GraphQL мутация
|
||||
* @param variables - Переменные мутации
|
||||
* @returns Результат мутации
|
||||
*/
|
||||
export function mutate<T = GraphQLData>(
|
||||
url: string,
|
||||
mutation: string,
|
||||
variables: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
return query<T>(url, mutation, variables)
|
||||
}
|
63
panel/graphql/mutations.ts
Normal file
63
panel/graphql/mutations.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export const ADMIN_LOGIN_MUTATION = `
|
||||
mutation AdminLogin($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
success
|
||||
token
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_LOGOUT_MUTATION = `
|
||||
mutation AdminLogout {
|
||||
logout {
|
||||
success
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_UPDATE_USER_MUTATION = `
|
||||
mutation AdminUpdateUser($user: AdminUserUpdateInput!) {
|
||||
adminUpdateUser(user: $user) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_UPDATE_ENV_VARIABLE_MUTATION = `
|
||||
mutation AdminUpdateEnvVariable($key: String!, $value: String!) {
|
||||
updateEnvVariable(key: $key, value: $value)
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_TOPIC_MUTATION = `
|
||||
mutation UpdateTopic($topic_input: TopicInput!) {
|
||||
update_topic(topic_input: $topic_input) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const DELETE_TOPIC_MUTATION = `
|
||||
mutation DeleteTopic($id: Int!) {
|
||||
delete_topic_by_id(id: $id) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_COMMUNITY_MUTATION = `
|
||||
mutation UpdateCommunity($community_input: CommunityInput!) {
|
||||
update_community(community_input: $community_input) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const DELETE_COMMUNITY_MUTATION = `
|
||||
mutation DeleteCommunity($slug: String!) {
|
||||
delete_community(slug: $slug) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
156
panel/graphql/queries.ts
Normal file
156
panel/graphql/queries.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
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) {
|
||||
shouts {
|
||||
id
|
||||
title
|
||||
slug
|
||||
body
|
||||
lead
|
||||
subtitle
|
||||
layout
|
||||
lang
|
||||
cover
|
||||
cover_caption
|
||||
media {
|
||||
url
|
||||
title
|
||||
body
|
||||
source
|
||||
pic
|
||||
date
|
||||
genre
|
||||
artist
|
||||
lyrics
|
||||
}
|
||||
seo
|
||||
created_at
|
||||
updated_at
|
||||
published_at
|
||||
featured_at
|
||||
deleted_at
|
||||
created_by {
|
||||
id
|
||||
email
|
||||
name
|
||||
}
|
||||
authors {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
topics {
|
||||
id
|
||||
title
|
||||
slug
|
||||
}
|
||||
stat {
|
||||
rating
|
||||
comments_count
|
||||
viewed
|
||||
}
|
||||
}
|
||||
total
|
||||
page
|
||||
perPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const ADMIN_GET_USERS_QUERY: string =
|
||||
gql`
|
||||
query AdminGetUsers($limit: Int, $offset: Int, $search: String) {
|
||||
adminGetUsers(limit: $limit, offset: $offset, search: $search) {
|
||||
authors {
|
||||
id
|
||||
email
|
||||
name
|
||||
slug
|
||||
roles
|
||||
created_at
|
||||
last_seen
|
||||
}
|
||||
total
|
||||
page
|
||||
perPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const ADMIN_GET_ROLES_QUERY: string =
|
||||
gql`
|
||||
query AdminGetRoles {
|
||||
adminGetRoles {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const ADMIN_GET_ENV_VARIABLES_QUERY: string =
|
||||
gql`
|
||||
query GetEnvVariables {
|
||||
getEnvVariables {
|
||||
name
|
||||
description
|
||||
variables {
|
||||
key
|
||||
value
|
||||
description
|
||||
type
|
||||
isSecret
|
||||
}
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const GET_COMMUNITIES_QUERY: string =
|
||||
gql`
|
||||
query GetCommunities {
|
||||
get_communities_all {
|
||||
id
|
||||
slug
|
||||
name
|
||||
desc
|
||||
pic
|
||||
created_at
|
||||
created_by {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
stat {
|
||||
shouts
|
||||
followers
|
||||
authors
|
||||
}
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const GET_TOPICS_QUERY: string =
|
||||
gql`
|
||||
query GetTopics {
|
||||
get_topics_all {
|
||||
id
|
||||
slug
|
||||
title
|
||||
body
|
||||
pic
|
||||
community
|
||||
parent_ids
|
||||
stat {
|
||||
shouts
|
||||
authors
|
||||
followers
|
||||
}
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
121
panel/login.tsx
121
panel/login.tsx
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Компонент страницы входа
|
||||
* @module LoginPage
|
||||
*/
|
||||
|
||||
import { Component, createSignal } from 'solid-js'
|
||||
import { login } from './auth'
|
||||
import logo from './publy.svg'
|
||||
|
||||
interface LoginPageProps {
|
||||
onLoginSuccess?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент страницы входа
|
||||
*/
|
||||
const LoginPage: Component<LoginPageProps> = (props) => {
|
||||
const [email, setEmail] = createSignal('')
|
||||
const [password, setPassword] = createSignal('')
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [formSubmitting, setFormSubmitting] = createSignal(false)
|
||||
|
||||
/**
|
||||
* Обработчик отправки формы входа
|
||||
* @param e - Событие отправки формы
|
||||
*/
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Предотвращаем повторную отправку формы
|
||||
if (formSubmitting()) return
|
||||
|
||||
// Очищаем пробелы в email
|
||||
const cleanEmail = email().trim()
|
||||
|
||||
if (!cleanEmail || !password()) {
|
||||
setError('Пожалуйста, заполните все поля')
|
||||
return
|
||||
}
|
||||
|
||||
setFormSubmitting(true)
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Используем функцию login из модуля auth
|
||||
const loginSuccessful = await login({
|
||||
email: cleanEmail,
|
||||
password: password()
|
||||
})
|
||||
|
||||
if (loginSuccessful) {
|
||||
// Вызываем коллбэк для оповещения родителя об успешном входе
|
||||
if (props.onLoginSuccess) {
|
||||
props.onLoginSuccess()
|
||||
}
|
||||
} else {
|
||||
throw new Error('Вход не выполнен')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при входе:', err)
|
||||
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
|
||||
setIsLoading(false)
|
||||
} finally {
|
||||
setFormSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="login-page">
|
||||
<div class="login-container">
|
||||
<img src={logo} alt="Logo" />
|
||||
<div class="error-message" style={{ opacity: error() ? 1 : 0 }}>{error()}</div>
|
||||
|
||||
<form onSubmit={handleSubmit} method="post">
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="Email"
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
disabled={isLoading()}
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Пароль"
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
disabled={isLoading()}
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading() || formSubmitting()}>
|
||||
{isLoading() ? (
|
||||
<>
|
||||
<span class="spinner"></span>
|
||||
Вход...
|
||||
</>
|
||||
) : (
|
||||
'Войти'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
188
panel/modals/EnvVariableModal.tsx
Normal file
188
panel/modals/EnvVariableModal.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Component, createMemo, createSignal, Show } from 'solid-js'
|
||||
import { query } from '../graphql'
|
||||
import { EnvVariable } from '../graphql/generated/schema'
|
||||
import { ADMIN_UPDATE_ENV_VARIABLE_MUTATION } from '../graphql/mutations'
|
||||
import formStyles from '../styles/Form.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
import TextPreview from '../ui/TextPreview'
|
||||
|
||||
interface EnvVariableModalProps {
|
||||
isOpen: boolean
|
||||
variable: EnvVariable
|
||||
onClose: () => void
|
||||
onSave: () => void
|
||||
onValueChange?: (value: string) => void // FIXME: no need
|
||||
}
|
||||
|
||||
const EnvVariableModal: Component<EnvVariableModalProps> = (props) => {
|
||||
const [value, setValue] = createSignal(props.variable.value)
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [showFormatted, setShowFormatted] = createSignal(false)
|
||||
|
||||
// Определяем нужно ли использовать textarea
|
||||
const needsTextarea = createMemo(() => {
|
||||
const val = value()
|
||||
return (
|
||||
val.length > 50 ||
|
||||
val.includes('\n') ||
|
||||
props.variable.type === 'json' ||
|
||||
props.variable.key.includes('URL') ||
|
||||
props.variable.key.includes('SECRET')
|
||||
)
|
||||
})
|
||||
|
||||
// Форматируем JSON если возможно
|
||||
const formattedValue = createMemo(() => {
|
||||
if (props.variable.type === 'json' || (value().startsWith('{') && value().endsWith('}'))) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value()), null, 2)
|
||||
} catch {
|
||||
return value()
|
||||
}
|
||||
}
|
||||
return value()
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await query<{ updateEnvVariable: boolean }>(
|
||||
`${location.origin}/graphql`,
|
||||
ADMIN_UPDATE_ENV_VARIABLE_MUTATION,
|
||||
{
|
||||
key: props.variable.key,
|
||||
value: value()
|
||||
}
|
||||
)
|
||||
|
||||
if (result?.updateEnvVariable) {
|
||||
props.onSave()
|
||||
} else {
|
||||
setError('Failed to update environment variable')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatValue = () => {
|
||||
if (props.variable.type === 'json') {
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(value()), null, 2)
|
||||
setValue(formatted)
|
||||
} catch (_e) {
|
||||
setError('Invalid JSON format')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={props.isOpen}
|
||||
title={`Редактировать ${props.variable.key}`}
|
||||
onClose={props.onClose}
|
||||
size="large"
|
||||
>
|
||||
<div class={formStyles['modal-wide']}>
|
||||
<form class={formStyles.form} onSubmit={(e) => e.preventDefault()}>
|
||||
<div class={formStyles['form-group']}>
|
||||
<label class={formStyles['form-label']}>Ключ:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.variable.key}
|
||||
disabled
|
||||
class={formStyles['form-input-disabled']}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={formStyles['form-group']}>
|
||||
<label class={formStyles['form-label']}>
|
||||
Значение:
|
||||
<span class={formStyles['form-label-info']}>
|
||||
{props.variable.type} {props.variable.isSecret && '(секретное)'}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Show when={needsTextarea()}>
|
||||
<div class={formStyles['textarea-container']}>
|
||||
<textarea
|
||||
value={value()}
|
||||
onInput={(e) => setValue(e.currentTarget.value)}
|
||||
class={formStyles['form-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']}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={formatValue}
|
||||
title="Форматировать JSON"
|
||||
>
|
||||
🎨 Форматировать
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => setShowFormatted(!showFormatted())}
|
||||
title={showFormatted() ? 'Скрыть превью' : 'Показать превью'}
|
||||
>
|
||||
{showFormatted() ? '👁️ Скрыть' : '👁️ Превью'}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!needsTextarea()}>
|
||||
<input
|
||||
type={props.variable.isSecret ? 'password' : 'text'}
|
||||
value={value()}
|
||||
onInput={(e) => setValue(e.currentTarget.value)}
|
||||
class={formStyles['form-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']}>
|
||||
<TextPreview content={formattedValue()} />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.variable.description}>
|
||||
<div class={formStyles['form-help']}>
|
||||
<strong>Описание:</strong> {props.variable.description}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class={formStyles['form-error']}>{error()}</div>
|
||||
</Show>
|
||||
|
||||
<div class={formStyles['form-actions']}>
|
||||
<Button variant="secondary" onClick={props.onClose} disabled={saving()}>
|
||||
Отменить
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} loading={saving()}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default EnvVariableModal
|
272
panel/modals/RolesModal.tsx
Normal file
272
panel/modals/RolesModal.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Component, createEffect, createSignal, For } from 'solid-js'
|
||||
import type { AdminUserInfo } from '../graphql/generated/schema'
|
||||
import styles from '../styles/Form.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
|
||||
export interface UserEditModalProps {
|
||||
user: AdminUserInfo
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (userData: {
|
||||
id: number
|
||||
email?: string
|
||||
name?: string
|
||||
slug?: string
|
||||
roles: string[]
|
||||
}) => Promise<void>
|
||||
}
|
||||
|
||||
const AVAILABLE_ROLES = [
|
||||
{ id: 'admin', name: 'Администратор', description: 'Полный доступ к системе' },
|
||||
{ id: 'editor', name: 'Редактор', description: 'Редактирование публикаций и управление сообществом' },
|
||||
{
|
||||
id: 'expert',
|
||||
name: 'Эксперт',
|
||||
description: 'Добавление доказательств и опровержений, управление темами'
|
||||
},
|
||||
{ id: 'author', name: 'Автор', description: 'Создание и редактирование своих публикаций' },
|
||||
{ id: 'reader', name: 'Читатель', description: 'Чтение и комментирование' }
|
||||
]
|
||||
|
||||
const UserEditModal: Component<UserEditModalProps> = (props) => {
|
||||
const [formData, setFormData] = createSignal({
|
||||
email: props.user.email || '',
|
||||
name: props.user.name || '',
|
||||
slug: props.user.slug || '',
|
||||
roles: props.user.roles || []
|
||||
})
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [errors, setErrors] = createSignal<Record<string, string>>({})
|
||||
|
||||
// Сброс формы при открытии модалки
|
||||
createEffect(() => {
|
||||
if (props.isOpen) {
|
||||
setFormData({
|
||||
email: props.user.email || '',
|
||||
name: props.user.name || '',
|
||||
slug: props.user.slug || '',
|
||||
roles: props.user.roles || []
|
||||
})
|
||||
setErrors({})
|
||||
}
|
||||
})
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
const data = formData()
|
||||
|
||||
// Валидация email
|
||||
if (!data.email.trim()) {
|
||||
newErrors.email = 'Email обязателен'
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||
newErrors.email = 'Некорректный формат email'
|
||||
}
|
||||
|
||||
// Валидация имени
|
||||
if (!data.name.trim()) {
|
||||
newErrors.name = 'Имя обязательно'
|
||||
}
|
||||
|
||||
// Валидация slug
|
||||
if (!data.slug.trim()) {
|
||||
newErrors.slug = 'Slug обязателен'
|
||||
} else if (!/^[a-z0-9-_]+$/.test(data.slug)) {
|
||||
newErrors.slug = 'Slug может содержать только латинские буквы, цифры, дефисы и подчеркивания'
|
||||
}
|
||||
|
||||
// Валидация ролей
|
||||
if (data.roles.length === 0) {
|
||||
newErrors.roles = 'Выберите хотя бы одну роль'
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await props.onSave({
|
||||
id: props.user.id,
|
||||
email: formData().email,
|
||||
name: formData().name,
|
||||
slug: formData().slug,
|
||||
roles: formData().roles
|
||||
})
|
||||
props.onClose()
|
||||
} catch (error) {
|
||||
console.error('Error saving user:', 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"
|
||||
>
|
||||
<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>
|
||||
<strong>ID:</strong> {props.user.id}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Дата регистрации:</strong> {formatDate(props.user.created_at)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Последняя активность:</strong> {formatDate(props.user.last_seen)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основные данные */}
|
||||
<div class={styles.section}>
|
||||
<h4 style={{ margin: '0 0 15px 0', color: '#495057' }}>Основные данные</h4>
|
||||
|
||||
<div class={styles.field}>
|
||||
<label for="email" class={styles.label}>
|
||||
Email <span style={{ color: 'red' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
class={`${styles.input} ${errors().email ? styles.inputError : ''}`}
|
||||
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>}
|
||||
</div>
|
||||
|
||||
<div class={styles.field}>
|
||||
<label for="name" class={styles.label}>
|
||||
Имя <span style={{ color: 'red' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
class={`${styles.input} ${errors().name ? styles.inputError : ''}`}
|
||||
value={formData().name}
|
||||
onInput={(e) => updateField('name', e.currentTarget.value)}
|
||||
disabled={loading()}
|
||||
placeholder="Иван Иванов"
|
||||
/>
|
||||
{errors().name && <div class={styles.fieldError}>{errors().name}</div>}
|
||||
</div>
|
||||
|
||||
<div class={styles.field}>
|
||||
<label for="slug" class={styles.label}>
|
||||
Slug (URL) <span style={{ color: 'red' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="slug"
|
||||
type="text"
|
||||
class={`${styles.input} ${errors().slug ? styles.inputError : ''}`}
|
||||
value={formData().slug}
|
||||
onInput={(e) => updateField('slug', e.currentTarget.value.toLowerCase())}
|
||||
disabled={loading()}
|
||||
placeholder="ivan-ivanov"
|
||||
/>
|
||||
<div class={styles.fieldHint}>
|
||||
Используется в URL профиля. Только латинские буквы, цифры, дефисы и подчеркивания.
|
||||
</div>
|
||||
{errors().slug && <div class={styles.fieldError}>{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={styles.rolesGrid}>
|
||||
<For each={AVAILABLE_ROLES}>
|
||||
{(role) => (
|
||||
<label
|
||||
class={`${styles.roleCard} ${formData().roles.includes(role.id) ? styles.roleCardSelected : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData().roles.includes(role.id)}
|
||||
onChange={() => handleRoleToggle(role.id)}
|
||||
disabled={loading()}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div class={styles.roleHeader}>
|
||||
<span class={styles.roleName}>{role.name}</span>
|
||||
<span class={styles.roleCheckmark}>
|
||||
{formData().roles.includes(role.id) ? '✓' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div class={styles.roleDescription}>{role.description}</div>
|
||||
</label>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
{errors().roles && <div class={styles.fieldError}>{errors().roles}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserEditModal
|
52
panel/modals/ShoutBodyModal.tsx
Normal file
52
panel/modals/ShoutBodyModal.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Component, For } from 'solid-js'
|
||||
import type { AdminShoutInfo, Maybe, Topic } from '../graphql/generated/schema'
|
||||
import styles from '../styles/Modal.module.css'
|
||||
import Modal from '../ui/Modal'
|
||||
import TextPreview from '../ui/TextPreview'
|
||||
|
||||
export interface ShoutBodyModalProps {
|
||||
shout: AdminShoutInfo
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ShoutBodyModal: Component<ShoutBodyModalProps> = (props) => {
|
||||
return (
|
||||
<Modal
|
||||
title={`Просмотр публикации: ${props.shout.title}`}
|
||||
isOpen={props.isOpen}
|
||||
onClose={props.onClose}
|
||||
size="large"
|
||||
>
|
||||
<div class={styles['shout-body']}>
|
||||
<div class={styles['shout-info']}>
|
||||
<div class={styles['info-row']}>
|
||||
<span class={styles['info-label']}>Автор:</span>
|
||||
<span class={styles['info-value']}>{props.shout?.authors?.[0]?.email}</span>
|
||||
</div>
|
||||
<div class={styles['info-row']}>
|
||||
<span class={styles['info-label']}>Просмотры:</span>
|
||||
<span class={styles['info-value']}>{props.shout.stat?.viewed || 0}</span>
|
||||
</div>
|
||||
<div class={styles['info-row']}>
|
||||
<span class={styles['info-label']}>Темы:</span>
|
||||
<div class={styles['topics-list']}>
|
||||
<For each={props.shout?.topics}>
|
||||
{(topic: Maybe<Topic>) => <span class={styles['topic-badge']}>{topic?.title || ''}</span>}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles['shout-content']}>
|
||||
<h3>Содержание</h3>
|
||||
<div class={styles['content-preview']}>
|
||||
<TextPreview content={props.shout.body || ''} maxHeight="70vh" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShoutBodyModal
|
185
panel/modals/TopicEditModal.tsx
Normal file
185
panel/modals/TopicEditModal.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
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 Modal from '../ui/Modal'
|
||||
|
||||
interface Topic {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
body?: string
|
||||
pic?: string
|
||||
community: number
|
||||
parent_ids?: number[]
|
||||
}
|
||||
|
||||
interface TopicEditModalProps {
|
||||
isOpen: boolean
|
||||
topic: Topic | null
|
||||
onClose: () => void
|
||||
onSave: (topic: Topic) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Модальное окно для редактирования топиков
|
||||
*/
|
||||
const TopicEditModal: Component<TopicEditModalProps> = (props) => {
|
||||
const [formData, setFormData] = createSignal<Topic>({
|
||||
id: 0,
|
||||
slug: '',
|
||||
title: '',
|
||||
body: '',
|
||||
pic: '',
|
||||
community: 0,
|
||||
parent_ids: []
|
||||
})
|
||||
|
||||
const [parentIdsText, setParentIdsText] = createSignal('')
|
||||
let bodyRef: HTMLDivElement | undefined
|
||||
|
||||
// Синхронизация с props.topic
|
||||
createEffect(() => {
|
||||
if (props.topic) {
|
||||
setFormData({ ...props.topic })
|
||||
setParentIdsText(props.topic.parent_ids?.join(', ') || '')
|
||||
|
||||
// Устанавливаем содержимое в contenteditable div
|
||||
if (bodyRef) {
|
||||
bodyRef.innerHTML = props.topic.body || ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
// Парсим parent_ids из строки
|
||||
const parentIds = parentIdsText()
|
||||
.split(',')
|
||||
.map((id) => Number.parseInt(id.trim()))
|
||||
.filter((id) => !Number.isNaN(id))
|
||||
|
||||
const updatedTopic = {
|
||||
...formData(),
|
||||
parent_ids: parentIds.length > 0 ? parentIds : undefined
|
||||
}
|
||||
|
||||
props.onSave(updatedTopic)
|
||||
}
|
||||
|
||||
const handleBodyInput = (e: Event) => {
|
||||
const target = e.target as HTMLDivElement
|
||||
setFormData((prev) => ({ ...prev, body: target.innerHTML }))
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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={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={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={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={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={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>
|
||||
|
||||
<div class={styles['modal-actions']}>
|
||||
<Button variant="secondary" onClick={props.onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopicEditModal
|
283
panel/routes/authors.tsx
Normal file
283
panel/routes/authors.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Component, createSignal, For, onMount, Show } from 'solid-js'
|
||||
import { query } from '../graphql'
|
||||
import type { Query, AdminUserInfo as User } from '../graphql/generated/schema'
|
||||
import { ADMIN_UPDATE_USER_MUTATION } from '../graphql/mutations'
|
||||
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 { formatDateRelative } from '../utils/date'
|
||||
|
||||
export interface AuthorsRouteProps {
|
||||
onError?: (error: string) => void
|
||||
onSuccess?: (message: string) => void
|
||||
}
|
||||
|
||||
const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
|
||||
console.log('[AuthorsRoute] Initializing...')
|
||||
const [authors, setUsers] = createSignal<User[]>([])
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [selectedUser, setSelectedUser] = createSignal<User | null>(null)
|
||||
const [showEditModal, setShowEditModal] = createSignal(false)
|
||||
|
||||
// Pagination state
|
||||
const [pagination, setPagination] = createSignal<{
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}>({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0,
|
||||
totalPages: 1
|
||||
})
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = createSignal('')
|
||||
|
||||
/**
|
||||
* Загрузка списка пользователей с учетом пагинации и поиска
|
||||
*/
|
||||
async function loadUsers() {
|
||||
console.log('[AuthorsRoute] Loading authors...')
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await query<{ adminGetUsers: Query['adminGetUsers'] }>(
|
||||
`${location.origin}/graphql`,
|
||||
ADMIN_GET_USERS_QUERY,
|
||||
{
|
||||
search: searchQuery(),
|
||||
limit: pagination().limit,
|
||||
offset: (pagination().page - 1) * pagination().limit
|
||||
}
|
||||
)
|
||||
if (data?.adminGetUsers?.authors) {
|
||||
console.log('[AuthorsRoute] Users loaded:', data.adminGetUsers.authors.length)
|
||||
setUsers(data.adminGetUsers.authors)
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
total: data.adminGetUsers.total || 0,
|
||||
totalPages: data.adminGetUsers.totalPages || 1
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthorsRoute] Failed to load authors:', error)
|
||||
props.onError?.(error instanceof Error ? error.message : 'Failed to load authors')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет данные пользователя (профиль и роли)
|
||||
*/
|
||||
async function updateUser(userData: {
|
||||
id: number
|
||||
email?: string
|
||||
name?: string
|
||||
slug?: string
|
||||
roles: string[]
|
||||
}) {
|
||||
try {
|
||||
await query(`${location.origin}/graphql`, ADMIN_UPDATE_USER_MUTATION, {
|
||||
user: userData
|
||||
})
|
||||
|
||||
setUsers((prev) =>
|
||||
prev.map((user) => {
|
||||
if (user.id === userData.id) {
|
||||
return {
|
||||
...user,
|
||||
email: userData.email || user.email,
|
||||
name: userData.name || user.name,
|
||||
slug: userData.slug || user.slug,
|
||||
roles: userData.roles
|
||||
}
|
||||
}
|
||||
return user
|
||||
})
|
||||
)
|
||||
|
||||
closeEditModal()
|
||||
props.onSuccess?.('Данные пользователя успешно обновлены')
|
||||
void loadUsers()
|
||||
} catch (err) {
|
||||
console.error('Ошибка обновления пользователя:', err)
|
||||
let errorMessage = err instanceof Error ? err.message : 'Ошибка обновления данных пользователя'
|
||||
|
||||
if (errorMessage.includes('author_role.community')) {
|
||||
errorMessage = 'Ошибка: для роли author требуется указать community. Обратитесь к администратору.'
|
||||
}
|
||||
|
||||
props.onError?.(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
setShowEditModal(false)
|
||||
setSelectedUser(null)
|
||||
}
|
||||
|
||||
// Pagination handlers
|
||||
function handlePageChange(page: number) {
|
||||
setPagination((prev) => ({ ...prev, page }))
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
function handlePerPageChange(limit: number) {
|
||||
setPagination((prev) => ({ ...prev, page: 1, limit }))
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
// Search handlers
|
||||
function handleSearchChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
setSearchQuery(input.value)
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
setPagination((prev) => ({ ...prev, page: 1 }))
|
||||
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...')
|
||||
void loadUsers()
|
||||
})
|
||||
|
||||
/**
|
||||
* Компонент для отображения роли с иконкой
|
||||
*/
|
||||
const RoleBadge: Component<{ role: string }> = (props) => {
|
||||
const getRoleIcon = (role: string): string => {
|
||||
switch (role.toLowerCase()) {
|
||||
case 'admin':
|
||||
return '👑'
|
||||
case 'editor':
|
||||
return '✏️'
|
||||
case 'expert':
|
||||
return '🎓'
|
||||
case 'author':
|
||||
return '📝'
|
||||
case 'reader':
|
||||
return '👤'
|
||||
case 'banned':
|
||||
return '🚫'
|
||||
case 'verified':
|
||||
return '✓'
|
||||
default:
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={styles['authors-container']}>
|
||||
<Show when={loading()}>
|
||||
<div class={styles['loading']}>Загрузка данных...</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && authors().length === 0}>
|
||||
<div class={styles['empty-state']}>Нет данных для отображения</div>
|
||||
</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>
|
||||
|
||||
<div class={styles['authors-list']}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Email</th>
|
||||
<th>Имя</th>
|
||||
<th>Создан</th>
|
||||
<th>Роли</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={authors()}>
|
||||
{(user) => (
|
||||
<tr>
|
||||
<td>{user.id}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>{user.name || '-'}</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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={pagination().page}
|
||||
totalPages={pagination().totalPages}
|
||||
total={pagination().total}
|
||||
limit={pagination().limit}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={showEditModal() && selectedUser()}>
|
||||
<UserEditModal
|
||||
user={selectedUser()!}
|
||||
isOpen={showEditModal()}
|
||||
onClose={closeEditModal}
|
||||
onSave={updateUser}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthorsRoute
|
381
panel/routes/communities.tsx
Normal file
381
panel/routes/communities.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import { Component, createSignal, For, onMount, Show } from 'solid-js'
|
||||
import { DELETE_COMMUNITY_MUTATION, UPDATE_COMMUNITY_MUTATION } from '../graphql/mutations'
|
||||
import { GET_COMMUNITIES_QUERY } from '../graphql/queries'
|
||||
import styles from '../styles/Table.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
|
||||
/**
|
||||
* Интерфейс для сообщества (используем локальный интерфейс для совместимости)
|
||||
*/
|
||||
interface Community {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
desc?: string
|
||||
pic: string
|
||||
created_at: number
|
||||
created_by: {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
stat: {
|
||||
shouts: number
|
||||
followers: number
|
||||
authors: number
|
||||
}
|
||||
}
|
||||
|
||||
interface CommunitiesRouteProps {
|
||||
onError: (error: string) => void
|
||||
onSuccess: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент для управления сообществами
|
||||
*/
|
||||
const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
|
||||
const [communities, setCommunities] = createSignal<Community[]>([])
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [editModal, setEditModal] = createSignal<{ show: boolean; community: Community | null }>({
|
||||
show: false,
|
||||
community: null
|
||||
})
|
||||
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; community: Community | null }>({
|
||||
show: false,
|
||||
community: null
|
||||
})
|
||||
|
||||
// Форма для редактирования
|
||||
const [formData, setFormData] = createSignal({
|
||||
slug: '',
|
||||
name: '',
|
||||
desc: '',
|
||||
pic: ''
|
||||
})
|
||||
|
||||
/**
|
||||
* Загружает список всех сообществ
|
||||
*/
|
||||
const loadCommunities = async () => {
|
||||
setLoading(true)
|
||||
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)
|
||||
}
|
||||
|
||||
setCommunities(result.data.get_communities_all || [])
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка загрузки сообществ: ${(error as Error).message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует дату
|
||||
*/
|
||||
const formatDate = (timestamp: number): string => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('ru-RU')
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает модалку редактирования
|
||||
*/
|
||||
const openEditModal = (community: Community) => {
|
||||
setFormData({
|
||||
slug: community.slug,
|
||||
name: community.name,
|
||||
desc: community.desc || '',
|
||||
pic: community.pic
|
||||
})
|
||||
setEditModal({ show: true, community })
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет сообщество
|
||||
*/
|
||||
const updateCommunity = async () => {
|
||||
try {
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: UPDATE_COMMUNITY_MUTATION,
|
||||
variables: { community_input: formData() }
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.errors) {
|
||||
throw new Error(result.errors[0].message)
|
||||
}
|
||||
|
||||
if (result.data.update_community.error) {
|
||||
throw new Error(result.data.update_community.error)
|
||||
}
|
||||
|
||||
props.onSuccess('Сообщество успешно обновлено')
|
||||
setEditModal({ show: false, community: null })
|
||||
await loadCommunities()
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка обновления сообщества: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет сообщество
|
||||
*/
|
||||
const deleteCommunity = async (slug: string) => {
|
||||
try {
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: DELETE_COMMUNITY_MUTATION,
|
||||
variables: { slug }
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.errors) {
|
||||
throw new Error(result.errors[0].message)
|
||||
}
|
||||
|
||||
if (result.data.delete_community.error) {
|
||||
throw new Error(result.data.delete_community.error)
|
||||
}
|
||||
|
||||
props.onSuccess('Сообщество успешно удалено')
|
||||
setDeleteModal({ show: false, community: null })
|
||||
await loadCommunities()
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка удаления сообщества: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем сообщества при монтировании компонента
|
||||
onMount(() => {
|
||||
void loadCommunities()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class={styles.container}>
|
||||
<div class={styles.header}>
|
||||
<h2>Управление сообществами</h2>
|
||||
<Button onClick={loadCommunities} disabled={loading()}>
|
||||
{loading() ? 'Загрузка...' : 'Обновить'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={!loading()}
|
||||
fallback={
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner" />
|
||||
<div>Загрузка сообществ...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<table class={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Slug</th>
|
||||
<th>Описание</th>
|
||||
<th>Создатель</th>
|
||||
<th>Публикации</th>
|
||||
<th>Подписчики</th>
|
||||
<th>Авторы</th>
|
||||
<th>Создано</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={communities()}>
|
||||
{(community) => (
|
||||
<tr
|
||||
onClick={() => openEditModal(community)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
class={styles['clickable-row']}
|
||||
>
|
||||
<td>{community.id}</td>
|
||||
<td>{community.name}</td>
|
||||
<td>{community.slug}</td>
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
'max-width': '200px',
|
||||
overflow: 'hidden',
|
||||
'text-overflow': 'ellipsis',
|
||||
'white-space': 'nowrap'
|
||||
}}
|
||||
title={community.desc}
|
||||
>
|
||||
{community.desc || '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td>{community.created_by.name || community.created_by.email}</td>
|
||||
<td>{community.stat.shouts}</td>
|
||||
<td>{community.stat.followers}</td>
|
||||
<td>{community.stat.authors}</td>
|
||||
<td>{formatDate(community.created_at)}</td>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteModal({ show: true, community })
|
||||
}}
|
||||
class={styles['delete-button']}
|
||||
title="Удалить сообщество"
|
||||
aria-label="Удалить сообщество"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</Show>
|
||||
|
||||
{/* Модальное окно редактирования */}
|
||||
<Modal
|
||||
isOpen={editModal().show}
|
||||
onClose={() => setEditModal({ show: false, community: null })}
|
||||
title={`Редактирование сообщества: ${editModal().community?.name || ''}`}
|
||||
>
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div style={{ 'margin-bottom': '16px' }}>
|
||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().slug}
|
||||
onInput={(e) => setFormData((prev) => ({ ...prev, slug: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
'border-radius': '4px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ 'margin-bottom': '16px' }}>
|
||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().name}
|
||||
onInput={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
'border-radius': '4px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ 'margin-bottom': '16px' }}>
|
||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
value={formData().desc}
|
||||
onInput={(e) => setFormData((prev) => ({ ...prev, desc: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
'border-radius': '4px',
|
||||
'min-height': '80px',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
placeholder="Описание сообщества..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ 'margin-bottom': '16px' }}>
|
||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
|
||||
Картинка (URL)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().pic}
|
||||
onInput={(e) => setFormData((prev) => ({ ...prev, pic: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
'border-radius': '4px'
|
||||
}}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={styles['modal-actions']}>
|
||||
<Button variant="secondary" onClick={() => setEditModal({ show: false, community: null })}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="primary" onClick={updateCommunity}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Модальное окно подтверждения удаления */}
|
||||
<Modal
|
||||
isOpen={deleteModal().show}
|
||||
onClose={() => setDeleteModal({ show: false, community: null })}
|
||||
title="Подтверждение удаления"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
Вы уверены, что хотите удалить сообщество "<strong>{deleteModal().community?.name}</strong>"?
|
||||
</p>
|
||||
<p class={styles['warning-text']}>
|
||||
Это действие нельзя отменить. Все публикации и темы сообщества могут быть затронуты.
|
||||
</p>
|
||||
<div class={styles['modal-actions']}>
|
||||
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, community: null })}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => deleteModal().community && deleteCommunity(deleteModal().community!.slug)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommunitiesRoute
|
275
panel/routes/env.tsx
Normal file
275
panel/routes/env.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { Component, createSignal, For, Show } from 'solid-js'
|
||||
import { query } from '../graphql'
|
||||
import type { EnvSection, EnvVariable, Query } from '../graphql/generated/schema'
|
||||
import { ADMIN_UPDATE_ENV_VARIABLE_MUTATION } from '../graphql/mutations'
|
||||
import { ADMIN_GET_ENV_VARIABLES_QUERY } from '../graphql/queries'
|
||||
import EnvVariableModal from '../modals/EnvVariableModal'
|
||||
import styles from '../styles/Admin.module.css'
|
||||
import Button from '../ui/Button'
|
||||
|
||||
export interface EnvRouteProps {
|
||||
onError?: (error: string) => void
|
||||
onSuccess?: (message: string) => void
|
||||
}
|
||||
|
||||
const EnvRoute: Component<EnvRouteProps> = (props) => {
|
||||
const [envSections, setEnvSections] = createSignal<EnvSection[]>([])
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [editingVariable, setEditingVariable] = createSignal<EnvVariable | null>(null)
|
||||
const [showVariableModal, setShowVariableModal] = createSignal(false)
|
||||
|
||||
// Состояние для показа/скрытия значений
|
||||
const [shownVars, setShownVars] = createSignal<{ [key: string]: boolean }>({})
|
||||
|
||||
/**
|
||||
* Загружает переменные окружения
|
||||
*/
|
||||
const loadEnvVariables = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await query<{ getEnvVariables: Query['getEnvVariables'] }>(
|
||||
`${location.origin}/graphql`,
|
||||
ADMIN_GET_ENV_VARIABLES_QUERY
|
||||
)
|
||||
|
||||
// Важно: пустой массив [] тоже валидный результат!
|
||||
if (result && Array.isArray(result.getEnvVariables)) {
|
||||
setEnvSections(result.getEnvVariables)
|
||||
console.log('Загружено секций переменных:', result.getEnvVariables.length)
|
||||
} else {
|
||||
console.warn('Неожиданный результат от getEnvVariables:', result)
|
||||
setEnvSections([]) // Устанавливаем пустой массив если что-то пошло не так
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load env variables:', error)
|
||||
props.onError?.(error instanceof Error ? error.message : 'Failed to load environment variables')
|
||||
setEnvSections([]) // Устанавливаем пустой массив при ошибке
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет значение переменной окружения
|
||||
*/
|
||||
const updateEnvVariable = async (key: string, value: string) => {
|
||||
try {
|
||||
const result = await query(`${location.origin}/graphql`, ADMIN_UPDATE_ENV_VARIABLE_MUTATION, {
|
||||
key,
|
||||
value
|
||||
})
|
||||
|
||||
if (result && typeof result === 'object' && 'updateEnvVariable' in result) {
|
||||
props.onSuccess?.(`Переменная ${key} успешно обновлена`)
|
||||
await loadEnvVariables()
|
||||
} else {
|
||||
props.onError?.('Не удалось обновить переменную')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка обновления переменной:', err)
|
||||
props.onError?.(err instanceof Error ? err.message : 'Ошибка при обновлении переменной')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик открытия модального окна редактирования переменной
|
||||
*/
|
||||
const openVariableModal = (variable: EnvVariable) => {
|
||||
setEditingVariable({ ...variable })
|
||||
setShowVariableModal(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик закрытия модального окна редактирования переменной
|
||||
*/
|
||||
const closeVariableModal = () => {
|
||||
setEditingVariable(null)
|
||||
setShowVariableModal(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик сохранения переменной
|
||||
*/
|
||||
const saveVariable = async () => {
|
||||
const variable = editingVariable()
|
||||
if (!variable) return
|
||||
|
||||
await updateEnvVariable(variable.key, variable.value)
|
||||
closeVariableModal()
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик изменения значения в модальном окне
|
||||
*/
|
||||
const handleVariableValueChange = (value: string) => {
|
||||
const variable = editingVariable()
|
||||
if (variable) {
|
||||
setEditingVariable({ ...variable, value })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключает показ значения переменной
|
||||
*/
|
||||
const toggleShow = (key: string) => {
|
||||
setShownVars((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Копирует значение в буфер обмена
|
||||
*/
|
||||
const CopyButton: Component<{ value: string }> = (props) => {
|
||||
const handleCopy = async (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.value)
|
||||
// Можно добавить всплывающее уведомление
|
||||
} catch (err) {
|
||||
alert(`Ошибка копирования: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<a class="btn" title="Скопировать" type="button" style="margin-left: 6px" onClick={handleCopy}>
|
||||
📋
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка показать/скрыть значение переменной
|
||||
*/
|
||||
const ShowHideButton: Component<{ shown: boolean; onToggle: () => void }> = (props) => {
|
||||
return (
|
||||
<a
|
||||
class="btn"
|
||||
title={props.shown ? 'Скрыть' : 'Показать'}
|
||||
type="button"
|
||||
style="margin-left: 6px"
|
||||
onClick={props.onToggle}
|
||||
>
|
||||
{props.shown ? '🙈' : '👁️'}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// Load env variables on mount
|
||||
void loadEnvVariables()
|
||||
|
||||
// ВРЕМЕННО: для тестирования пустого состояния
|
||||
// setTimeout(() => {
|
||||
// setLoading(false)
|
||||
// setEnvSections([])
|
||||
// console.log('Тест: установлено пустое состояние')
|
||||
// }, 1000)
|
||||
|
||||
return (
|
||||
<div class={styles['env-variables-container']}>
|
||||
<Show when={loading()}>
|
||||
<div class={styles['loading']}>Загрузка переменных окружения...</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && envSections().length === 0}>
|
||||
<div class={styles['empty-state']}>
|
||||
<h3>Переменные окружения не найдены</h3>
|
||||
<p>
|
||||
Переменные окружения не настроены или не обнаружены в системе.
|
||||
<br />
|
||||
Вы можете добавить переменные через файл <code>.env</code> или системные переменные.
|
||||
</p>
|
||||
<details style="margin-top: 16px;">
|
||||
<summary style="cursor: pointer; font-weight: 600;">Как добавить переменные?</summary>
|
||||
<div style="margin-top: 8px; padding: 12px; background: #f8f9fa; border-radius: 6px;">
|
||||
<p>
|
||||
<strong>Способ 1:</strong> Через командную строку
|
||||
</p>
|
||||
<pre style="background: #e9ecef; padding: 8px; border-radius: 4px; font-size: 12px;">
|
||||
export DEBUG=true export DB_URL="postgresql://localhost:5432/db" export
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
</pre>
|
||||
|
||||
<p style="margin-top: 12px;">
|
||||
<strong>Способ 2:</strong> Через файл .env
|
||||
</p>
|
||||
<pre style="background: #e9ecef; padding: 8px; border-radius: 4px; font-size: 12px;">
|
||||
DEBUG=true DB_URL=postgresql://localhost:5432/db REDIS_URL=redis://localhost:6379
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && envSections().length > 0}>
|
||||
<div class={styles['env-sections']}>
|
||||
<For each={envSections()}>
|
||||
{(section) => (
|
||||
<div class={styles['env-section']}>
|
||||
<h3 class={styles['section-name']}>{section.name}</h3>
|
||||
<Show when={section.description}>
|
||||
<p class={styles['section-description']}>{section.description}</p>
|
||||
</Show>
|
||||
<div class={styles['variables-list']}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ключ</th>
|
||||
<th>Значение</th>
|
||||
<th>Описание</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={section.variables}>
|
||||
{(variable) => {
|
||||
const shown = () => shownVars()[variable.key] || false
|
||||
return (
|
||||
<tr>
|
||||
<td>{variable.key}</td>
|
||||
<td>
|
||||
{variable.isSecret && !shown()
|
||||
? '••••••••'
|
||||
: variable.value || <span class={styles['empty-value']}>не задано</span>}
|
||||
<CopyButton value={variable.value || ''} />
|
||||
{variable.isSecret && (
|
||||
<ShowHideButton
|
||||
shown={shown()}
|
||||
onToggle={() => toggleShow(variable.key)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>{variable.description || '-'}</td>
|
||||
<td class={styles['actions']}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => openVariableModal(variable)}
|
||||
>
|
||||
Изменить
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={editingVariable()}>
|
||||
<EnvVariableModal
|
||||
isOpen={showVariableModal()}
|
||||
variable={editingVariable()!}
|
||||
onClose={closeVariableModal}
|
||||
onSave={saveVariable}
|
||||
onValueChange={handleVariableValueChange}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EnvRoute
|
89
panel/routes/login.tsx
Normal file
89
panel/routes/login.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Компонент страницы входа
|
||||
* @module LoginPage
|
||||
*/
|
||||
|
||||
import { useNavigate } from '@solidjs/router'
|
||||
import { createSignal, onMount } from 'solid-js'
|
||||
import publyLogo from '../assets/publy.svg?url'
|
||||
import { useAuth } from '../context/auth'
|
||||
import styles from '../styles/Login.module.css'
|
||||
import Button from '../ui/Button'
|
||||
|
||||
/**
|
||||
* Компонент страницы входа
|
||||
*/
|
||||
const LoginPage = () => {
|
||||
console.log('[LoginPage] Initializing...')
|
||||
const [username, setUsername] = createSignal('')
|
||||
const [password, setPassword] = createSignal('')
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const auth = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
onMount(() => {
|
||||
console.log('[LoginPage] Component mounted')
|
||||
// Если пользователь уже авторизован, редиректим на админ-панель
|
||||
if (auth.isAuthenticated()) {
|
||||
console.log('[LoginPage] User already authenticated, redirecting to admin...')
|
||||
navigate('/admin')
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await auth.login(username(), password())
|
||||
navigate('/admin')
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Ошибка при входе')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={styles['login-container']}>
|
||||
<form class={styles['login-form']} onSubmit={handleSubmit}>
|
||||
<img src={publyLogo} alt="Logo" class={styles['login-logo']} />
|
||||
<h1>Вход в панель администратора</h1>
|
||||
|
||||
{error() && <div class={styles['error-message']}>{error()}</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={styles['form-group']}>
|
||||
<label for="password">Пароль</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
disabled={loading()}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="primary" disabled={loading()} loading={loading()}>
|
||||
{loading() ? 'Вход...' : 'Войти'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
317
panel/routes/shouts.tsx
Normal file
317
panel/routes/shouts.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Component, createSignal, For, onMount, Show } from 'solid-js'
|
||||
import { query } from '../graphql'
|
||||
import type { Query, AdminShoutInfo as Shout } from '../graphql/generated/schema'
|
||||
import { ADMIN_GET_SHOUTS_QUERY } from '../graphql/queries'
|
||||
import styles from '../styles/Admin.module.css'
|
||||
import EditableCodePreview from '../ui/EditableCodePreview'
|
||||
import Modal from '../ui/Modal'
|
||||
import Pagination from '../ui/Pagination'
|
||||
import { formatDateRelative } from '../utils/date'
|
||||
|
||||
export interface ShoutsRouteProps {
|
||||
onError?: (error: string) => void
|
||||
onSuccess?: (message: string) => void
|
||||
}
|
||||
|
||||
const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
|
||||
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>('')
|
||||
|
||||
// Pagination state
|
||||
const [pagination, setPagination] = createSignal<{
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
|
||||
// Filter state
|
||||
const [searchQuery, setSearchQuery] = createSignal('')
|
||||
|
||||
/**
|
||||
* Загрузка списка публикаций
|
||||
*/
|
||||
async function loadShouts() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await query<{ adminGetShouts: Query['adminGetShouts'] }>(
|
||||
`${location.origin}/graphql`,
|
||||
ADMIN_GET_SHOUTS_QUERY,
|
||||
{
|
||||
limit: pagination().limit,
|
||||
offset: (pagination().page - 1) * pagination().limit
|
||||
}
|
||||
)
|
||||
if (result?.adminGetShouts?.shouts) {
|
||||
setShouts(result.adminGetShouts.shouts)
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
total: result.adminGetShouts.total || 0,
|
||||
totalPages: result.adminGetShouts.totalPages || 1
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load shouts:', error)
|
||||
props.onError?.(error instanceof Error ? error.message : 'Failed to load shouts')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load shouts on mount
|
||||
onMount(() => {
|
||||
void loadShouts()
|
||||
})
|
||||
|
||||
// Pagination handlers
|
||||
function handlePageChange(page: number) {
|
||||
setPagination((prev) => ({ ...prev, page }))
|
||||
void loadShouts()
|
||||
}
|
||||
|
||||
function handlePerPageChange(limit: number) {
|
||||
setPagination((prev) => ({ ...prev, page: 1, limit }))
|
||||
void loadShouts()
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function getShoutStatus(shout: Shout): string {
|
||||
if (shout.deleted_at) return '🗑️'
|
||||
if (shout.published_at) return '✅'
|
||||
return '📝'
|
||||
}
|
||||
|
||||
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 truncateText(text: string, maxLength = 100): string {
|
||||
if (!text || text.length <= maxLength) return text
|
||||
return `${text.substring(0, maxLength)}...`
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={styles['shouts-container']}>
|
||||
<Show when={loading()}>
|
||||
<div class={styles['loading']}>Загрузка публикаций...</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && shouts().length === 0}>
|
||||
<div class={styles['empty-state']}>Нет публикаций для отображения</div>
|
||||
</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>
|
||||
|
||||
<div class={styles['shouts-list']}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Заголовок</th>
|
||||
<th>Slug</th>
|
||||
<th>Статус</th>
|
||||
<th>Авторы</th>
|
||||
<th>Темы</th>
|
||||
<th>Создан</th>
|
||||
<th>Содержимое</th>
|
||||
<th>Media</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={shouts()}>
|
||||
{(shout) => (
|
||||
<tr>
|
||||
<td>{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']}>
|
||||
<For each={shout.authors}>
|
||||
{(author) => (
|
||||
<Show when={author}>
|
||||
{(safeAuthor) => (
|
||||
<span class={styles['author-badge']} title={safeAuthor()?.email || ''}>
|
||||
{safeAuthor()?.name || safeAuthor()?.email || `ID:${safeAuthor()?.id}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!shout.authors?.length}>
|
||||
<span class={styles['no-data']}>-</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td>
|
||||
<Show when={shout.topics?.length}>
|
||||
<div class={styles['topics-list']}>
|
||||
<For each={shout.topics}>
|
||||
{(topic) => (
|
||||
<Show when={topic}>
|
||||
{(safeTopic) => (
|
||||
<span class={styles['topic-badge']} title={safeTopic()?.slug || ''}>
|
||||
{safeTopic()?.title || safeTopic()?.slug}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!shout.topics?.length}>
|
||||
<span class={styles['no-data']}>-</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td>{formatDateRelative(shout.created_at)}</td>
|
||||
<td
|
||||
class={styles['body-cell']}
|
||||
onClick={() => {
|
||||
setSelectedShoutBody(shout.body)
|
||||
setShowBodyModal(true)
|
||||
}}
|
||||
style="cursor: pointer; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||
>
|
||||
{truncateText(shout.body.replace(/<[^>]*>/g, ''), 100)}
|
||||
</td>
|
||||
<td>
|
||||
<Show when={shout.media && shout.media.length > 0}>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<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"
|
||||
onClick={() => {
|
||||
setSelectedMediaBody(mediaItem?.body || '')
|
||||
setShowMediaBodyModal(true)
|
||||
}}
|
||||
>
|
||||
👁 body
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!shout.media || shout.media.length === 0}>
|
||||
<span class={styles['no-data']}>-</span>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Pagination
|
||||
currentPage={pagination().page}
|
||||
totalPages={pagination().totalPages}
|
||||
total={pagination().total}
|
||||
limit={pagination().limit}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Modal isOpen={showBodyModal()} onClose={() => setShowBodyModal(false)} title="Содержимое публикации">
|
||||
<EditableCodePreview
|
||||
content={selectedShoutBody()}
|
||||
maxHeight="70vh"
|
||||
onContentChange={(newContent) => {
|
||||
setSelectedShoutBody(newContent)
|
||||
}}
|
||||
onSave={(_content) => {
|
||||
// FIXME: добавить логику сохранения изменений в базу данных
|
||||
props.onSuccess?.('Содержимое публикации обновлено')
|
||||
setShowBodyModal(false)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowBodyModal(false)
|
||||
}}
|
||||
placeholder="Введите содержимое публикации..."
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showMediaBodyModal()}
|
||||
onClose={() => setShowMediaBodyModal(false)}
|
||||
title="Содержимое media.body"
|
||||
>
|
||||
<EditableCodePreview
|
||||
content={selectedMediaBody()}
|
||||
maxHeight="70vh"
|
||||
onContentChange={(newContent) => {
|
||||
setSelectedMediaBody(newContent)
|
||||
}}
|
||||
onSave={(_content) => {
|
||||
// FIXME: добавить логику сохранения изменений media.body
|
||||
props.onSuccess?.('Содержимое media.body обновлено')
|
||||
setShowMediaBodyModal(false)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowMediaBodyModal(false)
|
||||
}}
|
||||
placeholder="Введите содержимое media.body..."
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShoutsRoute
|
410
panel/routes/topics.tsx
Normal file
410
panel/routes/topics.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Компонент управления топиками
|
||||
* @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 { DELETE_TOPIC_MUTATION, UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
|
||||
import { GET_TOPICS_QUERY } from '../graphql/queries'
|
||||
import TopicEditModal from '../modals/TopicEditModal'
|
||||
import styles from '../styles/Table.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
|
||||
/**
|
||||
* Интерфейс топика
|
||||
*/
|
||||
interface Topic {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
body?: string
|
||||
pic?: string
|
||||
community: number
|
||||
parent_ids?: number[]
|
||||
children?: Topic[]
|
||||
level?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс свойств компонента
|
||||
*/
|
||||
interface TopicsRouteProps {
|
||||
onError: (error: string) => void
|
||||
onSuccess: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент управления топиками
|
||||
*/
|
||||
const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||
const [rawTopics, setRawTopics] = createSignal<Topic[]>([])
|
||||
const [topics, setTopics] = createSignal<Topic[]>([])
|
||||
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 loadTopics = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await query<{ get_topics_all: Query['get_topics_all'] }>(
|
||||
`${location.origin}/graphql`,
|
||||
GET_TOPICS_QUERY
|
||||
)
|
||||
|
||||
if (data?.get_topics_all) {
|
||||
// Строим иерархическую структуру
|
||||
const validTopics = data.get_topics_all.filter((topic): topic is Topic => topic !== null)
|
||||
setRawTopics(validTopics)
|
||||
}
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка загрузки топиков: ${(error as 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 sortTopics = (topics: Topic[], sortField?: 'id' | 'title', sortDir?: 'asc' | 'desc'): Topic[] => {
|
||||
const field = sortField || sortBy()
|
||||
const direction = sortDir || sortDirection()
|
||||
|
||||
const sortedTopics = topics.sort((a, b) => {
|
||||
let comparison = 0
|
||||
|
||||
if (field === 'title') {
|
||||
comparison = (a.title || '').localeCompare(b.title || '', 'ru')
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрезает текст до указанной длины
|
||||
*/
|
||||
const truncateText = (text: string, maxLength = 100): string => {
|
||||
if (!text) return '—'
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивно отображает топики с отступами для иерархии
|
||||
*/
|
||||
const renderTopics = (topics: Topic[]): JSX.Element[] => {
|
||||
const result: JSX.Element[] = []
|
||||
|
||||
topics.forEach((topic) => {
|
||||
result.push(
|
||||
<tr
|
||||
onClick={() => setEditModal({ show: true, topic })}
|
||||
style={{ cursor: 'pointer' }}
|
||||
class={styles['clickable-row']}
|
||||
>
|
||||
<td>{topic.id}</td>
|
||||
<td style={{ 'padding-left': `${(topic.level || 0) * 20}px` }}>
|
||||
{topic.level! > 0 && '└─ '}
|
||||
{topic.title}
|
||||
</td>
|
||||
<td>{topic.slug}</td>
|
||||
<td>
|
||||
<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>{topic.community}</td>
|
||||
<td>{topic.parent_ids?.join(', ') || '—'}</td>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteModal({ show: true, topic })
|
||||
}}
|
||||
class={styles['delete-button']}
|
||||
title="Удалить топик"
|
||||
aria-label="Удалить топик"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
if (topic.children && topic.children.length > 0) {
|
||||
result.push(...renderTopics(topic.children))
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет топик
|
||||
*/
|
||||
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 result = await response.json()
|
||||
|
||||
if (result.errors) {
|
||||
throw new Error(result.errors[0].message)
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет топик
|
||||
*/
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={styles.container}>
|
||||
<div class={styles.header}>
|
||||
<h2>Управление топиками</h2>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={!loading()}
|
||||
fallback={
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner" />
|
||||
<div>Загрузка топиков...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<table class={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Slug</th>
|
||||
<th>Описание</th>
|
||||
<th>Сообщество</th>
|
||||
<th>Родители</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={renderTopics(topics())}>{(row) => row}</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</Show>
|
||||
|
||||
{/* Модальное окно редактирования */}
|
||||
<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>
|
||||
<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={() => deleteModal().topic && deleteTopic(deleteModal().topic!.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopicsRoute
|
266
panel/styles.css
266
panel/styles.css
@@ -1,44 +1,73 @@
|
||||
/**
|
||||
* Основные стили приложения
|
||||
* Global Styles and CSS Variables
|
||||
* Minimal global styling with focus on CSS variables and reset
|
||||
*/
|
||||
|
||||
/* Сброс стилей */
|
||||
/* Global Styles */
|
||||
@import './styles/GlobalVariables.module.css';
|
||||
|
||||
/* CSS Reset and Base Styles */
|
||||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body, html {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Minimal Accessibility and Utility Styles */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Responsive Typography */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
body {
|
||||
background: none;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* Общие стили */
|
||||
:root {
|
||||
/* Основные цвета */
|
||||
--primary-color: #000000;
|
||||
--primary-dark: #333333;
|
||||
--primary-light: #F5F5F5;
|
||||
|
||||
/* Статусные цвета */
|
||||
--success-color: #155724;
|
||||
--success-light: #d4edda;
|
||||
--success-border: #c3e6cb;
|
||||
|
||||
--danger-color: #721c24;
|
||||
--danger-light: #f8d7da;
|
||||
--danger-border: #f5c6cb;
|
||||
|
||||
--warning-color: #856404;
|
||||
--warning-light: #fff3cd;
|
||||
--warning-border: #ffeaa7;
|
||||
|
||||
/* Текст и фон */
|
||||
--text-color: #000000;
|
||||
--text-secondary: #666666;
|
||||
--text-muted: #6b7280;
|
||||
--bg-color: #FFFFFF;
|
||||
--card-bg: #FFFFFF;
|
||||
|
||||
/* Границы и тени */
|
||||
--border-color: #E0E0E0;
|
||||
--border-radius-sm: 4px;
|
||||
@@ -52,11 +81,10 @@
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace;
|
||||
|
||||
/* Размеры */
|
||||
--container-max-width: 1200px;
|
||||
--container-max-width: 1400px;
|
||||
--header-height: 60px;
|
||||
|
||||
/* Анимации */
|
||||
--transition-fast: 0.2s ease;
|
||||
--transition-normal: 0.3s ease;
|
||||
|
||||
/* Z-индексы */
|
||||
@@ -83,29 +111,34 @@ body {
|
||||
}
|
||||
|
||||
/* Общие элементы интерфейса */
|
||||
.loading-screen, .loading {
|
||||
.loading-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--primary-color);
|
||||
min-height: 100vh;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color-light);
|
||||
font-size: var(--font-size-lg);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||
border-left-color: var(--primary-color);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 20px;
|
||||
border-top-color: var(--primary-color);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
@@ -168,34 +201,27 @@ body {
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
button:focus,
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: #E5E9F2;
|
||||
color: #A0AEC0;
|
||||
button:disabled,
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Стили для страницы входа */
|
||||
@@ -329,7 +355,7 @@ header h1 {
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 20px;
|
||||
padding: 1.5rem 3rem;
|
||||
max-width: var(--container-max-width);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
@@ -337,7 +363,7 @@ main {
|
||||
}
|
||||
|
||||
/* Таблица пользователей */
|
||||
.users-list {
|
||||
.authors-list {
|
||||
overflow-x: auto;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -351,6 +377,7 @@ table {
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
background-color: var(--card-bg);
|
||||
min-width: 900px;
|
||||
}
|
||||
|
||||
thead {
|
||||
@@ -358,10 +385,11 @@ thead {
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 14px 16px;
|
||||
padding: 18px 20px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
th {
|
||||
@@ -369,7 +397,7 @@ th {
|
||||
color: var(--text-secondary);
|
||||
background-color: #F5F7FA;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@@ -710,12 +738,12 @@ tr:hover {
|
||||
}
|
||||
|
||||
/* Поиск */
|
||||
.users-controls {
|
||||
.authors-controls {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -771,7 +799,7 @@ tr:hover {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.users-list {
|
||||
.authors-list {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -1117,7 +1145,7 @@ th.sortable.sorted .sort-icon {
|
||||
padding: 8px 5px;
|
||||
}
|
||||
|
||||
.users-list,
|
||||
.authors-list,
|
||||
.shouts-list table {
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -1385,99 +1413,7 @@ button:hover,
|
||||
}
|
||||
|
||||
/* Оптимизация для доступности */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.focus-visible:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Убираем скругления и делаем строгий стиль для пагинации и кнопок */
|
||||
button,
|
||||
.pagination,
|
||||
.pagination-button,
|
||||
.pagination-per-page select {
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
background: #ededed;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pagination-button {
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
background: #181818;
|
||||
color: #fff;
|
||||
border: 1px solid #222;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
margin-bottom: 8px;
|
||||
transition: background 0.15s, color 0.15s, border 0.15s;
|
||||
}
|
||||
|
||||
.pagination-button.active {
|
||||
background: #fff;
|
||||
color: #111;
|
||||
border: 2px solid #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pagination-button:hover:not(:disabled) {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
border-color: #111;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pagination-button:disabled {
|
||||
background: #aaa;
|
||||
color: #fff;
|
||||
opacity: 0.5;
|
||||
border-color: #888;
|
||||
}
|
||||
|
||||
.pagination-ellipsis {
|
||||
background: transparent;
|
||||
color: #888;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pagination-per-page select {
|
||||
background: #181818;
|
||||
color: #fff;
|
||||
border: 1px solid #222;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: flex-end;
|
||||
padding: 0;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
544
panel/styles/Admin.module.css
Normal file
544
panel/styles/Admin.module.css
Normal file
@@ -0,0 +1,544 @@
|
||||
/* Admin Panel Layout */
|
||||
.admin-panel {
|
||||
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);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 2rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.header-container h1 {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
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);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 1.5rem 3rem;
|
||||
background-color: var(--background-color);
|
||||
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);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
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;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
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;
|
||||
}
|
||||
|
||||
.empty-state details {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.empty-state summary:hover {
|
||||
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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Users Route Styles */
|
||||
.authors-container {
|
||||
padding: 1.5rem;
|
||||
background-color: var(--background-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.authors-controls {
|
||||
margin-bottom: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input-group {
|
||||
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);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
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);
|
||||
}
|
||||
|
||||
.search-button:hover {
|
||||
background-color: var(--primary-color-dark);
|
||||
}
|
||||
|
||||
.authors-list {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.authors-list table {
|
||||
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);
|
||||
}
|
||||
|
||||
.authors-list th {
|
||||
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;
|
||||
}
|
||||
|
||||
.roles-container {
|
||||
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);
|
||||
}
|
||||
|
||||
.role-icon {
|
||||
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);
|
||||
}
|
||||
|
||||
.edit-role-badge:hover {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Shouts Route Styles */
|
||||
.shouts-container {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.shouts-controls {
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.status-badge.status-published {
|
||||
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);
|
||||
}
|
||||
|
||||
.status-badge.status-deleted {
|
||||
background-color: var(--error-color-light);
|
||||
color: var(--error-color-dark);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.body-cell {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.body-cell:hover {
|
||||
background-color: var(--hover-color);
|
||||
}
|
||||
|
||||
.no-data {
|
||||
color: var(--text-color-light);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Environment Variables Route Styles */
|
||||
.env-variables-container {
|
||||
padding: 1.5rem 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.env-sections {
|
||||
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;
|
||||
}
|
||||
|
||||
.section-name {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--text-color);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.variables-list {
|
||||
overflow-x: auto;
|
||||
margin: 0 -1rem;
|
||||
}
|
||||
|
||||
.empty-value {
|
||||
color: var(--text-color-light);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
table {
|
||||
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;
|
||||
}
|
||||
|
||||
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; /* Выравнивание по верхнему краю */
|
||||
}
|
||||
|
||||
/* Специальные стили для колонок публикаций */
|
||||
.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 */
|
||||
|
||||
/* Компактные стили для колонки 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;
|
||||
}
|
||||
|
||||
.shouts-list td:nth-child(8) { /* Колонка содержимого */
|
||||
max-width: 200px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: var(--hover-color);
|
||||
}
|
||||
|
||||
/* Responsive Styles */
|
||||
@media (max-width: 1024px) {
|
||||
.header-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
padding: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
.authors-container,
|
||||
.shouts-container,
|
||||
.env-variables-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.search-input-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.shouts-controls {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.status-filter {
|
||||
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-left {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.authors-list {
|
||||
margin: 0 -1rem;
|
||||
}
|
||||
|
||||
.authors-list table {
|
||||
font-size: var(--font-size-sm);
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.authors-list th,
|
||||
.authors-list td {
|
||||
padding: 0.8rem 1rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.8rem 1rem;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-input-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
94
panel/styles/Button.module.css
Normal file
94
panel/styles/Button.module.css
Normal file
@@ -0,0 +1,94 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.button-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button-primary:hover:not(:disabled) {
|
||||
background-color: var(--primary-color-dark);
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background-color: var(--secondary-color-light);
|
||||
color: var(--secondary-color-dark);
|
||||
}
|
||||
|
||||
.button-secondary:hover:not(:disabled) {
|
||||
background-color: var(--secondary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
background-color: var(--error-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button-danger:hover:not(:disabled) {
|
||||
background-color: var(--error-color-dark);
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.button-small {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.button-medium {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.button-large {
|
||||
padding: 1rem 2rem;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
/* States */
|
||||
.button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button-loading {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.button-full-width {
|
||||
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;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
138
panel/styles/CodePreview.module.css
Normal file
138
panel/styles/CodePreview.module.css
Normal file
@@ -0,0 +1,138 @@
|
||||
.codePreview {
|
||||
position: relative;
|
||||
padding-left: 50px !important;
|
||||
background-color: #2d2d2d;
|
||||
color: #f8f8f2;
|
||||
tab-size: 2;
|
||||
line-height: 1.5;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lineNumber {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
color: #999;
|
||||
user-select: none;
|
||||
opacity: 0.5;
|
||||
padding-right: 10px;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.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: 100;
|
||||
}
|
||||
|
||||
/* Стили для EditableCodePreview */
|
||||
.editableCodeContainer {
|
||||
position: relative;
|
||||
background-color: #2d2d2d;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.editorControls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 12px;
|
||||
background-color: #1e1e1e;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.syntaxHighlight {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.editorArea {
|
||||
min-height: 150px;
|
||||
resize: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.editorArea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
441
panel/styles/Form.module.css
Normal file
441
panel/styles/Form.module.css
Normal file
@@ -0,0 +1,441 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px var(--primary-color-light);
|
||||
}
|
||||
|
||||
.form-group input:disabled,
|
||||
.form-group select:disabled,
|
||||
.form-group textarea:disabled {
|
||||
background-color: var(--disabled-bg);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-group select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||
background-position: right 0.5rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.5em 1.5em;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.form-group-horizontal {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group-horizontal label {
|
||||
flex: 0 0 200px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--error-color);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
color: var(--text-color-light);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-section:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-section-description {
|
||||
color: var(--text-color-light);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.radio-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.radio-option input[type="radio"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input-group button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Placeholder для contenteditable div */
|
||||
.input[contenteditable="true"]:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input[contenteditable="true"]:focus:empty::before {
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Стили для улучшенной формы редактирования пользователя */
|
||||
.section {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
background-color: #f8f9fa;
|
||||
border-color: #e9ecef;
|
||||
color: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.inputError {
|
||||
border-color: #dc3545;
|
||||
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.1);
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
color: #dc3545;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fieldHint {
|
||||
color: #6c757d;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f5c6cb;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Стили для грида ролей */
|
||||
.rolesGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.roleCard {
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background-color: #fff;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.roleCard:hover {
|
||||
border-color: #007bff;
|
||||
background-color: #f8f9ff;
|
||||
}
|
||||
|
||||
.roleCardSelected {
|
||||
border-color: #007bff;
|
||||
background-color: #e7f1ff;
|
||||
}
|
||||
|
||||
.roleHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.roleName {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.roleCheckmark {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.roleCardSelected .roleCheckmark {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.roleDescription {
|
||||
color: #6c757d;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Широкое модальное окно для переменных среды */
|
||||
.modal-wide {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* Улучшенные стили для форм */
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-label-info {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
color: #6b7280;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-input-disabled {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background-color: #f9fafb;
|
||||
color: #6b7280;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Контейнер для textarea с кнопками */
|
||||
.textarea-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.textarea-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Контейнер для превью кода */
|
||||
.code-preview-container {
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #1e1e1e;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.code-preview-container pre {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background: transparent;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Улучшенная справка */
|
||||
.form-help {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background-color: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #0c4a6e;
|
||||
}
|
||||
|
||||
.form-help strong {
|
||||
color: #075985;
|
||||
}
|
||||
|
||||
/* Ошибки */
|
||||
.form-error {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background-color: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Действия формы */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* Адаптивность для модального окна */
|
||||
@media (max-width: 768px) {
|
||||
.modal-wide {
|
||||
max-width: 95vw;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.textarea-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
}
|
101
panel/styles/GlobalVariables.module.css
Normal file
101
panel/styles/GlobalVariables.module.css
Normal file
@@ -0,0 +1,101 @@
|
||||
/* Global CSS Variables */
|
||||
:root {
|
||||
/* Colors */
|
||||
--primary-color: #2563eb;
|
||||
--primary-color-light: #dbeafe;
|
||||
--primary-color-dark: #1e40af;
|
||||
|
||||
--secondary-color: #4b5563;
|
||||
--secondary-color-light: #f3f4f6;
|
||||
--secondary-color-dark: #1f2937;
|
||||
|
||||
--success-color: #059669;
|
||||
--success-color-light: #d1fae5;
|
||||
--success-color-dark: #065f46;
|
||||
|
||||
--warning-color: #d97706;
|
||||
--warning-color-light: #fef3c7;
|
||||
--warning-color-dark: #92400e;
|
||||
|
||||
--error-color: #dc2626;
|
||||
--error-color-light: #fee2e2;
|
||||
--error-color-dark: #991b1b;
|
||||
|
||||
--info-color: #0284c7;
|
||||
--info-color-light: #e0f2fe;
|
||||
--info-color-dark: #075985;
|
||||
|
||||
/* Text Colors */
|
||||
--text-color: #111827;
|
||||
--text-color-light: #6b7280;
|
||||
--text-color-lighter: #9ca3af;
|
||||
|
||||
/* Background Colors */
|
||||
--background-color: #ffffff;
|
||||
--header-background: #f9fafb;
|
||||
--hover-color: #f3f4f6;
|
||||
|
||||
/* Border Colors */
|
||||
--border-color: #e5e7eb;
|
||||
|
||||
/* 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;
|
||||
|
||||
/* 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 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;
|
||||
|
||||
/* 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;
|
||||
|
||||
/* 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;
|
||||
}
|
0
panel/styles/Loading.module.css
Normal file
0
panel/styles/Loading.module.css
Normal file
78
panel/styles/Login.module.css
Normal file
78
panel/styles/Login.module.css
Normal file
@@ -0,0 +1,78 @@
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 2rem;
|
||||
background-color: white;
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
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);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 480px) {
|
||||
.login-form {
|
||||
margin: 1rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.login-form h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
228
panel/styles/Modal.module.css
Normal file
228
panel/styles/Modal.module.css
Normal file
@@ -0,0 +1,228 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: white;
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
width: 100%;
|
||||
animation: modal-appear 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Modal Sizes */
|
||||
.modal-small {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.modal-medium {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.modal-large {
|
||||
max-width: 1200px;
|
||||
width: 95vw;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.modal-large .content {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@keyframes modal-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 640px) {
|
||||
.backdrop {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.modal-small,
|
||||
.modal-medium,
|
||||
.modal-large {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Адаптивность для больших модальных окон */
|
||||
@media (max-width: 768px) {
|
||||
.modal-large {
|
||||
width: 95vw;
|
||||
max-width: none;
|
||||
margin: 20px;
|
||||
min-height: auto;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.modal-large .content {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Role Modal Specific Styles */
|
||||
.roles-grid {
|
||||
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;
|
||||
}
|
||||
|
||||
.role-option:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.role-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.role-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-light);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Environment Variable Modal Specific Styles */
|
||||
.env-variable-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background-color: var(--disabled-bg);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-light);
|
||||
padding: 0.5rem;
|
||||
background-color: var(--hover-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Body Preview Modal Specific Styles */
|
||||
.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;
|
||||
}
|
114
panel/styles/Pagination.module.css
Normal file
114
panel/styles/Pagination.module.css
Normal file
@@ -0,0 +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);
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: var(--text-color-light);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pagination-ellipsis {
|
||||
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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.pageButton:hover:not(:disabled) {
|
||||
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;
|
||||
}
|
||||
|
||||
.currentPage {
|
||||
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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.perPageSelect:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.perPageSelect:focus {
|
||||
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;
|
||||
}
|
||||
|
||||
.pageButton {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.pagination-per-page {
|
||||
order: 3;
|
||||
}
|
||||
}
|
209
panel/styles/Table.module.css
Normal file
209
panel/styles/Table.module.css
Normal file
@@ -0,0 +1,209 @@
|
||||
.table-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
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);
|
||||
}
|
||||
|
||||
.table td {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.badge-container {
|
||||
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;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
background-color: var(--primary-color-light);
|
||||
color: var(--primary-color-dark);
|
||||
}
|
||||
|
||||
.author-badge {
|
||||
background-color: var(--success-color-light);
|
||||
color: var(--success-color-dark);
|
||||
}
|
||||
|
||||
.topic-badge {
|
||||
background-color: var(--info-color-light);
|
||||
color: var(--info-color-dark);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.table-empty {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.table-loading {
|
||||
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;
|
||||
}
|
||||
|
||||
@keyframes table-loading {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Базовые стили для таблицы и контейнера */
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.table tbody tr {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.table tbody tr:nth-child(even) {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
/* Стили для действий */
|
||||
.action-button {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* Стили для предупреждающих сообщений */
|
||||
.warning-text {
|
||||
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;
|
||||
}
|
||||
|
||||
.clickable-row:hover {
|
||||
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;
|
||||
}
|
||||
|
||||
.delete-button:hover {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.delete-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
72
panel/styles/Utilities.module.css
Normal file
72
panel/styles/Utilities.module.css
Normal file
@@ -0,0 +1,72 @@
|
||||
/* Utility classes for consistent styling */
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flexCol {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.itemsCenter {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justifyCenter {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justifyBetween {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.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; }
|
||||
|
||||
.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; }
|
||||
|
||||
.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); }
|
4
panel/types/css.d.ts
vendored
Normal file
4
panel/types/css.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const styles: { [key: string]: string }
|
||||
export default styles
|
||||
}
|
15
panel/types/svg.d.ts
vendored
Normal file
15
panel/types/svg.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
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?url' {
|
||||
const url: string
|
||||
export default url
|
||||
}
|
35
panel/ui/Button.tsx
Normal file
35
panel/ui/Button.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Component, JSX, splitProps } from 'solid-js'
|
||||
import styles from '../styles/Button.module.css'
|
||||
|
||||
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger'
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
loading?: boolean
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
const Button: Component<ButtonProps> = (props) => {
|
||||
const [local, rest] = splitProps(props, ['variant', 'size', 'loading', 'fullWidth', 'class', 'children'])
|
||||
|
||||
const classes = () => {
|
||||
const baseClass = styles.button
|
||||
const variantClass = styles[`button-${local.variant || 'primary'}`]
|
||||
const sizeClass = styles[`button-${local.size || 'medium'}`]
|
||||
const loadingClass = local.loading ? styles['button-loading'] : ''
|
||||
const fullWidthClass = local.fullWidth ? styles['button-full-width'] : ''
|
||||
const customClass = local.class || ''
|
||||
|
||||
return [baseClass, variantClass, sizeClass, loadingClass, fullWidthClass, customClass]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
return (
|
||||
<button {...rest} class={classes()} disabled={props.disabled || local.loading}>
|
||||
{local.loading && <span class={styles['loading-spinner']} />}
|
||||
{local.children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button
|
103
panel/ui/CodePreview.tsx
Normal file
103
panel/ui/CodePreview.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import Prism from 'prismjs'
|
||||
import { JSX } from 'solid-js'
|
||||
import 'prismjs/components/prism-json'
|
||||
import 'prismjs/components/prism-markup'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
|
||||
import styles from '../styles/CodePreview.module.css'
|
||||
|
||||
/**
|
||||
* Определяет язык контента (html или json)
|
||||
*/
|
||||
function detectLanguage(content: string): string {
|
||||
try {
|
||||
JSON.parse(content)
|
||||
return 'json'
|
||||
} catch {
|
||||
if (/<[^>]*>/g.test(content)) {
|
||||
return 'markup'
|
||||
}
|
||||
}
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует XML/HTML с отступами
|
||||
*/
|
||||
function prettyFormatXML(xml: string): string {
|
||||
let formatted = ''
|
||||
const reg = /(>)(<)(\/*)/g
|
||||
const res = xml.replace(reg, '$1\r\n$2$3')
|
||||
let pad = 0
|
||||
res.split('\r\n').forEach((node) => {
|
||||
let indent = 0
|
||||
if (node.match(/.+<\/\w[^>]*>$/)) {
|
||||
indent = 0
|
||||
} else if (node.match(/^<\//)) {
|
||||
if (pad !== 0) pad -= 2
|
||||
} else if (node.match(/^<\w([^>]*[^/])?>.*$/)) {
|
||||
indent = 2
|
||||
} else {
|
||||
indent = 0
|
||||
}
|
||||
formatted += `${' '.repeat(pad)}${node}\r\n`
|
||||
pad += indent
|
||||
})
|
||||
return formatted.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует и подсвечивает код
|
||||
*/
|
||||
function formatCode(content: string): string {
|
||||
const language = detectLanguage(content)
|
||||
|
||||
if (language === 'json') {
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(content), null, 2)
|
||||
return Prism.highlight(formatted, Prism.languages[language], language)
|
||||
} catch {
|
||||
return content
|
||||
}
|
||||
} else if (language === 'markup') {
|
||||
const formatted = prettyFormatXML(content)
|
||||
return Prism.highlight(formatted, Prism.languages[language], language)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
interface CodePreviewProps extends JSX.HTMLAttributes<HTMLPreElement> {
|
||||
content: string
|
||||
language?: string
|
||||
maxHeight?: string
|
||||
}
|
||||
|
||||
const CodePreview = (props: CodePreviewProps) => {
|
||||
const language = () => props.language || detectLanguage(props.content)
|
||||
// const formattedCode = () => formatCode(props.content)
|
||||
|
||||
const numberedCode = () => {
|
||||
const lines = props.content.split('\n')
|
||||
return lines
|
||||
.map((line, index) => `<span class="${styles.lineNumber}">${index + 1}</span>${line}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
{...props}
|
||||
class={`${styles.codePreview} ${props.class || ''}`}
|
||||
style={`max-height: ${props.maxHeight || '500px'}; overflow-y: auto; ${props.style || ''}`}
|
||||
>
|
||||
<code
|
||||
class={`language-${language()} ${styles.code}`}
|
||||
innerHTML={Prism.highlight(numberedCode(), Prism.languages[language()], language())}
|
||||
/>
|
||||
{props.language && <span class={styles.languageBadge}>{props.language}</span>}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
export default CodePreview
|
||||
export { detectLanguage, formatCode }
|
266
panel/ui/EditableCodePreview.tsx
Normal file
266
panel/ui/EditableCodePreview.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import Prism from 'prismjs'
|
||||
import { createEffect, createSignal, onMount } from 'solid-js'
|
||||
import 'prismjs/components/prism-json'
|
||||
import 'prismjs/components/prism-markup'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/components/prism-css'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
|
||||
import styles from '../styles/CodePreview.module.css'
|
||||
import { detectLanguage } from './CodePreview'
|
||||
|
||||
interface EditableCodePreviewProps {
|
||||
content: string
|
||||
onContentChange: (newContent: string) => void
|
||||
onSave?: (content: string) => void
|
||||
onCancel?: () => void
|
||||
language?: string
|
||||
maxHeight?: string
|
||||
placeholder?: string
|
||||
showButtons?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Редактируемый компонент для кода с подсветкой синтаксиса
|
||||
*/
|
||||
const EditableCodePreview = (props: EditableCodePreviewProps) => {
|
||||
const [isEditing, setIsEditing] = createSignal(false)
|
||||
const [content, setContent] = createSignal(props.content)
|
||||
let editorRef: HTMLDivElement | undefined
|
||||
let highlightRef: HTMLPreElement | undefined
|
||||
|
||||
const language = () => props.language || detectLanguage(content())
|
||||
|
||||
/**
|
||||
* Обновляет подсветку синтаксиса
|
||||
*/
|
||||
const updateHighlight = () => {
|
||||
if (!highlightRef) return
|
||||
|
||||
const code = content() || ''
|
||||
const lang = language()
|
||||
|
||||
try {
|
||||
if (Prism.languages[lang]) {
|
||||
highlightRef.innerHTML = Prism.highlight(code, Prism.languages[lang], lang)
|
||||
} else {
|
||||
highlightRef.textContent = code
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error highlighting code:', e)
|
||||
highlightRef.textContent = code
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизирует скролл между редактором и подсветкой
|
||||
*/
|
||||
const syncScroll = () => {
|
||||
if (editorRef && highlightRef) {
|
||||
highlightRef.scrollTop = editorRef.scrollTop
|
||||
highlightRef.scrollLeft = editorRef.scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик изменения контента
|
||||
*/
|
||||
const handleInput = (e: Event) => {
|
||||
const target = e.target as HTMLDivElement
|
||||
const newContent = target.textContent || ''
|
||||
setContent(newContent)
|
||||
props.onContentChange(newContent)
|
||||
updateHighlight()
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик сохранения
|
||||
*/
|
||||
const handleSave = () => {
|
||||
if (props.onSave) {
|
||||
props.onSave(content())
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик отмены
|
||||
*/
|
||||
const handleCancel = () => {
|
||||
setContent(props.content) // Возвращаем исходный контент
|
||||
if (props.onCancel) {
|
||||
props.onCancel()
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик клавиш
|
||||
*/
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ctrl+Enter или Cmd+Enter для сохранения
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
return
|
||||
}
|
||||
|
||||
// Escape для отмены
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
handleCancel()
|
||||
return
|
||||
}
|
||||
|
||||
// Tab для отступа
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
// const target = e.target as HTMLDivElement
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0)
|
||||
range.deleteContents()
|
||||
range.insertNode(document.createTextNode(' ')) // Два пробела
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
handleInput(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Эффект для обновления контента при изменении props
|
||||
createEffect(() => {
|
||||
if (!isEditing()) {
|
||||
setContent(props.content)
|
||||
updateHighlight()
|
||||
}
|
||||
})
|
||||
|
||||
// Эффект для обновления подсветки при изменении контента
|
||||
createEffect(() => {
|
||||
content() // Реактивность
|
||||
updateHighlight()
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
updateHighlight()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class={styles.editableCodeContainer}>
|
||||
{/* Кнопки управления */}
|
||||
{props.showButtons !== false && (
|
||||
<div class={styles.editorControls}>
|
||||
{!isEditing() ? (
|
||||
<button class={styles.editButton} onClick={() => setIsEditing(true)}>
|
||||
✏️ Редактировать
|
||||
</button>
|
||||
) : (
|
||||
<div class={styles.editingControls}>
|
||||
<button class={styles.saveButton} onClick={handleSave}>
|
||||
💾 Сохранить (Ctrl+Enter)
|
||||
</button>
|
||||
<button class={styles.cancelButton} onClick={handleCancel}>
|
||||
❌ Отмена (Esc)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Контейнер редактора */}
|
||||
<div
|
||||
class={styles.editorWrapper}
|
||||
style={`max-height: ${props.maxHeight || '70vh'}; ${isEditing() ? 'border: 2px solid #007acc;' : ''}`}
|
||||
>
|
||||
{/* Подсветка синтаксиса (фон) */}
|
||||
<pre
|
||||
ref={highlightRef}
|
||||
class={`${styles.syntaxHighlight} language-${language()}`}
|
||||
style="position: absolute; top: 0; left: 0; pointer-events: none; color: transparent; background: transparent; margin: 0; padding: 12px; font-family: 'Fira Code', monospace; font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; overflow: hidden;"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Редактируемая область */}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable={isEditing()}
|
||||
class={styles.editorArea}
|
||||
style={`
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: ${isEditing() ? 'rgba(0, 0, 0, 0.05)' : 'transparent'};
|
||||
color: ${isEditing() ? 'rgba(255, 255, 255, 0.9)' : 'transparent'};
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-y: auto;
|
||||
outline: none;
|
||||
cursor: ${isEditing() ? 'text' : 'default'};
|
||||
caret-color: ${isEditing() ? '#fff' : 'transparent'};
|
||||
`}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={syncScroll}
|
||||
spellcheck={false}
|
||||
>
|
||||
{content()}
|
||||
</div>
|
||||
|
||||
{/* Превью для неактивного режима */}
|
||||
{!isEditing() && (
|
||||
<pre
|
||||
class={`${styles.codePreview} language-${language()}`}
|
||||
style={`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
`}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<code
|
||||
class={`language-${language()}`}
|
||||
innerHTML={(() => {
|
||||
try {
|
||||
return Prism.highlight(content(), Prism.languages[language()], language())
|
||||
} catch {
|
||||
return content()
|
||||
}
|
||||
})()}
|
||||
/>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Плейсхолдер */}
|
||||
{!content() && (
|
||||
<div
|
||||
class={styles.placeholder}
|
||||
onClick={() => setIsEditing(true)}
|
||||
style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #666; cursor: pointer; font-style: italic;"
|
||||
>
|
||||
{props.placeholder || 'Нажмите для редактирования...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Индикатор языка */}
|
||||
<span class={styles.languageBadge}>{language()}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditableCodePreview
|
48
panel/ui/Modal.tsx
Normal file
48
panel/ui/Modal.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Component, JSX, Show } from 'solid-js'
|
||||
import styles from '../styles/Modal.module.css'
|
||||
|
||||
export interface ModalProps {
|
||||
title: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
children: JSX.Element
|
||||
footer?: JSX.Element
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
const Modal: Component<ModalProps> = (props) => {
|
||||
const handleBackdropClick = (e: MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const modalClasses = () => {
|
||||
const baseClass = styles.modal
|
||||
const sizeClass = styles[`modal-${props.size || 'medium'}`]
|
||||
return [baseClass, sizeClass].join(' ')
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.isOpen}>
|
||||
<div class={styles.backdrop} onClick={handleBackdropClick}>
|
||||
<div class={modalClasses()}>
|
||||
<div class={styles.header}>
|
||||
<h2 class={styles.title}>{props.title}</h2>
|
||||
<button class={styles.close} onClick={props.onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class={styles.content}>{props.children}</div>
|
||||
|
||||
<Show when={props.footer}>
|
||||
<div class={styles.footer}>{props.footer}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default Modal
|
117
panel/ui/Pagination.tsx
Normal file
117
panel/ui/Pagination.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { For } from 'solid-js'
|
||||
import styles from '../styles/Pagination.module.css'
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
total: number
|
||||
limit: number
|
||||
onPageChange: (page: number) => void
|
||||
onPerPageChange?: (limit: number) => void
|
||||
perPageOptions?: number[]
|
||||
}
|
||||
|
||||
const Pagination = (props: PaginationProps) => {
|
||||
const perPageOptions = props.perPageOptions || [10, 20, 50, 100]
|
||||
|
||||
// Генерируем массив страниц для отображения
|
||||
const pages = () => {
|
||||
const result: (number | string)[] = []
|
||||
const maxVisiblePages = 5 // Максимальное количество видимых страниц
|
||||
|
||||
// Всегда показываем первую страницу
|
||||
result.push(1)
|
||||
|
||||
// Вычисляем диапазон страниц вокруг текущей
|
||||
let startPage = Math.max(2, props.currentPage - Math.floor(maxVisiblePages / 2))
|
||||
const endPage = Math.min(props.totalPages - 1, startPage + maxVisiblePages - 2)
|
||||
|
||||
// Корректируем диапазон, если он выходит за границы
|
||||
if (endPage - startPage < maxVisiblePages - 2) {
|
||||
startPage = Math.max(2, endPage - maxVisiblePages + 2)
|
||||
}
|
||||
|
||||
// Добавляем многоточие после первой страницы, если нужно
|
||||
if (startPage > 2) {
|
||||
result.push('...')
|
||||
}
|
||||
|
||||
// Добавляем страницы из диапазона
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
result.push(i)
|
||||
}
|
||||
|
||||
// Добавляем многоточие перед последней страницей, если нужно
|
||||
if (endPage < props.totalPages - 1) {
|
||||
result.push('...')
|
||||
}
|
||||
|
||||
// Всегда показываем последнюю страницу, если есть больше одной страницы
|
||||
if (props.totalPages > 1) {
|
||||
result.push(props.totalPages)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const startIndex = () => (props.currentPage - 1) * props.limit + 1
|
||||
const endIndex = () => Math.min(props.currentPage * props.limit, props.total)
|
||||
|
||||
return (
|
||||
<div class={styles.pagination}>
|
||||
<div class={styles['pagination-info']}>
|
||||
Показано {startIndex()} - {endIndex()} из {props.total}
|
||||
</div>
|
||||
|
||||
<div class={styles['pagination-controls']}>
|
||||
<button
|
||||
class={styles.pageButton}
|
||||
onClick={() => props.onPageChange(props.currentPage - 1)}
|
||||
disabled={props.currentPage === 1}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
|
||||
<For each={pages()}>
|
||||
{(page) => (
|
||||
<>
|
||||
{page === '...' ? (
|
||||
<span class={styles['pagination-ellipsis']}>...</span>
|
||||
) : (
|
||||
<button
|
||||
class={`${styles.pageButton} ${page === props.currentPage ? styles.currentPage : ''}`}
|
||||
onClick={() => props.onPageChange(Number(page))}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<button
|
||||
class={styles.pageButton}
|
||||
onClick={() => props.onPageChange(props.currentPage + 1)}
|
||||
disabled={props.currentPage === props.totalPages}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{props.onPerPageChange && (
|
||||
<div class={styles['pagination-per-page']}>
|
||||
На странице:
|
||||
<select
|
||||
class={styles.perPageSelect}
|
||||
value={props.limit}
|
||||
onChange={(e) => props.onPerPageChange!(Number(e.target.value))}
|
||||
>
|
||||
<For each={perPageOptions}>{(option) => <option value={option}>{option}</option>}</For>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Pagination
|
64
panel/ui/TextPreview.tsx
Normal file
64
panel/ui/TextPreview.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { JSX } from 'solid-js'
|
||||
import styles from '../styles/CodePreview.module.css'
|
||||
|
||||
/**
|
||||
* Компонент для простого просмотра текста без подсветки syntax
|
||||
* Убирает HTML теги и показывает чистый текст
|
||||
*/
|
||||
|
||||
interface TextPreviewProps extends JSX.HTMLAttributes<HTMLPreElement> {
|
||||
content: string
|
||||
maxHeight?: string
|
||||
showLineNumbers?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает HTML теги и декодирует HTML entity
|
||||
*/
|
||||
function stripHtmlTags(text: string): string {
|
||||
// Убираем HTML теги
|
||||
let cleaned = text.replace(/<[^>]*>/g, '')
|
||||
|
||||
// Декодируем базовые HTML entity
|
||||
cleaned = cleaned
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
|
||||
return cleaned.trim()
|
||||
}
|
||||
|
||||
const TextPreview = (props: TextPreviewProps) => {
|
||||
const cleanedContent = () => stripHtmlTags(props.content)
|
||||
|
||||
const contentWithLines = () => {
|
||||
if (!props.showLineNumbers) return cleanedContent()
|
||||
|
||||
const lines = cleanedContent().split('\n')
|
||||
return lines.map((line, index) => `${(index + 1).toString().padStart(3, ' ')} | ${line}`).join('\n')
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
{...props}
|
||||
class={`${styles.codePreview} ${props.class || ''}`}
|
||||
style={`
|
||||
max-height: ${props.maxHeight || '60vh'};
|
||||
overflow-y: auto;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
${props.style || ''}
|
||||
`}
|
||||
>
|
||||
<code class={styles.code}>{contentWithLines()}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextPreview
|
99
panel/utils/auth.ts
Normal file
99
panel/utils/auth.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Утилиты для работы с токенами авторизации
|
||||
* @module auth-utils
|
||||
*/
|
||||
|
||||
// Экспортируем константы для использования в других модулях
|
||||
export const AUTH_TOKEN_KEY = 'auth_token'
|
||||
export const CSRF_TOKEN_KEY = 'csrf_token'
|
||||
|
||||
/**
|
||||
* Получает токен авторизации из cookie
|
||||
* @returns Токен или пустую строку, если токен не найден
|
||||
*/
|
||||
export function getAuthTokenFromCookie(): string {
|
||||
console.log('[Auth] Checking auth token in cookies...')
|
||||
const cookieItems = document.cookie.split(';')
|
||||
for (const item of cookieItems) {
|
||||
const [name, value] = item.trim().split('=')
|
||||
if (name === AUTH_TOKEN_KEY) {
|
||||
console.log('[Auth] Found auth token in cookies')
|
||||
return value
|
||||
}
|
||||
}
|
||||
console.log('[Auth] No auth token found in cookies')
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает CSRF-токен из cookie
|
||||
* @returns CSRF-токен или пустую строку, если токен не найден
|
||||
*/
|
||||
export function getCsrfTokenFromCookie(): string {
|
||||
console.log('[Auth] Checking CSRF token in cookies...')
|
||||
const cookieItems = document.cookie.split(';')
|
||||
for (const item of cookieItems) {
|
||||
const [name, value] = item.trim().split('=')
|
||||
if (name === CSRF_TOKEN_KEY) {
|
||||
console.log('[Auth] Found CSRF token in cookies')
|
||||
return value
|
||||
}
|
||||
}
|
||||
console.log('[Auth] No CSRF token found in cookies')
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает все токены авторизации
|
||||
*/
|
||||
export function clearAuthTokens(): void {
|
||||
console.log('[Auth] Clearing all auth tokens...')
|
||||
// Очищаем токен из localStorage
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||
|
||||
// Для удаления cookie устанавливаем ей истекшее время жизни
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: Требуется для кроссбраузерной совместимости
|
||||
document.cookie = `${AUTH_TOKEN_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: Требуется для кроссбраузерной совместимости
|
||||
document.cookie = `${CSRF_TOKEN_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
|
||||
console.log('[Auth] Auth tokens cleared')
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет токен авторизации
|
||||
* @param token - Токен для сохранения
|
||||
*/
|
||||
export function saveAuthToken(token: string): void {
|
||||
console.log('[Auth] Attempting to save auth token...')
|
||||
if (!token) {
|
||||
console.log('[Auth] No token provided, skipping save')
|
||||
return
|
||||
}
|
||||
|
||||
// Всегда сохраняем токен в localStorage для надежности
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, token)
|
||||
console.log('[Auth] Token saved to localStorage')
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, авторизован ли пользователь
|
||||
* @returns Статус авторизации
|
||||
*/
|
||||
export function checkAuthStatus(): boolean {
|
||||
console.log('[Auth] Checking authentication status...')
|
||||
|
||||
// Проверяем наличие cookie auth_token
|
||||
const cookieToken = getAuthTokenFromCookie()
|
||||
const hasCookie = !!cookieToken && cookieToken.length > 10
|
||||
|
||||
// Проверяем наличие токена в localStorage
|
||||
const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
|
||||
const hasLocalToken = !!localToken && localToken.length > 10
|
||||
|
||||
const isAuth = hasCookie || hasLocalToken
|
||||
console.log(`[Auth] Cookie token: ${hasCookie ? 'present' : 'missing'}`)
|
||||
console.log(`[Auth] Local token: ${hasLocalToken ? 'present' : 'missing'}`)
|
||||
console.log(`[Auth] Authentication status: ${isAuth ? 'authenticated' : 'not authenticated'}`)
|
||||
|
||||
return isAuth
|
||||
}
|
104
panel/utils/date.ts
Normal file
104
panel/utils/date.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Форматирование даты в формате "X дней назад"
|
||||
* @param timestamp - Временная метка
|
||||
* @returns Форматированная строка с относительной датой
|
||||
*/
|
||||
export function formatDateRelative(timestamp?: number): string {
|
||||
if (!timestamp) return 'Н/Д'
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const diff = now - timestamp
|
||||
|
||||
// Меньше минуты
|
||||
if (diff < 60) {
|
||||
return 'только что'
|
||||
}
|
||||
|
||||
// Меньше часа
|
||||
if (diff < 3600) {
|
||||
const minutes = Math.floor(diff / 60)
|
||||
return `${minutes} ${getMinutesForm(minutes)} назад`
|
||||
}
|
||||
|
||||
// Меньше суток
|
||||
if (diff < 86400) {
|
||||
const hours = Math.floor(diff / 3600)
|
||||
return `${hours} ${getHoursForm(hours)} назад`
|
||||
}
|
||||
|
||||
// Меньше 30 дней
|
||||
if (diff < 2592000) {
|
||||
const days = Math.floor(diff / 86400)
|
||||
return `${days} ${getDaysForm(days)} назад`
|
||||
}
|
||||
|
||||
// Меньше года
|
||||
if (diff < 31536000) {
|
||||
const months = Math.floor(diff / 2592000)
|
||||
return `${months} ${getMonthsForm(months)} назад`
|
||||
}
|
||||
|
||||
// Больше года
|
||||
const years = Math.floor(diff / 31536000)
|
||||
return `${years} ${getYearsForm(years)} назад`
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение правильной формы слова "минута" в зависимости от числа
|
||||
*/
|
||||
function getMinutesForm(minutes: number): string {
|
||||
if (minutes % 10 === 1 && minutes % 100 !== 11) {
|
||||
return 'минуту'
|
||||
} else if ([2, 3, 4].includes(minutes % 10) && ![12, 13, 14].includes(minutes % 100)) {
|
||||
return 'минуты'
|
||||
}
|
||||
return 'минут'
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение правильной формы слова "час" в зависимости от числа
|
||||
*/
|
||||
function getHoursForm(hours: number): string {
|
||||
if (hours % 10 === 1 && hours % 100 !== 11) {
|
||||
return 'час'
|
||||
} else if ([2, 3, 4].includes(hours % 10) && ![12, 13, 14].includes(hours % 100)) {
|
||||
return 'часа'
|
||||
}
|
||||
return 'часов'
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение правильной формы слова "день" в зависимости от числа
|
||||
*/
|
||||
function getDaysForm(days: number): string {
|
||||
if (days % 10 === 1 && days % 100 !== 11) {
|
||||
return 'день'
|
||||
} else if ([2, 3, 4].includes(days % 10) && ![12, 13, 14].includes(days % 100)) {
|
||||
return 'дня'
|
||||
}
|
||||
return 'дней'
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение правильной формы слова "месяц" в зависимости от числа
|
||||
*/
|
||||
function getMonthsForm(months: number): string {
|
||||
if (months % 10 === 1 && months % 100 !== 11) {
|
||||
return 'месяц'
|
||||
} else if ([2, 3, 4].includes(months % 10) && ![12, 13, 14].includes(months % 100)) {
|
||||
return 'месяца'
|
||||
}
|
||||
return 'месяцев'
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение правильной формы слова "год" в зависимости от числа
|
||||
*/
|
||||
function getYearsForm(years: number): string {
|
||||
if (years % 10 === 1 && years % 100 !== 11) {
|
||||
return 'год'
|
||||
} else if ([2, 3, 4].includes(years % 10) && ![12, 13, 14].includes(years % 100)) {
|
||||
return 'года'
|
||||
}
|
||||
return 'лет'
|
||||
}
|
Reference in New Issue
Block a user