Release 1.7
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, '&')
|
||||
.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