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

@@ -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/<int:playlist_id>/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/<int:item_id>/delete")
@login_required
def delete_item(item_id: int):

View File

@@ -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;
}

View File

@@ -237,8 +237,11 @@
<h2 class="h5 mb-0">Items</h2>
<div class="text-muted small">Tip: drag items to reorder. Changes save automatically.</div>
</div>
<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">
<div class="small text-muted mb-2" id="reorder-status" aria-live="polite"></div>
@@ -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() {