diff --git a/public/index.html b/public/index.html index 6fdd985..d267719 100644 --- a/public/index.html +++ b/public/index.html @@ -39,6 +39,9 @@ h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text .files-list .file-row .f-size{color:var(--muted);flex-shrink:0} .files-list .file-row button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.85rem;padding:0 2px;flex-shrink:0} .files-list .file-row button:hover{color:#ef4444} +.paste-hint{font-size:.75rem;color:var(--muted);line-height:1.35;text-align:center} +.files-card .paste-hint{text-align:left} +@media(hover:none){.paste-hint{display:none}} .preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px} .preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border)} .preview.has-photo{border-style:solid} @@ -110,6 +113,7 @@ nav a:hover{color:var(--text)}
+

Ctrl+V — вставить фото из буфера

@@ -118,6 +122,7 @@ nav a:hover{color:var(--text)}
+

Ctrl+V — вставить файлы из буфера

diff --git a/public/js/index.js b/public/js/index.js index 1439ff1..9473b6e 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -13,7 +13,9 @@ const toast = document.getElementById('toast'); const camVideo = document.getElementById('camVideo'); const camCanvas = document.getElementById('camCanvas'); const camModal = document.getElementById('camModal'); +const photoPasteHint = document.getElementById('photoPasteHint'); let capturedBlob = null; +let capturedName = 'photo.jpg'; let stream = null; const descInput = document.getElementById('descInput'); const filesInput = document.getElementById('filesInput'); @@ -118,13 +120,119 @@ function clearFiles() { renderFiles(); } +const MAX_FILES = 10; +const MAX_FILE_BYTES = 10 * 1024 * 1024; +const MAX_TOTAL_BYTES = 30 * 1024 * 1024; +const BLOCKED_FILE_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i; +const IMAGE_FILE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.ico', '.heic', '.heif', '.jfif']); +const MIME_EXT = { + 'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp', + 'image/bmp': '.bmp', 'image/avif': '.avif', 'image/heic': '.heic', 'image/heif': '.heif', 'image/svg+xml': '.svg', + 'application/pdf': '.pdf', 'application/zip': '.zip', 'application/x-zip-compressed': '.zip', + 'application/msword': '.doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx', + 'application/vnd.ms-excel': '.xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx', + 'application/vnd.ms-powerpoint': '.ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx', + 'text/plain': '.txt', 'text/csv': '.csv', 'application/rtf': '.rtf', + 'application/x-rar-compressed': '.rar', 'application/vnd.rar': '.rar', 'application/x-7z-compressed': '.7z' +}; +let notifyTimer = null; + +function notify(text, isError) { + toast.textContent = text; + toast.classList.add('show'); + toast.classList.toggle('error', !!isError); + clearTimeout(notifyTimer); + notifyTimer = setTimeout(() => toast.classList.remove('show', 'error'), 3200); +} + +function extOf(name) { + const s = String(name || ''); + const i = s.lastIndexOf('.'); + return i > 0 ? s.slice(i).toLowerCase() : ''; +} + +function extFromFile(file) { + const named = extOf(file.name); + if (/^\.[a-z0-9]{1,8}$/i.test(named)) return named; + const byMime = MIME_EXT[String(file.type || '').toLowerCase()]; + if (byMime) return byMime; + const sub = String(file.type || '').split('/')[1] || ''; + return /^[a-z0-9]{1,8}$/i.test(sub) ? '.' + sub : ''; +} + +function pasteFileName(file) { + const base = String(file.name || '').trim(); + const stem = base.replace(/\.[a-z0-9]{1,8}$/i, ''); + if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base; + const d = new Date(); + const pad = n => String(n).padStart(2, '0'); + const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`; + return `Вставка ${stamp}${extFromFile(file)}`; +} + +function totalUploadBytes() { + return (capturedBlob ? capturedBlob.size : 0) + selectedFiles.reduce((sum, f) => sum + f.size, 0); +} + +function addFiles(list) { + const added = []; + const skipped = []; + for (const file of list) { + if (selectedFiles.length >= MAX_FILES) { skipped.push('Можно прикрепить не более 10 файлов'); break; } + if (file.size > MAX_FILE_BYTES) { skipped.push(`«${file.name}» больше 10 МБ`); continue; } + if (BLOCKED_FILE_EXT.test(extOf(file.name))) { skipped.push(`«${file.name}»: недопустимый тип файла`); continue; } + if (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push('Суммарный размер файлов — не более 30 МБ'); break; } + selectedFiles.push(file); + added.push(file.name); + } + if (added.length) renderFiles(); + return { added, skipped }; +} + filesInput.addEventListener('change', () => { - const keep = 10 - selectedFiles.length; - if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; } - selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep)); - if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов'); + const res = addFiles([...filesInput.files]); filesInput.value = ''; - renderFiles(); + if (res.skipped.length) alert(res.skipped[0]); +}); + +document.addEventListener('paste', (e) => { + if (form.style.display === 'none') return; + const clip = e.clipboardData; + if (!clip) return; + const pasted = []; + for (const item of Array.from(clip.items || [])) { + if (item.kind !== 'file') continue; + const file = item.getAsFile(); + if (file) pasted.push(file); + } + if (!pasted.length && clip.files && clip.files.length) pasted.push(...Array.from(clip.files)); + if (!pasted.length) return; + e.preventDefault(); + let photoSet = false; + let filesAdded = 0; + const skipped = []; + for (const raw of pasted) { + const name = pasteFileName(raw); + const file = raw.name === name ? raw : new File([raw], name, { type: raw.type || 'application/octet-stream', lastModified: raw.lastModified }); + if (!capturedBlob && IMAGE_FILE_EXT.has(extFromFile(file))) { + capturedBlob = file; + capturedName = name; + showPreview(URL.createObjectURL(file)); + photoSet = true; + continue; + } + const res = addFiles([file]); + filesAdded += res.added.length; + skipped.push(...res.skipped); + } + const parts = []; + if (photoSet) parts.push('Фото вставлено из буфера'); + if (filesAdded) parts.push(filesAdded === 1 ? 'Файл добавлен' : `Файлов добавлено: ${filesAdded}`); + if (!parts.length) { + if (skipped.length) notify(skipped[0], true); + return; + } + notify(parts.join('. ') + (skipped.length ? '. ' + skipped[0] : ''), skipped.length > 0); }); const params = new URLSearchParams(location.search); @@ -231,13 +339,16 @@ function showPreview(src) { preview.src = src; preview.classList.add('has-photo'); removePhotoBtn.classList.add('show'); + photoPasteHint.style.display = 'none'; } function removePhoto() { preview.src = placeholderSrc; preview.classList.remove('has-photo'); removePhotoBtn.classList.remove('show'); + photoPasteHint.style.display = ''; capturedBlob = null; + capturedName = 'photo.jpg'; } async function openCam() { @@ -264,6 +375,7 @@ function capture() { ctx.drawImage(camVideo, 0, 0); camCanvas.toBlob(blob => { capturedBlob = blob; + capturedName = 'photo.jpg'; showPreview(URL.createObjectURL(blob)); closeCam(); }, 'image/jpeg', captureQuality); @@ -292,7 +404,7 @@ form.addEventListener('submit', async (e) => { btn.textContent = 'Отправка...'; const fd = new FormData(); - fd.append('photo', capturedBlob, 'photo.jpg'); + fd.append('photo', capturedBlob, capturedName); selectedFiles.forEach(f => fd.append('files', f, f.name)); fd.append('student_name', studentName || document.getElementById('nameInput').value); fd.append('group_id', groupInput.value);