uchill/front_material/components/homework/SubmitHomeworkModal.tsx

574 lines
20 KiB
TypeScript
Raw 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';
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>
);
}