-
+
Имя
+
{error &&
}
-
-
+
-
-
+ onSelectSlot={handleSelectSlot}
+ onSelectEvent={handleSelectEvent}
+ onMonthChange={handleMonthChange}
+ />
+
+
+
+
);
diff --git a/front_material/app/(protected)/students/page.tsx b/front_material/app/(protected)/students/page.tsx
index 155a35e..c08ee4b 100644
--- a/front_material/app/(protected)/students/page.tsx
+++ b/front_material/app/(protected)/students/page.tsx
@@ -1,1714 +1,1695 @@
-'use client';
-
-import { useEffect, useState, useMemo } from 'react';
-import { useSearchParams } from 'next/navigation';
-import { useAuth } from '@/contexts/AuthContext';
-import dynamic from 'next/dynamic';
-import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
-import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
-import { DateCalendar } from '@mui/x-date-pickers/DateCalendar';
-import { PickersDay, PickersDayProps } from '@mui/x-date-pickers/PickersDay';
-import dayjs, { Dayjs } from 'dayjs';
-import isoWeek from 'dayjs/plugin/isoWeek';
-import 'dayjs/locale/ru';
-import Popover from '@mui/material/Popover';
-import { KeyboardArrowLeft, KeyboardArrowRight } from '@mui/icons-material';
-
-dayjs.extend(isoWeek);
-import { loadComponent } from '@/lib/material-components';
-import { useOptimizedFetch } from '@/hooks/useOptimizedFetch';
-import { apiClient } from '@/lib/api-client';
-import { SubjectNameSelect } from '@/components/dashboard/SubjectNameSelect';
-import {
- checkUserByEmail,
- addStudentInvitation,
- generateInvitationLink,
- getMentorshipRequestsPending,
- acceptMentorshipRequest,
- rejectMentorshipRequest,
- type MentorshipRequestItem,
-} from '@/api/students';
-import { getLessons, type Lesson } from '@/api/schedule';
-import { getHomeworkSubmissionsBySubject, type HomeworkSubmission } from '@/api/homework';
-import { format, addDays, subMonths } from 'date-fns';
-import { ru } from 'date-fns/locale';
-import { DashboardLayout, Panel, SectionHeader } from '@/components/dashboard/ui';
-import { DateRangePicker } from '@/components/common/DateRangePicker';
-import { LoadingSpinner } from '@/components/common/LoadingSpinner';
-import { getErrorMessage } from '@/lib/error-utils';
-import { useToast } from '@/contexts/ToastContext';
-
-const ApexChart = dynamic(() => import('react-apexcharts'), { ssr: false });
-import type { ApexOptions } from 'apexcharts';
-
-const CHART_COLORS = ['#6750A4', '#7D5260'];
-
-function getSubjectFromLesson(lesson: Lesson): string {
- if (typeof lesson.subject === 'string' && lesson.subject?.trim()) return lesson.subject.trim();
- const sub = (lesson as any).subject_name;
- if (typeof sub === 'string' && sub?.trim()) return sub.trim();
- const ms = (lesson as any).mentor_subject;
- if (ms?.name) return ms.name;
- return '';
-}
-
-function getDatesInRange(startStr: string, endStr: string): string[] {
- const dates: string[] = [];
- let d = new Date(startStr);
- const end = new Date(endStr);
- while (d <= end) {
- dates.push(format(d, 'yyyy-MM-dd'));
- d = addDays(d, 1);
- }
- return dates;
-}
-
-interface SubjectProgress {
- subject: string;
- total_lessons: number;
- completed_lessons: number;
- homework_count: number;
- homework_completed: number;
- homework_average: number;
-}
-
-interface StudentProgressResponse {
- student: {
- id: number;
- name: string;
- email: string;
- };
- overall: {
- average_grade: number;
- total_lessons: number;
- completed_lessons: number;
- total_grades: number;
- };
- subjects: SubjectProgress[];
- progress_timeline: Array<{
- date: string | null;
- grade: number;
- subject: string;
- comment: string;
- }>;
- daily_stats?: Array<{
- date: string;
- total_lessons: number;
- completed_lessons: number;
- }>;
-}
-
-export default function StudentsPage() {
- const { user: currentUser } = useAuth();
- const { showToast } = useToast();
- const [componentsLoaded, setComponentsLoaded] = useState(false);
- const [selectedStudent, setSelectedStudent] = useState
(null);
- const [startDate, setStartDate] = useState(null);
- const [endDate, setEndDate] = useState(null);
- const [selectedSubject, setSelectedSubject] = useState(null);
- const [progress, setProgress] = useState(null);
- const [progressLoading, setProgressLoading] = useState(false);
- const [periodAnchor, setPeriodAnchor] = useState(null);
- const [panelExpanded, setPanelExpanded] = useState(false);
- const [localStart, setLocalStart] = useState(null);
- const [localEnd, setLocalEnd] = useState(null);
- const [rangeMode, setRangeMode] = useState<'start' | 'end'>('start');
- const [lessons, setLessons] = useState([]);
- const [lessonsLoading, setLessonsLoading] = useState(false);
- const [homeworkSubmissions, setHomeworkSubmissions] = useState([]);
- const [homeworkLoading, setHomeworkLoading] = useState(false);
- // Панель «Добавить студента»
- const [showAddPanel, setShowAddPanel] = useState(false);
- const [addMode, setAddMode] = useState<'email' | 'code' | 'link'>('link');
- const [addEmail, setAddEmail] = useState('');
- const [addCode, setAddCode] = useState('');
- const [addCheckResult, setAddCheckResult] = useState<{ exists: boolean; is_client: boolean } | null>(null);
- const [addSubmitting, setAddSubmitting] = useState(false);
- const [addSubmitted, setAddSubmitted] = useState(false);
- const [addError, setAddError] = useState('');
- const [invitationLink, setInvitationLink] = useState(currentUser?.invitation_link || null);
- const [linkCopied, setLinkCopied] = useState(false);
-
- useEffect(() => {
- if (currentUser?.invitation_link) {
- setInvitationLink(currentUser.invitation_link);
- }
- }, [currentUser?.invitation_link]);
-
- const searchParams = useSearchParams();
- const tabFromUrl = searchParams?.get('tab');
- // Табы: студенты | запросы на менторство | ожидают ответа
- const [activeTab, setActiveTab] = useState<'students' | 'requests' | 'awaiting'>(
- tabFromUrl === 'requests' ? 'requests' : tabFromUrl === 'awaiting' ? 'awaiting' : 'students'
- );
- useEffect(() => {
- if (tabFromUrl === 'requests') setActiveTab('requests');
- if (tabFromUrl === 'awaiting') setActiveTab('awaiting');
- }, [tabFromUrl]);
- const [mentorshipRequests, setMentorshipRequests] = useState([]);
- const [requestsLoading, setRequestsLoading] = useState(false);
- const [requestActionId, setRequestActionId] = useState(null);
-
- useEffect(() => {
- Promise.all([
- loadComponent('elevated-card'),
- loadComponent('filled-button'),
- loadComponent('icon'),
- loadComponent('list'),
- loadComponent('list-item'),
- ]).then(() => {
- setComponentsLoaded(true);
- }).catch((err) => {
- console.error('Error loading components:', err);
- setComponentsLoaded(true);
- });
- }, []);
-
- useEffect(() => {
- if (periodAnchor) {
- setLocalStart(startDate);
- setLocalEnd(endDate);
- setRangeMode('start');
- }
- }, [periodAnchor, startDate, endDate]);
-
- const { data: studentsData, loading, refetch } = useOptimizedFetch({
- // Для менторов список студентов доступен через manage/clients
- url: '/manage/clients/',
- cacheKey: 'students_list',
- cacheTTL: 5 * 60 * 1000, // 5 минут
- });
-
- // Загрузка запросов на менторство: при переключении на вкладку и при монтировании (для показа табов)
- useEffect(() => {
- setRequestsLoading(true);
- getMentorshipRequestsPending()
- .then((data) => setMentorshipRequests(data))
- .catch(() => setMentorshipRequests([]))
- .finally(() => setRequestsLoading(false));
- }, [activeTab]); // при смене таба и при первом рендере
-
- // Загружаем детальный прогресс ученика при выборе и изменении фильтров
- useEffect(() => {
- if (!selectedStudent) {
- setProgress(null);
- setSelectedSubject(null);
- return;
- }
-
- const controller = new AbortController();
-
- const params: string[] = [];
- if (selectedSubject) {
- params.push(`subject=${encodeURIComponent(selectedSubject)}`);
- }
- if (startDate) {
- params.push(`start_date=${startDate.format('YYYY-MM-DD')}`);
- }
- if (endDate) {
- params.push(`end_date=${endDate.format('YYYY-MM-DD')}`);
- }
- const qs = params.length ? `?${params.join('&')}` : '';
-
- setProgressLoading(true);
- apiClient
- .get(`/student-progress/${selectedStudent.id}/progress/${qs}`, {
- signal: controller.signal as any,
- })
- .then((res) => {
- setProgress(res.data);
- if (res.data.subjects.length > 0) {
- const kept = selectedSubject && res.data.subjects.some((s) => s.subject === selectedSubject);
- if (!kept) setSelectedSubject(res.data.subjects[0].subject);
- } else {
- setSelectedSubject(null);
- }
- })
- .catch((err: any) => {
- if (err.name !== 'CanceledError' && err.name !== 'AbortError') {
- console.error('Ошибка загрузки прогресса студента', err);
- }
- setProgress(null);
- })
- .finally(() => {
- setProgressLoading(false);
- });
-
- return () => controller.abort();
- }, [selectedStudent, selectedSubject, startDate, endDate]);
-
- const startStr = startDate && endDate ? startDate.format('YYYY-MM-DD') : '';
- const endStr = startDate && endDate ? endDate.format('YYYY-MM-DD') : '';
-
- useEffect(() => {
- if (!selectedStudent || !startStr || !endStr) {
- setLessons([]);
- return;
- }
- let cancelled = false;
- setLessonsLoading(true);
- getLessons({
- start_date: startStr,
- end_date: endStr,
- client_id: String(selectedStudent.id),
- })
- .then((res) => {
- if (cancelled) return;
- const list = (res.results || []).filter((l: Lesson) => {
- const sub = getSubjectFromLesson(l);
- return selectedSubject ? sub === selectedSubject : true;
- });
- list.sort((a: Lesson, b: Lesson) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
- setLessons(list);
- })
- .catch(() => { if (!cancelled) setLessons([]); })
- .finally(() => { if (!cancelled) setLessonsLoading(false); });
- return () => { cancelled = true; };
- }, [selectedStudent, startStr, endStr, selectedSubject]);
-
- useEffect(() => {
- if (!selectedStudent?.user?.id || !selectedSubject || !startStr || !endStr) {
- setHomeworkSubmissions([]);
- return;
- }
- let cancelled = false;
- setHomeworkLoading(true);
- getHomeworkSubmissionsBySubject({
- subject: selectedSubject,
- start_date: startStr,
- end_date: endStr,
- child_id: String(selectedStudent.user.id),
- })
- .then((res) => {
- if (cancelled) return;
- setHomeworkSubmissions(res.results || []);
- })
- .catch(() => { if (!cancelled) setHomeworkSubmissions([]); })
- .finally(() => { if (!cancelled) setHomeworkLoading(false); });
- return () => { cancelled = true; };
- }, [selectedStudent?.user?.id, selectedSubject, startStr, endStr]);
-
- const periodStats = useMemo(() => {
- const completed = lessons.filter((l) => l.status === 'completed').length;
- const total = lessons.length;
- const attendanceRate = total > 0 ? Math.round((completed / total) * 100) : 0;
- const withGrades = lessons.filter((l) => l.status === 'completed' && ((l as any).mentor_grade != null || (l as any).school_grade != null));
- let sum = 0;
- let count = 0;
- withGrades.forEach((l) => {
- const mg = (l as any).mentor_grade;
- const sg = (l as any).school_grade;
- if (mg != null) { sum += mg; count++; }
- if (sg != null) { sum += sg; count++; }
- });
- const avgGrade = count > 0 ? Math.round((sum / count) * 10) / 10 : 0;
- const hwGraded = homeworkSubmissions.filter((s) => s.score != null && s.checked_at).length;
- return { completedLessons: completed, totalLessons: total, attendanceRate, avgGrade, hwGraded };
- }, [lessons, homeworkSubmissions]);
-
- const gradesChart = useMemo(() => {
- if (!startStr || !endStr) return { series: [], categories: [] };
- const allDates = getDatesInRange(startStr, endStr);
- const categories = allDates.map((d) => {
- const [, m, day] = d.split('-');
- return `${day}.${m}`;
- });
- const byDate: Record = {};
- lessons
- .filter((l) => l.status === 'completed' && ((l as any).mentor_grade != null || (l as any).school_grade != null))
- .forEach((l) => {
- const key = (l.start_time as string)?.slice?.(0, 10) || '';
- if (!key) return;
- byDate[key] = { mentor: (l as any).mentor_grade ?? null, school: (l as any).school_grade ?? null };
- });
- const mentorGrades = allDates.map((d) => byDate[d]?.mentor ?? null);
- const schoolGrades = allDates.map((d) => byDate[d]?.school ?? null);
- return {
- series: [
- { name: 'Оценка репетитора', data: mentorGrades },
- { name: 'Оценка в школе', data: schoolGrades },
- ],
- categories,
- };
- }, [lessons, startStr, endStr]);
-
- const homeworkChart = useMemo(() => {
- if (!startStr || !endStr) return { series: [], categories: [] };
- const allDates = getDatesInRange(startStr, endStr);
- const categories = allDates.map((d) => {
- const [, m, day] = d.split('-');
- return `${day}.${m}`;
- });
- const byDate: Record = {};
- homeworkSubmissions
- .filter((s) => s.checked_at && s.score != null)
- .forEach((s) => {
- const key = format(new Date(s.checked_at!), 'yyyy-MM-dd');
- byDate[key] = s.score ?? null;
- });
- const scores = allDates.map((d) => byDate[d] ?? null);
- return {
- series: [{ name: 'Оценка за ДЗ', data: scores }],
- categories,
- };
- }, [homeworkSubmissions, startStr, endStr]);
-
- const attendanceChart = useMemo(() => {
- if (!startStr || !endStr) return { series: [], categories: [] };
- const allDates = getDatesInRange(startStr, endStr);
- const categories = allDates.map((d) => {
- const [, m, day] = d.split('-');
- return `${day}.${m}`;
- });
- const byDate: Record = {};
- lessons.filter((l) => l.status === 'completed').forEach((l) => {
- const key = (l.start_time as string)?.slice?.(0, 10) || '';
- if (!key) return;
- byDate[key] = (byDate[key] ?? 0) + 1;
- });
- const data = allDates.map((d) => byDate[d] ?? 0);
- return { series: [{ name: 'Занятия проведены', data }], categories };
- }, [lessons, startStr, endStr]);
-
- const chartOptionsBase = useMemo(
- () => ({
- chart: { toolbar: { show: false }, zoom: { enabled: false } },
- stroke: { curve: 'smooth' as const, width: 2 },
- colors: CHART_COLORS,
- dataLabels: { enabled: false },
- xaxis: {
- axisBorder: { show: false },
- axisTicks: { show: false },
- labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '11px' } },
- },
- yaxis: {
- labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '11px' } },
- },
- legend: {
- position: 'bottom' as const,
- horizontalAlign: 'center' as const,
- labels: { colors: 'var(--md-sys-color-on-surface-variant)' },
- },
- grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 },
- }),
- [],
- );
-
- if (!componentsLoaded) {
- return (
-
- );
- }
-
- const students = studentsData?.results || [];
- const pendingInvitations = (studentsData as any)?.pending_invitations || [];
- const filteredStudents = students;
-
- return (
-
- {/* Табы: Студенты | Запросы на менторство | Ожидают ответа — если есть соответствующие данные */}
- {(mentorshipRequests.length > 0 || pendingInvitations.length > 0) && (
-
- setActiveTab('students')}
- style={{
- padding: '12px 20px',
- border: 'none',
- borderBottom: activeTab === 'students' ? '2px solid var(--md-sys-color-primary)' : '2px solid transparent',
- background: 'transparent',
- color: activeTab === 'students' ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-surface-variant)',
- fontSize: 16,
- fontWeight: 600,
- cursor: 'pointer',
- }}
- >
- Студенты
-
- {mentorshipRequests.length > 0 && (
- setActiveTab('requests')}
- style={{
- padding: '12px 20px',
- border: 'none',
- borderBottom: activeTab === 'requests' ? '2px solid var(--md-sys-color-primary)' : '2px solid transparent',
- background: 'transparent',
- color: activeTab === 'requests' ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-surface-variant)',
- fontSize: 16,
- fontWeight: 600,
- cursor: 'pointer',
- }}
- >
- Запросы на менторство ({mentorshipRequests.length})
-
- )}
- {pendingInvitations.length > 0 && (
- setActiveTab('awaiting')}
- style={{
- padding: '12px 20px',
- border: 'none',
- borderBottom: activeTab === 'awaiting' ? '2px solid var(--md-sys-color-primary)' : '2px solid transparent',
- background: 'transparent',
- color: activeTab === 'awaiting' ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-surface-variant)',
- fontSize: 16,
- fontWeight: 600,
- cursor: 'pointer',
- }}
- >
- Ожидают ответа ({pendingInvitations.length})
-
- )}
-
- )}
-
- {activeTab === 'awaiting' ? (
- pendingInvitations.length === 0 ? (
-
-
- Нет ожидающих подтверждения
-
-
- ) : (
-
- {pendingInvitations.map((inv: any) => {
- const st = inv.student || {};
- const title = [st.first_name, st.last_name].filter(Boolean).join(' ') || st.email || 'Ученик';
- const initials = [st.first_name?.[0], st.last_name?.[0]].filter(Boolean).map((c) => c?.toUpperCase()).join('') || st.email?.[0]?.toUpperCase() || 'У';
- return (
-
-
-
- {initials}
-
-
-
-
- {title}
-
-
- {st.email || '—'}
-
-
-
- );
- })}
-
- )
- ) : activeTab === 'requests' ? (
-
- {requestsLoading ? (
-
- ) : mentorshipRequests.length === 0 ? (
-
-
- Нет новых запросов на менторство
-
-
- ) : (
-
- {mentorshipRequests.map((req) => {
- const st = req.student;
- const title = [st.first_name, st.last_name].filter(Boolean).join(' ') || st.email || 'Ученик';
- const initials = [st.first_name?.[0], st.last_name?.[0]].filter(Boolean).map((c) => c?.toUpperCase()).join('') || st.email?.[0]?.toUpperCase() || 'У';
- return (
-
-
-
-
- {title}
-
-
- {st.email || '—'}
-
- {req.created_at && (
-
- {format(new Date(req.created_at), 'd MMM yyyy, HH:mm', { locale: ru })}
-
- )}
-
- {
- e.stopPropagation();
- setRequestActionId(req.id);
- try {
- await acceptMentorshipRequest(req.id);
- setMentorshipRequests((prev) => {
- const next = prev.filter((r) => r.id !== req.id);
- if (next.length === 0) setActiveTab('students');
- return next;
- });
- refetch?.();
- } finally {
- setRequestActionId(null);
- }
- }}
- style={{
- padding: '8px 14px',
- borderRadius: 12,
- border: 'none',
- background: 'var(--md-sys-color-primary)',
- color: 'var(--md-sys-color-on-primary)',
- fontSize: 14,
- fontWeight: 600,
- cursor: requestActionId === req.id ? 'not-allowed' : 'pointer',
- }}
- >
- {requestActionId === req.id ? '…' : 'Принять'}
-
- {
- e.stopPropagation();
- setRequestActionId(req.id);
- try {
- await rejectMentorshipRequest(req.id);
- setMentorshipRequests((prev) => {
- const next = prev.filter((r) => r.id !== req.id);
- if (next.length === 0) setActiveTab('students');
- return next;
- });
- } finally {
- setRequestActionId(null);
- }
- }}
- style={{
- padding: '8px 14px',
- borderRadius: 12,
- border: '1px solid var(--md-sys-color-outline)',
- background: 'transparent',
- color: 'var(--md-sys-color-on-surface)',
- fontSize: 14,
- fontWeight: 600,
- cursor: requestActionId === req.id ? 'not-allowed' : 'pointer',
- }}
- >
- {requestActionId === req.id ? '…' : 'Отклонить'}
-
-
-
-
- );
- })}
-
- )}
-
- ) : loading ? (
-
-
Загрузка студентов...
-
- ) : (
-
- {(activeTab === 'students' ? filteredStudents : []).map((student: any) => {
- const fullName = `${student?.user?.first_name || ''} ${student?.user?.last_name || ''}`.trim();
- const title = fullName || student?.user?.email || 'Студент';
- const initials = [student?.user?.first_name?.[0], student?.user?.last_name?.[0]].filter(Boolean).map((c) => c?.toUpperCase()).join('') || (student?.user?.email?.[0]?.toUpperCase()) || 'С';
- const subtitleParts: string[] = [];
- if (typeof student?.total_lessons === 'number') subtitleParts.push(`${student.total_lessons} занятий`);
- if (typeof student?.completed_lessons === 'number') subtitleParts.push(`${student.completed_lessons} завершено`);
- const subtitle = subtitleParts.join(' • ') || (student?.user?.email || '');
- const avatarUrl = student?.user?.avatar_url || student?.user?.avatar || '';
-
- return (
-
{
- setSelectedStudent(student);
- const defaultStart = dayjs().subtract(3, 'month');
- const defaultEnd = dayjs();
- setStartDate(defaultStart);
- setEndDate(defaultEnd);
- }}
- >
-
- {avatarUrl ? (
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : (
-
- {initials}
-
- )}
-
-
-
-
- {title}
-
-
- {subtitle}
-
- {student?.user?.login_link && (
-
- {
- e.stopPropagation();
- await navigator.clipboard.writeText(student.user.login_link);
- showToast('Ссылка для входа скопирована', 'success');
- }}
- style={{
- padding: '6px 10px',
- borderRadius: 8,
- border: '1px solid var(--md-sys-color-outline)',
- background: 'transparent',
- color: 'var(--md-sys-color-primary)',
- fontSize: 12,
- fontWeight: 600,
- cursor: 'pointer',
- }}
- >
- Копировать вход
-
-
- )}
-
-
- );
- })}
-
- {/* Карточка-действие «Добавить студента» — только во вкладке Студенты */}
- {activeTab === 'students' && (
-
{
- setShowAddPanel(true);
- setSelectedStudent(null);
- setAddSubmitted(false);
- setAddError('');
- setAddEmail('');
- setAddCode('');
- setAddCheckResult(null);
- }}
- >
-
-
-
-
- Добавить студента
-
-
- Создайте карточку нового ученика
-
-
-
- )}
-
- )}
-
- {(selectedStudent || showAddPanel) && (
-
-
-
-
- {showAddPanel ? 'Добавить студента' : 'Прогресс ученика'}
-
-
- {showAddPanel ? 'Введите email или 8-символьный код' : `${selectedStudent?.user?.first_name || ''} ${selectedStudent?.user?.last_name || ''}`.trim()}
-
-
-
- {!showAddPanel && (
- setPanelExpanded((v) => !v)}
- style={{
- border: 'none',
- background: 'transparent',
- cursor: 'pointer',
- padding: 4,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- color: 'var(--md-sys-color-on-surface-variant)',
- }}
- title={panelExpanded ? 'Свернуть панель' : 'Раскрыть панель (80% ширины)'}
- >
- {panelExpanded ? (
-
- ) : (
-
- )}
-
- )}
- {
- setSelectedStudent(null);
- setShowAddPanel(false);
- setPanelExpanded(false);
- }}
- style={{
- border: 'none',
- background: 'transparent',
- cursor: 'pointer',
- fontSize: 18,
- color: 'var(--md-sys-color-on-surface-variant)',
- }}
- >
- ✕
-
-
-
-
- {showAddPanel ? (
-
- {addSubmitted ? (
-
- Отправлено. Ждём подтверждения от ученика{addCheckResult?.exists === false ? ' (и установки пароля по ссылке из письма)' : ''}. После подтверждения учеником и при необходимости родителем взаимодействие будет разрешено.
-
- ) : (
- <>
-
- { setAddMode('link'); setAddCheckResult(null); setAddError(''); }}
- style={{
- padding: '8px 14px',
- borderRadius: 8,
- border: addMode === 'link' ? '2px solid var(--md-sys-color-primary)' : '1px solid var(--md-sys-color-outline)',
- background: addMode === 'link' ? 'var(--md-sys-color-primary-container)' : 'transparent',
- color: 'var(--md-sys-color-on-surface)',
- fontSize: 14,
- fontWeight: 600,
- cursor: 'pointer',
- }}
- >
- По ссылке
-
- { setAddMode('email'); setAddCheckResult(null); setAddError(''); }}
- style={{
- padding: '8px 14px',
- borderRadius: 8,
- border: addMode === 'email' ? '2px solid var(--md-sys-color-primary)' : '1px solid var(--md-sys-color-outline)',
- background: addMode === 'email' ? 'var(--md-sys-color-primary-container)' : 'transparent',
- color: 'var(--md-sys-color-on-surface)',
- fontSize: 14,
- fontWeight: 600,
- cursor: 'pointer',
- }}
- >
- По email
-
- { setAddMode('code'); setAddCheckResult(null); setAddError(''); }}
- style={{
- padding: '8px 14px',
- borderRadius: 8,
- border: addMode === 'code' ? '2px solid var(--md-sys-color-primary)' : '1px solid var(--md-sys-color-outline)',
- background: addMode === 'code' ? 'var(--md-sys-color-primary-container)' : 'transparent',
- color: 'var(--md-sys-color-on-surface)',
- fontSize: 14,
- fontWeight: 600,
- cursor: 'pointer',
- }}
- >
- 8-символьный код
-
-
- {addMode === 'email' ? (
-
-
- Email ученика (если не зарегистрирован — пришлём приглашение на почту)
-
-
{ setAddEmail(e.target.value); setAddError(''); }}
- onBlur={async () => {
- const em = addEmail.trim().toLowerCase();
- if (!em) { setAddCheckResult(null); return; }
- try {
- const res = await checkUserByEmail(em);
- setAddCheckResult(res);
- } catch {
- setAddCheckResult(null);
- }
- }}
- placeholder="example@mail.ru"
- style={{
- padding: '12px 14px',
- borderRadius: 12,
- border: '1px solid var(--md-sys-color-outline)',
- background: 'var(--md-sys-color-surface-container-low)',
- color: 'var(--md-sys-color-on-surface)',
- fontSize: 16,
- }}
- />
- {addCheckResult?.exists && addCheckResult?.is_client && (
-
- Ученик уже зарегистрирован. Введите его 8-символьный универсальный код (в личном кабинете ученика).
-
- )}
-
- ) : addMode === 'code' ? (
-
-
- Универсальный код ученика (8 символов: цифры и латинские буквы)
-
- {
- const v = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
- setAddCode(v);
- setAddError('');
- }}
- placeholder="A1B2C3D4"
- style={{
- padding: '12px 14px',
- borderRadius: 12,
- border: '1px solid var(--md-sys-color-outline)',
- background: 'var(--md-sys-color-surface-container-low)',
- color: 'var(--md-sys-color-on-surface)',
- fontSize: 16,
- letterSpacing: 4,
- }}
- />
-
- ) : (
-
-
- Отправьте эту ссылку ученику. Он сможет зарегистрироваться, указав только имя и фамилию.
-
-
- {invitationLink ? (
-
-
- {invitationLink}
-
-
- {
- await navigator.clipboard.writeText(invitationLink);
- setLinkCopied(true);
- showToast('Ссылка скопирована', 'success');
- setTimeout(() => setLinkCopied(false), 2000);
- }}
- style={{
- flex: 1,
- padding: '10px',
- borderRadius: 12,
- border: 'none',
- background: linkCopied ? 'var(--md-sys-color-tertiary)' : 'var(--md-sys-color-secondary)',
- color: 'var(--md-sys-color-on-secondary)',
- fontSize: 14,
- fontWeight: 600,
- cursor: 'pointer',
- }}
- >
- {linkCopied ? 'Скопировано!' : 'Копировать'}
-
- {
- setAddSubmitting(true);
- try {
- const res = await generateInvitationLink();
- setInvitationLink(res.invitation_link);
- await navigator.clipboard.writeText(res.invitation_link);
- setLinkCopied(true);
- showToast('Ссылка обновлена и скопирована', 'success');
- setTimeout(() => setLinkCopied(false), 2000);
- } catch (err: any) {
- setAddError(getErrorMessage(err, 'Ошибка обновления ссылки'));
- } finally {
- setAddSubmitting(false);
- }
- }}
- style={{
- flex: 1,
- padding: '10px',
- borderRadius: 12,
- border: '1px solid var(--md-sys-color-outline)',
- background: 'transparent',
- color: 'var(--md-sys-color-on-surface)',
- fontSize: 14,
- fontWeight: 600,
- cursor: 'pointer',
- }}
- >
- {addSubmitting ? '…' : 'Обновить'}
-
-
-
- ) : (
-
{
- setAddSubmitting(true);
- try {
- const res = await generateInvitationLink();
- setInvitationLink(res.invitation_link);
- } catch (err: any) {
- setAddError(getErrorMessage(err, 'Ошибка создания ссылки'));
- } finally {
- setAddSubmitting(false);
- }
- }}
- style={{
- padding: '12px 20px',
- borderRadius: 12,
- border: 'none',
- background: 'var(--md-sys-color-primary)',
- color: 'var(--md-sys-color-on-primary)',
- fontSize: 16,
- fontWeight: 600,
- cursor: 'pointer',
- }}
- >
- {addSubmitting ? 'Создание…' : 'Создать ссылку'}
-
- )}
-
- )}
- {addMode !== 'link' && (
-
{
- setAddError('');
- setAddSubmitting(true);
- try {
- if (addMode === 'email') {
- await addStudentInvitation({ email: addEmail.trim().toLowerCase() });
- } else {
- await addStudentInvitation({ universal_code: addCode });
- }
- setAddSubmitted(true);
- setAddEmail('');
- setAddCode('');
- setAddCheckResult(null);
- } catch (err: any) {
- setAddError(getErrorMessage(err, 'Ошибка отправки приглашения'));
- } finally {
- setAddSubmitting(false);
- }
- }}
- style={{
- padding: '12px 20px',
- borderRadius: 12,
- border: 'none',
- background: 'var(--md-sys-color-primary)',
- color: 'var(--md-sys-color-on-primary)',
- fontSize: 16,
- fontWeight: 600,
- cursor: addSubmitting ? 'not-allowed' : 'pointer',
- opacity: (addSubmitting || (addMode === 'email' ? !addEmail.trim() : addCode.length !== 8)) ? 0.6 : 1,
- }}
- >
- {addSubmitting ? 'Отправка…' : 'Отправить приглашение'}
-
- )}
- >
- )}
- {currentUser?.universal_code && (
-
-
- Ваш 8-символьный код
-
-
- {(currentUser.universal_code || '').split('').map((char, i) => (
-
- {char}
-
- ))}
-
-
- Поделитесь кодом с учеником — он сможет отправить вам запрос на связь
-
-
- )}
-
- ) : (
-
-
-
-
- Предмет:
- setSelectedSubject(v)}
- disabled={!progress || !progress.subjects.length}
- placeholder="Выберите предмет"
- />
-
-
-
- Период:
- {
- if (v.start_date) setStartDate(dayjs(v.start_date));
- else setStartDate(null);
- if (v.end_date) setEndDate(dayjs(v.end_date));
- else setEndDate(null);
- }}
- disabled={false}
- />
- setPeriodAnchor(e.currentTarget)}
- style={{
- padding: '8px 12px',
- borderRadius: 12,
- border: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
- background: 'var(--md-sys-color-surface-container-low)',
- color: 'var(--md-sys-color-on-surface)',
- fontSize: 14,
- cursor: 'pointer',
- outline: 'none',
- }}
- >
- Быстрый выбор
-
-
-
-
-
-
setPeriodAnchor(null)}
- anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
- transformOrigin={{ vertical: 'top', horizontal: 'left' }}
- slotProps={{
- paper: {
- sx: {
- mt: 1,
- borderRadius: 12,
- minWidth: 300,
- maxWidth: 360,
- overflow: 'hidden',
- },
- },
- }}
- >
-
-
-
- Выберите период
-
-
- {localStart && localEnd
- ? `${localStart.format('D MMM')} — ${localEnd.format('D MMM')}`
- : '—'}
-
-
- {rangeMode === 'start' ? 'Клик: начало периода' : 'Клик: конец периода'}
-
-
- {[
- { label: 'Эта неделя', fn: () => { setLocalStart(dayjs().startOf('isoWeek')); setLocalEnd(dayjs().endOf('isoWeek')); setRangeMode('end'); } },
- { label: 'Прошлая неделя', fn: () => { const w = dayjs().subtract(1, 'week'); setLocalStart(w.startOf('isoWeek')); setLocalEnd(w.endOf('isoWeek')); setRangeMode('end'); } },
- { label: 'Последние 7 дней', fn: () => { setLocalStart(dayjs().subtract(6, 'day')); setLocalEnd(dayjs()); setRangeMode('end'); } },
- { label: 'Текущий месяц', fn: () => { setLocalStart(dayjs().startOf('month')); setLocalEnd(dayjs().endOf('month')); setRangeMode('end'); } },
- { label: 'След. месяц', fn: () => { const m = dayjs().add(1, 'month'); setLocalStart(m.startOf('month')); setLocalEnd(m.endOf('month')); setRangeMode('end'); } },
- { label: 'Сбросить', fn: () => { setLocalStart(null); setLocalEnd(null); setRangeMode('start'); } },
- ].map(({ label, fn }) => (
-
- {label}
-
- ))}
-
-
{
- const d = val ? dayjs(val as Date) : null;
- if (!d) return;
- if (rangeMode === 'start') {
- setLocalStart(d);
- setLocalEnd(null);
- setRangeMode('end');
- } else {
- if (localStart && d.isBefore(localStart, 'day')) {
- setLocalStart(d);
- setLocalEnd(localStart);
- } else {
- setLocalEnd(d);
- }
- }
- }}
- slots={{
- day: (props: PickersDayProps) => {
- const { day, selected, sx, ...rest } = props;
- const d = dayjs(day as Date);
- const start = localStart ? dayjs(localStart).startOf('day') : null;
- const end = localEnd ? dayjs(localEnd).startOf('day') : null;
- const isStart = start && d.isSame(start, 'day');
- const isEnd = end && d.isSame(end, 'day');
- const inBetween = start && end && !isStart && !isEnd && d.isAfter(start) && d.isBefore(end);
- const inRange = isStart || isEnd || inBetween;
- return (
-
- );
- },
- }}
- sx={{ width: '100%', '& .MuiPickersCalendarHeader-root': { marginBottom: 0 } }}
- />
-
- setPeriodAnchor(null)}
- style={{
- padding: '8px 14px',
- borderRadius: 8,
- border: 'none',
- background: 'transparent',
- color: 'var(--md-sys-color-on-surface-variant)',
- fontSize: 14,
- cursor: 'pointer',
- }}
- >
- Отмена
-
- {
- setStartDate(localStart);
- setEndDate(localEnd);
- setPeriodAnchor(null);
- }}
- style={{
- padding: '8px 14px',
- borderRadius: 8,
- border: 'none',
- background: 'var(--md-sys-color-primary)',
- color: 'var(--md-sys-color-on-primary)',
- fontSize: 14,
- fontWeight: 600,
- cursor: 'pointer',
- }}
- >
- Применить
-
-
-
-
-
-
- {(lessonsLoading && lessons.length === 0) ? (
-
-
-
- ) : startStr && endStr ? (
- <>
-
-
-
Занятий проведено
-
{periodStats.completedLessons}
-
из {periodStats.totalLessons}
-
-
-
Посещаемость
-
{periodStats.attendanceRate}%
-
-
-
Средняя оценка
-
{periodStats.avgGrade || '—'}
-
-
-
ДЗ с оценкой
-
{periodStats.hwGraded}
-
-
-
-
-
Успеваемость (репетитор и школа)
- {gradesChart.categories.length === 0 ? (
-
Нет оценок за период
- ) : (
-
- )}
-
-
-
-
Успеваемость по ДЗ
- {homeworkChart.categories.length === 0 ? (
-
Нет оценок за ДЗ за период
- ) : (
-
- )}
-
-
-
-
Посещаемость
- {attendanceChart.categories.length === 0 ? (
-
Нет проведённых занятий за период
- ) : (
-
- )}
-
- >
- ) : (
-
- Выберите период
-
- )}
-
- )}
-
- )}
-
- );
-}
+'use client';
+
+import { useEffect, useState, useMemo } from 'react';
+import { useSearchParams } from 'next/navigation';
+import { useAuth } from '@/contexts/AuthContext';
+import dynamic from 'next/dynamic';
+import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
+import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
+import { DateCalendar } from '@mui/x-date-pickers/DateCalendar';
+import { PickersDay, PickersDayProps } from '@mui/x-date-pickers/PickersDay';
+import dayjs, { Dayjs } from 'dayjs';
+import isoWeek from 'dayjs/plugin/isoWeek';
+import 'dayjs/locale/ru';
+import Popover from '@mui/material/Popover';
+import { KeyboardArrowLeft, KeyboardArrowRight } from '@mui/icons-material';
+
+dayjs.extend(isoWeek);
+import { loadComponent } from '@/lib/material-components';
+import { useOptimizedFetch } from '@/hooks/useOptimizedFetch';
+import { apiClient } from '@/lib/api-client';
+import { SubjectNameSelect } from '@/components/dashboard/SubjectNameSelect';
+import {
+ checkUserByEmail,
+ addStudentInvitation,
+ generateInvitationLink,
+ getMentorshipRequestsPending,
+ acceptMentorshipRequest,
+ rejectMentorshipRequest,
+ type MentorshipRequestItem,
+} from '@/api/students';
+import { getLessons, type Lesson } from '@/api/schedule';
+import { getHomeworkSubmissionsBySubject, type HomeworkSubmission } from '@/api/homework';
+import { format, addDays, subMonths } from 'date-fns';
+import { ru } from 'date-fns/locale';
+import { DashboardLayout, Panel, SectionHeader } from '@/components/dashboard/ui';
+import { DateRangePicker } from '@/components/common/DateRangePicker';
+import { LoadingSpinner } from '@/components/common/LoadingSpinner';
+import { getErrorMessage } from '@/lib/error-utils';
+import { useToast } from '@/contexts/ToastContext';
+
+const ApexChart = dynamic(() => import('react-apexcharts'), { ssr: false });
+import type { ApexOptions } from 'apexcharts';
+
+const CHART_COLORS = ['#6750A4', '#7D5260'];
+
+function getSubjectFromLesson(lesson: Lesson): string {
+ if (typeof lesson.subject === 'string' && lesson.subject?.trim()) return lesson.subject.trim();
+ const sub = (lesson as any).subject_name;
+ if (typeof sub === 'string' && sub?.trim()) return sub.trim();
+ const ms = (lesson as any).mentor_subject;
+ if (ms?.name) return ms.name;
+ return '';
+}
+
+function getDatesInRange(startStr: string, endStr: string): string[] {
+ const dates: string[] = [];
+ let d = new Date(startStr);
+ const end = new Date(endStr);
+ while (d <= end) {
+ dates.push(format(d, 'yyyy-MM-dd'));
+ d = addDays(d, 1);
+ }
+ return dates;
+}
+
+interface SubjectProgress {
+ subject: string;
+ total_lessons: number;
+ completed_lessons: number;
+ homework_count: number;
+ homework_completed: number;
+ homework_average: number;
+}
+
+interface StudentProgressResponse {
+ student: {
+ id: number;
+ name: string;
+ email: string;
+ };
+ overall: {
+ average_grade: number;
+ total_lessons: number;
+ completed_lessons: number;
+ total_grades: number;
+ };
+ subjects: SubjectProgress[];
+ progress_timeline: Array<{
+ date: string | null;
+ grade: number;
+ subject: string;
+ comment: string;
+ }>;
+ daily_stats?: Array<{
+ date: string;
+ total_lessons: number;
+ completed_lessons: number;
+ }>;
+}
+
+export default function StudentsPage() {
+ const { user: currentUser } = useAuth();
+ const { showToast } = useToast();
+ const [componentsLoaded, setComponentsLoaded] = useState(false);
+ const [selectedStudent, setSelectedStudent] = useState(null);
+ const [startDate, setStartDate] = useState(null);
+ const [endDate, setEndDate] = useState(null);
+ const [selectedSubject, setSelectedSubject] = useState(null);
+ const [progress, setProgress] = useState(null);
+ const [progressLoading, setProgressLoading] = useState(false);
+ const [periodAnchor, setPeriodAnchor] = useState(null);
+ const [panelExpanded, setPanelExpanded] = useState(false);
+ const [localStart, setLocalStart] = useState(null);
+ const [localEnd, setLocalEnd] = useState(null);
+ const [rangeMode, setRangeMode] = useState<'start' | 'end'>('start');
+ const [lessons, setLessons] = useState([]);
+ const [lessonsLoading, setLessonsLoading] = useState(false);
+ const [homeworkSubmissions, setHomeworkSubmissions] = useState([]);
+ const [homeworkLoading, setHomeworkLoading] = useState(false);
+ // Панель «Добавить студента»
+ const [showAddPanel, setShowAddPanel] = useState(false);
+ const [addMode, setAddMode] = useState<'email' | 'code' | 'link'>('link');
+ const [addEmail, setAddEmail] = useState('');
+ const [addCode, setAddCode] = useState('');
+ const [addCheckResult, setAddCheckResult] = useState<{ exists: boolean; is_client: boolean } | null>(null);
+ const [addSubmitting, setAddSubmitting] = useState(false);
+ const [addSubmitted, setAddSubmitted] = useState(false);
+ const [addError, setAddError] = useState('');
+ const [invitationLink, setInvitationLink] = useState(currentUser?.invitation_link || null);
+ const [linkCopied, setLinkCopied] = useState(false);
+
+ useEffect(() => {
+ if (currentUser?.invitation_link) {
+ setInvitationLink(currentUser.invitation_link);
+ }
+ }, [currentUser?.invitation_link]);
+
+ const searchParams = useSearchParams();
+ const tabFromUrl = searchParams?.get('tab');
+ // Табы: студенты | запросы на менторство | ожидают ответа
+ const [activeTab, setActiveTab] = useState<'students' | 'requests' | 'awaiting'>(
+ tabFromUrl === 'requests' ? 'requests' : tabFromUrl === 'awaiting' ? 'awaiting' : 'students'
+ );
+ useEffect(() => {
+ if (tabFromUrl === 'requests') setActiveTab('requests');
+ if (tabFromUrl === 'awaiting') setActiveTab('awaiting');
+ }, [tabFromUrl]);
+ const [mentorshipRequests, setMentorshipRequests] = useState([]);
+ const [requestsLoading, setRequestsLoading] = useState(false);
+ const [requestActionId, setRequestActionId] = useState(null);
+
+ useEffect(() => {
+ Promise.all([
+ loadComponent('elevated-card'),
+ loadComponent('filled-button'),
+ loadComponent('icon'),
+ loadComponent('list'),
+ loadComponent('list-item'),
+ ]).then(() => {
+ setComponentsLoaded(true);
+ }).catch((err) => {
+ console.error('Error loading components:', err);
+ setComponentsLoaded(true);
+ });
+ }, []);
+
+ useEffect(() => {
+ if (periodAnchor) {
+ setLocalStart(startDate);
+ setLocalEnd(endDate);
+ setRangeMode('start');
+ }
+ }, [periodAnchor, startDate, endDate]);
+
+ const { data: studentsData, loading, refetch } = useOptimizedFetch({
+ // Для менторов список студентов доступен через manage/clients
+ url: '/manage/clients/',
+ cacheKey: 'students_list',
+ cacheTTL: 5 * 60 * 1000, // 5 минут
+ });
+
+ // Загрузка запросов на менторство: при переключении на вкладку и при монтировании (для показа табов)
+ useEffect(() => {
+ setRequestsLoading(true);
+ getMentorshipRequestsPending()
+ .then((data) => setMentorshipRequests(data))
+ .catch(() => setMentorshipRequests([]))
+ .finally(() => setRequestsLoading(false));
+ }, [activeTab]); // при смене таба и при первом рендере
+
+ // Загружаем детальный прогресс ученика при выборе и изменении фильтров
+ useEffect(() => {
+ if (!selectedStudent) {
+ setProgress(null);
+ setSelectedSubject(null);
+ return;
+ }
+
+ const controller = new AbortController();
+
+ const params: string[] = [];
+ if (selectedSubject) {
+ params.push(`subject=${encodeURIComponent(selectedSubject)}`);
+ }
+ if (startDate) {
+ params.push(`start_date=${startDate.format('YYYY-MM-DD')}`);
+ }
+ if (endDate) {
+ params.push(`end_date=${endDate.format('YYYY-MM-DD')}`);
+ }
+ const qs = params.length ? `?${params.join('&')}` : '';
+
+ setProgressLoading(true);
+ apiClient
+ .get(`/student-progress/${selectedStudent.id}/progress/${qs}`, {
+ signal: controller.signal as any,
+ })
+ .then((res) => {
+ setProgress(res.data);
+ if (res.data.subjects.length > 0) {
+ const kept = selectedSubject && res.data.subjects.some((s) => s.subject === selectedSubject);
+ if (!kept) setSelectedSubject(res.data.subjects[0].subject);
+ } else {
+ setSelectedSubject(null);
+ }
+ })
+ .catch((err: any) => {
+ if (err.name !== 'CanceledError' && err.name !== 'AbortError') {
+ console.error('Ошибка загрузки прогресса студента', err);
+ }
+ setProgress(null);
+ })
+ .finally(() => {
+ setProgressLoading(false);
+ });
+
+ return () => controller.abort();
+ }, [selectedStudent, selectedSubject, startDate, endDate]);
+
+ const startStr = startDate && endDate ? startDate.format('YYYY-MM-DD') : '';
+ const endStr = startDate && endDate ? endDate.format('YYYY-MM-DD') : '';
+
+ useEffect(() => {
+ if (!selectedStudent || !startStr || !endStr) {
+ setLessons([]);
+ return;
+ }
+ let cancelled = false;
+ setLessonsLoading(true);
+ getLessons({
+ start_date: startStr,
+ end_date: endStr,
+ client_id: String(selectedStudent.id),
+ })
+ .then((res) => {
+ if (cancelled) return;
+ const list = (res.results || []).filter((l: Lesson) => {
+ const sub = getSubjectFromLesson(l);
+ return selectedSubject ? sub === selectedSubject : true;
+ });
+ list.sort((a: Lesson, b: Lesson) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
+ setLessons(list);
+ })
+ .catch(() => { if (!cancelled) setLessons([]); })
+ .finally(() => { if (!cancelled) setLessonsLoading(false); });
+ return () => { cancelled = true; };
+ }, [selectedStudent, startStr, endStr, selectedSubject]);
+
+ useEffect(() => {
+ if (!selectedStudent?.user?.id || !selectedSubject || !startStr || !endStr) {
+ setHomeworkSubmissions([]);
+ return;
+ }
+ let cancelled = false;
+ setHomeworkLoading(true);
+ getHomeworkSubmissionsBySubject({
+ subject: selectedSubject,
+ start_date: startStr,
+ end_date: endStr,
+ child_id: String(selectedStudent.user.id),
+ })
+ .then((res) => {
+ if (cancelled) return;
+ setHomeworkSubmissions(res.results || []);
+ })
+ .catch(() => { if (!cancelled) setHomeworkSubmissions([]); })
+ .finally(() => { if (!cancelled) setHomeworkLoading(false); });
+ return () => { cancelled = true; };
+ }, [selectedStudent?.user?.id, selectedSubject, startStr, endStr]);
+
+ const periodStats = useMemo(() => {
+ const completed = lessons.filter((l) => l.status === 'completed').length;
+ const total = lessons.length;
+ const attendanceRate = total > 0 ? Math.round((completed / total) * 100) : 0;
+ const withGrades = lessons.filter((l) => l.status === 'completed' && ((l as any).mentor_grade != null || (l as any).school_grade != null));
+ let sum = 0;
+ let count = 0;
+ withGrades.forEach((l) => {
+ const mg = (l as any).mentor_grade;
+ const sg = (l as any).school_grade;
+ if (mg != null) { sum += mg; count++; }
+ if (sg != null) { sum += sg; count++; }
+ });
+ const avgGrade = count > 0 ? Math.round((sum / count) * 10) / 10 : 0;
+ const hwGraded = homeworkSubmissions.filter((s) => s.score != null && s.checked_at).length;
+ return { completedLessons: completed, totalLessons: total, attendanceRate, avgGrade, hwGraded };
+ }, [lessons, homeworkSubmissions]);
+
+ const gradesChart = useMemo(() => {
+ if (!startStr || !endStr) return { series: [], categories: [] };
+ const allDates = getDatesInRange(startStr, endStr);
+ const categories = allDates.map((d) => {
+ const [, m, day] = d.split('-');
+ return `${day}.${m}`;
+ });
+ const byDate: Record = {};
+ lessons
+ .filter((l) => l.status === 'completed' && ((l as any).mentor_grade != null || (l as any).school_grade != null))
+ .forEach((l) => {
+ const key = (l.start_time as string)?.slice?.(0, 10) || '';
+ if (!key) return;
+ byDate[key] = { mentor: (l as any).mentor_grade ?? null, school: (l as any).school_grade ?? null };
+ });
+ const mentorGrades = allDates.map((d) => byDate[d]?.mentor ?? null);
+ const schoolGrades = allDates.map((d) => byDate[d]?.school ?? null);
+ return {
+ series: [
+ { name: 'Оценка репетитора', data: mentorGrades },
+ { name: 'Оценка в школе', data: schoolGrades },
+ ],
+ categories,
+ };
+ }, [lessons, startStr, endStr]);
+
+ const homeworkChart = useMemo(() => {
+ if (!startStr || !endStr) return { series: [], categories: [] };
+ const allDates = getDatesInRange(startStr, endStr);
+ const categories = allDates.map((d) => {
+ const [, m, day] = d.split('-');
+ return `${day}.${m}`;
+ });
+ const byDate: Record = {};
+ homeworkSubmissions
+ .filter((s) => s.checked_at && s.score != null)
+ .forEach((s) => {
+ const key = format(new Date(s.checked_at!), 'yyyy-MM-dd');
+ byDate[key] = s.score ?? null;
+ });
+ const scores = allDates.map((d) => byDate[d] ?? null);
+ return {
+ series: [{ name: 'Оценка за ДЗ', data: scores }],
+ categories,
+ };
+ }, [homeworkSubmissions, startStr, endStr]);
+
+ const attendanceChart = useMemo(() => {
+ if (!startStr || !endStr) return { series: [], categories: [] };
+ const allDates = getDatesInRange(startStr, endStr);
+ const categories = allDates.map((d) => {
+ const [, m, day] = d.split('-');
+ return `${day}.${m}`;
+ });
+ const byDate: Record = {};
+ lessons.filter((l) => l.status === 'completed').forEach((l) => {
+ const key = (l.start_time as string)?.slice?.(0, 10) || '';
+ if (!key) return;
+ byDate[key] = (byDate[key] ?? 0) + 1;
+ });
+ const data = allDates.map((d) => byDate[d] ?? 0);
+ return { series: [{ name: 'Занятия проведены', data }], categories };
+ }, [lessons, startStr, endStr]);
+
+ const chartOptionsBase = useMemo(
+ () => ({
+ chart: { toolbar: { show: false }, zoom: { enabled: false } },
+ stroke: { curve: 'smooth' as const, width: 2 },
+ colors: CHART_COLORS,
+ dataLabels: { enabled: false },
+ xaxis: {
+ axisBorder: { show: false },
+ axisTicks: { show: false },
+ labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '11px' } },
+ },
+ yaxis: {
+ labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '11px' } },
+ },
+ legend: {
+ position: 'bottom' as const,
+ horizontalAlign: 'center' as const,
+ labels: { colors: 'var(--md-sys-color-on-surface-variant)' },
+ },
+ grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 },
+ }),
+ [],
+ );
+
+ if (!componentsLoaded) {
+ return (
+
+ );
+ }
+
+ const students = studentsData?.results || [];
+ const pendingInvitations = (studentsData as any)?.pending_invitations || [];
+ const filteredStudents = students;
+
+ return (
+
+ {/* Табы: Студенты | Запросы на менторство | Ожидают ответа — если есть соответствующие данные */}
+ {(mentorshipRequests.length > 0 || pendingInvitations.length > 0) && (
+
+ setActiveTab('students')}
+ style={{
+ padding: '12px 20px',
+ border: 'none',
+ borderBottom: activeTab === 'students' ? '2px solid var(--md-sys-color-primary)' : '2px solid transparent',
+ background: 'transparent',
+ color: activeTab === 'students' ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-surface-variant)',
+ fontSize: 16,
+ fontWeight: 600,
+ cursor: 'pointer',
+ }}
+ >
+ Студенты
+
+ {mentorshipRequests.length > 0 && (
+ setActiveTab('requests')}
+ style={{
+ padding: '12px 20px',
+ border: 'none',
+ borderBottom: activeTab === 'requests' ? '2px solid var(--md-sys-color-primary)' : '2px solid transparent',
+ background: 'transparent',
+ color: activeTab === 'requests' ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-surface-variant)',
+ fontSize: 16,
+ fontWeight: 600,
+ cursor: 'pointer',
+ }}
+ >
+ Запросы на менторство ({mentorshipRequests.length})
+
+ )}
+ {pendingInvitations.length > 0 && (
+ setActiveTab('awaiting')}
+ style={{
+ padding: '12px 20px',
+ border: 'none',
+ borderBottom: activeTab === 'awaiting' ? '2px solid var(--md-sys-color-primary)' : '2px solid transparent',
+ background: 'transparent',
+ color: activeTab === 'awaiting' ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-surface-variant)',
+ fontSize: 16,
+ fontWeight: 600,
+ cursor: 'pointer',
+ }}
+ >
+ Ожидают ответа ({pendingInvitations.length})
+
+ )}
+
+ )}
+
+ {activeTab === 'awaiting' ? (
+ pendingInvitations.length === 0 ? (
+
+
+ Нет ожидающих подтверждения
+
+
+ ) : (
+
+ {pendingInvitations.map((inv: any) => {
+ const st = inv.student || {};
+ const title = [st.first_name, st.last_name].filter(Boolean).join(' ') || st.email || 'Ученик';
+ const initials = [st.first_name?.[0], st.last_name?.[0]].filter(Boolean).map((c) => c?.toUpperCase()).join('') || st.email?.[0]?.toUpperCase() || 'У';
+ return (
+
+
+
+ {initials}
+
+
+
+
+ {title}
+
+
+ {st.email || '—'}
+
+
+
+ );
+ })}
+
+ )
+ ) : activeTab === 'requests' ? (
+
+ {requestsLoading ? (
+
+ ) : mentorshipRequests.length === 0 ? (
+
+
+ Нет новых запросов на менторство
+
+
+ ) : (
+
+ {mentorshipRequests.map((req) => {
+ const st = req.student;
+ const title = [st.first_name, st.last_name].filter(Boolean).join(' ') || st.email || 'Ученик';
+ const initials = [st.first_name?.[0], st.last_name?.[0]].filter(Boolean).map((c) => c?.toUpperCase()).join('') || st.email?.[0]?.toUpperCase() || 'У';
+ return (
+
+
+
+
+ {title}
+
+
+ {st.email || '—'}
+
+ {req.created_at && (
+
+ {format(new Date(req.created_at), 'd MMM yyyy, HH:mm', { locale: ru })}
+
+ )}
+
+ {
+ e.stopPropagation();
+ setRequestActionId(req.id);
+ try {
+ await acceptMentorshipRequest(req.id);
+ setMentorshipRequests((prev) => {
+ const next = prev.filter((r) => r.id !== req.id);
+ if (next.length === 0) setActiveTab('students');
+ return next;
+ });
+ refetch?.();
+ } finally {
+ setRequestActionId(null);
+ }
+ }}
+ style={{
+ padding: '8px 14px',
+ borderRadius: 12,
+ border: 'none',
+ background: 'var(--md-sys-color-primary)',
+ color: 'var(--md-sys-color-on-primary)',
+ fontSize: 14,
+ fontWeight: 600,
+ cursor: requestActionId === req.id ? 'not-allowed' : 'pointer',
+ }}
+ >
+ {requestActionId === req.id ? '…' : 'Принять'}
+
+ {
+ e.stopPropagation();
+ setRequestActionId(req.id);
+ try {
+ await rejectMentorshipRequest(req.id);
+ setMentorshipRequests((prev) => {
+ const next = prev.filter((r) => r.id !== req.id);
+ if (next.length === 0) setActiveTab('students');
+ return next;
+ });
+ } finally {
+ setRequestActionId(null);
+ }
+ }}
+ style={{
+ padding: '8px 14px',
+ borderRadius: 12,
+ border: '1px solid var(--md-sys-color-outline)',
+ background: 'transparent',
+ color: 'var(--md-sys-color-on-surface)',
+ fontSize: 14,
+ fontWeight: 600,
+ cursor: requestActionId === req.id ? 'not-allowed' : 'pointer',
+ }}
+ >
+ {requestActionId === req.id ? '…' : 'Отклонить'}
+
+
+
+
+ );
+ })}
+
+ )}
+
+ ) : loading ? (
+
+
Загрузка студентов...
+
+ ) : (
+
+ {(activeTab === 'students' ? filteredStudents : []).map((student: any) => {
+ const fullName = `${student?.user?.first_name || ''} ${student?.user?.last_name || ''}`.trim();
+ const title = fullName || student?.user?.email || 'Студент';
+ const initials = [student?.user?.first_name?.[0], student?.user?.last_name?.[0]].filter(Boolean).map((c) => c?.toUpperCase()).join('') || (student?.user?.email?.[0]?.toUpperCase()) || 'С';
+ const subtitleParts: string[] = [];
+ if (typeof student?.total_lessons === 'number') subtitleParts.push(`${student.total_lessons} занятий`);
+ if (typeof student?.completed_lessons === 'number') subtitleParts.push(`${student.completed_lessons} завершено`);
+ const subtitle = subtitleParts.join(' • ') || (student?.user?.email || '');
+ const avatarUrl = student?.user?.avatar_url || student?.user?.avatar || '';
+
+ return (
+
{
+ setSelectedStudent(student);
+ const defaultStart = dayjs().subtract(3, 'month');
+ const defaultEnd = dayjs();
+ setStartDate(defaultStart);
+ setEndDate(defaultEnd);
+ }}
+ >
+
+ {avatarUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ {initials}
+
+ )}
+
+
+
+
+ {title}
+
+
+ {subtitle}
+
+ {student?.user?.login_link && (
+
+ {
+ e.stopPropagation();
+ await navigator.clipboard.writeText(student.user.login_link);
+ showToast('Ссылка для входа скопирована', 'success');
+ }}
+ style={{
+ padding: '6px 10px',
+ borderRadius: 8,
+ border: '1px solid var(--md-sys-color-outline)',
+ background: 'transparent',
+ color: 'var(--md-sys-color-primary)',
+ fontSize: 12,
+ fontWeight: 600,
+ cursor: 'pointer',
+ }}
+ >
+ Копировать вход
+
+
+ )}
+
+
+ );
+ })}
+
+ {/* Карточка-действие «Добавить студента» — только во вкладке Студенты */}
+ {activeTab === 'students' && (
+
{
+ setShowAddPanel(true);
+ setSelectedStudent(null);
+ setAddSubmitted(false);
+ setAddError('');
+ setAddEmail('');
+ setAddCode('');
+ setAddCheckResult(null);
+ }}
+ >
+
+
+
+
+ Добавить студента
+
+
+ Создайте карточку нового ученика
+
+
+
+ )}
+
+ )}
+
+ {(selectedStudent || showAddPanel) && (
+
+
+
+
+ {showAddPanel ? 'Добавить студента' : 'Прогресс ученика'}
+
+
+ {showAddPanel ? 'Введите email или 8-символьный код' : `${selectedStudent?.user?.first_name || ''} ${selectedStudent?.user?.last_name || ''}`.trim()}
+
+
+
+ {!showAddPanel && (
+ setPanelExpanded((v) => !v)}
+ style={{
+ border: 'none',
+ background: 'transparent',
+ cursor: 'pointer',
+ padding: 4,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: 'var(--md-sys-color-on-surface-variant)',
+ }}
+ title={panelExpanded ? 'Свернуть панель' : 'Раскрыть панель (80% ширины)'}
+ >
+ {panelExpanded ? (
+
+ ) : (
+
+ )}
+
+ )}
+ {
+ setSelectedStudent(null);
+ setShowAddPanel(false);
+ setPanelExpanded(false);
+ }}
+ style={{
+ border: 'none',
+ background: 'transparent',
+ cursor: 'pointer',
+ fontSize: 18,
+ color: 'var(--md-sys-color-on-surface-variant)',
+ }}
+ >
+ ✕
+
+
+
+
+ {showAddPanel ? (
+
+ {addSubmitted ? (
+
+ Отправлено. Ждём подтверждения от ученика{addCheckResult?.exists === false ? ' (и установки пароля по ссылке из письма)' : ''}. После подтверждения учеником и при необходимости родителем взаимодействие будет разрешено.
+
+ ) : (
+ <>
+
+ { setAddMode('link'); setAddCheckResult(null); setAddError(''); }}
+ style={{
+ padding: '8px 14px',
+ borderRadius: 8,
+ border: addMode === 'link' ? '2px solid var(--md-sys-color-primary)' : '1px solid var(--md-sys-color-outline)',
+ background: addMode === 'link' ? 'var(--md-sys-color-primary-container)' : 'transparent',
+ color: 'var(--md-sys-color-on-surface)',
+ fontSize: 14,
+ fontWeight: 600,
+ cursor: 'pointer',
+ }}
+ >
+ По ссылке
+
+ { setAddMode('email'); setAddCheckResult(null); setAddError(''); }}
+ style={{
+ padding: '8px 14px',
+ borderRadius: 8,
+ border: addMode === 'email' ? '2px solid var(--md-sys-color-primary)' : '1px solid var(--md-sys-color-outline)',
+ background: addMode === 'email' ? 'var(--md-sys-color-primary-container)' : 'transparent',
+ color: 'var(--md-sys-color-on-surface)',
+ fontSize: 14,
+ fontWeight: 600,
+ cursor: 'pointer',
+ }}
+ >
+ По email
+
+ { setAddMode('code'); setAddCheckResult(null); setAddError(''); }}
+ style={{
+ padding: '8px 14px',
+ borderRadius: 8,
+ border: addMode === 'code' ? '2px solid var(--md-sys-color-primary)' : '1px solid var(--md-sys-color-outline)',
+ background: addMode === 'code' ? 'var(--md-sys-color-primary-container)' : 'transparent',
+ color: 'var(--md-sys-color-on-surface)',
+ fontSize: 14,
+ fontWeight: 600,
+ cursor: 'pointer',
+ }}
+ >
+ 8-символьный код
+
+
+ {addMode === 'email' ? (
+
+
+ Email ученика (если не зарегистрирован — пришлём приглашение на почту)
+
+
{ setAddEmail(e.target.value); setAddError(''); }}
+ onBlur={async () => {
+ const em = addEmail.trim().toLowerCase();
+ if (!em) { setAddCheckResult(null); return; }
+ try {
+ const res = await checkUserByEmail(em);
+ setAddCheckResult(res);
+ } catch {
+ setAddCheckResult(null);
+ }
+ }}
+ placeholder="example@mail.ru"
+ style={{
+ padding: '12px 14px',
+ borderRadius: 12,
+ border: '1px solid var(--md-sys-color-outline)',
+ background: 'var(--md-sys-color-surface-container-low)',
+ color: 'var(--md-sys-color-on-surface)',
+ fontSize: 16,
+ }}
+ />
+ {addCheckResult?.exists && addCheckResult?.is_client && (
+
+ Ученик уже зарегистрирован. Введите его 8-символьный универсальный код (в личном кабинете ученика).
+
+ )}
+
+ ) : addMode === 'code' ? (
+
+
+ Универсальный код ученика (8 символов: цифры и латинские буквы)
+
+ {
+ const v = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
+ setAddCode(v);
+ setAddError('');
+ }}
+ placeholder="A1B2C3D4"
+ style={{
+ padding: '12px 14px',
+ borderRadius: 12,
+ border: '1px solid var(--md-sys-color-outline)',
+ background: 'var(--md-sys-color-surface-container-low)',
+ color: 'var(--md-sys-color-on-surface)',
+ fontSize: 16,
+ letterSpacing: 4,
+ }}
+ />
+
+ ) : (
+
+
+ Отправьте эту ссылку ученику. Он сможет зарегистрироваться, указав только имя и фамилию.
+
+
+ {invitationLink ? (
+
+
+ {invitationLink}
+
+
+ {
+ await navigator.clipboard.writeText(invitationLink);
+ setLinkCopied(true);
+ showToast('Ссылка скопирована', 'success');
+ setTimeout(() => setLinkCopied(false), 2000);
+ }}
+ style={{
+ flex: 1,
+ padding: '10px',
+ borderRadius: 12,
+ border: 'none',
+ background: linkCopied ? 'var(--md-sys-color-tertiary)' : 'var(--md-sys-color-secondary)',
+ color: 'var(--md-sys-color-on-secondary)',
+ fontSize: 14,
+ fontWeight: 600,
+ cursor: 'pointer',
+ }}
+ >
+ {linkCopied ? 'Скопировано!' : 'Копировать'}
+
+ {
+ setAddSubmitting(true);
+ try {
+ const res = await generateInvitationLink();
+ setInvitationLink(res.invitation_link);
+ await navigator.clipboard.writeText(res.invitation_link);
+ setLinkCopied(true);
+ showToast('Ссылка обновлена и скопирована', 'success');
+ setTimeout(() => setLinkCopied(false), 2000);
+ } catch (err: any) {
+ setAddError(getErrorMessage(err, 'Ошибка обновления ссылки'));
+ } finally {
+ setAddSubmitting(false);
+ }
+ }}
+ style={{
+ flex: 1,
+ padding: '10px',
+ borderRadius: 12,
+ border: '1px solid var(--md-sys-color-outline)',
+ background: 'transparent',
+ color: 'var(--md-sys-color-on-surface)',
+ fontSize: 14,
+ fontWeight: 600,
+ cursor: 'pointer',
+ }}
+ >
+ {addSubmitting ? '…' : 'Обновить'}
+
+
+
+ ) : (
+
{
+ setAddSubmitting(true);
+ try {
+ const res = await generateInvitationLink();
+ setInvitationLink(res.invitation_link);
+ } catch (err: any) {
+ setAddError(getErrorMessage(err, 'Ошибка создания ссылки'));
+ } finally {
+ setAddSubmitting(false);
+ }
+ }}
+ style={{
+ padding: '12px 20px',
+ borderRadius: 12,
+ border: 'none',
+ background: 'var(--md-sys-color-primary)',
+ color: 'var(--md-sys-color-on-primary)',
+ fontSize: 16,
+ fontWeight: 600,
+ cursor: 'pointer',
+ }}
+ >
+ {addSubmitting ? 'Создание…' : 'Создать ссылку'}
+
+ )}
+
+ )}
+ {addMode !== 'link' && (
+
{
+ setAddError('');
+ setAddSubmitting(true);
+ try {
+ if (addMode === 'email') {
+ await addStudentInvitation({ email: addEmail.trim().toLowerCase() });
+ } else {
+ await addStudentInvitation({ universal_code: addCode });
+ }
+ setAddSubmitted(true);
+ setAddEmail('');
+ setAddCode('');
+ setAddCheckResult(null);
+ } catch (err: any) {
+ setAddError(getErrorMessage(err, 'Ошибка отправки приглашения'));
+ } finally {
+ setAddSubmitting(false);
+ }
+ }}
+ style={{
+ padding: '12px 20px',
+ borderRadius: 12,
+ border: 'none',
+ background: 'var(--md-sys-color-primary)',
+ color: 'var(--md-sys-color-on-primary)',
+ fontSize: 16,
+ fontWeight: 600,
+ cursor: addSubmitting ? 'not-allowed' : 'pointer',
+ opacity: (addSubmitting || (addMode === 'email' ? !addEmail.trim() : addCode.length !== 8)) ? 0.6 : 1,
+ }}
+ >
+ {addSubmitting ? 'Отправка…' : 'Отправить приглашение'}
+
+ )}
+ >
+ )}
+ {currentUser?.universal_code && (
+
+
+ Ваш 8-символьный код
+
+
+ {(currentUser.universal_code || '').split('').map((char, i) => (
+
+ {char}
+
+ ))}
+
+
+ Поделитесь кодом с учеником — он сможет отправить вам запрос на связь
+
+
+ )}
+
+ ) : (
+
+
+
+
+ Предмет:
+ setSelectedSubject(v)}
+ disabled={!progress || !progress.subjects.length}
+ placeholder="Выберите предмет"
+ />
+
+
+
+ Период:
+ {
+ if (v.start_date) setStartDate(dayjs(v.start_date));
+ else setStartDate(null);
+ if (v.end_date) setEndDate(dayjs(v.end_date));
+ else setEndDate(null);
+ }}
+ disabled={false}
+ />
+ setPeriodAnchor(e.currentTarget)}
+ style={{
+ padding: '8px 12px',
+ borderRadius: 12,
+ border: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
+ background: 'var(--md-sys-color-surface-container-low)',
+ color: 'var(--md-sys-color-on-surface)',
+ fontSize: 14,
+ cursor: 'pointer',
+ outline: 'none',
+ }}
+ >
+ Быстрый выбор
+
+
+
+
+
+
setPeriodAnchor(null)}
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
+ transformOrigin={{ vertical: 'top', horizontal: 'left' }}
+ slotProps={{
+ paper: {
+ sx: {
+ mt: 1,
+ borderRadius: 12,
+ minWidth: 300,
+ maxWidth: 360,
+ overflow: 'hidden',
+ },
+ },
+ }}
+ >
+
+
+
+ Выберите период
+
+
+ {localStart && localEnd
+ ? `${localStart.format('D MMM')} — ${localEnd.format('D MMM')}`
+ : '—'}
+
+
+ {rangeMode === 'start' ? 'Клик: начало периода' : 'Клик: конец периода'}
+
+
+ {[
+ { label: 'Эта неделя', fn: () => { setLocalStart(dayjs().startOf('isoWeek')); setLocalEnd(dayjs().endOf('isoWeek')); setRangeMode('end'); } },
+ { label: 'Прошлая неделя', fn: () => { const w = dayjs().subtract(1, 'week'); setLocalStart(w.startOf('isoWeek')); setLocalEnd(w.endOf('isoWeek')); setRangeMode('end'); } },
+ { label: 'Последние 7 дней', fn: () => { setLocalStart(dayjs().subtract(6, 'day')); setLocalEnd(dayjs()); setRangeMode('end'); } },
+ { label: 'Текущий месяц', fn: () => { setLocalStart(dayjs().startOf('month')); setLocalEnd(dayjs().endOf('month')); setRangeMode('end'); } },
+ { label: 'След. месяц', fn: () => { const m = dayjs().add(1, 'month'); setLocalStart(m.startOf('month')); setLocalEnd(m.endOf('month')); setRangeMode('end'); } },
+ { label: 'Сбросить', fn: () => { setLocalStart(null); setLocalEnd(null); setRangeMode('start'); } },
+ ].map(({ label, fn }) => (
+
+ {label}
+
+ ))}
+
+
{
+ const d = val ? dayjs(val as Date) : null;
+ if (!d) return;
+ if (rangeMode === 'start') {
+ setLocalStart(d);
+ setLocalEnd(null);
+ setRangeMode('end');
+ } else {
+ if (localStart && d.isBefore(localStart, 'day')) {
+ setLocalStart(d);
+ setLocalEnd(localStart);
+ } else {
+ setLocalEnd(d);
+ }
+ }
+ }}
+ slots={{
+ day: (props: PickersDayProps) => {
+ const { day, selected, sx, ...rest } = props;
+ const d = dayjs(day as Date);
+ const start = localStart ? dayjs(localStart).startOf('day') : null;
+ const end = localEnd ? dayjs(localEnd).startOf('day') : null;
+ const isStart = start && d.isSame(start, 'day');
+ const isEnd = end && d.isSame(end, 'day');
+ const inBetween = start && end && !isStart && !isEnd && d.isAfter(start) && d.isBefore(end);
+ const inRange = isStart || isEnd || inBetween;
+ return (
+
+ );
+ },
+ }}
+ sx={{ width: '100%', '& .MuiPickersCalendarHeader-root': { marginBottom: 0 } }}
+ />
+
+ setPeriodAnchor(null)}
+ style={{
+ padding: '8px 14px',
+ borderRadius: 8,
+ border: 'none',
+ background: 'transparent',
+ color: 'var(--md-sys-color-on-surface-variant)',
+ fontSize: 14,
+ cursor: 'pointer',
+ }}
+ >
+ Отмена
+
+ {
+ setStartDate(localStart);
+ setEndDate(localEnd);
+ setPeriodAnchor(null);
+ }}
+ style={{
+ padding: '8px 14px',
+ borderRadius: 8,
+ border: 'none',
+ background: 'var(--md-sys-color-primary)',
+ color: 'var(--md-sys-color-on-primary)',
+ fontSize: 14,
+ fontWeight: 600,
+ cursor: 'pointer',
+ }}
+ >
+ Применить
+
+
+
+
+
+
+ {(lessonsLoading && lessons.length === 0) ? (
+
+
+
+ ) : startStr && endStr ? (
+ <>
+
+
+
Занятий проведено
+
{periodStats.completedLessons}
+
из {periodStats.totalLessons}
+
+
+
Посещаемость
+
{periodStats.attendanceRate}%
+
+
+
Средняя оценка
+
{periodStats.avgGrade || '—'}
+
+
+
ДЗ с оценкой
+
{periodStats.hwGraded}
+
+
+
+
+
Успеваемость (репетитор и школа)
+ {gradesChart.categories.length === 0 ? (
+
Нет оценок за период
+ ) : (
+
+ )}
+
+
+
+
Успеваемость по ДЗ
+ {homeworkChart.categories.length === 0 ? (
+
Нет оценок за ДЗ за период
+ ) : (
+
+ )}
+
+
+
+
Посещаемость
+ {attendanceChart.categories.length === 0 ? (
+
Нет проведённых занятий за период
+ ) : (
+
+ )}
+
+ >
+ ) : (
+
+ Выберите период
+
+ )}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/front_material/components/livekit/LiveKitRoomContent.tsx b/front_material/components/livekit/LiveKitRoomContent.tsx
index 6debfdd..663a76a 100644
--- a/front_material/components/livekit/LiveKitRoomContent.tsx
+++ b/front_material/components/livekit/LiveKitRoomContent.tsx
@@ -1,1062 +1,1062 @@
-'use client';
-
-/**
- * LiveKit видеокомната — вариант из коробки (@livekit/components-react)
- */
-
-import React, { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react';
-import { createPortal } from 'react-dom';
-
-/** Ошибка LiveKit updatePages (placeholder/track race) — не выкидываем пользователя из занятия. */
-function isLiveKitLayoutError(error: unknown): boolean {
- const msg = error instanceof Error ? error.message : String(error);
- return (
- msg.includes('Element not part of the array') ||
- msg.includes('updatePages') ||
- msg.includes('_placeholder not in')
- );
-}
-
-class LiveKitLayoutErrorBoundary extends React.Component<
- { children: React.ReactNode },
- { error: unknown; recoverKey: number }
-> {
- state = { error: null as unknown, recoverKey: 0 };
-
- static getDerivedStateFromError(error: unknown) {
- return { error };
- }
-
- componentDidCatch(error: unknown) {
- if (isLiveKitLayoutError(error)) {
- window.setTimeout(() => {
- this.setState((s) => ({ error: null, recoverKey: s.recoverKey + 1 }));
- }, 100);
- }
- }
-
- render() {
- if (this.state.error && !isLiveKitLayoutError(this.state.error)) {
- throw this.state.error;
- }
- if (this.state.error) {
- return (
-
- );
- }
- return {this.props.children} ;
- }
-}
-import { useRouter, useSearchParams } from 'next/navigation';
-import { LiveKitRoom, VideoConference, RoomAudioRenderer, ConnectionStateToast, useTracks, useRemoteParticipants, ParticipantTile, useRoomContext, useStartAudio } from '@livekit/components-react';
-import { ExitLessonModal } from '@/components/livekit/ExitLessonModal';
-import { Track, RoomEvent, VideoPresets } from 'livekit-client';
-
-/** 2K (1440p) — разрешение и кодирование для высокого качества при хорошем канале */
-const PRESET_2K = {
- resolution: { width: 2560, height: 1440 },
- encoding: { maxBitrate: 6_000_000, maxFramerate: 30 } as const,
-};
-import { isTrackReference } from '@livekit/components-core';
-import '@/styles/livekit-components.css';
-import '@/styles/livekit-theme.css';
-import { getLesson } from '@/api/schedule';
-import type { Lesson } from '@/api/schedule';
-import { getOrCreateLessonBoard } from '@/api/board';
-
-/** Извлечь board_id и is_mentor из metadata LiveKit JWT (без верификации). */
-function getTokenMetadata(token: string | null): { board_id?: string; is_mentor?: boolean } {
- if (!token) return {};
- try {
- const parts = token.split('.');
- if (parts.length !== 3) return {};
- const payload = JSON.parse(
- atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))
- ) as { metadata?: string };
- const meta = payload.metadata;
- if (!meta || typeof meta !== 'string') return {};
- const parsed = JSON.parse(meta) as { board_id?: string; is_mentor?: boolean };
- return {
- board_id: parsed?.board_id ?? undefined,
- is_mentor: parsed?.is_mentor === true,
- };
- } catch {
- return {};
- }
-}
-import { WhiteboardIframe } from '@/components/board/WhiteboardIframe';
-import { LoadingSpinner } from '@/components/common/LoadingSpinner';
-import { getOrCreateLessonChat } from '@/api/chat';
-import type { Chat } from '@/api/chat';
-import { ChatWindow } from '@/components/chat/ChatWindow';
-import { useAuth } from '@/contexts/AuthContext';
-import { getAvatarUrl } from '@/api/profile';
-import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar';
-import { getNavBadges } from '@/api/navBadges';
-import type { NavBadges } from '@/api/navBadges';
-
-const CHAT_PANEL_WIDTH = 420;
-
-/** Камера собеседника в углу при открытой доске; сдвигается влево, когда открыт чат */
-function RemoteParticipantPiP({ chatOpen }: { chatOpen: boolean }) {
- const tracks = useTracks(
- [Track.Source.Camera, Track.Source.ScreenShare],
- { onlySubscribed: true }
- );
- const remoteRef = tracks.find(
- (ref) => isTrackReference(ref) && ref.participant && !ref.participant.isLocal
- );
- if (!remoteRef || !isTrackReference(remoteRef)) return null;
- return (
-
- );
-}
-
-const AVATAR_IMG_CLASS = 'lk-participant-avatar-img';
-
-/**
- * Подставляет аватары в плейсхолдер, когда камера выключена:
- * — у удалённых участников (собеседник видит их фото);
- * — у локального участника (своё фото вижу я и собеседник).
- * GET /api/users//avatar_url/
- */
-function RemoteParticipantAvatarPlaceholder() {
- const { user } = useAuth();
- const remoteParticipants = useRemoteParticipants();
- const [avatarUrls, setAvatarUrls] = useState>(new Map());
- const [localAvatarUrl, setLocalAvatarUrl] = useState(null);
- const injectedRef = useRef>(new WeakSet());
- const identityKey = remoteParticipants.map((p) => p.identity).sort().join(',');
-
- // Аватар собеседников
- useEffect(() => {
- if (remoteParticipants.length === 0) {
- setAvatarUrls(new Map());
- return;
- }
- let cancelled = false;
- const map = new Map();
- Promise.all(
- remoteParticipants.map(async (p) => {
- const id = p.identity;
- const url = await getAvatarUrl(id);
- return { id, url } as const;
- })
- ).then((results) => {
- if (cancelled) return;
- results.forEach(({ id, url }) => {
- if (url) map.set(id, url);
- });
- setAvatarUrls(new Map(map));
- });
- return () => { cancelled = true; };
- }, [identityKey]);
-
- // Свой аватар (чтобы видел и я, и собеседник, когда камера выключена)
- useEffect(() => {
- const id = user?.id;
- if (id == null) {
- setLocalAvatarUrl(null);
- return;
- }
- let cancelled = false;
- getAvatarUrl(String(id)).then((url) => {
- if (!cancelled) setLocalAvatarUrl(url);
- });
- return () => { cancelled = true; };
- }, [user?.id]);
-
- const runInject = React.useCallback(() => {
- const injectInto = (placeholder: Element, url: string | null) => {
- let img = placeholder.querySelector(`img.${AVATAR_IMG_CLASS}`) as HTMLImageElement | null;
- if (url) {
- if (!img) {
- img = document.createElement('img');
- img.className = AVATAR_IMG_CLASS;
- img.alt = '';
- placeholder.appendChild(img);
- }
- if (img.src !== url) img.src = url;
- injectedRef.current.add(placeholder);
- } else {
- if (img) img.remove();
- }
- };
-
- // Плитки собеседников
- const remotePlaceholders = document.querySelectorAll(
- '.lk-participant-tile[data-lk-local-participant="false"] .lk-participant-placeholder'
- );
- const urls = remoteParticipants.map((p) => avatarUrls.get(p.identity) ?? null);
- remotePlaceholders.forEach((placeholder, i) => {
- const url = urls[Math.min(i, urls.length - 1)] ?? null;
- injectInto(placeholder, url);
- });
-
- // Своя плитка — свой аватар (вижу я и собеседник)
- const localPlaceholders = document.querySelectorAll(
- '.lk-participant-tile[data-lk-local-participant="true"] .lk-participant-placeholder'
- );
- localPlaceholders.forEach((placeholder) => injectInto(placeholder, localAvatarUrl));
- }, [remoteParticipants, avatarUrls, localAvatarUrl]);
-
- useLayoutEffect(() => {
- runInject();
- const t1 = setTimeout(runInject, 300);
- const t2 = setTimeout(runInject, 1000);
- return () => {
- clearTimeout(t1);
- clearTimeout(t2);
- };
- }, [runInject]);
-
- return null;
-}
-
-const LS_AUDIO_PLAYBACK_ALLOWED = 'videoConference_audioPlaybackAllowed';
-const LS_AUDIO_ENABLED = 'videoConference_audioEnabled';
-const LS_VIDEO_ENABLED = 'videoConference_videoEnabled';
-
-/**
- * Оверлей «Разрешить звук» — показываем только при первом посещении.
- * После клика сохраняем в localStorage, чтобы не спрашивать постоянно.
- */
-function StartAudioOverlay() {
- const room = useRoomContext();
- const { mergedProps, canPlayAudio } = useStartAudio({ room, props: {} });
- const [dismissed, setDismissed] = useState(() => {
- try {
- return localStorage.getItem(LS_AUDIO_PLAYBACK_ALLOWED) === 'true';
- } catch {
- return false;
- }
- });
- useEffect(() => {
- if (canPlayAudio) {
- try {
- localStorage.setItem(LS_AUDIO_PLAYBACK_ALLOWED, 'true');
- } catch {}
- }
- }, [canPlayAudio]);
- if (canPlayAudio || dismissed) return null;
- const handleClick = () => {
- try {
- localStorage.setItem(LS_AUDIO_PLAYBACK_ALLOWED, 'true');
- } catch {}
- setDismissed(true);
- mergedProps.onClick?.();
- };
- return (
-
-
- Чтобы слышать собеседника, разрешите воспроизведение звука
-
-
- volume_up
- Разрешить звук
-
-
- );
-}
-
-function PreJoinScreen({
- onJoin,
- onCancel,
-}: {
- onJoin: (audio: boolean, video: boolean) => void;
- onCancel: () => void;
-}) {
- const [audioEnabled, setAudioEnabled] = useState(true);
- const [videoEnabled, setVideoEnabled] = useState(true);
- const [preview, setPreview] = useState(null);
- const videoRef = React.useRef(null);
-
- // Подтягиваем из localStorage после монтирования (SSR не имеет доступа к LS)
- useEffect(() => {
- const saved = getSavedAudioVideo();
- setAudioEnabled(saved.audio);
- setVideoEnabled(saved.video);
- }, []);
-
- useEffect(() => {
- if (!videoEnabled) return;
- let stream: MediaStream | null = null;
- navigator.mediaDevices
- .getUserMedia({ video: { width: { ideal: 2560 }, height: { ideal: 1440 }, frameRate: { ideal: 30 } }, audio: false })
- .then((s) => {
- stream = s;
- setPreview(s);
- if (videoRef.current) videoRef.current.srcObject = s;
- })
- .catch(() => {});
- return () => {
- stream?.getTracks().forEach((t) => t.stop());
- setPreview(null);
- };
- }, [videoEnabled]);
-
- const handleJoin = () => {
- try {
- localStorage.setItem(LS_AUDIO_ENABLED, String(audioEnabled));
- localStorage.setItem(LS_VIDEO_ENABLED, String(videoEnabled));
- console.log(`[LiveKit аудио/видео] PreJoin handleJoin: audio=${audioEnabled}, video=${videoEnabled} → localStorage`);
- } catch {}
- preview?.getTracks().forEach((t) => t.stop());
- onJoin(audioEnabled, videoEnabled);
- };
-
- return (
-
-
-
-
Настройки перед входом
-
Настройте камеру и микрофон
-
-
-
- {videoEnabled ? (
-
- ) : (
-
-
videocam_off
-
Камера выключена
-
- )}
-
-
-
- Микрофон
- {
- setAudioEnabled((v) => {
- const next = !v;
- try {
- localStorage.setItem(LS_AUDIO_ENABLED, String(next));
- console.log(`[LiveKit аудио/видео] PreJoin toggle микрофон: ${next} → localStorage`);
- } catch {}
- return next;
- });
- }}
- style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: audioEnabled ? 'var(--md-sys-color-primary)' : '#666', color: '#fff', cursor: 'pointer' }}
- >
- {audioEnabled ? 'Выключить' : 'Включить'}
-
-
-
- Камера
- {
- setVideoEnabled((v) => {
- const next = !v;
- try {
- localStorage.setItem(LS_VIDEO_ENABLED, String(next));
- console.log(`[LiveKit аудио/видео] PreJoin toggle камера: ${next} → localStorage`);
- } catch {}
- return next;
- });
- }}
- style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: videoEnabled ? 'var(--md-sys-color-primary)' : '#666', color: '#fff', cursor: 'pointer' }}
- >
- {videoEnabled ? 'Выключить' : 'Включить'}
-
-
-
-
-
- Отмена
-
-
- videocam
- Войти в конференцию
-
-
-
-
-
- );
-}
-
-type RoomContentProps = {
- lessonId: number | null;
- boardId: string | null;
- boardLoading: boolean;
- showBoard: boolean;
- setShowBoard: (v: boolean) => void;
- userDisplayName: string;
-};
-
-function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard, userDisplayName }: RoomContentProps) {
- const room = useRoomContext();
- const router = useRouter();
- const { user } = useAuth();
- const [showPlatformChat, setShowPlatformChat] = useState(false);
- const [lessonChat, setLessonChat] = useState(null);
- const [lessonChatLoading, setLessonChatLoading] = useState(false);
- const [showExitModal, setShowExitModal] = useState(false);
- const [showNavMenu, setShowNavMenu] = useState(false);
- const [navBadges, setNavBadges] = useState(null);
-
- useEffect(() => {
- if (!user) return;
- getNavBadges().then(setNavBadges).catch(() => setNavBadges(null));
- }, [user]);
-
- useEffect(() => {
- if (!showPlatformChat || !lessonId) {
- if (!showPlatformChat) setLessonChat(null);
- return;
- }
- setLessonChatLoading(true);
- getOrCreateLessonChat(lessonId)
- .then((c) => setLessonChat(c))
- .catch(() => setLessonChat(null))
- .finally(() => setLessonChatLoading(false));
- }, [showPlatformChat, lessonId]);
-
- // Вставляем бургер (слева от микрофона) и кнопку «Выйти» в панель LiveKit
- useEffect(() => {
- if (showBoard) return;
- const id = setTimeout(() => {
- const bar = document.querySelector('.lk-control-bar');
- if (!bar) return;
- if (!bar.querySelector('.lk-burger-button')) {
- const burger = document.createElement('button');
- burger.type = 'button';
- burger.className = 'lk-button lk-burger-button';
- burger.title = 'Меню';
- burger.innerHTML = 'menu ';
- burger.addEventListener('click', () => window.dispatchEvent(new CustomEvent('livekit-burger-click')));
- bar.insertBefore(burger, bar.firstChild);
- }
- if (!bar.querySelector('.lk-custom-exit-button')) {
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'lk-button lk-custom-exit-button';
- btn.setAttribute('data-lk-source', 'disconnect');
- btn.title = 'Выйти';
- btn.innerHTML = 'logout ';
- btn.addEventListener('click', () => window.dispatchEvent(new CustomEvent('livekit-exit-click')));
- bar.appendChild(btn);
- }
- }, 800);
- return () => clearTimeout(id);
- }, [showBoard]);
-
- useEffect(() => {
- const handler = () => setShowNavMenu((v) => !v);
- window.addEventListener('livekit-burger-click', handler);
- return () => window.removeEventListener('livekit-burger-click', handler);
- }, []);
-
- useEffect(() => {
- const handler = () => {
- if (user?.role === 'mentor') {
- setShowExitModal(true);
- } else {
- room.disconnect();
- }
- };
- window.addEventListener('livekit-exit-click', handler);
- return () => window.removeEventListener('livekit-exit-click', handler);
- }, [user?.role, room]);
-
- // Сохраняем в localStorage при переключении микрофона/камеры + логи для отладки
- useEffect(() => {
- const lp = room.localParticipant;
- if (!lp) return;
- const log = (source: string, audio: boolean, video: boolean) => {
- console.log(`[LiveKit аудио/видео] ${source}: audio=${audio}, video=${video} → localStorage.${LS_AUDIO_ENABLED}=${audio}, ${LS_VIDEO_ENABLED}=${video}`);
- };
- const saveFromState = (source: string) => {
- const audio = lp.isMicrophoneEnabled;
- const video = lp.isCameraEnabled;
- try {
- localStorage.setItem(LS_AUDIO_ENABLED, String(audio));
- localStorage.setItem(LS_VIDEO_ENABLED, String(video));
- log(source, audio, video);
- } catch (e) {
- console.error('[LiveKit аудио/видео] Ошибка сохранения:', e);
- }
- };
- const onTrackMuted = (pub: { source?: Track.Source }, participant: { sid?: string }) => {
- if (participant?.sid !== lp.sid) return;
- try {
- if (pub?.source === Track.Source.Microphone) {
- localStorage.setItem(LS_AUDIO_ENABLED, 'false');
- log('TrackMuted(Microphone)', false, lp.isCameraEnabled);
- } else if (pub?.source === Track.Source.Camera) {
- localStorage.setItem(LS_VIDEO_ENABLED, 'false');
- log('TrackMuted(Camera)', lp.isMicrophoneEnabled, false);
- } else {
- saveFromState('TrackMuted(?)');
- }
- } catch (e) {
- console.error('[LiveKit аудио/видео] TrackMuted ошибка:', e);
- }
- };
- const onTrackUnmuted = (pub: { source?: Track.Source }, participant: { sid?: string }) => {
- if (participant?.sid !== lp.sid) return;
- try {
- if (pub?.source === Track.Source.Microphone) {
- localStorage.setItem(LS_AUDIO_ENABLED, 'true');
- log('TrackUnmuted(Microphone)', true, lp.isCameraEnabled);
- } else if (pub?.source === Track.Source.Camera) {
- localStorage.setItem(LS_VIDEO_ENABLED, 'true');
- log('TrackUnmuted(Camera)', lp.isMicrophoneEnabled, true);
- } else {
- saveFromState('TrackUnmuted(?)');
- }
- } catch (e) {
- console.error('[LiveKit аудио/видео] TrackUnmuted ошибка:', e);
- }
- };
- const onLocalTrackPublished = () => saveFromState('LocalTrackPublished');
- room.on(RoomEvent.TrackMuted, onTrackMuted);
- room.on(RoomEvent.TrackUnmuted, onTrackUnmuted);
- room.on(RoomEvent.LocalTrackPublished, onLocalTrackPublished);
- saveFromState('init');
- return () => {
- room.off(RoomEvent.TrackMuted, onTrackMuted);
- room.off(RoomEvent.TrackUnmuted, onTrackUnmuted);
- room.off(RoomEvent.LocalTrackPublished, onLocalTrackPublished);
- /* Не сохраняем при unmount/Disconnected — треки уже удалены, lp.isMicrophoneEnabled вернёт false */
- };
- }, [room]);
-
- return (
-
-
-
-
- {/* Boundary только вокруг VideoConference — при reset доска не перемонтируется */}
-
-
- message} />
-
-
-
-
- {/* Сайдбар, PiP, бургер и навигация в Portal */}
- {typeof document !== 'undefined' && createPortal(
- <>
- {showBoard &&
}
- {/* Бургер при открытой доске — панель скрыта, показываем свой бургер */}
- {showBoard && (
-
setShowNavMenu((v) => !v)}
- style={{
- position: 'fixed',
- left: 16,
- bottom: 64,
- width: 48,
- height: 48,
- borderRadius: 12,
- border: 'none',
- background: 'rgba(0,0,0,0.7)',
- color: '#fff',
- cursor: 'pointer',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- zIndex: 10002,
- boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
- }}
- title="Меню"
- >
- menu
-
- )}
- {/* Выдвижная навигация — BottomNavigationBar слева, 3 колонки */}
- {showNavMenu && (
- <>
-
setShowNavMenu(false)}
- style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 10003, backdropFilter: 'blur(4px)' }}
- />
-
- setShowNavMenu(false)}
- />
-
- >
- )}
-
- setShowBoard(false)}
- style={{
- width: 48,
- height: 48,
- borderRadius: 12,
- border: 'none',
- background: !showBoard ? 'var(--md-sys-color-primary)' : 'rgba(255,255,255,0.2)',
- color: '#fff',
- cursor: 'pointer',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- }}
- title="Камера"
- >
- videocam
-
- (boardId && !boardLoading ? setShowBoard(true) : undefined)}
- disabled={!boardId || boardLoading}
- aria-disabled={!boardId || boardLoading}
- style={{
- width: 48,
- height: 48,
- borderRadius: 12,
- border: 'none',
- background: showBoard ? 'var(--md-sys-color-primary)' : 'rgba(255,255,255,0.2)',
- color: '#fff',
- cursor: boardId && !boardLoading ? 'pointer' : 'not-allowed',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- opacity: !boardId || boardLoading ? 0.6 : 1,
- pointerEvents: !boardId || boardLoading ? 'none' : 'auto',
- }}
- title={
- boardLoading
- ? 'Загрузка доски...'
- : !boardId
- ? 'Доска недоступна (проверяем каждые 10 с)'
- : 'Доска'
- }
- >
- {boardLoading ? (
-
- ) : (
- draw
- )}
-
- {lessonId != null && (
- setShowPlatformChat((v) => !v)}
- style={{
- width: 48,
- height: 48,
- borderRadius: 12,
- border: 'none',
- background: showPlatformChat ? 'var(--md-sys-color-primary)' : 'rgba(255,255,255,0.2)',
- color: '#fff',
- cursor: 'pointer',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- }}
- title="Чат"
- >
- chat
-
- )}
-
- >,
- document.body
- )}
-
- {/* Панель чата сервиса (не LiveKit) */}
- {showPlatformChat && (
-
- {lessonChatLoading ? (
-
- progress_activity
-
- ) : (
-
setShowPlatformChat(false)}
- />
- )}
-
- )}
-
-
setShowExitModal(false)}
- onExit={() => room.disconnect()}
- />
-
- );
-}
-
-const SS_PREJOIN_DONE = 'livekit_prejoin_done';
-
-/** PreJoin — показываем при первом заходе. После входа в комнату или перезагрузки — пропускаем (sessionStorage). */
-function getInitialShowPreJoin(searchParams: URLSearchParams): boolean {
- try {
- if (typeof window !== 'undefined' && sessionStorage.getItem(SS_PREJOIN_DONE) === '1') {
- return false;
- }
- return searchParams.get('skip_prejoin') !== '1';
- } catch {
- return true;
- }
-}
-
-function getSavedAudioVideo(): { audio: boolean; video: boolean } {
- try {
- const rawA = localStorage.getItem(LS_AUDIO_ENABLED);
- const rawV = localStorage.getItem(LS_VIDEO_ENABLED);
- const audio = rawA !== 'false';
- const video = rawV !== 'false';
- console.log(`[LiveKit аудио/видео] Чтение из localStorage: ${LS_AUDIO_ENABLED}=${rawA} → audio=${audio}, ${LS_VIDEO_ENABLED}=${rawV} → video=${video}`);
- return { audio, video };
- } catch (e) {
- console.error('[LiveKit аудио/видео] Ошибка чтения localStorage:', e);
- return { audio: true, video: true };
- }
-}
-
-export default function LiveKitRoomContent() {
- const router = useRouter();
- const searchParams = useSearchParams();
- const accessToken = searchParams.get('token');
- const lessonIdParam = searchParams.get('lesson_id');
- const { user } = useAuth();
-
- const [serverUrl, setServerUrl] = useState
('');
- const [showPreJoin, setShowPreJoin] = useState(() => getInitialShowPreJoin(searchParams));
- const [audioEnabled, setAudioEnabled] = useState(true);
- const [videoEnabled, setVideoEnabled] = useState(true);
- const [avReady, setAvReady] = useState(false);
-
- // Подтягиваем из localStorage после монтирования (SSR не имеет доступа к LS)
- useEffect(() => {
- const saved = getSavedAudioVideo();
- console.log('[LiveKit аудио/видео] useEffect: загрузили из localStorage, устанавливаем state:', saved);
- setAudioEnabled(saved.audio);
- setVideoEnabled(saved.video);
- setAvReady(true);
- }, []);
- const [lessonCompleted, setLessonCompleted] = useState(false);
- const [effectiveLessonId, setEffectiveLessonId] = useState(null);
- const [boardId, setBoardId] = useState(null);
- const [boardLoading, setBoardLoading] = useState(false);
- const boardPollRef = useRef | null>(null);
- const [isMentor, setIsMentor] = useState(false);
- const [showBoard, setShowBoard] = useState(false);
- const [userDisplayName, setUserDisplayName] = useState('Пользователь');
-
- // Доска и is_mentor из metadata LiveKit токена или getOrCreateLessonBoard; при отсутствии доски — опрос раз в 10 с
- useEffect(() => {
- const meta = getTokenMetadata(accessToken);
- if (meta.is_mentor === true) setIsMentor(true);
- if (meta.board_id) {
- setBoardId(meta.board_id);
- setBoardLoading(false);
- return;
- }
- if (!effectiveLessonId) {
- setBoardLoading(false);
- return;
- }
-
- let cancelled = false;
- const stopPoll = () => {
- if (boardPollRef.current) {
- clearInterval(boardPollRef.current);
- boardPollRef.current = null;
- }
- };
- setBoardLoading(true);
- getOrCreateLessonBoard(effectiveLessonId)
- .then((b) => {
- if (!cancelled) {
- setBoardId(b.board_id);
- stopPoll();
- }
- })
- .catch(() => {})
- .finally(() => { if (!cancelled) setBoardLoading(false); });
-
- boardPollRef.current = setInterval(() => {
- if (cancelled) return;
- getOrCreateLessonBoard(effectiveLessonId)
- .then((b) => {
- if (!cancelled) {
- setBoardId(b.board_id);
- stopPoll();
- }
- })
- .catch(() => {});
- }, 10_000);
-
- return () => {
- cancelled = true;
- stopPoll();
- };
- }, [accessToken, effectiveLessonId]);
-
- // Fallback: is_mentor из API урока (lesson.mentor.id === user.id), если токен старый или без metadata
- useEffect(() => {
- if (!effectiveLessonId || !user) return;
- const userId = user.id ?? (user as { pk?: number }).pk;
- if (!userId) return;
- getLesson(String(effectiveLessonId))
- .then((lesson) => {
- const mentorId = typeof lesson.mentor === 'object' && lesson.mentor
- ? Number(lesson.mentor.id)
- : Number(lesson.mentor);
- if (mentorId && Number(userId) === mentorId) {
- setIsMentor(true);
- }
- })
- .catch(() => {});
- }, [effectiveLessonId, user]);
-
- useEffect(() => {
- const token = localStorage.getItem('access_token');
- if (token) {
- const base = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8123/api';
- fetch(`${base}/profile/me/`, { headers: { Authorization: `Bearer ${token}` } })
- .then((r) => r.json())
- .then((u: { first_name?: string; last_name?: string; email?: string }) => {
- const raw = `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.email || 'Пользователь';
- const name = /%[0-9A-Fa-f]{2}/.test(raw) ? decodeURIComponent(raw) : raw;
- setUserDisplayName(name);
- })
- .catch(() => {});
- }
- }, []);
-
- useEffect(() => {
- const id = lessonIdParam ? parseInt(lessonIdParam, 10) : null;
- if (id && !isNaN(id)) {
- setEffectiveLessonId(id);
- try {
- sessionStorage.setItem('livekit_lesson_id', String(id));
- } catch {}
- } else {
- try {
- const stored = sessionStorage.getItem('livekit_lesson_id');
- if (stored) setEffectiveLessonId(parseInt(stored, 10));
- } catch {}
- }
- }, [lessonIdParam]);
-
- useEffect(() => {
- const load = async () => {
- if (lessonIdParam) {
- try {
- const l = await getLesson(lessonIdParam);
- if (l.status === 'completed') {
- const now = new Date();
- const end = l.completed_at ? new Date(l.completed_at) : new Date(l.end_time);
- if (now > new Date(end.getTime() + 10 * 60000)) setLessonCompleted(true);
- }
- } catch {}
- }
- const token = localStorage.getItem('access_token');
- if (token) {
- try {
- const base = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8123/api';
- const res = await fetch(`${base}/video/livekit/config/`, { headers: { Authorization: `Bearer ${token}` } });
- if (res.ok) {
- const config = await res.json();
- setServerUrl(config.server_url || 'ws://127.0.0.1:7880');
- }
- } catch {}
- }
- };
- load();
- }, [lessonIdParam]);
-
- if (lessonCompleted) {
- return (
-
-
-
Урок завершён. Видеоконференция недоступна.
-
router.push('/dashboard')} style={{ padding: '12px 24px', borderRadius: 12, border: 'none', background: 'var(--md-sys-color-primary)', color: 'var(--md-sys-color-on-primary)', cursor: 'pointer' }}>
- На главную
-
-
-
- );
- }
-
- if (!accessToken || !serverUrl) {
- return (
-
- );
- }
-
- if (showPreJoin) {
- return (
- {
- try {
- sessionStorage.setItem(SS_PREJOIN_DONE, '1');
- } catch {}
- setAudioEnabled(audio);
- setVideoEnabled(video);
- setShowPreJoin(false);
- }}
- onCancel={() => router.push('/dashboard')}
- />
- );
- }
-
- if (!avReady) {
- return (
-
- );
- }
-
- console.log('[LiveKit аудио/видео] Передача в LiveKitRoom:', { audio: audioEnabled, video: videoEnabled });
- return (
- <>
- {/* Доска — сиблинг LiveKitRoom, iframe создаётся один раз, не перемонтируется */}
- {boardId && (
-
-
-
- )}
- router.push('/dashboard')}
- style={{ height: '100vh' }}
- data-lk-theme="default"
- options={{
- adaptiveStream: true,
- dynacast: true,
- // Захват до 2K (1440p), при отсутствии поддержки браузер даст меньше
- videoCaptureDefaults: {
- resolution: PRESET_2K.resolution,
- frameRate: 30,
- },
- publishDefaults: {
- simulcast: true,
- // Камера: до 2K, 6 Mbps — вариативность через слои 1080p, 720p, 360p
- videoEncoding: PRESET_2K.encoding,
- // Два слоя поверх основного: 720p и 360p для вариативности при слабом канале
- videoSimulcastLayers: [VideoPresets.h720, VideoPresets.h360],
- // Демонстрация экрана: 2K, 6 Mbps, те же два слоя для адаптации
- screenShareEncoding: { maxBitrate: 6_000_000, maxFramerate: 30 },
- screenShareSimulcastLayers: [VideoPresets.h720, VideoPresets.h360],
- degradationPreference: 'maintain-resolution',
- },
- audioCaptureDefaults: {
- noiseSuppression: true,
- echoCancellation: true,
- },
- }}
- >
-
-
-
-
- >
- );
-}
+'use client';
+
+/**
+ * LiveKit видеокомната — вариант из коробки (@livekit/components-react)
+ */
+
+import React, { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+
+/** Ошибка LiveKit updatePages (placeholder/track race) — не выкидываем пользователя из занятия. */
+function isLiveKitLayoutError(error: unknown): boolean {
+ const msg = error instanceof Error ? error.message : String(error);
+ return (
+ msg.includes('Element not part of the array') ||
+ msg.includes('updatePages') ||
+ msg.includes('_placeholder not in')
+ );
+}
+
+class LiveKitLayoutErrorBoundary extends React.Component<
+ { children: React.ReactNode },
+ { error: unknown; recoverKey: number }
+> {
+ state = { error: null as unknown, recoverKey: 0 };
+
+ static getDerivedStateFromError(error: unknown) {
+ return { error };
+ }
+
+ componentDidCatch(error: unknown) {
+ if (isLiveKitLayoutError(error)) {
+ window.setTimeout(() => {
+ this.setState((s) => ({ error: null, recoverKey: s.recoverKey + 1 }));
+ }, 100);
+ }
+ }
+
+ render() {
+ if (this.state.error && !isLiveKitLayoutError(this.state.error)) {
+ throw this.state.error;
+ }
+ if (this.state.error) {
+ return (
+
+ );
+ }
+ return {this.props.children} ;
+ }
+}
+import { useRouter, useSearchParams } from 'next/navigation';
+import { LiveKitRoom, VideoConference, RoomAudioRenderer, ConnectionStateToast, useTracks, useRemoteParticipants, ParticipantTile, useRoomContext, useStartAudio } from '@livekit/components-react';
+import { ExitLessonModal } from '@/components/livekit/ExitLessonModal';
+import { Track, RoomEvent, VideoPresets } from 'livekit-client';
+
+/** 2K (1440p) — разрешение и кодирование для высокого качества при хорошем канале */
+const PRESET_2K = {
+ resolution: { width: 2560, height: 1440 },
+ encoding: { maxBitrate: 6_000_000, maxFramerate: 30 } as const,
+};
+import { isTrackReference } from '@livekit/components-core';
+import '@/styles/livekit-components.css';
+import '@/styles/livekit-theme.css';
+import { getLesson } from '@/api/schedule';
+import type { Lesson } from '@/api/schedule';
+import { getOrCreateLessonBoard } from '@/api/board';
+
+/** Извлечь board_id и is_mentor из metadata LiveKit JWT (без верификации). */
+function getTokenMetadata(token: string | null): { board_id?: string; is_mentor?: boolean } {
+ if (!token) return {};
+ try {
+ const parts = token.split('.');
+ if (parts.length !== 3) return {};
+ const payload = JSON.parse(
+ atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))
+ ) as { metadata?: string };
+ const meta = payload.metadata;
+ if (!meta || typeof meta !== 'string') return {};
+ const parsed = JSON.parse(meta) as { board_id?: string; is_mentor?: boolean };
+ return {
+ board_id: parsed?.board_id ?? undefined,
+ is_mentor: parsed?.is_mentor === true,
+ };
+ } catch {
+ return {};
+ }
+}
+import { WhiteboardIframe } from '@/components/board/WhiteboardIframe';
+import { LoadingSpinner } from '@/components/common/LoadingSpinner';
+import { getOrCreateLessonChat } from '@/api/chat';
+import type { Chat } from '@/api/chat';
+import { ChatWindow } from '@/components/chat/ChatWindow';
+import { useAuth } from '@/contexts/AuthContext';
+import { getAvatarUrl } from '@/api/profile';
+import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar';
+import { getNavBadges } from '@/api/navBadges';
+import type { NavBadges } from '@/api/navBadges';
+
+const CHAT_PANEL_WIDTH = 420;
+
+/** Камера собеседника в углу при открытой доске; сдвигается влево, когда открыт чат */
+function RemoteParticipantPiP({ chatOpen }: { chatOpen: boolean }) {
+ const tracks = useTracks(
+ [Track.Source.Camera, Track.Source.ScreenShare],
+ { onlySubscribed: true }
+ );
+ const remoteRef = tracks.find(
+ (ref) => isTrackReference(ref) && ref.participant && !ref.participant.isLocal
+ );
+ if (!remoteRef || !isTrackReference(remoteRef)) return null;
+ return (
+
+ );
+}
+
+const AVATAR_IMG_CLASS = 'lk-participant-avatar-img';
+
+/**
+ * Подставляет аватары в плейсхолдер, когда камера выключена:
+ * — у удалённых участников (собеседник видит их фото);
+ * — у локального участника (своё фото вижу я и собеседник).
+ * GET /api/users//avatar_url/
+ */
+function RemoteParticipantAvatarPlaceholder() {
+ const { user } = useAuth();
+ const remoteParticipants = useRemoteParticipants();
+ const [avatarUrls, setAvatarUrls] = useState>(new Map());
+ const [localAvatarUrl, setLocalAvatarUrl] = useState(null);
+ const injectedRef = useRef>(new WeakSet());
+ const identityKey = remoteParticipants.map((p) => p.identity).sort().join(',');
+
+ // Аватар собеседников
+ useEffect(() => {
+ if (remoteParticipants.length === 0) {
+ setAvatarUrls(new Map());
+ return;
+ }
+ let cancelled = false;
+ const map = new Map();
+ Promise.all(
+ remoteParticipants.map(async (p) => {
+ const id = p.identity;
+ const url = await getAvatarUrl(id);
+ return { id, url } as const;
+ })
+ ).then((results) => {
+ if (cancelled) return;
+ results.forEach(({ id, url }) => {
+ if (url) map.set(id, url);
+ });
+ setAvatarUrls(new Map(map));
+ });
+ return () => { cancelled = true; };
+ }, [identityKey]);
+
+ // Свой аватар (чтобы видел и я, и собеседник, когда камера выключена)
+ useEffect(() => {
+ const id = user?.id;
+ if (id == null) {
+ setLocalAvatarUrl(null);
+ return;
+ }
+ let cancelled = false;
+ getAvatarUrl(String(id)).then((url) => {
+ if (!cancelled) setLocalAvatarUrl(url);
+ });
+ return () => { cancelled = true; };
+ }, [user?.id]);
+
+ const runInject = React.useCallback(() => {
+ const injectInto = (placeholder: Element, url: string | null) => {
+ let img = placeholder.querySelector(`img.${AVATAR_IMG_CLASS}`) as HTMLImageElement | null;
+ if (url) {
+ if (!img) {
+ img = document.createElement('img');
+ img.className = AVATAR_IMG_CLASS;
+ img.alt = '';
+ placeholder.appendChild(img);
+ }
+ if (img.src !== url) img.src = url;
+ injectedRef.current.add(placeholder);
+ } else {
+ if (img) img.remove();
+ }
+ };
+
+ // Плитки собеседников
+ const remotePlaceholders = document.querySelectorAll(
+ '.lk-participant-tile[data-lk-local-participant="false"] .lk-participant-placeholder'
+ );
+ const urls = remoteParticipants.map((p) => avatarUrls.get(p.identity) ?? null);
+ remotePlaceholders.forEach((placeholder, i) => {
+ const url = urls[Math.min(i, urls.length - 1)] ?? null;
+ injectInto(placeholder, url);
+ });
+
+ // Своя плитка — свой аватар (вижу я и собеседник)
+ const localPlaceholders = document.querySelectorAll(
+ '.lk-participant-tile[data-lk-local-participant="true"] .lk-participant-placeholder'
+ );
+ localPlaceholders.forEach((placeholder) => injectInto(placeholder, localAvatarUrl));
+ }, [remoteParticipants, avatarUrls, localAvatarUrl]);
+
+ useLayoutEffect(() => {
+ runInject();
+ const t1 = setTimeout(runInject, 300);
+ const t2 = setTimeout(runInject, 1000);
+ return () => {
+ clearTimeout(t1);
+ clearTimeout(t2);
+ };
+ }, [runInject]);
+
+ return null;
+}
+
+const LS_AUDIO_PLAYBACK_ALLOWED = 'videoConference_audioPlaybackAllowed';
+const LS_AUDIO_ENABLED = 'videoConference_audioEnabled';
+const LS_VIDEO_ENABLED = 'videoConference_videoEnabled';
+
+/**
+ * Оверлей «Разрешить звук» — показываем только при первом посещении.
+ * После клика сохраняем в localStorage, чтобы не спрашивать постоянно.
+ */
+function StartAudioOverlay() {
+ const room = useRoomContext();
+ const { mergedProps, canPlayAudio } = useStartAudio({ room, props: {} });
+ const [dismissed, setDismissed] = useState(() => {
+ try {
+ return localStorage.getItem(LS_AUDIO_PLAYBACK_ALLOWED) === 'true';
+ } catch {
+ return false;
+ }
+ });
+ useEffect(() => {
+ if (canPlayAudio) {
+ try {
+ localStorage.setItem(LS_AUDIO_PLAYBACK_ALLOWED, 'true');
+ } catch {}
+ }
+ }, [canPlayAudio]);
+ if (canPlayAudio || dismissed) return null;
+ const handleClick = () => {
+ try {
+ localStorage.setItem(LS_AUDIO_PLAYBACK_ALLOWED, 'true');
+ } catch {}
+ setDismissed(true);
+ mergedProps.onClick?.();
+ };
+ return (
+
+
+ Чтобы слышать собеседника, разрешите воспроизведение звука
+
+
+ volume_up
+ Разрешить звук
+
+
+ );
+}
+
+function PreJoinScreen({
+ onJoin,
+ onCancel,
+}: {
+ onJoin: (audio: boolean, video: boolean) => void;
+ onCancel: () => void;
+}) {
+ const [audioEnabled, setAudioEnabled] = useState(true);
+ const [videoEnabled, setVideoEnabled] = useState(true);
+ const [preview, setPreview] = useState(null);
+ const videoRef = React.useRef(null);
+
+ // Подтягиваем из localStorage после монтирования (SSR не имеет доступа к LS)
+ useEffect(() => {
+ const saved = getSavedAudioVideo();
+ setAudioEnabled(saved.audio);
+ setVideoEnabled(saved.video);
+ }, []);
+
+ useEffect(() => {
+ if (!videoEnabled) return;
+ let stream: MediaStream | null = null;
+ navigator.mediaDevices
+ .getUserMedia({ video: { width: { ideal: 2560 }, height: { ideal: 1440 }, frameRate: { ideal: 30 } }, audio: false })
+ .then((s) => {
+ stream = s;
+ setPreview(s);
+ if (videoRef.current) videoRef.current.srcObject = s;
+ })
+ .catch(() => {});
+ return () => {
+ stream?.getTracks().forEach((t) => t.stop());
+ setPreview(null);
+ };
+ }, [videoEnabled]);
+
+ const handleJoin = () => {
+ try {
+ localStorage.setItem(LS_AUDIO_ENABLED, String(audioEnabled));
+ localStorage.setItem(LS_VIDEO_ENABLED, String(videoEnabled));
+ console.log(`[LiveKit аудио/видео] PreJoin handleJoin: audio=${audioEnabled}, video=${videoEnabled} → localStorage`);
+ } catch {}
+ preview?.getTracks().forEach((t) => t.stop());
+ onJoin(audioEnabled, videoEnabled);
+ };
+
+ return (
+
+
+
+
Настройки перед входом
+
Настройте камеру и микрофон
+
+
+
+ {videoEnabled ? (
+
+ ) : (
+
+
videocam_off
+
Камера выключена
+
+ )}
+
+
+
+ Микрофон
+ {
+ setAudioEnabled((v) => {
+ const next = !v;
+ try {
+ localStorage.setItem(LS_AUDIO_ENABLED, String(next));
+ console.log(`[LiveKit аудио/видео] PreJoin toggle микрофон: ${next} → localStorage`);
+ } catch {}
+ return next;
+ });
+ }}
+ style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: audioEnabled ? 'var(--md-sys-color-primary)' : '#666', color: '#fff', cursor: 'pointer' }}
+ >
+ {audioEnabled ? 'Выключить' : 'Включить'}
+
+
+
+ Камера
+ {
+ setVideoEnabled((v) => {
+ const next = !v;
+ try {
+ localStorage.setItem(LS_VIDEO_ENABLED, String(next));
+ console.log(`[LiveKit аудио/видео] PreJoin toggle камера: ${next} → localStorage`);
+ } catch {}
+ return next;
+ });
+ }}
+ style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: videoEnabled ? 'var(--md-sys-color-primary)' : '#666', color: '#fff', cursor: 'pointer' }}
+ >
+ {videoEnabled ? 'Выключить' : 'Включить'}
+
+
+
+
+
+ Отмена
+
+
+ videocam
+ Войти в конференцию
+
+
+
+
+
+ );
+}
+
+type RoomContentProps = {
+ lessonId: number | null;
+ boardId: string | null;
+ boardLoading: boolean;
+ showBoard: boolean;
+ setShowBoard: (v: boolean) => void;
+ userDisplayName: string;
+};
+
+function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard, userDisplayName }: RoomContentProps) {
+ const room = useRoomContext();
+ const router = useRouter();
+ const { user } = useAuth();
+ const [showPlatformChat, setShowPlatformChat] = useState(false);
+ const [lessonChat, setLessonChat] = useState(null);
+ const [lessonChatLoading, setLessonChatLoading] = useState(false);
+ const [showExitModal, setShowExitModal] = useState(false);
+ const [showNavMenu, setShowNavMenu] = useState(false);
+ const [navBadges, setNavBadges] = useState(null);
+
+ useEffect(() => {
+ if (!user) return;
+ getNavBadges().then(setNavBadges).catch(() => setNavBadges(null));
+ }, [user]);
+
+ useEffect(() => {
+ if (!showPlatformChat || !lessonId) {
+ if (!showPlatformChat) setLessonChat(null);
+ return;
+ }
+ setLessonChatLoading(true);
+ getOrCreateLessonChat(lessonId)
+ .then((c) => setLessonChat(c))
+ .catch(() => setLessonChat(null))
+ .finally(() => setLessonChatLoading(false));
+ }, [showPlatformChat, lessonId]);
+
+ // Вставляем бургер (слева от микрофона) и кнопку «Выйти» в панель LiveKit
+ useEffect(() => {
+ if (showBoard) return;
+ const id = setTimeout(() => {
+ const bar = document.querySelector('.lk-control-bar');
+ if (!bar) return;
+ if (!bar.querySelector('.lk-burger-button')) {
+ const burger = document.createElement('button');
+ burger.type = 'button';
+ burger.className = 'lk-button lk-burger-button';
+ burger.title = 'Меню';
+ burger.innerHTML = 'menu ';
+ burger.addEventListener('click', () => window.dispatchEvent(new CustomEvent('livekit-burger-click')));
+ bar.insertBefore(burger, bar.firstChild);
+ }
+ if (!bar.querySelector('.lk-custom-exit-button')) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'lk-button lk-custom-exit-button';
+ btn.setAttribute('data-lk-source', 'disconnect');
+ btn.title = 'Выйти';
+ btn.innerHTML = 'logout ';
+ btn.addEventListener('click', () => window.dispatchEvent(new CustomEvent('livekit-exit-click')));
+ bar.appendChild(btn);
+ }
+ }, 800);
+ return () => clearTimeout(id);
+ }, [showBoard]);
+
+ useEffect(() => {
+ const handler = () => setShowNavMenu((v) => !v);
+ window.addEventListener('livekit-burger-click', handler);
+ return () => window.removeEventListener('livekit-burger-click', handler);
+ }, []);
+
+ useEffect(() => {
+ const handler = () => {
+ if (user?.role === 'mentor') {
+ setShowExitModal(true);
+ } else {
+ room.disconnect();
+ }
+ };
+ window.addEventListener('livekit-exit-click', handler);
+ return () => window.removeEventListener('livekit-exit-click', handler);
+ }, [user?.role, room]);
+
+ // Сохраняем в localStorage при переключении микрофона/камеры + логи для отладки
+ useEffect(() => {
+ const lp = room.localParticipant;
+ if (!lp) return;
+ const log = (source: string, audio: boolean, video: boolean) => {
+ console.log(`[LiveKit аудио/видео] ${source}: audio=${audio}, video=${video} → localStorage.${LS_AUDIO_ENABLED}=${audio}, ${LS_VIDEO_ENABLED}=${video}`);
+ };
+ const saveFromState = (source: string) => {
+ const audio = lp.isMicrophoneEnabled;
+ const video = lp.isCameraEnabled;
+ try {
+ localStorage.setItem(LS_AUDIO_ENABLED, String(audio));
+ localStorage.setItem(LS_VIDEO_ENABLED, String(video));
+ log(source, audio, video);
+ } catch (e) {
+ console.error('[LiveKit аудио/видео] Ошибка сохранения:', e);
+ }
+ };
+ const onTrackMuted = (pub: { source?: Track.Source }, participant: { sid?: string }) => {
+ if (participant?.sid !== lp.sid) return;
+ try {
+ if (pub?.source === Track.Source.Microphone) {
+ localStorage.setItem(LS_AUDIO_ENABLED, 'false');
+ log('TrackMuted(Microphone)', false, lp.isCameraEnabled);
+ } else if (pub?.source === Track.Source.Camera) {
+ localStorage.setItem(LS_VIDEO_ENABLED, 'false');
+ log('TrackMuted(Camera)', lp.isMicrophoneEnabled, false);
+ } else {
+ saveFromState('TrackMuted(?)');
+ }
+ } catch (e) {
+ console.error('[LiveKit аудио/видео] TrackMuted ошибка:', e);
+ }
+ };
+ const onTrackUnmuted = (pub: { source?: Track.Source }, participant: { sid?: string }) => {
+ if (participant?.sid !== lp.sid) return;
+ try {
+ if (pub?.source === Track.Source.Microphone) {
+ localStorage.setItem(LS_AUDIO_ENABLED, 'true');
+ log('TrackUnmuted(Microphone)', true, lp.isCameraEnabled);
+ } else if (pub?.source === Track.Source.Camera) {
+ localStorage.setItem(LS_VIDEO_ENABLED, 'true');
+ log('TrackUnmuted(Camera)', lp.isMicrophoneEnabled, true);
+ } else {
+ saveFromState('TrackUnmuted(?)');
+ }
+ } catch (e) {
+ console.error('[LiveKit аудио/видео] TrackUnmuted ошибка:', e);
+ }
+ };
+ const onLocalTrackPublished = () => saveFromState('LocalTrackPublished');
+ room.on(RoomEvent.TrackMuted, onTrackMuted);
+ room.on(RoomEvent.TrackUnmuted, onTrackUnmuted);
+ room.on(RoomEvent.LocalTrackPublished, onLocalTrackPublished);
+ saveFromState('init');
+ return () => {
+ room.off(RoomEvent.TrackMuted, onTrackMuted);
+ room.off(RoomEvent.TrackUnmuted, onTrackUnmuted);
+ room.off(RoomEvent.LocalTrackPublished, onLocalTrackPublished);
+ /* Не сохраняем при unmount/Disconnected — треки уже удалены, lp.isMicrophoneEnabled вернёт false */
+ };
+ }, [room]);
+
+ return (
+
+
+
+
+ {/* Boundary только вокруг VideoConference — при reset доска не перемонтируется */}
+
+
+ message} />
+
+
+
+
+ {/* Сайдбар, PiP, бургер и навигация в Portal */}
+ {typeof document !== 'undefined' && createPortal(
+ <>
+ {showBoard &&
}
+ {/* Бургер при открытой доске — панель скрыта, показываем свой бургер */}
+ {showBoard && (
+
setShowNavMenu((v) => !v)}
+ style={{
+ position: 'fixed',
+ left: 16,
+ bottom: 64,
+ width: 48,
+ height: 48,
+ borderRadius: 12,
+ border: 'none',
+ background: 'rgba(0,0,0,0.7)',
+ color: '#fff',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ zIndex: 10002,
+ boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
+ }}
+ title="Меню"
+ >
+ menu
+
+ )}
+ {/* Выдвижная навигация — BottomNavigationBar слева, 3 колонки */}
+ {showNavMenu && (
+ <>
+
setShowNavMenu(false)}
+ style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 10003, backdropFilter: 'blur(4px)' }}
+ />
+
+ setShowNavMenu(false)}
+ />
+
+ >
+ )}
+
+ setShowBoard(false)}
+ style={{
+ width: 48,
+ height: 48,
+ borderRadius: 12,
+ border: 'none',
+ background: !showBoard ? 'var(--md-sys-color-primary)' : 'rgba(255,255,255,0.2)',
+ color: '#fff',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ }}
+ title="Камера"
+ >
+ videocam
+
+ (boardId && !boardLoading ? setShowBoard(true) : undefined)}
+ disabled={!boardId || boardLoading}
+ aria-disabled={!boardId || boardLoading}
+ style={{
+ width: 48,
+ height: 48,
+ borderRadius: 12,
+ border: 'none',
+ background: showBoard ? 'var(--md-sys-color-primary)' : 'rgba(255,255,255,0.2)',
+ color: '#fff',
+ cursor: boardId && !boardLoading ? 'pointer' : 'not-allowed',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ opacity: !boardId || boardLoading ? 0.6 : 1,
+ pointerEvents: !boardId || boardLoading ? 'none' : 'auto',
+ }}
+ title={
+ boardLoading
+ ? 'Загрузка доски...'
+ : !boardId
+ ? 'Доска недоступна (проверяем каждые 10 с)'
+ : 'Доска'
+ }
+ >
+ {boardLoading ? (
+
+ ) : (
+ draw
+ )}
+
+ {lessonId != null && (
+ setShowPlatformChat((v) => !v)}
+ style={{
+ width: 48,
+ height: 48,
+ borderRadius: 12,
+ border: 'none',
+ background: showPlatformChat ? 'var(--md-sys-color-primary)' : 'rgba(255,255,255,0.2)',
+ color: '#fff',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ }}
+ title="Чат"
+ >
+ chat
+
+ )}
+
+ >,
+ document.body
+ )}
+
+ {/* Панель чата сервиса (не LiveKit) */}
+ {showPlatformChat && (
+
+ {lessonChatLoading ? (
+
+ progress_activity
+
+ ) : (
+
setShowPlatformChat(false)}
+ />
+ )}
+
+ )}
+
+
setShowExitModal(false)}
+ onExit={() => room.disconnect()}
+ />
+
+ );
+}
+
+const SS_PREJOIN_DONE = 'livekit_prejoin_done';
+
+/** PreJoin — показываем при первом заходе. После входа в комнату или перезагрузки — пропускаем (sessionStorage). */
+function getInitialShowPreJoin(searchParams: URLSearchParams): boolean {
+ try {
+ if (typeof window !== 'undefined' && sessionStorage.getItem(SS_PREJOIN_DONE) === '1') {
+ return false;
+ }
+ return searchParams.get('skip_prejoin') !== '1';
+ } catch {
+ return true;
+ }
+}
+
+function getSavedAudioVideo(): { audio: boolean; video: boolean } {
+ try {
+ const rawA = localStorage.getItem(LS_AUDIO_ENABLED);
+ const rawV = localStorage.getItem(LS_VIDEO_ENABLED);
+ const audio = rawA !== 'false';
+ const video = rawV !== 'false';
+ console.log(`[LiveKit аудио/видео] Чтение из localStorage: ${LS_AUDIO_ENABLED}=${rawA} → audio=${audio}, ${LS_VIDEO_ENABLED}=${rawV} → video=${video}`);
+ return { audio, video };
+ } catch (e) {
+ console.error('[LiveKit аудио/видео] Ошибка чтения localStorage:', e);
+ return { audio: true, video: true };
+ }
+}
+
+export default function LiveKitRoomContent() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const accessToken = searchParams.get('token');
+ const lessonIdParam = searchParams.get('lesson_id');
+ const { user } = useAuth();
+
+ const [serverUrl, setServerUrl] = useState
('');
+ const [showPreJoin, setShowPreJoin] = useState(() => getInitialShowPreJoin(searchParams));
+ const [audioEnabled, setAudioEnabled] = useState(true);
+ const [videoEnabled, setVideoEnabled] = useState(true);
+ const [avReady, setAvReady] = useState(false);
+
+ // Подтягиваем из localStorage после монтирования (SSR не имеет доступа к LS)
+ useEffect(() => {
+ const saved = getSavedAudioVideo();
+ console.log('[LiveKit аудио/видео] useEffect: загрузили из localStorage, устанавливаем state:', saved);
+ setAudioEnabled(saved.audio);
+ setVideoEnabled(saved.video);
+ setAvReady(true);
+ }, []);
+ const [lessonCompleted, setLessonCompleted] = useState(false);
+ const [effectiveLessonId, setEffectiveLessonId] = useState(null);
+ const [boardId, setBoardId] = useState(null);
+ const [boardLoading, setBoardLoading] = useState(false);
+ const boardPollRef = useRef | null>(null);
+ const [isMentor, setIsMentor] = useState(false);
+ const [showBoard, setShowBoard] = useState(false);
+ const [userDisplayName, setUserDisplayName] = useState('Пользователь');
+
+ // Доска и is_mentor из metadata LiveKit токена или getOrCreateLessonBoard; при отсутствии доски — опрос раз в 10 с
+ useEffect(() => {
+ const meta = getTokenMetadata(accessToken);
+ if (meta.is_mentor === true) setIsMentor(true);
+ if (meta.board_id) {
+ setBoardId(meta.board_id);
+ setBoardLoading(false);
+ return;
+ }
+ if (!effectiveLessonId) {
+ setBoardLoading(false);
+ return;
+ }
+
+ let cancelled = false;
+ const stopPoll = () => {
+ if (boardPollRef.current) {
+ clearInterval(boardPollRef.current);
+ boardPollRef.current = null;
+ }
+ };
+ setBoardLoading(true);
+ getOrCreateLessonBoard(effectiveLessonId)
+ .then((b) => {
+ if (!cancelled) {
+ setBoardId(b.board_id);
+ stopPoll();
+ }
+ })
+ .catch(() => {})
+ .finally(() => { if (!cancelled) setBoardLoading(false); });
+
+ boardPollRef.current = setInterval(() => {
+ if (cancelled) return;
+ getOrCreateLessonBoard(effectiveLessonId)
+ .then((b) => {
+ if (!cancelled) {
+ setBoardId(b.board_id);
+ stopPoll();
+ }
+ })
+ .catch(() => {});
+ }, 10_000);
+
+ return () => {
+ cancelled = true;
+ stopPoll();
+ };
+ }, [accessToken, effectiveLessonId]);
+
+ // Fallback: is_mentor из API урока (lesson.mentor.id === user.id), если токен старый или без metadata
+ useEffect(() => {
+ if (!effectiveLessonId || !user) return;
+ const userId = user.id ?? (user as { pk?: number }).pk;
+ if (!userId) return;
+ getLesson(String(effectiveLessonId))
+ .then((lesson) => {
+ const mentorId = typeof lesson.mentor === 'object' && lesson.mentor
+ ? Number(lesson.mentor.id)
+ : Number(lesson.mentor);
+ if (mentorId && Number(userId) === mentorId) {
+ setIsMentor(true);
+ }
+ })
+ .catch(() => {});
+ }, [effectiveLessonId, user]);
+
+ useEffect(() => {
+ const token = localStorage.getItem('access_token');
+ if (token) {
+ const base = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8123/api';
+ fetch(`${base}/profile/me/`, { headers: { Authorization: `Bearer ${token}` } })
+ .then((r) => r.json())
+ .then((u: { first_name?: string; last_name?: string; email?: string }) => {
+ const raw = `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.email || 'Пользователь';
+ const name = /%[0-9A-Fa-f]{2}/.test(raw) ? decodeURIComponent(raw) : raw;
+ setUserDisplayName(name);
+ })
+ .catch(() => {});
+ }
+ }, []);
+
+ useEffect(() => {
+ const id = lessonIdParam ? parseInt(lessonIdParam, 10) : null;
+ if (id && !isNaN(id)) {
+ setEffectiveLessonId(id);
+ try {
+ sessionStorage.setItem('livekit_lesson_id', String(id));
+ } catch {}
+ } else {
+ try {
+ const stored = sessionStorage.getItem('livekit_lesson_id');
+ if (stored) setEffectiveLessonId(parseInt(stored, 10));
+ } catch {}
+ }
+ }, [lessonIdParam]);
+
+ useEffect(() => {
+ const load = async () => {
+ if (lessonIdParam) {
+ try {
+ const l = await getLesson(lessonIdParam);
+ if (l.status === 'completed') {
+ const now = new Date();
+ const end = l.completed_at ? new Date(l.completed_at) : new Date(l.end_time);
+ if (now > new Date(end.getTime() + 10 * 60000)) setLessonCompleted(true);
+ }
+ } catch {}
+ }
+ const token = localStorage.getItem('access_token');
+ if (token) {
+ try {
+ const base = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8123/api';
+ const res = await fetch(`${base}/video/livekit/config/`, { headers: { Authorization: `Bearer ${token}` } });
+ if (res.ok) {
+ const config = await res.json();
+ setServerUrl(config.server_url || 'ws://127.0.0.1:7880');
+ }
+ } catch {}
+ }
+ };
+ load();
+ }, [lessonIdParam]);
+
+ if (lessonCompleted) {
+ return (
+
+
+
Урок завершён. Видеоконференция недоступна.
+
router.push('/dashboard')} style={{ padding: '12px 24px', borderRadius: 12, border: 'none', background: 'var(--md-sys-color-primary)', color: 'var(--md-sys-color-on-primary)', cursor: 'pointer' }}>
+ На главную
+
+
+
+ );
+ }
+
+ if (!accessToken || !serverUrl) {
+ return (
+
+ );
+ }
+
+ if (showPreJoin) {
+ return (
+ {
+ try {
+ sessionStorage.setItem(SS_PREJOIN_DONE, '1');
+ } catch {}
+ setAudioEnabled(audio);
+ setVideoEnabled(video);
+ setShowPreJoin(false);
+ }}
+ onCancel={() => router.push('/dashboard')}
+ />
+ );
+ }
+
+ if (!avReady) {
+ return (
+
+ );
+ }
+
+ console.log('[LiveKit аудио/видео] Передача в LiveKitRoom:', { audio: audioEnabled, video: videoEnabled });
+ return (
+ <>
+ {/* Доска — сиблинг LiveKitRoom, iframe создаётся один раз, не перемонтируется */}
+ {boardId && (
+
+
+
+ )}
+ router.push('/dashboard')}
+ style={{ height: '100vh' }}
+ data-lk-theme="default"
+ options={{
+ adaptiveStream: true,
+ dynacast: true,
+ // Захват до 2K (1440p), при отсутствии поддержки браузер даст меньше
+ videoCaptureDefaults: {
+ resolution: PRESET_2K.resolution,
+ frameRate: 30,
+ },
+ publishDefaults: {
+ simulcast: true,
+ // Камера: до 2K, 6 Mbps — вариативность через слои 1080p, 720p, 360p
+ videoEncoding: PRESET_2K.encoding,
+ // Два слоя поверх основного: 720p и 360p для вариативности при слабом канале
+ videoSimulcastLayers: [VideoPresets.h720, VideoPresets.h360],
+ // Демонстрация экрана: 2K, 6 Mbps, те же два слоя для адаптации
+ screenShareEncoding: { maxBitrate: 6_000_000, maxFramerate: 30 },
+ screenShareSimulcastLayers: [VideoPresets.h720, VideoPresets.h360],
+ degradationPreference: 'maintain-resolution',
+ },
+ audioCaptureDefaults: {
+ noiseSuppression: true,
+ echoCancellation: true,
+ },
+ }}
+ >
+
+
+
+
+ >
+ );
+}
diff --git a/front_material/components/navigation/BottomNavigationBar.tsx b/front_material/components/navigation/BottomNavigationBar.tsx
index 2485b8d..88ff4cc 100644
--- a/front_material/components/navigation/BottomNavigationBar.tsx
+++ b/front_material/components/navigation/BottomNavigationBar.tsx
@@ -25,6 +25,8 @@ interface BottomNavigationBarProps {
userRole?: string;
user?: User | null;
navBadges?: NavBadges | null;
+ /** Слот для кнопки уведомлений (на мобильном — 4-й элемент в первом ряду). */
+ notificationsSlot?: React.ReactNode;
/** Выдвижная панель справа (3 колонки). При клике по пункту вызывается onClose. */
slideout?: boolean;
onClose?: () => void;
@@ -57,7 +59,7 @@ function getBadgeCount(item: NavigationItem, navBadges: NavBadges | null | undef
}
}
-export function BottomNavigationBar({ userRole, user, navBadges, slideout, onClose }: BottomNavigationBarProps) {
+export function BottomNavigationBar({ userRole, user, navBadges, notificationsSlot, slideout, onClose }: BottomNavigationBarProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -113,8 +115,8 @@ export function BottomNavigationBar({ userRole, user, navBadges, slideout, onClo
return common;
}, [userRole]);
- const firstRowItems = navigationItems.slice(0, 5);
- const restItems = navigationItems.slice(5);
+ const firstRowItems = navigationItems.slice(0, notificationsSlot ? 3 : 5);
+ const restItems = navigationItems.slice(notificationsSlot ? 3 : 5);
const hasMore = restItems.length > 0;
// Подсветка активного таба по текущему URL
@@ -270,22 +272,32 @@ export function BottomNavigationBar({ userRole, user, navBadges, slideout, onClo
{userRole === 'parent' &&
}
{userRole === 'parent' ? (
-
+
{firstRowItems.map((item, i) => renderButton(item, i))}
+ {notificationsSlot}
) : (
- firstRowItems.map((item, i) => renderButton(item, i))
+ <>
+ {firstRowItems.map((item, i) => renderButton(item, i))}
+ {notificationsSlot}
+ >
)}
- {restItems.map((item, i) => renderButton(item, 5 + i))}
+ {restItems.map((item, i) => renderButton(item, (notificationsSlot ? 3 : 5) + i))}
diff --git a/front_material/components/notifications/NotificationBell.tsx b/front_material/components/notifications/NotificationBell.tsx
index 16ab1f9..a890c00 100644
--- a/front_material/components/notifications/NotificationBell.tsx
+++ b/front_material/components/notifications/NotificationBell.tsx
@@ -91,7 +91,7 @@ function NotificationItem({
const SCROLL_LOAD_MORE_THRESHOLD = 80;
-export function NotificationBell() {
+export function NotificationBell({ embedded }: { embedded?: boolean }) {
const refreshNavBadges = useNavBadgesRefresh();
const {
list,
@@ -164,16 +164,26 @@ export function NotificationBell() {
{/* Панель уведомлений — выезжает справа от колокольчика */}
{open && (
@@ -182,8 +192,9 @@ export function NotificationBell() {
className="notification-panel-enter-active"
style={{
position: 'absolute',
- right: 52,
- bottom: 0,
+ ...(embedded
+ ? { bottom: '100%', marginBottom: 8, left: '50%', transform: 'translateX(-50%)' }
+ : { right: 52, bottom: 0 }),
width: PANEL_WIDTH,
maxHeight: PANEL_MAX_HEIGHT,
backgroundColor: 'var(--md-sys-color-surface)',
@@ -295,56 +306,97 @@ export function NotificationBell() {
)}
- {/* Кнопка-колокольчик */}
- 0 ? `Уведомления: ${unreadCount} непрочитанных` : 'Уведомления'}
- onClick={() => setOpen((o) => !o)}
- style={{
- position: 'relative',
- width: 48,
- height: 48,
- borderRadius: '50%',
- border: 'none',
- background: 'var(--md-sys-color-primary-container)',
- color: 'var(--md-sys-color-primary)',
- cursor: 'pointer',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- boxShadow: 'var(--ios-shadow-soft)',
- }}
- >
-
- notifications
-
- {unreadCount > 0 && (
-
- {unreadCount}
+ {/* Кнопка-колокольчик: в меню — как пункт навигации, иначе — круглая */}
+ {embedded ? (
+ 0 ? `Уведомления: ${unreadCount} непрочитанных` : 'Уведомления'}
+ onClick={() => setOpen((o) => !o)}
+ >
+
+
+ notifications
+
+ {unreadCount > 0 && (
+
+ {unreadCount > 99 ? '99+' : unreadCount}
+
+ )}
- )}
-
+ Уведомления
+
+ ) : (
+ 0 ? `Уведомления: ${unreadCount} непрочитанных` : 'Уведомления'}
+ onClick={() => setOpen((o) => !o)}
+ style={{
+ position: 'relative',
+ width: 48,
+ height: 48,
+ borderRadius: '50%',
+ border: 'none',
+ background: 'var(--md-sys-color-primary-container)',
+ color: 'var(--md-sys-color-primary)',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ boxShadow: 'var(--ios-shadow-soft)',
+ }}
+ >
+
+ notifications
+
+ {unreadCount > 0 && (
+
+ {unreadCount}
+
+ )}
+
+ )}
>
);
diff --git a/front_material/components/profile/ProfilePaymentTab.tsx b/front_material/components/profile/ProfilePaymentTab.tsx
index db95c1d..37fbabb 100644
--- a/front_material/components/profile/ProfilePaymentTab.tsx
+++ b/front_material/components/profile/ProfilePaymentTab.tsx
@@ -188,15 +188,21 @@ export function ProfilePaymentTab() {
{free ? (
-
handleActivateFree(plan)}
- disabled={!!activatingPlanId}
- style={{ cursor: activatingPlanId ? 'wait' : 'pointer' }}
- >
- {activatingPlanId === plan.id ? 'Активация...' : 'Активировать'}
-
+ subscription ? (
+
+ Подписка уже активирована
+
+ ) : (
+
handleActivateFree(plan)}
+ disabled={!!activatingPlanId}
+ style={{ cursor: activatingPlanId ? 'wait' : 'pointer' }}
+ >
+ {activatingPlanId === plan.id ? 'Активация...' : 'Активировать'}
+
+ )
) : (
Подробнее и оплатить
diff --git a/front_material/components/referrals/ReferralsPageContent.tsx b/front_material/components/referrals/ReferralsPageContent.tsx
index 6d294f6..737cf12 100644
--- a/front_material/components/referrals/ReferralsPageContent.tsx
+++ b/front_material/components/referrals/ReferralsPageContent.tsx
@@ -1,17 +1,26 @@
'use client';
import { useState, useEffect } from 'react';
-import { getReferralProfile, getReferralStats } from '@/api/referrals';
+import { getReferralProfile, getReferralStats, getMyReferrals, type MyReferralItem } from '@/api/referrals';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { useToast } from '@/contexts/ToastContext';
const formatCurrency = (v: number) =>
new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 }).format(v);
+function formatDate(s: string) {
+ try {
+ return new Date(s).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
+ } catch {
+ return s;
+ }
+}
+
export function ReferralsPageContent() {
const { showToast } = useToast();
const [profile, setProfile] = useState
(null);
const [stats, setStats] = useState(null);
+ const [referralsList, setReferralsList] = useState<{ direct: MyReferralItem[]; indirect: MyReferralItem[] } | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
@@ -19,6 +28,7 @@ export function ReferralsPageContent() {
Promise.all([
getReferralProfile().then(setProfile),
getReferralStats().then(setStats),
+ getMyReferrals().then(setReferralsList).catch(() => setReferralsList({ direct: [], indirect: [] })),
])
.finally(() => setLoading(false));
}, []);
@@ -138,6 +148,58 @@ export function ReferralsPageContent() {
)}
+
+ {/* Список приглашённых рефералов */}
+ {referralsList && (referralsList.direct.length > 0 || referralsList.indirect.length > 0) && (
+
+
+ ПРИГЛАШЁННЫЕ
+
+
+ {referralsList.direct.length > 0 && (
+
+
+ Прямые рефералы ({referralsList.direct.length})
+
+
+ {referralsList.direct.map((r: MyReferralItem, i: number) => (
+
+ {r.email} — {r.level}, {r.total_points} баллов, зарегистрирован {formatDate(r.created_at)}
+
+ ))}
+
+
+ )}
+ {referralsList.indirect.length > 0 && (
+
+
+ Рефералы ваших рефералов ({referralsList.indirect.length})
+
+
+ {referralsList.indirect.map((r: MyReferralItem, i: number) => (
+
+ {r.email} — {r.level}, {r.total_points} баллов, зарегистрирован {formatDate(r.created_at)}
+
+ ))}
+
+
+ )}
+
+
+ )}
+ {referralsList && referralsList.direct.length === 0 && referralsList.indirect.length === 0 && (
+
+ Пока никого нет. Поделитесь реферальной ссылкой — когда кто-то зарегистрируется по ней, он появится здесь и вы получите уведомление.
+
+ )}
);
}
diff --git a/front_material/styles/globals.css b/front_material/styles/globals.css
index d40b965..7eb5c9c 100644
--- a/front_material/styles/globals.css
+++ b/front_material/styles/globals.css
@@ -169,6 +169,10 @@ body:has([data-no-nav]) {
padding-bottom: 0;
}
+body:has(.protected-layout-root) {
+ padding-bottom: 0;
+}
+
body > * {
position: relative;
z-index: 1;
@@ -270,10 +274,10 @@ img {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E");
}
-/* Кастомный нижний бар iOS 26 — первый ряд всегда, остальное по раскрытию */
+/* Кастомный нижний бар iOS 26 — первый ряд всегда, остальное по раскрытию. Ноутбук и выше: fixed, bottom 20px */
.ios26-bottom-nav-container {
position: fixed;
- bottom: 30px;
+ bottom: 20px;
left: 16px;
right: 16px;
z-index: 1000;
@@ -294,6 +298,70 @@ img {
padding-bottom: env(safe-area-inset-bottom, 0);
}
+/* Protected layout: контент скроллится сверху, снизу меню. На мобильном — меню в потоке; ноутбук+ — fixed */
+.protected-layout-root {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ height: 100vh;
+}
+
+.protected-layout-root .protected-main {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+}
+
+/* Ноутбук и выше (768px+): нижний бар fixed, bottom 20px, контенту отступ снизу */
+@media (min-width: 768px) {
+ .protected-layout-root .ios26-bottom-nav-container {
+ position: fixed;
+ bottom: 20px;
+ left: 16px;
+ right: 16px;
+ margin: 0 auto;
+ max-width: min(900px, 100%);
+ }
+
+ .protected-layout-root .protected-main {
+ padding-bottom: 88px;
+ }
+}
+
+/* Мобильный: меню в потоке, на всю ширину, прижато к низу */
+@media (max-width: 767px) {
+ .protected-layout-root .ios26-bottom-nav-container {
+ position: relative;
+ bottom: auto;
+ left: 0;
+ right: 0;
+ margin: 0;
+ max-width: 100%;
+ flex-shrink: 0;
+ border-radius: 0;
+ }
+
+ .protected-layout-root .ios26-bottom-nav {
+ border-radius: 0;
+ border-left: none;
+ border-right: none;
+ padding-bottom: calc(8px + env(safe-area-inset-bottom, 0));
+ }
+
+ /* Все строки навигации по 4 элемента на мобильном */
+ .protected-layout-root .ios26-bottom-nav-first-row {
+ grid-template-columns: repeat(4, 1fr);
+ }
+
+ .protected-layout-root .ios26-bottom-nav-first-row-buttons {
+ grid-template-columns: repeat(4, 1fr);
+ }
+
+ .protected-layout-root .ios26-bottom-nav-rest {
+ grid-template-columns: repeat(4, 1fr);
+ }
+}
+
.ios26-bottom-nav-expand-trigger {
all: unset;
cursor: pointer;
@@ -353,6 +421,15 @@ img {
grid-template-columns: unset;
}
+/* Первый ряд: 4 колонки, когда есть слот уведомлений (мобильное меню) */
+.ios26-bottom-nav-first-row--with-notifications {
+ grid-template-columns: repeat(4, 1fr);
+}
+
+.ios26-bottom-nav-first-row-buttons--with-notifications {
+ grid-template-columns: repeat(4, 1fr);
+}
+
.ios26-bottom-nav-first-row-buttons {
flex: 1;
min-width: 0;
@@ -1306,6 +1383,308 @@ img {
font-size: 15px !important;
}
+/* ========== Адаптив: планшет и телефон (dashboard, chat, materials, homework, my-progress, request-mentor, profile, livekit, students, feedback, analytics, payment, referrals) ========== */
+
+/* Планшет: 768px — 1024px */
+@media (max-width: 1024px) {
+ .protected-main {
+ padding-left: 12px !important;
+ padding-right: 12px !important;
+ padding-top: 12px !important;
+ padding-bottom: 12px !important;
+ }
+ .protected-main[data-full-width] {
+ padding: 12px !important;
+ }
+ .ios26-dashboard {
+ padding: 12px;
+ }
+ .ios26-dashboard.ios26-dashboard-grid {
+ gap: 12px;
+ }
+ .ios26-stat-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .ios26-analytics-chart-row {
+ flex-direction: column;
+ }
+ .ios26-analytics-two-cols {
+ flex-direction: column;
+ }
+ .ios26-dashboard-analytics .ios26-stat-grid--aside {
+ width: 100%;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+ .ios26-dashboard-analytics .ios26-stat-grid--aside .ios26-stat-tile {
+ flex: 1;
+ min-width: 140px;
+ }
+ .ios26-bottom-nav-container {
+ left: 8px;
+ right: 8px;
+ }
+ .ios26-panel {
+ padding: 12px;
+ }
+}
+
+/* Телефон: до 767px */
+@media (max-width: 767px) {
+ body {
+ padding-bottom: calc(50px + 52px);
+ }
+ .protected-main {
+ padding-left: 10px !important;
+ padding-right: 10px !important;
+ padding-top: 10px !important;
+ padding-bottom: 10px !important;
+ }
+ .protected-main[data-full-width] {
+ padding: 10px !important;
+ }
+ .ios26-dashboard {
+ padding: 10px;
+ }
+ .ios26-dashboard.ios26-dashboard-grid {
+ grid-template-columns: 1fr;
+ gap: 10px;
+ }
+ .ios26-stat-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ }
+ .ios26-stat-tile {
+ padding: 10px 12px;
+ }
+ .ios26-stat-label,
+ .ios26-stat-value {
+ font-size: 15px;
+ }
+ .ios26-section-title {
+ font-size: 18px;
+ margin-bottom: 12px;
+ }
+ .ios26-bottom-nav-container {
+ left: 6px;
+ right: 6px;
+ bottom: 20px;
+ }
+ .ios26-bottom-nav {
+ padding: 6px 8px;
+ }
+ .ios26-bottom-nav-first-row {
+ gap: 2px 4px;
+ }
+ .ios26-bottom-nav-rest--expanded {
+ max-height: 200px;
+ }
+ .ios26-panel {
+ padding: 10px;
+ border-radius: var(--ios26-radius-sm);
+ }
+ .ios26-dashboard-analytics .ios26-analytics-chart,
+ .ios26-dashboard-analytics .ios26-analytics-chart-placeholder {
+ min-height: 60vh;
+ }
+ .ios26-analytics-nav .ios26-analytics-nav-btn:first-child {
+ left: 8px;
+ }
+ .ios26-analytics-nav .ios26-analytics-nav-btn:last-child {
+ right: 8px;
+ }
+ .ios26-feedback-page {
+ padding: 10px;
+ }
+ .ios26-feedback-kanban {
+ gap: 12px;
+ }
+ .ios26-list-row {
+ font-size: 15px;
+ padding: 10px 0;
+ }
+
+ /* Страница Студенты: сетка карточек и боковая панель */
+ .page-students {
+ padding: 16px !important;
+ }
+ .students-cards-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ align-items: start;
+ }
+ .students-side-panel {
+ width: 100% !important;
+ max-width: 100vw;
+ right: 0 !important;
+ border-top-left-radius: 0 !important;
+ border-bottom-left-radius: 0 !important;
+ }
+
+ /* Страница Профиль: одна колонка, поля в 1 колонку */
+ .page-profile {
+ padding: 16px !important;
+ }
+ .page-profile-grid {
+ grid-template-columns: 1fr !important;
+ }
+ .page-profile-fields {
+ grid-template-columns: 1fr !important;
+ }
+
+ /* Аналитика: уже есть .ios26-analytics-chart-row column, .ios26-analytics-two-cols column */
+}
+
+/* Маленький телефон: до 480px */
+@media (max-width: 480px) {
+ .protected-main {
+ padding-left: 8px !important;
+ padding-right: 8px !important;
+ padding-top: 8px !important;
+ padding-bottom: 8px !important;
+ }
+ .protected-main[data-full-width] {
+ padding: 8px !important;
+ }
+ .page-students {
+ padding: 12px !important;
+ }
+ .students-cards-grid {
+ grid-template-columns: 1fr;
+ gap: 10px;
+ }
+ .page-profile {
+ padding: 12px !important;
+ }
+ .ios26-dashboard {
+ padding: 8px;
+ }
+ .ios26-dashboard.ios26-dashboard-grid {
+ gap: 8px;
+ }
+ .ios26-stat-grid {
+ grid-template-columns: 1fr;
+ }
+ .ios26-stat-tile {
+ padding: 12px;
+ }
+ .ios26-bottom-nav-container {
+ left: 4px;
+ right: 4px;
+ bottom: 16px;
+ }
+ .ios26-bottom-nav-first-row {
+ grid-template-columns: repeat(5, 1fr);
+ }
+ .ios26-bottom-nav-button {
+ min-width: 0;
+ }
+ .ios26-bottom-nav-label {
+ font-size: 10px;
+ }
+ .ios26-panel {
+ padding: 8px;
+ }
+ .ios26-section-title {
+ font-size: 17px;
+ }
+}
+
+/* Страница Студенты: сетка карточек (десктоп) */
+.students-cards-grid {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 16px;
+ align-items: start;
+}
+
+/* Страница календаря (расписание): планшет — правая часть сверху, календарь снизу; телефон — только правая часть */
+@media (max-width: 1024px) {
+ .ios26-schedule-layout {
+ grid-template-columns: 1fr !important;
+ grid-template-rows: auto 1fr;
+ }
+ .ios26-schedule-calendar-wrap {
+ order: 2;
+ }
+ .ios26-schedule-right-wrap {
+ order: 1;
+ }
+}
+@media (max-width: 767px) {
+ .ios26-schedule-calendar-wrap {
+ display: none !important;
+ }
+ .ios26-schedule-layout {
+ grid-template-rows: 1fr !important;
+ min-height: auto !important;
+ }
+}
+
+/* Chat: список + окно чата — на планшете и телефоне одна колонка, список сверху */
+@media (max-width: 900px) {
+ .ios26-chat-page {
+ padding: 10px !important;
+ }
+ .ios26-chat-layout {
+ grid-template-columns: 1fr !important;
+ grid-template-rows: auto 1fr;
+ height: calc(100vh - 120px) !important;
+ max-height: none !important;
+ }
+ .ios26-chat-layout > div:first-of-type {
+ max-height: 38vh;
+ min-height: 180px;
+ overflow: auto;
+ }
+}
+@media (max-width: 480px) {
+ .ios26-chat-page {
+ padding: 8px !important;
+ }
+ .ios26-chat-layout {
+ height: calc(100vh - 100px) !important;
+ }
+ .ios26-chat-layout > div:first-of-type {
+ max-height: 35vh;
+ min-height: 160px;
+ }
+}
+
+/* Materials / Homework / Students / Request-mentor / Profile: карточки и контент */
+@media (max-width: 767px) {
+ .ios26-payment-tab,
+ .ios26-plan-card-grid {
+ gap: 10px;
+ }
+ .ios26-plan-card {
+ padding: 12px;
+ }
+ .protected-main md-elevated-card {
+ padding: 20px !important;
+ border-radius: 16px !important;
+ }
+}
+
+@media (max-width: 480px) {
+ .protected-main md-elevated-card {
+ padding: 14px !important;
+ border-radius: 14px !important;
+ }
+}
+
+/* LiveKit: полноэкранный контейнер на мобильных */
+@media (max-width: 767px) {
+ [data-lk-theme] {
+ font-size: 14px;
+ }
+ .protected-main[data-no-nav] {
+ padding: 0 !important;
+ }
+}
+
/* Flip-карточка эффект */
.flip-card {
position: relative;
diff --git a/front_material/styles/livekit-theme.css b/front_material/styles/livekit-theme.css
index 796a9a2..334162f 100644
--- a/front_material/styles/livekit-theme.css
+++ b/front_material/styles/livekit-theme.css
@@ -1,429 +1,429 @@
-/**
- * Кастомизация LiveKit через CSS переменные.
- * Все стили и скрипты LiveKit отдаются с нашего сервера (бандл + этот файл).
- */
-
-@keyframes lk-spin {
- to { transform: rotate(360deg); }
-}
-
-:root {
- /* Цвета фона */
- --lk-bg: #1a1a1a;
- --lk-bg2: #2a2a2a;
- --lk-bg3: #3a3a3a;
-
- /* Цвета текста */
- --lk-fg: #ffffff;
- --lk-fg2: rgba(255, 255, 255, 0.7);
-
- /* Основные цвета */
- --lk-control-bg: var(--md-sys-color-primary);
- --lk-control-hover-bg: var(--md-sys-color-primary-container);
- --lk-button-bg: rgba(255, 255, 255, 0.15);
- --lk-button-hover-bg: rgba(255, 255, 255, 0.25);
-
- /* Границы */
- --lk-border-color: rgba(255, 255, 255, 0.1);
- --lk-border-radius: 12px;
-
- /* Фокус */
- --lk-focus-ring: var(--md-sys-color-primary);
-
- /* Ошибки */
- --lk-danger: var(--md-sys-color-error);
-
- /* Размеры */
- --lk-control-bar-height: 80px;
- --lk-participant-tile-gap: 12px;
-}
-
-/* Панель управления — без ограничения по ширине */
-.lk-control-bar {
- background: rgba(0, 0, 0, 0.8) !important;
- backdrop-filter: blur(20px) !important;
- border-radius: 16px !important;
- padding: 12px 16px !important;
- margin: 16px !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
- max-width: none !important;
- width: auto !important;
-}
-
-.lk-control-bar .lk-button-group,
-.lk-control-bar .lk-button-group-menu {
- max-width: none !important;
- width: auto !important;
-}
-
-/* Кнопки управления — ширина по контенту, без жёсткого ограничения */
-.lk-control-bar .lk-button {
- min-width: 48px !important;
- width: auto !important;
- height: 48px !important;
- border-radius: 12px !important;
- transition: all 0.2s ease !important;
- padding-left: 12px !important;
- padding-right: 12px !important;
-}
-
-/* Русские подписи: скрываем английский текст, показываем свой */
-.lk-control-bar .lk-button[data-lk-source="microphone"],
-.lk-control-bar .lk-button[data-lk-source="camera"],
-.lk-control-bar .lk-button[data-lk-source="screen_share"],
-.lk-control-bar .lk-chat-toggle,
-.lk-control-bar .lk-disconnect-button,
-.lk-control-bar .lk-start-audio-button {
- font-size: 0 !important;
-}
-
-.lk-control-bar .lk-button[data-lk-source="microphone"] > svg,
-.lk-control-bar .lk-button[data-lk-source="camera"] > svg,
-.lk-control-bar .lk-button[data-lk-source="screen_share"] > svg,
-.lk-control-bar .lk-chat-toggle > svg,
-.lk-control-bar .lk-disconnect-button > svg {
- width: 16px !important;
- height: 16px !important;
- flex-shrink: 0 !important;
-}
-
-.lk-control-bar .lk-button[data-lk-source="microphone"]::after {
- content: "Микрофон";
- font-size: 1rem;
-}
-
-.lk-control-bar .lk-button[data-lk-source="camera"]::after {
- content: "Камера";
- font-size: 1rem;
-}
-
-.lk-control-bar .lk-button[data-lk-source="screen_share"]::after {
- content: "Поделиться экраном";
- font-size: 1rem;
-}
-
-.lk-control-bar .lk-button[data-lk-source="screen_share"][data-lk-enabled="true"]::after {
- content: "Остановить демонстрацию";
-}
-
-.lk-control-bar .lk-chat-toggle::after {
- content: "Чат";
- font-size: 1rem;
-}
-
-/* Кнопка бургер слева от микрофона — в панели LiveKit */
-.lk-burger-button {
- background: rgba(255, 255, 255, 0.15) !important;
- color: #fff !important;
-}
-
-/* Скрываем стандартную кнопку «Выйти» — используем свою внутри панели (модалка: Выйти / Выйти и завершить занятие) */
-.lk-control-bar .lk-disconnect-button {
- display: none !important;
-}
-.lk-control-bar .lk-disconnect-button::after {
- content: "Выйти";
- font-size: 1rem;
-}
-
-/* Наша кнопка «Выйти» — внутри панели, рядом с «Поделиться экраном» */
-.lk-control-bar .lk-custom-exit-button {
- font-size: 0 !important;
- background: var(--md-sys-color-error) !important;
- color: #fff !important;
- border: none;
- cursor: pointer;
- display: inline-flex !important;
- align-items: center;
- justify-content: center;
-}
-.lk-control-bar .lk-custom-exit-button::after {
- content: "Выйти";
- font-size: 1rem;
-}
-.lk-control-bar .lk-custom-exit-button > .material-symbols-outlined {
- color: #fff !important;
-}
-
-/* Скрываем кнопку «Начать видео» — у нас свой StartAudioOverlay */
-.lk-control-bar .lk-start-audio-button {
- display: none !important;
-}
-
-/* Кнопки без текста (только иконка) — минимальный размер */
-.lk-button {
- min-width: 48px !important;
- width: auto !important;
- height: 48px !important;
- border-radius: 12px !important;
- transition: all 0.2s ease !important;
-}
-
-.lk-button:hover {
- transform: scale(1.05);
-}
-
-.lk-button:active {
- transform: scale(0.95);
-}
-
-/* Активная кнопка */
-.lk-button[data-lk-enabled="true"] {
- background: var(--md-sys-color-primary) !important;
-}
-
-/* Кнопка отключения — белые иконка и текст */
-.lk-disconnect-button {
- background: var(--md-sys-color-error) !important;
- color: #fff !important;
-}
-.lk-disconnect-button > svg {
- color: #fff !important;
- fill: currentColor;
-}
-
-/* Плитки участников */
-.lk-participant-tile {
- border-radius: 12px !important;
- overflow: hidden !important;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
-}
-
-/* Плейсхолдер без камеры: скрываем дефолтную SVG, показываем аватар из API */
-.lk-participant-tile .lk-participant-placeholder svg {
- display: none !important;
-}
-
-/* Контейнер для аватара — нужен для container queries */
-.lk-participant-tile .lk-participant-placeholder {
- container-type: size;
-}
-
-.lk-participant-tile .lk-participant-placeholder img.lk-participant-avatar-img {
- /* Квадрат: меньшая сторона контейнера, максимум 400px */
- --avatar-size: min(min(80cqw, 80cqh), 400px);
- width: var(--avatar-size);
- height: var(--avatar-size);
- aspect-ratio: 1 / 1;
- object-fit: cover;
- object-position: center;
- border-radius: 50%;
-}
-
-/* Fallback для браузеров без container queries */
-@supports not (width: 1cqw) {
- .lk-participant-tile .lk-participant-placeholder img.lk-participant-avatar-img {
- width: 200px;
- height: 200px;
- }
-}
-
-/* Имя участника — белый текст (Камера, PiP) */
-.lk-participant-name {
- background: rgba(0, 0, 0, 0.7) !important;
- backdrop-filter: blur(10px) !important;
- border-radius: 8px !important;
- padding: 6px 12px !important;
- font-weight: 600 !important;
- color: #fff !important;
-}
-
-/* Чат LiveKit скрыт — используем чат сервиса (платформы) */
-.lk-video-conference .lk-chat {
- display: none !important;
-}
-
-.lk-control-bar .lk-chat-toggle {
- display: none !important;
-}
-
-/* Стили чата платформы оставляем для других страниц */
-.lk-chat {
- background: var(--md-sys-color-surface) !important;
- border-left: 1px solid var(--md-sys-color-outline) !important;
-}
-
-.lk-chat-entry {
- background: var(--md-sys-color-surface-container) !important;
- border-radius: 12px !important;
- padding: 12px !important;
- margin-bottom: 12px !important;
-}
-
-/* Сетка участников */
-.lk-grid-layout {
- gap: 12px !important;
- padding: 12px !important;
-}
-
-/* Меню выбора устройств — без ограничения по ширине */
-.lk-device-menu,
-.lk-media-device-select {
- max-width: none !important;
- width: max-content !important;
- min-width: 0 !important;
-}
-
-.lk-media-device-select {
- background: rgba(0, 0, 0, 0.95) !important;
- backdrop-filter: blur(20px) !important;
- border-radius: 12px !important;
- padding: 8px !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
- border: 1px solid rgba(255, 255, 255, 0.1) !important;
-}
-
-.lk-media-device-select button {
- border-radius: 8px !important;
- padding: 10px 14px !important;
- transition: background 0.2s ease !important;
- width: 100% !important;
- min-width: 0 !important;
- white-space: normal !important;
- text-align: left !important;
-}
-
-.lk-media-device-select button:hover {
- background: rgba(255, 255, 255, 0.1) !important;
-}
-
-.lk-media-device-select button[data-lk-active="true"] {
- background: var(--md-sys-color-primary) !important;
-}
-
-/* Индикатор говорящего */
-.lk-participant-tile[data-lk-speaking="true"] {
- box-shadow: 0 0 0 3px var(--md-sys-color-primary) !important;
-}
-
-/* Layout для 1-на-1: собеседник на весь экран, своя камера в углу */
-/* Карусель position:absolute выходит из flow — остаётся только основной контент. */
-/* Сетка 5fr 1fr: единственный grid-ребёнок (основное видео) получает 5fr (расширяется). */
-.lk-focus-layout {
- position: relative !important;
- grid-template-columns: 5fr 1fr !important;
-}
-
-/* Основное видео (собеседник) на весь экран */
-.lk-focus-layout .lk-focus-layout-wrapper {
- width: 100% !important;
- height: 100% !important;
-}
-
-.lk-focus-layout .lk-focus-layout-wrapper .lk-participant-tile {
- width: 100% !important;
- height: 100% !important;
- border-radius: 0 !important;
-}
-
-/* Демонстрация экрана — на весь экран только в режиме фокуса (после клика на раскрытие) */
-/* Структура: .lk-focus-layout-wrapper > .lk-focus-layout > .lk-participant-tile */
-.lk-focus-layout > .lk-participant-tile[data-lk-source="screen_share"] {
- position: absolute !important;
- width: 100% !important;
- height: 100% !important;
- top: 0 !important;
- left: 0 !important;
- border-radius: 0 !important;
- z-index: 50 !important;
-}
-
-/* Карусель с локальным видео (своя камера) */
-.lk-focus-layout .lk-carousel {
- position: absolute !important;
- bottom: 80px !important;
- right: 16px !important;
- width: 280px !important;
- height: auto !important;
- z-index: 100 !important;
- pointer-events: auto !important;
-}
-
-.lk-focus-layout .lk-carousel .lk-participant-tile {
- width: 280px !important;
- height: 158px !important;
- border-radius: 12px !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;
- border: 2px solid rgba(255, 255, 255, 0.2) !important;
-}
-
-/* Скрыть стрелки карусели (они не нужны для 1 участника) */
-.lk-focus-layout .lk-carousel button[aria-label*="Previous"],
-.lk-focus-layout .lk-carousel button[aria-label*="Next"] {
- display: none !important;
-}
-
-/* Если используется grid layout (фоллбэк) */
-.lk-grid-layout {
- position: relative !important;
-}
-
-/* Для 2 участников: первый на весь экран, второй в углу */
-.lk-grid-layout[data-lk-participants="2"] {
- display: block !important;
- position: relative !important;
-}
-
-.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:first-child {
- position: absolute !important;
- top: 0 !important;
- left: 0 !important;
- width: 100% !important;
- height: 100% !important;
- border-radius: 0 !important;
-}
-
-.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child {
- position: absolute !important;
- bottom: 80px !important;
- right: 16px !important;
- width: 280px !important;
- height: 158px !important;
- border-radius: 12px !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;
- border: 2px solid rgba(255, 255, 255, 0.2) !important;
- z-index: 100 !important;
-}
-
-/* Адаптивность */
-@media (max-width: 768px) {
- .lk-control-bar {
- border-radius: 12px !important;
- padding: 8px 12px !important;
- }
-
- .lk-control-bar .lk-button,
- .lk-button {
- min-width: 44px !important;
- width: auto !important;
- height: 44px !important;
- }
-
- /* Уменьшаем размер локального видео на мобильных */
- .lk-focus-layout .lk-carousel,
- .lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child {
- width: 160px !important;
- height: 90px !important;
- bottom: 70px !important;
- right: 12px !important;
- }
-}
-
-/* Качество отображения видео в контейнере LiveKit */
-.lk-participant-media-video {
- background: #000 !important;
-}
-/* Демонстрация экрана: contain чтобы не обрезать, чёткое отображение */
-.lk-participant-media-video[data-lk-source="screen_share"] {
- object-fit: contain !important;
- object-position: center !important;
- image-rendering: -webkit-optimize-contrast;
- image-rendering: crisp-edges;
-}
-/* Сетка: минимальная высота плиток для крупного видео */
-.lk-grid-layout {
- min-height: 0;
-}
-.lk-grid-layout .lk-participant-tile {
- min-height: 240px;
-}
+/**
+ * Кастомизация LiveKit через CSS переменные.
+ * Все стили и скрипты LiveKit отдаются с нашего сервера (бандл + этот файл).
+ */
+
+@keyframes lk-spin {
+ to { transform: rotate(360deg); }
+}
+
+:root {
+ /* Цвета фона */
+ --lk-bg: #1a1a1a;
+ --lk-bg2: #2a2a2a;
+ --lk-bg3: #3a3a3a;
+
+ /* Цвета текста */
+ --lk-fg: #ffffff;
+ --lk-fg2: rgba(255, 255, 255, 0.7);
+
+ /* Основные цвета */
+ --lk-control-bg: var(--md-sys-color-primary);
+ --lk-control-hover-bg: var(--md-sys-color-primary-container);
+ --lk-button-bg: rgba(255, 255, 255, 0.15);
+ --lk-button-hover-bg: rgba(255, 255, 255, 0.25);
+
+ /* Границы */
+ --lk-border-color: rgba(255, 255, 255, 0.1);
+ --lk-border-radius: 12px;
+
+ /* Фокус */
+ --lk-focus-ring: var(--md-sys-color-primary);
+
+ /* Ошибки */
+ --lk-danger: var(--md-sys-color-error);
+
+ /* Размеры */
+ --lk-control-bar-height: 80px;
+ --lk-participant-tile-gap: 12px;
+}
+
+/* Панель управления — без ограничения по ширине */
+.lk-control-bar {
+ background: rgba(0, 0, 0, 0.8) !important;
+ backdrop-filter: blur(20px) !important;
+ border-radius: 16px !important;
+ padding: 12px 16px !important;
+ margin: 16px !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
+ max-width: none !important;
+ width: auto !important;
+}
+
+.lk-control-bar .lk-button-group,
+.lk-control-bar .lk-button-group-menu {
+ max-width: none !important;
+ width: auto !important;
+}
+
+/* Кнопки управления — ширина по контенту, без жёсткого ограничения */
+.lk-control-bar .lk-button {
+ min-width: 48px !important;
+ width: auto !important;
+ height: 48px !important;
+ border-radius: 12px !important;
+ transition: all 0.2s ease !important;
+ padding-left: 12px !important;
+ padding-right: 12px !important;
+}
+
+/* Русские подписи: скрываем английский текст, показываем свой */
+.lk-control-bar .lk-button[data-lk-source="microphone"],
+.lk-control-bar .lk-button[data-lk-source="camera"],
+.lk-control-bar .lk-button[data-lk-source="screen_share"],
+.lk-control-bar .lk-chat-toggle,
+.lk-control-bar .lk-disconnect-button,
+.lk-control-bar .lk-start-audio-button {
+ font-size: 0 !important;
+}
+
+.lk-control-bar .lk-button[data-lk-source="microphone"] > svg,
+.lk-control-bar .lk-button[data-lk-source="camera"] > svg,
+.lk-control-bar .lk-button[data-lk-source="screen_share"] > svg,
+.lk-control-bar .lk-chat-toggle > svg,
+.lk-control-bar .lk-disconnect-button > svg {
+ width: 16px !important;
+ height: 16px !important;
+ flex-shrink: 0 !important;
+}
+
+.lk-control-bar .lk-button[data-lk-source="microphone"]::after {
+ content: "Микрофон";
+ font-size: 1rem;
+}
+
+.lk-control-bar .lk-button[data-lk-source="camera"]::after {
+ content: "Камера";
+ font-size: 1rem;
+}
+
+.lk-control-bar .lk-button[data-lk-source="screen_share"]::after {
+ content: "Поделиться экраном";
+ font-size: 1rem;
+}
+
+.lk-control-bar .lk-button[data-lk-source="screen_share"][data-lk-enabled="true"]::after {
+ content: "Остановить демонстрацию";
+}
+
+.lk-control-bar .lk-chat-toggle::after {
+ content: "Чат";
+ font-size: 1rem;
+}
+
+/* Кнопка бургер слева от микрофона — в панели LiveKit */
+.lk-burger-button {
+ background: rgba(255, 255, 255, 0.15) !important;
+ color: #fff !important;
+}
+
+/* Скрываем стандартную кнопку «Выйти» — используем свою внутри панели (модалка: Выйти / Выйти и завершить занятие) */
+.lk-control-bar .lk-disconnect-button {
+ display: none !important;
+}
+.lk-control-bar .lk-disconnect-button::after {
+ content: "Выйти";
+ font-size: 1rem;
+}
+
+/* Наша кнопка «Выйти» — внутри панели, рядом с «Поделиться экраном» */
+.lk-control-bar .lk-custom-exit-button {
+ font-size: 0 !important;
+ background: var(--md-sys-color-error) !important;
+ color: #fff !important;
+ border: none;
+ cursor: pointer;
+ display: inline-flex !important;
+ align-items: center;
+ justify-content: center;
+}
+.lk-control-bar .lk-custom-exit-button::after {
+ content: "Выйти";
+ font-size: 1rem;
+}
+.lk-control-bar .lk-custom-exit-button > .material-symbols-outlined {
+ color: #fff !important;
+}
+
+/* Скрываем кнопку «Начать видео» — у нас свой StartAudioOverlay */
+.lk-control-bar .lk-start-audio-button {
+ display: none !important;
+}
+
+/* Кнопки без текста (только иконка) — минимальный размер */
+.lk-button {
+ min-width: 48px !important;
+ width: auto !important;
+ height: 48px !important;
+ border-radius: 12px !important;
+ transition: all 0.2s ease !important;
+}
+
+.lk-button:hover {
+ transform: scale(1.05);
+}
+
+.lk-button:active {
+ transform: scale(0.95);
+}
+
+/* Активная кнопка */
+.lk-button[data-lk-enabled="true"] {
+ background: var(--md-sys-color-primary) !important;
+}
+
+/* Кнопка отключения — белые иконка и текст */
+.lk-disconnect-button {
+ background: var(--md-sys-color-error) !important;
+ color: #fff !important;
+}
+.lk-disconnect-button > svg {
+ color: #fff !important;
+ fill: currentColor;
+}
+
+/* Плитки участников */
+.lk-participant-tile {
+ border-radius: 12px !important;
+ overflow: hidden !important;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
+}
+
+/* Плейсхолдер без камеры: скрываем дефолтную SVG, показываем аватар из API */
+.lk-participant-tile .lk-participant-placeholder svg {
+ display: none !important;
+}
+
+/* Контейнер для аватара — нужен для container queries */
+.lk-participant-tile .lk-participant-placeholder {
+ container-type: size;
+}
+
+.lk-participant-tile .lk-participant-placeholder img.lk-participant-avatar-img {
+ /* Квадрат: меньшая сторона контейнера, максимум 400px */
+ --avatar-size: min(min(80cqw, 80cqh), 400px);
+ width: var(--avatar-size);
+ height: var(--avatar-size);
+ aspect-ratio: 1 / 1;
+ object-fit: cover;
+ object-position: center;
+ border-radius: 50%;
+}
+
+/* Fallback для браузеров без container queries */
+@supports not (width: 1cqw) {
+ .lk-participant-tile .lk-participant-placeholder img.lk-participant-avatar-img {
+ width: 200px;
+ height: 200px;
+ }
+}
+
+/* Имя участника — белый текст (Камера, PiP) */
+.lk-participant-name {
+ background: rgba(0, 0, 0, 0.7) !important;
+ backdrop-filter: blur(10px) !important;
+ border-radius: 8px !important;
+ padding: 6px 12px !important;
+ font-weight: 600 !important;
+ color: #fff !important;
+}
+
+/* Чат LiveKit скрыт — используем чат сервиса (платформы) */
+.lk-video-conference .lk-chat {
+ display: none !important;
+}
+
+.lk-control-bar .lk-chat-toggle {
+ display: none !important;
+}
+
+/* Стили чата платформы оставляем для других страниц */
+.lk-chat {
+ background: var(--md-sys-color-surface) !important;
+ border-left: 1px solid var(--md-sys-color-outline) !important;
+}
+
+.lk-chat-entry {
+ background: var(--md-sys-color-surface-container) !important;
+ border-radius: 12px !important;
+ padding: 12px !important;
+ margin-bottom: 12px !important;
+}
+
+/* Сетка участников */
+.lk-grid-layout {
+ gap: 12px !important;
+ padding: 12px !important;
+}
+
+/* Меню выбора устройств — без ограничения по ширине */
+.lk-device-menu,
+.lk-media-device-select {
+ max-width: none !important;
+ width: max-content !important;
+ min-width: 0 !important;
+}
+
+.lk-media-device-select {
+ background: rgba(0, 0, 0, 0.95) !important;
+ backdrop-filter: blur(20px) !important;
+ border-radius: 12px !important;
+ padding: 8px !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
+ border: 1px solid rgba(255, 255, 255, 0.1) !important;
+}
+
+.lk-media-device-select button {
+ border-radius: 8px !important;
+ padding: 10px 14px !important;
+ transition: background 0.2s ease !important;
+ width: 100% !important;
+ min-width: 0 !important;
+ white-space: normal !important;
+ text-align: left !important;
+}
+
+.lk-media-device-select button:hover {
+ background: rgba(255, 255, 255, 0.1) !important;
+}
+
+.lk-media-device-select button[data-lk-active="true"] {
+ background: var(--md-sys-color-primary) !important;
+}
+
+/* Индикатор говорящего */
+.lk-participant-tile[data-lk-speaking="true"] {
+ box-shadow: 0 0 0 3px var(--md-sys-color-primary) !important;
+}
+
+/* Layout для 1-на-1: собеседник на весь экран, своя камера в углу */
+/* Карусель position:absolute выходит из flow — остаётся только основной контент. */
+/* Сетка 5fr 1fr: единственный grid-ребёнок (основное видео) получает 5fr (расширяется). */
+.lk-focus-layout {
+ position: relative !important;
+ grid-template-columns: 5fr 1fr !important;
+}
+
+/* Основное видео (собеседник) на весь экран */
+.lk-focus-layout .lk-focus-layout-wrapper {
+ width: 100% !important;
+ height: 100% !important;
+}
+
+.lk-focus-layout .lk-focus-layout-wrapper .lk-participant-tile {
+ width: 100% !important;
+ height: 100% !important;
+ border-radius: 0 !important;
+}
+
+/* Демонстрация экрана — на весь экран только в режиме фокуса (после клика на раскрытие) */
+/* Структура: .lk-focus-layout-wrapper > .lk-focus-layout > .lk-participant-tile */
+.lk-focus-layout > .lk-participant-tile[data-lk-source="screen_share"] {
+ position: absolute !important;
+ width: 100% !important;
+ height: 100% !important;
+ top: 0 !important;
+ left: 0 !important;
+ border-radius: 0 !important;
+ z-index: 50 !important;
+}
+
+/* Карусель с локальным видео (своя камера) */
+.lk-focus-layout .lk-carousel {
+ position: absolute !important;
+ bottom: 80px !important;
+ right: 16px !important;
+ width: 280px !important;
+ height: auto !important;
+ z-index: 100 !important;
+ pointer-events: auto !important;
+}
+
+.lk-focus-layout .lk-carousel .lk-participant-tile {
+ width: 280px !important;
+ height: 158px !important;
+ border-radius: 12px !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;
+ border: 2px solid rgba(255, 255, 255, 0.2) !important;
+}
+
+/* Скрыть стрелки карусели (они не нужны для 1 участника) */
+.lk-focus-layout .lk-carousel button[aria-label*="Previous"],
+.lk-focus-layout .lk-carousel button[aria-label*="Next"] {
+ display: none !important;
+}
+
+/* Если используется grid layout (фоллбэк) */
+.lk-grid-layout {
+ position: relative !important;
+}
+
+/* Для 2 участников: первый на весь экран, второй в углу */
+.lk-grid-layout[data-lk-participants="2"] {
+ display: block !important;
+ position: relative !important;
+}
+
+.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:first-child {
+ position: absolute !important;
+ top: 0 !important;
+ left: 0 !important;
+ width: 100% !important;
+ height: 100% !important;
+ border-radius: 0 !important;
+}
+
+.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child {
+ position: absolute !important;
+ bottom: 80px !important;
+ right: 16px !important;
+ width: 280px !important;
+ height: 158px !important;
+ border-radius: 12px !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;
+ border: 2px solid rgba(255, 255, 255, 0.2) !important;
+ z-index: 100 !important;
+}
+
+/* Адаптивность */
+@media (max-width: 768px) {
+ .lk-control-bar {
+ border-radius: 12px !important;
+ padding: 8px 12px !important;
+ }
+
+ .lk-control-bar .lk-button,
+ .lk-button {
+ min-width: 44px !important;
+ width: auto !important;
+ height: 44px !important;
+ }
+
+ /* Уменьшаем размер локального видео на мобильных */
+ .lk-focus-layout .lk-carousel,
+ .lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child {
+ width: 160px !important;
+ height: 90px !important;
+ bottom: 70px !important;
+ right: 12px !important;
+ }
+}
+
+/* Качество отображения видео в контейнере LiveKit */
+.lk-participant-media-video {
+ background: #000 !important;
+}
+/* Демонстрация экрана: contain чтобы не обрезать, чёткое отображение */
+.lk-participant-media-video[data-lk-source="screen_share"] {
+ object-fit: contain !important;
+ object-position: center !important;
+ image-rendering: -webkit-optimize-contrast;
+ image-rendering: crisp-edges;
+}
+/* Сетка: минимальная высота плиток для крупного видео */
+.lk-grid-layout {
+ min-height: 0;
+}
+.lk-grid-layout .lk-participant-tile {
+ min-height: 240px;
+}