From e793ef1de64045104dd6b364dfe7df953db36820 Mon Sep 17 00:00:00 2001 From: romantarkin Date: Sat, 2 Aug 2025 16:19:00 +0500 Subject: [PATCH] mobile ui --- frontend/README.md | 111 ++++++ frontend/package.json | 2 +- frontend/public/index.html | 14 +- frontend/src/components/Header.module.css | 56 +++ frontend/src/components/SideMenu.js | 71 +++- frontend/src/components/SideMenu.module.css | 141 +++++++ frontend/src/index.css | 44 +++ frontend/src/modals/CreateUserModal.js | 18 +- frontend/src/modals/EditUserModal.js | 18 +- frontend/src/pages/CampaignPage.js | 113 +++--- frontend/src/pages/Dashboard.js | 7 +- frontend/src/pages/Dashboard.module.css | 53 +++ frontend/src/pages/DeliveryHistoryPage.js | 62 ++-- frontend/src/pages/EmailTemplatesPage.js | 81 ++-- frontend/src/pages/GroupsPage.js | 153 +++++--- frontend/src/pages/Login.module.css | 105 ++++++ frontend/src/pages/SmtpServersPage.js | 96 +++-- frontend/src/pages/TemplateVersionsPage.js | 116 ++++-- frontend/src/pages/UnsubscribedPage.js | 81 ++-- frontend/src/pages/UsersPage.js | 180 ++++----- frontend/src/styles/Common.module.css | 386 ++++++++++++++++++++ frontend/src/styles/Modal.module.css | 82 +++++ frontend/src/styles/MultiSelect.module.css | 94 +++++ frontend/src/styles/Paginator.module.css | 72 ++++ 24 files changed, 1736 insertions(+), 420 deletions(-) create mode 100644 frontend/README.md create mode 100644 frontend/src/pages/Dashboard.module.css create mode 100644 frontend/src/styles/Common.module.css diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6b25f4 --- /dev/null +++ b/frontend/README.md @@ -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/` \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index d8ab50f..f64fc58 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/public/index.html b/frontend/public/index.html index 3b3cc32..6e6b24e 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,17 +1,19 @@ - + - - - + + + + + - React App + CoreSync MRM - +
diff --git a/frontend/src/components/Header.module.css b/frontend/src/components/Header.module.css index 95defd8..d621679 100644 --- a/frontend/src/components/Header.module.css +++ b/frontend/src/components/Header.module.css @@ -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; + } } \ No newline at end of file diff --git a/frontend/src/components/SideMenu.js b/frontend/src/components/SideMenu.js index 74f10ed..85f8dd6 100644 --- a/frontend/src/components/SideMenu.js +++ b/frontend/src/components/SideMenu.js @@ -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 ( - + <> + {/* Мобильная кнопка меню */} + + + {/* Оверлей для мобильного меню */} +
setIsMobileMenuOpen(false)} + /> + + {/* Боковое меню */} + + ); }; diff --git a/frontend/src/components/SideMenu.module.css b/frontend/src/components/SideMenu.module.css index 165b80f..82eda35 100644 --- a/frontend/src/components/SideMenu.module.css +++ b/frontend/src/components/SideMenu.module.css @@ -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; + } } \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index ec2585e..b1cd28c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; + } +} diff --git a/frontend/src/modals/CreateUserModal.js b/frontend/src/modals/CreateUserModal.js index e769872..db96f25 100644 --- a/frontend/src/modals/CreateUserModal.js +++ b/frontend/src/modals/CreateUserModal.js @@ -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 (

Добавить пользователя

@@ -14,20 +14,10 @@ export default function CreateUserModal({ isOpen, onClose, user, roles, loading, onChange({ ...user, name: e.target.value })} required className={styles.input} />
diff --git a/frontend/src/pages/CampaignPage.js b/frontend/src/pages/CampaignPage.js index 29d5e6e..42ce881 100644 --- a/frontend/src/pages/CampaignPage.js +++ b/frontend/src/pages/CampaignPage.js @@ -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 ( -
-
- +
+
+

Кампания

+
+ +
-

Кампания

- {loading &&
Загрузка...
} - {error &&
{error}
} + + {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && ( <> - - +
+ - - - - - - - + + + + + + + {campaigns.map(c => ( - - - - - - - - + + + + + + + ))} {campaigns.length === 0 && ( - + + + )}
IDГруппаВерсия шаблонаТемаСтатусЗапланированоIDГруппаВерсия шаблонаТемаСтатусЗапланированоДействия
{c.id}{getGroupName(c.group_id)}{getVersionName(c.template_version_id)}{c.subject_override || ''}{c.status}{c.scheduled_at ? new Date(c.scheduled_at).toLocaleString() : ''} - -
{c.id}{getGroupName(c.group_id)}{getVersionName(c.template_version_id)}{c.subject_override || ''}{c.status}{c.scheduled_at ? new Date(c.scheduled_at).toLocaleString() : ''} + +
Нет данных
+
📧
+
Нет кампаний
+
Создайте первую кампанию для начала работы
+
@@ -263,6 +279,7 @@ function CampaignPage() { /> )} + {editCampaign && ( )} + {createCampaign && ( { const [active, setActive] = useState('smtp'); @@ -33,11 +34,11 @@ const Dashboard = () => { } return ( -
+
-
+
-
+
{renderPage()}
diff --git a/frontend/src/pages/Dashboard.module.css b/frontend/src/pages/Dashboard.module.css new file mode 100644 index 0000000..50e49a3 --- /dev/null +++ b/frontend/src/pages/Dashboard.module.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/pages/DeliveryHistoryPage.js b/frontend/src/pages/DeliveryHistoryPage.js index 4ec00df..3070448 100644 --- a/frontend/src/pages/DeliveryHistoryPage.js +++ b/frontend/src/pages/DeliveryHistoryPage.js @@ -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 ( -
-

История отправок

- {loading &&
Загрузка...
} - {error &&
{error}
} +
+
+

История отправок

+
+ + {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && ( <> - - +
+ - - - - - - - - + + + + + + + + {logs.map(l => ( - - - - - - - - - + + + + + + + + + ))} {logs.length === 0 && ( - + + + )}
IDEmailСтатусДата отправкиОткрытоКликОшибкаКампанияIDEmailСтатусДата отправкиОткрытоКликОшибкаКампания
{l.id}{l.Subscriber?.email || l.subscriber_id}{l.status}{l.sent_at ? new Date(l.sent_at).toLocaleString() : ''}{l.opened_at ? new Date(l.opened_at).toLocaleString() : ''}{l.clicked_at ? new Date(l.clicked_at).toLocaleString() : ''}{l.error_message || ''}{l.campaign_id}
{l.id}{l.Subscriber?.email || l.subscriber_id}{l.status}{l.sent_at ? new Date(l.sent_at).toLocaleString() : ''}{l.opened_at ? new Date(l.opened_at).toLocaleString() : ''}{l.clicked_at ? new Date(l.clicked_at).toLocaleString() : ''}{l.error_message || ''}{l.campaign_id}
Нет данных
+
📊
+
Нет истории отправок
+
История отправок появится после запуска кампаний
+
@@ -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; \ No newline at end of file diff --git a/frontend/src/pages/EmailTemplatesPage.js b/frontend/src/pages/EmailTemplatesPage.js index 60134bf..a8d911a 100644 --- a/frontend/src/pages/EmailTemplatesPage.js +++ b/frontend/src/pages/EmailTemplatesPage.js @@ -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 ( -
-
- +
+
+

Шаблоны писем

+
+ +
-

Шаблоны писем

- {loading &&
Загрузка...
} - {error &&
{error}
} + + {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && ( <> - - +
+ - - - + + + + {templates.map(t => ( - setSelectedTemplate(t)}> - - - setSelectedTemplate(t)}> + + + + ))} {templates.length === 0 && ( - + + + )}
IDНазваниеIDНазваниеОписаниеДействия
{t.id}{t.name} - -
{t.id}{t.name}{t.description} + +
Нет данных
+
📝
+
Нет шаблонов
+
Создайте первый шаблон письма
+
@@ -181,6 +209,7 @@ function EmailTemplatesPage() { /> )} + {editTemplate && ( )} + {createTemplate && ( -
- +
+
+

Подписчики и группы

+
+ +
-

Группы подписчиков

- {loading &&
Загрузка...
} - {error &&
{error}
} + + {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && ( <> - - +
+ - - - - + + + + {groups.map(g => ( - setSelectedGroup(g)}> - - - - setSelectedGroup(g)}> + + + + ))} {groups.length === 0 && ( - + + + )}
IDНазваниеОписаниеIDНазваниеОписаниеДействия
{g.id}{g.name}{g.description} - -
{g.id}{g.name}{g.description} + +
Нет данных
+
👥
+
Нет групп
+
Создайте первую группу для организации подписчиков
+
@@ -184,6 +210,7 @@ function GroupsPage() { /> )} + {editGroup && ( )} + {createGroup && ( - -

Подписчики группы: {group.name}

-
- +
+ + +
+

Подписчики группы: {group.name}

+
+ +
- {loading &&
Загрузка...
} - {error &&
{error}
} + + {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && ( <> - - +
+ - - - - - + + + + + {subs.map(gs => ( - - - - - - + + + + + ))} {subs.length === 0 && ( - + + + )}
IDEmailИмяСтатусIDEmailИмяСтатусДействия
{gs.Subscriber?.id || ''}{gs.Subscriber?.email || ''}{gs.Subscriber?.name || ''}{gs.Subscriber?.status || ''} - -
{gs.Subscriber?.id || ''}{gs.Subscriber?.email || ''}{gs.Subscriber?.name || ''}{gs.Subscriber?.status || ''} + +
Нет данных
+
📧
+
Нет подписчиков
+
В этой группе пока нет подписчиков
+
@@ -397,6 +457,7 @@ function SubscribersInGroupPage({ group, onBack, token }) { /> )} + {editSub && ( )} + {createSub && ( { - 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 ( -
-
- +
+
+

SMTP-серверы

+
+ +
-

SMTP-серверы

- {loading &&
Загрузка...
} - {error &&
{error}
} + + {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && ( <> - - +
+ - - - - - - - - + + + + + + + + {servers.map(s => ( - - - - - - - - - + + + + + + + + ))} {servers.length === 0 && ( - + + + )}
IDНазваниеHostPortSecureПользовательОтправительIDНазваниеHostPortSecureПользовательОтправительДействия
{s.id}{s.name}{s.host}{s.port}{s.secure ? 'Да' : 'Нет'}{s.username}{s.from_email} - -
{s.id}{s.name}{s.host}{s.port}{s.secure ? 'Да' : 'Нет'}{s.username}{s.from_email} + +
Нет данных
+
📧
+
Нет SMTP-серверов
+
Добавьте первый SMTP-сервер для начала работы
+
@@ -207,6 +230,7 @@ function SmtpServersPage() { /> )} + {editServer && ( )} + {createServer && ( - -

Версии шаблона: {template.name}

-
- +
+ + +
+

Версии шаблона: {template.name}

+
+ +
- {loading &&
Загрузка...
} - {error &&
{error}
} + + {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && ( <> - - +
+ - - - - - - + + + + + + {versions.map(v => ( - - - - - - - + + + + + + ))} {versions.length === 0 && ( - + + + )}
IDВерсияТемаHTMLТекстIDВерсияТемаHTMLТекстДействия
{v.id}{v.version_number}{v.subject}{v.body_html ? Есть : Нет}{v.body_text ? Есть : Нет} - -
{v.id}{v.version_number}{v.subject} + {v.body_html ? + Есть : + Нет + } + + {v.body_text ? + Есть : + Нет + } + + +
Нет версий
+
📄
+
Нет версий
+
Создайте первую версию для этого шаблона
+
- {/* Пагинация если нужно */} + {total > PAGE_SIZE && ( -
- - Страница {page} - -
+ )} )} + {createVersionModal && ( )} + {editVersion && ( ); -} - -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' }; \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/pages/UnsubscribedPage.js b/frontend/src/pages/UnsubscribedPage.js index 2c23c30..796ed72 100644 --- a/frontend/src/pages/UnsubscribedPage.js +++ b/frontend/src/pages/UnsubscribedPage.js @@ -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 ( -
-
- +
+
+

Отписались

+
+ +
-

Отписались

- {loading &&
Загрузка...
} - {error &&
{error}
} + + {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && ( <> - - +
+ - - - - - + + + + + {subs.map(s => ( - - - - - - + + + + + ))} {subs.length === 0 && ( - + + + )}
IDEmailИмяДата отпискиIDEmailИмяДата отпискиДействия
{s.id}{s.email}{s.name}{s.unsubscribed_at ? new Date(s.unsubscribed_at).toLocaleString() : ''} - -
{s.id}{s.email}{s.name}{s.unsubscribed_at ? new Date(s.unsubscribed_at).toLocaleString() : ''} + +
Нет данных
+
🚫
+
Нет отписок
+
Список отписавшихся пользователей пуст
+
@@ -179,6 +205,7 @@ function UnsubscribedPage() { /> )} + {editSub && ( )} + {createSub && ( { - 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 ( -
-
- +
+
+

Управление пользователями

+
+ +
-

Пользователи

- {usersLoading &&
Загрузка...
} - {usersError &&
{usersError}
} - {!usersLoading && !usersError && ( + + {loading &&
Загрузка...
} + {error &&
{error}
} + + {!loading && !error && ( <> - - +
+ - - - - - + + + + + {users.map(u => ( - - - - - - + + + + + ))} {users.length === 0 && ( - + + + )}
IDEmailИмяРольIDEmailИмяРольДействия
{u.id}{u.email}{u.name}{getRoleName(u.role_id)} - -
{u.id}{u.email}{u.name}{u.role} + +
Нет данных
+
👤
+
Нет пользователей
+
Создайте первого пользователя
+
)} + {editUser && ( setEditUser(null)} user={editUser} - roles={roles} loading={editLoading} onChange={setEditUser} onSave={handleEditSave} /> )} + {createUser && ( setCreateUser(null)} user={createUser} - roles={roles} loading={createLoading} onChange={setCreateUser} onSave={handleCreateSave} /> )} - {/* Отладочная информация */} - {process.env.NODE_ENV === 'development' && ( -
- Debug Info:
- Roles count: {roles.length}
- Roles: {JSON.stringify(roles.slice(0, 3))}
- Edit user: {editUser ? JSON.stringify(editUser) : 'null'}
- Create user: {createUser ? JSON.stringify(createUser) : 'null'} -
- )}
); } -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; \ No newline at end of file diff --git a/frontend/src/styles/Common.module.css b/frontend/src/styles/Common.module.css new file mode 100644 index 0000000..ff0492b --- /dev/null +++ b/frontend/src/styles/Common.module.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/styles/Modal.module.css b/frontend/src/styles/Modal.module.css index 822ebe0..dfa8f7e 100644 --- a/frontend/src/styles/Modal.module.css +++ b/frontend/src/styles/Modal.module.css @@ -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; + } } \ No newline at end of file diff --git a/frontend/src/styles/MultiSelect.module.css b/frontend/src/styles/MultiSelect.module.css index dc1ea1d..ad2a0d1 100644 --- a/frontend/src/styles/MultiSelect.module.css +++ b/frontend/src/styles/MultiSelect.module.css @@ -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; + } } \ No newline at end of file diff --git a/frontend/src/styles/Paginator.module.css b/frontend/src/styles/Paginator.module.css index 26d271a..813653c 100644 --- a/frontend/src/styles/Paginator.module.css +++ b/frontend/src/styles/Paginator.module.css @@ -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; + } } \ No newline at end of file