719 lines
25 KiB
TypeScript
719 lines
25 KiB
TypeScript
'use client';
|
||
|
||
import React, { useEffect, useState, useRef } from 'react';
|
||
import {
|
||
updateHomework,
|
||
publishHomework,
|
||
type Homework,
|
||
type HomeworkFileItem,
|
||
} from '@/api/homework';
|
||
import { getMyMaterials } from '@/api/materials';
|
||
import type { Material } from '@/api/materials';
|
||
import apiClient from '@/lib/api-client';
|
||
|
||
const MAX_FILE_SIZE_MB = 10;
|
||
const MAX_FILES = 10;
|
||
|
||
interface EditHomeworkDraftModalProps {
|
||
isOpen: boolean;
|
||
homework: Homework | null;
|
||
onClose: () => void;
|
||
onSuccess: () => void;
|
||
}
|
||
|
||
function getFileUrl(file: HomeworkFileItem | null): string {
|
||
if (!file?.file) return '';
|
||
if (file.file.startsWith('http')) return file.file;
|
||
const base = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8123` : '';
|
||
return file.file.startsWith('/') ? `${base}${file.file}` : `${base}/${file.file}`;
|
||
}
|
||
|
||
export function EditHomeworkDraftModal({
|
||
isOpen,
|
||
homework,
|
||
onClose,
|
||
onSuccess,
|
||
}: EditHomeworkDraftModalProps) {
|
||
const [title, setTitle] = useState('');
|
||
const [description, setDescription] = useState('');
|
||
const [deadline, setDeadline] = useState('');
|
||
const [existingFiles, setExistingFiles] = useState<HomeworkFileItem[]>([]);
|
||
const [newFiles, setNewFiles] = useState<File[]>([]);
|
||
const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set());
|
||
const [materials, setMaterials] = useState<Material[]>([]);
|
||
const [materialsLoading, setMaterialsLoading] = useState(false);
|
||
const [selectedMaterialIds, setSelectedMaterialIds] = useState<Set<string>>(new Set());
|
||
const [materialsSearch, setMaterialsSearch] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
const [publishing, setPublishing] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen || !homework) return;
|
||
setTitle(homework.title || '');
|
||
setDescription(homework.description || '');
|
||
setDeadline(homework.deadline ? homework.deadline.slice(0, 16) : '');
|
||
setExistingFiles(homework.files?.filter(f => f.file_type === 'assignment') || []);
|
||
setNewFiles([]);
|
||
setSelectedMaterialIds(new Set());
|
||
setError(null);
|
||
}, [isOpen, homework]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
setMaterialsLoading(true);
|
||
getMyMaterials()
|
||
.then((list) => setMaterials(Array.isArray(list) ? list : []))
|
||
.catch(() => setMaterials([]))
|
||
.finally(() => setMaterialsLoading(false));
|
||
}, [isOpen]);
|
||
|
||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = Array.from(e.target.files || []);
|
||
if (!files.length || !homework) return;
|
||
|
||
const validFiles: File[] = [];
|
||
for (const file of files) {
|
||
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
|
||
setError(`Файл "${file.name}" больше ${MAX_FILE_SIZE_MB} МБ`);
|
||
continue;
|
||
}
|
||
if (existingFiles.length + newFiles.length + validFiles.length >= MAX_FILES) {
|
||
setError(`Максимум ${MAX_FILES} файлов`);
|
||
break;
|
||
}
|
||
validFiles.push(file);
|
||
}
|
||
|
||
for (const file of validFiles) {
|
||
const fileKey = `${file.name}-${Date.now()}`;
|
||
setUploadingFiles((prev) => new Set(prev).add(fileKey));
|
||
setNewFiles((prev) => [...prev, file]);
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('homework', String(homework.id));
|
||
formData.append('file_type', 'assignment');
|
||
formData.append('file', file);
|
||
|
||
const res = await apiClient.post<HomeworkFileItem>('/homework/files/', formData);
|
||
setExistingFiles((prev) => [...prev, res.data]);
|
||
setNewFiles((prev) => prev.filter((f) => f !== file));
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Ошибка загрузки файла');
|
||
setNewFiles((prev) => prev.filter((f) => f !== file));
|
||
} finally {
|
||
setUploadingFiles((prev) => {
|
||
const next = new Set(prev);
|
||
next.delete(fileKey);
|
||
return next;
|
||
});
|
||
}
|
||
}
|
||
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = '';
|
||
}
|
||
};
|
||
|
||
const handleRemoveFile = async (fileId: number) => {
|
||
try {
|
||
await apiClient.delete(`/homework/files/${fileId}/`);
|
||
setExistingFiles((prev) => prev.filter((f) => f.id !== fileId));
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Ошибка удаления файла');
|
||
}
|
||
};
|
||
|
||
const handleMaterialToggle = (materialId: string) => {
|
||
setSelectedMaterialIds((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(materialId)) {
|
||
next.delete(materialId);
|
||
} else {
|
||
next.add(materialId);
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const attachMaterialFiles = async () => {
|
||
if (!homework || selectedMaterialIds.size === 0) return;
|
||
|
||
for (const materialId of selectedMaterialIds) {
|
||
const material = materials.find((m) => String(m.id) === materialId);
|
||
if (!material?.file) continue;
|
||
|
||
try {
|
||
const response = await fetch(material.file);
|
||
const blob = await response.blob();
|
||
const filename = material.title || material.file.split('/').pop() || 'material';
|
||
const file = new File([blob], filename, { type: blob.type });
|
||
|
||
const formData = new FormData();
|
||
formData.append('homework', String(homework.id));
|
||
formData.append('file_type', 'assignment');
|
||
formData.append('file', file);
|
||
|
||
const res = await apiClient.post<HomeworkFileItem>('/homework/files/', formData);
|
||
setExistingFiles((prev) => [...prev, res.data]);
|
||
} catch {
|
||
// Ignore material attach errors
|
||
}
|
||
}
|
||
setSelectedMaterialIds(new Set());
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
if (!homework) return;
|
||
try {
|
||
setError(null);
|
||
setSaving(true);
|
||
await attachMaterialFiles();
|
||
await updateHomework(homework.id, {
|
||
title: title.trim() || homework.title,
|
||
description: description.trim(),
|
||
deadline: deadline ? new Date(deadline).toISOString() : null,
|
||
});
|
||
onSuccess();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : 'Ошибка сохранения');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handlePublish = async () => {
|
||
if (!homework) return;
|
||
if (!title.trim()) {
|
||
setError('Укажите название задания');
|
||
return;
|
||
}
|
||
if (!description.trim()) {
|
||
setError('Укажите текст задания');
|
||
return;
|
||
}
|
||
try {
|
||
setError(null);
|
||
setPublishing(true);
|
||
await attachMaterialFiles();
|
||
await updateHomework(homework.id, {
|
||
title: title.trim(),
|
||
description: description.trim(),
|
||
deadline: deadline ? new Date(deadline).toISOString() : null,
|
||
});
|
||
await publishHomework(homework.id);
|
||
onSuccess();
|
||
onClose();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : 'Ошибка публикации');
|
||
} finally {
|
||
setPublishing(false);
|
||
}
|
||
};
|
||
|
||
if (!isOpen || !homework) return null;
|
||
|
||
const isLoading = saving || publishing || uploadingFiles.size > 0;
|
||
|
||
const filteredMaterials = materials.filter((m) => {
|
||
if (!materialsSearch.trim()) return true;
|
||
const q = materialsSearch.toLowerCase();
|
||
return (
|
||
(m.title || '').toLowerCase().includes(q) ||
|
||
(m.description || '').toLowerCase().includes(q)
|
||
);
|
||
});
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
background: 'rgba(0, 0, 0, 0.5)',
|
||
zIndex: 999,
|
||
}}
|
||
onClick={onClose}
|
||
/>
|
||
<div
|
||
className="ios26-panel"
|
||
style={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
width: '90vw',
|
||
maxWidth: 600,
|
||
background: 'var(--md-sys-color-surface)',
|
||
overflow: 'hidden',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
zIndex: 1001,
|
||
}}
|
||
>
|
||
{/* Header */}
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: 12,
|
||
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}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
width: 44,
|
||
height: 44,
|
||
borderRadius: 12,
|
||
border: 'none',
|
||
background: 'none',
|
||
cursor: 'pointer',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
}}
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>
|
||
close
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div
|
||
style={{
|
||
padding: '24px',
|
||
paddingBottom: 'max(24px, env(safe-area-inset-bottom, 0px) + 100px)',
|
||
overflowY: 'auto',
|
||
flex: 1,
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||
{/* Title */}
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: 'block',
|
||
fontSize: 14,
|
||
fontWeight: 500,
|
||
marginBottom: 8,
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
}}
|
||
>
|
||
Название задания *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
placeholder="Введите название"
|
||
disabled={isLoading}
|
||
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>
|
||
|
||
{/* Description */}
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: 'block',
|
||
fontSize: 14,
|
||
fontWeight: 500,
|
||
marginBottom: 8,
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
}}
|
||
>
|
||
Текст задания *
|
||
</label>
|
||
<textarea
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
rows={4}
|
||
placeholder="Опишите задание, шаги, ссылки..."
|
||
disabled={isLoading}
|
||
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>
|
||
|
||
{/* Deadline */}
|
||
<div>
|
||
<label
|
||
style={{
|
||
display: 'block',
|
||
fontSize: 14,
|
||
fontWeight: 500,
|
||
marginBottom: 8,
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
}}
|
||
>
|
||
Дедлайн (опционально)
|
||
</label>
|
||
<input
|
||
type="datetime-local"
|
||
value={deadline}
|
||
onChange={(e) => setDeadline(e.target.value)}
|
||
disabled={isLoading}
|
||
style={{
|
||
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>
|
||
|
||
{/* Files */}
|
||
<div>
|
||
<div
|
||
style={{
|
||
fontSize: 14,
|
||
fontWeight: 500,
|
||
marginBottom: 8,
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
}}
|
||
>
|
||
Файлы и материалы к ДЗ
|
||
</div>
|
||
|
||
{/* File upload */}
|
||
<input
|
||
type="file"
|
||
multiple
|
||
ref={fileInputRef}
|
||
className="hidden"
|
||
id="edit-homework-file"
|
||
onChange={handleFileChange}
|
||
disabled={isLoading}
|
||
accept=".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png,.zip,.rar"
|
||
style={{ display: 'none' }}
|
||
/>
|
||
<label
|
||
htmlFor="edit-homework-file"
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 8,
|
||
padding: '14px 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: 14,
|
||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||
}}
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
|
||
upload_file
|
||
</span>
|
||
{uploadingFiles.size > 0
|
||
? `Загрузка ${uploadingFiles.size}…`
|
||
: 'Загрузить файлы'}
|
||
</label>
|
||
|
||
{/* Existing files */}
|
||
{existingFiles.length > 0 && (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: 8,
|
||
marginTop: 12,
|
||
}}
|
||
>
|
||
{existingFiles.map((file) => {
|
||
const url = getFileUrl(file);
|
||
const isImage = /\.(jpe?g|png|gif|webp|bmp)$/i.test(
|
||
file.filename || ''
|
||
);
|
||
return (
|
||
<div
|
||
key={file.id}
|
||
style={{
|
||
width: 80,
|
||
aspectRatio: '1',
|
||
borderRadius: 12,
|
||
overflow: 'hidden',
|
||
border: '2px solid var(--md-sys-color-outline-variant)',
|
||
background: 'var(--md-sys-color-surface-variant)',
|
||
position: 'relative',
|
||
}}
|
||
>
|
||
{isImage && url ? (
|
||
<img
|
||
src={url}
|
||
alt=""
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
objectFit: 'cover',
|
||
}}
|
||
/>
|
||
) : (
|
||
<div
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<span
|
||
className="material-symbols-outlined"
|
||
style={{
|
||
fontSize: 28,
|
||
color: 'var(--md-sys-color-primary)',
|
||
}}
|
||
>
|
||
description
|
||
</span>
|
||
</div>
|
||
)}
|
||
<span
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
fontSize: 10,
|
||
padding: 4,
|
||
background: 'rgba(0,0,0,0.6)',
|
||
color: '#fff',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{file.filename}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleRemoveFile(file.id)}
|
||
disabled={isLoading}
|
||
style={{
|
||
position: 'absolute',
|
||
top: 2,
|
||
right: 2,
|
||
width: 20,
|
||
height: 20,
|
||
borderRadius: '50%',
|
||
border: 'none',
|
||
background: 'var(--md-sys-color-error)',
|
||
color: '#fff',
|
||
fontSize: 14,
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Materials */}
|
||
<div>
|
||
<div
|
||
style={{
|
||
fontSize: 14,
|
||
fontWeight: 500,
|
||
marginBottom: 8,
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
}}
|
||
>
|
||
Прикрепить из моих материалов
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={materialsSearch}
|
||
onChange={(e) => setMaterialsSearch(e.target.value)}
|
||
placeholder="Поиск материалов..."
|
||
disabled={isLoading}
|
||
style={{
|
||
width: '100%',
|
||
padding: '10px 14px',
|
||
borderRadius: 10,
|
||
border: '1px solid var(--md-sys-color-outline)',
|
||
background: 'var(--md-sys-color-surface)',
|
||
fontSize: 14,
|
||
marginBottom: 8,
|
||
}}
|
||
/>
|
||
{materialsLoading ? (
|
||
<p
|
||
style={{
|
||
fontSize: 13,
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
}}
|
||
>
|
||
Загрузка материалов…
|
||
</p>
|
||
) : filteredMaterials.length === 0 ? (
|
||
<p
|
||
style={{
|
||
fontSize: 13,
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
}}
|
||
>
|
||
Нет материалов
|
||
</p>
|
||
) : (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: 8,
|
||
maxHeight: 160,
|
||
overflowY: 'auto',
|
||
}}
|
||
>
|
||
{filteredMaterials.slice(0, 20).map((m) => {
|
||
const materialId = String(m.id);
|
||
const isSelected = selectedMaterialIds.has(materialId);
|
||
return (
|
||
<button
|
||
key={materialId}
|
||
type="button"
|
||
onClick={() => handleMaterialToggle(materialId)}
|
||
disabled={isLoading}
|
||
style={{
|
||
padding: '8px 14px',
|
||
borderRadius: 10,
|
||
border: `2px solid ${
|
||
isSelected
|
||
? 'var(--md-sys-color-primary)'
|
||
: 'var(--md-sys-color-outline-variant)'
|
||
}`,
|
||
background: isSelected
|
||
? 'var(--md-sys-color-primary-container)'
|
||
: 'var(--md-sys-color-surface-variant)',
|
||
color: isSelected
|
||
? 'var(--md-sys-color-on-primary-container)'
|
||
: 'var(--md-sys-color-on-surface)',
|
||
fontSize: 13,
|
||
cursor: 'pointer',
|
||
maxWidth: 200,
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{m.title || 'Без названия'}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Error */}
|
||
{error && (
|
||
<div
|
||
style={{
|
||
padding: 16,
|
||
background: 'rgba(186,26,26,0.1)',
|
||
borderRadius: 12,
|
||
color: 'var(--md-sys-color-error)',
|
||
fontSize: 14,
|
||
}}
|
||
>
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Actions */}
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: 12,
|
||
paddingTop: 8,
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={handlePublish}
|
||
disabled={isLoading}
|
||
style={{
|
||
padding: '14px 28px',
|
||
borderRadius: 14,
|
||
border: 'none',
|
||
background: 'var(--md-sys-color-primary)',
|
||
color: 'var(--md-sys-color-on-primary)',
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||
opacity: isLoading ? 0.7 : 1,
|
||
}}
|
||
>
|
||
{publishing ? 'Публикация...' : 'Опубликовать ДЗ'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleSave}
|
||
disabled={isLoading}
|
||
style={{
|
||
padding: '14px 28px',
|
||
borderRadius: 14,
|
||
border: '1px solid var(--md-sys-color-outline)',
|
||
background: 'transparent',
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||
opacity: isLoading ? 0.7 : 1,
|
||
}}
|
||
>
|
||
{saving ? 'Сохранение...' : 'Сохранить черновик'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|