mobile ui
This commit is contained in:
parent
175f2079a6
commit
e793ef1de6
111
frontend/README.md
Normal file
111
frontend/README.md
Normal file
@ -0,0 +1,111 @@
|
||||
# CoreSync MRM Frontend
|
||||
|
||||
Адаптивное веб-приложение для управления email-рассылками.
|
||||
|
||||
## Адаптивность
|
||||
|
||||
Приложение полностью адаптировано для работы на всех устройствах:
|
||||
|
||||
### Поддерживаемые разрешения:
|
||||
- **Desktop**: 1200px+
|
||||
- **Tablet**: 768px - 1199px
|
||||
- **Mobile**: 320px - 767px
|
||||
- **Small Mobile**: до 479px
|
||||
|
||||
### Ключевые особенности адаптивности:
|
||||
|
||||
#### 1. Мобильное меню
|
||||
- На мобильных устройствах боковое меню скрывается и открывается по кнопке
|
||||
- Добавлен оверлей для закрытия меню
|
||||
- Плавные анимации открытия/закрытия
|
||||
|
||||
#### 2. Адаптивные таблицы
|
||||
- Горизонтальная прокрутка на мобильных устройствах
|
||||
- Оптимизированные размеры ячеек для touch-устройств
|
||||
- Улучшенная читаемость на маленьких экранах
|
||||
|
||||
#### 3. Адаптивные формы
|
||||
- Увеличенные размеры полей ввода для touch-устройств
|
||||
- Минимальная высота 44px для всех интерактивных элементов
|
||||
- Оптимизированные отступы и размеры шрифтов
|
||||
|
||||
#### 4. Адаптивные кнопки
|
||||
- Увеличенные размеры для touch-устройств
|
||||
- Улучшенная доступность
|
||||
- Поддержка hover и active состояний
|
||||
|
||||
#### 5. Модальные окна
|
||||
- Адаптивные размеры и отступы
|
||||
- Оптимизированное позиционирование на мобильных
|
||||
- Поддержка прокрутки контента
|
||||
|
||||
### Технические особенности:
|
||||
|
||||
#### CSS Media Queries:
|
||||
```css
|
||||
/* Планшеты */
|
||||
@media (max-width: 1024px) { ... }
|
||||
|
||||
/* Мобильные устройства */
|
||||
@media (max-width: 768px) { ... }
|
||||
|
||||
/* Маленькие мобильные */
|
||||
@media (max-width: 480px) { ... }
|
||||
|
||||
/* Touch устройства */
|
||||
@media (hover: none) and (pointer: coarse) { ... }
|
||||
```
|
||||
|
||||
#### Поддержка устройств:
|
||||
- **iOS Safari**: Полная поддержка
|
||||
- **Android Chrome**: Полная поддержка
|
||||
- **Desktop browsers**: Chrome, Firefox, Safari, Edge
|
||||
- **Touch devices**: iPad, iPhone, Android tablets/phones
|
||||
|
||||
### Структура стилей:
|
||||
|
||||
```
|
||||
src/
|
||||
├── styles/
|
||||
│ ├── Common.module.css # Общие адаптивные стили
|
||||
│ ├── Modal.module.css # Адаптивные модальные окна
|
||||
│ ├── MultiSelect.module.css # Адаптивные селекты
|
||||
│ └── Paginator.module.css # Адаптивная пагинация
|
||||
├── components/
|
||||
│ ├── Header.module.css # Адаптивный хедер
|
||||
│ └── SideMenu.module.css # Адаптивное меню
|
||||
└── pages/
|
||||
├── Dashboard.module.css # Адаптивная главная страница
|
||||
└── Login.module.css # Адаптивная страница входа
|
||||
```
|
||||
|
||||
### Производительность:
|
||||
|
||||
- Оптимизированные изображения и иконки
|
||||
- Минимальные CSS-анимации для плавности
|
||||
- Эффективная работа с touch-событиями
|
||||
- Поддержка `-webkit-overflow-scrolling: touch`
|
||||
|
||||
### Доступность:
|
||||
|
||||
- Поддержка screen readers
|
||||
- Правильная семантика HTML
|
||||
- Достаточный контраст цветов
|
||||
- Увеличенные размеры для touch-интерфейса
|
||||
|
||||
## Запуск проекта
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Приложение будет доступно по адресу `http://localhost:3000`
|
||||
|
||||
## Сборка для продакшена
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Собранные файлы будут в папке `build/`
|
||||
@ -10,7 +10,7 @@
|
||||
"react-scripts": "5.0.1",
|
||||
"react-select": "^5.10.2"
|
||||
},
|
||||
"proxy": "http://project.ru",
|
||||
"proxy": "http://project.ru:8081",
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/logo.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site created using create-react-app" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<meta name="description" content="CoreSync MRM - Система управления email-рассылками" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>React App</title>
|
||||
<title>CoreSync MRM</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<noscript>Для работы приложения необходимо включить JavaScript</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -12,16 +12,19 @@
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.user {
|
||||
font-size: 16px;
|
||||
color: #6366f1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logout {
|
||||
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
||||
color: #fff;
|
||||
@ -33,6 +36,59 @@
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.logout:hover {
|
||||
background: linear-gradient(90deg, #3730a3 0%, #06b6d4 100%);
|
||||
}
|
||||
|
||||
/* Адаптивные стили для мобильных устройств */
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.right {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.logout {
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
height: 48px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.right {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.logout {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Улучшенная поддержка touch устройств */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.logout {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@ -1,26 +1,59 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import styles from './SideMenu.module.css';
|
||||
|
||||
const SideMenu = ({ active, onSelect }) => {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
const handleMenuSelect = (menuItem) => {
|
||||
onSelect(menuItem);
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={styles.menu}>
|
||||
<div className={styles.project}>CoreSync MRM</div>
|
||||
<nav className={styles.nav}>
|
||||
<div className={styles.section}>Email-рассылки</div>
|
||||
<ul>
|
||||
<li className={active === 'smtp' ? styles.active : ''} onClick={() => onSelect('smtp')}>SMTP-сервера</li>
|
||||
<li className={active === 'template' ? styles.active : ''} onClick={() => onSelect('template')}>Шаблон письма</li>
|
||||
<li className={active === 'unsubscribed' ? styles.active : ''} onClick={() => onSelect('unsubscribed')}>Отписались</li>
|
||||
<li className={active === 'groups' ? styles.active : ''} onClick={() => onSelect('groups')}>Подписчики и группы</li>
|
||||
<li className={active === 'history' ? styles.active : ''} onClick={() => onSelect('history')}>История отправок</li>
|
||||
<li className={active === 'campaign' ? styles.active : ''} onClick={() => onSelect('campaign')}>Кампания</li>
|
||||
</ul>
|
||||
<div className={styles.section}>Администрирование</div>
|
||||
<ul>
|
||||
<li className={active === 'users' ? styles.active : ''} onClick={() => onSelect('users')}>Управление пользователями</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<>
|
||||
{/* Мобильная кнопка меню */}
|
||||
<button
|
||||
className={styles.mobileMenuButton}
|
||||
onClick={toggleMobileMenu}
|
||||
aria-label="Открыть меню"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Оверлей для мобильного меню */}
|
||||
<div
|
||||
className={`${styles.mobileOverlay} ${isMobileMenuOpen ? styles.open : ''}`}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Боковое меню */}
|
||||
<aside className={`${styles.menu} ${isMobileMenuOpen ? styles.open : ''}`}>
|
||||
<div className={styles.project}>CoreSync MRM</div>
|
||||
<nav className={styles.nav}>
|
||||
<div className={styles.section}>Email-рассылки</div>
|
||||
<ul>
|
||||
<li className={active === 'smtp' ? styles.active : ''} onClick={() => handleMenuSelect('smtp')}>SMTP-сервера</li>
|
||||
<li className={active === 'template' ? styles.active : ''} onClick={() => handleMenuSelect('template')}>Шаблон письма</li>
|
||||
<li className={active === 'unsubscribed' ? styles.active : ''} onClick={() => handleMenuSelect('unsubscribed')}>Отписались</li>
|
||||
<li className={active === 'groups' ? styles.active : ''} onClick={() => handleMenuSelect('groups')}>Подписчики и группы</li>
|
||||
<li className={active === 'history' ? styles.active : ''} onClick={() => handleMenuSelect('history')}>История отправок</li>
|
||||
<li className={active === 'campaign' ? styles.active : ''} onClick={() => handleMenuSelect('campaign')}>Кампания</li>
|
||||
</ul>
|
||||
<div className={styles.section}>Администрирование</div>
|
||||
<ul>
|
||||
<li className={active === 'users' ? styles.active : ''} onClick={() => handleMenuSelect('users')}>Управление пользователями</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -6,7 +6,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0 24px 0;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.project {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
@ -16,10 +18,12 @@
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex: 1;
|
||||
padding: 0 0 0 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
font-size: 15px;
|
||||
color: #6366f1;
|
||||
@ -28,11 +32,13 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 0;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
@ -41,13 +47,148 @@ li {
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s, border 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
li:hover {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
border-left: 3px solid #6366f1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Мобильное меню */
|
||||
.mobileMenuButton {
|
||||
display: none; /* По умолчанию скрыта на десктопе */
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 12px;
|
||||
z-index: 1000;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mobileOverlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* Адаптивные стили для планшетов */
|
||||
@media (max-width: 1024px) {
|
||||
.menu {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.project {
|
||||
font-size: 20px;
|
||||
padding: 24px 20px 12px 20px;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 10px 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 10px 20px 4px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Адаптивные стили для мобильных устройств */
|
||||
@media (max-width: 768px) {
|
||||
.menu {
|
||||
position: fixed;
|
||||
left: -250px;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
width: 250px;
|
||||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.menu.open {
|
||||
transform: translateX(250px);
|
||||
}
|
||||
|
||||
.mobileMenuButton {
|
||||
display: flex; /* Показываем только на мобильных */
|
||||
}
|
||||
|
||||
.mobileOverlay.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.project {
|
||||
font-size: 18px;
|
||||
padding: 20px 16px 12px 16px;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 8px 16px 4px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.menu {
|
||||
width: 280px;
|
||||
left: -280px;
|
||||
}
|
||||
|
||||
.menu.open {
|
||||
transform: translateX(280px);
|
||||
}
|
||||
|
||||
.project {
|
||||
font-size: 16px;
|
||||
padding: 16px 12px 8px 12px;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 6px 12px 2px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Улучшенная поддержка touch устройств */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
li {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.mobileMenuButton {
|
||||
min-height: 30px;
|
||||
min-width: 30px;
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
@ -5,9 +5,53 @@ body {
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Глобальные адаптивные стили */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Медиа-запросы для разных устройств */
|
||||
@media (max-width: 768px) {
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
html {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Улучшенная поддержка touch устройств */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
button, a, [role="button"] {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Поддержка темной темы */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background-color: #1a1a1a;
|
||||
--text-color: #ffffff;
|
||||
--border-color: #333333;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--background-color: #ffffff;
|
||||
--text-color: #000000;
|
||||
--border-color: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import Modal from './Modal';
|
||||
import styles from '../styles/UserModal.module.css';
|
||||
|
||||
export default function CreateUserModal({ isOpen, onClose, user, roles, loading, onChange, onSave }) {
|
||||
export default function CreateUserModal({ isOpen, onClose, user, loading, onChange, onSave }) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||
<h3 className={styles.title}>Добавить пользователя</h3>
|
||||
@ -14,20 +14,10 @@ export default function CreateUserModal({ isOpen, onClose, user, roles, loading,
|
||||
<input type="text" value={user.name} onChange={e => onChange({ ...user, name: e.target.value })} required className={styles.input} />
|
||||
</label>
|
||||
<label className={styles.label}>Роль
|
||||
<select value={user.role_id} onChange={e => onChange({ ...user, role_id: Number(e.target.value) })} required className={styles.input}>
|
||||
{roles.length === 0 ? (
|
||||
<option value="">Загрузка ролей...</option>
|
||||
) : (
|
||||
roles.map(role => (
|
||||
<option key={role.id} value={role.id}>{role.name}</option>
|
||||
))
|
||||
)}
|
||||
<select value={user.role} onChange={e => onChange({ ...user, role: e.target.value })} required className={styles.input}>
|
||||
<option value="user">Пользователь</option>
|
||||
<option value="admin">Администратор</option>
|
||||
</select>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div style={{ fontSize: '10px', color: '#666', marginTop: '4px' }}>
|
||||
Debug: {roles.length} roles, user.role_id: {user.role_id}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<label className={styles.label}>Пароль
|
||||
<input type="password" value={user.password} onChange={e => onChange({ ...user, password: e.target.value })} required className={styles.input} />
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import Modal from './Modal';
|
||||
import styles from '../styles/UserModal.module.css';
|
||||
|
||||
export default function EditUserModal({ isOpen, onClose, user, roles, loading, onChange, onSave }) {
|
||||
export default function EditUserModal({ isOpen, onClose, user, loading, onChange, onSave }) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||
<h3 className={styles.title}>Редактировать пользователя</h3>
|
||||
@ -14,20 +14,10 @@ export default function EditUserModal({ isOpen, onClose, user, roles, loading, o
|
||||
<input type="text" value={user.name} onChange={e => onChange({ ...user, name: e.target.value })} required className={styles.input} />
|
||||
</label>
|
||||
<label className={styles.label}>Роль
|
||||
<select value={user.role_id} onChange={e => onChange({ ...user, role_id: Number(e.target.value) })} required className={styles.input}>
|
||||
{roles.length === 0 ? (
|
||||
<option value="">Загрузка ролей...</option>
|
||||
) : (
|
||||
roles.map(role => (
|
||||
<option key={role.id} value={role.id}>{role.name}</option>
|
||||
))
|
||||
)}
|
||||
<select value={user.role} onChange={e => onChange({ ...user, role: e.target.value })} required className={styles.input}>
|
||||
<option value="user">Пользователь</option>
|
||||
<option value="admin">Администратор</option>
|
||||
</select>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div style={{ fontSize: '10px', color: '#666', marginTop: '4px' }}>
|
||||
Debug: {roles.length} roles, user.role_id: {user.role_id}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<div className={styles.actions}>
|
||||
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Сохранение...' : 'Сохранить'}</button>
|
||||
|
||||
@ -3,11 +3,12 @@ import { useUser } from '../context/UserContext';
|
||||
import EditCampaignModal from '../modals/EditCampaignModal';
|
||||
import CreateCampaignModal from '../modals/CreateCampaignModal';
|
||||
import Paginator from '../components/Paginator';
|
||||
import styles from '../styles/Common.module.css';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function CampaignPage() {
|
||||
const { token, user } = useUser();
|
||||
const { token } = useUser();
|
||||
const [campaigns, setCampaigns] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
@ -98,14 +99,14 @@ function CampaignPage() {
|
||||
|
||||
const fetchSmtpServers = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/mail/smtp-servers?limit=1000', {
|
||||
const res = await fetch('/api/mail/smtp-servers', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && Array.isArray(data)) {
|
||||
setSmtpServers(data);
|
||||
} else if (res.ok && Array.isArray(data.rows)) {
|
||||
if (res.ok && Array.isArray(data.rows)) {
|
||||
setSmtpServers(data.rows);
|
||||
} else if (res.ok && Array.isArray(data)) {
|
||||
setSmtpServers(data);
|
||||
} else {
|
||||
setSmtpServers([]);
|
||||
}
|
||||
@ -136,10 +137,7 @@ function CampaignPage() {
|
||||
};
|
||||
|
||||
const handleEdit = (c) => {
|
||||
setEditCampaign({
|
||||
...c,
|
||||
smtp_server_ids: c.SmtpServers ? c.SmtpServers.map(s => s.id) : [],
|
||||
});
|
||||
setEditCampaign(c);
|
||||
};
|
||||
|
||||
const handleEditSave = async (e) => {
|
||||
@ -152,7 +150,7 @@ function CampaignPage() {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ ...editCampaign, user_id: user?.id, smtp_server_ids: editCampaign.smtp_server_ids })
|
||||
body: JSON.stringify(editCampaign)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
@ -169,14 +167,7 @@ function CampaignPage() {
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setCreateCampaign({
|
||||
group_id: groups[0]?.id || '',
|
||||
template_version_id: versions[0]?.id || '',
|
||||
subject_override: '',
|
||||
scheduled_at: '',
|
||||
status: 'draft',
|
||||
smtp_server_ids: [],
|
||||
});
|
||||
setCreateCampaign({ group_id: '', template_version_id: '', subject_override: '', status: 'draft', scheduled_at: '' });
|
||||
};
|
||||
|
||||
const handleCreateSave = async (e) => {
|
||||
@ -189,7 +180,7 @@ function CampaignPage() {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ ...createCampaign, user_id: user?.id, smtp_server_ids: createCampaign.smtp_server_ids })
|
||||
body: JSON.stringify(createCampaign)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
@ -212,46 +203,71 @@ function CampaignPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
||||
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить кампанию</button>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.pageHeader}>
|
||||
<h2 className={styles.pageTitle}>Кампания</h2>
|
||||
<div className={styles.pageActions}>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className={`${styles.button} ${styles.buttonGradient}`}
|
||||
>
|
||||
+ Добавить кампанию
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Кампания</h2>
|
||||
{loading && <div>Загрузка...</div>}
|
||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
||||
|
||||
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<thead style={{ background: '#f3f4f6' }}>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.tableHeader}>
|
||||
<tr>
|
||||
<th style={thStyle}>ID</th>
|
||||
<th style={thStyle}>Группа</th>
|
||||
<th style={thStyle}>Версия шаблона</th>
|
||||
<th style={thStyle}>Тема</th>
|
||||
<th style={thStyle}>Статус</th>
|
||||
<th style={thStyle}>Запланировано</th>
|
||||
<th style={thStyle}></th>
|
||||
<th className={styles.tableHeaderCell}>ID</th>
|
||||
<th className={styles.tableHeaderCell}>Группа</th>
|
||||
<th className={styles.tableHeaderCell}>Версия шаблона</th>
|
||||
<th className={styles.tableHeaderCell}>Тема</th>
|
||||
<th className={styles.tableHeaderCell}>Статус</th>
|
||||
<th className={styles.tableHeaderCell}>Запланировано</th>
|
||||
<th className={styles.tableHeaderCell}>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{campaigns.map(c => (
|
||||
<tr key={c.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||
<td style={tdStyle}>{c.id}</td>
|
||||
<td style={tdStyle}>{getGroupName(c.group_id)}</td>
|
||||
<td style={tdStyle}>{getVersionName(c.template_version_id)}</td>
|
||||
<td style={tdStyle}>{c.subject_override || ''}</td>
|
||||
<td style={tdStyle}>{c.status}</td>
|
||||
<td style={tdStyle}>{c.scheduled_at ? new Date(c.scheduled_at).toLocaleString() : ''}</td>
|
||||
<td style={tdStyle}>
|
||||
<button onClick={() => handleEdit(c)} style={btnStyle}>Редактировать</button>
|
||||
<button onClick={() => handleDelete(c.id)} style={btnStyle} disabled={deleteLoading === c.id}>
|
||||
<tr key={c.id} className={styles.tableRow}>
|
||||
<td className={styles.tableCell}>{c.id}</td>
|
||||
<td className={styles.tableCell}>{getGroupName(c.group_id)}</td>
|
||||
<td className={styles.tableCell}>{getVersionName(c.template_version_id)}</td>
|
||||
<td className={styles.tableCell}>{c.subject_override || ''}</td>
|
||||
<td className={styles.tableCell}>{c.status}</td>
|
||||
<td className={styles.tableCell}>{c.scheduled_at ? new Date(c.scheduled_at).toLocaleString() : ''}</td>
|
||||
<td className={styles.tableCellActions}>
|
||||
<button
|
||||
onClick={() => handleEdit(c)}
|
||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
className={`${styles.button} ${styles.buttonDanger}`}
|
||||
disabled={deleteLoading === c.id}
|
||||
>
|
||||
{deleteLoading === c.id ? 'Удаление...' : 'Удалить'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{campaigns.length === 0 && (
|
||||
<tr><td colSpan={7} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
||||
<tr>
|
||||
<td colSpan={7} className={styles.emptyState}>
|
||||
<div className={styles.emptyStateIcon}>📧</div>
|
||||
<div className={styles.emptyStateTitle}>Нет кампаний</div>
|
||||
<div className={styles.emptyStateText}>Создайте первую кампанию для начала работы</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -263,6 +279,7 @@ function CampaignPage() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editCampaign && (
|
||||
<EditCampaignModal
|
||||
isOpen={!!editCampaign}
|
||||
@ -277,6 +294,7 @@ function CampaignPage() {
|
||||
getVersionName={getVersionName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{createCampaign && (
|
||||
<CreateCampaignModal
|
||||
isOpen={!!createCampaign}
|
||||
@ -295,9 +313,4 @@ function CampaignPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
||||
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
|
||||
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
|
||||
|
||||
export default CampaignPage;
|
||||
@ -9,6 +9,7 @@ import UnsubscribedPage from './UnsubscribedPage';
|
||||
import GroupsPage from './GroupsPage';
|
||||
import DeliveryHistoryPage from './DeliveryHistoryPage';
|
||||
import CampaignPage from './CampaignPage';
|
||||
import styles from './Dashboard.module.css';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [active, setActive] = useState('smtp');
|
||||
@ -33,11 +34,11 @@ const Dashboard = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh', background: '#f8fafc' }}>
|
||||
<div className={styles.dashboard}>
|
||||
<SideMenu active={active} onSelect={setActive} />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<div className={styles.content}>
|
||||
<Header user={user} onLogout={handleLogout} />
|
||||
<div style={{ flex: 1, padding: 32 }}>
|
||||
<div className={styles.pageContent}>
|
||||
{renderPage()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
53
frontend/src/pages/Dashboard.module.css
Normal file
53
frontend/src/pages/Dashboard.module.css
Normal file
@ -0,0 +1,53 @@
|
||||
.dashboard {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0; /* Предотвращает переполнение на мобильных */
|
||||
}
|
||||
|
||||
.pageContent {
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Адаптивные стили для планшетов */
|
||||
@media (max-width: 1024px) {
|
||||
.pageContent {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Адаптивные стили для мобильных устройств */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.pageContent {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.pageContent {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Улучшенная поддержка touch устройств */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.pageContent {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useUser } from '../context/UserContext';
|
||||
import Paginator from '../components/Paginator';
|
||||
import styles from '../styles/Common.module.css';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@ -44,40 +45,50 @@ function DeliveryHistoryPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>История отправок</h2>
|
||||
{loading && <div>Загрузка...</div>}
|
||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
||||
<div className={styles.container}>
|
||||
<div className={styles.pageHeader}>
|
||||
<h2 className={styles.pageTitle}>История отправок</h2>
|
||||
</div>
|
||||
|
||||
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<thead style={{ background: '#f3f4f6' }}>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.tableHeader}>
|
||||
<tr>
|
||||
<th style={thStyle}>ID</th>
|
||||
<th style={thStyle}>Email</th>
|
||||
<th style={thStyle}>Статус</th>
|
||||
<th style={thStyle}>Дата отправки</th>
|
||||
<th style={thStyle}>Открыто</th>
|
||||
<th style={thStyle}>Клик</th>
|
||||
<th style={thStyle}>Ошибка</th>
|
||||
<th style={thStyle}>Кампания</th>
|
||||
<th className={styles.tableHeaderCell}>ID</th>
|
||||
<th className={styles.tableHeaderCell}>Email</th>
|
||||
<th className={styles.tableHeaderCell}>Статус</th>
|
||||
<th className={styles.tableHeaderCell}>Дата отправки</th>
|
||||
<th className={styles.tableHeaderCell}>Открыто</th>
|
||||
<th className={styles.tableHeaderCell}>Клик</th>
|
||||
<th className={styles.tableHeaderCell}>Ошибка</th>
|
||||
<th className={styles.tableHeaderCell}>Кампания</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map(l => (
|
||||
<tr key={l.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||
<td style={tdStyle}>{l.id}</td>
|
||||
<td style={tdStyle}>{l.Subscriber?.email || l.subscriber_id}</td>
|
||||
<td style={tdStyle}>{l.status}</td>
|
||||
<td style={tdStyle}>{l.sent_at ? new Date(l.sent_at).toLocaleString() : ''}</td>
|
||||
<td style={tdStyle}>{l.opened_at ? new Date(l.opened_at).toLocaleString() : ''}</td>
|
||||
<td style={tdStyle}>{l.clicked_at ? new Date(l.clicked_at).toLocaleString() : ''}</td>
|
||||
<td style={tdStyle}>{l.error_message || ''}</td>
|
||||
<td style={tdStyle}>{l.campaign_id}</td>
|
||||
<tr key={l.id} className={styles.tableRow}>
|
||||
<td className={styles.tableCell}>{l.id}</td>
|
||||
<td className={styles.tableCell}>{l.Subscriber?.email || l.subscriber_id}</td>
|
||||
<td className={styles.tableCell}>{l.status}</td>
|
||||
<td className={styles.tableCell}>{l.sent_at ? new Date(l.sent_at).toLocaleString() : ''}</td>
|
||||
<td className={styles.tableCell}>{l.opened_at ? new Date(l.opened_at).toLocaleString() : ''}</td>
|
||||
<td className={styles.tableCell}>{l.clicked_at ? new Date(l.clicked_at).toLocaleString() : ''}</td>
|
||||
<td className={styles.tableCell}>{l.error_message || ''}</td>
|
||||
<td className={styles.tableCell}>{l.campaign_id}</td>
|
||||
</tr>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<tr><td colSpan={8} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
||||
<tr>
|
||||
<td colSpan={8} className={styles.emptyState}>
|
||||
<div className={styles.emptyStateIcon}>📊</div>
|
||||
<div className={styles.emptyStateTitle}>Нет истории отправок</div>
|
||||
<div className={styles.emptyStateText}>История отправок появится после запуска кампаний</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -93,7 +104,4 @@ function DeliveryHistoryPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
||||
|
||||
export default DeliveryHistoryPage;
|
||||
@ -4,11 +4,12 @@ import EditTemplateModal from '../modals/EditTemplateModal';
|
||||
import CreateTemplateModal from '../modals/CreateTemplateModal';
|
||||
import TemplateVersionsPage from './TemplateVersionsPage';
|
||||
import Paginator from '../components/Paginator';
|
||||
import styles from '../styles/Common.module.css';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function EmailTemplatesPage() {
|
||||
const { token, user } = useUser();
|
||||
const { token } = useUser();
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
@ -36,7 +37,7 @@ function EmailTemplatesPage() {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Ошибка загрузки шаблонов');
|
||||
setError(data.error || 'Ошибка загрузки');
|
||||
setTemplates([]);
|
||||
setTotal(0);
|
||||
} else {
|
||||
@ -87,7 +88,7 @@ function EmailTemplatesPage() {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ ...editTemplate, user_id: user?.id })
|
||||
body: JSON.stringify(editTemplate)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
@ -104,7 +105,7 @@ function EmailTemplatesPage() {
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setCreateTemplate({ name: '' });
|
||||
setCreateTemplate({ name: '', description: '' });
|
||||
};
|
||||
|
||||
const handleCreateSave = async (e) => {
|
||||
@ -117,7 +118,7 @@ function EmailTemplatesPage() {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ ...createTemplate, user_id: user?.id })
|
||||
body: JSON.stringify(createTemplate)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
@ -138,38 +139,65 @@ function EmailTemplatesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
||||
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить шаблон</button>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.pageHeader}>
|
||||
<h2 className={styles.pageTitle}>Шаблоны писем</h2>
|
||||
<div className={styles.pageActions}>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className={`${styles.button} ${styles.buttonGradient}`}
|
||||
>
|
||||
+ Добавить шаблон
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Шаблоны писем</h2>
|
||||
{loading && <div>Загрузка...</div>}
|
||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
||||
|
||||
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<thead style={{ background: '#f3f4f6' }}>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.tableHeader}>
|
||||
<tr>
|
||||
<th style={thStyle}>ID</th>
|
||||
<th style={thStyle}>Название</th>
|
||||
<th style={thStyle}></th>
|
||||
<th className={styles.tableHeaderCell}>ID</th>
|
||||
<th className={styles.tableHeaderCell}>Название</th>
|
||||
<th className={styles.tableHeaderCell}>Описание</th>
|
||||
<th className={styles.tableHeaderCell}>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map(t => (
|
||||
<tr key={t.id} style={{ borderBottom: '1px solid #e5e7eb', cursor: 'pointer' }} onClick={() => setSelectedTemplate(t)}>
|
||||
<td style={tdStyle}>{t.id}</td>
|
||||
<td style={tdStyle}>{t.name}</td>
|
||||
<td style={tdStyle}>
|
||||
<button onClick={e => { e.stopPropagation(); handleEdit(t); }} style={btnStyle}>Редактировать</button>
|
||||
<button onClick={e => { e.stopPropagation(); handleDelete(t.id); }} style={btnStyle} disabled={deleteLoading === t.id}>
|
||||
<tr key={t.id} className={styles.tableRow} style={{ cursor: 'pointer' }} onClick={() => setSelectedTemplate(t)}>
|
||||
<td className={styles.tableCell}>{t.id}</td>
|
||||
<td className={styles.tableCell}>{t.name}</td>
|
||||
<td className={styles.tableCell}>{t.description}</td>
|
||||
<td className={styles.tableCellActions}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(t); }}
|
||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(t.id); }}
|
||||
className={`${styles.button} ${styles.buttonDanger}`}
|
||||
disabled={deleteLoading === t.id}
|
||||
>
|
||||
{deleteLoading === t.id ? 'Удаление...' : 'Удалить'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{templates.length === 0 && (
|
||||
<tr><td colSpan={3} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
||||
<tr>
|
||||
<td colSpan={4} className={styles.emptyState}>
|
||||
<div className={styles.emptyStateIcon}>📝</div>
|
||||
<div className={styles.emptyStateTitle}>Нет шаблонов</div>
|
||||
<div className={styles.emptyStateText}>Создайте первый шаблон письма</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -181,6 +209,7 @@ function EmailTemplatesPage() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editTemplate && (
|
||||
<EditTemplateModal
|
||||
isOpen={!!editTemplate}
|
||||
@ -191,6 +220,7 @@ function EmailTemplatesPage() {
|
||||
onSave={handleEditSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{createTemplate && (
|
||||
<CreateTemplateModal
|
||||
isOpen={!!createTemplate}
|
||||
@ -205,9 +235,4 @@ function EmailTemplatesPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
||||
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
|
||||
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
|
||||
|
||||
export default EmailTemplatesPage;
|
||||
@ -5,6 +5,7 @@ import CreateGroupModal from '../modals/CreateGroupModal';
|
||||
import EditSubscriberModal from '../modals/EditSubscriberModal';
|
||||
import CreateSubscriberModal from '../modals/CreateSubscriberModal';
|
||||
import Paginator from '../components/Paginator';
|
||||
import styles from '../styles/Common.module.css';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@ -139,40 +140,65 @@ function GroupsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
||||
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить группу</button>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.pageHeader}>
|
||||
<h2 className={styles.pageTitle}>Подписчики и группы</h2>
|
||||
<div className={styles.pageActions}>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className={`${styles.button} ${styles.buttonGradient}`}
|
||||
>
|
||||
+ Добавить группу
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Группы подписчиков</h2>
|
||||
{loading && <div>Загрузка...</div>}
|
||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
||||
|
||||
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<thead style={{ background: '#f3f4f6' }}>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.tableHeader}>
|
||||
<tr>
|
||||
<th style={thStyle}>ID</th>
|
||||
<th style={thStyle}>Название</th>
|
||||
<th style={thStyle}>Описание</th>
|
||||
<th style={thStyle}></th>
|
||||
<th className={styles.tableHeaderCell}>ID</th>
|
||||
<th className={styles.tableHeaderCell}>Название</th>
|
||||
<th className={styles.tableHeaderCell}>Описание</th>
|
||||
<th className={styles.tableHeaderCell}>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map(g => (
|
||||
<tr key={g.id} style={{ borderBottom: '1px solid #e5e7eb', cursor: 'pointer' }} onClick={() => setSelectedGroup(g)}>
|
||||
<td style={tdStyle}>{g.id}</td>
|
||||
<td style={tdStyle}>{g.name}</td>
|
||||
<td style={tdStyle}>{g.description}</td>
|
||||
<td style={tdStyle}>
|
||||
<button onClick={e => { e.stopPropagation(); handleEdit(g); }} style={btnStyle}>Редактировать</button>
|
||||
<button onClick={e => { e.stopPropagation(); handleDelete(g.id); }} style={btnStyle} disabled={deleteLoading === g.id}>
|
||||
<tr key={g.id} className={styles.tableRow} style={{ cursor: 'pointer' }} onClick={() => setSelectedGroup(g)}>
|
||||
<td className={styles.tableCell}>{g.id}</td>
|
||||
<td className={styles.tableCell}>{g.name}</td>
|
||||
<td className={styles.tableCell}>{g.description}</td>
|
||||
<td className={styles.tableCellActions}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(g); }}
|
||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(g.id); }}
|
||||
className={`${styles.button} ${styles.buttonDanger}`}
|
||||
disabled={deleteLoading === g.id}
|
||||
>
|
||||
{deleteLoading === g.id ? 'Удаление...' : 'Удалить'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{groups.length === 0 && (
|
||||
<tr><td colSpan={4} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
||||
<tr>
|
||||
<td colSpan={4} className={styles.emptyState}>
|
||||
<div className={styles.emptyStateIcon}>👥</div>
|
||||
<div className={styles.emptyStateTitle}>Нет групп</div>
|
||||
<div className={styles.emptyStateText}>Создайте первую группу для организации подписчиков</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -184,6 +210,7 @@ function GroupsPage() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editGroup && (
|
||||
<EditGroupModal
|
||||
isOpen={!!editGroup}
|
||||
@ -194,6 +221,7 @@ function GroupsPage() {
|
||||
onSave={handleEditSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{createGroup && (
|
||||
<CreateGroupModal
|
||||
isOpen={!!createGroup}
|
||||
@ -349,43 +377,75 @@ function SubscribersInGroupPage({ group, onBack, token }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={onBack} style={{ marginBottom: 18, background: 'none', border: 'none', color: '#6366f1', fontWeight: 600, fontSize: 16, cursor: 'pointer' }}>← Назад к группам</button>
|
||||
<h2>Подписчики группы: {group.name}</h2>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
||||
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить подписчика</button>
|
||||
<div className={styles.container}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className={`${styles.button} ${styles.buttonSecondary}`}
|
||||
style={{ marginBottom: 18 }}
|
||||
>
|
||||
← Назад к группам
|
||||
</button>
|
||||
|
||||
<div className={styles.pageHeader}>
|
||||
<h2 className={styles.pageTitle}>Подписчики группы: {group.name}</h2>
|
||||
<div className={styles.pageActions}>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className={`${styles.button} ${styles.buttonGradient}`}
|
||||
>
|
||||
+ Добавить подписчика
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{loading && <div>Загрузка...</div>}
|
||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
||||
|
||||
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<thead style={{ background: '#f3f4f6' }}>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.tableHeader}>
|
||||
<tr>
|
||||
<th style={thStyle}>ID</th>
|
||||
<th style={thStyle}>Email</th>
|
||||
<th style={thStyle}>Имя</th>
|
||||
<th style={thStyle}>Статус</th>
|
||||
<th style={thStyle}></th>
|
||||
<th className={styles.tableHeaderCell}>ID</th>
|
||||
<th className={styles.tableHeaderCell}>Email</th>
|
||||
<th className={styles.tableHeaderCell}>Имя</th>
|
||||
<th className={styles.tableHeaderCell}>Статус</th>
|
||||
<th className={styles.tableHeaderCell}>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subs.map(gs => (
|
||||
<tr key={gs.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||
<td style={tdStyle}>{gs.Subscriber?.id || ''}</td>
|
||||
<td style={tdStyle}>{gs.Subscriber?.email || ''}</td>
|
||||
<td style={tdStyle}>{gs.Subscriber?.name || ''}</td>
|
||||
<td style={tdStyle}>{gs.Subscriber?.status || ''}</td>
|
||||
<td style={tdStyle}>
|
||||
<button onClick={() => handleEdit(gs)} style={btnStyle}>Редактировать</button>
|
||||
<button onClick={() => handleDelete(gs.id)} style={btnStyle} disabled={deleteLoading === gs.id}>
|
||||
<tr key={gs.id} className={styles.tableRow}>
|
||||
<td className={styles.tableCell}>{gs.Subscriber?.id || ''}</td>
|
||||
<td className={styles.tableCell}>{gs.Subscriber?.email || ''}</td>
|
||||
<td className={styles.tableCell}>{gs.Subscriber?.name || ''}</td>
|
||||
<td className={styles.tableCell}>{gs.Subscriber?.status || ''}</td>
|
||||
<td className={styles.tableCellActions}>
|
||||
<button
|
||||
onClick={() => handleEdit(gs)}
|
||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(gs.id)}
|
||||
className={`${styles.button} ${styles.buttonDanger}`}
|
||||
disabled={deleteLoading === gs.id}
|
||||
>
|
||||
{deleteLoading === gs.id ? 'Удаление...' : 'Удалить'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{subs.length === 0 && (
|
||||
<tr><td colSpan={5} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
||||
<tr>
|
||||
<td colSpan={5} className={styles.emptyState}>
|
||||
<div className={styles.emptyStateIcon}>📧</div>
|
||||
<div className={styles.emptyStateTitle}>Нет подписчиков</div>
|
||||
<div className={styles.emptyStateText}>В этой группе пока нет подписчиков</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -397,6 +457,7 @@ function SubscribersInGroupPage({ group, onBack, token }) {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editSub && (
|
||||
<EditSubscriberModal
|
||||
isOpen={!!editSub}
|
||||
@ -407,6 +468,7 @@ function SubscribersInGroupPage({ group, onBack, token }) {
|
||||
onSave={handleEditSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{createSub && (
|
||||
<CreateSubscriberModal
|
||||
isOpen={!!createSub}
|
||||
@ -421,9 +483,4 @@ function SubscribersInGroupPage({ group, onBack, token }) {
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
||||
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
|
||||
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
|
||||
|
||||
export default GroupsPage;
|
||||
@ -4,12 +4,14 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, #e0e7ff 0%, #f0fdfa 100%);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 340px;
|
||||
max-width: 100%;
|
||||
gap: 20px;
|
||||
padding: 36px;
|
||||
border-radius: 16px;
|
||||
@ -37,9 +39,12 @@
|
||||
outline: none;
|
||||
transition: border 0.2s;
|
||||
background: #f8fafc;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border: 1.5px solid #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.button {
|
||||
@ -53,6 +58,16 @@
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
||||
transition: background 0.2s;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: linear-gradient(90deg, #4f46e5 0%, #0891b2 100%);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
@ -60,4 +75,94 @@
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
margin-top: -10px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* Адаптивные стили для планшетов */
|
||||
@media (max-width: 1024px) {
|
||||
.form {
|
||||
width: 320px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-size: 15px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Адаптивные стили для мобильных устройств */
|
||||
@media (max-width: 768px) {
|
||||
.wrapper {
|
||||
padding: 12px;
|
||||
align-items: flex-start;
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 28px 24px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-size: 16px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.wrapper {
|
||||
padding: 8px;
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.form {
|
||||
padding: 24px 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-size: 15px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Улучшенная поддержка touch устройств */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.input {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 52px;
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { useUser } from '../context/UserContext';
|
||||
import EditSmtpModal from '../modals/EditSmtpModal';
|
||||
import CreateSmtpModal from '../modals/CreateSmtpModal';
|
||||
import Paginator from '../components/Paginator';
|
||||
import styles from '../styles/Common.module.css';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@ -117,9 +118,7 @@ function SmtpServersPage() {
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setCreateServer({
|
||||
name: '', host: '', port: 587, secure: false, username: '', password: '', from_email: ''
|
||||
});
|
||||
setCreateServer({ name: '', host: '', port: 587, secure: false, username: '', password: '', from_email: '', from_name: '' });
|
||||
};
|
||||
|
||||
const handleCreateSave = async (e) => {
|
||||
@ -130,14 +129,13 @@ function SmtpServersPage() {
|
||||
}
|
||||
setCreateLoading(true);
|
||||
try {
|
||||
const { group_id, ...serverData } = createServer;
|
||||
const res = await fetch('/api/mail/smtp-servers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ ...serverData, user_id: user?.id })
|
||||
body: JSON.stringify({ ...createServer, user_id: user?.id })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
@ -154,48 +152,73 @@ function SmtpServersPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
||||
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить SMTP-сервер</button>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.pageHeader}>
|
||||
<h2 className={styles.pageTitle}>SMTP-серверы</h2>
|
||||
<div className={styles.pageActions}>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className={`${styles.button} ${styles.buttonGradient}`}
|
||||
>
|
||||
+ Добавить SMTP-сервер
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h2>SMTP-серверы</h2>
|
||||
{loading && <div>Загрузка...</div>}
|
||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
||||
|
||||
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<thead style={{ background: '#f3f4f6' }}>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.tableHeader}>
|
||||
<tr>
|
||||
<th style={thStyle}>ID</th>
|
||||
<th style={thStyle}>Название</th>
|
||||
<th style={thStyle}>Host</th>
|
||||
<th style={thStyle}>Port</th>
|
||||
<th style={thStyle}>Secure</th>
|
||||
<th style={thStyle}>Пользователь</th>
|
||||
<th style={thStyle}>Отправитель</th>
|
||||
<th style={thStyle}></th>
|
||||
<th className={styles.tableHeaderCell}>ID</th>
|
||||
<th className={styles.tableHeaderCell}>Название</th>
|
||||
<th className={styles.tableHeaderCell}>Host</th>
|
||||
<th className={styles.tableHeaderCell}>Port</th>
|
||||
<th className={styles.tableHeaderCell}>Secure</th>
|
||||
<th className={styles.tableHeaderCell}>Пользователь</th>
|
||||
<th className={styles.tableHeaderCell}>Отправитель</th>
|
||||
<th className={styles.tableHeaderCell}>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{servers.map(s => (
|
||||
<tr key={s.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||
<td style={tdStyle}>{s.id}</td>
|
||||
<td style={tdStyle}>{s.name}</td>
|
||||
<td style={tdStyle}>{s.host}</td>
|
||||
<td style={tdStyle}>{s.port}</td>
|
||||
<td style={tdStyle}>{s.secure ? 'Да' : 'Нет'}</td>
|
||||
<td style={tdStyle}>{s.username}</td>
|
||||
<td style={tdStyle}>{s.from_email}</td>
|
||||
<td style={tdStyle}>
|
||||
<button onClick={() => handleEdit(s)} style={btnStyle}>Редактировать</button>
|
||||
<button onClick={() => handleDelete(s.id)} style={btnStyle} disabled={deleteLoading === s.id}>
|
||||
<tr key={s.id} className={styles.tableRow}>
|
||||
<td className={styles.tableCell}>{s.id}</td>
|
||||
<td className={styles.tableCell}>{s.name}</td>
|
||||
<td className={styles.tableCell}>{s.host}</td>
|
||||
<td className={styles.tableCell}>{s.port}</td>
|
||||
<td className={styles.tableCell}>{s.secure ? 'Да' : 'Нет'}</td>
|
||||
<td className={styles.tableCell}>{s.username}</td>
|
||||
<td className={styles.tableCell}>{s.from_email}</td>
|
||||
<td className={styles.tableCellActions}>
|
||||
<button
|
||||
onClick={() => handleEdit(s)}
|
||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(s.id)}
|
||||
className={`${styles.button} ${styles.buttonDanger}`}
|
||||
disabled={deleteLoading === s.id}
|
||||
>
|
||||
{deleteLoading === s.id ? 'Удаление...' : 'Удалить'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
<tr><td colSpan={8} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
||||
<tr>
|
||||
<td colSpan={8} className={styles.emptyState}>
|
||||
<div className={styles.emptyStateIcon}>📧</div>
|
||||
<div className={styles.emptyStateTitle}>Нет SMTP-серверов</div>
|
||||
<div className={styles.emptyStateText}>Добавьте первый SMTP-сервер для начала работы</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -207,6 +230,7 @@ function SmtpServersPage() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editServer && (
|
||||
<EditSmtpModal
|
||||
isOpen={!!editServer}
|
||||
@ -217,6 +241,7 @@ function SmtpServersPage() {
|
||||
onSave={handleEditSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{createServer && (
|
||||
<CreateSmtpModal
|
||||
isOpen={!!createServer}
|
||||
@ -231,9 +256,4 @@ function SmtpServersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
||||
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
|
||||
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
|
||||
|
||||
export default SmtpServersPage;
|
||||
@ -1,5 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import CreateTemplateVersionModal from '../modals/CreateTemplateVersionModal';
|
||||
import Paginator from '../components/Paginator';
|
||||
import styles from '../styles/Common.module.css';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@ -128,58 +130,102 @@ export default function TemplateVersionsPage({ template, onBack, token }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={onBack} style={{ marginBottom: 18, background: 'none', border: 'none', color: '#6366f1', fontWeight: 600, fontSize: 16, cursor: 'pointer' }}>← Назад к шаблонам</button>
|
||||
<h2>Версии шаблона: {template.name}</h2>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
||||
<button onClick={handleAddVersion} style={addBtnStyle}>+ Добавить версию</button>
|
||||
<div className={styles.container}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className={`${styles.button} ${styles.buttonSecondary}`}
|
||||
style={{ marginBottom: 18 }}
|
||||
>
|
||||
← Назад к шаблонам
|
||||
</button>
|
||||
|
||||
<div className={styles.pageHeader}>
|
||||
<h2 className={styles.pageTitle}>Версии шаблона: {template.name}</h2>
|
||||
<div className={styles.pageActions}>
|
||||
<button
|
||||
onClick={handleAddVersion}
|
||||
className={`${styles.button} ${styles.buttonGradient}`}
|
||||
>
|
||||
+ Добавить версию
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{loading && <div>Загрузка...</div>}
|
||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
||||
|
||||
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<thead style={{ background: '#f3f4f6' }}>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.tableHeader}>
|
||||
<tr>
|
||||
<th style={thStyle}>ID</th>
|
||||
<th style={thStyle}>Версия</th>
|
||||
<th style={thStyle}>Тема</th>
|
||||
<th style={thStyle}>HTML</th>
|
||||
<th style={thStyle}>Текст</th>
|
||||
<th style={thStyle}></th>
|
||||
<th className={styles.tableHeaderCell}>ID</th>
|
||||
<th className={styles.tableHeaderCell}>Версия</th>
|
||||
<th className={styles.tableHeaderCell}>Тема</th>
|
||||
<th className={styles.tableHeaderCell}>HTML</th>
|
||||
<th className={styles.tableHeaderCell}>Текст</th>
|
||||
<th className={styles.tableHeaderCell}>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{versions.map(v => (
|
||||
<tr key={v.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||
<td style={tdStyle}>{v.id}</td>
|
||||
<td style={tdStyle}>{v.version_number}</td>
|
||||
<td style={tdStyle}>{v.subject}</td>
|
||||
<td style={tdStyle}>{v.body_html ? <span style={{ color: '#10b981' }}>Есть</span> : <span style={{ color: '#ef4444' }}>Нет</span>}</td>
|
||||
<td style={tdStyle}>{v.body_text ? <span style={{ color: '#10b981' }}>Есть</span> : <span style={{ color: '#ef4444' }}>Нет</span>}</td>
|
||||
<td style={tdStyle}>
|
||||
<button onClick={() => handleEditVersion(v)} style={btnStyle}>Редактировать</button>
|
||||
<button onClick={() => handleDeleteVersion(v.id)} style={btnStyle} disabled={deleteLoading === v.id}>
|
||||
<tr key={v.id} className={styles.tableRow}>
|
||||
<td className={styles.tableCell}>{v.id}</td>
|
||||
<td className={styles.tableCell}>{v.version_number}</td>
|
||||
<td className={styles.tableCell}>{v.subject}</td>
|
||||
<td className={styles.tableCell}>
|
||||
{v.body_html ?
|
||||
<span style={{ color: '#10b981' }}>Есть</span> :
|
||||
<span style={{ color: '#ef4444' }}>Нет</span>
|
||||
}
|
||||
</td>
|
||||
<td className={styles.tableCell}>
|
||||
{v.body_text ?
|
||||
<span style={{ color: '#10b981' }}>Есть</span> :
|
||||
<span style={{ color: '#ef4444' }}>Нет</span>
|
||||
}
|
||||
</td>
|
||||
<td className={styles.tableCellActions}>
|
||||
<button
|
||||
onClick={() => handleEditVersion(v)}
|
||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteVersion(v.id)}
|
||||
className={`${styles.button} ${styles.buttonDanger}`}
|
||||
disabled={deleteLoading === v.id}
|
||||
>
|
||||
{deleteLoading === v.id ? 'Удаление...' : 'Удалить'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{versions.length === 0 && (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет версий</td></tr>
|
||||
<tr>
|
||||
<td colSpan={6} className={styles.emptyState}>
|
||||
<div className={styles.emptyStateIcon}>📄</div>
|
||||
<div className={styles.emptyStateTitle}>Нет версий</div>
|
||||
<div className={styles.emptyStateText}>Создайте первую версию для этого шаблона</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Пагинация если нужно */}
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} style={btnStyle}>Назад</button>
|
||||
<span style={{ margin: '0 12px' }}>Страница {page}</span>
|
||||
<button onClick={() => setPage(p => p + 1)} disabled={page * PAGE_SIZE >= total} style={btnStyle}>Вперёд</button>
|
||||
</div>
|
||||
<Paginator
|
||||
page={page}
|
||||
total={total}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{createVersionModal && (
|
||||
<CreateTemplateVersionModal
|
||||
isOpen={createVersionModal}
|
||||
@ -188,6 +234,7 @@ export default function TemplateVersionsPage({ template, onBack, token }) {
|
||||
onSave={handleCreateVersionSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editVersion && (
|
||||
<CreateTemplateVersionModal
|
||||
isOpen={!!editVersion}
|
||||
@ -199,9 +246,4 @@ export default function TemplateVersionsPage({ template, onBack, token }) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
||||
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
|
||||
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { useUser } from '../context/UserContext';
|
||||
import EditUnsubModal from '../modals/EditUnsubModal';
|
||||
import CreateUnsubModal from '../modals/CreateUnsubModal';
|
||||
import Paginator from '../components/Paginator';
|
||||
import styles from '../styles/Common.module.css';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@ -85,7 +86,7 @@ function UnsubscribedPage() {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ ...editSub, status: 'unsubscribed' })
|
||||
body: JSON.stringify(editSub)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
@ -102,7 +103,7 @@ function UnsubscribedPage() {
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setCreateSub({ email: '', name: '', status: 'unsubscribed' });
|
||||
setCreateSub({ email: '', name: '' });
|
||||
};
|
||||
|
||||
const handleCreateSave = async (e) => {
|
||||
@ -132,42 +133,67 @@ function UnsubscribedPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
||||
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить запись</button>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.pageHeader}>
|
||||
<h2 className={styles.pageTitle}>Отписались</h2>
|
||||
<div className={styles.pageActions}>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className={`${styles.button} ${styles.buttonGradient}`}
|
||||
>
|
||||
+ Добавить запись
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Отписались</h2>
|
||||
{loading && <div>Загрузка...</div>}
|
||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
||||
|
||||
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<thead style={{ background: '#f3f4f6' }}>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.tableHeader}>
|
||||
<tr>
|
||||
<th style={thStyle}>ID</th>
|
||||
<th style={thStyle}>Email</th>
|
||||
<th style={thStyle}>Имя</th>
|
||||
<th style={thStyle}>Дата отписки</th>
|
||||
<th style={thStyle}></th>
|
||||
<th className={styles.tableHeaderCell}>ID</th>
|
||||
<th className={styles.tableHeaderCell}>Email</th>
|
||||
<th className={styles.tableHeaderCell}>Имя</th>
|
||||
<th className={styles.tableHeaderCell}>Дата отписки</th>
|
||||
<th className={styles.tableHeaderCell}>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subs.map(s => (
|
||||
<tr key={s.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||
<td style={tdStyle}>{s.id}</td>
|
||||
<td style={tdStyle}>{s.email}</td>
|
||||
<td style={tdStyle}>{s.name}</td>
|
||||
<td style={tdStyle}>{s.unsubscribed_at ? new Date(s.unsubscribed_at).toLocaleString() : ''}</td>
|
||||
<td style={tdStyle}>
|
||||
<button onClick={() => handleEdit(s)} style={btnStyle}>Редактировать</button>
|
||||
<button onClick={() => handleDelete(s.id)} style={btnStyle} disabled={deleteLoading === s.id}>
|
||||
<tr key={s.id} className={styles.tableRow}>
|
||||
<td className={styles.tableCell}>{s.id}</td>
|
||||
<td className={styles.tableCell}>{s.email}</td>
|
||||
<td className={styles.tableCell}>{s.name}</td>
|
||||
<td className={styles.tableCell}>{s.unsubscribed_at ? new Date(s.unsubscribed_at).toLocaleString() : ''}</td>
|
||||
<td className={styles.tableCellActions}>
|
||||
<button
|
||||
onClick={() => handleEdit(s)}
|
||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(s.id)}
|
||||
className={`${styles.button} ${styles.buttonDanger}`}
|
||||
disabled={deleteLoading === s.id}
|
||||
>
|
||||
{deleteLoading === s.id ? 'Удаление...' : 'Удалить'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{subs.length === 0 && (
|
||||
<tr><td colSpan={5} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
||||
<tr>
|
||||
<td colSpan={5} className={styles.emptyState}>
|
||||
<div className={styles.emptyStateIcon}>🚫</div>
|
||||
<div className={styles.emptyStateTitle}>Нет отписок</div>
|
||||
<div className={styles.emptyStateText}>Список отписавшихся пользователей пуст</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -179,6 +205,7 @@ function UnsubscribedPage() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editSub && (
|
||||
<EditUnsubModal
|
||||
isOpen={!!editSub}
|
||||
@ -189,6 +216,7 @@ function UnsubscribedPage() {
|
||||
onSave={handleEditSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{createSub && (
|
||||
<CreateUnsubModal
|
||||
isOpen={!!createSub}
|
||||
@ -203,9 +231,4 @@ function UnsubscribedPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
||||
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
|
||||
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
|
||||
|
||||
export default UnsubscribedPage;
|
||||
@ -3,36 +3,31 @@ import { useUser } from '../context/UserContext';
|
||||
import EditUserModal from '../modals/EditUserModal';
|
||||
import CreateUserModal from '../modals/CreateUserModal';
|
||||
import Paginator from '../components/Paginator';
|
||||
import styles from '../styles/Common.module.css';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function UsersPage() {
|
||||
const { token } = useUser();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [usersTotal, setUsersTotal] = useState(0);
|
||||
const [usersPage, setUsersPage] = useState(1);
|
||||
const [usersLoading, setUsersLoading] = useState(false);
|
||||
const [usersError, setUsersError] = useState('');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [editUser, setEditUser] = useState(null);
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(null);
|
||||
const [createUser, setCreateUser] = useState(null);
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [roles, setRoles] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers(usersPage);
|
||||
fetchUsers(page);
|
||||
// eslint-disable-next-line
|
||||
}, [usersPage]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
}, [page]);
|
||||
|
||||
const fetchUsers = async (page) => {
|
||||
setUsersLoading(true);
|
||||
setUsersError('');
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const offset = (page - 1) * PAGE_SIZE;
|
||||
const res = await fetch(`/api/auth/users?limit=${PAGE_SIZE}&offset=${offset}`, {
|
||||
@ -40,41 +35,19 @@ function UsersPage() {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setUsersError(data.error || 'Ошибка загрузки пользователей');
|
||||
setError(data.error || 'Ошибка загрузки');
|
||||
setUsers([]);
|
||||
setUsersTotal(0);
|
||||
setTotal(0);
|
||||
} else {
|
||||
setUsers(Array.isArray(data) ? data : data.rows || []);
|
||||
setUsersTotal(data.count || (Array.isArray(data) ? data.length : 0));
|
||||
setUsers(Array.isArray(data.rows) ? data.rows : []);
|
||||
setTotal(data.count || 0);
|
||||
}
|
||||
} catch (e) {
|
||||
setUsersError('Ошибка сети');
|
||||
setError('Ошибка сети');
|
||||
setUsers([]);
|
||||
setUsersTotal(0);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/roles', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
const data = await res.json();
|
||||
console.log('Roles API response:', data); // Отладочная информация
|
||||
if (res.ok) {
|
||||
// API всегда возвращает объект с rows и count
|
||||
const rolesData = data.rows || [];
|
||||
console.log('Setting roles:', rolesData); // Отладочная информация
|
||||
setRoles(rolesData);
|
||||
} else {
|
||||
console.error('Roles API error:', data); // Отладочная информация
|
||||
setRoles([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Roles fetch error:', error); // Отладочная информация
|
||||
setRoles([]);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -90,7 +63,7 @@ function UsersPage() {
|
||||
const data = await res.json();
|
||||
alert(data.error || 'Ошибка удаления');
|
||||
} else {
|
||||
fetchUsers(usersPage);
|
||||
fetchUsers(page);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка сети');
|
||||
@ -113,18 +86,14 @@ function UsersPage() {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: editUser.email,
|
||||
name: editUser.name,
|
||||
role_id: editUser.role_id
|
||||
})
|
||||
body: JSON.stringify(editUser)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
alert(data.error || 'Ошибка обновления');
|
||||
} else {
|
||||
setEditUser(null);
|
||||
fetchUsers(usersPage);
|
||||
fetchUsers(page);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка сети');
|
||||
@ -134,12 +103,7 @@ function UsersPage() {
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
// Убеждаемся, что у нас есть роли перед созданием пользователя
|
||||
if (roles.length === 0) {
|
||||
alert('Загрузка ролей... Пожалуйста, подождите.');
|
||||
return;
|
||||
}
|
||||
setCreateUser({ email: '', name: '', role_id: roles[0]?.id || 1, password: '' });
|
||||
setCreateUser({ email: '', password: '', name: '', role: 'user' });
|
||||
};
|
||||
|
||||
const handleCreateSave = async (e) => {
|
||||
@ -159,7 +123,7 @@ function UsersPage() {
|
||||
alert(data.error || 'Ошибка создания');
|
||||
} else {
|
||||
setCreateUser(null);
|
||||
fetchUsers(usersPage);
|
||||
fetchUsers(page);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка сети');
|
||||
@ -168,99 +132,103 @@ function UsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleName = (role_id) => {
|
||||
const role = roles.find(r => r.id === role_id);
|
||||
return role ? role.name : role_id;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
||||
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить пользователя</button>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.pageHeader}>
|
||||
<h2 className={styles.pageTitle}>Управление пользователями</h2>
|
||||
<div className={styles.pageActions}>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className={`${styles.button} ${styles.buttonGradient}`}
|
||||
>
|
||||
+ Добавить пользователя
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Пользователи</h2>
|
||||
{usersLoading && <div>Загрузка...</div>}
|
||||
{usersError && <div style={{ color: 'red' }}>{usersError}</div>}
|
||||
{!usersLoading && !usersError && (
|
||||
|
||||
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<thead style={{ background: '#f3f4f6' }}>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.tableHeader}>
|
||||
<tr>
|
||||
<th style={thStyle}>ID</th>
|
||||
<th style={thStyle}>Email</th>
|
||||
<th style={thStyle}>Имя</th>
|
||||
<th style={thStyle}>Роль</th>
|
||||
<th style={thStyle}></th>
|
||||
<th className={styles.tableHeaderCell}>ID</th>
|
||||
<th className={styles.tableHeaderCell}>Email</th>
|
||||
<th className={styles.tableHeaderCell}>Имя</th>
|
||||
<th className={styles.tableHeaderCell}>Роль</th>
|
||||
<th className={styles.tableHeaderCell}>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||
<td style={tdStyle}>{u.id}</td>
|
||||
<td style={tdStyle}>{u.email}</td>
|
||||
<td style={tdStyle}>{u.name}</td>
|
||||
<td style={tdStyle}>{getRoleName(u.role_id)}</td>
|
||||
<td style={tdStyle}>
|
||||
<button onClick={() => handleEdit(u)} style={btnStyle}>Редактировать</button>
|
||||
<button onClick={() => handleDelete(u.id)} style={btnStyle} disabled={deleteLoading === u.id}>
|
||||
<tr key={u.id} className={styles.tableRow}>
|
||||
<td className={styles.tableCell}>{u.id}</td>
|
||||
<td className={styles.tableCell}>{u.email}</td>
|
||||
<td className={styles.tableCell}>{u.name}</td>
|
||||
<td className={styles.tableCell}>{u.role}</td>
|
||||
<td className={styles.tableCellActions}>
|
||||
<button
|
||||
onClick={() => handleEdit(u)}
|
||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(u.id)}
|
||||
className={`${styles.button} ${styles.buttonDanger}`}
|
||||
disabled={deleteLoading === u.id}
|
||||
>
|
||||
{deleteLoading === u.id ? 'Удаление...' : 'Удалить'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr><td colSpan={5} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
||||
<tr>
|
||||
<td colSpan={5} className={styles.emptyState}>
|
||||
<div className={styles.emptyStateIcon}>👤</div>
|
||||
<div className={styles.emptyStateTitle}>Нет пользователей</div>
|
||||
<div className={styles.emptyStateText}>Создайте первого пользователя</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<Paginator
|
||||
page={usersPage}
|
||||
total={usersTotal}
|
||||
page={page}
|
||||
total={total}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={setUsersPage}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editUser && (
|
||||
<EditUserModal
|
||||
isOpen={!!editUser}
|
||||
onClose={() => setEditUser(null)}
|
||||
user={editUser}
|
||||
roles={roles}
|
||||
loading={editLoading}
|
||||
onChange={setEditUser}
|
||||
onSave={handleEditSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{createUser && (
|
||||
<CreateUserModal
|
||||
isOpen={!!createUser}
|
||||
onClose={() => setCreateUser(null)}
|
||||
user={createUser}
|
||||
roles={roles}
|
||||
loading={createLoading}
|
||||
onChange={setCreateUser}
|
||||
onSave={handleCreateSave}
|
||||
/>
|
||||
)}
|
||||
{/* Отладочная информация */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div style={{ marginTop: 20, padding: 10, background: '#f0f0f0', fontSize: '12px' }}>
|
||||
<strong>Debug Info:</strong><br/>
|
||||
Roles count: {roles.length}<br/>
|
||||
Roles: {JSON.stringify(roles.slice(0, 3))}<br/>
|
||||
Edit user: {editUser ? JSON.stringify(editUser) : 'null'}<br/>
|
||||
Create user: {createUser ? JSON.stringify(createUser) : 'null'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
||||
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
|
||||
|
||||
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
|
||||
|
||||
export default UsersPage;
|
||||
386
frontend/src/styles/Common.module.css
Normal file
386
frontend/src/styles/Common.module.css
Normal file
@ -0,0 +1,386 @@
|
||||
/* Общие стили для таблиц */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
background: #f3f4f6;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.tableHeaderCell {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.tableRow {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.tableRow:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.tableCell {
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.tableCellActions {
|
||||
padding: 8px 16px;
|
||||
background: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Общие стили для кнопок */
|
||||
.button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.buttonPrimary {
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.buttonPrimary:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
.buttonSecondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.buttonSecondary:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.buttonDanger {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.buttonDanger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.buttonSuccess {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.buttonSuccess:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.buttonGradient {
|
||||
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
||||
}
|
||||
|
||||
.buttonGradient:hover {
|
||||
background: linear-gradient(90deg, #4f46e5 0%, #0891b2 100%);
|
||||
}
|
||||
|
||||
/* Общие стили для форм */
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.formGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.formInput {
|
||||
padding: 10px 12px;
|
||||
border: 1.5px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.formInput:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.formTextarea {
|
||||
padding: 10px 12px;
|
||||
border: 1.5px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
transition: border-color 0.2s;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.formTextarea:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.formSelect {
|
||||
padding: 10px 12px;
|
||||
border: 1.5px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.formSelect:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
/* Общие стили для контейнеров */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pageActions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Адаптивные стили для планшетов */
|
||||
@media (max-width: 1024px) {
|
||||
.tableHeaderCell,
|
||||
.tableCell {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.formInput,
|
||||
.formTextarea,
|
||||
.formSelect {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Адаптивные стили для мобильных устройств */
|
||||
@media (max-width: 768px) {
|
||||
.table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.tableHeaderCell,
|
||||
.tableCell {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.tableCellActions {
|
||||
padding: 6px 10px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.formInput,
|
||||
.formTextarea,
|
||||
.formSelect {
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.pageActions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.pageActions .button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.tableHeaderCell,
|
||||
.tableCell {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.tableCellActions {
|
||||
padding: 4px 8px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.formInput,
|
||||
.formTextarea,
|
||||
.formSelect {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Улучшенная поддержка touch устройств */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.button {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.formInput,
|
||||
.formTextarea,
|
||||
.formSelect {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.tableCellActions {
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Стили для состояний загрузки */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #10b981;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
/* Стили для пустого состояния */
|
||||
.emptyState {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.emptyStateIcon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.emptyStateTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.emptyStateText {
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
@ -9,6 +9,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
@ -16,9 +17,12 @@
|
||||
border-radius: 14px;
|
||||
padding: 32px 28px 24px 28px;
|
||||
min-width: 340px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
box-shadow: 0 12px 48px 0 rgba(31,38,135,0.22);
|
||||
position: relative;
|
||||
animation: modalIn 0.18s cubic-bezier(.4,1.3,.6,1);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
@ -36,9 +40,87 @@
|
||||
z-index: 2;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from { transform: translateY(40px) scale(0.98); opacity: 0; }
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
|
||||
/* Адаптивные стили для планшетов */
|
||||
@media (max-width: 1024px) {
|
||||
.modal {
|
||||
padding: 28px 24px 20px 24px;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
font-size: 24px;
|
||||
top: 10px;
|
||||
right: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Адаптивные стили для мобильных устройств */
|
||||
@media (max-width: 768px) {
|
||||
.overlay {
|
||||
padding: 12px;
|
||||
align-items: flex-start;
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
padding: 24px 20px 16px 20px;
|
||||
min-width: 280px;
|
||||
max-width: 95vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
font-size: 22px;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.overlay {
|
||||
padding: 8px;
|
||||
padding-top: 50px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
padding: 20px 16px 12px 16px;
|
||||
min-width: 260px;
|
||||
max-width: 98vw;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
font-size: 20px;
|
||||
top: 6px;
|
||||
right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Улучшенная поддержка touch устройств */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.closeBtn {
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
position: relative;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -15,28 +16,34 @@
|
||||
transition: border 0.2s;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.control:focus, .control:active {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
background: #f3f4f6;
|
||||
color: #b3b3b3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.value {
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
margin-left: 8px;
|
||||
font-size: 14px;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@ -51,6 +58,7 @@
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.option {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
@ -61,10 +69,96 @@
|
||||
color: #374151;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
/* Адаптивные стили для планшетов */
|
||||
@media (max-width: 1024px) {
|
||||
.wrapper {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.control {
|
||||
padding: 8px 10px;
|
||||
font-size: 15px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.option {
|
||||
padding: 6px 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Адаптивные стили для мобильных устройств */
|
||||
@media (max-width: 768px) {
|
||||
.wrapper {
|
||||
min-width: 140px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.control {
|
||||
padding: 10px 12px;
|
||||
font-size: 16px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.option {
|
||||
padding: 10px 16px;
|
||||
font-size: 15px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
max-height: 250px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.wrapper {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.control {
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.option {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Улучшенная поддержка touch устройств */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.control {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.option {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,9 @@
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
background: #f3f4f6;
|
||||
@ -17,7 +19,11 @@
|
||||
cursor: pointer;
|
||||
transition: background 0.18s, color 0.18s;
|
||||
box-shadow: 0 1px 4px 0 rgba(99,102,241,0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page {
|
||||
border: none;
|
||||
background: #f3f4f6;
|
||||
@ -30,10 +36,76 @@
|
||||
cursor: pointer;
|
||||
transition: background 0.18s, color 0.18s;
|
||||
box-shadow: 0 1px 4px 0 rgba(99,102,241,0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pageActive {
|
||||
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
||||
color: #fff;
|
||||
cursor: default;
|
||||
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
||||
}
|
||||
|
||||
/* Адаптивные стили для планшетов */
|
||||
@media (max-width: 1024px) {
|
||||
.wrapper {
|
||||
margin-top: 20px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn, .page {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.page {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Адаптивные стили для мобильных устройств */
|
||||
@media (max-width: 768px) {
|
||||
.wrapper {
|
||||
margin-top: 16px;
|
||||
gap: 3px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn, .page {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.page {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.wrapper {
|
||||
margin-top: 12px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.btn, .page {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.page {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Улучшенная поддержка touch устройств */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.btn, .page {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user