uchill/front_material/app/(protected)/materials/page.tsx

2375 lines
98 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 { 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<string, string> = {
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<FileTypeCategory, string> = {
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<string | null>(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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
<span className="material-symbols-outlined" style={{ fontSize: 48, color: 'var(--md-sys-color-primary)' }}>description</span>
</div>
);
}
if (text === null) {
return (
<div style={{ padding: 16, fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}>Загрузка</div>
);
}
const lines = text.split(/\r?\n/).slice(0, TEXT_PREVIEW_LINES);
const display = lines.join('\n') + (text.length >= TEXT_PREVIEW_MAX_CHARS ? '\n…' : '');
return (
<pre
style={{
margin: 0,
padding: '12px 14px',
fontSize: 11,
lineHeight: 1.45,
fontFamily: 'ui-monospace, monospace',
color: 'var(--md-sys-color-on-surface)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: TEXT_PREVIEW_LINES,
WebkitBoxOrient: 'vertical',
textAlign: 'left',
height: '100%',
boxSizing: 'border-box',
}}
>
{display || ' (пусто)'}
</pre>
);
}
const TEXT_FULL_MAX_CHARS = 500000;
function MaterialTextPreviewFull({ url }: { url: string }) {
const [text, setText] = useState<string | null>(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 (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)' }}>
Не удалось загрузить содержимое
</div>
);
}
if (text === null) {
return (
<div style={{ padding: 24, fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)' }}>Загрузка</div>
);
}
return (
<pre
style={{
margin: 0,
padding: 16,
fontSize: 13,
lineHeight: 1.5,
fontFamily: 'ui-monospace, monospace',
color: 'var(--md-sys-color-on-surface)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
textAlign: 'left',
minWidth: 0,
}}
>
{text || ' (пусто)'}
</pre>
);
}
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<FileTypeCategory | null>(null);
const [mentorFilter, setMentorFilter] = useState<number | null>(null);
const [openMenuId, setOpenMenuId] = useState<number | null>(null);
// Состояние для редактирования материала
const [editingMaterial, setEditingMaterial] = useState<Material | null>(null);
const [editFormData, setEditFormData] = useState({ title: '', description: '' });
const [editFile, setEditFile] = useState<File | null>(null);
const [editLoading, setEditLoading] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
// Состояние для выбора учеников
const [students, setStudents] = useState<Student[]>([]);
const [studentsLoading, setStudentsLoading] = useState(false);
const [selectedStudentIds, setSelectedStudentIds] = useState<string[]>([]);
const [studentSearch, setStudentSearch] = useState('');
// Состояние для просмотра материала
const [previewMaterial, setPreviewMaterial] = useState<any | null>(null);
// Панель «Добавить материал» (выдвижная справа)
const [addPanelOpen, setAddPanelOpen] = useState(false);
const [addTitle, setAddTitle] = useState('');
const [addDescription, setAddDescription] = useState('');
const [addFile, setAddFile] = useState<File | null>(null);
const [addFilePreviewUrl, setAddFilePreviewUrl] = useState<string | null>(null);
const [addShareStudentIds, setAddShareStudentIds] = useState<string[]>([]);
const [addStudentSearch, setAddStudentSearch] = useState('');
const [addLoading, setAddLoading] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
const [addStudentsLoaded, setAddStudentsLoaded] = useState(false);
const [addStudentsList, setAddStudentsList] = useState<Student[]>([]);
const [addStudentSelectOpen, setAddStudentSelectOpen] = useState(false);
const addStudentSelectRef = useRef<HTMLDivElement>(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<any[]>([]);
const [materialsPage, setMaterialsPage] = useState(1);
const [materialsHasMore, setMaterialsHasMore] = useState(true);
const [materialsLoading, setMaterialsLoading] = useState(true);
const [materialsLoadingMore, setMaterialsLoadingMore] = useState(false);
const [materialsError, setMaterialsError] = useState<Error | null>(null);
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
const searchForRef = useRef<string>('');
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<FileTypeCategory>();
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<number>();
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 (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '50vh' }}>
<div>Загрузка...</div>
</div>
);
}
return (
<div style={{ padding: '24px' }} data-tour="materials-root">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '16px',
gap: 16,
}}
>
<div style={{ flex: 1, minWidth: 0 }} />
{!isClient && (
<button
type="button"
data-tour="materials-add"
onClick={() => setAddPanelOpen(true)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '10px 16px',
fontSize: 14,
fontWeight: 500,
color: 'var(--md-sys-color-on-primary)',
background: 'var(--md-sys-color-primary)',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>add</span>
Добавить материал
</button>
)}
</div>
<div
style={{
marginBottom: '24px',
position: 'relative',
width: '100%',
cursor: 'text',
}}
onClick={(e) => {
const input = (e.currentTarget as HTMLElement).querySelector('input');
input?.focus();
}}
>
<input
type="text"
value={searchQuery}
onChange={(e) => 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',
}}
/>
<md-icon
style={{
position: 'absolute',
left: 14,
top: '50%',
transform: 'translateY(-50%)',
fontSize: 20,
color: 'var(--md-sys-color-on-surface-variant)',
pointerEvents: 'none',
}}
>
search
</md-icon>
</div>
{/* Чипы «Ментор» — только для студента: фильтр по тому, кто дал материал */}
{isClient && mentorChips.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '16px' }}>
<button
type="button"
onClick={() => setMentorFilter(null)}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
height: '32px',
padding: '0 16px',
borderRadius: '8px',
fontSize: '14px',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s ease',
border: !mentorFilter ? 'none' : '1px solid var(--md-sys-color-outline)',
background: !mentorFilter ? 'var(--md-sys-color-primary)' : 'transparent',
color: !mentorFilter ? 'var(--md-sys-color-on-primary)' : 'var(--md-sys-color-on-surface)',
}}
>
Все менторы
</button>
{mentorChips.map(({ id, name }) => {
const isSelected = mentorFilter === id;
return (
<button
key={id}
type="button"
onClick={() => {
if (mentorFilter === id) {
setMentorFilter(null);
} else {
setMentorFilter(id);
}
}}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
height: '32px',
padding: '0 16px',
borderRadius: '8px',
fontSize: '14px',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s ease',
border: isSelected ? 'none' : '1px solid var(--md-sys-color-outline)',
background: isSelected ? 'var(--md-sys-color-primary)' : 'transparent',
color: isSelected ? 'var(--md-sys-color-on-primary)' : 'var(--md-sys-color-on-surface)',
}}
>
{name}
</button>
);
})}
</div>
)}
{/* Кастомные чипы типов файлов - показываем только если есть файлы */}
{visibleChips.length > 0 && (
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
marginBottom: '24px'
}}>
{visibleChips.map(({ value, label }) => {
const isSelected = fileTypeFilter === value;
return (
<button
key={value ?? 'all'}
type="button"
onClick={() => {
if (fileTypeFilter === value) {
setFileTypeFilter(null);
} else {
setFileTypeFilter(value);
}
}}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
height: '32px',
padding: '0 16px',
borderRadius: '8px',
fontSize: '14px',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s ease',
border: isSelected ? 'none' : '1px solid var(--md-sys-color-outline)',
background: isSelected ? 'var(--md-sys-color-primary)' : 'transparent',
color: isSelected ? 'var(--md-sys-color-on-primary)' : 'var(--md-sys-color-on-surface)',
}}
>
{label}
</button>
);
})}
</div>
)}
{materialsError && materialsError.message !== 'canceled' && (
<ErrorDisplay error={materialsError} onRetry={refetch} />
)}
{/* Прогресс поиска — тонкая полоска под полем поиска */}
{materialsLoading && (
<div
style={{
height: 3,
background: 'var(--md-sys-color-outline-variant)',
borderRadius: 2,
marginBottom: 24,
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
width: '40%',
background: 'var(--md-sys-color-primary)',
borderRadius: 2,
animation: 'searchProgress 1.2s ease-in-out infinite',
}}
/>
</div>
)}
{materialsLoading && materialsList.length === 0 ? (
<LoadingSpinner size="large" />
) : filteredMaterials.length === 0 ? (
<md-elevated-card style={{ padding: '40px', borderRadius: '20px', textAlign: 'center' }}>
<md-icon style={{ fontSize: '64px', color: 'var(--md-sys-color-on-surface-variant)', marginBottom: '16px' }}>
folder
</md-icon>
<p style={{ fontSize: '16px', color: 'var(--md-sys-color-on-surface-variant)' }}>
{searchQuery ? 'Материалы не найдены' : 'Нет материалов'}
</p>
</md-elevated-card>
) : (
<div
className="materials-cards-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(min(340px, 100%), 1fr))',
gap: '24px',
opacity: materialsLoading ? 0.7 : 1,
transition: 'opacity 0.2s ease',
}}
>
{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 (
<md-elevated-card
key={material.id}
className="ios26-panel"
style={{
borderRadius: 'var(--ios26-radius-md)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.2s, box-shadow 0.25s ease',
background: 'var(--ios26-glass)',
backdropFilter: 'var(--ios26-blur)',
WebkitBackdropFilter: 'var(--ios26-blur)',
border: '1px solid var(--ios26-glass-border)',
boxShadow: 'var(--ios26-shadow)',
}}
onMouseEnter={(e: any) => {
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)';
}}
>
{/* Шапка карточки: заголовок; меню Редактировать/Удалить только для ментора */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '16px 16px 0',
position: 'relative',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
{truncateTo8(material.title || material.file_name || 'Без названия')}
</div>
</div>
{!isClient && (
<div style={{ position: 'relative' }}>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpenMenuId((prev) => (prev === material.id ? null : material.id));
}}
style={{
background: 'none',
border: 'none',
padding: 8,
cursor: 'pointer',
color: 'var(--md-sys-color-on-surface-variant)',
}}
aria-label="Меню"
>
<md-icon>more_vert</md-icon>
</button>
{openMenuId === material.id && (
<>
<div
role="button"
tabIndex={0}
style={{ position: 'fixed', inset: 0, zIndex: 10 }}
onClick={() => setOpenMenuId(null)}
onKeyDown={(e) => e.key === 'Escape' && setOpenMenuId(null)}
aria-label="Закрыть меню"
/>
<div
style={{
position: 'absolute',
right: 0,
top: '100%',
marginTop: 4,
zIndex: 11,
minWidth: 160,
padding: '8px 0',
background: 'var(--md-sys-color-surface-container-high)',
borderRadius: 12,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}
>
<button
type="button"
onClick={async () => {
setOpenMenuId(null);
// Открываем sidebar редактирования
setEditingMaterial(material);
setEditFormData({
title: material.title || '',
description: material.description || '',
});
setEditFile(null);
setEditError(null);
setStudentSearch('');
// Загружаем список студентов
setStudentsLoading(true);
try {
const studentsData = await getStudents({ page_size: 1000 });
setStudents(studentsData.results || []);
// Загружаем полные данные материала для получения shared_with
const fullMaterial = await getMaterialById(material.id);
const sharedIds = fullMaterial.shared_with?.map((u) => String(u.id)) || [];
setSelectedStudentIds(sharedIds);
} catch (err) {
console.error('Ошибка загрузки данных:', err);
} finally {
setStudentsLoading(false);
}
}}
style={{
display: 'block',
width: '100%',
padding: '10px 16px',
border: 'none',
background: 'none',
textAlign: 'left',
fontSize: 14,
color: 'var(--md-sys-color-on-surface)',
cursor: 'pointer',
}}
>
Редактировать
</button>
<button
type="button"
onClick={() => {
setOpenMenuId(null);
// TODO: удалить материал (подтверждение + API)
}}
style={{
display: 'block',
width: '100%',
padding: '10px 16px',
border: 'none',
background: 'none',
textAlign: 'left',
fontSize: 14,
color: 'var(--md-sys-color-error)',
cursor: 'pointer',
}}
>
Удалить
</button>
</div>
</>
)}
</div>
)}
</div>
{/* Превью: фото, видео, PDF, текст или иконка — максимальная область */}
<div
style={{
width: '100%',
aspectRatio: '4/3',
minHeight: 200,
background: 'var(--md-sys-color-surface-container-low)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: 12,
position: 'relative',
overflow: 'hidden',
}}
>
{isImage && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={mediaUrl}
alt={material.title}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
)}
{isVideo && (
<>
<video
src={mediaUrl}
preload="metadata"
muted
playsInline
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.25)',
}}
>
<md-icon style={{ fontSize: 64, color: 'rgba(255,255,255,0.95)' }}>play_circle_filled</md-icon>
</div>
</>
)}
{isPdf && !isImage && !isVideo && (
<div
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
}}
>
<iframe
src={`${mediaUrl}#toolbar=0&view=FitH`}
title={material.title || 'PDF'}
style={{
position: 'absolute',
top: 0,
left: 0,
width: 'calc(100% + 20px)',
height: '100%',
border: 'none',
background: 'var(--md-sys-color-surface-container-low)',
pointerEvents: 'none',
}}
/>
{/* Невидимый слой поверх — блокирует скролл и клики по iframe */}
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 1,
cursor: 'default',
}}
aria-hidden
/>
</div>
)}
{isText && !isImage && !isVideo && !isPdf && (
<div style={{ width: '100%', height: '100%', overflow: 'hidden', background: 'var(--md-sys-color-surface)' }}>
<MaterialTextPreview url={mediaUrl} />
</div>
)}
{!isImage && !isVideo && !isPdf && !isText && (
<span
className="material-symbols-outlined"
style={{
fontSize: 64,
color: 'var(--md-sys-color-primary)',
fontVariationSettings: "'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48",
}}
aria-hidden
>
{iconName}
</span>
)}
</div>
{/* Контент: тип файла + дата, описание */}
<div style={{ padding: '16px', flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}>
{getFileTypeLabel(material)}
{material.created_at && ` · ${new Date(material.created_at).toLocaleDateString('ru-RU')}`}
</div>
{material.description && (
<p
style={{
fontSize: 14,
color: 'var(--md-sys-color-on-surface-variant)',
margin: 0,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
lineHeight: 1.4,
}}
>
{material.description}
</p>
)}
{/* Кнопки: вторичная и основная */}
<div style={{ display: 'flex', gap: 8, marginTop: 'auto', paddingTop: 8 }}>
<button
type="button"
onClick={() => setPreviewMaterial({ ...material, mediaUrl })}
style={{
flex: 1,
padding: '10px 16px',
borderRadius: 20,
border: '1px solid var(--md-sys-color-primary)',
background: 'transparent',
color: 'var(--md-sys-color-primary)',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
}}
>
Открыть
</button>
<button
type="button"
onClick={() => {
if (mediaUrl) {
const a = document.createElement('a');
a.href = mediaUrl;
a.download = material.file_name || material.title || 'file';
a.click();
}
}}
style={{
flex: 1,
padding: '10px 16px',
borderRadius: 20,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
}}
>
Скачать
</button>
</div>
</div>
</md-elevated-card>
);
})}
{/* Сентинель для подгрузки по скроллу */}
{materialsHasMore && (
<div
ref={loadMoreSentinelRef}
style={{
gridColumn: '1 / -1',
display: 'flex',
justifyContent: 'center',
padding: '24px 0',
minHeight: 40,
}}
>
{materialsLoadingMore && (
<LoadingSpinner size="small" />
)}
</div>
)}
</div>
)}
{/* Модальное окно просмотра материала */}
{previewMaterial && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.8)',
zIndex: 200,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
}}
onClick={() => setPreviewMaterial(null)}
>
<div
style={{
background: 'var(--md-sys-color-surface)',
borderRadius: 20,
maxWidth: '90vw',
maxHeight: '90vh',
width: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
{previewMaterial.title || 'Без названия'}
</h2>
{previewMaterial.description && (
<p style={{ margin: '4px 0 0', fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)' }}>
{previewMaterial.description}
</p>
)}
</div>
<button
type="button"
onClick={() => setPreviewMaterial(null)}
style={{
background: 'none',
border: 'none',
padding: 8,
cursor: 'pointer',
color: 'var(--md-sys-color-on-surface-variant)',
marginLeft: 16,
}}
>
<md-icon>close</md-icon>
</button>
</div>
{/* Content — скролл и органы управления по умолчанию */}
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'stretch',
justifyContent: 'stretch',
padding: 20,
overflow: 'auto',
background: 'var(--md-sys-color-surface-container-low)',
minHeight: 300,
}}
>
{(() => {
const url = previewMaterial.mediaUrl;
const isImage = (previewMaterial.file_type || '').toLowerCase().startsWith('image/') ||
/\.(jpe?g|png|gif|webp|bmp|svg)(\?|$)/i.test((previewMaterial.file_name || previewMaterial.file || '').toLowerCase());
const isVideo = (previewMaterial.file_type || '').toLowerCase().startsWith('video/') ||
/\.(mp4|webm|ogg|mov|avi|mkv)(\?|$)/i.test((previewMaterial.file_name || previewMaterial.file || '').toLowerCase());
const isAudio = (previewMaterial.file_type || '').toLowerCase().startsWith('audio/') ||
/\.(mp3|wav|ogg|m4a|flac)(\?|$)/i.test((previewMaterial.file_name || previewMaterial.file || '').toLowerCase());
const isPdf = isPdfMaterial(previewMaterial);
const isText = isTextPreviewMaterial(previewMaterial);
// Изображение
if (url && isImage) {
return (
<img
src={url}
alt={previewMaterial.title}
style={{
maxWidth: '100%',
width: 'auto',
height: 'auto',
maxHeight: 'none',
objectFit: 'contain',
borderRadius: 12,
alignSelf: 'center',
}}
/>
);
}
// Видео — с управлением по умолчанию
if (url && isVideo) {
return (
<video
controls
autoPlay
src={url}
style={{
maxWidth: '100%',
maxHeight: '75vh',
width: 'auto',
borderRadius: 12,
alignSelf: 'center',
}}
>
Ваш браузер не поддерживает воспроизведение видео.
</video>
);
}
// Аудио
if (url && isAudio) {
return (
<div style={{ width: '100%', maxWidth: 500, textAlign: 'center', alignSelf: 'center' }}>
<md-icon style={{ fontSize: 80, color: 'var(--md-sys-color-primary)', marginBottom: 20 }}>
audiotrack
</md-icon>
<audio
controls
autoPlay
src={url}
style={{ width: '100%' }}
>
Ваш браузер не поддерживает воспроизведение аудио.
</audio>
</div>
);
}
// PDF — iframe с содержимым, скролл и панель по умолчанию
if (url && isPdf) {
return (
<iframe
src={url}
title={previewMaterial.title || 'PDF'}
style={{
width: '100%',
minHeight: '70vh',
border: 'none',
borderRadius: 12,
background: '#fff',
flex: 1,
}}
/>
);
}
// Текстовые файлы — полное содержимое со скроллом
if (url && isText) {
return (
<div style={{ width: '100%', overflow: 'auto', background: 'var(--md-sys-color-surface)', borderRadius: 12 }}>
<MaterialTextPreviewFull url={url} />
</div>
);
}
// Файлы, которые браузер не может отобразить (офис, архивы и т.д.) —
// не открываем в iframe (это вызывает скачивание), а показываем сообщение
if (url && cannotDisplayInBrowser(previewMaterial)) {
const fileName = (previewMaterial.file_name || previewMaterial.file || 'файл').split('/').pop() || 'файл';
return (
<div style={{
width: '100%',
flex: 1,
minHeight: 300,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
color: 'var(--md-sys-color-on-surface)',
padding: 32,
background: 'var(--md-sys-color-surface-container-high)',
borderRadius: 16,
}}>
<span className="material-symbols-outlined" style={{ fontSize: 64, color: 'var(--md-sys-color-error)', marginBottom: 16 }}>error_outline</span>
<p style={{ fontSize: 18, fontWeight: 600, margin: '0 0 8px' }}>
Не удалось открыть {fileName}
</p>
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', margin: 0 }}>
Этот формат нельзя просмотреть в браузере. Используйте кнопку «Скачать» ниже.
</p>
</div>
);
}
// Остальное — иконка и сообщение
const fileName = (previewMaterial.file_name || previewMaterial.file || 'файл').split('/').pop() || 'файл';
return (
<div style={{
width: '100%',
flex: 1,
minHeight: 300,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
color: 'var(--md-sys-color-on-surface)',
padding: 32,
background: 'var(--md-sys-color-surface-container-high)',
borderRadius: 16,
}}>
<span className="material-symbols-outlined" style={{ fontSize: 64, color: 'var(--md-sys-color-error)', marginBottom: 16 }}>error_outline</span>
<p style={{ fontSize: 18, fontWeight: 600, margin: '0 0 8px' }}>
Не удалось открыть {fileName}
</p>
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', margin: 0 }}>
Этот формат нельзя просмотреть в браузере. Используйте кнопку «Скачать» ниже.
</p>
</div>
);
})()}
</div>
{/* Footer */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderTop: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)' }}>
{previewMaterial.file_name && (
<span style={{ marginRight: 16 }}>{previewMaterial.file_name}</span>
)}
{previewMaterial.file_size && (
<span>{(previewMaterial.file_size / (1024 * 1024)).toFixed(2)} МБ</span>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{previewMaterial.mediaUrl && (
<button
type="button"
onClick={() => {
const a = document.createElement('a');
a.href = previewMaterial.mediaUrl;
a.download = previewMaterial.file_name || previewMaterial.title || 'file';
a.click();
}}
style={{
padding: '10px 20px',
borderRadius: 12,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
}}
>
Скачать
</button>
)}
<button
type="button"
onClick={() => setPreviewMaterial(null)}
style={{
padding: '10px 20px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'transparent',
color: 'var(--md-sys-color-on-surface)',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
}}
>
Закрыть
</button>
</div>
</div>
</div>
</div>
)}
{/* Sidebar для редактирования материала */}
{editingMaterial && (
<>
{/* Overlay */}
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.5)',
zIndex: 100,
}}
onClick={() => !editLoading && setEditingMaterial(null)}
/>
{/* Sidebar */}
<div
className="students-side-panel"
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: '100%',
maxWidth: 'min(400px, 100vw)',
background: 'var(--md-sys-color-surface)',
boxShadow: '-4px 0 20px rgba(0, 0, 0, 0.15)',
zIndex: 101,
display: 'flex',
flexDirection: 'column',
animation: 'slideInRight 0.3s ease',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
Редактировать
</h2>
<button
type="button"
onClick={() => !editLoading && setEditingMaterial(null)}
style={{
background: 'none',
border: 'none',
padding: 8,
cursor: editLoading ? 'not-allowed' : 'pointer',
color: 'var(--md-sys-color-on-surface-variant)',
opacity: editLoading ? 0.5 : 1,
}}
>
<md-icon>close</md-icon>
</button>
</div>
{/* Form */}
<form
onSubmit={async (e) => {
e.preventDefault();
if (!editingMaterial) return;
setEditLoading(true);
setEditError(null);
try {
const updatedMaterial = await updateMaterial(editingMaterial.id, {
title: editFormData.title,
description: editFormData.description,
file: editFile || undefined,
});
// Синхронизируем список учеников с доступом
try {
await shareMaterial(editingMaterial.id, selectedStudentIds);
} catch (shareErr) {
console.error('Ошибка выдачи доступа:', shareErr);
}
// Локально обновляем материал в списке без перезагрузки всей страницы
mutate((prev: any[]) =>
prev.map((m: any) =>
m.id === editingMaterial.id ? { ...m, ...updatedMaterial } : m
)
);
setEditingMaterial(null);
} catch (err: any) {
console.error('Ошибка обновления материала:', err);
setEditError(err.message || 'Не удалось обновить материал');
} finally {
setEditLoading(false);
}
}}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
padding: '20px',
gap: '16px',
overflowY: 'auto',
}}
>
{/* Название */}
<div>
<label
style={{
display: 'block',
marginBottom: 8,
fontSize: 14,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface)',
}}
>
Название *
</label>
<input
type="text"
required
value={editFormData.title}
onChange={(e) => setEditFormData({ ...editFormData, title: e.target.value })}
disabled={editLoading}
style={{
width: '100%',
padding: '12px 16px',
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',
}}
placeholder="Название материала"
/>
</div>
{/* Описание */}
<div>
<label
style={{
display: 'block',
marginBottom: 8,
fontSize: 14,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface)',
}}
>
Описание
</label>
<textarea
value={editFormData.description}
onChange={(e) => setEditFormData({ ...editFormData, description: e.target.value })}
disabled={editLoading}
rows={4}
style={{
width: '100%',
padding: '12px 16px',
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',
resize: 'vertical',
}}
placeholder="Описание материала..."
/>
</div>
{/* Новый файл */}
<div>
<label
style={{
display: 'block',
marginBottom: 8,
fontSize: 14,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface)',
}}
>
Новый файл (опционально)
</label>
<input
type="file"
id="edit-file-input"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const maxSize = 10 * 1024 * 1024; // 10 MB
if (file.size > maxSize) {
setEditError(`Файл слишком большой. Максимум 10 МБ.`);
return;
}
setEditFile(file);
setEditError(null);
}
}}
disabled={editLoading}
style={{ display: 'none' }}
/>
<label
htmlFor="edit-file-input"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: '12px 16px',
border: '1px dashed var(--md-sys-color-outline)',
borderRadius: 12,
cursor: editLoading ? 'not-allowed' : 'pointer',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
opacity: editLoading ? 0.5 : 1,
}}
>
<md-icon>upload_file</md-icon>
{editFile ? editFile.name : 'Выбрать файл'}
</label>
{editingMaterial.file_name && !editFile && (
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)' }}>
Текущий файл: {editingMaterial.file_name}
</p>
)}
{editFile && (
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)' }}>
Размер: {(editFile.size / (1024 * 1024)).toFixed(2)} МБ
</p>
)}
</div>
{/* Доступ для учеников */}
<div>
<label
style={{
display: 'block',
marginBottom: 8,
fontSize: 14,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface)',
}}
>
Ученики с доступом
</label>
{studentsLoading ? (
<div style={{ padding: '12px', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
Загрузка списка учеников...
</div>
) : students.length === 0 ? (
<div style={{ padding: '12px', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
У вас пока нет учеников
</div>
) : (
<div
style={{
border: '1px solid var(--md-sys-color-outline)',
borderRadius: 12,
overflow: 'hidden',
}}
>
{/* Поиск */}
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--md-sys-color-outline-variant)' }}>
<input
type="text"
value={studentSearch}
onChange={(e) => setStudentSearch(e.target.value)}
placeholder="Найти ученика..."
style={{
width: '100%',
padding: '8px 12px',
fontSize: 14,
border: '1px solid var(--md-sys-color-outline-variant)',
borderRadius: 8,
background: 'var(--md-sys-color-surface)',
color: 'var(--md-sys-color-on-surface)',
outline: 'none',
}}
/>
</div>
{/* Список учеников */}
<div style={{ maxHeight: 200, overflowY: 'auto', padding: '8px 0' }}>
{students
.filter((student) => {
if (!studentSearch.trim()) return true;
const query = studentSearch.toLowerCase();
const fullName = `${student.user.first_name} ${student.user.last_name}`.toLowerCase();
const email = student.user.email.toLowerCase();
return fullName.includes(query) || email.includes(query);
})
.map((student) => {
const fullName = `${student.user.first_name} ${student.user.last_name}`.trim();
const id = String(student.user.id);
const checked = selectedStudentIds.includes(id);
return (
<label
key={student.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 12px',
cursor: editLoading ? 'not-allowed' : 'pointer',
opacity: editLoading ? 0.5 : 1,
}}
>
<input
type="checkbox"
checked={checked}
disabled={editLoading}
onChange={(e) => {
setSelectedStudentIds((prev) =>
e.target.checked
? [...prev, id]
: prev.filter((x) => x !== id)
);
}}
style={{
width: 18,
height: 18,
accentColor: 'var(--md-sys-color-primary)',
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface)' }}>
{fullName || student.user.email}
</div>
{fullName && (
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)' }}>
{student.user.email}
</div>
)}
</div>
</label>
);
})}
</div>
</div>
)}
{selectedStudentIds.length > 0 && (
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)' }}>
Выбрано: {selectedStudentIds.length} {selectedStudentIds.length === 1 ? 'ученик' : selectedStudentIds.length < 5 ? 'ученика' : 'учеников'}
</p>
)}
</div>
{/* Ошибка */}
{editError && (
<div
style={{
padding: '12px 16px',
background: 'var(--md-sys-color-error-container)',
color: 'var(--md-sys-color-on-error-container)',
borderRadius: 12,
fontSize: 14,
}}
>
{editError}
</div>
)}
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Кнопки */}
<div style={{ display: 'flex', gap: 12 }}>
<button
type="button"
onClick={() => !editLoading && setEditingMaterial(null)}
disabled={editLoading}
style={{
flex: 1,
padding: '14px 20px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'transparent',
color: 'var(--md-sys-color-on-surface)',
fontSize: 14,
fontWeight: 500,
cursor: editLoading ? 'not-allowed' : 'pointer',
opacity: editLoading ? 0.5 : 1,
}}
>
Отмена
</button>
<button
type="submit"
disabled={editLoading}
style={{
flex: 1,
padding: '14px 20px',
borderRadius: 12,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 14,
fontWeight: 500,
cursor: editLoading ? 'not-allowed' : 'pointer',
opacity: editLoading ? 0.7 : 1,
}}
>
{editLoading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</form>
</div>
{/* CSS анимация */}
<style>{`
@keyframes slideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
`}</style>
</>
)}
{/* Панель «Добавить материал» — выдвижная справа */}
{addPanelOpen && (
<>
<div
role="presentation"
onClick={closeAddPanel}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.4)',
zIndex: 9998,
animation: 'fadeIn 0.2s ease-out',
}}
/>
<div
className="students-side-panel"
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: 'min(400px, 100vw)',
background: 'var(--md-sys-color-surface)',
boxShadow: '-4px 0 24px rgba(0,0,0,0.15)',
zIndex: 9999,
display: 'flex',
flexDirection: 'column',
animation: 'slideInRight 0.3s ease-out',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
Добавить материал
</h2>
<button
type="button"
onClick={closeAddPanel}
disabled={addLoading}
aria-label="Закрыть"
style={{
padding: 8,
border: 'none',
background: 'transparent',
color: 'var(--md-sys-color-on-surface-variant)',
cursor: addLoading ? 'not-allowed' : 'pointer',
borderRadius: 8,
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>close</span>
</button>
</div>
<form onSubmit={handleAddSubmit} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '20px' }}>
<div style={{ marginBottom: 16 }}>
<label htmlFor="add-title" style={{ display: 'block', fontSize: 14, fontWeight: 500, color: 'var(--md-sys-color-on-surface)', marginBottom: 6 }}>
Название *
</label>
<input
id="add-title"
type="text"
value={addTitle}
onChange={(e) => setAddTitle(e.target.value)}
placeholder="Название материала"
disabled={addLoading}
required
style={{
width: '100%',
padding: '12px 14px',
fontSize: 14,
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',
}}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label htmlFor="add-description" style={{ display: 'block', fontSize: 14, fontWeight: 500, color: 'var(--md-sys-color-on-surface)', marginBottom: 6 }}>
Описание
</label>
<textarea
id="add-description"
value={addDescription}
onChange={(e) => setAddDescription(e.target.value)}
placeholder="Описание материала..."
disabled={addLoading}
rows={3}
style={{
width: '100%',
padding: '12px 14px',
fontSize: 14,
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',
resize: 'vertical',
}}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, color: 'var(--md-sys-color-on-surface)', marginBottom: 6 }}>
Файл (до 10 МБ) *
</label>
<input
id="add-file"
type="file"
onChange={(e) => setAddFile(e.target.files?.[0] ?? null)}
disabled={addLoading}
style={{ display: 'none' }}
/>
<label
htmlFor="add-file"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: '14px',
border: '1px dashed var(--md-sys-color-outline)',
borderRadius: 12,
background: 'var(--md-sys-color-surface-container-high)',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
cursor: addLoading ? 'not-allowed' : 'pointer',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>upload_file</span>
{addFile ? addFile.name : 'Выберите файл'}
</label>
{addFile && (
<div
style={{
marginTop: 10,
borderRadius: 12,
overflow: 'hidden',
border: '1px solid var(--md-sys-color-outline-variant)',
background: 'var(--md-sys-color-surface-container-high)',
}}
>
{addFile.type.startsWith('image/') && addFilePreviewUrl ? (
<img
src={addFilePreviewUrl}
alt=""
style={{
display: 'block',
width: '100%',
maxHeight: 200,
objectFit: 'contain',
background: 'var(--md-sys-color-surface-container)',
}}
/>
) : (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '14px 16px',
}}
>
<span
className="material-symbols-outlined"
style={{ fontSize: 40, color: 'var(--md-sys-color-primary)' }}
>
description
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--md-sys-color-on-surface)' }}>
{addFile.name}
</div>
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 2 }}>
{addFile.size < 1024
? `${addFile.size} Б`
: addFile.size < 1024 * 1024
? `${(addFile.size / 1024).toFixed(1)} КБ`
: `${(addFile.size / (1024 * 1024)).toFixed(2)} МБ`}
</div>
</div>
</div>
)}
</div>
)}
</div>
<div style={{ marginBottom: 16 }} ref={addStudentSelectRef}>
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, color: 'var(--md-sys-color-on-surface)', marginBottom: 6 }}>
Ученики, которым доступен материал
</label>
{!addStudentsLoaded ? (
<div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)', padding: '12px 0' }}>Загрузка...</div>
) : addStudentsList.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)', padding: '12px 0' }}>Нет учеников</div>
) : (
<>
<button
type="button"
onClick={() => setAddStudentSelectOpen((o) => !o)}
disabled={addLoading}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '12px 14px',
fontSize: 14,
textAlign: 'left',
border: '1px solid var(--md-sys-color-outline)',
borderRadius: 12,
background: 'var(--md-sys-color-surface-container-high)',
color: 'var(--md-sys-color-on-surface)',
cursor: addLoading ? 'not-allowed' : 'pointer',
boxSizing: 'border-box',
minHeight: 48,
}}
>
{addShareStudentIds.length > 0 ? (
<>
<div style={{ display: 'flex', alignItems: 'center', marginRight: 4 }}>
{addStudentsList
.filter((s) => addShareStudentIds.includes(String(s.user?.id ?? s.id)))
.slice(0, 4)
.map((s, idx) => {
const id = String(s.user?.id ?? s.id);
const avatarUrl = s.user?.avatar_url || s.user?.avatar || null;
const name = `${s.user?.first_name || ''} ${s.user?.last_name || ''}`.trim() || s.user?.email || '';
const initial = name ? name[0].toUpperCase() : '?';
return (
<div
key={id}
style={{
width: 28,
height: 28,
borderRadius: '50%',
overflow: 'hidden',
marginLeft: idx === 0 ? 0 : -8,
border: '2px solid var(--md-sys-color-surface-container-high)',
background: 'var(--md-sys-color-primary-container)',
color: 'var(--md-sys-color-on-primary-container)',
fontSize: 12,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
title={name}
>
{avatarUrl ? (
<img src={avatarUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
initial
)}
</div>
);
})}
</div>
<span style={{ color: 'var(--md-sys-color-on-surface-variant)', fontSize: 13 }}>
Выбрано: {addShareStudentIds.length}
</span>
</>
) : (
<>
<span className="material-symbols-outlined" style={{ fontSize: 20, color: 'var(--md-sys-color-on-surface-variant)' }}>group</span>
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Выбрать учеников</span>
</>
)}
<span
className="material-symbols-outlined"
style={{
marginLeft: 'auto',
fontSize: 20,
color: 'var(--md-sys-color-on-surface-variant)',
transform: addStudentSelectOpen ? 'rotate(180deg)' : 'none',
transition: 'transform 0.2s ease',
}}
>
expand_more
</span>
</button>
{addStudentSelectOpen && (
<div
style={{
marginTop: 6,
border: '1px solid var(--md-sys-color-outline-variant)',
borderRadius: 12,
overflow: 'hidden',
background: 'var(--md-sys-color-surface)',
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
}}
>
<div style={{ padding: 8, borderBottom: '1px solid var(--md-sys-color-outline-variant)', position: 'relative' }}>
<span
className="material-symbols-outlined"
style={{
position: 'absolute',
left: 20,
top: '50%',
transform: 'translateY(-50%)',
fontSize: 18,
color: 'var(--md-sys-color-on-surface-variant)',
pointerEvents: 'none',
}}
>
search
</span>
<input
type="text"
value={addStudentSearch}
onChange={(e) => setAddStudentSearch(e.target.value)}
placeholder="Поиск по имени или email..."
style={{
width: '100%',
padding: '10px 12px 10px 40px',
fontSize: 14,
border: '1px solid var(--md-sys-color-outline)',
borderRadius: 8,
background: 'var(--md-sys-color-surface-container-high)',
color: 'var(--md-sys-color-on-surface)',
outline: 'none',
boxSizing: 'border-box',
}}
/>
</div>
<div style={{ maxHeight: 220, overflowY: 'auto', padding: 4 }}>
{addStudentsList
.filter((s) => {
if (!addStudentSearch.trim()) return true;
const q = addStudentSearch.toLowerCase();
const name = `${s.user?.first_name || ''} ${s.user?.last_name || ''}`.trim().toLowerCase();
const email = (s.user?.email || '').toLowerCase();
return name.includes(q) || email.includes(q);
})
.map((s) => {
const id = String(s.user?.id ?? s.id);
const name = `${s.user?.first_name || ''} ${s.user?.last_name || ''}`.trim() || s.user?.email || '';
const email = s.user?.email || '';
const avatarUrl = s.user?.avatar_url || s.user?.avatar || null;
const initial = name ? name[0].toUpperCase() : '?';
const checked = addShareStudentIds.includes(id);
return (
<label
key={id}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 12px',
cursor: addLoading ? 'default' : 'pointer',
borderRadius: 8,
background: checked ? 'var(--md-sys-color-primary-container)' : 'transparent',
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: '50%',
overflow: 'hidden',
flexShrink: 0,
background: 'var(--md-sys-color-surface-container-high)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 16,
fontWeight: 600,
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
{avatarUrl ? (
<img src={avatarUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
initial
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--md-sys-color-on-surface)' }}>{name}</div>
{email && (
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 2 }}>{email}</div>
)}
</div>
<input
type="checkbox"
checked={checked}
disabled={addLoading}
onChange={(e) => {
setAddShareStudentIds((prev) =>
e.target.checked ? [...prev, id] : prev.filter((x) => x !== id)
);
}}
style={{ width: 20, height: 20, accentColor: 'var(--md-sys-color-primary)', flexShrink: 0 }}
/>
</label>
);
})}
</div>
</div>
)}
</>
)}
</div>
{addError && (
<div
style={{
padding: '12px 14px',
background: 'var(--md-sys-color-error-container)',
color: 'var(--md-sys-color-on-error-container)',
borderRadius: 12,
fontSize: 14,
marginBottom: 16,
}}
>
{addError}
</div>
)}
</div>
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--md-sys-color-outline-variant)', display: 'flex', gap: 12 }}>
<button
type="button"
onClick={closeAddPanel}
disabled={addLoading}
style={{
padding: '12px 20px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'transparent',
color: 'var(--md-sys-color-on-surface)',
fontSize: 14,
fontWeight: 500,
cursor: addLoading ? 'not-allowed' : 'pointer',
}}
>
Отмена
</button>
<button
type="submit"
disabled={addLoading}
style={{
flex: 1,
padding: '12px 20px',
borderRadius: 12,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 14,
fontWeight: 500,
cursor: addLoading ? 'not-allowed' : 'pointer',
}}
>
{addLoading ? 'Загрузка...' : 'Загрузить'}
</button>
</div>
</form>
</div>
<style>{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
`}</style>
</>
)}
</div>
);
}