574 lines
20 KiB
TypeScript
574 lines
20 KiB
TypeScript
'use client';
|
||
|
||
import React, { useState, useEffect, useMemo } from 'react';
|
||
import { submitHomework, validateHomeworkFiles, getHomeworkById, type Homework } from '@/api/homework';
|
||
import { getBackendOrigin } from '@/lib/api-client';
|
||
|
||
const MAX_FILES = 10;
|
||
const MAX_FILE_SIZE_MB = 50;
|
||
|
||
type FileKind = 'image' | 'video' | 'audio' | '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.startsWith('audio/') || /\.(mp3|wav|ogg|m4a|flac)$/i.test(name)) return 'audio';
|
||
if (t === 'application/pdf' || name.endsWith('.pdf')) return 'pdf';
|
||
return 'other';
|
||
}
|
||
|
||
function getFileIcon(kind: FileKind): string {
|
||
switch (kind) {
|
||
case 'image': return 'image';
|
||
case 'video': return 'videocam';
|
||
case 'audio': return 'audiotrack';
|
||
case 'pdf': return 'picture_as_pdf';
|
||
default: return 'description';
|
||
}
|
||
}
|
||
|
||
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} Б`;
|
||
}
|
||
|
||
function FilePreview({ file, onRemove, disabled }: { file: File; onRemove: () => void; disabled?: boolean }) {
|
||
const kind = getFileKind(file);
|
||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||
const icon = getFileIcon(kind);
|
||
|
||
useEffect(() => {
|
||
if (kind === 'image' || kind === 'video' || kind === 'pdf') {
|
||
const url = URL.createObjectURL(file);
|
||
setObjectUrl(url);
|
||
return () => URL.revokeObjectURL(url);
|
||
}
|
||
}, [file, kind]);
|
||
|
||
const preview = useMemo(() => {
|
||
if (kind === 'image' && objectUrl) {
|
||
return (
|
||
<img
|
||
src={objectUrl}
|
||
alt=""
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
objectFit: 'cover',
|
||
borderRadius: 'var(--ios26-radius-xs)',
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
if (kind === 'video' && objectUrl) {
|
||
return (
|
||
<video
|
||
src={objectUrl}
|
||
muted
|
||
playsInline
|
||
preload="metadata"
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
objectFit: 'cover',
|
||
borderRadius: 'var(--ios26-radius-xs)',
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
if (kind === 'pdf' && objectUrl) {
|
||
return (
|
||
<iframe
|
||
src={objectUrl}
|
||
title={file.name}
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
border: 'none',
|
||
borderRadius: 'var(--ios26-radius-xs)',
|
||
minHeight: 120,
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<div
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
background: 'var(--md-sys-color-surface-variant)',
|
||
borderRadius: 'var(--ios26-radius-xs)',
|
||
color: 'var(--md-sys-color-primary)',
|
||
}}
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 32 }}>{icon}</span>
|
||
</div>
|
||
);
|
||
}, [kind, objectUrl, icon, file.name]);
|
||
|
||
const displayName = file.name.length > 24 ? file.name.slice(0, 21) + '…' : file.name;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
position: 'relative',
|
||
background: 'var(--md-sys-color-surface-variant)',
|
||
borderRadius: 'var(--ios26-radius-sm)',
|
||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||
overflow: 'hidden',
|
||
boxShadow: 'var(--ios26-shadow)',
|
||
}}
|
||
>
|
||
<div style={{ aspectRatio: '4/3', minHeight: 72, maxHeight: 100 }}>
|
||
{preview}
|
||
</div>
|
||
<div style={{ padding: '8px 10px 10px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||
<div style={{ minWidth: 0, flex: 1 }}>
|
||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--md-sys-color-on-surface)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{displayName}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 2 }}>
|
||
{formatSize(file.size)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onRemove}
|
||
disabled={disabled}
|
||
aria-label="Удалить"
|
||
style={{
|
||
flexShrink: 0,
|
||
width: 32,
|
||
height: 32,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
borderRadius: 'var(--ios26-radius-xs)',
|
||
border: 'none',
|
||
background: 'rgba(186,26,26,0.12)',
|
||
color: 'var(--md-sys-color-error)',
|
||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||
opacity: disabled ? 0.6 : 1,
|
||
}}
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>close</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface SubmitHomeworkModalProps {
|
||
isOpen: boolean;
|
||
homeworkId: number;
|
||
onClose: () => void;
|
||
onSuccess: () => void;
|
||
}
|
||
|
||
/** Строит полный URL для скачивания с бэкенда (порт 8123). */
|
||
function fileUrl(href: string): string {
|
||
if (!href) return '';
|
||
if (href.startsWith('http://') || href.startsWith('https://')) return href;
|
||
const base = typeof window !== 'undefined' ? getBackendOrigin() : '';
|
||
return base + (href.startsWith('/') ? href : '/' + href);
|
||
}
|
||
|
||
export function SubmitHomeworkModal({ isOpen, homeworkId, onClose, onSuccess }: SubmitHomeworkModalProps) {
|
||
const [homework, setHomework] = useState<Homework | null>(null);
|
||
const [content, setContent] = useState('');
|
||
const [files, setFiles] = useState<File[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
setContent('');
|
||
setFiles([]);
|
||
setUploadProgress(null);
|
||
setError(null);
|
||
setHomework(null);
|
||
} else if (homeworkId) {
|
||
getHomeworkById(homeworkId)
|
||
.then(setHomework)
|
||
.catch(() => setHomework(null));
|
||
}
|
||
}, [isOpen, homeworkId]);
|
||
|
||
const assignmentFiles = useMemo(() => {
|
||
if (!homework) return [];
|
||
const list: { label: string; url: string }[] = [];
|
||
if (homework.attachment) {
|
||
list.push({ label: 'Файл задания', url: fileUrl(homework.attachment) });
|
||
}
|
||
(homework.files ?? []).filter((f) => f.file_type === 'assignment').forEach((f) => {
|
||
if (f.file) list.push({ label: f.filename || 'Файл', url: fileUrl(f.file) });
|
||
});
|
||
if (homework.attachment_url?.trim()) {
|
||
list.push({ label: 'Ссылка на материал', url: homework.attachment_url.trim() });
|
||
}
|
||
return list;
|
||
}, [homework]);
|
||
|
||
if (!isOpen) return null;
|
||
|
||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const list = Array.from(e.target.files || []);
|
||
const combined = [...files, ...list];
|
||
const { valid, error: validationError } = validateHomeworkFiles(combined);
|
||
if (!valid) {
|
||
setError(validationError ?? `Максимум ${MAX_FILES} файлов, каждый до ${MAX_FILE_SIZE_MB} МБ`);
|
||
return;
|
||
}
|
||
setFiles(combined);
|
||
setError(null);
|
||
e.target.value = '';
|
||
};
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!content.trim() && files.length === 0) {
|
||
setError('Укажите текст или прикрепите файлы');
|
||
return;
|
||
}
|
||
const { valid, error: validationError } = validateHomeworkFiles(files);
|
||
if (!valid) {
|
||
setError(validationError ?? `Максимум ${MAX_FILES} файлов, каждый до ${MAX_FILE_SIZE_MB} МБ`);
|
||
return;
|
||
}
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
setUploadProgress(0);
|
||
await submitHomework(
|
||
homeworkId,
|
||
{ content: content.trim(), text: content.trim(), files },
|
||
(percent) => setUploadProgress(percent)
|
||
);
|
||
setUploadProgress(100);
|
||
await Promise.resolve(onSuccess());
|
||
onClose();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Ошибка отправки');
|
||
setUploadProgress(null);
|
||
} finally {
|
||
setLoading(false);
|
||
setUploadProgress(null);
|
||
}
|
||
};
|
||
|
||
const PANEL_WIDTH = 420;
|
||
|
||
const inputId = 'hw-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)',
|
||
overflow: 'hidden',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
zIndex: 1002,
|
||
}}
|
||
>
|
||
<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)' }}>
|
||
Отправить решение
|
||
</h2>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
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>
|
||
<form onSubmit={handleSubmit} style={{ padding: '24px 28px', overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||
{assignmentFiles.length > 0 && (
|
||
<div style={{ marginBottom: 28 }}>
|
||
<div
|
||
style={{
|
||
display: 'block',
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
marginBottom: 12,
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
}}
|
||
>
|
||
Файлы задания (от ментора)
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
{assignmentFiles.map(({ label, url }, i) => (
|
||
<a
|
||
key={i}
|
||
href={url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 10,
|
||
padding: '12px 18px',
|
||
borderRadius: 14,
|
||
background: 'var(--md-sys-color-surface-variant)',
|
||
color: 'var(--md-sys-color-primary)',
|
||
fontSize: 16,
|
||
textDecoration: 'none',
|
||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||
}}
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>download</span>
|
||
{label}
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ marginBottom: 28 }}>
|
||
<label
|
||
htmlFor="hw-content"
|
||
style={{
|
||
display: 'block',
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
marginBottom: 10,
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
}}
|
||
>
|
||
Текст решения
|
||
</label>
|
||
<textarea
|
||
id="hw-content"
|
||
value={content}
|
||
onChange={(e) => setContent(e.target.value)}
|
||
rows={6}
|
||
style={{
|
||
width: '100%',
|
||
padding: '14px 18px',
|
||
borderRadius: 14,
|
||
border: '1px solid var(--md-sys-color-outline)',
|
||
fontSize: 16,
|
||
lineHeight: 1.5,
|
||
resize: 'vertical',
|
||
background: 'var(--md-sys-color-surface)',
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
}}
|
||
placeholder="Введите текст решения..."
|
||
disabled={loading}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: 28 }}>
|
||
<label
|
||
style={{
|
||
display: 'block',
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
marginBottom: 12,
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
}}
|
||
>
|
||
Файлы (до {MAX_FILE_SIZE_MB} МБ, не более {MAX_FILES} шт.)
|
||
</label>
|
||
|
||
<input
|
||
id={inputId}
|
||
type="file"
|
||
multiple
|
||
onChange={handleFileChange}
|
||
disabled={loading}
|
||
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.zip"
|
||
style={{ position: 'absolute', width: 1, height: 1, opacity: 0, pointerEvents: 'none' }}
|
||
/>
|
||
<label
|
||
htmlFor={inputId}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 12,
|
||
padding: '20px 24px',
|
||
borderRadius: 14,
|
||
border: '2px dashed var(--md-sys-color-outline)',
|
||
background: 'var(--md-sys-color-surface-variant)',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
fontSize: 16,
|
||
fontWeight: 500,
|
||
cursor: loading ? 'not-allowed' : 'pointer',
|
||
opacity: loading ? 0.6 : 1,
|
||
transition: 'border-color 0.2s, background 0.2s',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!loading) {
|
||
e.currentTarget.style.borderColor = 'var(--md-sys-color-primary)';
|
||
e.currentTarget.style.background = 'var(--md-sys-color-primary-container)';
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.borderColor = 'var(--md-sys-color-outline)';
|
||
e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)';
|
||
}}
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>upload_file</span>
|
||
Прикрепить файлы
|
||
</label>
|
||
|
||
{files.length > 0 && (
|
||
<div
|
||
style={{
|
||
marginTop: 20,
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
|
||
gap: 16,
|
||
}}
|
||
>
|
||
{files.map((f, i) => (
|
||
<FilePreview
|
||
key={`${f.name}-${i}-${f.size}`}
|
||
file={f}
|
||
onRemove={() => setFiles((p) => p.filter((_, j) => j !== i))}
|
||
disabled={loading}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{uploadProgress != null && (
|
||
<div style={{ marginTop: 16 }}>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
fontSize: 13,
|
||
marginBottom: 8,
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
}}
|
||
>
|
||
<span>Загрузка на сервер</span>
|
||
<span style={{ fontWeight: 600, color: 'var(--md-sys-color-primary)' }}>{uploadProgress}%</span>
|
||
</div>
|
||
<div
|
||
style={{
|
||
height: 10,
|
||
background: 'var(--md-sys-color-surface-variant)',
|
||
borderRadius: 'var(--ios26-radius-xs)',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
height: '100%',
|
||
width: `${uploadProgress}%`,
|
||
background: 'var(--md-sys-color-primary)',
|
||
transition: 'width 0.2s ease',
|
||
borderRadius: 'var(--ios26-radius-xs)',
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{error && (
|
||
<div
|
||
style={{
|
||
marginBottom: 16,
|
||
padding: 12,
|
||
background: 'rgba(186,26,26,0.1)',
|
||
borderRadius: 'var(--ios26-radius-xs)',
|
||
color: 'var(--md-sys-color-error)',
|
||
fontSize: 14,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>error</span>
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', gap: 12 }}>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
disabled={loading}
|
||
style={{
|
||
flex: 1,
|
||
padding: '14px 24px',
|
||
borderRadius: 'var(--ios26-radius-sm)',
|
||
border: '1px solid var(--md-sys-color-outline)',
|
||
background: 'transparent',
|
||
fontSize: 15,
|
||
fontWeight: 500,
|
||
cursor: 'pointer',
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
}}
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={loading || (!content.trim() && files.length === 0)}
|
||
style={{
|
||
flex: 1,
|
||
padding: '14px 24px',
|
||
borderRadius: 'var(--ios26-radius-sm)',
|
||
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.7 : 1,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 8,
|
||
}}
|
||
>
|
||
{loading && uploadProgress != null && (
|
||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>cloud_upload</span>
|
||
)}
|
||
{loading ? (uploadProgress != null ? `Загрузка ${uploadProgress}%` : 'Отправка...') : 'Отправить'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|