Release 1.7

This commit is contained in:
2026-01-27 16:16:23 +01:00
parent 0c2720618a
commit 5221f9f670
3 changed files with 578 additions and 1 deletions

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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() {