reactions-admin-tab
All checks were successful
Deploy on push / deploy (push) Successful in 7s

This commit is contained in:
2025-07-04 12:39:41 +03:00
parent db92cc6406
commit c8728540ed
13 changed files with 1310 additions and 9 deletions

View File

@@ -101,7 +101,6 @@ export function DataProvider(props: { children: JSX.Element }) {
// Обертка для setTopics с логированием
const setTopicsWithLogging = (newTopics: Topic[]) => {
console.log('[DataProvider] setTopics called with', newTopics.length, 'topics')
console.log('[DataProvider] Sample topic parent_ids:', newTopics.slice(0, 3).map(t => ({ id: t.id, title: t.title, parent_ids: t.parent_ids })))
setTopics(newTopics)
}

View File

@@ -194,6 +194,33 @@ export const ADMIN_UPDATE_TOPIC_MUTATION = `
}
`
export const ADMIN_UPDATE_REACTION_MUTATION = `
mutation AdminUpdateReaction($reaction: AdminReactionUpdateInput!) {
adminUpdateReaction(reaction: $reaction) {
success
error
}
}
`
export const ADMIN_DELETE_REACTION_MUTATION = `
mutation AdminDeleteReaction($reaction_id: Int!) {
adminDeleteReaction(reaction_id: $reaction_id) {
success
error
}
}
`
export const ADMIN_RESTORE_REACTION_MUTATION = `
mutation AdminRestoreReaction($reaction_id: Int!) {
adminRestoreReaction(reaction_id: $reaction_id) {
success
error
}
}
`
export const ADMIN_CREATE_TOPIC_MUTATION = `
mutation AdminCreateTopic($topic: AdminTopicInput!) {
adminCreateTopic(topic: $topic) {

View File

@@ -193,6 +193,46 @@ export const GET_TOPICS_BY_COMMUNITY_QUERY: string =
}
`.loc?.source.body || ''
export const ADMIN_GET_REACTIONS_QUERY: string =
gql`
query AdminGetReactions($limit: Int, $offset: Int, $search: String, $kind: ReactionKind, $shout_id: Int, $status: String) {
adminGetReactions(limit: $limit, offset: $offset, search: $search, kind: $kind, shout_id: $shout_id, status: $status) {
reactions {
id
kind
body
created_at
updated_at
deleted_at
reply_to
created_by {
id
name
email
slug
}
shout {
id
title
slug
layout
created_at
published_at
deleted_at
}
stat {
comments_count
rating
}
}
total
page
perPage
totalPages
}
}
`.loc?.source.body || ''
export const ADMIN_GET_TOPICS_QUERY: string =
gql`
query AdminGetTopics($community_id: Int!) {

View File

@@ -0,0 +1,206 @@
import { Component, createSignal, createEffect } from 'solid-js'
import styles from '../styles/Modal.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import HTMLEditor from '../ui/HTMLEditor'
interface ReactionEditModalProps {
reaction: {
id: number
kind: string
body: string
created_at: number
updated_at?: number
deleted_at?: number
reply_to?: number
created_by: {
id: number
name: string
email: string
slug: string
}
shout: {
id: number
title: string
slug: string
layout: string
created_at: number
published_at?: number
deleted_at?: number
}
stat: {
comments_count: number
rating: number
}
}
isOpen: boolean
onClose: () => void
onSave: (reaction: { id: number; body?: string; deleted_at?: number }) => Promise<void>
}
/**
* Модальное окно для редактирования реакции
*/
const ReactionEditModal: Component<ReactionEditModalProps> = (props) => {
const [body, setBody] = createSignal('')
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal('')
// Инициализация данных при изменении реакции
createEffect(() => {
if (props.reaction) {
setBody(props.reaction.body || '')
setError('')
}
})
/**
* Обработка сохранения изменений
*/
async function handleSave() {
try {
setLoading(true)
setError('')
const updateData: { id: number; body?: string; deleted_at?: number } = {
id: props.reaction.id,
body: body(),
}
await props.onSave(updateData)
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка сохранения')
} finally {
setLoading(false)
}
}
/**
* Получает название типа реакции на русском
*/
const getReactionName = (kind: string): string => {
switch (kind) {
case 'LIKE':
return 'Лайк'
case 'DISLIKE':
return 'Дизлайк'
case 'COMMENT':
return 'Комментарий'
case 'QUOTE':
return 'Цитата'
case 'AGREE':
return 'Согласен'
case 'DISAGREE':
return 'Не согласен'
case 'ASK':
return 'Вопрос'
case 'PROPOSE':
return 'Предложение'
case 'PROOF':
return 'Доказательство'
case 'DISPROOF':
return 'Опровержение'
case 'ACCEPT':
return 'Принять'
case 'REJECT':
return 'Отклонить'
case 'CREDIT':
return 'Упоминание'
case 'SILENT':
return 'Причастность'
default:
return kind
}
}
return (
<Modal isOpen={props.isOpen} onClose={props.onClose} title="Редактирование реакции">
<div class={styles['modal-content']}>
{error() && (
<div class={styles['error-message']}>
{error()}
</div>
)}
<div class={styles['form-group']}>
<label class={styles['form-label']}>ID реакции:</label>
<input
type="text"
value={props.reaction.id}
disabled
class={styles['form-input']}
/>
</div>
<div class={styles['form-group']}>
<label class={styles['form-label']}>Тип реакции:</label>
<input
type="text"
value={getReactionName(props.reaction.kind)}
disabled
class={styles['form-input']}
/>
</div>
<div class={styles['form-group']}>
<label class={styles['form-label']}>Автор:</label>
<input
type="text"
value={`${props.reaction.created_by.name || 'Без имени'} (${props.reaction.created_by.email})`}
disabled
class={styles['form-input']}
/>
</div>
<div class={styles['form-group']}>
<label class={styles['form-label']}>Публикация:</label>
<input
type="text"
value={`${props.reaction.shout.title} (ID: ${props.reaction.shout.id})`}
disabled
class={styles['form-input']}
/>
</div>
<div class={styles['form-group']}>
<label class={styles['form-label']}>Текст реакции:</label>
<HTMLEditor
value={body()}
onInput={(value) => setBody(value)}
placeholder="Введите текст реакции (поддерживается HTML)..."
rows={6}
/>
</div>
<div class={styles['form-group']}>
<label class={styles['form-label']}>Статистика:</label>
<div class={styles['stat-info']}>
<span>Рейтинг: {props.reaction.stat.rating}</span>
<span>Комментариев: {props.reaction.stat.comments_count}</span>
</div>
</div>
<div class={styles['form-group']}>
<label class={styles['form-label']}>Статус:</label>
<input
type="text"
value={props.reaction.deleted_at ? 'Удалено' : 'Активно'}
disabled
class={styles['form-input']}
/>
</div>
<div class={styles['modal-actions']}>
<Button variant="secondary" onClick={props.onClose} disabled={loading()}>
Отмена
</Button>
<Button variant="primary" onClick={handleSave} disabled={loading()}>
{loading() ? 'Сохранение...' : 'Сохранить'}
</Button>
</div>
</div>
</Modal>
)
}
export default ReactionEditModal

View File

@@ -17,6 +17,7 @@ import CollectionsRoute from './collections'
import CommunitiesRoute from './communities'
import EnvRoute from './env'
import InvitesRoute from './invites'
import ReactionsRoute from './reactions'
import ShoutsRoute from './shouts'
import { Topics as TopicsRoute } from './topics'
@@ -145,6 +146,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
>
Приглашения
</Button>
<Button
variant={currentTab() === 'reactions' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/reactions')}
>
Реакции
</Button>
<Button
variant={currentTab() === 'env' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/env')}
@@ -188,6 +195,10 @@ const AdminPage: Component<AdminPageProps> = (props) => {
<InvitesRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={currentTab() === 'reactions'}>
<ReactionsRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={currentTab() === 'env'}>
<EnvRoute onError={handleError} onSuccess={handleSuccess} />
</Show>

442
panel/routes/reactions.tsx Normal file
View File

@@ -0,0 +1,442 @@
import { Component, createSignal, For, onMount, Show } from 'solid-js'
import { query } from '../graphql'
import type { Query } from '../graphql/generated/schema'
import { ADMIN_DELETE_REACTION_MUTATION, ADMIN_RESTORE_REACTION_MUTATION, ADMIN_UPDATE_REACTION_MUTATION } from '../graphql/mutations'
import { ADMIN_GET_REACTIONS_QUERY } from '../graphql/queries'
import ReactionEditModal from '../modals/ReactionEditModal'
import styles from '../styles/Admin.module.css'
import Button from '../ui/Button'
import Pagination from '../ui/Pagination'
import TableControls from '../ui/TableControls'
import { formatDateRelative } from '../utils/date'
export interface ReactionsRouteProps {
onError?: (error: string) => void
onSuccess?: (message: string) => void
}
/**
* Тип реакции для админки
*/
interface AdminReaction {
id: number
kind: string
body: string
created_at: number
updated_at?: number
deleted_at?: number
reply_to?: number
created_by: {
id: number
name: string
email: string
slug: string
}
shout: {
id: number
title: string
slug: string
layout: string
created_at: number
published_at?: number
deleted_at?: number
}
stat: {
comments_count: number
rating: number
}
}
const ReactionsRoute: Component<ReactionsRouteProps> = (props) => {
console.log('[ReactionsRoute] Initializing...')
const [reactions, setReactions] = createSignal<AdminReaction[]>([])
const [loading, setLoading] = createSignal(true)
const [selectedReaction, setSelectedReaction] = createSignal<AdminReaction | null>(null)
const [showEditModal, setShowEditModal] = createSignal(false)
// Pagination state
const [pagination, setPagination] = createSignal<{
page: number
limit: number
total: number
totalPages: number
}>({
page: 1,
limit: 20,
total: 0,
totalPages: 1
})
// Фильтры
const [searchQuery, setSearchQuery] = createSignal('')
const [kindFilter, setKindFilter] = createSignal('')
const [shoutIdFilter, setShoutIdFilter] = createSignal('')
const [statusFilter, setStatusFilter] = createSignal('all')
/**
* Загрузка списка реакций
*/
async function loadReactions() {
console.log('[ReactionsRoute] Loading reactions...')
try {
setLoading(true)
const data = await query<{ adminGetReactions: {
reactions: AdminReaction[]
total: number
page: number
perPage: number
totalPages: number
} }>(
`${location.origin}/graphql`,
ADMIN_GET_REACTIONS_QUERY,
{
search: searchQuery(),
kind: kindFilter() || undefined,
shout_id: shoutIdFilter() ? parseInt(shoutIdFilter()) : undefined,
status: statusFilter(),
limit: pagination().limit,
offset: (pagination().page - 1) * pagination().limit
}
)
if (data?.adminGetReactions?.reactions) {
console.log('[ReactionsRoute] Reactions loaded:', data.adminGetReactions.reactions.length)
setReactions(data.adminGetReactions.reactions as AdminReaction[])
setPagination((prev) => ({
...prev,
total: data.adminGetReactions.total || 0,
totalPages: data.adminGetReactions.totalPages || 1
}))
}
} catch (error) {
console.error('[ReactionsRoute] Failed to load reactions:', error)
props.onError?.(error instanceof Error ? error.message : 'Не удалось загрузить список реакций')
} finally {
setLoading(false)
}
}
/**
* Обновляет реакцию
*/
async function updateReaction(reactionData: { id: number; body?: string; deleted_at?: number }) {
try {
await query(`${location.origin}/graphql`, ADMIN_UPDATE_REACTION_MUTATION, {
reaction: reactionData
})
closeEditModal()
props.onSuccess?.('Реакция успешно обновлена')
void loadReactions()
} catch (err) {
console.error('Ошибка обновления реакции:', err)
props.onError?.(err instanceof Error ? err.message : 'Ошибка обновления реакции')
}
}
/**
* Удаляет реакцию
*/
async function deleteReaction(id: number) {
try {
await query(`${location.origin}/graphql`, ADMIN_DELETE_REACTION_MUTATION, { reaction_id: id })
props.onSuccess?.('Реакция успешно удалена')
void loadReactions()
} catch (err) {
console.error('Ошибка удаления реакции:', err)
props.onError?.(err instanceof Error ? err.message : 'Ошибка удаления реакции')
}
}
/**
* Восстанавливает реакцию
*/
async function restoreReaction(id: number) {
try {
await query(`${location.origin}/graphql`, ADMIN_RESTORE_REACTION_MUTATION, { reaction_id: id })
props.onSuccess?.('Реакция успешно восстановлена')
void loadReactions()
} catch (err) {
console.error('Ошибка восстановления реакции:', err)
props.onError?.(err instanceof Error ? err.message : 'Ошибка восстановления реакции')
}
}
function closeEditModal() {
setShowEditModal(false)
setSelectedReaction(null)
}
// Pagination handlers
function handlePageChange(page: number) {
setPagination((prev) => ({ ...prev, page }))
void loadReactions()
}
function handlePerPageChange(limit: number) {
setPagination((prev) => ({ ...prev, page: 1, limit }))
void loadReactions()
}
// Search handlers
function handleSearchChange(value: string) {
setSearchQuery(value)
}
function handleSearch() {
setPagination((prev) => ({ ...prev, page: 1 }))
void loadReactions()
}
// Load reactions on mount
onMount(() => {
console.log('[ReactionsRoute] Component mounted, loading reactions...')
void loadReactions()
})
/**
* Получает эмоджи для типа реакции
*/
const getReactionIcon = (kind: string): string => {
switch (kind) {
case 'LIKE':
return '👍'
case 'DISLIKE':
return '👎'
case 'COMMENT':
return '💬'
case 'QUOTE':
return '❝'
case 'AGREE':
return '✅'
case 'DISAGREE':
return '❌'
case 'ASK':
return '❓'
case 'PROPOSE':
return '💡'
case 'PROOF':
return '🔬'
case 'DISPROOF':
return '🚫'
case 'ACCEPT':
return '✔️'
case 'REJECT':
return '❌'
case 'CREDIT':
return '🎨'
case 'SILENT':
return '🤫'
default:
return '💬'
}
}
/**
* Получает название типа реакции на русском
*/
const getReactionName = (kind: string): string => {
switch (kind) {
case 'LIKE':
return 'Лайк'
case 'DISLIKE':
return 'Дизлайк'
case 'COMMENT':
return 'Комментарий'
case 'QUOTE':
return 'Цитата'
case 'AGREE':
return 'Согласен'
case 'DISAGREE':
return 'Не согласен'
case 'ASK':
return 'Вопрос'
case 'PROPOSE':
return 'Предложение'
case 'PROOF':
return 'Доказательство'
case 'DISPROOF':
return 'Опровержение'
case 'ACCEPT':
return 'Принять'
case 'REJECT':
return 'Отклонить'
case 'CREDIT':
return 'Упоминание'
case 'SILENT':
return 'Причастность'
default:
return kind
}
}
return (
<div class={styles['reactions-container']}>
<Show when={loading()}>
<div class={styles['loading']}>Загрузка данных...</div>
</Show>
<Show when={!loading()}>
<div class={styles['filters-section']}>
<TableControls
searchValue={searchQuery()}
onSearchChange={handleSearchChange}
onSearch={handleSearch}
searchPlaceholder="Поиск по тексту, автору или публикации..."
isLoading={loading()}
/>
<div class={styles['additional-filters']}>
<select
value={kindFilter()}
onChange={(e) => setKindFilter(e.target.value)}
class={styles['filter-select']}
>
<option value="">Все типы</option>
<option value="LIKE">Лайк</option>
<option value="DISLIKE">Дизлайк</option>
<option value="COMMENT">Комментарий</option>
<option value="QUOTE">Цитата</option>
<option value="AGREE">Согласен</option>
<option value="DISAGREE">Не согласен</option>
<option value="ASK">Вопрос</option>
<option value="PROPOSE">Предложение</option>
<option value="PROOF">Доказательство</option>
<option value="DISPROOF">Опровержение</option>
<option value="ACCEPT">Принять</option>
<option value="REJECT">Отклонить</option>
<option value="CREDIT">Упоминание</option>
<option value="SILENT">Причастность</option>
</select>
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.target.value)}
class={styles['filter-select']}
>
<option value="all">Все статусы</option>
<option value="active">Активные</option>
<option value="deleted">Удаленные</option>
</select>
<input
type="text"
placeholder="ID публикации"
value={shoutIdFilter()}
onInput={(e) => setShoutIdFilter(e.target.value)}
class={styles['filter-input']}
/>
<Button variant="primary" onClick={() => void loadReactions()}>
Применить фильтры
</Button>
</div>
</div>
<Show when={reactions().length === 0}>
<div class={styles['empty-state']}>Нет данных для отображения</div>
</Show>
<Show when={reactions().length > 0}>
<div class={styles['reactions-list']}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Тип</th>
<th>Текст</th>
<th>Автор</th>
<th>Публикация</th>
<th>Создано</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<For each={reactions()}>
{(reaction) => (
<tr
class={reaction.deleted_at ? styles['deleted-row'] : ''}
onClick={() => {
setSelectedReaction(reaction)
setShowEditModal(true)
}}
>
<td>{reaction.id}</td>
<td>
<span title={getReactionName(reaction.kind)} class={styles['reaction-icon']}>
{getReactionIcon(reaction.kind)}
</span>
</td>
<td class={styles['body-cell']}>
<div class={styles['body-preview']}>
{reaction.body ? reaction.body.substring(0, 100) + (reaction.body.length > 100 ? '...' : '') : '-'}
</div>
</td>
<td>
<div class={styles['author-cell']}>
<div>{reaction.created_by.name || 'Без имени'}</div>
<div class={styles['author-email']}>{reaction.created_by.email}</div>
</div>
</td>
<td>
<div class={styles['shout-cell']}>
<div class={styles['shout-title']}>
{reaction.shout.title.substring(0, 50)}
{reaction.shout.title.length > 50 ? '...' : ''}
</div>
<div class={styles['shout-meta']}>
ID: {reaction.shout.id} | {reaction.shout.slug}
</div>
</div>
</td>
<td>{formatDateRelative(reaction.created_at)()}</td>
<td>
<span class={reaction.deleted_at ? styles['status-deleted'] : styles['status-active']}>
{reaction.deleted_at ? 'Удалено' : 'Активно'}
</span>
</td>
<td>
<div class={styles['actions-cell']} onClick={(e) => e.stopPropagation()}>
<Show when={reaction.deleted_at}>
<Button variant="primary" size="small" onClick={() => restoreReaction(reaction.id)}>
Восстановить
</Button>
</Show>
<Show when={!reaction.deleted_at}>
<Button variant="danger" size="small" onClick={() => deleteReaction(reaction.id)}>
Удалить
</Button>
</Show>
</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>
<Show when={showEditModal() && selectedReaction()}>
<ReactionEditModal
reaction={selectedReaction()!}
isOpen={showEditModal()}
onClose={closeEditModal}
onSave={updateReaction}
/>
</Show>
</div>
)
}
export default ReactionsRoute

View File

@@ -274,8 +274,6 @@ main {
white-space: nowrap;
}
.roles-cell {
min-width: 200px;
}
@@ -334,9 +332,6 @@ main {
background-color: white;
}
.shouts-list {
}
.status-badge {
display: inline-flex;
align-items: center;
@@ -584,8 +579,6 @@ td {
hyphens: auto;
}
/* Responsive Styles */
@media (max-width: 1024px) {
.header-container {
@@ -677,3 +670,198 @@ td {
flex-direction: column;
}
}
/* Styles for reaction-related components */
.reactions-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.reactions-list {
width: 100%;
overflow-x: auto;
}
.reactions-list table {
width: 100%;
border-collapse: collapse;
background-color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 8px;
table-layout: fixed;
}
/* Оптимизация ширины колонок для реакций */
.reactions-list th:nth-child(1), /* ID */
.reactions-list td:nth-child(1) {
width: 80px;
text-align: center;
}
.reactions-list th:nth-child(2), /* ТИП */
.reactions-list td:nth-child(2) {
width: 60px;
text-align: center;
}
.reactions-list th:nth-child(3), /* ТЕКСТ */
.reactions-list td:nth-child(3) {
width: 25%;
}
.reactions-list th:nth-child(4), /* АВТОР */
.reactions-list td:nth-child(4) {
width: 18%;
}
.reactions-list th:nth-child(5), /* ПУБЛИКАЦИЯ */
.reactions-list td:nth-child(5) {
width: 22%;
}
.reactions-list th:nth-child(6), /* СОЗДАНО */
.reactions-list td:nth-child(6) {
width: 120px;
}
.reactions-list th:nth-child(7), /* СТАТУС */
.reactions-list td:nth-child(7) {
width: 100px;
text-align: center;
}
.reactions-list th:nth-child(8), /* ДЕЙСТВИЯ */
.reactions-list td:nth-child(8) {
width: 120px;
text-align: center;
}
.reactions-list th,
.reactions-list td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.reactions-list th {
background-color: #f8f9fa;
font-weight: 600;
color: #333;
}
.reactions-list tr:hover {
background-color: #f8f9fa;
cursor: pointer;
}
.reactions-list tr.deleted-row {
background-color: #fff5f5;
color: #666;
}
.reactions-list tr.deleted-row:hover {
background-color: #fed7d7;
}
.body-cell {
max-width: 200px;
}
.body-preview {
word-wrap: break-word;
overflow-wrap: break-word;
font-size: 0.875rem;
line-height: 1.4;
}
.author-cell {
min-width: 150px;
}
.author-email {
font-size: 0.75rem;
color: #666;
margin-top: 0.25rem;
}
.shout-cell {
min-width: 200px;
}
.shout-title {
font-weight: 500;
margin-bottom: 0.25rem;
}
.shout-meta {
font-size: 0.75rem;
color: #666;
}
.status-active {
color: #28a745;
font-weight: 500;
}
.status-deleted {
color: #dc3545;
font-weight: 500;
}
.actions-cell {
min-width: 120px;
}
.actions-cell button {
margin-right: 0.5rem;
}
.filters-section {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.additional-filters {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.filter-select,
.filter-input {
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.875rem;
min-width: 120px;
}
.filter-select:focus,
.filter-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.stat-info {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: #666;
}
.stat-info span {
padding: 0.25rem 0.5rem;
background-color: #f8f9fa;
border-radius: 4px;
}
.reaction-icon {
font-size: 1.25rem;
display: inline-block;
cursor: pointer;
}

View File

@@ -363,6 +363,68 @@
border-left: 4px solid var(--primary-color);
}
/* HTML Editor в модальных окнах использует стили из Form.module.css */
.form-textarea {
padding: 0.75rem;
border: 2px solid var(--border-color);
border-radius: 0.5rem;
font-size: 0.875rem;
background-color: var(--bg-color);
color: var(--text-color);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-family: var(--font-family);
resize: vertical;
min-height: 120px;
}
.form-textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 4px rgba(0, 123, 255, 0.1);
transform: translateY(-1px);
}
.stat-info {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: var(--text-color-light);
}
.stat-info span {
padding: 0.375rem 0.75rem;
background-color: var(--hover-bg);
border-radius: 0.375rem;
border: 1px solid var(--border-color);
}
.error-message {
padding: 0.75rem;
background-color: var(--error-color-light, #fef2f2);
color: var(--error-color-dark, #dc2626);
border-radius: 0.5rem;
border: 1px solid var(--error-color, #fca5a5);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
}
.form-label {
font-weight: 600;
color: var(--text-color);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
/* Body Preview Modal */
.body-preview {
width: 100%;