From 5221f9f670ade334b3b9035cf7c934b49ac23e18 Mon Sep 17 00:00:00 2001 From: bramval Date: Tue, 27 Jan 2026 16:16:23 +0100 Subject: [PATCH] Release 1.7 --- app/routes/company.py | 143 +++++++ app/static/styles.css | 6 + app/templates/company/playlist_detail.html | 430 ++++++++++++++++++++- 3 files changed, 578 insertions(+), 1 deletion(-) diff --git a/app/routes/company.py b/app/routes/company.py index 5b16814..1053ff2 100644 --- a/app/routes/company.py +++ b/app/routes/company.py @@ -1114,6 +1114,149 @@ def add_playlist_item(playlist_id: int): return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) +@bp.post("/playlists//items/bulk-images") +@login_required +def bulk_upload_playlist_images(playlist_id: int): + """Bulk upload multiple images to a playlist. + + Expects multipart/form-data: + - files: multiple image files + - crop_mode: "16:9" or "9:16" (optional; defaults to 16:9) + - duration_seconds: optional (defaults to 10) + + Returns JSON: + { ok: true, items: [...] } + """ + + company_user_required() + + playlist = db.session.get(Playlist, playlist_id) + if not playlist or playlist.company_id != current_user.company_id: + abort(404) + + # This endpoint is intended for AJAX. + def _json_error(message: str, status: int = 400): + return jsonify({"ok": False, "error": message}), status + + files = request.files.getlist("files") + if not files: + return _json_error("No files uploaded") + + crop_mode = (request.form.get("crop_mode") or "16:9").strip().lower() + if crop_mode not in {"16:9", "9:16"}: + crop_mode = "16:9" + + raw_duration = request.form.get("duration_seconds") + try: + duration = int(raw_duration) if raw_duration is not None else 10 + except (TypeError, ValueError): + duration = 10 + duration = max(1, duration) + + # Quota check before processing. + with db.session.no_autoflush: + company = db.session.get(Company, current_user.company_id) + if not company: + abort(404) + + upload_root = current_app.config["UPLOAD_FOLDER"] + used_bytes = get_company_upload_bytes(upload_root, company.id) + usage = compute_storage_usage(used_bytes=used_bytes, max_bytes=company.storage_max_bytes) + storage_max_human = _format_bytes(usage["max_bytes"]) if usage.get("max_bytes") else None + + if usage.get("is_exceeded"): + return _json_error(_storage_limit_error_message(storage_max_human=storage_max_human), 403) + + # Determine starting position. + max_pos = ( + db.session.query(db.func.max(PlaylistItem.position)).filter_by(playlist_id=playlist_id).scalar() or 0 + ) + + saved_relpaths: list[str] = [] + items: list[PlaylistItem] = [] + + try: + for idx, f in enumerate(files, start=1): + if not f or not f.filename: + raise ValueError("file_required") + + filename = secure_filename(f.filename) + ext = os.path.splitext(filename)[1].lower() + if ext not in ALLOWED_IMAGE_EXTENSIONS: + raise ValueError("unsupported") + + relpath = _save_compressed_image( + f, + current_app.config["UPLOAD_FOLDER"], + current_user.company_id, + crop_mode=crop_mode, + ) + saved_relpaths.append(relpath) + + it = PlaylistItem( + playlist=playlist, + item_type="image", + title=None, + duration_seconds=duration, + position=max_pos + idx, + file_path=relpath, + ) + items.append(it) + + # Post-save quota check (like single image uploads) + if company.storage_max_bytes is not None and int(company.storage_max_bytes or 0) > 0: + used_after = get_company_upload_bytes(upload_root, company.id) + usage_after = compute_storage_usage(used_bytes=used_after, max_bytes=company.storage_max_bytes) + if usage_after.get("is_exceeded"): + # Remove all newly saved files and reject. + for p in saved_relpaths: + _try_delete_upload(p, upload_root) + return _json_error(_storage_limit_error_message(storage_max_human=storage_max_human), 403) + + for it in items: + db.session.add(it) + db.session.commit() + except ValueError as e: + # Clean up any saved files. + upload_root = current_app.config["UPLOAD_FOLDER"] + for p in saved_relpaths: + _try_delete_upload(p, upload_root) + + code = str(e) + if code == "unsupported": + return _json_error( + "Unsupported image type. Please upload one of: " + ", ".join(sorted(ALLOWED_IMAGE_EXTENSIONS)) + ) + if code == "file_required": + return _json_error("File required") + return _json_error("Failed to process image upload", 500) + except Exception: + db.session.rollback() + upload_root = current_app.config["UPLOAD_FOLDER"] + for p in saved_relpaths: + _try_delete_upload(p, upload_root) + return _json_error("Failed to process image upload", 500) + + return jsonify( + { + "ok": True, + "items": [ + { + "id": it.id, + "playlist_id": it.playlist_id, + "position": it.position, + "item_type": it.item_type, + "title": it.title, + "file_path": it.file_path, + "url": it.url, + "duration_seconds": it.duration_seconds, + } + for it in items + ], + } + ) + + @bp.post("/items//delete") @login_required def delete_item(item_id: int): diff --git a/app/static/styles.css b/app/static/styles.css index 6d708cc..9dcee68 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -314,3 +314,9 @@ h1, h2, h3, .display-1, .display-2, .display-3 { .schedule-status-dot.inactive { background: #dc3545; } + +/* Dropzone disabled state (used by bulk upload) */ +.dropzone.disabled { + opacity: 0.6; + pointer-events: none; +} diff --git a/app/templates/company/playlist_detail.html b/app/templates/company/playlist_detail.html index dbce5e2..27cf268 100644 --- a/app/templates/company/playlist_detail.html +++ b/app/templates/company/playlist_detail.html @@ -237,7 +237,10 @@

Items

Tip: drag items to reorder. Changes save automatically.
- +
+ + +
@@ -513,6 +516,65 @@
+ {# Bulk upload images modal #} + + {# Load Cropper.js BEFORE our inline script so window.Cropper is available #} @@ -1410,6 +1472,372 @@ `; } + + // 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 = ` +
${escapeHtml(name)}
+
${escapeHtml(initialText || '')}
+ `; + 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, '''); + } + + 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() {