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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

View File

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

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

View File

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

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

View 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

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

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

View File

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

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

View 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);
}
}

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

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

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

View File

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

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

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

View 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);
}

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

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

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

@@ -0,0 +1,103 @@
import Prism from 'prismjs'
import { JSX } from 'solid-js'
import 'prismjs/components/prism-json'
import 'prismjs/components/prism-markup'
import 'prismjs/themes/prism-tomorrow.css'
import styles from '../styles/CodePreview.module.css'
/**
* Определяет язык контента (html или json)
*/
function detectLanguage(content: string): string {
try {
JSON.parse(content)
return 'json'
} catch {
if (/<[^>]*>/g.test(content)) {
return 'markup'
}
}
return 'plaintext'
}
/**
* Форматирует XML/HTML с отступами
*/
function prettyFormatXML(xml: string): string {
let formatted = ''
const reg = /(>)(<)(\/*)/g
const res = xml.replace(reg, '$1\r\n$2$3')
let pad = 0
res.split('\r\n').forEach((node) => {
let indent = 0
if (node.match(/.+<\/\w[^>]*>$/)) {
indent = 0
} else if (node.match(/^<\//)) {
if (pad !== 0) pad -= 2
} else if (node.match(/^<\w([^>]*[^/])?>.*$/)) {
indent = 2
} else {
indent = 0
}
formatted += `${' '.repeat(pad)}${node}\r\n`
pad += indent
})
return formatted.trim()
}
/**
* Форматирует и подсвечивает код
*/
function formatCode(content: string): string {
const language = detectLanguage(content)
if (language === 'json') {
try {
const formatted = JSON.stringify(JSON.parse(content), null, 2)
return Prism.highlight(formatted, Prism.languages[language], language)
} catch {
return content
}
} else if (language === 'markup') {
const formatted = prettyFormatXML(content)
return Prism.highlight(formatted, Prism.languages[language], language)
}
return content
}
interface CodePreviewProps extends JSX.HTMLAttributes<HTMLPreElement> {
content: string
language?: string
maxHeight?: string
}
const CodePreview = (props: CodePreviewProps) => {
const language = () => props.language || detectLanguage(props.content)
// const formattedCode = () => formatCode(props.content)
const numberedCode = () => {
const lines = props.content.split('\n')
return lines
.map((line, index) => `<span class="${styles.lineNumber}">${index + 1}</span>${line}`)
.join('\n')
}
return (
<pre
{...props}
class={`${styles.codePreview} ${props.class || ''}`}
style={`max-height: ${props.maxHeight || '500px'}; overflow-y: auto; ${props.style || ''}`}
>
<code
class={`language-${language()} ${styles.code}`}
innerHTML={Prism.highlight(numberedCode(), Prism.languages[language()], language())}
/>
{props.language && <span class={styles.languageBadge}>{props.language}</span>}
</pre>
)
}
export default CodePreview
export { detectLanguage, formatCode }

View File

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

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

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

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

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

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

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

99
panel/utils/auth.ts Normal file
View 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
View 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 'лет'
}