- Created reusable utility modules:
* static/js/utils/stringUtils.js - string operations (esc, norm, escAttr, truncate, getInitials)
* static/js/utils/timing.js - timing utilities (debounce, throttle, delay, poll, once)
* static/js/utils/scroll.js - scroll helpers (smoothScrollTo, scrollToTop, isElementInViewport, etc.)
- Updated static/js/utils/index.js:
* Exports all utilities from a single entry point
* Provides centralized access to all utility functions
- Modified base.html:
* Removed inline esc() and norm() function definitions
* Added ES6 module imports for utilities
* Exported utilities to window for backward compatibility
* All existing code continues to work without changes
Benefits:
- Eliminates code duplication (esc/norm found in 9 templates)
- Centralizes utility functions for easier maintenance
- Enables future improvements (testing, optimization)
- Maintains backward compatibility with existing templates
Tests: All 12 static file tests passing ✅
1120 lines
46 KiB
HTML
1120 lines
46 KiB
HTML
{% load static api_tags %}
|
||
<!doctype html>
|
||
<html lang="ru" data-bs-theme="auto">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<meta name="color-scheme" content="light dark">
|
||
{% if user.is_authenticated %}
|
||
<meta name="api-access" content="{% api_access_token request %}">
|
||
{% endif %}
|
||
|
||
<title>{% block title %}HiRo{% endblock %}</title>
|
||
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'img/favicon-32.png' %}">
|
||
<link rel="apple-touch-icon" href="{% static 'img/apple-touch-icon.png' %}">
|
||
<link rel="icon" type="image/png" sizes="192x192" href="{% static 'img/logo-192.png' %}">
|
||
|
||
|
||
<!-- Bootstrap / Icons / FullCalendar -->
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||
rel="stylesheet"
|
||
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
|
||
crossorigin="anonymous">
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||
|
||
<!-- Базовые стили приложения (+ feed) -->
|
||
<style>
|
||
:root{
|
||
--sidebar-w: 190px;
|
||
--rightbar-w: 350px;
|
||
--navbar-h: 60px;
|
||
--bs-primary:#007AFF;
|
||
--bs-link-color: var(--bs-primary);
|
||
--bs-link-hover-color:#0062CC;
|
||
|
||
/* feed tokens */
|
||
--feed-radius: 18px;
|
||
--feed-shadow: 0 1px 2px rgba(0,0,0,.04), 0 10px 30px rgba(0,0,0,.08);
|
||
--feed-shadow-hover: 0 4px 10px rgba(0,0,0,.06), 0 16px 40px rgba(0,0,0,.12);
|
||
}
|
||
[data-bs-theme="dark"] {
|
||
--bs-body-bg:#1C1C1E;
|
||
--bs-body-color:#F2F2F7;
|
||
--bs-border-color:#3A3A3C;
|
||
--bs-tertiary-bg:#2C2C2E;
|
||
--bs-primary:#0A84FF;
|
||
--bs-link-color:var(--bs-primary);
|
||
--bs-link-hover-color:#59AFFF;
|
||
}
|
||
body{
|
||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||
font-size:17px;
|
||
padding-top:var(--navbar-h);
|
||
overflow-x:hidden;
|
||
background-color:var(--bs-body-bg);
|
||
}
|
||
@supports(padding-top: env(safe-area-inset-top)){
|
||
body{ padding-top: calc(var(--navbar-h) + env(safe-area-inset-top)); }
|
||
}
|
||
.btn{ border-radius:10px; min-height:44px; transition:background-color .3s, transform .1s; }
|
||
.btn:hover{ transform:scale(1.02); }
|
||
.form-control{ border-radius:10px; height:44px; font-size:17px; }
|
||
.form-control:focus{ box-shadow:0 0 0 2px rgba(var(--bs-primary-rgb), .25); border-color: var(--bs-primary); }
|
||
.dropdown-menu{ border-radius:13px; box-shadow:0 6px 24px rgba(0,0,0,.12); }
|
||
|
||
/* navbar */
|
||
.app-navbar{ position:fixed; inset:0 0 auto 0; z-index:1030; border-bottom:.5px solid var(--bs-border-color); }
|
||
|
||
/* layout */
|
||
.app-layout{ display:block; }
|
||
@media (min-width: 992px){
|
||
.app-layout.with-sidebar.with-rightbar{
|
||
display:grid; grid-template-columns: var(--sidebar-w) 1fr var(--rightbar-w); align-items:start;
|
||
}
|
||
.app-layout.with-sidebar:not(.with-rightbar){
|
||
display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:start;
|
||
}
|
||
}
|
||
|
||
|
||
/* левый sidebar (sticky ≥lg) */
|
||
#sidebar{ --bs-offcanvas-width: var(--sidebar-w); border-radius:0 13px 13px 0; }
|
||
@media (min-width: 992px){
|
||
#sidebar.offcanvas-lg{
|
||
position:sticky; top:var(--navbar-h);
|
||
height:calc(100dvh - var(--navbar-h));
|
||
align-self:start; overflow:auto;
|
||
border-right:.5px solid var(--bs-border-color);
|
||
background:var(--bs-body-bg);
|
||
transform:none !important; visibility:visible !important;
|
||
}
|
||
}
|
||
|
||
/* бейдж возле колокола */
|
||
.bell-wrapper{ position:relative; line-height:1; display:inline-block; }
|
||
.bell-dot{
|
||
position:absolute; top:-2px; right:-2px;
|
||
width:9px; height:9px; border-radius:999px;
|
||
background:var(--bs-danger); color:#fff;
|
||
font-size:7px; line-height:9px; font-weight:700;
|
||
display:flex; align-items:center; justify-content:center;
|
||
box-shadow:0 0 0 1px rgba(255,255,255,.65);
|
||
}
|
||
|
||
/* mobile FAB */
|
||
.calendar-fab{
|
||
position:fixed; right:1rem; bottom:1rem; z-index:1060;
|
||
width:52px; height:52px; border-radius:9999px;
|
||
box-shadow:0 8px 24px rgba(0,0,0,.18);
|
||
}
|
||
|
||
/* ===== feed styles (карточки ленты) ===== */
|
||
.feed-header{ display:flex; align-items:center; gap:.75rem; margin-bottom:1rem; }
|
||
.feed-header .title{ font-weight:800; letter-spacing:.2px; margin:0; }
|
||
|
||
.feed-stream{ display:flex; flex-direction:column; gap:0; }
|
||
|
||
.feed-card{
|
||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
|
||
border-radius: var(--feed-radius);
|
||
background: var(--bs-body-bg);
|
||
box-shadow: var(--feed-shadow);
|
||
transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease;
|
||
overflow:hidden;
|
||
}
|
||
.feed-card:hover{ box-shadow: var(--feed-shadow-hover); transform: translateY(-1px); }
|
||
|
||
.feed-hd{ display:flex; align-items:center; gap:.75rem; padding:.9rem 1rem .5rem 1rem; }
|
||
.feed-ava{
|
||
width:42px; height:42px; border-radius:9999px; overflow:hidden;
|
||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
|
||
background: var(--bs-tertiary-bg);
|
||
display:grid; place-items:center; flex:0 0 auto;
|
||
}
|
||
.feed-ava img{ width:100%; height:100%; object-fit:cover; display:block; }
|
||
.feed-meta{ line-height:1.2; }
|
||
.feed-author{ font-weight:700; color: var(--bs-body-color); }
|
||
.feed-sub{ font-size:.82rem; color: var(--bs-secondary-color); }
|
||
|
||
.feed-pin{
|
||
margin-left:auto; font-size:.75rem;
|
||
padding:.2rem .5rem; border-radius:999px;
|
||
background:#fff3cd; color:#664d03; border:1px solid #ffe69c;
|
||
}
|
||
[data-bs-theme="dark"] .feed-pin{ background:#3b2e04; color:#ffda6a; border-color:#4f3f0a; }
|
||
|
||
.feed-body{ padding:.25rem .75rem .5rem .75rem; position:relative; }
|
||
.feed-title {
|
||
font: 700 1.3rem/1.5 var(--bs-body-font-family);
|
||
margin: 0.25rem 0 0.35rem;
|
||
color: #4B4B4B; /* Альтернатива: rgba(0,0,0,.8) */
|
||
}
|
||
|
||
.feed-img{ position:relative; border-top:1px solid var(--bs-border-color); background: var(--bs-tertiary-bg); }
|
||
.feed-img img{ display:block; width:100%; height:auto; aspect-ratio:16/9; object-fit:cover; }
|
||
|
||
.feed-actions{
|
||
display:flex; align-items:center; gap:.5rem; padding:0rem 1rem .9rem 1rem;
|
||
border-top:1px solid var(--bs-border-color); position:relative; z-index:2;
|
||
}
|
||
.btn-ghost{
|
||
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent);
|
||
color: var(--bs-body-color);
|
||
padding:.35rem .6rem; border-radius:999px; font-size:.9rem;
|
||
}
|
||
.btn-ghost:hover{ background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent); }
|
||
.btn-ghost .bi{ font-size:1rem; margin-right:.35rem; vertical-align:-2px; }
|
||
|
||
/* «Новые сотрудники» */
|
||
.feed-new{
|
||
border-radius: var(--feed-radius);
|
||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
|
||
background: var(--bs-body-bg);
|
||
box-shadow: var(--feed-shadow);
|
||
padding:.9rem 1rem;
|
||
}
|
||
|
||
/* stretched-link не перекрывает футер/формы */
|
||
.feed-body .stretched-link::after,
|
||
.feed-img .stretched-link::after { z-index: 1; }
|
||
.collapse{ position:relative; z-index:2; }
|
||
|
||
/* подпись «Скрыть/число» у кнопки комментариев по aria-expanded */
|
||
.btn-ghost .txt-close{ display:none; }
|
||
.btn-ghost[aria-expanded="true"] .txt-open{ display:none; }
|
||
.btn-ghost[aria-expanded="true"] .txt-close{ display:inline; }
|
||
|
||
/* Буквенные разделители (липкие) */
|
||
.alpha-header{
|
||
position: sticky; top: calc(var(--navbar-h) + 8px);
|
||
z-index: 1;
|
||
background: color-mix(in srgb, var(--bs-body-bg) 92%, transparent);
|
||
backdrop-filter: blur(6px);
|
||
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
|
||
color: var(--bs-secondary-color);
|
||
display: inline-flex; align-items: center; gap:.5rem;
|
||
padding: .2rem .6rem; border-radius: 999px; font-weight: 700; font-size: .85rem;
|
||
margin: .25rem 0 .5rem .25rem;
|
||
}
|
||
.alpha-dot{ width:6px; height:6px; border-radius:999px; background: var(--bs-primary); opacity:.6; }
|
||
/* Иконки-действия справа в карточке */
|
||
.action-btn{ min-height: unset; padding:.35rem .6rem; }
|
||
.action-btn .bi{ font-size: 1rem; }
|
||
|
||
/* Переносим длинные слова/ссылки, сохраняем переносы строк */
|
||
.feed-text, .feed-full{
|
||
overflow-wrap: anywhere;
|
||
width: 100%;
|
||
margin-right: 0;
|
||
padding-right: 0;
|
||
}
|
||
|
||
/* чтобы кнопка «Подробнее» не была перекрыта stretched-link */
|
||
.readmore-toggle{ position: relative; z-index: 2; }
|
||
</style>
|
||
<style>
|
||
/* Base card / layout */
|
||
.ios-card{
|
||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
|
||
border-radius: var(--feed-radius);
|
||
background: var(--bs-body-bg);
|
||
box-shadow: var(--feed-shadow);
|
||
transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease;
|
||
overflow:hidden;
|
||
}
|
||
.ios-card:hover{ box-shadow: var(--feed-shadow-hover); transform: translateY(-1px); }
|
||
|
||
.ios-header{
|
||
position: sticky; top: var(--navbar-h, 0px);
|
||
z-index: 5; padding:.75rem 0;
|
||
backdrop-filter: saturate(180%) blur(12px);
|
||
background: color-mix(in srgb, var(--bs-body-bg) 85%, transparent);
|
||
border-bottom: none !important;
|
||
}
|
||
.ios-title{ font-weight: 800; letter-spacing: -.01em; }
|
||
.row.g-ios{ align-items: flex-start; }
|
||
|
||
.ios-ava, .ios-ava--ph{
|
||
display: grid; place-items: center; background: var(--bs-tertiary-bg);
|
||
color: var(--bs-secondary-color); font-size: 3rem; line-height: 1;
|
||
width: 100%; height: 100%; border-radius: 28px;
|
||
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
|
||
}
|
||
.ava-wrap{ position: relative; width:132px; height:132px; margin:0 auto 12px; display:block; }
|
||
|
||
.btn-icon{
|
||
width:36px; height:36px; padding:0; border-radius:9999px; display:grid; place-items:center;
|
||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
|
||
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent); color: var(--bs-body-color);
|
||
}
|
||
.btn-icon:hover{ background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent); }
|
||
|
||
.ios-name{ font-weight:800; font-size:1.25rem; }
|
||
.ios-role{ font-size:.9rem; color: var(--bs-secondary-color); }
|
||
|
||
.chips{ display:flex; flex-wrap:wrap; gap:.5rem; }
|
||
.chip{
|
||
padding:.35rem .65rem; border-radius:9999px; font-size:.85rem; line-height:1;
|
||
background: var(--bs-secondary-bg); color: var(--bs-body-color);
|
||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
|
||
}
|
||
|
||
.ios-collapse-trigger{
|
||
width:100%; display:flex; align-items:center; justify-content:space-between; gap:.75rem;
|
||
padding:.6rem .9rem; cursor:pointer; border-radius:14px;
|
||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
|
||
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent); color: var(--bs-body-color);
|
||
}
|
||
.ios-collapse-caret{ transition: transform .25s ease; }
|
||
.ios-collapse-trigger[aria-expanded="true"] .ios-collapse-caret{ transform: rotate(180deg); }
|
||
|
||
.ios-list{
|
||
border-radius:14px; overflow:hidden; background: var(--bs-body-bg);
|
||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
|
||
}
|
||
.ios-cell{
|
||
display:flex; align-items:center; gap:.75rem; padding:.85rem 1rem;
|
||
border-bottom:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
|
||
}
|
||
.ios-cell:last-child{ border-bottom:0; }
|
||
.ios-cell i{ width:1.25rem; text-align:center; opacity:.9; }
|
||
.ios-cell a{ text-decoration:none; color:inherit; }
|
||
|
||
.ios-section-title{ font-weight:700; font-size:1rem; margin-bottom:.5rem; }
|
||
.rounded-ios{ border-radius:14px !important; }
|
||
|
||
/* Departments list */
|
||
.ios-cell.dept-item{ justify-content: space-between; }
|
||
.ios-cell.dept-head{ border-left: 0; background: transparent; }
|
||
|
||
.dept-line{ display:flex; align-items:center; gap:.5rem; }
|
||
.dept-name{ font-weight:700; }
|
||
.dept-role{ color: var(--bs-secondary-color); }
|
||
|
||
.badge-head{
|
||
display:inline-flex; align-items:center; gap:.35rem;
|
||
padding:.2rem .55rem; border-radius:999px;
|
||
background: color-mix(in srgb, var(--bs-warning) 15%, var(--bs-body-bg));
|
||
color: color-mix(in srgb, var(--bs-warning) 60%, black 0%);
|
||
border:1px solid color-mix(in srgb, var(--bs-warning) 35%, transparent);
|
||
font-weight:700; font-size:.8rem; line-height:1;
|
||
white-space: nowrap;
|
||
}
|
||
.badge-head .bi{ font-size:.95rem; line-height:1; }
|
||
|
||
/* NEW: blue pill like .badge-head, for the gear edit button */
|
||
.badge-gear{
|
||
display:inline-flex; align-items:center; gap:.35rem;
|
||
padding:.2rem .55rem; border-radius:999px; cursor:pointer;
|
||
background: color-mix(in srgb, var(--bs-primary) 15%, var(--bs-body-bg));
|
||
color: color-mix(in srgb, var(--bs-primary) 65%, black 0%);
|
||
border:1px solid color-mix(in srgb, var(--bs-primary) 35%, transparent);
|
||
font-weight:700; font-size:.8rem; line-height:1; white-space: nowrap;
|
||
}
|
||
.badge-gear .bi{ font-size:.95rem; line-height:1; }
|
||
.badge-gear:hover{ background: color-mix(in srgb, var(--bs-primary) 20%, var(--bs-body-bg)); }
|
||
|
||
@media (max-width: 420px){
|
||
.badge-head .lbl, .badge-gear .lbl{ display:none; }
|
||
}
|
||
|
||
.ios-collapse-trigger + .collapse { margin-top: 0 !important; }
|
||
.ios-collapse-trigger + .collapse > .ios-list,
|
||
.ios-collapse-trigger + .collapse > .chips { margin-top: .5rem; }
|
||
|
||
.collapsing { overflow: hidden; transition: height .35s ease; }
|
||
|
||
.ios-acc{
|
||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
|
||
border-radius:14px; background: var(--bs-body-bg); overflow: hidden;
|
||
}
|
||
.ios-acc + .ios-acc{ margin-top:.75rem; }
|
||
.ios-acc .ios-collapse-trigger{ border:0; border-radius:0; background: transparent; width:100%; padding:.6rem .9rem; }
|
||
.ios-acc .ios-collapse-trigger:focus{ box-shadow:none; }
|
||
.ios-acc .collapse{ margin:0 !important; }
|
||
.ios-acc .ios-list{ border:0; border-radius:0; }
|
||
.ios-acc .chips{ margin:0; }
|
||
.ios-acc .ios-acc-body{
|
||
padding:.5rem .25rem .75rem;
|
||
border-top:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
|
||
}
|
||
|
||
/* Activity block adjustments */
|
||
#activity .p-4{ padding:0 !important; }
|
||
#activity .ios-section-title{ padding:1rem 1rem .5rem !important; margin:0 !important; }
|
||
#activity .activity-feed{ margin:0 !important; }
|
||
#activity .activity-feed .feed-card{
|
||
width:100% !important; max-width:none !important; border:0 !important; box-shadow:none !important;
|
||
border-radius:0 !important; margin:0 !important;
|
||
}
|
||
#activity .activity-feed{ display:flex; flex-direction:column; gap:1rem; }
|
||
#activity .activity-feed .feed-stream{ display:flex; flex-direction:column; gap:1rem; }
|
||
#activity .activity-feed .row > *{ flex:0 0 100% !important; max-width:100% !important; }
|
||
#activity .feed-actions{ border-top: 0 !important; }
|
||
#activity .collapse .bg-body-tertiary.border{ border: 0 !important; }
|
||
|
||
/* Skills UI */
|
||
#skillsBlock[data-editable="1"] .chip.chip-add{display:inline-flex;align-items:center;cursor:pointer;border-style:dashed}
|
||
#skillsBlock[data-editable="1"] .chip.chip-add.pulse{transform:scale(.97);transition:transform .15s ease}
|
||
#skillsBlock[data-editable="1"] .chip.chip-add .bi{font-size:.95rem}
|
||
.chip.chip-skill{position:relative;padding:.35rem .65rem}
|
||
.chip.chip-skill .lbl{transition:opacity .15s ease}
|
||
.chip.chip-skill .chip-del-btn{
|
||
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
|
||
background:transparent;border:0;padding:0;margin:0;cursor:pointer;
|
||
color:var(--bs-danger);font-size:1rem;line-height:1;
|
||
visibility:hidden;opacity:0;transition:opacity .15s ease;z-index:1
|
||
}
|
||
.chip.chip-skill .chip-del-btn .bi{pointer-events:none}
|
||
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .lbl{opacity:0}
|
||
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .chip-del-btn,
|
||
#skillsBlock[data-editable="1"] .chip.chip-skill:focus-within .chip-del-btn{visibility:visible;opacity:1}
|
||
.skills-empty{color:var(--bs-secondary-color);font-size:.875rem;margin-right:.25rem}
|
||
|
||
/* Dept inline editor */
|
||
.dept-edit-btn{
|
||
width:32px;height:32px;padding:0;display:grid;place-items:center;border-radius:999px;
|
||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
|
||
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent);
|
||
}
|
||
.dept-edit-btn:hover{ background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent); }
|
||
.dept-editor-wrap{ flex: 0 0 100%; width:100%; margin-top:.5rem; }
|
||
.dept-editor form{ display:flex; align-items:center; gap:.5rem; }
|
||
.dept-role-select{ min-width: 180px; }
|
||
/* полностью прячем роль, когда открыт редактор */
|
||
.dept-item.is-editing .dept-role{ display:none !important; }
|
||
|
||
/* не даём блоку с кнопками сжиматься/уезжать */
|
||
.dept-item > .d-flex{
|
||
flex: 0 1 auto;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
gap: .35rem .5rem;
|
||
max-width: 100%;
|
||
}
|
||
/* 2) Левую часть реально можно ужать с многоточием */
|
||
.dept-line{ min-width: 0; }
|
||
|
||
/* 3) Сами пилюли не растягиваются */
|
||
.badge-head,
|
||
.badge-gear{ flex: 0 0 auto; }
|
||
|
||
/* 4) Строка отдела допускает перенос элементов (на всякий) */
|
||
.ios-cell{ flex-wrap: wrap; }
|
||
|
||
/* 5) При открытом редакторе полностью прячем роль */
|
||
.dept-item.is-editing .dept-role{ display:none !important; }
|
||
|
||
/* 6) На узких — прячем подписи внутри пилюль, чтобы всё поместилось */
|
||
@media (max-width: 576px){
|
||
.badge-head .lbl,
|
||
.badge-gear .lbl{ display:none; }
|
||
}
|
||
/* (аккуратная связка кнопки-разделителя и панели списка) — у тебя была потеряна скобка */
|
||
.ios-acc .ios-collapse-trigger + .collapse { margin-top: 0 !important; }
|
||
.ios-acc .ios-acc-body { border-top: 0; }
|
||
|
||
/* Группа иконок у заголовка */
|
||
.header-icon-group .btn-icon{ width:36px; height:36px; }
|
||
|
||
/* Варианты окраски, дружат со светлой/тёмной темой */
|
||
.btn-icon--primary{
|
||
color: var(--bs-primary);
|
||
border-color: color-mix(in srgb, var(--bs-primary) 30%, var(--bs-border-color));
|
||
}
|
||
.btn-icon--primary:hover{
|
||
background: color-mix(in srgb, var(--bs-primary) 16%, var(--bs-body-bg));
|
||
}
|
||
|
||
.btn-icon--success{
|
||
color: var(--bs-success);
|
||
border-color: color-mix(in srgb, var(--bs-success) 30%, var(--bs-border-color));
|
||
}
|
||
.btn-icon--success:hover{
|
||
background: color-mix(in srgb, var(--bs-success) 16%, var(--bs-body-bg));
|
||
}
|
||
|
||
.btn-icon--danger{
|
||
color: var(--bs-danger);
|
||
border-color: color-mix(in srgb, var(--bs-danger) 30%, var(--bs-border-color));
|
||
}
|
||
.btn-icon--danger:hover{
|
||
background: color-mix(in srgb, var(--bs-danger) 16%, var(--bs-body-bg));
|
||
}
|
||
|
||
/* Акцентированный фокус для клавиатуры */
|
||
.btn-icon:focus-visible{
|
||
outline: 2px solid color-mix(in srgb, currentColor 60%, transparent);
|
||
outline-offset: 2px;
|
||
}
|
||
.container-xxl { max-width: 1820px; padding-inline: var(--bs-gutter-x,.75rem); }
|
||
</style>
|
||
|
||
{% block extra_css %}{% endblock %}
|
||
</head>
|
||
<body class="min-vh-100">
|
||
|
||
{% include "includes/navbar.html" %}
|
||
|
||
<div class="container-xxl px-2 px-lg-3">
|
||
<div class="app-layout {% if user.is_authenticated %}with-sidebar with-rightbar{% endif %}">
|
||
{% if user.is_authenticated %}
|
||
{% include "includes/sidebar.html" %}
|
||
{% endif %}
|
||
|
||
<main class="main py-3">
|
||
{% block content %}{% endblock %}
|
||
</main>
|
||
|
||
{% if user.is_authenticated %}
|
||
{% include "includes/rightbar_calendar.html" %}
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- JS -->
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
||
crossorigin="anonymous" defer></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js" defer></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.8/locales-all.global.min.js" defer></script>
|
||
|
||
{# Импорт утилит из модулей #}
|
||
<script type="module">
|
||
import { esc, norm, escAttr, truncate, getInitials } from '{% static "js/utils/stringUtils.js" %}';
|
||
import { debounce, throttle, delay } from '{% static "js/utils/timing.js" %}';
|
||
import { smoothScrollTo, scrollToTop, isElementInViewport } from '{% static "js/utils/scroll.js" %}';
|
||
|
||
// Экспортируем в window для обратной совместимости с существующим кодом
|
||
window.esc = esc;
|
||
window.norm = norm;
|
||
window.escAttr = escAttr;
|
||
window.truncate = truncate;
|
||
window.getInitials = getInitials;
|
||
window.debounce = debounce;
|
||
window.throttle = throttle;
|
||
window.delay = delay;
|
||
window.smoothScrollTo = smoothScrollTo;
|
||
window.scrollToTop = scrollToTop;
|
||
window.isElementInViewport = isElementInViewport;
|
||
</script>
|
||
|
||
<script>
|
||
// реальная высота navbar -> CSS var
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const nav = document.querySelector('.app-navbar');
|
||
const setH = () => nav && document.documentElement.style.setProperty('--navbar-h', nav.offsetHeight + 'px');
|
||
setH(); addEventListener('resize', setH);
|
||
});
|
||
</script>
|
||
|
||
<script>
|
||
// Глобальный обработчик лайков (использует стили/классы базового шаблона)
|
||
(function(){
|
||
function getCookie(name){
|
||
const m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
|
||
return m ? m.pop() : '';
|
||
}
|
||
|
||
document.addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('[data-like-post]');
|
||
if (!btn) return;
|
||
e.preventDefault();
|
||
|
||
const url = btn.getAttribute('data-like-url');
|
||
if (!url || btn.disabled) return;
|
||
|
||
// текущее состояние -> какая операция нужна
|
||
const currentlyLiked =
|
||
btn.classList.contains('is-liked') ||
|
||
btn.getAttribute('aria-pressed') === 'true';
|
||
const op = currentlyLiked ? 'unlike' : 'like';
|
||
|
||
btn.disabled = true;
|
||
try{
|
||
const resp = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-CSRFToken': getCookie('csrftoken'),
|
||
'Accept': 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest'
|
||
},
|
||
body: new URLSearchParams({ op })
|
||
});
|
||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||
|
||
const data = await resp.json().catch(() => ({}));
|
||
// совместимость с разными ключами
|
||
const liked = (data.liked !== undefined) ? !!data.liked
|
||
: (data.is_liked !== undefined) ? !!data.is_liked
|
||
: !currentlyLiked; // если что-то пошло не так — просто инвертируем
|
||
|
||
const count = (data.likes !== undefined) ? data.likes
|
||
: (data.likes_count !== undefined) ? data.likes_count
|
||
: null;
|
||
|
||
// обновляем кнопку/иконку/aria
|
||
btn.classList.toggle('is-liked', liked);
|
||
btn.setAttribute('aria-pressed', liked ? 'true' : 'false');
|
||
|
||
const icon = btn.querySelector('.bi');
|
||
if (icon){
|
||
icon.classList.toggle('bi-heart', !liked);
|
||
icon.classList.toggle('bi-heart-fill', liked);
|
||
}
|
||
|
||
// счётчик (в пределах карточки)
|
||
const card = btn.closest('[data-post-card]');
|
||
const cnt = card && card.querySelector('[data-like-count]');
|
||
if (cnt && count !== null) cnt.textContent = String(count);
|
||
|
||
// если есть скрытый input name="op", поддержим и серверный постбэк
|
||
const opInput = btn.closest('form')?.querySelector('input[name="op"]');
|
||
if (opInput) opInput.value = liked ? 'unlike' : 'like';
|
||
|
||
}catch(err){
|
||
console.error(err);
|
||
}finally{
|
||
btn.disabled = false;
|
||
}
|
||
}, { passive:false });
|
||
})();
|
||
</script>
|
||
|
||
{% if user.is_authenticated %}
|
||
<script>
|
||
// Базовая карта аватаров (только текущий пользователь; остальные подтянутся из ws)
|
||
window.__CHAT_AVATARS__ = {
|
||
"{{ user.id }}": {% if user.avatar %}"{{ user.avatar.url }}"{% else %}""{% endif %}
|
||
};
|
||
</script>
|
||
{% endif %}
|
||
|
||
<script>
|
||
// автоскролл / авто-рост textarea / кнопка «вниз» / разделитель «Новые сообщения» / отметка прочитанного
|
||
(function(){
|
||
const box = document.getElementById('chatScroll');
|
||
if (!box) return;
|
||
const ta = document.getElementById('id_content');
|
||
const form = document.getElementById('chatForm');
|
||
const btn = document.getElementById('scrollBtn');
|
||
|
||
const chatId = Number(box?.dataset.chatId || 0);
|
||
const meId = Number(box?.dataset.meId || 0);
|
||
|
||
function getCookie(name){
|
||
const m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
|
||
return m ? m.pop() : '';
|
||
}
|
||
const csrf = getCookie('csrftoken');
|
||
|
||
const LS_KEY = `chat:lastRead:${chatId}`;
|
||
function getLocalLastReadTs(){
|
||
const v = localStorage.getItem(LS_KEY);
|
||
return v ? Number(v) : NaN;
|
||
}
|
||
function setLocalLastReadTs(ts){
|
||
localStorage.setItem(LS_KEY, String(ts));
|
||
}
|
||
|
||
(function initLastRead(){
|
||
let lastRead = Number(box.dataset.lastReadTs || NaN);
|
||
if (!Number.isFinite(lastRead)){
|
||
const ls = getLocalLastReadTs();
|
||
if (Number.isFinite(ls)) lastRead = ls;
|
||
}
|
||
if (Number.isFinite(lastRead)) box.dataset.lastReadTs = String(lastRead);
|
||
})();
|
||
|
||
let lastMarkedTs = Number(box.dataset.lastReadTs || 0);
|
||
|
||
function atBottom(){ return (box.scrollHeight - box.scrollTop - box.clientHeight) < 60; }
|
||
|
||
function autoscroll(){
|
||
const prev = box.style.scrollBehavior;
|
||
box.style.scrollBehavior = 'auto';
|
||
box.scrollTop = box.scrollHeight;
|
||
if (prev) { box.style.scrollBehavior = prev; } else { box.style.removeProperty('scroll-behavior'); }
|
||
}
|
||
|
||
function toggleBtn(){ btn?.classList.toggle('show', !atBottom()); }
|
||
|
||
function makeDivider(cls, text){
|
||
const div = document.createElement('div');
|
||
div.className = cls;
|
||
div.setAttribute('role','separator');
|
||
div.setAttribute('aria-label', text);
|
||
div.innerHTML = `<span>${text}</span>`;
|
||
return div;
|
||
}
|
||
|
||
function findUnreadTarget(){
|
||
const id = window.__FIRST_UNREAD_ID__;
|
||
const ts = window.__UNREAD_FROM_TS__;
|
||
const lastReadTs = Number(box.dataset.lastReadTs || NaN);
|
||
|
||
if (id != null){
|
||
const el = box.querySelector(`.msg[data-id="${id}"]`);
|
||
if (el) return el;
|
||
}
|
||
if (Number.isFinite(ts)){
|
||
const items = Array.from(box.querySelectorAll('.msg[data-ts]'));
|
||
const el = items.find(e => Number(e.dataset.ts) >= Number(ts));
|
||
if (el) return el;
|
||
}
|
||
if (Number.isFinite(lastReadTs)){
|
||
const items = Array.from(box.querySelectorAll(`.msg[data-ts][data-author-id]`));
|
||
return items.find(e => Number(e.dataset.ts) > lastReadTs && Number(e.dataset.authorId) !== meId) || null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function insertUnreadDivider(){
|
||
if (!box || box.querySelector('.unread-divider')) return null;
|
||
const target = findUnreadTarget();
|
||
if (target){
|
||
const div = makeDivider('unread-divider', 'Новые сообщения');
|
||
target.parentNode.insertBefore(div, target);
|
||
return div;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function removeUnreadDivider(){
|
||
const d = box.querySelector('.unread-divider');
|
||
if (d) d.remove();
|
||
}
|
||
|
||
async function markRead(ts){
|
||
ts = Number(ts);
|
||
if (!Number.isFinite(ts) || ts <= lastMarkedTs) return;
|
||
lastMarkedTs = ts;
|
||
setLocalLastReadTs(ts);
|
||
box.dataset.lastReadTs = String(ts);
|
||
removeUnreadDivider();
|
||
|
||
// URL: только если есть chat в контексте — иначе fallback на suffix
|
||
{% if chat %}
|
||
let markUrl = "{% url 'communications:chat_mark_read' chat.pk %}";
|
||
{% else %}
|
||
let markUrl = "";
|
||
{% endif %}
|
||
if (!markUrl){
|
||
const base = location.pathname.endsWith("/") ? location.pathname : (location.pathname + "/");
|
||
markUrl = base + "mark-read/";
|
||
}
|
||
try{
|
||
await fetch(markUrl, {
|
||
method: 'POST',
|
||
headers: { 'X-CSRFToken': csrf, 'Accept':'application/json' },
|
||
body: new URLSearchParams({ upto_ts: String(ts) }),
|
||
credentials: 'same-origin'
|
||
});
|
||
// 👉 сообщаем глобальному апдейтеру бейджа
|
||
const chatId = Number(document.getElementById('chatScroll')?.dataset.chatId || 0);
|
||
if (chatId){
|
||
window.dispatchEvent(new CustomEvent('chat:read', { detail: { chatId: String(chatId) } }));
|
||
}
|
||
}catch(_){}
|
||
}
|
||
|
||
function autosize(){
|
||
if (!ta) return;
|
||
ta.style.height='auto';
|
||
ta.style.height=Math.min(ta.scrollHeight, 6*24)+'px';
|
||
if (atBottom()) autoscroll();
|
||
}
|
||
|
||
const mark = insertUnreadDivider();
|
||
requestAnimationFrame(()=>{
|
||
if (mark) { mark.scrollIntoView({ block:'center' }); } else { autoscroll(); }
|
||
autosize();
|
||
toggleBtn();
|
||
window.__SCROLLED_ON_INIT__ = true;
|
||
});
|
||
|
||
ta?.addEventListener('input', autosize);
|
||
ta?.addEventListener('keydown', e=>{
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter'){ e.preventDefault(); form?.requestSubmit(); }
|
||
});
|
||
box?.addEventListener('scroll', toggleBtn);
|
||
btn?.addEventListener('click', autoscroll);
|
||
|
||
const io = new IntersectionObserver((entries)=>{
|
||
entries.forEach(en=>{
|
||
if (en.isIntersecting){
|
||
const el = en.target.closest('.msg');
|
||
if (!el) return;
|
||
const ts = Number(el.dataset.ts || 0);
|
||
markRead(ts);
|
||
}
|
||
});
|
||
}, { root: box, threshold: 1.0 });
|
||
|
||
function observeLastForeign(){
|
||
io.disconnect();
|
||
const msgs = Array.from(box.querySelectorAll(`.msg[data-ts][data-author-id]`));
|
||
const lastForeign = [...msgs].reverse().find(e => Number(e.dataset.authorId) !== meId);
|
||
if (lastForeign){
|
||
const bubble = lastForeign.querySelector('.bubble') || lastForeign;
|
||
io.observe(bubble);
|
||
}
|
||
}
|
||
observeLastForeign();
|
||
|
||
let bottomTimer=null;
|
||
box.addEventListener('scroll', ()=>{
|
||
clearTimeout(bottomTimer);
|
||
if (atBottom()){
|
||
bottomTimer = setTimeout(()=>{
|
||
const lastMsg = box.querySelector('.msg:last-of-type');
|
||
const ts = lastMsg ? Number(lastMsg.dataset.ts || 0) : 0;
|
||
markRead(ts);
|
||
}, 300);
|
||
}
|
||
});
|
||
|
||
window.__CHAT_MARK_READ__ = { markRead, observeLastForeign, autoscroll, atBottom };
|
||
})();
|
||
</script>
|
||
|
||
<script>
|
||
(function(){
|
||
const chatId = {{ chat.id|default_if_none:"null"|default:"null" }};
|
||
const meId = {{ user.id|default_if_none:"null"|default:"null" }};
|
||
if (chatId === null) return;
|
||
|
||
const scrollEl = document.getElementById('chatScroll');
|
||
const form = document.getElementById('chatForm');
|
||
const ta = document.getElementById('id_content');
|
||
const AVA = window.__CHAT_AVATARS__ || {};
|
||
const typingEl = document.getElementById('typing');
|
||
|
||
const { markRead, observeLastForeign, autoscroll, atBottom } = window.__CHAT_MARK_READ__ || {};
|
||
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const ws = new WebSocket(`${proto}://${location.host}/ws/chat/${chatId}/`);
|
||
|
||
const z2 = n => (n < 10 ? '0' : '') + n;
|
||
function toTS(msg){
|
||
if (msg && msg.created_ts != null && !isNaN(Number(msg.created_ts))) return Number(msg.created_ts);
|
||
if (msg && msg.created){
|
||
const t = Date.parse(msg.created); if (!isNaN(t)) return t;
|
||
}
|
||
return Date.now();
|
||
}
|
||
function fmtHHMM(ts){
|
||
const d = new Date(ts);
|
||
return `${z2(d.getHours())}:${z2(d.getMinutes())}`;
|
||
}
|
||
function fmtDay(ts){
|
||
const d = new Date(ts);
|
||
return `${z2(d.getDate())}.${z2(d.getMonth()+1)}.${d.getFullYear()}`;
|
||
}
|
||
|
||
// HTML экранирование и утилиты импортированы из модулей
|
||
// См. блок импорта утилит ниже
|
||
|
||
function avatarHTML(url){
|
||
return url
|
||
? `<span class="mini-ava border"><img src="${url}" alt="" loading="lazy"></span>`
|
||
: `<span class="mini-ava border d-inline-grid"><i class="bi-person"></i></span>`;
|
||
}
|
||
function nameHTML(id, name, url){
|
||
if (Number(id) === Number(meId)){
|
||
return `<a href="{% url 'employees:profile' %}" class="text-decoration-none">Вы</a>`;
|
||
}
|
||
if (url){
|
||
return `<a href="${url}" class="text-decoration-none fw-semibold">${esc(name || 'Сотрудник')}</a>`;
|
||
}
|
||
return `<a href="{% url 'employees:employee_detail' 0 %}`.replace('/0/', `/${id}/`) +
|
||
`" class="text-decoration-none fw-semibold">${esc(name || 'Сотрудник')}</a>`;
|
||
}
|
||
|
||
function renderDayDivider(dateStr){
|
||
const div = document.createElement('div');
|
||
div.className = 'day-divider';
|
||
div.innerHTML = `<span>${esc(dateStr)}</span>`;
|
||
scrollEl.appendChild(div);
|
||
}
|
||
|
||
(function ensureLastDayFromDom(){
|
||
const dl = scrollEl.querySelector('.day-divider:last-of-type span');
|
||
if (dl) {
|
||
scrollEl.dataset.lastDay = dl.textContent.trim();
|
||
} else {
|
||
const lastMsg = scrollEl.querySelector('.msg:last-of-type');
|
||
if (lastMsg){
|
||
const ts = Number(lastMsg.dataset.ts || Date.now());
|
||
scrollEl.dataset.lastDay = fmtDay(ts);
|
||
}
|
||
}
|
||
})();
|
||
|
||
function renderMessage(msg){
|
||
const mine = Number(msg.author_id) === Number(meId);
|
||
if (msg.author_id && msg.avatar && !AVA[msg.author_id]) AVA[msg.author_id] = msg.avatar;
|
||
|
||
const ts = toTS(msg);
|
||
const dayStr = msg.day || fmtDay(ts);
|
||
if (dayStr && scrollEl.dataset.lastDay !== dayStr){
|
||
renderDayDivider(dayStr);
|
||
scrollEl.dataset.lastDay = dayStr;
|
||
}
|
||
|
||
const wrap = document.createElement('div');
|
||
wrap.className = `d-flex mb-3 msg ${mine ? 'justify-content-end' : 'justify-content-start'}`;
|
||
if (msg.id != null) wrap.setAttribute('data-id', String(msg.id));
|
||
wrap.setAttribute('data-ts', String(ts));
|
||
wrap.setAttribute('data-author-id', String(msg.author_id || ''));
|
||
|
||
const who = nameHTML(msg.author_id, msg.author_name || msg.author, msg.author_url);
|
||
const time = fmtHHMM(ts);
|
||
const text = esc(msg.content || '').replace(/\n/g,'<br>');
|
||
const bubble = `
|
||
<div class="d-flex flex-column" style="max-width:80%;">
|
||
<div class="small text-secondary ${mine ? 'text-end' : ''}">
|
||
${who} · ${time}
|
||
</div>
|
||
<div class="mt-1 bubble ${mine ? 'bubble-me' : 'bubble-other'}">
|
||
${text}
|
||
</div>
|
||
</div>`;
|
||
|
||
const avaUrl = msg.avatar || AVA[msg.author_id] || "";
|
||
const ava = avatarHTML(avaUrl);
|
||
|
||
if (!mine){
|
||
const link = msg.author_url || `{% url 'employees:employee_detail' 0 %}`.replace('/0/', `/${msg.author_id}/`);
|
||
wrap.innerHTML = `<a class="me-2 text-decoration-none" href="${link}">${ava}</a>${bubble}`;
|
||
}else{
|
||
wrap.innerHTML = `${bubble}<a class="ms-2 text-decoration-none" href="{% url 'employees:profile' %}">${ava}</a>`;
|
||
}
|
||
|
||
const keep = atBottom && atBottom();
|
||
scrollEl.appendChild(wrap);
|
||
if (keep && autoscroll) autoscroll();
|
||
|
||
if (observeLastForeign) observeLastForeign();
|
||
if (keep && window.__CHAT_MARK_READ__?.markRead){
|
||
window.__CHAT_MARK_READ__.markRead(ts);
|
||
}
|
||
}
|
||
|
||
let typingTimer = null;
|
||
function showTyping(){
|
||
if (!typingEl) return;
|
||
typingEl.classList.remove('d-none');
|
||
clearTimeout(typingTimer);
|
||
typingTimer = setTimeout(()=> typingEl.classList.add('d-none'), 2000);
|
||
}
|
||
|
||
ws.addEventListener('message', (e)=>{
|
||
try{
|
||
const data = JSON.parse(e.data);
|
||
if (data.type === 'typing' && Number(data.user_id) !== Number(meId)){ showTyping(); return; }
|
||
const msg = data.payload || data.message || data;
|
||
renderMessage(msg);
|
||
}catch(_){}
|
||
});
|
||
|
||
ws.addEventListener('close', ()=>{ setTimeout(()=>location.reload(), 2000); });
|
||
|
||
form?.addEventListener('submit', (e)=>{
|
||
e.preventDefault();
|
||
const text = (ta?.value || '').trim();
|
||
if (!text || ws.readyState !== WebSocket.OPEN) return;
|
||
ws.send(JSON.stringify({ content: text }));
|
||
ta.value=''; ta.dispatchEvent(new Event('input'));
|
||
});
|
||
|
||
let lastTyping = 0;
|
||
ta?.addEventListener('input', ()=>{
|
||
const now = Date.now();
|
||
if (ws.readyState === WebSocket.OPEN && now - lastTyping > 1500){
|
||
ws.send(JSON.stringify({ type:'typing' }));
|
||
lastTyping = now;
|
||
}
|
||
});
|
||
|
||
if (!window.__SCROLLED_ON_INIT__ && window.__CHAT_MARK_READ__?.autoscroll) window.__CHAT_MARK_READ__.autoscroll();
|
||
})();
|
||
</script>
|
||
|
||
{# Поиск/фильтр + скрытие пустых секций #}
|
||
<script>
|
||
(function(){
|
||
const search = document.getElementById('chatSearch');
|
||
const labels = document.querySelectorAll('[data-filter]');
|
||
const sections = {
|
||
global: document.querySelector('.chat-section[data-sec="global"]'),
|
||
department: document.querySelector('.chat-section[data-sec="department"]'),
|
||
private: document.querySelector('.chat-section[data-sec="private"]')
|
||
};
|
||
let active = 'all';
|
||
// norm() импортирован из утилит, доступен через window.norm
|
||
|
||
function apply(){
|
||
const q = norm(search?.value || '');
|
||
document.querySelectorAll('.chat-row').forEach(el=>{
|
||
const type = el.getAttribute('data-type');
|
||
const hay = norm(el.getAttribute('data-haystack'));
|
||
const okType = (active === 'all') || (type === active);
|
||
const okText = !q || hay.includes(q);
|
||
el.style.display = (okType && okText) ? '' : 'none';
|
||
});
|
||
|
||
Object.entries(sections).forEach(([key, wrap])=>{
|
||
if (!wrap) return;
|
||
const list = wrap.querySelector('.list-chats');
|
||
const visible = list && list.querySelector('.chat-row:not([style*="display: none"])');
|
||
const note = wrap.querySelector('[data-empty-note]');
|
||
wrap.style.display = visible ? '' : 'none';
|
||
if (note) note.style.display = visible ? 'none' : '';
|
||
});
|
||
}
|
||
|
||
labels.forEach(l=>{
|
||
l.addEventListener('click', ()=>{
|
||
labels.forEach(x=>x.classList.toggle('active', x===l));
|
||
active = l.getAttribute('data-filter');
|
||
apply();
|
||
});
|
||
});
|
||
search && search.addEventListener('input', apply);
|
||
apply();
|
||
})();
|
||
</script>
|
||
|
||
{# Realtime-обновления списка через /ws/chats/ — только если есть список #}
|
||
<script>
|
||
(function(){
|
||
const listAny = document.querySelector('.chat-row');
|
||
if (!listAny) return;
|
||
const meId = {{ user.id|default_if_none:"null"|default:"null" }};
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const ws = new WebSocket(`${proto}://${location.host}/ws/chats/`);
|
||
|
||
function resortSection(sectionListEl){
|
||
const rows = Array.from(sectionListEl.querySelectorAll('.chat-row'));
|
||
rows.sort((a,b)=>{
|
||
const ta = Number(a.getAttribute('data-last-ts') || 0);
|
||
const tb = Number(b.getAttribute('data-last-ts') || 0);
|
||
return tb - ta;
|
||
});
|
||
rows.forEach(r => sectionListEl.appendChild(r));
|
||
}
|
||
|
||
function containerForType(t){
|
||
if (t === 'global') return document.getElementById('sec-global');
|
||
if (t === 'department') return document.getElementById('sec-department');
|
||
return document.getElementById('sec-private');
|
||
}
|
||
|
||
ws.addEventListener('message', (e)=>{
|
||
try{
|
||
const data = JSON.parse(e.data);
|
||
if (data.type !== 'list_update') return;
|
||
|
||
const chatId = String(data.chat_id);
|
||
const msg = data.message || {};
|
||
const row = document.querySelector(`.chat-row[data-chat-id="${chatId}"]`);
|
||
if (!row) return;
|
||
|
||
const ts = Number(msg.created_ts || Date.now());
|
||
row.setAttribute('data-last-ts', String(ts));
|
||
const timeEl = row.querySelector('[data-last-time]');
|
||
const authEl = row.querySelector('[data-last-author]');
|
||
const prevEl = row.querySelector('[data-last-preview]');
|
||
if (timeEl) timeEl.textContent = msg.created || '';
|
||
if (authEl) authEl.textContent = msg.author_name || 'Сотрудник';
|
||
if (prevEl) prevEl.textContent = (msg.content || '').slice(0, 120);
|
||
|
||
const badge = row.querySelector('[data-unread]');
|
||
const cntEl = row.querySelector('[data-unread-count]');
|
||
if (Number(msg.author_id) === Number(meId)){
|
||
if (cntEl) cntEl.textContent = '0';
|
||
if (badge) badge.classList.add('d-none');
|
||
}else{
|
||
const cur = Number((cntEl && cntEl.textContent) || 0) + 1;
|
||
if (cntEl) cntEl.textContent = String(cur);
|
||
if (badge && cur > 0) badge.classList.remove('d-none');
|
||
}
|
||
|
||
const type = row.getAttribute('data-type');
|
||
const list = containerForType(type);
|
||
if (list){ resortSection(list); }
|
||
}catch(_){}
|
||
});
|
||
|
||
ws.addEventListener('close', ()=>{ setTimeout(()=>location.reload(), 2000); });
|
||
})();
|
||
</script>
|
||
<script>
|
||
(function(){
|
||
const meId = {{ user.id|default:"0" }};
|
||
const badge = document.getElementById('sidebarChatBadge');
|
||
|
||
function setBadge(n){
|
||
if (!badge) return;
|
||
const v = Math.max(0, Number(n)||0);
|
||
badge.dataset.count = String(v);
|
||
badge.textContent = v > 99 ? '99+' : String(v);
|
||
badge.classList.toggle('d-none', v <= 0);
|
||
}
|
||
|
||
// стартовое значение (из шаблона сайдбара)
|
||
setBadge(badge?.dataset.count || 0);
|
||
|
||
// карта непрочитанных по чатам (если мы на списке чатов — проинициализируем из DOM)
|
||
const chatUnread = Object.create(null);
|
||
document.querySelectorAll('.chat-row[data-chat-id]').forEach(row=>{
|
||
const id = String(row.getAttribute('data-chat-id'));
|
||
const cnt = Number(row.querySelector('[data-unread-count]')?.textContent || 0);
|
||
chatUnread[id] = cnt;
|
||
});
|
||
function recompute(){
|
||
let total = 0;
|
||
for (const k in chatUnread){ total += Number(chatUnread[k]||0); }
|
||
setBadge(total);
|
||
}
|
||
if (Object.keys(chatUnread).length) recompute();
|
||
|
||
// глобальный канал обновлений списка чатов
|
||
try{
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const ws = new WebSocket(`${proto}://${location.host}/ws/chats/`);
|
||
|
||
ws.addEventListener('message', (e)=>{
|
||
try{
|
||
const data = JSON.parse(e.data);
|
||
if (data.type !== 'list_update') return;
|
||
|
||
const chatId = String(data.chat_id);
|
||
const msg = data.message || {};
|
||
const isMine = Number(msg.author_id) === Number(meId);
|
||
|
||
// если сообщение моё — обнуляем счётчик для этого чата, иначе +1
|
||
chatUnread[chatId] = isMine ? 0 : (Number(chatUnread[chatId]||0) + 1);
|
||
recompute();
|
||
}catch(_){}
|
||
});
|
||
ws.addEventListener('close', ()=>{ /* молча, без перезапуска */ });
|
||
}catch(_){}
|
||
|
||
// Обнуляем конкретный чат, когда где-то на сайте он помечен прочитанным
|
||
window.addEventListener('chat:read', (e)=>{
|
||
const chatId = String(e?.detail?.chatId || '');
|
||
if (!chatId) return;
|
||
chatUnread[chatId] = 0;
|
||
recompute();
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
{% block extra_js %}{% endblock %}
|
||
</body>
|
||
</html>
|