feat
This commit is contained in:
parent
26f4dbbd92
commit
82f1bd9b49
@ -51,28 +51,27 @@
|
||||
<div class="preview" id="preview"></div>
|
||||
</div>
|
||||
</form>
|
||||
<script defer>
|
||||
const post = JSON.parse({{post|tojson}})
|
||||
const postId = post._id
|
||||
<script>
|
||||
const post = JSON.parse({{ post|tojson }});
|
||||
const postId = post._id;
|
||||
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const preview = document.getElementById('preview');
|
||||
const createBtn = document.getElementById('createBtn');
|
||||
const form = document.getElementById('postForm');
|
||||
|
||||
// Fill form fields
|
||||
document.getElementById('author').value = post.author;
|
||||
if (document.getElementById('grade')) {
|
||||
document.getElementById('grade').value = post.grade;
|
||||
|
||||
const inputEl = document.getElementById('grade');
|
||||
|
||||
let list = inputEl.dataset.list;
|
||||
list = JSON.parse(list.replace(/'/g, '"'));
|
||||
|
||||
list.push(post.grade);
|
||||
list = [...new Set(list)];
|
||||
|
||||
inputEl.dataset.list = JSON.stringify(list);
|
||||
inputEl.dataset.value = post.grade;
|
||||
|
||||
}
|
||||
|
||||
document.getElementById('title').value = post.title;
|
||||
document.getElementById('city').value = post.city;
|
||||
document.getElementById('size').value = post.size;
|
||||
@ -80,39 +79,110 @@
|
||||
document.getElementById('genre').value = post.genre;
|
||||
document.getElementById('price').value = post.price;
|
||||
document.getElementById('description').value = post.description;
|
||||
let filesOrdered = [];
|
||||
console.log(post.medias_original)
|
||||
const existingMedias = (post?.medias_original ?? []).map((media) => ({ url: `/b/posts/${media}`, name: `${media}`, isExisting: true, type: "image",}));
|
||||
|
||||
async function uploadMediaFiles(files) {
|
||||
if (!files || files.length === 0) return [];
|
||||
let filesOrdered = [];
|
||||
let isSubmitting = false;
|
||||
|
||||
const existingMedias = (post?.medias_original ?? []).map((media) => ({
|
||||
url: `/b/posts/${media}`,
|
||||
name: `${media}`,
|
||||
isExisting: true,
|
||||
type: "image",
|
||||
}));
|
||||
|
||||
const CHUNK_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||
|
||||
// ========== Chunked upload ==========
|
||||
async function uploadFileInChunks(file, onProgress) {
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
const fileId = crypto.randomUUID();
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const formData = new FormData();
|
||||
for (let file of files) {
|
||||
formData.append('medias', file);
|
||||
}
|
||||
formData.append('chunk', chunk, file.name);
|
||||
formData.append('file_id', fileId);
|
||||
formData.append('chunk_index', i);
|
||||
formData.append('total_chunks', totalChunks);
|
||||
formData.append('filename', file.name);
|
||||
formData.append('content_type', file.type || 'application/octet-stream');
|
||||
|
||||
const res = await fetch('/api/posts/media', {
|
||||
const res = await fetch('/api/posts/media/chunk', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Ошибка загрузки медиа');
|
||||
return await res.json();
|
||||
if (!res.ok) throw new Error(`Chunk ${i} failed`);
|
||||
|
||||
onProgress((i + 1) / totalChunks);
|
||||
}
|
||||
|
||||
function loadExistingMedias() {
|
||||
existingMedias.forEach(media => {
|
||||
filesOrdered.push({
|
||||
name: media.name,
|
||||
url: media.url,
|
||||
isExisting: true,
|
||||
type: media.type
|
||||
const finalRes = await fetch('/api/posts/media/finalize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_id: fileId })
|
||||
});
|
||||
});
|
||||
renderPreviews();
|
||||
|
||||
if (!finalRes.ok) throw new Error('Finalize failed');
|
||||
const data = await finalRes.json();
|
||||
return data.post_medias[0];
|
||||
}
|
||||
|
||||
function createProgressBar(fileName) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'upload-item';
|
||||
wrapper.innerHTML = `
|
||||
<div class="upload-name">${fileName}</div>
|
||||
<div class="progress-bar"><div class="progress-fill"></div></div>
|
||||
<div class="progress-text">0%</div>
|
||||
`;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
async function uploadMediaFiles(files) {
|
||||
if (!files || files.length === 0) return { post_medias: [] };
|
||||
|
||||
let progressContainer = document.getElementById('uploadProgress');
|
||||
if (!progressContainer) {
|
||||
progressContainer = document.createElement('div');
|
||||
progressContainer.id = 'uploadProgress';
|
||||
progressContainer.className = 'upload-progress-container';
|
||||
preview.parentNode.insertBefore(progressContainer, preview);
|
||||
}
|
||||
progressContainer.innerHTML = '';
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const file of files) {
|
||||
const progressEl = createProgressBar(file.name);
|
||||
progressContainer.appendChild(progressEl);
|
||||
const fill = progressEl.querySelector('.progress-fill');
|
||||
const text = progressEl.querySelector('.progress-text');
|
||||
|
||||
try {
|
||||
const name = await uploadFileInChunks(file, (p) => {
|
||||
const pct = Math.round(p * 100);
|
||||
fill.style.width = pct + '%';
|
||||
text.textContent = pct + '%';
|
||||
});
|
||||
results.push(name);
|
||||
progressEl.classList.add('done');
|
||||
text.textContent = '✓';
|
||||
} catch (err) {
|
||||
progressEl.classList.add('error');
|
||||
text.textContent = 'Ошибка';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => progressContainer.remove(), 1200);
|
||||
return { post_medias: results };
|
||||
}
|
||||
|
||||
// ========== Preview helpers ==========
|
||||
function createObjectURLSafe(item) {
|
||||
if (item.isExisting || !item.file) return item.url;
|
||||
if (!item.previewURL) {
|
||||
@ -130,7 +200,6 @@
|
||||
|
||||
function renderPreviews() {
|
||||
preview.innerHTML = '';
|
||||
|
||||
filesOrdered.forEach((item, idx) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'item';
|
||||
@ -142,13 +211,11 @@
|
||||
title.textContent = idx === 0 ? 'Титульная' : `${idx + 1}`;
|
||||
|
||||
const isImage = item.type && item.type.startsWith('image');
|
||||
|
||||
const mediaEl = document.createElement(isImage ? 'img' : 'video');
|
||||
mediaEl.className = 'thumb';
|
||||
mediaEl.src = createObjectURLSafe(item);
|
||||
if (!isImage) mediaEl.controls = true;
|
||||
|
||||
// Контролы
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'controls';
|
||||
|
||||
@ -159,13 +226,12 @@
|
||||
makeFirstBtn.addEventListener('click', () => moveFile(idx, 0));
|
||||
|
||||
const leftBtn = document.createElement('button');
|
||||
|
||||
leftBtn.textContent = idx;
|
||||
leftBtn.textContent = '←';
|
||||
leftBtn.disabled = idx === 0;
|
||||
leftBtn.addEventListener('click', () => moveFile(idx, idx - 1));
|
||||
|
||||
const rightBtn = document.createElement('button');
|
||||
rightBtn.textContent = idx + 2;
|
||||
rightBtn.textContent = '→';
|
||||
rightBtn.disabled = idx === filesOrdered.length - 1;
|
||||
rightBtn.addEventListener('click', () => moveFile(idx, idx + 1));
|
||||
|
||||
@ -173,7 +239,9 @@
|
||||
removeBtn.textContent = '✖';
|
||||
removeBtn.addEventListener('click', () => removeFile(idx));
|
||||
|
||||
controls.append(idx>0?makeFirstBtn:'', idx>0?leftBtn:'', idx < filesOrdered.length - 1?rightBtn:'', removeBtn);
|
||||
if (idx > 0) controls.append(makeFirstBtn, leftBtn);
|
||||
if (idx < filesOrdered.length - 1) controls.append(rightBtn);
|
||||
controls.append(removeBtn);
|
||||
|
||||
div.append(mediaEl, title, controls);
|
||||
preview.appendChild(div);
|
||||
@ -183,19 +251,15 @@
|
||||
e.dataTransfer.setData('text/plain', idx.toString());
|
||||
div.classList.add('dragging');
|
||||
});
|
||||
|
||||
div.addEventListener('dragend', () => {
|
||||
div.classList.remove('dragging');
|
||||
document.querySelectorAll('.item').forEach(el => el.classList.remove('drag-over'));
|
||||
});
|
||||
|
||||
div.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
div.classList.add('drag-over');
|
||||
});
|
||||
|
||||
div.addEventListener('dragleave', () => div.classList.remove('drag-over'));
|
||||
|
||||
div.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
div.classList.remove('drag-over');
|
||||
@ -220,53 +284,61 @@
|
||||
renderPreviews();
|
||||
}
|
||||
|
||||
function loadExistingMedias() {
|
||||
existingMedias.forEach(media => {
|
||||
filesOrdered.push({
|
||||
name: media.name,
|
||||
url: media.url,
|
||||
isExisting: true,
|
||||
type: media.type
|
||||
});
|
||||
});
|
||||
renderPreviews();
|
||||
}
|
||||
|
||||
// ========== File select → upload → show blob only after success ==========
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const newFiles = Array.from(e.target.files);
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
createBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const uploadedNames = await uploadMediaFiles(newFiles);
|
||||
const uploaded = await uploadMediaFiles(newFiles);
|
||||
|
||||
newFiles.forEach((file, i) => {
|
||||
filesOrdered.push({
|
||||
name: uploadedNames['post_medias'][i],
|
||||
url: `/b/posts/${uploadedNames['post_medias'][i]}`,
|
||||
name: uploaded.post_medias[i],
|
||||
url: `/b/posts/${uploaded.post_medias[i]}`,
|
||||
file: file,
|
||||
type: file.type,
|
||||
isExisting: false
|
||||
});
|
||||
});
|
||||
|
||||
renderPreviews();
|
||||
renderPreviews(); // blob appears only here
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки фото:', err);
|
||||
alert('Не удалось загрузить фото. Попробуйте ещё раз.');
|
||||
}
|
||||
|
||||
} finally {
|
||||
createBtn.disabled = false;
|
||||
fileInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
loadExistingMedias();
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
filesOrdered.forEach(revokeObjectURLSafe);
|
||||
});
|
||||
|
||||
// === Отправка формы ===
|
||||
// ========== Save button – only one request ==========
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const author = '{{ user.username }}';
|
||||
const grade = '{{ user.grade }}';
|
||||
const form = document.getElementById('postForm');
|
||||
const createBtn = document.getElementById('createBtn');
|
||||
|
||||
createBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
|
||||
// ... (проверка обязательных полей остаётся без изменений)
|
||||
|
||||
const requiredFields = [...document.querySelectorAll('input[required]'), ...document.querySelectorAll('textarea[required]')];
|
||||
const requiredFields = [
|
||||
...document.querySelectorAll('input[required]'),
|
||||
...document.querySelectorAll('textarea[required]')
|
||||
];
|
||||
let hasError = false;
|
||||
|
||||
requiredFields.forEach(field => {
|
||||
@ -276,24 +348,35 @@
|
||||
: !field.value.trim();
|
||||
|
||||
if (isEmpty) {
|
||||
group.classList.add('error');
|
||||
group?.classList.add('error');
|
||||
hasError = true;
|
||||
} else {
|
||||
group.classList.remove('error');
|
||||
group?.classList.remove('error');
|
||||
}
|
||||
});
|
||||
|
||||
if (hasError) return;
|
||||
|
||||
// === Подготовка данных ===
|
||||
if (filesOrdered.length === 0) {
|
||||
showToast({ message: 'Поле фото должно быть заполнено', duration: 2500 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Lock
|
||||
isSubmitting = true;
|
||||
createBtn.disabled = true;
|
||||
createBtn.classList.add('disabled');
|
||||
|
||||
const data = new FormData(form);
|
||||
|
||||
if (document.querySelector('#author').value === '') {
|
||||
if (document.querySelector('#author')?.value === '') {
|
||||
data.set('author', author);
|
||||
}
|
||||
|
||||
{% if 'user' in user.get('roles') and user.get('grade') is not none %}
|
||||
data.set('grade', grade);
|
||||
{% endif %}
|
||||
|
||||
filesOrdered.forEach(f => data.append('post_medias', f.name));
|
||||
|
||||
try {
|
||||
@ -307,21 +390,31 @@
|
||||
const next = params.get('next') || '/profile/posts';
|
||||
window.location.href = next;
|
||||
} else {
|
||||
showToast({message: 'Ошибка при сохранение поста', status: "error", duration:2500});
|
||||
showToast({ message: 'Ошибка при сохранении поста', status: 'error', duration: 2500 });
|
||||
isSubmitting = false;
|
||||
createBtn.disabled = false;
|
||||
createBtn.classList.remove('disabled');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast({message: 'Ошибка соединения', status: "error", duration:2500});
|
||||
showToast({ message: 'Ошибка соединения', status: 'error', duration: 2500 });
|
||||
isSubmitting = false;
|
||||
createBtn.disabled = false;
|
||||
createBtn.classList.remove('disabled');
|
||||
}
|
||||
});
|
||||
|
||||
// Удаление ошибки при вводе
|
||||
document.querySelectorAll('.floating-group input, .floating-group textarea').forEach(input => {
|
||||
input.addEventListener('input', () => {
|
||||
input.closest('.floating-group').classList.remove('error');
|
||||
input.closest('.floating-group')?.classList.remove('error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('load', loadExistingMedias);
|
||||
window.addEventListener('beforeunload', () => {
|
||||
filesOrdered.forEach(revokeObjectURLSafe);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -329,6 +422,62 @@
|
||||
|
||||
{% macro style() -%}
|
||||
<style>
|
||||
.upload-progress-container {
|
||||
margin: 12px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upload-name {
|
||||
margin-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, #4f46e5, #7c3aed);
|
||||
border-radius: 3px;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.upload-item.done .progress-fill {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.upload-item.error .progress-fill {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
#createBtn.disabled,
|
||||
#createBtn:disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.preview {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user