- ApiClient теперь обрабатывает 401 и 403 ошибки для обновления токена - Добавлен JWTRefreshMiddleware для проактивного обновления токенов за 5 минут до истечения - Добавлена настройка JWT_REFRESH_THRESHOLD_MINUTES (по умолчанию 5) - Исправлена проблема с периодическими 403 ошибками после истечения access token
230 lines
9.4 KiB
HTML
230 lines
9.4 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"> -->
|
||
<!-- Кастомный Bootstrap с нашей системой отступов -->
|
||
<link rel="stylesheet" href="{% static 'css/bootstrap-custom.css' %}">
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||
|
||
<!-- CSS Components -->
|
||
<link rel="stylesheet" href="{% static 'css/variables.css' %}">
|
||
<link rel="stylesheet" href="{% static 'css/components/common.css' %}">
|
||
<link rel="stylesheet" href="{% static 'css/components/spacing-utils.css' %}">
|
||
<link rel="stylesheet" href="{% static 'css/components/layout-spacing.css' %}">
|
||
<link rel="stylesheet" href="{% static 'css/components/buttons.css' %}">
|
||
<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/base-app.css' %}">
|
||
<link rel="stylesheet" href="{% static 'css/components/ios-components.css' %}">
|
||
<link rel="stylesheet" href="{% static 'css/notifications/notifications.css' %}">
|
||
|
||
{% block extra_css %}{% endblock %}
|
||
</head>
|
||
<body class="min-vh-100">
|
||
|
||
{% include "includes/navbar.html" %}
|
||
|
||
<div class="container-xxl">
|
||
<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">
|
||
{% 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" %}';
|
||
import { initNavbarHeight } from '{% static "js/components/navbarHeight.js" %}';
|
||
import { initLikeHandler } from '{% static "js/components/likeHandler.js" %}';
|
||
import { initTextareaAutogrow } from '{% static "js/components/textareaAutogrow.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;
|
||
|
||
// Инициализация компонентов
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const navbarHeight = initNavbarHeight();
|
||
const likeHandler = initLikeHandler();
|
||
const textareaAutogrow = initTextareaAutogrow();
|
||
|
||
if (navbarHeight) window.navbarHeight = navbarHeight;
|
||
if (likeHandler) window.likeHandler = likeHandler;
|
||
if (textareaAutogrow) window.textareaAutogrow = textareaAutogrow;
|
||
});
|
||
</script>
|
||
|
||
{# ========== CHAT MODULES ========== #}
|
||
{% if user.is_authenticated %}
|
||
<script type="module">
|
||
import { initChatAvatarMap } from "{% static 'js/components/chatAvatarMap.js' %}";
|
||
import { initChatMarkRead } from "{% static 'js/components/chatMarkRead.js' %}";
|
||
import { initChatWebSocket } from "{% static 'js/components/chatWebSocket.js' %}";
|
||
import { initChatListFilter } from "{% static 'js/components/chatListFilter.js' %}";
|
||
import { initChatBadgeManager } from "{% static 'js/components/chatBadgeManager.js' %}";
|
||
import { initChatListRealtime } from "{% static 'js/components/chatListRealtime.js' %}";
|
||
|
||
// Инициализация карты аватаров
|
||
const avatarMap = initChatAvatarMap({
|
||
"{{ user.id }}": {% if user.avatar %}"{{ user.avatar.url }}"{% else %}""{% endif %}
|
||
});
|
||
|
||
// Инициализация badge менеджера (слушает события chat:read)
|
||
const badgeManager = initChatBadgeManager();
|
||
|
||
// Инициализация фильтрации списка чатов
|
||
const chatListFilter = initChatListFilter();
|
||
|
||
// Инициализация realtime-обновлений списка чатов
|
||
const chatListRealtime = initChatListRealtime({
|
||
meId: {{ user.id|default:"0" }},
|
||
badgeManager: badgeManager
|
||
});
|
||
|
||
// Инициализация отметки прочитанного (если открыт конкретный чат)
|
||
{% if chat %}
|
||
const markReadApi = initChatMarkRead({
|
||
chatId: {{ chat.id }},
|
||
meId: {{ user.id|default:"0" }},
|
||
scrollContainerId: 'chatScroll',
|
||
textareaId: 'id_content',
|
||
formId: 'chatForm',
|
||
scrollBtnId: 'scrollBtn',
|
||
markReadUrl: "{% url 'communications:chat_mark_read' chat.pk %}",
|
||
initialLastReadTs: {{ chat.last_read_timestamp|default:"null" }}
|
||
});
|
||
|
||
// Инициализация WebSocket для чата
|
||
const chatWebSocket = initChatWebSocket({
|
||
chatId: {{ chat.id }},
|
||
meId: {{ user.id|default:"0" }},
|
||
scrollContainerId: 'chatScroll',
|
||
formId: 'chatForm',
|
||
textareaId: 'id_content',
|
||
typingIndicatorId: 'typing',
|
||
profileUrl: "{% url 'employees:profile' %}",
|
||
detailUrlTemplate: "{% url 'employees:employee_detail' 0 %}",
|
||
avatarMap: avatarMap.getAll(),
|
||
markReadApi: markReadApi
|
||
});
|
||
{% endif %}
|
||
|
||
// Глобальный обновлятель бейджа в сайдбаре
|
||
(function initSidebarBadge() {
|
||
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);
|
||
|
||
// Карта непрочитанных по чатам
|
||
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();
|
||
|
||
// Глобальный WebSocket для обновления бейджа
|
||
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);
|
||
|
||
chatUnread[chatId] = isMine ? 0 : (Number(chatUnread[chatId] || 0) + 1);
|
||
recompute();
|
||
} catch (_) {}
|
||
});
|
||
} catch (_) {}
|
||
|
||
// Обработка события chat:read
|
||
window.addEventListener('chat:read', (e) => {
|
||
const chatId = String(e?.detail?.chatId || '');
|
||
if (!chatId) return;
|
||
chatUnread[chatId] = 0;
|
||
recompute();
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
<!-- Notification Manager -->
|
||
<script src="{% static 'js/notifications/notification-manager.js' %}" defer></script>
|
||
{% endif %}
|
||
|
||
{% block extra_js %}{% endblock %}
|
||
</body>
|
||
</html>
|