2374 lines
98 KiB
TypeScript
2374 lines
98 KiB
TypeScript
'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' }}>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
marginBottom: '16px',
|
||
gap: 16,
|
||
}}
|
||
>
|
||
<div style={{ flex: 1, minWidth: 0 }} />
|
||
{!isClient && (
|
||
<button
|
||
type="button"
|
||
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>
|
||
);
|
||
}
|