uchill/front_material/components/livekit/ExitLessonModal.tsx

856 lines
31 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
/**
* Панель выхода из LiveKit (выезжает справа, как на странице ДЗ):
* 1) Выйти | Выйти и завершить занятие
* 2) Если «завершить» — Выдать ДЗ? Да | Нет | Позже
* 3) Если «Да» — форма: текст + файлы, кнопки Позже | Сохранить
*/
import React, { useState, useEffect } from 'react';
import { completeLesson, uploadLessonFile } from '@/api/schedule';
import { createHomework } from '@/api/homework';
const MAX_LESSON_FILES = 10;
const MAX_FILE_SIZE_MB = 10;
function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} МБ`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
return `${bytes} Б`;
}
type FileKind = 'image' | 'video' | 'pdf' | 'other';
function getFileKind(file: File): FileKind {
const t = file.type?.toLowerCase() ?? '';
const name = file.name.toLowerCase();
if (t.startsWith('image/') || /\.(jpe?g|png|gif|webp|bmp|svg|ico)$/i.test(name)) return 'image';
if (t.startsWith('video/') || /\.(mp4|webm|ogg|mov|avi|mkv)$/i.test(name)) return 'video';
if (t === 'application/pdf' || name.endsWith('.pdf')) return 'pdf';
return 'other';
}
function FilePreviewChip({ file, onRemove, disabled }: { file: File; onRemove: () => void; disabled?: boolean }) {
const kind = getFileKind(file);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const name = file.name.length > 28 ? file.name.slice(0, 25) + '…' : file.name;
useEffect(() => {
if (kind === 'image' || kind === 'video' || kind === 'pdf') {
const url = URL.createObjectURL(file);
setObjectUrl(url);
return () => URL.revokeObjectURL(url);
}
}, [file, kind]);
const previewBlock = (() => {
if (kind === 'image' && objectUrl) {
return (
<img
src={objectUrl}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 8 }}
/>
);
}
if (kind === 'video' && objectUrl) {
return (
<video
src={objectUrl}
muted
playsInline
preload="metadata"
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 8 }}
/>
);
}
if (kind === 'pdf' && objectUrl) {
return (
<iframe
src={objectUrl}
title={file.name}
style={{
width: '100%',
height: '100%',
border: 'none',
borderRadius: 8,
minHeight: 140,
}}
/>
);
}
return (
<div
style={{
width: '100%',
height: '100%',
minHeight: 60,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--md-sys-color-surface-variant)',
borderRadius: 8,
color: 'var(--md-sys-color-primary)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 28 }}>description</span>
</div>
);
})();
return (
<div
style={{
background: 'var(--md-sys-color-surface-variant)',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline-variant)',
overflow: 'hidden',
}}
>
<div style={{ aspectRatio: '4/3', minHeight: 80, maxHeight: 140 }}>
{previewBlock}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 12px',
}}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{name}
</div>
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)' }}>{formatSize(file.size)}</div>
</div>
<button
type="button"
onClick={onRemove}
disabled={disabled}
aria-label="Удалить"
style={{
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
border: 'none',
background: 'rgba(186,26,26,0.15)',
color: 'var(--md-sys-color-error)',
cursor: disabled ? 'not-allowed' : 'pointer',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>close</span>
</button>
</div>
</div>
);
}
export type ExitStep = 'choose_exit' | 'grades' | 'comment' | 'choose_hw' | 'hw_form';
export interface ExitLessonModalProps {
isOpen: boolean;
/** Если null, показывается только «Выйти» (без завершения занятия). */
lessonId: number | null;
onClose: () => void;
/** Вызвать после выхода (отключиться от комнаты и уйти). */
onExit: () => void;
}
export function ExitLessonModal({ isOpen, lessonId, onClose, onExit }: ExitLessonModalProps) {
const [step, setStep] = useState<ExitStep>('choose_exit');
const [formData, setFormData] = useState({
mentor_grade: '',
school_grade: '',
mentor_notes: '',
});
const [homeworkText, setHomeworkText] = useState('');
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (isOpen) {
setMounted(true);
const t = requestAnimationFrame(() => {
requestAnimationFrame(() => setVisible(true));
});
return () => cancelAnimationFrame(t);
} else {
setVisible(false);
}
}, [isOpen]);
const reset = () => {
setStep('choose_exit');
setFormData({ mentor_grade: '', school_grade: '', mentor_notes: '' });
setHomeworkText('');
setFiles([]);
setError(null);
};
const handleClose = () => {
if (!loading) {
reset();
onClose();
}
};
const handleTransitionEnd = (e: React.TransitionEvent) => {
if (e.propertyName !== 'transform') return;
if (!isOpen) setMounted(false);
};
const PANEL_WIDTH = 420;
if (!mounted) return null;
/** Просто выйти из комнаты (без завершения занятия). */
const handleJustExit = () => {
reset();
onClose();
onExit();
};
const notes = (formData.mentor_notes || '').trim();
const mentorGrade = formData.mentor_grade ? parseInt(formData.mentor_grade, 10) : undefined;
const schoolGrade = formData.school_grade ? parseInt(formData.school_grade, 10) : undefined;
/** Завершить занятие без ДЗ и выйти. */
const handleCompleteNoHw = async () => {
if (lessonId == null) return;
setLoading(true);
setError(null);
try {
const result = await completeLesson(String(lessonId), notes, mentorGrade, schoolGrade, undefined);
if (!result?.success) {
setError(result?.message || 'Не удалось завершить занятие.');
return;
}
reset();
onClose();
onExit();
} catch (e) {
setError(e instanceof Error ? e.message : 'Не удалось завершить занятие');
} finally {
setLoading(false);
}
};
/** Завершить занятие, создать черновик ДЗ «заполнить позже» и выйти. */
const handleCompleteHwLater = async () => {
if (lessonId == null) return;
setLoading(true);
setError(null);
try {
const completeResult = await completeLesson(String(lessonId), notes, mentorGrade, schoolGrade, undefined);
if (!completeResult?.success) {
setError(completeResult?.message || 'Не удалось завершить занятие.');
return;
}
await createHomework({
title: 'ДЗ (заполнить позже)',
description: '',
lesson_id: lessonId,
status: 'draft',
fill_later: true,
});
reset();
onClose();
onExit();
} catch (e) {
setError(e instanceof Error ? e.message : 'Не удалось завершить занятие');
} finally {
setLoading(false);
}
};
/** Сохранить ДЗ (текст + файлы), завершить занятие и выйти.
* Порядок: 1) загружаем файлы к уроку, 2) создаём ДЗ через API homeworks, 3) после успеха — complete урока.
* В complete передаём только ID только что загруженных файлов, чтобы в ДЗ попали только они (не старые). */
const handleSaveHw = async () => {
if (lessonId == null) return;
const text = homeworkText.trim();
if (!text && files.length === 0) {
setError('Добавьте текст или прикрепите файлы');
return;
}
setLoading(true);
setError(null);
try {
const lessonFileIds: number[] = [];
for (const file of files) {
const created = await uploadLessonFile(lessonId, file);
const id = typeof created.id === 'number' ? created.id : parseInt(String(created.id), 10);
if (!isNaN(id)) lessonFileIds.push(id);
}
await createHomework({
title: 'Домашнее задание',
description: text || '',
lesson_id: lessonId,
status: 'published',
});
const result = await completeLesson(
String(lessonId),
notes,
mentorGrade,
schoolGrade,
text || undefined,
true,
lessonFileIds
);
if (!result?.success) {
setError(result?.message || 'Занятие не удалось завершить.');
return;
}
reset();
onClose();
onExit();
} catch (e) {
setError(e instanceof Error ? e.message : 'Не удалось сохранить ДЗ и завершить занятие');
} finally {
setLoading(false);
}
};
/** В форме ДЗ нажать «Позже» — то же, что и выбор «Позже» на шаге выбора ДЗ. */
const handleHwFormLater = () => {
handleCompleteHwLater();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const list = e.target.files;
if (!list?.length) return;
const add: File[] = [];
for (let i = 0; i < list.length; i++) {
const f = list[i];
if (f.size > MAX_FILE_SIZE_MB * 1024 * 1024) continue;
add.push(f);
}
setFiles((prev) => {
const next = [...prev, ...add].slice(0, MAX_LESSON_FILES);
return next;
});
e.target.value = '';
};
const fileInputId = 'exit-lesson-file-input';
return (
<div
className="ios26-panel"
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: PANEL_WIDTH,
background: 'var(--md-sys-color-surface)',
boxShadow: '-4px 0 24px rgba(0,0,0,0.15)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: 10002,
transform: isOpen && visible ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 0.3s ease',
}}
onTransitionEnd={handleTransitionEnd}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '20px 24px',
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
flexShrink: 0,
}}
>
<h2 style={{ fontSize: 20, fontWeight: 600, margin: 0, color: 'var(--md-sys-color-on-surface)' }}>
{step === 'choose_exit' && 'Выйти из занятия'}
{step === 'grades' && 'Оценки'}
{step === 'comment' && 'Комментарий'}
{step === 'choose_hw' && 'Завершить занятие'}
{step === 'hw_form' && 'Домашнее задание'}
</h2>
<button
type="button"
onClick={handleClose}
disabled={loading}
style={{
width: 44,
height: 44,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'none',
border: 'none',
borderRadius: 12,
cursor: 'pointer',
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>close</span>
</button>
</div>
<div style={{ padding: '24px 28px', overflowY: 'auto', flex: 1 }}>
{step === 'choose_exit' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<button
type="button"
onClick={handleJustExit}
disabled={loading}
style={{
padding: '14px 20px',
borderRadius: 14,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface)',
fontSize: 16,
fontWeight: 500,
cursor: loading ? 'not-allowed' : 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<span className="material-symbols-outlined">logout</span>
Выйти
</button>
{lessonId != null && (
<button
type="button"
onClick={() => setStep('grades')}
disabled={loading}
style={{
padding: '14px 20px',
borderRadius: 14,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 16,
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<span className="material-symbols-outlined">check_circle</span>
Выйти и завершить занятие
</button>
)}
</div>
)}
{step === 'grades' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ margin: '0 0 8px', fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)' }}>
Необязательно
</p>
<div>
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8, color: 'var(--md-sys-color-on-surface-variant)' }}>
Оценка за занятие (15)
</label>
<input
type="number"
min={1}
max={5}
value={formData.mentor_grade}
onChange={(e) => setFormData((d) => ({ ...d, mentor_grade: e.target.value }))}
style={{
width: '100%',
padding: '12px 16px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface)',
fontSize: 15,
color: 'var(--md-sys-color-on-surface)',
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8, color: 'var(--md-sys-color-on-surface-variant)' }}>
Оценка в школе (15)
</label>
<input
type="number"
min={1}
max={5}
value={formData.school_grade}
onChange={(e) => setFormData((d) => ({ ...d, school_grade: e.target.value }))}
style={{
width: '100%',
padding: '12px 16px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface)',
fontSize: 15,
color: 'var(--md-sys-color-on-surface)',
}}
/>
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
<button
type="button"
onClick={() => setStep('choose_exit')}
disabled={loading}
style={{
padding: '14px 20px',
borderRadius: 14,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface)',
fontSize: 15,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
Назад
</button>
<button
type="button"
onClick={() => setStep('comment')}
disabled={loading}
style={{
flex: 1,
padding: '14px 20px',
borderRadius: 14,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 15,
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
Далее
</button>
</div>
</div>
)}
{step === 'comment' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ margin: '0 0 8px', fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)' }}>
Комментарий к занятию (необязательно)
</p>
<textarea
value={formData.mentor_notes}
onChange={(e) => setFormData((d) => ({ ...d, mentor_notes: e.target.value }))}
rows={5}
placeholder="Что прошли, успехи, рекомендации..."
disabled={loading}
style={{
width: '100%',
padding: '12px 16px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface)',
fontSize: 15,
color: 'var(--md-sys-color-on-surface)',
resize: 'vertical',
}}
/>
<div style={{ display: 'flex', gap: 12 }}>
<button
type="button"
onClick={() => setStep('grades')}
disabled={loading}
style={{
padding: '14px 20px',
borderRadius: 14,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface)',
fontSize: 15,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
Назад
</button>
<button
type="button"
onClick={() => setStep('choose_hw')}
disabled={loading}
style={{
flex: 1,
padding: '14px 20px',
borderRadius: 14,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 15,
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
Далее
</button>
</div>
</div>
)}
{step === 'choose_hw' && (
<>
<p style={{ margin: '0 0 16px', fontSize: 15, color: 'var(--md-sys-color-on-surface-variant)' }}>
Выдать домашнее задание?
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<button
type="button"
onClick={() => setStep('comment')}
disabled={loading}
style={{
padding: '14px 20px',
borderRadius: 14,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface)',
fontSize: 16,
fontWeight: 500,
cursor: loading ? 'not-allowed' : 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<span className="material-symbols-outlined">arrow_back</span>
Назад
</button>
<button
type="button"
onClick={() => setStep('hw_form')}
disabled={loading}
style={{
padding: '14px 20px',
borderRadius: 14,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface)',
color: 'var(--md-sys-color-on-surface)',
fontSize: 16,
fontWeight: 500,
cursor: loading ? 'not-allowed' : 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<span className="material-symbols-outlined">assignment</span>
Да
</button>
<button
type="button"
onClick={handleCompleteNoHw}
disabled={loading}
style={{
padding: '14px 20px',
borderRadius: 14,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface)',
color: 'var(--md-sys-color-on-surface)',
fontSize: 16,
fontWeight: 500,
cursor: loading ? 'not-allowed' : 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<span className="material-symbols-outlined">cancel</span>
Нет
</button>
<button
type="button"
onClick={handleCompleteHwLater}
disabled={loading}
style={{
padding: '14px 20px',
borderRadius: 14,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface)',
color: 'var(--md-sys-color-on-surface)',
fontSize: 16,
fontWeight: 500,
cursor: loading ? 'not-allowed' : 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<span className="material-symbols-outlined">schedule</span>
Позже (заполню на странице ДЗ)
</button>
</div>
</>
)}
{step === 'hw_form' && (
<>
<div style={{ marginBottom: 20 }}>
<label
htmlFor="exit-hw-text"
style={{
display: 'block',
fontSize: 14,
fontWeight: 600,
marginBottom: 8,
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
Текст задания
</label>
<textarea
id="exit-hw-text"
value={homeworkText}
onChange={(e) => setHomeworkText(e.target.value)}
rows={4}
placeholder="Опишите домашнее задание..."
disabled={loading}
style={{
width: '100%',
padding: '12px 16px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface)',
fontSize: 15,
color: 'var(--md-sys-color-on-surface)',
resize: 'vertical',
}}
/>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, color: 'var(--md-sys-color-on-surface-variant)' }}>
Файлы (до {MAX_FILE_SIZE_MB} МБ, не более {MAX_LESSON_FILES} шт.)
</div>
<input
id={fileInputId}
type="file"
multiple
onChange={handleFileChange}
disabled={loading}
accept="*"
style={{ position: 'absolute', width: 1, height: 1, opacity: 0, pointerEvents: 'none' }}
/>
<label
htmlFor={fileInputId}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
padding: '16px 20px',
borderRadius: 12,
border: '2px dashed var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 15,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>upload_file</span>
Прикрепить файлы
</label>
{files.length > 0 && (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
{files.map((f, i) => (
<FilePreviewChip
key={`${f.name}-${i}-${f.size}`}
file={f}
onRemove={() => setFiles((p) => p.filter((_, j) => j !== i))}
disabled={loading}
/>
))}
</div>
)}
</div>
{error && (
<div
style={{
background: 'rgba(186,26,26,0.1)',
border: '1px solid var(--md-sys-color-error)',
borderRadius: 12,
padding: 12,
marginBottom: 16,
fontSize: 14,
color: 'var(--md-sys-color-error)',
}}
>
{error}
</div>
)}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<button
type="button"
onClick={() => setStep('choose_hw')}
disabled={loading}
style={{
padding: '14px 20px',
borderRadius: 14,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface)',
fontSize: 15,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
Назад
</button>
<button
type="button"
onClick={handleHwFormLater}
disabled={loading}
style={{
flex: 1,
minWidth: 100,
padding: '14px 20px',
borderRadius: 14,
border: '1px solid var(--md-sys-color-outline)',
background: 'transparent',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 15,
fontWeight: 500,
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
Позже
</button>
<button
type="button"
onClick={handleSaveHw}
disabled={loading}
style={{
flex: 1,
minWidth: 100,
padding: '14px 20px',
borderRadius: 14,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 15,
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.8 : 1,
}}
>
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</>
)}
</div>
</div>
);
}