'use client'; import { useCallback, useEffect, useState, useMemo, useRef, type FormEvent } from 'react'; import { loadComponent } from '@/lib/material-components'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { ErrorDisplay } from '@/components/common/ErrorDisplay'; import { createMaterial, updateMaterial, shareMaterial, getMaterialById, getMaterials, type Material } from '@/api/materials'; import { getStudents, type Student } from '@/api/students'; import { useAuth } from '@/contexts/AuthContext'; // Иконки по типу материала const MATERIAL_ICONS: Record = { image: 'image', video: 'videocam', audio: 'audiotrack', document: 'description', presentation: 'slideshow', archive: 'folder_zip', other: 'insert_drive_file', }; // Определить тип для иконки по material_type, MIME и расширению файла function getMaterialTypeForIcon(material: any): string { const type = material?.material_type; if (type && type !== 'other' && MATERIAL_ICONS[type]) return type; const mime = (material?.file_type || '').toLowerCase(); const name = material?.file_name || material?.file || ''; if (mime.startsWith('image/') || /\.(jpe?g|png|gif|webp|bmp|svg)(\?|$)/i.test(name)) return 'image'; if (mime.startsWith('video/') || /\.(mp4|webm|ogg|mov|avi)(\?|$)/i.test(name)) return 'video'; if (mime.startsWith('audio/') || /\.(mp3|wav|ogg|m4a)(\?|$)/i.test(name)) return 'audio'; if (mime.includes('pdf') || /\.pdf(\?|$)/i.test(name) || mime.includes('document') || /\.(docx?|odt)(\?|$)/i.test(name)) return 'document'; if (mime.includes('presentation') || mime.includes('powerpoint') || /\.(pptx?|odp)(\?|$)/i.test(name)) return 'presentation'; if (mime.includes('zip') || mime.includes('rar') || mime.includes('archive') || /\.(zip|rar|7z|tar|gz)(\?|$)/i.test(name)) return 'archive'; return 'other'; } function getMaterialIcon(material: any): string { const type = getMaterialTypeForIcon(material); return MATERIAL_ICONS[type] || MATERIAL_ICONS.other; } // Базовый URL медиа (тот же хост, что и API) function getMediaBaseUrl(): string { if (typeof window === 'undefined') return ''; const protocol = window.location.protocol; const hostname = window.location.hostname; return `${protocol}//${hostname}:8123`; } // Получить URL медиа для превью: собираем на фронте, чтобы хост совпадал с API function getMediaUrl(material: any): string | null { if (!material) return null; const base = getMediaBaseUrl(); if (material.file) { const f = String(material.file).trim(); if (f.startsWith('http')) return f; // Бэкенд отдаёт путь вида /media/materials/... или materials/... const path = f.startsWith('/') ? f : `/${f}`; return `${base}${path}`; } if (material.file_url) return material.file_url; return material.url || null; } const IMAGE_MIME_PREFIX = 'image/'; const IMAGE_EXT = /\.(jpe?g|png|gif|webp|bmp|svg)(\?|$)/i; const VIDEO_MIME_PREFIX = 'video/'; const VIDEO_EXT = /\.(mp4|webm|ogg|mov|avi)(\?|$)/i; // Сократить строку до 8 символов (с многоточием при обрезке) function truncateTo8(s: string): string { const t = (s || '').trim(); if (t.length <= 8) return t; return t.slice(0, 8) + '…'; } // Ключ категории типа файла (для фильтра) type FileTypeCategory = 'image' | 'video' | 'audio' | 'document' | 'presentation' | 'archive' | 'other'; function getFileTypeCategory(material: any): FileTypeCategory { const name = (material?.file_name || material?.file || '').toLowerCase(); const mime = (material?.file_type || '').toLowerCase(); const ext = name.match(/\.([a-z0-9]+)(\?|$)/i)?.[1]?.toLowerCase() || ''; if (mime.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return 'image'; if (mime.startsWith('video/') || ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'].includes(ext)) return 'video'; if (mime.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'm4a', 'flac'].includes(ext)) return 'audio'; if (mime.includes('pdf') || mime.includes('document') || ['pdf', 'doc', 'docx', 'odt', 'txt', 'rtf'].includes(ext)) return 'document'; if (mime.includes('presentation') || mime.includes('powerpoint') || ['ppt', 'pptx', 'odp'].includes(ext)) return 'presentation'; if (mime.includes('zip') || mime.includes('rar') || ['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return 'archive'; return 'other'; } // Подпись типа файла для отображения: Картинка, Документ, Аудио, Видео и т.д. const FILE_TYPE_LABELS: Record = { image: 'Картинка', video: 'Видео', audio: 'Аудио', document: 'Документ', presentation: 'Презентация', archive: 'Архив', other: 'Файл', }; function getFileTypeLabel(material: any): string { return FILE_TYPE_LABELS[getFileTypeCategory(material)]; } // Расширение файла для сообщения «Не удалось открыть файл .xxx» function getFileExtension(material: any): string { const name = (material?.file_name || material?.file || '').toLowerCase(); const m = name.match(/\.([a-z0-9]+)(\?|$)/i); return m ? `.${m[1]}` : ''; } // Типы, которые браузер не может отобразить — вместо iframe показываем модальное сообщение const BROWSER_CANNOT_DISPLAY = [ 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'zip', 'rar', '7z', 'tar', 'gz', 'exe', 'dmg', 'msi', ]; function cannotDisplayInBrowser(material: any): boolean { const ext = getFileExtension(material).replace(/^\./, '').toLowerCase(); return BROWSER_CANNOT_DISPLAY.includes(ext); } function isImageMaterial(material: any): boolean { if (material.material_type === 'image') return true; const mime = (material.file_type || '').toLowerCase(); if (mime.startsWith(IMAGE_MIME_PREFIX)) return true; const name = material.file_name || material.file || ''; return IMAGE_EXT.test(name); } function isVideoMaterial(material: any): boolean { if (material.material_type === 'video') return true; const mime = (material.file_type || '').toLowerCase(); if (mime.startsWith(VIDEO_MIME_PREFIX)) return true; const name = material.file_name || material.file || ''; return VIDEO_EXT.test(name); } const PDF_EXT = /\.pdf(\?|$)/i; const TEXT_EXT = /\.(txt|md|py|php|js|ts|jsx|tsx|vue|json|html|htm|css|scss|less|xml|csv|rtf|log|yml|yaml|env|sql|sh|bat|cmd|ini|cfg|conf)(\?|$)/i; function isPdfMaterial(material: any): boolean { const mime = (material?.file_type || '').toLowerCase(); if (mime.includes('pdf')) return true; const name = (material?.file_name || material?.file || '').toLowerCase(); return PDF_EXT.test(name); } function isTextPreviewMaterial(material: any): boolean { const mime = (material?.file_type || '').toLowerCase(); if (mime.startsWith('text/') || mime.includes('json') || mime.includes('javascript') || mime.includes('xml')) return true; const name = (material?.file_name || material?.file || '').toLowerCase(); return TEXT_EXT.test(name); } const FILE_TYPE_CHIPS: { value: FileTypeCategory | null; label: string }[] = [ { value: 'image', label: 'Картинка' }, { value: 'document', label: 'Документ' }, { value: 'audio', label: 'Аудио' }, { value: 'video', label: 'Видео' }, { value: 'presentation', label: 'Презентация' }, { value: 'archive', label: 'Архив' }, { value: 'other', label: 'Файл' }, ]; const SEARCH_DEBOUNCE_MS = 400; const TEXT_PREVIEW_MAX_CHARS = 1200; const TEXT_PREVIEW_LINES = 18; function MaterialTextPreview({ url }: { url: string }) { const [text, setText] = useState(null); const [failed, setFailed] = useState(false); useEffect(() => { let cancelled = false; setFailed(false); setText(null); fetch(url) .then((r) => { if (!r.ok) throw new Error('fetch failed'); return r.text(); }) .then((t) => { if (!cancelled) setText(t.slice(0, TEXT_PREVIEW_MAX_CHARS)); }) .catch(() => { if (!cancelled) setFailed(true); }); return () => { cancelled = true; }; }, [url]); if (failed) { return (
description
); } if (text === null) { return (
Загрузка…
); } const lines = text.split(/\r?\n/).slice(0, TEXT_PREVIEW_LINES); const display = lines.join('\n') + (text.length >= TEXT_PREVIEW_MAX_CHARS ? '\n…' : ''); return (
      {display || ' (пусто)'}
    
); } const TEXT_FULL_MAX_CHARS = 500000; function MaterialTextPreviewFull({ url }: { url: string }) { const [text, setText] = useState(null); const [failed, setFailed] = useState(false); useEffect(() => { let cancelled = false; setFailed(false); setText(null); fetch(url) .then((r) => { if (!r.ok) throw new Error('fetch failed'); return r.text(); }) .then((t) => { if (!cancelled) setText(t.length > TEXT_FULL_MAX_CHARS ? t.slice(0, TEXT_FULL_MAX_CHARS) + '\n\n… (файл обрезан)' : t); }) .catch(() => { if (!cancelled) setFailed(true); }); return () => { cancelled = true; }; }, [url]); if (failed) { return (
Не удалось загрузить содержимое
); } if (text === null) { return (
Загрузка…
); } return (
      {text || ' (пусто)'}
    
); } export default function MaterialsPage() { const { user } = useAuth(); const isClient = user?.role === 'client'; const [componentsLoaded, setComponentsLoaded] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchQueryDebounced, setSearchQueryDebounced] = useState(''); const [fileTypeFilter, setFileTypeFilter] = useState(null); const [mentorFilter, setMentorFilter] = useState(null); const [openMenuId, setOpenMenuId] = useState(null); // Состояние для редактирования материала const [editingMaterial, setEditingMaterial] = useState(null); const [editFormData, setEditFormData] = useState({ title: '', description: '' }); const [editFile, setEditFile] = useState(null); const [editLoading, setEditLoading] = useState(false); const [editError, setEditError] = useState(null); // Состояние для выбора учеников const [students, setStudents] = useState([]); const [studentsLoading, setStudentsLoading] = useState(false); const [selectedStudentIds, setSelectedStudentIds] = useState([]); const [studentSearch, setStudentSearch] = useState(''); // Состояние для просмотра материала const [previewMaterial, setPreviewMaterial] = useState(null); // Панель «Добавить материал» (выдвижная справа) const [addPanelOpen, setAddPanelOpen] = useState(false); const [addTitle, setAddTitle] = useState(''); const [addDescription, setAddDescription] = useState(''); const [addFile, setAddFile] = useState(null); const [addFilePreviewUrl, setAddFilePreviewUrl] = useState(null); const [addShareStudentIds, setAddShareStudentIds] = useState([]); const [addStudentSearch, setAddStudentSearch] = useState(''); const [addLoading, setAddLoading] = useState(false); const [addError, setAddError] = useState(null); const [addStudentsLoaded, setAddStudentsLoaded] = useState(false); const [addStudentsList, setAddStudentsList] = useState([]); const [addStudentSelectOpen, setAddStudentSelectOpen] = useState(false); const addStudentSelectRef = useRef(null); useEffect(() => { Promise.all([ loadComponent('elevated-card'), loadComponent('filled-text-field'), loadComponent('filled-button'), loadComponent('icon'), ]).then(() => { setComponentsLoaded(true); }); }, []); // Debounce поиска — уменьшаем количество запросов при вводе useEffect(() => { const timer = setTimeout(() => { setSearchQueryDebounced(searchQuery); }, SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); }, [searchQuery]); // Список материалов с подгрузкой по скроллу: первые 10, затем страницами const PAGE_SIZE = 10; const [materialsList, setMaterialsList] = useState([]); const [materialsPage, setMaterialsPage] = useState(1); const [materialsHasMore, setMaterialsHasMore] = useState(true); const [materialsLoading, setMaterialsLoading] = useState(true); const [materialsLoadingMore, setMaterialsLoadingMore] = useState(false); const [materialsError, setMaterialsError] = useState(null); const loadMoreSentinelRef = useRef(null); const searchForRef = useRef(''); const loadMaterialsPage = useCallback(async (page: number, append: boolean) => { const search = searchQueryDebounced.trim() || undefined; searchForRef.current = search ?? ''; const isFirst = page === 1; if (isFirst) { setMaterialsLoading(true); } else { setMaterialsLoadingMore(true); } setMaterialsError(null); try { const data = await getMaterials({ page, page_size: PAGE_SIZE, search, }); if (searchForRef.current !== (search ?? '')) return; const list = data.results || []; setMaterialsList((prev) => { if (!append) return list; const prevIds = new Set(prev.map((m: any) => m.id)); const newItems = list.filter((m: any) => !prevIds.has(m.id)); return newItems.length === 0 ? prev : [...prev, ...newItems]; }); setMaterialsHasMore(!!data.next); setMaterialsPage(page); } catch (err: any) { if (searchForRef.current !== (search ?? '')) return; setMaterialsError(err instanceof Error ? err : new Error(err?.message || 'Ошибка загрузки')); } finally { if (searchForRef.current === (search ?? '')) { setMaterialsLoading(false); setMaterialsLoadingMore(false); } } }, [searchQueryDebounced]); // Первая загрузка и сброс при смене поиска useEffect(() => { setMaterialsList([]); setMaterialsPage(1); setMaterialsHasMore(true); loadMaterialsPage(1, false); }, [searchQueryDebounced]); // Подгрузка по скроллу (IntersectionObserver) useEffect(() => { if (!materialsHasMore || materialsLoadingMore || materialsLoading) return; const el = loadMoreSentinelRef.current; if (!el) return; const observer = new IntersectionObserver( (entries) => { if (!entries[0]?.isIntersecting) return; loadMaterialsPage(materialsPage + 1, true); }, { rootMargin: '200px', threshold: 0.1 } ); observer.observe(el); return () => observer.disconnect(); }, [materialsHasMore, materialsLoadingMore, materialsLoading, materialsPage, loadMaterialsPage]); const refetch = useCallback(() => { setMaterialsList([]); setMaterialsPage(1); setMaterialsHasMore(true); loadMaterialsPage(1, false); }, [loadMaterialsPage]); const mutate = useCallback((updater: (prev: any[]) => any[]) => { setMaterialsList(updater); }, []); const materials = materialsList; // Определяем какие категории имеют файлы const availableCategories = useMemo(() => { const categories = new Set(); materials.forEach((m: any) => { categories.add(getFileTypeCategory(m)); }); return categories; }, [materials]); // Фильтруем чипы - показываем только те, для которых есть файлы const visibleChips = useMemo(() => { return FILE_TYPE_CHIPS.filter(({ value }) => value && availableCategories.has(value)); }, [availableCategories]); // Уникальные менторы (владельцы материалов) — для чипов у студента const mentorChips = useMemo(() => { const seen = new Set(); const list: { id: number; name: string }[] = []; materials.forEach((m: any) => { const owner = m.owner; if (!owner?.id) return; if (seen.has(owner.id)) return; seen.add(owner.id); const name = [owner.first_name, owner.last_name].filter(Boolean).join(' ') || owner.email || `Ментор ${owner.id}`; list.push({ id: owner.id, name: name.trim() || 'Без имени' }); }); return list.sort((a, b) => a.name.localeCompare(b.name)); }, [materials]); const filteredMaterials = useMemo( () => materials.filter((m: any) => { const matchesType = !fileTypeFilter || getFileTypeCategory(m) === fileTypeFilter; const matchesMentor = !mentorFilter || (m.owner?.id === mentorFilter); return matchesType && matchesMentor; }), [materials, fileTypeFilter, mentorFilter] ); // Закрытие выпадающего списка учеников по клику снаружи useEffect(() => { if (!addStudentSelectOpen) return; const handle = (e: MouseEvent) => { if (addStudentSelectRef.current && !addStudentSelectRef.current.contains(e.target as Node)) { setAddStudentSelectOpen(false); } }; document.addEventListener('mousedown', handle); return () => document.removeEventListener('mousedown', handle); }, [addStudentSelectOpen]); // Загрузка списка учеников при открытии панели добавления useEffect(() => { if (!addPanelOpen) return; setAddStudentsLoaded(false); getStudents({ page_size: 1000 }) .then((res) => { setAddStudentsList(res.results || []); }) .catch(() => setAddStudentsList([])) .finally(() => setAddStudentsLoaded(true)); }, [addPanelOpen]); const closeAddPanel = () => { if (addLoading) return; setAddPanelOpen(false); setAddTitle(''); setAddDescription(''); setAddFile(null); if (addFilePreviewUrl) { URL.revokeObjectURL(addFilePreviewUrl); setAddFilePreviewUrl(null); } setAddShareStudentIds([]); setAddStudentSearch(''); setAddError(null); }; // Превью выбранного файла: object URL для изображений useEffect(() => { if (!addFile) { if (addFilePreviewUrl) { URL.revokeObjectURL(addFilePreviewUrl); setAddFilePreviewUrl(null); } return; } if (addFile.type.startsWith('image/')) { const url = URL.createObjectURL(addFile); setAddFilePreviewUrl(url); return () => { URL.revokeObjectURL(url); setAddFilePreviewUrl(null); }; } if (addFilePreviewUrl) { URL.revokeObjectURL(addFilePreviewUrl); setAddFilePreviewUrl(null); } }, [addFile]); const handleAddSubmit = async (e: FormEvent) => { e.preventDefault(); setAddError(null); if (!addTitle.trim()) { setAddError('Укажите название материала'); return; } if (!addFile) { setAddError('Выберите файл для загрузки'); return; } setAddLoading(true); try { const created = await createMaterial({ title: addTitle.trim(), description: addDescription.trim() || undefined, file: addFile, }); if (addShareStudentIds.length > 0) { try { await shareMaterial(created.id, addShareStudentIds); } catch { // материал уже создан } } refetch(); closeAddPanel(); } catch (err: any) { setAddError(err?.message || 'Ошибка при создании материала'); } finally { setAddLoading(false); } }; if (!componentsLoaded) { return (
Загрузка...
); } return (
{!isClient && ( )}
{ const input = (e.currentTarget as HTMLElement).querySelector('input'); input?.focus(); }} > setSearchQuery(e.target.value)} placeholder="Поиск по названию, описанию или имени файла..." style={{ width: '100%', padding: '12px 16px 12px 44px', fontSize: 16, border: '1px solid var(--md-sys-color-outline)', borderRadius: 12, background: 'var(--md-sys-color-surface)', color: 'var(--md-sys-color-on-surface)', outline: 'none', boxSizing: 'border-box', }} /> search
{/* Чипы «Ментор» — только для студента: фильтр по тому, кто дал материал */} {isClient && mentorChips.length > 0 && (
{mentorChips.map(({ id, name }) => { const isSelected = mentorFilter === id; return ( ); })}
)} {/* Кастомные чипы типов файлов - показываем только если есть файлы */} {visibleChips.length > 0 && (
{visibleChips.map(({ value, label }) => { const isSelected = fileTypeFilter === value; return ( ); })}
)} {materialsError && materialsError.message !== 'canceled' && ( )} {/* Прогресс поиска — тонкая полоска под полем поиска */} {materialsLoading && (
)} {materialsLoading && materialsList.length === 0 ? ( ) : filteredMaterials.length === 0 ? ( folder

{searchQuery ? 'Материалы не найдены' : 'Нет материалов'}

) : (
{filteredMaterials.map((material: any) => { const mediaUrl = getMediaUrl(material); const isImage = isImageMaterial(material) && mediaUrl; const isVideo = isVideoMaterial(material) && mediaUrl; const isPdf = isPdfMaterial(material) && mediaUrl; const isText = isTextPreviewMaterial(material) && mediaUrl; const iconName = getMaterialIcon(material); const ownerName = material.owner ? [material.owner.first_name, material.owner.last_name].filter(Boolean).join(' ') || material.owner.email : ''; return ( { e.currentTarget.style.transform = 'translateY(-4px)'; e.currentTarget.style.boxShadow = 'var(--ios26-shadow-hover)'; }} onMouseLeave={(e: any) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = 'var(--ios26-shadow)'; }} > {/* Шапка карточки: заголовок; меню Редактировать/Удалить только для ментора */}
{truncateTo8(material.title || material.file_name || 'Без названия')}
{!isClient && (
{openMenuId === material.id && ( <>
setOpenMenuId(null)} onKeyDown={(e) => e.key === 'Escape' && setOpenMenuId(null)} aria-label="Закрыть меню" />
)}
)}
{/* Превью: фото, видео, PDF, текст или иконка — максимальная область */}
{isImage && ( // eslint-disable-next-line @next/next/no-img-element {material.title} )} {isVideo && ( <>