uchill/front_material/components/homework/EditHomeworkDraftModal.tsx

719 lines
25 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';
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>
</>
);
}