Исправлена фильтрация новых сотрудников и центрирование карусели
- Добавлен фильтр created_at__gte в EmployeeViewSet API - Исправлен параметр is_active -> active в запросе к API - Добавлено центрирование карусели новых сотрудников (justify-content: center) - Обновлена документация API endpoints
This commit is contained in:
parent
5cda78b49b
commit
eb5fb926a1
@ -1338,6 +1338,7 @@ class EmployeeViewSet(viewsets.ModelViewSet):
|
||||
?active=true|false
|
||||
?actually_active=true|false — реально активные: email_verified=True и не DISMISSED
|
||||
(или нет действий, но is_active=True)
|
||||
?created_at__gte=<iso_date> — зарегистрированные после указанной даты
|
||||
|
||||
Сортировка:
|
||||
?ordering=last_name|first_name|created_at|id (+ '-' для DESC)
|
||||
@ -1459,6 +1460,11 @@ class EmployeeViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
)
|
||||
|
||||
# фильтр по дате создания
|
||||
created_at_gte = qp.get("created_at__gte")
|
||||
if created_at_gte:
|
||||
qs = qs.filter(created_at__gte=created_at_gte)
|
||||
|
||||
return qs
|
||||
|
||||
def get_serializer_class(self):
|
||||
|
||||
@ -130,8 +130,8 @@ def _api_list_new_employees(request, limit: int = 10) -> list[dict]:
|
||||
resp = _api(request).get(
|
||||
API_EMPLOYEES,
|
||||
params={
|
||||
"is_active": "true",
|
||||
"created_at__gte": since, # или "created_at_after": since — см. ниже
|
||||
"active": "true",
|
||||
"created_at__gte": since,
|
||||
"ordering": "-created_at",
|
||||
"page_size": limit,
|
||||
},
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
/**
|
||||
* department-list.css
|
||||
* Стили списка отделов
|
||||
*
|
||||
* Использует:
|
||||
* - common.css (.feed-author, .feed-sub)
|
||||
* - feed-cards.css (.feed-header)
|
||||
*/
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
SCROLLABLE LIST
|
||||
────────────────────────────────────────────────────────────── */
|
||||
/* список — единственный прокручиваемый блок */
|
||||
#deptList {
|
||||
max-height: calc(100dvh - var(--navbar-h) - 160px);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
#deptList .dept-row {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
#deptList .dept-row + .dept-row {
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
}
|
||||
@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Employee List Component
|
||||
*
|
||||
* Стили для списка сотрудников с аватарами, должностями и действиями
|
||||
* Оптимизирован для вертикальной прокрутки и адаптивного отображения
|
||||
*
|
||||
* Использует:
|
||||
* - common.css (.feed-author, .feed-sub)
|
||||
* - feed-cards.css (.feed-hd, .feed-ava)
|
||||
*/
|
||||
|
||||
/* Токены списка сотрудников */
|
||||
#empList{
|
||||
--emp-row-py: var(--space-1); /* вертикальные отступы строки: 8px */
|
||||
--emp-row-px: var(--space-2); /* горизонтальные отступы строки: 16px */
|
||||
--emp-gap: var(--space-2); /* расстояние внутри строки: 16px */
|
||||
--emp-avatar: 56px; /* размер аватара */
|
||||
}
|
||||
|
||||
/* Контейнер списка (скролл в колонке) */
|
||||
#empList{
|
||||
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 12%);
|
||||
border-radius: var(--feed-radius, 16px);
|
||||
background: var(--bs-body-bg);
|
||||
padding: 0;
|
||||
max-height: calc(100dvh - var(--navbar-h, 0px) - 160px);
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Важно: убираем межстрочный gap от .feed-stream */
|
||||
#empList.feed-stream{
|
||||
display: block;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
/* Строка сотрудника */
|
||||
#empList .emp-row{
|
||||
padding: var(--emp-row-py) var(--emp-row-px) !important;
|
||||
margin: 0 !important;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
#empList .emp-row:last-child{
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* Шапка строки: [аватар] [контент] [действия] */
|
||||
#empList .emp-row .feed-hd{
|
||||
display: grid;
|
||||
grid-template-columns: var(--emp-avatar) 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--emp-gap);
|
||||
margin: 0;
|
||||
padding: 0 !important; /* без внутренних паддингов — ими управляет .emp-row */
|
||||
}
|
||||
|
||||
/* Аватар */
|
||||
#empList .emp-row .feed-ava{
|
||||
width: var(--emp-avatar);
|
||||
height: var(--emp-avatar);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--bs-secondary-bg), transparent 30%);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
#empList .emp-row .feed-ava img{
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
#empList .emp-row .feed-ava i{
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
/* Контент */
|
||||
#empList .emp-row .feed-meta{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Специфичные стили для employee list */
|
||||
#empList .feed-author{
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.emp-pos{
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
#empList .feed-sub{
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
/* Действия справа */
|
||||
.emp-actions{
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
/* Хвост карточки в списке не используем — убираем отступы */
|
||||
#empList .emp-row .feed-actions{
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
#empList .emp-row .feed-body:empty{
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#empList .emp-row .feed-body{
|
||||
padding: 0 !important;
|
||||
}
|
||||
@ -21,6 +21,22 @@
|
||||
overflow-y: visible; /* нужно для инфо-блока */
|
||||
scrollbar-width: none; /* Firefox скрыть полосы */
|
||||
-ms-overflow-style: none; /* IE/старый Edge */
|
||||
|
||||
/* Градиентная маска по краям - фиксированная на контейнере */
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
black 40px,
|
||||
black calc(100% - 40px),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
black 40px,
|
||||
black calc(100% - 40px),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.join-scroller::-webkit-scrollbar{
|
||||
@ -29,31 +45,12 @@
|
||||
|
||||
.join-rail{
|
||||
display: flex;
|
||||
flex-wrap: nowrap; /* не переносить элементы */
|
||||
align-items: flex-start;
|
||||
justify-content: center; /* центрировать элементы */
|
||||
gap: .9rem; /* start: инфо-блоки снизу */
|
||||
padding: .25rem .25rem;
|
||||
}
|
||||
|
||||
/* мягкие «маски» по краям */
|
||||
.join-scroller::before,
|
||||
.join-scroller::after{
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 32px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to right, var(--bs-body-bg), transparent);
|
||||
}
|
||||
|
||||
.join-scroller::after{
|
||||
right: 0;
|
||||
left: auto;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.join-scroller::before{
|
||||
left: 0;
|
||||
min-width: 100%; /* занять всю ширину контейнера */
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
@ -82,6 +79,13 @@
|
||||
z-index: 2; /* поверх соседей */
|
||||
}
|
||||
|
||||
/* Отключение hover при перетаскивании */
|
||||
.join-item.no-hover:hover,
|
||||
.join-item.no-hover:focus-within{
|
||||
--size: var(--base);
|
||||
z-index: auto;
|
||||
}
|
||||
|
||||
/* круг с фото */
|
||||
.join-ava{
|
||||
width: var(--size);
|
||||
@ -132,6 +136,13 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Скрытие инфо при перетаскивании */
|
||||
.join-item.no-hover .join-info{
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.join-info .name{
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
|
||||
169
backend/static/css/components/list-view.css
Normal file
169
backend/static/css/components/list-view.css
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* List View Component
|
||||
*
|
||||
* Универсальные стили для списочных представлений (сотрудники, отделы и др.)
|
||||
* Оптимизированы для вертикальной прокрутки и адаптивного отображения
|
||||
*
|
||||
* Использует:
|
||||
* - common.css (.feed-author, .feed-sub)
|
||||
* - feed-cards.css (.feed-hd, .feed-ava)
|
||||
*/
|
||||
|
||||
/* Токены списочных представлений */
|
||||
.list-view{
|
||||
--list-row-py: var(--space-1); /* вертикальные отступы строки: 8px */
|
||||
--list-row-px: var(--space-2); /* горизонтальные отступы строки: 16px */
|
||||
--list-gap: var(--space-2); /* расстояние внутри строки: 16px */
|
||||
--list-avatar: 56px; /* размер аватара */
|
||||
}
|
||||
|
||||
/* Контейнер списков (скролл в колонке) */
|
||||
.list-view{
|
||||
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 12%);
|
||||
border-radius: var(--feed-radius, 16px);
|
||||
background: var(--bs-body-bg);
|
||||
padding: 0;
|
||||
max-height: calc(100dvh - var(--navbar-h, 0px) - 160px);
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Важно: убираем межстрочный gap от .feed-stream */
|
||||
.list-view.feed-stream{
|
||||
display: block;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
/* Кастомный скроллбар - тонкий и незаметный */
|
||||
.list-view::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.list-view::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
margin: 16px 4px; /* большой отступ сверху/снизу чтобы не попадать на скругленные углы */
|
||||
}
|
||||
|
||||
.list-view::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--bs-border-color) 40%, transparent);
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--bs-body-bg); /* визуальный отступ от края */
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.list-view::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--bs-border-color) 60%, transparent);
|
||||
border: 2px solid var(--bs-body-bg);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Убираем стрелки полностью */
|
||||
.list-view::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
/* Для Firefox */
|
||||
.list-view {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--bs-border-color) 40%, transparent) transparent;
|
||||
}
|
||||
|
||||
/* Важно: убираем межстрочный gap от .feed-stream */
|
||||
.list-view.feed-stream{
|
||||
display: block;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
/* Строки списков */
|
||||
.list-view .list-row{
|
||||
padding: var(--list-row-py) var(--list-row-px) !important;
|
||||
margin: 0 !important;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.list-view .list-row:last-of-type{
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* Шапка строки: базовый flex */
|
||||
.list-view .list-row .feed-hd{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--list-gap);
|
||||
min-height: var(--list-avatar); /* минимальная высота как у аватара для единообразия */
|
||||
margin: 0;
|
||||
padding: 0 !important; /* без внутренних паддингов — ими управляет строка */
|
||||
}
|
||||
|
||||
/* Шапка с аватаром: переключаем на grid */
|
||||
.list-view .list-row:has(.feed-ava) .feed-hd{
|
||||
display: grid;
|
||||
grid-template-columns: var(--list-avatar) 1fr auto;
|
||||
}
|
||||
|
||||
/* Аватар */
|
||||
.list-view .list-row .feed-ava{
|
||||
width: var(--list-avatar);
|
||||
height: var(--list-avatar);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--bs-secondary-bg), transparent 30%);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.list-view .list-row .feed-ava img{
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.list-view .list-row .feed-ava i{
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
/* Контент */
|
||||
.list-view .list-row .feed-meta{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Специфичные стили для списков */
|
||||
.list-view .feed-author{
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.emp-pos{
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.list-view .feed-sub{
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
/* Действия справа */
|
||||
.emp-actions{
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
/* Хвост карточки в списке не используем — убираем отступы */
|
||||
.list-view .list-row .feed-actions{
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.list-view .list-row .feed-body:empty{
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.list-view .list-row .feed-body{
|
||||
padding: 0 !important;
|
||||
}
|
||||
@ -22,15 +22,16 @@
|
||||
z-index: 10;
|
||||
padding: var(--space-2) 0; /* 8px сверху/снизу */
|
||||
margin-bottom: var(--space-3); /* 12px отступ снизу */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
|
||||
/* Полупрозрачный фон с размытием */
|
||||
backdrop-filter: saturate(180%) blur(12px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(12px);
|
||||
background: color-mix(in srgb, var(--bs-body-bg) 85%, transparent);
|
||||
/* Полупрозрачный фон с градиентом */
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--bs-body-bg) 90%, transparent) 0%,
|
||||
color-mix(in srgb, var(--bs-body-bg) 85%, transparent) 30%,
|
||||
color-mix(in srgb, var(--bs-body-bg) 70%, transparent) 50%,
|
||||
color-mix(in srgb, var(--bs-body-bg) 40%, transparent) 70%,
|
||||
transparent 100%
|
||||
);
|
||||
|
||||
/* Убрана border-bottom - линия не нужна */
|
||||
|
||||
@ -38,9 +39,52 @@
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
/* Псевдоэлемент для градиентного размытия */
|
||||
.page-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
backdrop-filter: saturate(180%) blur(12px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(12px);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
black 0%,
|
||||
black 30%,
|
||||
rgba(0, 0, 0, 0.7) 50%,
|
||||
rgba(0, 0, 0, 0.4) 70%,
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
black 0%,
|
||||
black 30%,
|
||||
rgba(0, 0, 0, 0.7) 50%,
|
||||
rgba(0, 0, 0, 0.4) 70%,
|
||||
transparent 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Контент должен быть выше псевдоэлемента */
|
||||
.page-header > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* При скролле - более выраженный фон */
|
||||
.page-header.scrolled {
|
||||
background: color-mix(in srgb, var(--bs-body-bg) 92%, transparent);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--bs-body-bg) 95%, transparent) 0%,
|
||||
color-mix(in srgb, var(--bs-body-bg) 90%, transparent) 30%,
|
||||
color-mix(in srgb, var(--bs-body-bg) 75%, transparent) 50%,
|
||||
color-mix(in srgb, var(--bs-body-bg) 45%, transparent) 70%,
|
||||
transparent 100%
|
||||
);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
@ -151,7 +195,8 @@
|
||||
|
||||
/* ===== БЛОК ФИЛЬТРОВ ===== */
|
||||
.page-filters-collapse {
|
||||
margin-bottom: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-filters {
|
||||
@ -222,7 +267,13 @@
|
||||
|
||||
/* ===== КНОПКА СОЗДАНИЯ В ВИДЕ КАРТОЧКИ (для модала) ===== */
|
||||
/* Кнопка должна выглядеть как ссылка в feed-card */
|
||||
.feed-card button.dept-row {
|
||||
.page-header .feed-card {
|
||||
margin-top: var(--space-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header .feed-card button.dept-row,
|
||||
.page-header .feed-card a.dept-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
width: 100%;
|
||||
@ -231,22 +282,26 @@
|
||||
}
|
||||
|
||||
/* Текст кнопки должен быть как feed-author */
|
||||
.feed-card button.dept-row .feed-author {
|
||||
.page-header .feed-card button.dept-row .feed-author,
|
||||
.page-header .feed-card a.dept-row .feed-author {
|
||||
font-weight: 700;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
/* Иконка должна быть синей как в оригинале */
|
||||
.feed-card button.dept-row i {
|
||||
.page-header .feed-card button.dept-row i,
|
||||
.page-header .feed-card a.dept-row i {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.feed-card button.dept-row:hover {
|
||||
.page-header .feed-card button.dept-row:hover,
|
||||
.page-header .feed-card a.dept-row:hover {
|
||||
background-color: var(--bs-light);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feed-card button.dept-row:hover {
|
||||
.page-header .feed-card button.dept-row:hover,
|
||||
.page-header .feed-card a.dept-row:hover {
|
||||
background-color: color-mix(in srgb, var(--bs-body-bg) 95%, white);
|
||||
}
|
||||
}
|
||||
@ -254,12 +309,26 @@
|
||||
/* ===== ТЕМНАЯ ТЕМА ===== */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.page-header {
|
||||
background: color-mix(in srgb, var(--bs-body-bg) 80%, transparent);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--bs-body-bg) 85%, transparent) 0%,
|
||||
color-mix(in srgb, var(--bs-body-bg) 80%, transparent) 45%,
|
||||
color-mix(in srgb, var(--bs-body-bg) 65%, transparent) 50%,
|
||||
color-mix(in srgb, var(--bs-body-bg) 35%, transparent) 70%,
|
||||
transparent 100%
|
||||
);
|
||||
border-bottom-color: color-mix(in srgb, var(--bs-border-color), transparent 60%);
|
||||
}
|
||||
|
||||
.page-header.scrolled {
|
||||
background: color-mix(in srgb, var(--bs-body-bg) 90%, transparent);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--bs-body-bg) 90%, transparent) 0%,
|
||||
color-mix(in srgb, var(--bs-body-bg) 80%, transparent) 30%,
|
||||
color-mix(in srgb, var(--bs-body-bg) 72%, transparent) 50%,
|
||||
color-mix(in srgb, var(--bs-body-bg) 40%, transparent) 70%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.page-filters {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @module autoScrollerHandler
|
||||
* @description Автоматический горизонтальный скроллер для галереи элементов.
|
||||
* Поддерживает бесконечный цикл, паузу при наведении, центрирование элемента.
|
||||
* Поддерживает плавную непрерывную прокрутку, паузу при наведении, центрирование элемента.
|
||||
*
|
||||
* Пример HTML:
|
||||
* <div class="js-join-scroller overflow-hidden">
|
||||
@ -13,10 +13,10 @@
|
||||
*
|
||||
* Использование:
|
||||
* import { initAutoScroller } from './autoScrollerHandler.js';
|
||||
* initAutoScroller({ selector: '.js-join-scroller' });
|
||||
* initAutoScroller({ selector: '.js-join-scroller', speed: 0.5 });
|
||||
*/
|
||||
|
||||
const DEFAULT_STEP_MS = 3000;
|
||||
const DEFAULT_SPEED = 0.5; // пикселей за кадр (примерно 30px/сек при 60fps)
|
||||
const SCROLL_BEHAVIOR = 'smooth';
|
||||
|
||||
/**
|
||||
@ -57,11 +57,13 @@ function computeScrollStep(rail) {
|
||||
* Инициализирует автоскроллер для одного контейнера.
|
||||
* @param {HTMLElement} wrapper - Обёртка со скроллом
|
||||
* @param {Object} options - Опции
|
||||
* @param {number} [options.stepMs=3000] - Интервал между прокрутками в миллисекундах
|
||||
* @param {number} [options.speed=0.5] - Скорость прокрутки в пикселях за кадр
|
||||
* @returns {Object} API с методами start, stop, destroy
|
||||
*/
|
||||
function initSingleScroller(wrapper, options = {}) {
|
||||
const { stepMs = DEFAULT_STEP_MS } = options;
|
||||
const { speed = DEFAULT_SPEED } = options;
|
||||
|
||||
console.log('initSingleScroller: starting, speed =', speed);
|
||||
|
||||
const rail = wrapper.querySelector('.join-rail');
|
||||
if (!rail) {
|
||||
@ -71,7 +73,10 @@ function initSingleScroller(wrapper, options = {}) {
|
||||
|
||||
// Проверяем, нужен ли скролл (контент переполняет контейнер)
|
||||
const hasOverflow = rail.scrollWidth > wrapper.clientWidth + 4;
|
||||
console.log('initSingleScroller: hasOverflow =', hasOverflow, 'scrollWidth =', rail.scrollWidth, 'clientWidth =', wrapper.clientWidth);
|
||||
|
||||
if (!hasOverflow) {
|
||||
console.log('initSingleScroller: no overflow, skipping');
|
||||
return { start: () => {}, stop: () => {}, destroy: () => {} };
|
||||
}
|
||||
|
||||
@ -79,39 +84,80 @@ function initSingleScroller(wrapper, options = {}) {
|
||||
const originalChildren = Array.from(rail.children);
|
||||
const clones = originalChildren.map(node => node.cloneNode(true));
|
||||
rail.append(...clones);
|
||||
|
||||
console.log('initSingleScroller: cloned', originalChildren.length, 'items');
|
||||
|
||||
// Пересчитываем overflow после клонирования
|
||||
const hasOverflowAfterClone = rail.scrollWidth > wrapper.clientWidth + 4;
|
||||
console.log('initSingleScroller: hasOverflowAfterClone =', hasOverflowAfterClone);
|
||||
|
||||
if (!hasOverflowAfterClone) {
|
||||
// Удаляем клоны если даже с ними нет переполнения
|
||||
clones.forEach(clone => clone.remove());
|
||||
console.log('initSingleScroller: no overflow after clone, removing clones');
|
||||
return { start: () => {}, stop: () => {}, destroy: () => {} };
|
||||
}
|
||||
|
||||
// Состояние
|
||||
let scrollStep = computeScrollStep(rail);
|
||||
const originalCount = originalChildren.length;
|
||||
let timer = null;
|
||||
let animationFrame = null;
|
||||
let paused = false;
|
||||
let resizeFrame = null;
|
||||
|
||||
console.log('initSingleScroller: scrollStep =', scrollStep, 'originalCount =', originalCount);
|
||||
|
||||
/**
|
||||
* Один шаг скролла.
|
||||
* Один кадр анимации - плавный скролл.
|
||||
*/
|
||||
function tick() {
|
||||
if (paused) return;
|
||||
let frameCount = 0;
|
||||
let accumulatedScroll = 0; // Накопленные дробные пиксели
|
||||
|
||||
function animate() {
|
||||
if (paused) {
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
frameCount++;
|
||||
|
||||
const maxScroll = scrollStep * originalCount;
|
||||
|
||||
// Если достигли конца первой копии - прыгаем в начало
|
||||
if (wrapper.scrollLeft >= maxScroll - 2) {
|
||||
wrapper.scrollLeft = 0;
|
||||
accumulatedScroll = 0;
|
||||
console.log('animate: reset to start, maxScroll =', maxScroll);
|
||||
}
|
||||
|
||||
wrapper.scrollBy({
|
||||
left: scrollStep,
|
||||
behavior: SCROLL_BEHAVIOR
|
||||
});
|
||||
// Накапливаем дробные пиксели
|
||||
accumulatedScroll += speed;
|
||||
|
||||
// Скроллим только когда накопился хотя бы 1 пиксель
|
||||
if (accumulatedScroll >= 1) {
|
||||
const scrollAmount = Math.floor(accumulatedScroll);
|
||||
accumulatedScroll -= scrollAmount; // Оставляем дробную часть
|
||||
|
||||
wrapper.scrollBy({
|
||||
left: scrollAmount,
|
||||
behavior: 'auto'
|
||||
});
|
||||
|
||||
if (frameCount % 60 === 0) {
|
||||
console.log('animate: frame', frameCount, 'scrollLeft =', wrapper.scrollLeft, 'scrollAmount =', scrollAmount);
|
||||
}
|
||||
}
|
||||
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Запускает автоматическую прокрутку.
|
||||
*/
|
||||
function start() {
|
||||
if (!timer) {
|
||||
timer = setInterval(tick, stepMs);
|
||||
console.log('start: starting animation');
|
||||
if (!animationFrame) {
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,9 +165,9 @@ function initSingleScroller(wrapper, options = {}) {
|
||||
* Останавливает автоматическую прокрутку.
|
||||
*/
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
if (animationFrame) {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
animationFrame = null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -140,6 +186,29 @@ function initSingleScroller(wrapper, options = {}) {
|
||||
paused = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик нажатия кнопки мыши на элементе.
|
||||
*/
|
||||
function handleItemMouseDown(event) {
|
||||
// Отменяем поведение по умолчанию чтобы не начиналось перетаскивание
|
||||
event.preventDefault();
|
||||
|
||||
// Сразу снимаем паузу и убираем увеличение
|
||||
const item = event.currentTarget;
|
||||
paused = false;
|
||||
|
||||
// Добавляем класс для отключения hover
|
||||
item.classList.add('no-hover');
|
||||
|
||||
// Добавляем глобальный обработчик отпускания кнопки
|
||||
function handleGlobalMouseUp() {
|
||||
item.classList.remove('no-hover');
|
||||
document.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
}
|
||||
|
||||
document.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик получения фокуса элементом.
|
||||
*/
|
||||
@ -181,6 +250,7 @@ function initSingleScroller(wrapper, options = {}) {
|
||||
items.forEach(item => {
|
||||
item.addEventListener('mouseenter', handleItemMouseEnter);
|
||||
item.addEventListener('mouseleave', handleItemMouseLeave);
|
||||
item.addEventListener('mousedown', handleItemMouseDown);
|
||||
item.addEventListener('focusin', handleItemFocusIn);
|
||||
item.addEventListener('focusout', handleItemFocusOut);
|
||||
});
|
||||
@ -191,8 +261,12 @@ function initSingleScroller(wrapper, options = {}) {
|
||||
|
||||
// Проверяем prefers-reduced-motion
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
console.log('initSingleScroller: prefersReducedMotion =', prefersReducedMotion);
|
||||
|
||||
if (!prefersReducedMotion) {
|
||||
start();
|
||||
} else {
|
||||
console.log('initSingleScroller: skipping animation due to prefers-reduced-motion');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -204,6 +278,7 @@ function initSingleScroller(wrapper, options = {}) {
|
||||
items.forEach(item => {
|
||||
item.removeEventListener('mouseenter', handleItemMouseEnter);
|
||||
item.removeEventListener('mouseleave', handleItemMouseLeave);
|
||||
item.removeEventListener('mousedown', handleItemMouseDown);
|
||||
item.removeEventListener('focusin', handleItemFocusIn);
|
||||
item.removeEventListener('focusout', handleItemFocusOut);
|
||||
});
|
||||
@ -223,16 +298,16 @@ function initSingleScroller(wrapper, options = {}) {
|
||||
* Инициализирует автоскроллеры для всех элементов по селектору.
|
||||
* @param {Object} options - Опции
|
||||
* @param {string} [options.selector='.js-join-scroller'] - CSS-селектор контейнеров
|
||||
* @param {number} [options.stepMs=3000] - Интервал между прокрутками в миллисекундах
|
||||
* @param {number} [options.speed=0.5] - Скорость прокрутки в пикселях за кадр (примерно 30px/сек при 60fps)
|
||||
* @returns {Object} API с методом destroy
|
||||
*/
|
||||
export function initAutoScroller(options = {}) {
|
||||
const { selector = '.js-join-scroller', stepMs = DEFAULT_STEP_MS } = options;
|
||||
const { selector = '.js-join-scroller', speed = DEFAULT_SPEED } = options;
|
||||
|
||||
const scrollers = [];
|
||||
|
||||
document.querySelectorAll(selector).forEach(wrapper => {
|
||||
const scroller = initSingleScroller(wrapper, { stepMs });
|
||||
const scroller = initSingleScroller(wrapper, { speed });
|
||||
scrollers.push(scroller);
|
||||
});
|
||||
|
||||
|
||||
169
backend/static/js/components/createPostModal.js
Normal file
169
backend/static/js/components/createPostModal.js
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* createPostModal.js
|
||||
* Обработчик модального окна создания публикации
|
||||
*/
|
||||
|
||||
export function initCreatePostModal(options = {}) {
|
||||
const {
|
||||
modalId = 'createPostModal',
|
||||
formId = 'createPostForm',
|
||||
errorsId = 'createPostErrors',
|
||||
submitBtnId = 'createPostSubmit',
|
||||
submitUrl = '/feed/post/new/'
|
||||
} = options;
|
||||
|
||||
const modal = document.getElementById(modalId);
|
||||
const form = document.getElementById(formId);
|
||||
const errorsDiv = document.getElementById(errorsId);
|
||||
const submitBtn = document.getElementById(submitBtnId);
|
||||
|
||||
if (!modal || !form) {
|
||||
console.error('Modal or form not found!', { modalId, formId });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Create post modal initialized');
|
||||
|
||||
// Маппинг полей для человекочитаемых названий
|
||||
const fieldLabels = {
|
||||
'title': 'Заголовок',
|
||||
'body': 'Содержание',
|
||||
'image': 'Изображение',
|
||||
'attachment': 'Вложение',
|
||||
'type': 'Тип публикации'
|
||||
};
|
||||
|
||||
/**
|
||||
* Очистка формы и ошибок
|
||||
*/
|
||||
function clearFormErrors() {
|
||||
errorsDiv.classList.add('d-none');
|
||||
errorsDiv.innerHTML = '';
|
||||
form.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
|
||||
form.querySelectorAll('.invalid-feedback').forEach(el => el.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация обязательных полей
|
||||
*/
|
||||
function validateRequiredFields() {
|
||||
const title = form.querySelector('[name="title"]');
|
||||
const body = form.querySelector('[name="body"]');
|
||||
let hasErrors = false;
|
||||
|
||||
if (!title.value.trim()) {
|
||||
title.classList.add('is-invalid');
|
||||
const feedback = document.createElement('div');
|
||||
feedback.className = 'invalid-feedback';
|
||||
feedback.textContent = 'Заголовок обязателен для заполнения';
|
||||
title.parentElement.appendChild(feedback);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (!body.value.trim()) {
|
||||
body.classList.add('is-invalid');
|
||||
const feedback = document.createElement('div');
|
||||
feedback.className = 'invalid-feedback';
|
||||
feedback.textContent = 'Содержание обязательно для заполнения';
|
||||
body.parentElement.appendChild(feedback);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
return !hasErrors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отображение ошибок по полям
|
||||
*/
|
||||
function displayFieldErrors(errors) {
|
||||
const errorMessages = [];
|
||||
|
||||
for (const [fieldName, messages] of Object.entries(errors)) {
|
||||
const field = form.querySelector(`[name="${fieldName}"]`);
|
||||
const errorText = Array.isArray(messages) ? messages.join(', ') : messages;
|
||||
const label = fieldLabels[fieldName] || fieldName;
|
||||
|
||||
errorMessages.push(`<strong>${label}:</strong> ${errorText}`);
|
||||
|
||||
if (field) {
|
||||
field.classList.add('is-invalid');
|
||||
const feedback = document.createElement('div');
|
||||
feedback.className = 'invalid-feedback';
|
||||
feedback.textContent = errorText;
|
||||
field.parentElement.appendChild(feedback);
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMessages.length > 0) {
|
||||
errorsDiv.innerHTML = errorMessages.join('<br>');
|
||||
errorsDiv.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать общее сообщение об ошибке
|
||||
*/
|
||||
function displayGeneralError(message) {
|
||||
errorsDiv.textContent = message;
|
||||
errorsDiv.classList.remove('d-none');
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать уведомление об успехе
|
||||
*/
|
||||
function showSuccessNotification(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast align-items-center text-bg-success border-0 position-fixed top-0 start-50 translate-middle-x mt-3';
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.setAttribute('aria-live', 'assertive');
|
||||
toast.setAttribute('aria-atomic', 'true');
|
||||
toast.style.zIndex = '9999';
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi-check-circle me-2"></i>${message}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
|
||||
bsToast.show();
|
||||
|
||||
toast.addEventListener('hidden.bs.toast', () => {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправка формы через обычный submit (не AJAX)
|
||||
* Проще использовать стандартную отправку формы Django
|
||||
*/
|
||||
function submitForm(e) {
|
||||
clearFormErrors();
|
||||
|
||||
if (!validateRequiredFields()) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Отключаем кнопку и показываем загрузку
|
||||
submitBtn.disabled = true;
|
||||
const originalText = submitBtn.innerHTML;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Публикация...';
|
||||
|
||||
// Позволяем форме отправиться обычным способом
|
||||
return true;
|
||||
}
|
||||
|
||||
// Обработчик отправки формы
|
||||
form.addEventListener('submit', submitForm);
|
||||
|
||||
// Очистка ошибок при открытии модала
|
||||
modal.addEventListener('show.bs.modal', () => {
|
||||
clearFormErrors();
|
||||
form.reset();
|
||||
});
|
||||
}
|
||||
0
backend/static/js/components/customScrollbar.js
Normal file
0
backend/static/js/components/customScrollbar.js
Normal file
@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="{% static 'css/components/feed-cards.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/ios-search.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/department-list.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/list-view.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@ -23,45 +23,45 @@
|
||||
{% include "includes/components/page_header.html" with icon="bi-diagram-3" title="Отделы" count=departments|length count_label="Всего:" search_id="deptFilter" search_placeholder="Поиск по названию или руководителю" %}
|
||||
{% endif %}
|
||||
|
||||
<div class="feed-card">
|
||||
<div id="deptList" class="py-1">
|
||||
{# поддержка обоих форматов: dict(results) или list #}
|
||||
{% with rows=departments.results|default:departments %}
|
||||
{% for dept in rows %}
|
||||
<div id="deptList" class="list-view feed-stream">
|
||||
{# поддержка обоих форматов: dict(results) или list #}
|
||||
{% with rows=departments.results|default:departments %}
|
||||
{% for dept in rows %}
|
||||
{% with h=dept.head emp=dept.employees_count|default:0 %}
|
||||
{% url 'employees:department_detail' dept.id as dept_url %}
|
||||
<div class="dept-row d-flex align-items-start gap-2"
|
||||
<article class="dept-row list-row"
|
||||
data-haystack="{{ dept.name }}{% if h %} {{ h.last_name }}{% if h.first_name %} {{ h.first_name }}{% endif %}{% endif %}"
|
||||
data-emp="{{ emp }}">
|
||||
<div class="flex-grow-1 position-relative">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
{% if dept_url %}
|
||||
<a href="{{ dept_url }}" class="feed-author text-decoration-none stretched-link">
|
||||
{{ dept.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="feed-author">{{ dept.name }}</span>
|
||||
{% endif %}
|
||||
<header class="feed-hd">
|
||||
<div class="feed-meta flex-grow-1">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
{% if dept_url %}
|
||||
<a href="{{ dept_url }}" class="feed-author text-decoration-none">
|
||||
{{ dept.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="feed-author">{{ dept.name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="feed-sub mt-1">
|
||||
{% if h %}
|
||||
Руководитель: {{ h.last_name }}{% if h.first_name %} {{ h.first_name }}{% endif %}
|
||||
{% endif %}
|
||||
{% if h and emp %} · {% endif %}
|
||||
{% if emp %}{{ emp }} сотрудников{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="feed-sub mt-1">
|
||||
{% if h %}
|
||||
Руководитель: {{ h.last_name }}{% if h.first_name %} {{ h.first_name }}{% endif %}
|
||||
{% endif %}
|
||||
{% if h and emp %} · {% endif %}
|
||||
{% if emp %}{{ emp }} сотрудников{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if dept_url %}
|
||||
<i class="bi-chevron-right text-secondary ms-2 mt-1"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if dept_url %}
|
||||
<i class="bi-chevron-right text-secondary"></i>
|
||||
{% endif %}
|
||||
</header>
|
||||
</article>
|
||||
{% endwith %}
|
||||
{% empty %}
|
||||
<div class="p-3 text-secondary">Отделов пока нет.</div>
|
||||
<div class="alert alert-info shadow-sm">Отделов пока нет.</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-secondary small">
|
||||
Всего сотрудников: <span id="empTotal">0</span>
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="{% static 'css/components/feed-cards.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/ios-search.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/employee-list.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/components/list-view.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@ -19,12 +19,12 @@
|
||||
<div class="col-12">
|
||||
|
||||
{# ===== НОВАЯ ШАПКА С КОМПОНЕНТОМ ===== #}
|
||||
{% include "includes/components/page_header.html" with icon="bi-people" title="Сотрудники" count=employees|length count_label=count_label search_id="empFilter" search_placeholder="Поиск по ФИО, должности или отделу" has_filters=True filters_target="empFilters" filters_template="employees/_filters.html" create_as_card=True create_modal="#createEmployeeModal" create_text="Создать профиль" %}
|
||||
{% include "includes/components/page_header.html" with icon="bi-people" title="Сотрудники" count=employees|length count_label=count_label search_id="empFilter" search_placeholder="Поиск по ФИО, должности или отделу" has_filters=True filters_target="empFilters" filters_template="employees/_filters.html" create_as_card=True create_modal="createEmployeeModal" create_text="Создать профиль" %}
|
||||
|
||||
<div id="empList" class="feed-stream">
|
||||
<div id="empList" class="list-view feed-stream">
|
||||
{% if employees %}
|
||||
{% for emp in employees %}
|
||||
<article class="emp-row"
|
||||
<article class="emp-row list-row"
|
||||
data-name="{{ emp.last_name }} {{ emp.first_name }} {{ emp.patronymic }} {% firstof emp.position.name emp.position_label '' %}"
|
||||
data-depts="{% if emp.departments %}{% for d in emp.departments %}{% if d.name %}{{ d.name }}{% if not forloop.last %}, {% endif %}{% endif %}{% endfor %}{% elif is_filtered_by_department and current_department %}{{ current_department.name }}{% endif %}">
|
||||
<header class="feed-hd">
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
{% load static %}
|
||||
{% for post in items %}
|
||||
{% firstof post.id post.pk as post_id %}
|
||||
<article data-post-card id="post-{{ post_id }}">
|
||||
<article class="feed-post"
|
||||
id="post-{{ post_id }}"
|
||||
data-post-card
|
||||
data-searchtitle="{{ post.title|default:'Без названия'|escape }}"
|
||||
data-searchauthor="{% with a=post.author %}{% if a %}{% firstof a.full_name a.get_full_name a.first_name a.last_name a.username a.email 'Автор' %}{% else %}Автор{% endif %}{% endwith %}"
|
||||
data-searchbody="{{ post.body|striptags|truncatewords:50|default:'Без содержания'|escape }}">
|
||||
<header class="feed-hd">
|
||||
|
||||
{# --- Аватар автора --- #}
|
||||
|
||||
56
backend/templates/feed/components/modal_create_post.html
Normal file
56
backend/templates/feed/components/modal_create_post.html
Normal file
@ -0,0 +1,56 @@
|
||||
{% comment %}
|
||||
Модальное окно для создания публикации
|
||||
Использование: {% include "feed/components/modal_create_post.html" %}
|
||||
{% endcomment %}
|
||||
|
||||
<div class="modal fade" id="createPostModal" tabindex="-1" aria-labelledby="createPostModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="createPostModalLabel">
|
||||
<i class="bi-megaphone me-2"></i>Создать публикацию
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
|
||||
<form id="createPostForm" method="POST" action="{% url 'feed:post_create' %}?type=company" novalidate enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="modal-body">
|
||||
<div id="createPostErrors" class="alert alert-danger d-none mb-3"></div>
|
||||
|
||||
<input type="hidden" name="type" id="post_type" value="company">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="post_title" class="form-label">Заголовок <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="post_title" name="title" required maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="post_body" class="form-label">Содержание <span class="text-danger">*</span></label>
|
||||
<textarea class="form-control" id="post_body" name="body" rows="6" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="post_image" class="form-label">Изображение</label>
|
||||
<input type="file" class="form-control" id="post_image" name="image" accept="image/*">
|
||||
<small class="form-text text-muted">Поддерживаются форматы: JPG, PNG, GIF</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="post_attachment" class="form-label">Вложение</label>
|
||||
<input type="file" class="form-control" id="post_attachment" name="attachment">
|
||||
<small class="form-text text-muted">Любой файл до 10 МБ</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary" id="createPostSubmit">
|
||||
<i class="bi-check-lg me-1"></i>Опубликовать
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -16,19 +16,15 @@
|
||||
|
||||
{# ===== НОВАЯ ШАПКА С КОМПОНЕНТОМ ===== #}
|
||||
{% if user.is_authenticated and user.is_staff %}
|
||||
{% url 'feed:post_create' as post_create_url_base %}
|
||||
{% with post_create_url=post_create_url_base|add:"?type=company" %}
|
||||
{% include "includes/components/page_header.html" with icon="bi-megaphone" title="Лента" no_search=True create_url=post_create_url create_text="Новая публикация" create_icon="bi-plus-circle" %}
|
||||
{% endwith %}
|
||||
{% include "includes/components/page_header.html" with icon="bi-megaphone" title="Лента" search_id="feedFilter" search_placeholder="Поиск по названию, автору или содержанию" create_as_card=True create_modal="createPostModal" create_text="Создать публикацию" %}
|
||||
{% else %}
|
||||
{% include "includes/components/page_header.html" with icon="bi-megaphone" title="Лента" no_search=True create_url='' create_text="Новая публикация" create_icon="bi-plus-circle" %}
|
||||
{% include "includes/components/page_header.html" with icon="bi-megaphone" title="Лента" search_id="feedFilter" search_placeholder="Поиск по названию, автору или содержанию" %}
|
||||
{% endif %}
|
||||
|
||||
{% if new_employees %}
|
||||
<div class="feed-new mb-3">
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0 text-secondary">Присоединились недавно</h6>
|
||||
<a href="{% url 'employees:employees_list' %}" class="small text-primary text-decoration-none">Все сотрудники</a>
|
||||
<h6 class="mb-0 text-secondary"></h6>
|
||||
</div>
|
||||
|
||||
<div class="join-scroller js-join-scroller" aria-label="Новые сотрудники">
|
||||
@ -68,21 +64,21 @@
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="text-center">
|
||||
<a class="link" href="{% url 'employees:employee_detail' emp_id %}">Профиль →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0 text-secondary"></h6>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="feed-card mb-3">
|
||||
<div class="feed-hd">
|
||||
</div>
|
||||
<div>
|
||||
<div id="feedList">
|
||||
{% if page %}
|
||||
{% include "feed/_feed_cards.html" with items=page.object_list %}
|
||||
{% elif posts %}
|
||||
@ -102,15 +98,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{# Модальное окно для создания публикации #}
|
||||
{% include "feed/components/modal_create_post.html" %}
|
||||
|
||||
<script type="module">
|
||||
import { initAutoScroller } from "{% static 'js/components/autoScrollerHandler.js' %}";
|
||||
import { initCreatePostModal } from "{% static 'js/components/createPostModal.js' %}";
|
||||
import { ListFilter, createDataAttrMatcher } from '{% static "js/components/listFilter.js" %}';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initAutoScroller({
|
||||
selector: '.js-join-scroller',
|
||||
stepMs: 3000
|
||||
speed: 0.5 // пикселей за кадр (примерно 30px/сек)
|
||||
});
|
||||
|
||||
// Инициализация модала создания публикации
|
||||
initCreatePostModal({
|
||||
submitUrl: '{% url "feed:post_create" %}?type=company'
|
||||
});
|
||||
|
||||
// Инициализация поиска по публикациям
|
||||
new ListFilter({
|
||||
listSelector: '#feedList',
|
||||
itemSelector: '.feed-post',
|
||||
searchInputSelector: '#feedFilter',
|
||||
clearButtonSelector: '.ios-search-clear',
|
||||
matchFn: createDataAttrMatcher(['searchtitle', 'searchauthor', 'searchbody']),
|
||||
debounceMs: 300
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,131 +1,133 @@
|
||||
{# Универсальный компонент шапки страницы списка. См. документацию в PAGE_HEADER_SUMMARY.md #}
|
||||
|
||||
{# ===== ШАПКА СТРАНИЦЫ ===== #}
|
||||
<div class="page-header d-flex align-items-center gap-2">
|
||||
{# Иконка #}
|
||||
{% if icon %}
|
||||
<i class="{{ icon }} fs-2 text-primary flex-shrink-0"></i>
|
||||
{% endif %}
|
||||
|
||||
{# Заголовок #}
|
||||
<h2 class="page-title mb-0">{{ title }}</h2>
|
||||
|
||||
{# Счётчик (если есть) #}
|
||||
{% if count is not None %}
|
||||
<span class="page-count text-secondary">
|
||||
{{ count_label|default:"Всего:" }} <span class="count-value">{{ count }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{# Правая часть - кнопки и поиск #}
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
{# Переключатель области (Мои/Все) #}
|
||||
{% if scope_urls %}
|
||||
<div class="btn-group page-scope-switch" role="group" aria-label="Область">
|
||||
<a href="{{ scope_urls.mine }}"
|
||||
class="btn btn-outline-secondary btn-sm {% if scope == 'mine' %}active{% endif %}">
|
||||
Мои
|
||||
</a>
|
||||
<a href="{{ scope_urls.all }}"
|
||||
class="btn btn-outline-secondary btn-sm {% if scope == 'all' %}active{% endif %}">
|
||||
Все
|
||||
</a>
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{# Иконка #}
|
||||
{% if icon %}
|
||||
<i class="{{ icon }} fs-2 text-primary flex-shrink-0"></i>
|
||||
{% endif %}
|
||||
|
||||
{# Дополнительные кнопки (slot) #}
|
||||
{% if extra_buttons %}
|
||||
{{ extra_buttons }}
|
||||
{# Заголовок #}
|
||||
<h2 class="page-title mb-0">{{ title }}</h2>
|
||||
|
||||
{# Счётчик (если есть) #}
|
||||
{% if count is not None %}
|
||||
<span class="page-count text-secondary">
|
||||
{{ count_label|default:"Всего:" }} <span class="count-value">{{ count }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{# Поиск iOS-стиль #}
|
||||
{% if not no_search and search_id %}
|
||||
<div class="page-search ios-search">
|
||||
<i class="bi-search"></i>
|
||||
<input type="search"
|
||||
id="{{ search_id }}"
|
||||
class="ios-search-input"
|
||||
placeholder="{{ search_placeholder|default:'Поиск...' }}"
|
||||
autocomplete="off">
|
||||
<button type="button" class="ios-search-clear" aria-label="Очистить">
|
||||
<i class="bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Кнопка раскрытия фильтров #}
|
||||
{% if has_filters and filters_target %}
|
||||
<button class="btn btn-outline-secondary btn-circle page-filter-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#{{ filters_target }}"
|
||||
aria-expanded="false"
|
||||
aria-controls="{{ filters_target }}"
|
||||
aria-label="Показать фильтры">
|
||||
<i class="bi-funnel"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{# Кнопка создания (только если create_as_card не установлен) #}
|
||||
{% if not no_create and not create_as_card %}
|
||||
{% if create_modal %}
|
||||
<button type="button"
|
||||
class="btn btn-primary btn-sm btn-pill btn-icon page-create-btn"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#{{ create_modal }}">
|
||||
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
|
||||
<span class="btn-text">{{ create_text|default:"Создать" }}</span>
|
||||
</button>
|
||||
{% elif create_url %}
|
||||
<a href="{{ create_url }}"
|
||||
class="btn btn-primary btn-sm btn-pill btn-icon page-create-btn">
|
||||
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
|
||||
<span class="btn-text">{{ create_text|default:"Создать" }}</span>
|
||||
</a>
|
||||
{# Правая часть - кнопки и поиск #}
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
{# Переключатель области (Мои/Все) #}
|
||||
{% if scope_urls %}
|
||||
<div class="btn-group page-scope-switch" role="group" aria-label="Область">
|
||||
<a href="{{ scope_urls.mine }}"
|
||||
class="btn btn-outline-secondary btn-sm {% if scope == 'mine' %}active{% endif %}">
|
||||
Мои
|
||||
</a>
|
||||
<a href="{{ scope_urls.all }}"
|
||||
class="btn btn-outline-secondary btn-sm {% if scope == 'all' %}active{% endif %}">
|
||||
Все
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Дополнительные кнопки (slot) #}
|
||||
{% if extra_buttons %}
|
||||
{{ extra_buttons }}
|
||||
{% endif %}
|
||||
|
||||
{# Поиск iOS-стиль #}
|
||||
{% if not no_search and search_id %}
|
||||
<div class="page-search ios-search">
|
||||
<i class="bi-search"></i>
|
||||
<input type="search"
|
||||
id="{{ search_id }}"
|
||||
class="ios-search-input"
|
||||
placeholder="{{ search_placeholder|default:'Поиск...' }}"
|
||||
autocomplete="off">
|
||||
<button type="button" class="ios-search-clear" aria-label="Очистить">
|
||||
<i class="bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Кнопка раскрытия фильтров #}
|
||||
{% if has_filters and filters_target %}
|
||||
<button class="btn btn-outline-secondary btn-circle page-filter-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#{{ filters_target }}"
|
||||
aria-expanded="false"
|
||||
aria-controls="{{ filters_target }}"
|
||||
aria-label="Показать фильтры">
|
||||
<i class="bi-funnel"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{# Кнопка создания (только если create_as_card не установлен) #}
|
||||
{% if not no_create and not create_as_card %}
|
||||
{% if create_modal %}
|
||||
<button type="button"
|
||||
class="btn btn-primary btn-sm btn-pill btn-icon page-create-btn"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#{{ create_modal }}">
|
||||
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
|
||||
<span class="btn-text">{{ create_text|default:"Создать" }}</span>
|
||||
</button>
|
||||
{% elif create_url %}
|
||||
<a href="{{ create_url }}"
|
||||
class="btn btn-primary btn-sm btn-pill btn-icon page-create-btn">
|
||||
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
|
||||
<span class="btn-text">{{ create_text|default:"Создать" }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===== БЛОК ФИЛЬТРОВ (скрываемый) ===== #}
|
||||
{% if has_filters and filters_target %}
|
||||
<div class="collapse page-filters-collapse" id="{{ filters_target }}">
|
||||
<div class="page-filters">
|
||||
{% if filters_template %}
|
||||
{# Включаем внешний шаблон фильтров #}
|
||||
{% include filters_template %}
|
||||
{% else %}
|
||||
{# Или используем переданный HTML #}
|
||||
{{ filters_html|safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== КНОПКА СОЗДАНИЯ В ВИДЕ КАРТОЧКИ ===== #}
|
||||
{% if not no_create and create_as_card %}
|
||||
{% if create_modal %}
|
||||
<div class="feed-card mb-2">
|
||||
<button type="button"
|
||||
class="dept-row d-flex w-100 justify-content-center align-items-center gap-2 text-decoration-none border-0 bg-transparent"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#{{ create_modal }}"
|
||||
role="button"
|
||||
aria-label="{{ create_text|default:'Создать' }}">
|
||||
<span class="feed-author">{{ create_text|default:"Создать" }}</span>
|
||||
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% elif create_url %}
|
||||
<div class="feed-card mb-2">
|
||||
<a href="{{ create_url }}"
|
||||
class="dept-row d-flex w-100 justify-content-center align-items-center gap-2 text-decoration-none border-0 bg-transparent"
|
||||
role="button"
|
||||
aria-label="{{ create_text|default:'Создать' }}">
|
||||
<span class="feed-author">{{ create_text|default:"Создать" }}</span>
|
||||
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ===== БЛОК ФИЛЬТРОВ (скрываемый) ===== #}
|
||||
{% if has_filters and filters_target %}
|
||||
<div class="collapse page-filters-collapse" id="{{ filters_target }}">
|
||||
<div class="page-filters">
|
||||
{% if filters_template %}
|
||||
{# Включаем внешний шаблон фильтров #}
|
||||
{% include filters_template %}
|
||||
{% else %}
|
||||
{# Или используем переданный HTML #}
|
||||
{{ filters_html|safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== КНОПКА СОЗДАНИЯ В ВИДЕ КАРТОЧКИ ===== #}
|
||||
{% if not no_create and create_as_card %}
|
||||
{% if create_modal %}
|
||||
<div class="feed-card mb-2">
|
||||
<button type="button"
|
||||
class="dept-row d-flex w-100 justify-content-center align-items-center gap-2 text-decoration-none border-0 bg-transparent"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#{{ create_modal }}"
|
||||
role="button"
|
||||
aria-label="{{ create_text|default:'Создать' }}">
|
||||
<span class="feed-author">{{ create_text|default:"Создать" }}</span>
|
||||
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% elif create_url %}
|
||||
<div class="feed-card mb-2">
|
||||
<a href="{{ create_url }}"
|
||||
class="dept-row d-flex w-100 justify-content-center align-items-center gap-2 text-decoration-none border-0 bg-transparent"
|
||||
role="button"
|
||||
aria-label="{{ create_text|default:'Создать' }}">
|
||||
<span class="feed-author">{{ create_text|default:"Создать" }}</span>
|
||||
<i class="{{ create_icon|default:'bi-plus-lg' }}"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user