diff --git a/front_material/app/(auth)/layout.tsx b/front_material/app/(auth)/layout.tsx index 8485d1c..b741839 100644 --- a/front_material/app/(auth)/layout.tsx +++ b/front_material/app/(auth)/layout.tsx @@ -1,64 +1,64 @@ -import { AuthRedirect } from '@/components/auth/AuthRedirect'; - -export default function AuthLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - -
- {/* Левая колонка — пустая, фон как у body */} -
- Uchill Logo -
- - {/* Правая колонка — форма на белом фоне */} -
-
- Uchill Logo -
- {children} -
-
-
- ); -} +import { AuthRedirect } from '@/components/auth/AuthRedirect'; + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + +
+ {/* Левая колонка — пустая, фон как у body */} +
+ Uchill Logo +
+ + {/* Правая колонка — форма на белом фоне */} +
+
+ Uchill Logo +
+ {children} +
+
+
+ ); +} diff --git a/front_material/app/(protected)/chat/page.tsx b/front_material/app/(protected)/chat/page.tsx index d65b001..65f4a6c 100644 --- a/front_material/app/(protected)/chat/page.tsx +++ b/front_material/app/(protected)/chat/page.tsx @@ -167,15 +167,14 @@ export default function ChatPage() { }, [normalizeChat, refreshNavBadges]); return ( -
+
{ + const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`); + setIsMobile(mq.matches); + const listener = () => setIsMobile(mq.matches); + mq.addEventListener('change', listener); + return () => mq.removeEventListener('change', listener); + }, []); + return isMobile; +} import { useRouter, usePathname } from 'next/navigation'; import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar'; import { TopNavigationBar } from '@/components/navigation/TopNavigationBar'; @@ -22,6 +35,7 @@ export default function ProtectedLayout({ const router = useRouter(); const pathname = usePathname(); const { user, loading } = useAuth(); + const isMobile = useIsMobile(); const [navBadges, setNavBadges] = useState(null); const [subscriptionChecked, setSubscriptionChecked] = useState(false); @@ -139,25 +153,45 @@ export default function ProtectedLayout({ return ( - {!isFullWidthPage && } -
- {children} -
- {!isLiveKit && ( - - - - )} - {!isLiveKit && user && ( - - )} + {!isFullWidthPage && } +
+ {children} +
+ {!isLiveKit && ( + + : null} + /> + + )} + {!isLiveKit && user && !isMobile && ( + + )} +
); diff --git a/front_material/app/(protected)/profile/page.tsx b/front_material/app/(protected)/profile/page.tsx index 8749ae3..7b1de34 100644 --- a/front_material/app/(protected)/profile/page.tsx +++ b/front_material/app/(protected)/profile/page.tsx @@ -381,6 +381,7 @@ function ProfilePage() { return (
-
+
+
{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) && ( -
- - {mentorshipRequests.length > 0 && ( - - )} - {pendingInvitations.length > 0 && ( - - )} -
- )} - - {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 ( - -
-
- {initials} -
-
-
-
- {title} -
-
- {st.email || '—'} -
- {req.created_at && ( -
- {format(new Date(req.created_at), 'd MMM yyyy, HH:mm', { locale: ru })} -
- )} -
- - -
-
-
- ); - })} -
- )} -
- ) : 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 - {title} - ) : ( -
- {initials} -
- )} -
- -
-
- {title} -
-
- {subtitle} -
- {student?.user?.login_link && ( -
- -
- )} -
-
- ); - })} - - {/* Карточка-действие «Добавить студента» — только во вкладке Студенты */} - {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 && ( - - )} - -
-
- - {showAddPanel ? ( -
- {addSubmitted ? ( -
- Отправлено. Ждём подтверждения от ученика{addCheckResult?.exists === false ? ' (и установки пароля по ссылке из письма)' : ''}. После подтверждения учеником и при необходимости родителем взаимодействие будет разрешено. -
- ) : ( - <> -
- - - -
- {addMode === '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' ? ( -
- - { - 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} -
-
- - -
-
- ) : ( - - )} -
- )} - {addMode !== 'link' && ( - - )} - - )} - {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(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 }) => ( - - ))} -
- { - 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 } }} - /> -
- - -
-
-
-
- - {(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) && ( +
+ + {mentorshipRequests.length > 0 && ( + + )} + {pendingInvitations.length > 0 && ( + + )} +
+ )} + + {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 ( + +
+
+ {initials} +
+
+
+
+ {title} +
+
+ {st.email || '—'} +
+ {req.created_at && ( +
+ {format(new Date(req.created_at), 'd MMM yyyy, HH:mm', { locale: ru })} +
+ )} +
+ + +
+
+
+ ); + })} +
+ )} +
+ ) : 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 + {title} + ) : ( +
+ {initials} +
+ )} +
+ +
+
+ {title} +
+
+ {subtitle} +
+ {student?.user?.login_link && ( +
+ +
+ )} +
+
+ ); + })} + + {/* Карточка-действие «Добавить студента» — только во вкладке Студенты */} + {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 && ( + + )} + +
+
+ + {showAddPanel ? ( +
+ {addSubmitted ? ( +
+ Отправлено. Ждём подтверждения от ученика{addCheckResult?.exists === false ? ' (и установки пароля по ссылке из письма)' : ''}. После подтверждения учеником и при необходимости родителем взаимодействие будет разрешено. +
+ ) : ( + <> +
+ + + +
+ {addMode === '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' ? ( +
+ + { + 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} +
+
+ + +
+
+ ) : ( + + )} +
+ )} + {addMode !== 'link' && ( + + )} + + )} + {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(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 }) => ( + + ))} +
+ { + 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 } }} + /> +
+ + +
+
+
+
+ + {(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 ( -
-

- Чтобы слышать собеседника, разрешите воспроизведение звука -

- -
- ); -} - -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 ? ( -
-
-
- Микрофон - -
-
- Камера - -
-
-
- - -
-
-
-
- ); -} - -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 && ( - - )} - {/* Выдвижная навигация — BottomNavigationBar слева, 3 колонки */} - {showNavMenu && ( - <> -
setShowNavMenu(false)} - style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 10003, backdropFilter: 'blur(4px)' }} - /> - - setShowNavMenu(false)} - /> - - - )} -
- - - {lessonId != null && ( - - )} -
- , - 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 ( -
-
-

Урок завершён. Видеоконференция недоступна.

- -
-
- ); - } - - 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 ( +
+

+ Чтобы слышать собеседника, разрешите воспроизведение звука +

+ +
+ ); +} + +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 ? ( +
+
+
+ Микрофон + +
+
+ Камера + +
+
+
+ + +
+
+
+
+ ); +} + +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 && ( + + )} + {/* Выдвижная навигация — BottomNavigationBar слева, 3 колонки */} + {showNavMenu && ( + <> +
setShowNavMenu(false)} + style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 10003, backdropFilter: 'blur(4px)' }} + /> + + setShowNavMenu(false)} + /> + + + )} +
+ + + {lessonId != null && ( + + )} +
+ , + 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 ( +
+
+

Урок завершён. Видеоконференция недоступна.

+ +
+
+ ); + } + + 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() {
)} - {/* Кнопка-колокольчик */} - + Уведомления + + ) : ( + + )}
); 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 ? ( - + subscription ? ( + + Подписка уже активирована + + ) : ( + + ) ) : ( Подробнее и оплатить 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; +}