0.5.7-shouts-admin
All checks were successful
Deploy on push / deploy (push) Successful in 5s

This commit is contained in:
2025-06-28 13:47:08 +03:00
parent 7c11c9875f
commit c48f5f9368
7 changed files with 829 additions and 11 deletions

View File

@@ -86,6 +86,75 @@ interface EnvSection {
variables: EnvVariable[]
}
/**
* Интерфейс для публикации
*/
interface Shout {
id: number
title: string
slug: string
body: string
lead?: string
subtitle?: string
layout: string
lang: string
cover?: string
cover_caption?: string
media?: any[]
seo?: string
created_at: number
updated_at?: number
published_at?: number
featured_at?: number
deleted_at?: number
created_by: {
id: number
email?: string
name?: string
}
updated_by?: {
id: number
email?: string
name?: string
}
deleted_by?: {
id: number
email?: string
name?: string
}
community: {
id: number
name?: string
}
authors?: Array<{
id: number
email?: string
name?: string
slug?: string
}>
topics?: Array<{
id: number
title?: string
slug?: string
}>
version_of?: number
draft?: number
stat?: any
}
/**
* Интерфейс для ответа API с публикациями
*/
interface AdminGetShoutsResponse {
adminGetShouts: {
shouts: Shout[]
total: number
page: number
perPage: number
totalPages: number
}
}
/**
* Интерфейс свойств компонента AdminPage
*/
@@ -129,6 +198,23 @@ const AdminPage: Component<AdminPageProps> = (props) => {
// Поиск
const [searchQuery, setSearchQuery] = createSignal('')
// Публикации
const [shouts, setShouts] = createSignal<Shout[]>([])
const [shoutsLoading, setShoutsLoading] = createSignal(false)
const [shoutsStatus, setShoutsStatus] = createSignal('all') // all, published, draft, deleted
const [shoutsPagination, setShoutsPagination] = createSignal<{
page: number
limit: number
total: number
totalPages: number
}>({
page: 1,
limit: 10,
total: 0,
totalPages: 1
})
const [shoutsSearchQuery, setShoutsSearchQuery] = createSignal('')
// Периодическая проверка авторизации
onMount(() => {
// Получаем параметры из URL при загрузке
@@ -249,17 +335,90 @@ const AdminPage: Component<AdminPageProps> = (props) => {
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()
console.error('Ошибка при загрузке ролей:', err)
setError('Не удалось загрузить роли')
}
}
/**
* Загрузка списка публикаций с учетом пагинации и поиска
*/
async function loadShouts() {
setShoutsLoading(true)
setError(null)
try {
const { page, limit } = shoutsPagination()
const offset = (page - 1) * limit
const search = shoutsSearchQuery().trim()
const status = shoutsStatus()
const data = await query<AdminGetShoutsResponse>(
`${location.origin}/graphql`,
`
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
seo
created_at
updated_at
published_at
featured_at
deleted_at
created_by {
id
email
name
}
authors {
id
email
name
slug
}
topics {
id
title
slug
}
version_of
draft
}
total
page
perPage
totalPages
}
}
`,
{ limit, offset, search: search || undefined, status }
)
if (data?.adminGetShouts) {
setShouts(data.adminGetShouts.shouts)
setShoutsPagination({
page: data.adminGetShouts.page,
limit: data.adminGetShouts.perPage,
total: data.adminGetShouts.total,
totalPages: data.adminGetShouts.totalPages
})
}
} catch (err) {
console.error('Ошибка при загрузке публикаций:', err)
setError('Не удалось загрузить публикации')
} finally {
setShoutsLoading(false)
}
}
@@ -855,9 +1014,13 @@ const AdminPage: Component<AdminPageProps> = (props) => {
*/
const handleTabChange = (tab: string) => {
setActiveTab(tab)
setError(null)
setSuccessMessage(null)
if (tab === 'env' && envSections().length === 0) {
loadEnvVariables()
} else if (tab === 'shouts' && shouts().length === 0) {
loadShouts()
}
}
@@ -1028,6 +1191,24 @@ const AdminPage: Component<AdminPageProps> = (props) => {
)
}
// Вспомогательные функции для публикаций
function getShoutStatus(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: number = 100): string {
if (!text || text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
return (
<div class="admin-page">
<header>
@@ -1042,6 +1223,9 @@ const AdminPage: Component<AdminPageProps> = (props) => {
<button class={activeTab() === 'users' ? 'active' : ''} onClick={() => handleTabChange('users')}>
Пользователи
</button>
<button class={activeTab() === 'shouts' ? 'active' : ''} onClick={() => handleTabChange('shouts')}>
Публикации
</button>
<button class={activeTab() === 'env' ? 'active' : ''} onClick={() => handleTabChange('env')}>
Переменные среды
</button>
@@ -1129,6 +1313,135 @@ const AdminPage: Component<AdminPageProps> = (props) => {
</Show>
</Show>
<Show when={activeTab() === 'shouts'}>
<Show when={shoutsLoading()}>
<div class="loading">Загрузка публикаций...</div>
</Show>
<Show when={!shoutsLoading() && shouts().length === 0 && !error()}>
<div class="empty-state">Нет публикаций для отображения</div>
</Show>
<Show when={!shoutsLoading() && shouts().length > 0}>
<div class="shouts-controls">
<div class="search-container">
<div class="search-input-group">
<input
type="text"
placeholder="Поиск по заголовку, slug или ID..."
value={shoutsSearchQuery()}
onInput={(e) => setShoutsSearchQuery(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
loadShouts()
}
}}
class="search-input"
/>
<button class="search-button" onClick={loadShouts}>
Поиск
</button>
</div>
</div>
<div class="status-filter">
<select
value={shoutsStatus()}
onInput={(e) => {
setShoutsStatus(e.currentTarget.value)
loadShouts()
}}
>
<option value="all">Все</option>
<option value="published">Опубликованные</option>
<option value="draft">Черновики</option>
<option value="deleted">Удаленные</option>
</select>
</div>
</div>
<div class="shouts-list">
<table>
<thead>
<tr>
<th>ID</th>
<th>Заголовок</th>
<th>Slug</th>
<th>Статус</th>
<th>Авторы</th>
<th>Темы</th>
<th>Создан</th>
<th>Body (preview)</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={`status-badge ${getShoutStatusClass(shout)}`}>
{getShoutStatus(shout)}
</span>
</td>
<td>
<Show when={shout.authors && shout.authors.length > 0}>
<div class="authors-list">
<For each={shout.authors}>
{(author) => (
<span class="author-badge" title={author.email}>
{author.name || author.email || `ID:${author.id}`}
</span>
)}
</For>
</div>
</Show>
<Show when={!shout.authors || shout.authors.length === 0}>
<span class="no-data">-</span>
</Show>
</td>
<td>
<Show when={shout.topics && shout.topics.length > 0}>
<div class="topics-list">
<For each={shout.topics}>
{(topic) => (
<span class="topic-badge" title={topic.slug}>
{topic.title || topic.slug}
</span>
)}
</For>
</div>
</Show>
<Show when={!shout.topics || shout.topics.length === 0}>
<span class="no-data">-</span>
</Show>
</td>
<td>{formatDateRelative(shout.created_at)}</td>
<td title={shout.body}>
<div class="body-preview">
{truncateText(shout.body.replace(/<[^>]*>/g, ''), 100)}
</div>
</td>
<td>
<Show when={shout.media && shout.media.length > 0}>
<span class="media-count">{shout.media!.length} файл(ов)</span>
</Show>
<Show when={!shout.media || shout.media.length === 0}>
<span class="no-data">-</span>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</Show>
<Show when={activeTab() === 'env'}>
<EnvVariablesTab />
</Show>

View File

@@ -3,7 +3,7 @@
* @module LoginPage
*/
import { Component, createSignal, Show } from 'solid-js'
import { Component, createSignal } from 'solid-js'
import { login } from './auth'
interface LoginPageProps {

View File

@@ -848,3 +848,136 @@ th.sortable.sorted .sort-icon {
cursor: pointer;
user-select: none;
}
/* Стили для таблицы публикаций */
.shouts-controls {
display: flex;
gap: 20px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.status-filter select {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: white;
font-size: 14px;
min-width: 150px;
}
.shouts-list {
overflow-x: auto;
margin-top: 1rem;
}
.shouts-list table {
min-width: 1200px;
}
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
text-align: center;
min-width: 70px;
}
.status-published {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-draft {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.status-deleted {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.authors-list, .topics-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-width: 200px;
}
.author-badge, .topic-badge {
display: inline-block;
padding: 2px 6px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
font-size: 11px;
color: #495057;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.author-badge {
background-color: #e3f2fd;
border-color: #bbdefb;
color: #1565c0;
}
.topic-badge {
background-color: #f3e5f5;
border-color: #e1bee7;
color: #7b1fa2;
}
.body-preview {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: #666;
}
.media-count {
font-size: 12px;
color: #6c757d;
font-style: italic;
}
.no-data {
color: #adb5bd;
font-style: italic;
font-size: 13px;
}
/* Адаптивные стили для публикаций */
@media (max-width: 768px) {
.shouts-controls {
flex-direction: column;
align-items: stretch;
}
.status-filter select {
min-width: unset;
}
.shouts-list table {
font-size: 12px;
}
.authors-list, .topics-list {
max-width: 150px;
}
.body-preview {
max-width: 200px;
}
}