Release 1.7
This commit is contained in:
@@ -237,7 +237,10 @@
|
||||
<h2 class="h5 mb-0">Items</h2>
|
||||
<div class="text-muted small">Tip: drag items to reorder. Changes save automatically.</div>
|
||||
</div>
|
||||
<button class="btn btn-brand" type="button" id="open-add-item">Add item</button>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary" type="button" id="open-bulk-upload">Bulk upload</button>
|
||||
<button class="btn btn-brand" type="button" id="open-add-item">Add item</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
@@ -513,6 +516,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Bulk upload images modal #}
|
||||
<div class="modal fade" id="bulkUploadModal" tabindex="-1" aria-labelledby="bulkUploadModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="bulkUploadModalLabel">Bulk upload images</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="text-muted small mb-2">Choose an aspect ratio. Images will be center-cropped automatically.</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="btn-group w-100" role="group" aria-label="Bulk crop mode">
|
||||
<input type="radio" class="btn-check" name="bulk_crop_mode_choice" id="bulk-crop-16-9" autocomplete="off" checked>
|
||||
<label class="btn btn-outline-primary" for="bulk-crop-16-9">16:9 (landscape)</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="bulk_crop_mode_choice" id="bulk-crop-9-16" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="bulk-crop-9-16">9:16 (portrait)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bulk-dropzone" class="dropzone">
|
||||
<div><strong>Drag & drop</strong> multiple images here</div>
|
||||
<div class="text-muted small">or click to select files</div>
|
||||
</div>
|
||||
<input id="bulk-file-input" class="form-control d-none" type="file" accept="image/*" multiple />
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="small text-muted" id="bulk-status" aria-live="polite"></div>
|
||||
|
||||
<div id="bulk-upload-progress" class="d-none mt-2" aria-live="polite">
|
||||
<div class="progress" style="height: 10px;">
|
||||
<div
|
||||
id="bulk-upload-progress-bar"
|
||||
class="progress-bar"
|
||||
role="progressbar"
|
||||
style="width: 0%"
|
||||
aria-valuenow="0"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-muted small mt-1" id="bulk-upload-progress-text">Uploading…</div>
|
||||
</div>
|
||||
|
||||
<ul class="list-group mt-2" id="bulk-file-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<div class="small text-danger me-auto" id="bulk-error" aria-live="polite"></div>
|
||||
<button type="button" class="btn btn-outline-ink" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-brand" id="bulk-upload-submit" disabled>Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Load Cropper.js BEFORE our inline script so window.Cropper is available #}
|
||||
<script src="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.js"></script>
|
||||
|
||||
@@ -1410,6 +1472,372 @@
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Expose for other scripts on this page (bulk upload appends cards).
|
||||
window.__renderPlaylistCardInnerHtml = renderCardInnerHtml;
|
||||
})();
|
||||
|
||||
(function() {
|
||||
// -------------------------
|
||||
// Bulk image upload (auto center-crop)
|
||||
// -------------------------
|
||||
const openBtn = document.getElementById('open-bulk-upload');
|
||||
const modalEl = document.getElementById('bulkUploadModal');
|
||||
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
||||
|
||||
const dropzone = document.getElementById('bulk-dropzone');
|
||||
const fileInput = document.getElementById('bulk-file-input');
|
||||
const submitBtn = document.getElementById('bulk-upload-submit');
|
||||
const statusEl = document.getElementById('bulk-status');
|
||||
const errorEl = document.getElementById('bulk-error');
|
||||
const listEl = document.getElementById('bulk-file-list');
|
||||
|
||||
const crop169 = document.getElementById('bulk-crop-16-9');
|
||||
const crop916 = document.getElementById('bulk-crop-9-16');
|
||||
|
||||
const cfg = document.getElementById('page-config');
|
||||
const TARGET_W = parseInt(cfg?.dataset?.imageCropTargetW || '1920', 10) || 1920;
|
||||
const TARGET_H = parseInt(cfg?.dataset?.imageCropTargetH || '1080', 10) || 1080;
|
||||
|
||||
const uploadUrl = `{{ url_for('company.bulk_upload_playlist_images', playlist_id=playlist.id) }}`;
|
||||
|
||||
let selectedFiles = [];
|
||||
let processedFiles = []; // cropped output
|
||||
|
||||
function setError(msg) {
|
||||
if (errorEl) errorEl.textContent = (msg || '').trim();
|
||||
}
|
||||
function setStatus(msg) {
|
||||
if (statusEl) statusEl.textContent = (msg || '').trim();
|
||||
}
|
||||
|
||||
function currentCropMode() {
|
||||
return crop916?.checked ? '9:16' : '16:9';
|
||||
}
|
||||
|
||||
function clearList() {
|
||||
if (listEl) listEl.innerHTML = '';
|
||||
}
|
||||
|
||||
function addListItem(name, initialText) {
|
||||
if (!listEl) return null;
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
li.innerHTML = `
|
||||
<div class="text-truncate" style="max-width: 70%;">${escapeHtml(name)}</div>
|
||||
<div class="small text-muted">${escapeHtml(initialText || '')}</div>
|
||||
`;
|
||||
listEl.appendChild(li);
|
||||
return li;
|
||||
}
|
||||
|
||||
function setListItemStatus(li, txt, kind) {
|
||||
if (!li) return;
|
||||
const right = li.querySelector('div.small');
|
||||
if (!right) return;
|
||||
right.textContent = txt || '';
|
||||
right.classList.toggle('text-danger', kind === 'err');
|
||||
right.classList.toggle('text-success', kind === 'ok');
|
||||
right.classList.toggle('text-muted', !kind);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
async function loadImageFromFile(file) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function centerCropToAspect(file, cropMode) {
|
||||
// cropMode is "16:9" or "9:16"
|
||||
const img = await loadImageFromFile(file);
|
||||
const srcW = img.naturalWidth || img.width;
|
||||
const srcH = img.naturalHeight || img.height;
|
||||
if (!srcW || !srcH) throw new Error('Invalid image');
|
||||
|
||||
const targetAspect = (cropMode === '9:16') ? (9 / 16) : (16 / 9);
|
||||
const srcAspect = srcW / srcH;
|
||||
|
||||
// Compute center crop rect in source pixels
|
||||
let cropW = srcW;
|
||||
let cropH = srcH;
|
||||
if (srcAspect > targetAspect) {
|
||||
// too wide -> crop width
|
||||
cropW = Math.max(1, Math.round(srcH * targetAspect));
|
||||
cropH = srcH;
|
||||
} else {
|
||||
// too tall -> crop height
|
||||
cropW = srcW;
|
||||
cropH = Math.max(1, Math.round(srcW / targetAspect));
|
||||
}
|
||||
const cropX = Math.max(0, Math.round((srcW - cropW) / 2));
|
||||
const cropY = Math.max(0, Math.round((srcH - cropH) / 2));
|
||||
|
||||
const outW = (cropMode === '9:16') ? TARGET_H : TARGET_W;
|
||||
const outH = (cropMode === '9:16') ? TARGET_W : TARGET_H;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = outW;
|
||||
canvas.height = outH;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas not supported');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(img, cropX, cropY, cropW, cropH, 0, 0, outW, outH);
|
||||
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
if (!blob) throw new Error('Failed to encode cropped image');
|
||||
|
||||
const base = (file.name || 'image').replace(/\.[^/.]+$/, '');
|
||||
const outName = `${base}_${cropMode.replace(':','x')}.png`;
|
||||
return new File([blob], outName, { type: 'image/png' });
|
||||
}
|
||||
|
||||
async function processSelection(files) {
|
||||
setError('');
|
||||
processedFiles = [];
|
||||
clearList();
|
||||
submitBtn.disabled = true;
|
||||
|
||||
selectedFiles = (files || []).filter(f => f && f.type && f.type.startsWith('image/'));
|
||||
if (!selectedFiles.length) {
|
||||
setStatus('');
|
||||
setError('Please select one or more image files.');
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = currentCropMode();
|
||||
setStatus(`Preparing ${selectedFiles.length} image(s)…`);
|
||||
|
||||
// Process sequentially to keep memory use stable.
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
const f = selectedFiles[i];
|
||||
const li = addListItem(f.name, 'Cropping…');
|
||||
try {
|
||||
const cropped = await centerCropToAspect(f, mode);
|
||||
processedFiles.push(cropped);
|
||||
setListItemStatus(li, 'Ready', 'ok');
|
||||
} catch (e) {
|
||||
console.warn('Failed to crop', f.name, e);
|
||||
setListItemStatus(li, 'Failed to crop', 'err');
|
||||
}
|
||||
}
|
||||
|
||||
if (!processedFiles.length) {
|
||||
setStatus('');
|
||||
setError('No images could be processed.');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(`Ready to upload ${processedFiles.length} image(s).`);
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
setError('');
|
||||
if (!processedFiles.length) {
|
||||
setError('Please add some images first.');
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
dropzone?.classList.add('disabled');
|
||||
|
||||
const progressWrap = document.getElementById('bulk-upload-progress');
|
||||
const progressBar = document.getElementById('bulk-upload-progress-bar');
|
||||
const progressText = document.getElementById('bulk-upload-progress-text');
|
||||
|
||||
function setProgressVisible(visible) {
|
||||
progressWrap?.classList.toggle('d-none', !visible);
|
||||
}
|
||||
|
||||
function setProgress(pct, text) {
|
||||
const p = Math.max(0, Math.min(100, Math.round(Number(pct) || 0)));
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${p}%`;
|
||||
progressBar.setAttribute('aria-valuenow', String(p));
|
||||
}
|
||||
if (progressText) progressText.textContent = text || `${p}%`;
|
||||
}
|
||||
|
||||
setProgressVisible(true);
|
||||
setProgress(0, 'Uploading…');
|
||||
|
||||
const mode = currentCropMode();
|
||||
setStatus(`Uploading ${processedFiles.length} image(s)…`);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set('crop_mode', mode);
|
||||
fd.set('duration_seconds', '10');
|
||||
for (const f of processedFiles) fd.append('files', f);
|
||||
|
||||
// Use XHR so we can track upload progress.
|
||||
const xhr = new XMLHttpRequest();
|
||||
const xhrPromise = new Promise((resolve) => {
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState !== 4) return;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
|
||||
xhr.open('POST', uploadUrl, true);
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (!e || !e.lengthComputable) {
|
||||
setProgress(0, 'Uploading…');
|
||||
return;
|
||||
}
|
||||
const pct = (e.total > 0) ? ((e.loaded / e.total) * 100) : 0;
|
||||
setProgress(pct, `Uploading… ${Math.round(pct)}%`);
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
// Network error
|
||||
setError('Upload failed (network error).');
|
||||
};
|
||||
|
||||
xhr.send(fd);
|
||||
await xhrPromise;
|
||||
|
||||
const status = xhr.status;
|
||||
const text = xhr.responseText || '';
|
||||
let data = null;
|
||||
try { data = JSON.parse(text); } catch (e) {}
|
||||
|
||||
const resOk = (status >= 200 && status < 300);
|
||||
if (!resOk) {
|
||||
let msg = (data && data.error) ? data.error : `Upload failed (HTTP ${status}).`;
|
||||
setError(msg);
|
||||
submitBtn.disabled = false;
|
||||
dropzone?.classList.remove('disabled');
|
||||
setStatus('');
|
||||
setProgressVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || !data.ok) {
|
||||
setError((data && data.error) ? data.error : 'Upload failed.');
|
||||
submitBtn.disabled = false;
|
||||
dropzone?.classList.remove('disabled');
|
||||
setStatus('');
|
||||
setProgressVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(100, 'Processing…');
|
||||
|
||||
// Append cards in returned order.
|
||||
const list = document.getElementById('playlist-items');
|
||||
const render = window.__renderPlaylistCardInnerHtml;
|
||||
|
||||
if (list && Array.isArray(data.items) && render) {
|
||||
for (const item of data.items) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'playlist-card';
|
||||
el.setAttribute('draggable', 'true');
|
||||
el.setAttribute('data-item-id', item.id);
|
||||
el.innerHTML = render(item);
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(`Uploaded ${data.items?.length || processedFiles.length} image(s).`);
|
||||
|
||||
// Reset & close.
|
||||
selectedFiles = [];
|
||||
processedFiles = [];
|
||||
clearList();
|
||||
submitBtn.disabled = true;
|
||||
window.setTimeout(() => {
|
||||
modal?.hide();
|
||||
setStatus('');
|
||||
setError('');
|
||||
}, 500);
|
||||
|
||||
dropzone?.classList.remove('disabled');
|
||||
setProgressVisible(false);
|
||||
}
|
||||
|
||||
function resetModal() {
|
||||
setError('');
|
||||
setStatus('');
|
||||
selectedFiles = [];
|
||||
processedFiles = [];
|
||||
clearList();
|
||||
submitBtn.disabled = true;
|
||||
try { if (fileInput) fileInput.value = ''; } catch (e) {}
|
||||
|
||||
// Reset progress UI
|
||||
try {
|
||||
document.getElementById('bulk-upload-progress')?.classList.add('d-none');
|
||||
const bar = document.getElementById('bulk-upload-progress-bar');
|
||||
if (bar) {
|
||||
bar.style.width = '0%';
|
||||
bar.setAttribute('aria-valuenow', '0');
|
||||
}
|
||||
const txt = document.getElementById('bulk-upload-progress-text');
|
||||
if (txt) txt.textContent = 'Uploading…';
|
||||
} catch (e) {}
|
||||
|
||||
// default to landscape
|
||||
if (crop169) crop169.checked = true;
|
||||
if (crop916) crop916.checked = false;
|
||||
}
|
||||
|
||||
openBtn?.addEventListener('click', () => {
|
||||
resetModal();
|
||||
modal?.show();
|
||||
});
|
||||
|
||||
dropzone?.addEventListener('click', () => fileInput?.click());
|
||||
dropzone?.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); });
|
||||
dropzone?.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
|
||||
dropzone?.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove('dragover');
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
await processSelection(files);
|
||||
});
|
||||
fileInput?.addEventListener('change', async () => {
|
||||
const files = Array.from(fileInput.files || []);
|
||||
await processSelection(files);
|
||||
});
|
||||
|
||||
// If user switches aspect ratio after selecting files, re-process automatically.
|
||||
[crop169, crop916].forEach((el) => {
|
||||
el?.addEventListener('change', async () => {
|
||||
if (selectedFiles.length) await processSelection(selectedFiles);
|
||||
});
|
||||
});
|
||||
|
||||
submitBtn?.addEventListener('click', () => {
|
||||
upload().catch((err) => {
|
||||
console.warn('Bulk upload failed', err);
|
||||
setError('Bulk upload failed.');
|
||||
submitBtn.disabled = false;
|
||||
setStatus('');
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
(function() {
|
||||
|
||||
Reference in New Issue
Block a user