upgrade schema, resolvers, panel added
This commit is contained in:
111
panel/App.tsx
Normal file
111
panel/App.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Route, Router, RouteSectionProps } from '@solidjs/router'
|
||||
import { Component, Suspense, lazy } from 'solid-js'
|
||||
import { isAuthenticated } from './auth'
|
||||
|
||||
// Ленивая загрузка компонентов
|
||||
const LoginPage = lazy(() => import('./login'))
|
||||
const AdminPage = lazy(() => import('./admin'))
|
||||
|
||||
/**
|
||||
* Компонент корневого шаблона приложения
|
||||
* @param props - Свойства маршрута, включающие дочерние элементы
|
||||
*/
|
||||
const RootLayout: Component<RouteSectionProps> = (props) => {
|
||||
return (
|
||||
<div class="app-container">
|
||||
{/* Здесь может быть общий хедер, футер или другие элементы */}
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент защиты маршрутов
|
||||
* Проверяет авторизацию и либо показывает дочерние элементы,
|
||||
* либо перенаправляет на страницу входа
|
||||
*/
|
||||
const RequireAuth: Component<RouteSectionProps> = (props) => {
|
||||
const authed = isAuthenticated()
|
||||
|
||||
if (!authed) {
|
||||
// Если не авторизован, перенаправляем на /login
|
||||
window.location.href = '/login'
|
||||
return (
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner"></div>
|
||||
<h2>Перенаправление на страницу входа...</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{props.children}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент для публичных маршрутов с редиректом,
|
||||
* если пользователь уже авторизован
|
||||
*/
|
||||
const PublicOnlyRoute: Component<RouteSectionProps> = (props) => {
|
||||
// Если пользователь авторизован, перенаправляем на админ-панель
|
||||
if (isAuthenticated()) {
|
||||
window.location.href = '/admin'
|
||||
return (
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner"></div>
|
||||
<h2>Перенаправление в админ-панель...</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{props.children}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент перенаправления с корневого маршрута
|
||||
*/
|
||||
const RootRedirect: Component = () => {
|
||||
const authenticated = isAuthenticated()
|
||||
|
||||
// Выполняем перенаправление сразу после рендеринга
|
||||
setTimeout(() => {
|
||||
window.location.href = authenticated ? '/admin' : '/login'
|
||||
}, 100)
|
||||
|
||||
return (
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner"></div>
|
||||
<h2>Перенаправление...</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Корневой компонент приложения с настроенными маршрутами
|
||||
*/
|
||||
const App: Component = () => {
|
||||
return (
|
||||
<Router root={RootLayout}>
|
||||
<Suspense fallback={
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner"></div>
|
||||
<h2>Загрузка...</h2>
|
||||
</div>
|
||||
}>
|
||||
{/* Корневой маршрут с перенаправлением */}
|
||||
<Route path="/" component={RootRedirect} />
|
||||
|
||||
{/* Маршрут логина (только для неавторизованных) */}
|
||||
<Route path="/login" component={PublicOnlyRoute}>
|
||||
<Route path="/" component={LoginPage} />
|
||||
</Route>
|
||||
|
||||
{/* Защищенные маршруты (только для авторизованных) */}
|
||||
<Route path="/admin" component={RequireAuth}>
|
||||
<Route path="/*" component={AdminPage} />
|
||||
</Route>
|
||||
</Suspense>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
676
panel/admin.tsx
Normal file
676
panel/admin.tsx
Normal file
@@ -0,0 +1,676 @@
|
||||
/**
|
||||
* Компонент страницы администратора
|
||||
* @module AdminPage
|
||||
*/
|
||||
|
||||
import { useNavigate } from '@solidjs/router'
|
||||
import { Component, For, Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js'
|
||||
import { query } from './graphql'
|
||||
import { isAuthenticated, logout } from './auth'
|
||||
|
||||
/**
|
||||
* Интерфейс для данных пользователя
|
||||
*/
|
||||
interface User {
|
||||
id: number
|
||||
email: string
|
||||
name?: string
|
||||
slug?: string
|
||||
roles: string[]
|
||||
created_at?: number
|
||||
last_seen?: number
|
||||
muted: boolean
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для роли пользователя
|
||||
*/
|
||||
interface Role {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для ответа API с пользователями
|
||||
*/
|
||||
interface AdminGetUsersResponse {
|
||||
adminGetUsers: {
|
||||
users: User[]
|
||||
total: number
|
||||
page: number
|
||||
perPage: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для ответа API с ролями
|
||||
*/
|
||||
interface AdminGetRolesResponse {
|
||||
adminGetRoles: Role[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент страницы администратора
|
||||
*/
|
||||
const AdminPage: Component = () => {
|
||||
const [activeTab, setActiveTab] = createSignal('users')
|
||||
const [users, setUsers] = createSignal<User[]>([])
|
||||
const [roles, setRoles] = createSignal<Role[]>([])
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [selectedUser, setSelectedUser] = createSignal<User | null>(null)
|
||||
const [showRolesModal, setShowRolesModal] = createSignal(false)
|
||||
const [successMessage, setSuccessMessage] = createSignal<string | null>(null)
|
||||
|
||||
// Параметры пагинации
|
||||
const [pagination, setPagination] = createSignal<{
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}>({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0,
|
||||
totalPages: 1
|
||||
})
|
||||
|
||||
// Поиск
|
||||
const [searchQuery, setSearchQuery] = createSignal('')
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Периодическая проверка авторизации
|
||||
onMount(() => {
|
||||
// Загружаем данные при монтировании
|
||||
loadUsers()
|
||||
loadRoles()
|
||||
})
|
||||
|
||||
/**
|
||||
* Загрузка списка пользователей с учетом пагинации и поиска
|
||||
*/
|
||||
async function loadUsers() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { page, limit } = pagination()
|
||||
const offset = (page - 1) * limit
|
||||
const search = searchQuery().trim()
|
||||
|
||||
const data = await query<AdminGetUsersResponse>(
|
||||
`
|
||||
query AdminGetUsers($limit: Int, $offset: Int, $search: String) {
|
||||
adminGetUsers(limit: $limit, offset: $offset, search: $search) {
|
||||
users {
|
||||
id
|
||||
email
|
||||
name
|
||||
slug
|
||||
roles
|
||||
created_at
|
||||
last_seen
|
||||
muted
|
||||
is_active
|
||||
}
|
||||
total
|
||||
page
|
||||
perPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ limit, offset, search: search || null }
|
||||
)
|
||||
|
||||
if (data?.adminGetUsers) {
|
||||
setUsers(data.adminGetUsers.users)
|
||||
setPagination({
|
||||
page: data.adminGetUsers.page,
|
||||
limit: data.adminGetUsers.perPage,
|
||||
total: data.adminGetUsers.total,
|
||||
totalPages: data.adminGetUsers.totalPages
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки пользователей:', err)
|
||||
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
|
||||
|
||||
// Если ошибка авторизации - перенаправляем на логин
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message.includes('401') ||
|
||||
err.message.includes('авторизации') ||
|
||||
err.message.includes('unauthorized') ||
|
||||
err.message.includes('Unauthorized'))
|
||||
) {
|
||||
handleLogout()
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка списка ролей
|
||||
*/
|
||||
async function loadRoles() {
|
||||
try {
|
||||
const data = await query<AdminGetRolesResponse>(`
|
||||
query AdminGetRoles {
|
||||
adminGetRoles {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
if (data?.adminGetRoles) {
|
||||
setRoles(data.adminGetRoles)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки ролей:', err)
|
||||
// Если ошибка авторизации - перенаправляем на логин
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message.includes('401') ||
|
||||
err.message.includes('авторизации') ||
|
||||
err.message.includes('unauthorized') ||
|
||||
err.message.includes('Unauthorized'))
|
||||
) {
|
||||
handleLogout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик изменения страницы
|
||||
* @param page - Номер страницы
|
||||
*/
|
||||
function handlePageChange(page: number) {
|
||||
if (page < 1 || page > pagination().totalPages) return
|
||||
setPagination((prev) => ({ ...prev, page }))
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик изменения количества записей на странице
|
||||
* @param limit - Количество записей на странице
|
||||
*/
|
||||
function handlePerPageChange(limit: number) {
|
||||
setPagination((prev) => ({ ...prev, page: 1, limit }))
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик изменения поискового запроса
|
||||
* @param e - Событие изменения ввода
|
||||
*/
|
||||
function handleSearchChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
setSearchQuery(target.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет поиск при нажатии Enter или кнопки поиска
|
||||
*/
|
||||
function handleSearch() {
|
||||
setPagination((prev) => ({ ...prev, page: 1 })) // Сбрасываем на первую страницу при поиске
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик нажатия клавиши в поле поиска
|
||||
* @param e - Событие нажатия клавиши
|
||||
*/
|
||||
function handleSearchKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSearch()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Блокировка/разблокировка пользователя
|
||||
* @param userId - ID пользователя
|
||||
* @param isActive - Текущий статус активности
|
||||
*/
|
||||
async function toggleUserBlock(userId: number, isActive: boolean) {
|
||||
// Запрашиваем подтверждение
|
||||
const action = isActive ? 'заблокировать' : 'разблокировать'
|
||||
if (!confirm(`Вы действительно хотите ${action} этого пользователя?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await query(
|
||||
`
|
||||
mutation AdminToggleUserBlock($userId: Int!) {
|
||||
adminToggleUserBlock(userId: $userId) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ userId }
|
||||
)
|
||||
|
||||
// Обновляем статус пользователя
|
||||
setUsers((prev) =>
|
||||
prev.map((user) => {
|
||||
if (user.id === userId) {
|
||||
return { ...user, is_active: !isActive }
|
||||
}
|
||||
return user
|
||||
})
|
||||
)
|
||||
|
||||
// Показываем сообщение об успехе
|
||||
setSuccessMessage(`Пользователь успешно ${isActive ? 'заблокирован' : 'разблокирован'}`)
|
||||
|
||||
// Скрываем сообщение через 3 секунды
|
||||
setTimeout(() => setSuccessMessage(null), 3000)
|
||||
} catch (err) {
|
||||
console.error('Ошибка изменения статуса блокировки:', err)
|
||||
setError(err instanceof Error ? err.message : 'Ошибка изменения статуса блокировки')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Включение/отключение режима "mute" для пользователя
|
||||
* @param userId - ID пользователя
|
||||
* @param isMuted - Текущий статус mute
|
||||
*/
|
||||
async function toggleUserMute(userId: number, isMuted: boolean) {
|
||||
// Запрашиваем подтверждение
|
||||
const action = isMuted ? 'включить звук' : 'отключить звук'
|
||||
if (!confirm(`Вы действительно хотите ${action} для этого пользователя?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await query(
|
||||
`
|
||||
mutation AdminToggleUserMute($userId: Int!) {
|
||||
adminToggleUserMute(userId: $userId) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ userId }
|
||||
)
|
||||
|
||||
// Обновляем статус пользователя
|
||||
setUsers((prev) =>
|
||||
prev.map((user) => {
|
||||
if (user.id === userId) {
|
||||
return { ...user, muted: !isMuted }
|
||||
}
|
||||
return user
|
||||
})
|
||||
)
|
||||
|
||||
// Показываем сообщение об успехе
|
||||
setSuccessMessage(`Звук для пользователя успешно ${isMuted ? 'включен' : 'отключен'}`)
|
||||
|
||||
// Скрываем сообщение через 3 секунды
|
||||
setTimeout(() => setSuccessMessage(null), 3000)
|
||||
} catch (err) {
|
||||
console.error('Ошибка изменения статуса mute:', err)
|
||||
setError(err instanceof Error ? err.message : 'Ошибка изменения статуса mute')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрывает модальное окно управления ролями
|
||||
*/
|
||||
function closeRolesModal() {
|
||||
setShowRolesModal(false)
|
||||
setSelectedUser(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет роли пользователя
|
||||
* @param userId - ID пользователя
|
||||
* @param roles - Новый список ролей
|
||||
*/
|
||||
async function updateUserRoles(userId: number, newRoles: string[]) {
|
||||
try {
|
||||
await query(
|
||||
`
|
||||
mutation AdminUpdateUser($userId: Int!, $input: AdminUserUpdateInput!) {
|
||||
adminUpdateUser(userId: $userId, input: $input) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
userId,
|
||||
input: { roles: newRoles }
|
||||
}
|
||||
)
|
||||
|
||||
// Обновляем роли пользователя в списке
|
||||
setUsers((prev) =>
|
||||
prev.map((user) => {
|
||||
if (user.id === userId) {
|
||||
return { ...user, roles: newRoles }
|
||||
}
|
||||
return user
|
||||
})
|
||||
)
|
||||
|
||||
// Закрываем модальное окно
|
||||
closeRolesModal()
|
||||
|
||||
// Показываем сообщение об успехе
|
||||
setSuccessMessage('Роли пользователя успешно обновлены')
|
||||
|
||||
// Скрываем сообщение через 3 секунды
|
||||
setTimeout(() => setSuccessMessage(null), 3000)
|
||||
} catch (err) {
|
||||
console.error('Ошибка обновления ролей:', err)
|
||||
setError(err instanceof Error ? err.message : 'Ошибка обновления ролей')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выход из системы
|
||||
*/
|
||||
function handleLogout() {
|
||||
// Сначала выполняем локальные действия по очистке данных
|
||||
setUsers([])
|
||||
setRoles([])
|
||||
|
||||
// Затем выполняем выход
|
||||
logout(() => {
|
||||
// Для гарантии перенаправления после выхода
|
||||
window.location.href = '/login'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирование даты
|
||||
* @param timestamp - Временная метка
|
||||
*/
|
||||
function formatDate(timestamp?: number): string {
|
||||
if (!timestamp) return 'Н/Д'
|
||||
return new Date(timestamp * 1000).toLocaleString('ru')
|
||||
}
|
||||
|
||||
/**
|
||||
* Формирует массив номеров страниц для отображения в пагинации
|
||||
* @returns Массив номеров страниц
|
||||
*/
|
||||
function getPageNumbers(): number[] {
|
||||
const result: number[] = []
|
||||
const maxVisible = 5 // Максимальное количество видимых номеров страниц
|
||||
|
||||
const paginationData = pagination()
|
||||
const currentPage = paginationData.page
|
||||
const totalPages = paginationData.totalPages
|
||||
|
||||
let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2))
|
||||
const endPage = Math.min(totalPages, startPage + maxVisible - 1)
|
||||
|
||||
// Если endPage достиг предела, сдвигаем startPage назад
|
||||
if (endPage - startPage + 1 < maxVisible && startPage > 1) {
|
||||
startPage = Math.max(1, endPage - maxVisible + 1)
|
||||
}
|
||||
|
||||
// Генерируем номера страниц
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
result.push(i)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент пагинации
|
||||
*/
|
||||
const Pagination: Component = () => {
|
||||
const paginationData = pagination()
|
||||
const currentPage = paginationData.page
|
||||
const total = paginationData.totalPages
|
||||
|
||||
return (
|
||||
<div class="pagination">
|
||||
<div class="pagination-info">
|
||||
Показано {users().length} из {paginationData.total} пользователей
|
||||
</div>
|
||||
<div class="pagination-controls">
|
||||
<button
|
||||
class="pagination-button"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
|
||||
<For each={getPageNumbers()}>
|
||||
{(page) =>
|
||||
typeof page === 'number' ? (
|
||||
<button
|
||||
class={`pagination-button ${page === currentPage ? 'active' : ''}`}
|
||||
onClick={() => handlePageChange(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
) : (
|
||||
<span class="pagination-ellipsis">{page}</span>
|
||||
)
|
||||
}
|
||||
</For>
|
||||
|
||||
<button
|
||||
class="pagination-button"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === total}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
<div class="pagination-per-page">
|
||||
<label>
|
||||
Записей на странице:
|
||||
<select
|
||||
value={paginationData.limit}
|
||||
onChange={(e) => handlePerPageChange(Number.parseInt(e.target.value))}
|
||||
>
|
||||
<option value={5}>5</option>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент модального окна для управления ролями
|
||||
*/
|
||||
const RolesModal: Component = () => {
|
||||
const user = selectedUser()
|
||||
const [selectedRoles, setSelectedRoles] = createSignal<string[]>(user ? [...user.roles] : [])
|
||||
|
||||
const toggleRole = (role: string) => {
|
||||
const current = selectedRoles()
|
||||
if (current.includes(role)) {
|
||||
setSelectedRoles(current.filter((r) => r !== role))
|
||||
} else {
|
||||
setSelectedRoles([...current, role])
|
||||
}
|
||||
}
|
||||
|
||||
const saveRoles = () => {
|
||||
if (user) {
|
||||
updateUserRoles(user.id, selectedRoles())
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<h2>Управление ролями пользователя</h2>
|
||||
<p>Пользователь: {user.email}</p>
|
||||
|
||||
<div class="roles-list">
|
||||
<For each={roles()}>
|
||||
{(role) => (
|
||||
<div class="role-item">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRoles().includes(role.name)}
|
||||
onChange={() => toggleRole(role.name)}
|
||||
/>
|
||||
{role.name}
|
||||
</label>
|
||||
<Show when={role.description}>
|
||||
<p class="role-description">{role.description}</p>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-button" onClick={closeRolesModal}>
|
||||
Отмена
|
||||
</button>
|
||||
<button class="save-button" onClick={saveRoles}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="admin-page">
|
||||
<header>
|
||||
<div class="header-container">
|
||||
<h1>Панель администратора</h1>
|
||||
<button class="logout-button" onClick={handleLogout}>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="admin-tabs">
|
||||
<button class={activeTab() === 'users' ? 'active' : ''} onClick={() => setActiveTab('users')}>
|
||||
Пользователи
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<Show when={error()}>
|
||||
<div class="error-message">{error()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={successMessage()}>
|
||||
<div class="success-message">{successMessage()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div class="loading">Загрузка данных...</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && users().length === 0 && !error()}>
|
||||
<div class="empty-state">Нет данных для отображения</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && users().length > 0}>
|
||||
<div class="users-controls">
|
||||
<div class="search-container">
|
||||
<div class="search-input-group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по email, имени или ID..."
|
||||
value={searchQuery()}
|
||||
onInput={handleSearchChange}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
class="search-input"
|
||||
/>
|
||||
<button class="search-button" onClick={handleSearch}>
|
||||
Поиск
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="users-list">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Email</th>
|
||||
<th>Имя</th>
|
||||
<th>Роли</th>
|
||||
<th>Создан</th>
|
||||
<th>Последний вход</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={users()}>
|
||||
{(user) => (
|
||||
<tr class={user.is_active ? '' : 'blocked'}>
|
||||
<td>{user.id}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>{user.name || '-'}</td>
|
||||
<td>{user.roles.join(', ') || '-'}</td>
|
||||
<td>{formatDate(user.created_at)}</td>
|
||||
<td>{formatDate(user.last_seen)}</td>
|
||||
<td>
|
||||
<span class={`status ${user.is_active ? 'active' : 'inactive'}`}>
|
||||
{user.is_active ? 'Активен' : 'Заблокирован'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class={user.is_active ? 'block' : 'unblock'}
|
||||
onClick={() => toggleUserBlock(user.id, user.is_active)}
|
||||
>
|
||||
{user.is_active ? 'Блокировать' : 'Разблокировать'}
|
||||
</button>
|
||||
<button
|
||||
class={user.muted ? 'unmute' : 'mute'}
|
||||
onClick={() => toggleUserMute(user.id, user.muted)}
|
||||
>
|
||||
{user.muted ? 'Unmute' : 'Mute'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination />
|
||||
</Show>
|
||||
</main>
|
||||
|
||||
<Show when={showRolesModal()}>
|
||||
<RolesModal />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminPage
|
143
panel/auth.ts
Normal file
143
panel/auth.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Модуль авторизации
|
||||
* @module auth
|
||||
*/
|
||||
|
||||
import { query } from './graphql'
|
||||
|
||||
/**
|
||||
* Интерфейс для учетных данных
|
||||
*/
|
||||
export interface Credentials {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для результата авторизации
|
||||
*/
|
||||
export interface LoginResult {
|
||||
success: boolean
|
||||
token?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для ответа API при логине
|
||||
*/
|
||||
interface LoginResponse {
|
||||
login: LoginResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Константа для имени ключа токена в localStorage
|
||||
*/
|
||||
const AUTH_TOKEN_KEY = 'auth_token'
|
||||
|
||||
/**
|
||||
* Константа для имени ключа токена в cookie
|
||||
*/
|
||||
const AUTH_COOKIE_NAME = 'auth_token'
|
||||
|
||||
/**
|
||||
* Получает токен авторизации из cookie
|
||||
* @returns Токен или пустую строку, если токен не найден
|
||||
*/
|
||||
function getAuthTokenFromCookie(): string {
|
||||
const cookieItems = document.cookie.split(';')
|
||||
for (const item of cookieItems) {
|
||||
const [name, value] = item.trim().split('=')
|
||||
if (name === AUTH_COOKIE_NAME) {
|
||||
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_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
|
||||
|
||||
// Дополнительно пытаемся сделать запрос на сервер для удаления серверных сессий
|
||||
try {
|
||||
fetch('/logout', {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
}).catch(e => {
|
||||
console.error('Ошибка при запросе на выход:', e)
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Ошибка при выходе:', e)
|
||||
}
|
||||
|
||||
// Вызываем функцию обратного вызова после очистки токенов
|
||||
if (callback) callback()
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет вход в систему
|
||||
* @param credentials - Учетные данные
|
||||
* @returns Результат авторизации
|
||||
*/
|
||||
export async function login(credentials: Credentials): Promise<boolean> {
|
||||
try {
|
||||
// Используем query из graphql.ts для выполнения запроса
|
||||
const data = await query<LoginResponse>(
|
||||
`
|
||||
mutation Login($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
success
|
||||
token
|
||||
error
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
email: credentials.email,
|
||||
password: credentials.password
|
||||
}
|
||||
)
|
||||
|
||||
if (data?.login?.success) {
|
||||
// Проверяем, установил ли сервер cookie
|
||||
const cookieToken = getAuthTokenFromCookie()
|
||||
const hasCookie = !!cookieToken && cookieToken.length > 10
|
||||
|
||||
// Если cookie не установлена, но есть токен в ответе, сохраняем его в localStorage
|
||||
if (!hasCookie && data.login.token) {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, data.login.token)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
throw new Error(data?.login?.error || 'Ошибка авторизации')
|
||||
} catch (error) {
|
||||
console.error('Ошибка при входе:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
189
panel/graphql.ts
Normal file
189
panel/graphql.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* API-клиент для работы с GraphQL
|
||||
* @module api
|
||||
*/
|
||||
|
||||
/**
|
||||
* Базовый URL для API
|
||||
*/
|
||||
// Всегда используем абсолютный путь к API
|
||||
const API_URL = window.location.origin + '/graphql'
|
||||
|
||||
/**
|
||||
* Константа для имени ключа токена в localStorage
|
||||
*/
|
||||
const AUTH_TOKEN_KEY = 'auth_token'
|
||||
|
||||
/**
|
||||
* Тип для произвольных данных GraphQL
|
||||
*/
|
||||
type GraphQLData = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Получает токен авторизации из cookie
|
||||
* @returns Токен или пустую строку, если токен не найден
|
||||
*/
|
||||
function getAuthTokenFromCookie(): string {
|
||||
const cookieItems = document.cookie.split(';')
|
||||
for (const item of cookieItems) {
|
||||
const [name, value] = item.trim().split('=')
|
||||
if (name === 'auth_token') {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает ошибки от 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'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет GraphQL запрос
|
||||
* @param query - GraphQL запрос
|
||||
* @param variables - Переменные запроса
|
||||
* @returns Результат запроса
|
||||
*/
|
||||
export async function query<T = GraphQLData>(
|
||||
query: string,
|
||||
variables: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': '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) {
|
||||
// В соответствии с логами сервера, формат должен быть: Bearer <token>
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
// Для отладки
|
||||
console.debug('Отправка запроса с токеном авторизации')
|
||||
}
|
||||
|
||||
const response = await fetch(API_URL, {
|
||||
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, перенаправляем на страницу входа
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||
window.location.href = '/login'
|
||||
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 = '/login'
|
||||
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 mutation - GraphQL мутация
|
||||
* @param variables - Переменные мутации
|
||||
* @returns Результат мутации
|
||||
*/
|
||||
export function mutate<T = GraphQLData>(
|
||||
mutation: string,
|
||||
variables: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
return query<T>(mutation, variables)
|
||||
}
|
12
panel/index.tsx
Normal file
12
panel/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Точка входа в клиентское приложение
|
||||
* @module index
|
||||
*/
|
||||
|
||||
import { render } from 'solid-js/web'
|
||||
import App from './App'
|
||||
|
||||
import './styles.css'
|
||||
|
||||
// Рендеринг приложения в корневой элемент
|
||||
render(() => <App />, document.getElementById('root') as HTMLElement)
|
112
panel/login.tsx
Normal file
112
panel/login.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Компонент страницы входа
|
||||
* @module LoginPage
|
||||
*/
|
||||
|
||||
import { useNavigate } from '@solidjs/router'
|
||||
import { Component, createSignal, onMount } from 'solid-js'
|
||||
import { login, isAuthenticated } from './auth'
|
||||
|
||||
/**
|
||||
* Компонент страницы входа
|
||||
*/
|
||||
const LoginPage: Component = () => {
|
||||
const [email, setEmail] = createSignal('')
|
||||
const [password, setPassword] = createSignal('')
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
/**
|
||||
* Проверка авторизации при загрузке компонента
|
||||
* и перенаправление если пользователь уже авторизован
|
||||
*/
|
||||
onMount(() => {
|
||||
// Если пользователь уже авторизован, перенаправляем на админ-панель
|
||||
if (isAuthenticated()) {
|
||||
window.location.href = '/admin'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Обработчик отправки формы входа
|
||||
* @param e - Событие отправки формы
|
||||
*/
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Очищаем пробелы в email
|
||||
const cleanEmail = email().trim()
|
||||
|
||||
if (!cleanEmail || !password()) {
|
||||
setError('Пожалуйста, заполните все поля')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Используем функцию login из модуля auth
|
||||
const loginSuccessful = await login({
|
||||
email: cleanEmail,
|
||||
password: password()
|
||||
})
|
||||
|
||||
if (loginSuccessful) {
|
||||
// Используем прямое перенаправление для надежности
|
||||
window.location.href = '/admin'
|
||||
} else {
|
||||
throw new Error('Вход не выполнен')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при входе:', err)
|
||||
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="login-page">
|
||||
<div class="login-container">
|
||||
<h1>Вход в систему</h1>
|
||||
|
||||
{error() && <div class="error-message">{error()}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
disabled={isLoading()}
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
disabled={isLoading()}
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading()}>
|
||||
{isLoading() ? 'Вход...' : 'Войти'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
587
panel/styles.css
Normal file
587
panel/styles.css
Normal file
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* Основные стили приложения
|
||||
*/
|
||||
|
||||
/* Сброс стилей */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Общие стили */
|
||||
:root {
|
||||
--primary-color: #3498db;
|
||||
--primary-dark: #2980b9;
|
||||
--success-color: #2ecc71;
|
||||
--success-light: #d1fae5;
|
||||
--danger-color: #e74c3c;
|
||||
--danger-light: #fee2e2;
|
||||
--warning-color: #f39c12;
|
||||
--warning-light: #fef3c7;
|
||||
--text-color: #333;
|
||||
--bg-color: #f5f5f5;
|
||||
--card-bg: #fff;
|
||||
--border-color: #ddd;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Общие элементы интерфейса */
|
||||
.loading-screen, .loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||
border-left-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 20px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: var(--danger-light);
|
||||
border-left: 4px solid var(--danger-color);
|
||||
color: var(--danger-color);
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: var(--success-light);
|
||||
border-left: 4px solid var(--success-color);
|
||||
color: var(--success-color);
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Стили для формы и кнопок */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 10px 15px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Стили для страницы входа */
|
||||
.login-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
padding: 30px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-container h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Стили для админ-панели */
|
||||
.admin-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: var(--card-bg);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
padding: 15px 20px;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
color: var(--primary-color);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background-color: transparent;
|
||||
color: var(--danger-color);
|
||||
border: 1px solid var(--danger-color);
|
||||
width: auto;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #ddd;
|
||||
margin-bottom: 1.5rem;
|
||||
gap: 10px;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.admin-tabs button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.2s;
|
||||
width: auto;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.admin-tabs button.active {
|
||||
border-bottom-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.admin-tabs button:hover {
|
||||
background-color: rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* Таблица пользователей */
|
||||
.users-list {
|
||||
overflow-x: auto;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: rgba(52, 152, 219, 0.05);
|
||||
}
|
||||
|
||||
tr.blocked {
|
||||
background-color: rgba(231, 76, 60, 0.05);
|
||||
}
|
||||
|
||||
/* Статусы пользователей */
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.status.active {
|
||||
background-color: var(--success-light);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.status.blocked {
|
||||
background-color: var(--danger-light);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.status.muted {
|
||||
background-color: var(--warning-light);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
/* Кнопки действий */
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
button.block {
|
||||
background-color: var(--danger-color);
|
||||
}
|
||||
|
||||
button.unblock {
|
||||
background-color: var(--success-color);
|
||||
}
|
||||
|
||||
button.mute {
|
||||
background-color: var(--warning-color);
|
||||
}
|
||||
|
||||
button.unmute {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Стили для редактирования ролей */
|
||||
.roles-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.roles-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.edit-roles-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
width: auto;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.edit-roles-button:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(52, 152, 219, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Модальное окно */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin-top: 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.roles-list {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.role-item {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.role-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.role-item label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.role-description {
|
||||
margin-top: 4px;
|
||||
margin-left: 24px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
padding: 8px 16px;
|
||||
background-color: #ccc;
|
||||
color: #333;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
padding: 8px 16px;
|
||||
background-color: var(--primary-color);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* Стили для пагинации */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
padding: 10px 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pagination-button {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pagination-button:hover:not(:disabled) {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.pagination-button.active {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.pagination-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-ellipsis {
|
||||
padding: 0 8px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.pagination-per-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.pagination-per-page select {
|
||||
margin-left: 8px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* Поиск */
|
||||
.users-controls {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input-group {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px 0 0 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.search-button {
|
||||
padding: 8px 16px;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-button:hover {
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* Адаптивные стили */
|
||||
@media (max-width: 768px) {
|
||||
.pagination {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.users-list {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 8px 5px;
|
||||
}
|
||||
|
||||
.pagination-per-page {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user