add project file upload to student form, project_files table with backup/restore, download in journal, and crash-proof entries endpoint
This commit is contained in:
@@ -21,6 +21,21 @@ h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text
|
||||
.photo-btns button:hover{border-color:var(--accent)}
|
||||
.photo-btns .cam{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||||
.photo-btns .cam:hover{opacity:.85}
|
||||
.photo-btns .files-btn p{font-size:.8rem;font-weight:500;margin:0}
|
||||
.files-btn{width:100%;border-radius:var(--radius);padding:12px 16px;font-size:.9rem;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--card);color:var(--text);transition:border .15s,background .15s}
|
||||
.files-btn:hover{border-color:var(--accent)}
|
||||
.files-card{width:100%;border:1px dashed var(--border);border-radius:var(--radius);padding:16px;display:flex;flex-direction:column;gap:12px;background:var(--bg)}
|
||||
.files-card .files-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||
.files-card label{font-size:.8rem;font-weight:600;color:var(--text);text-transform:none;letter-spacing:0}
|
||||
.files-card .files-clear{background:none;border:none;color:#ef4444;font-size:.8rem;cursor:pointer;padding:0;display:none}
|
||||
.files-card .files-clear.show{display:block}
|
||||
.files-list{display:flex;flex-direction:column;gap:6px;max-height:180px;overflow-y:auto}
|
||||
.files-list:empty{display:none}
|
||||
.files-list .file-row{display:flex;align-items:center;gap:8px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:.8rem}
|
||||
.files-list .file-row .f-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--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}
|
||||
.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}
|
||||
@@ -82,6 +97,15 @@ nav a:hover{color:var(--text)}
|
||||
<button type="button" class="cam" onclick="openCam()">Камера</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="files-card">
|
||||
<div class="files-head">
|
||||
<label>Файлы проекта</label>
|
||||
<button type="button" class="files-clear" id="filesClearBtn" onclick="clearFiles()">Очистить</button>
|
||||
</div>
|
||||
<button type="button" class="files-btn" onclick="document.getElementById('filesInput').click()">Выбрать файлы</button>
|
||||
<input type="file" id="filesInput" multiple hidden>
|
||||
<div class="files-list" id="filesList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fields-col">
|
||||
@@ -139,6 +163,58 @@ const camModal = document.getElementById('camModal');
|
||||
let capturedBlob = null;
|
||||
let stream = null;
|
||||
const descInput = document.getElementById('descInput');
|
||||
const filesInput = document.getElementById('filesInput');
|
||||
const filesList = document.getElementById('filesList');
|
||||
const filesClearBtn = document.getElementById('filesClearBtn');
|
||||
let selectedFiles = [];
|
||||
|
||||
function humanSize(b) {
|
||||
if (b < 1024) return b + ' Б';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
|
||||
return (b / 1048576).toFixed(1) + ' МБ';
|
||||
}
|
||||
|
||||
function renderFiles() {
|
||||
filesList.innerHTML = '';
|
||||
selectedFiles.forEach((f, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'file-row';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'f-name';
|
||||
name.textContent = f.name;
|
||||
name.title = f.name;
|
||||
const size = document.createElement('span');
|
||||
size.className = 'f-size';
|
||||
size.textContent = humanSize(f.size);
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button';
|
||||
del.textContent = '✕';
|
||||
del.onclick = () => removeFile(i);
|
||||
row.append(name, size, del);
|
||||
filesList.appendChild(row);
|
||||
});
|
||||
filesClearBtn.classList.toggle('show', selectedFiles.length > 0);
|
||||
}
|
||||
|
||||
function removeFile(i) {
|
||||
selectedFiles.splice(i, 1);
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
function clearFiles() {
|
||||
selectedFiles = [];
|
||||
filesInput.value = '';
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
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 файлов');
|
||||
filesInput.value = '';
|
||||
renderFiles();
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
let studentName = '';
|
||||
@@ -234,6 +310,7 @@ form.addEventListener('submit', async (e) => {
|
||||
|
||||
const fd = new FormData();
|
||||
if (capturedBlob) fd.append('photo', capturedBlob, 'photo.jpg');
|
||||
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
||||
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
||||
fd.append('group_id', groupInput.value);
|
||||
fd.append('description', document.getElementById('descInput').value);
|
||||
@@ -250,6 +327,7 @@ form.addEventListener('submit', async (e) => {
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
form.reset();
|
||||
removePhoto();
|
||||
clearFiles();
|
||||
toast.textContent = 'Готово!';
|
||||
toast.classList.remove('error');
|
||||
toast.classList.add('show');
|
||||
|
||||
Reference in New Issue
Block a user