Initial import

This commit is contained in:
2026-01-25 13:26:45 +01:00
parent a5fe0f73a0
commit f4b7fb62f5
8 changed files with 834 additions and 149 deletions

View File

@@ -24,7 +24,22 @@
<tbody>
{% for p in playlists %}
<tr>
<td><strong>{{ p.name }}</strong></td>
<td>
<strong>{{ p.name }}</strong>
{# Indicators: schedule + priority #}
{% set has_schedule = (p.schedule_start is not none) or (p.schedule_end is not none) %}
{% if has_schedule %}
{% set is_active = (not p.schedule_start or p.schedule_start <= now_utc) and (not p.schedule_end or now_utc <= p.schedule_end) %}
<span class="ms-2" title="Scheduled playlist" style="font-weight:700;">📅</span>
<span
class="ms-1 schedule-status-dot {{ 'active' if is_active else 'inactive' }}"
title="{{ 'Schedule active' if is_active else 'Schedule inactive' }}"
></span>
{% endif %}
{% if p.is_priority %}
<span class="ms-1" title="Priority playlist" style="color:#dc3545; font-weight:700;"></span>
{% endif %}
</td>
<td class="text-end">{{ p.items|length }}</td>
<td class="text-end">
<div class="d-inline-flex gap-2">
@@ -79,27 +94,17 @@
class="btn btn-ink btn-sm js-edit-playlists"
data-display-id="{{ d.id }}"
data-display-name="{{ d.name }}"
data-current-desc="{{ d.description or '' }}"
data-current-transition="{{ d.transition or 'none' }}"
data-legacy-playlist-id="{{ d.assigned_playlist_id or '' }}"
data-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}"
>
Select playlists
Configure display
</button>
<div class="small text-muted">
<span class="js-active-playlists-summary" data-display-id="{{ d.id }}"></span>
</div>
</div>
<div class="d-flex justify-content-end">
<button
type="button"
class="btn btn-ink btn-sm js-edit-desc"
data-display-id="{{ d.id }}"
data-display-name="{{ d.name }}"
data-current-desc="{{ d.description or '' }}"
>
Edit description
</button>
</div>
</div>
</div>
</div>
@@ -122,36 +127,37 @@
</div>
</div>
<!-- Edit description modal -->
<div class="modal fade" id="editDescModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editDescModalTitle">Edit description</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<label class="form-label" for="editDescInput">Description</label>
<textarea class="form-control" id="editDescInput" maxlength="200" rows="3" placeholder="Optional description (max 200 chars)"></textarea>
<div class="form-text"><span id="editDescCount">0</span>/200</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-brand" id="editDescSaveBtn">Save</button>
</div>
</div>
</div>
</div>
<!-- Edit playlists modal -->
<div class="modal fade" id="editPlaylistsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editPlaylistsModalTitle">Select playlists</h5>
<h5 class="modal-title" id="editPlaylistsModalTitle">Configure display</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label" for="editPlaylistsDescInput">Description</label>
<textarea
class="form-control"
id="editPlaylistsDescInput"
maxlength="200"
rows="3"
placeholder="Optional description (max 200 chars)"
></textarea>
<div class="form-text"><span id="editPlaylistsDescCount">0</span>/200</div>
</div>
<div class="mb-3">
<label class="form-label" for="editPlaylistsTransitionSelect">Slide transition</label>
<select class="form-select" id="editPlaylistsTransitionSelect">
<option value="none">None</option>
<option value="fade">Fade</option>
<option value="slide">Slide</option>
</select>
<div class="form-text">Applied on the display when switching between playlist items.</div>
</div>
<hr class="my-3" />
<div class="text-muted small mb-2">Tick the playlists that should be active on this display.</div>
<div id="editPlaylistsList" class="d-flex flex-column gap-2"></div>
<div class="form-text mt-2" id="editPlaylistsHint"></div>
@@ -285,66 +291,6 @@
return data.display;
}
// Description modal
const modalEl = document.getElementById('editDescModal');
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
const titleEl = document.getElementById('editDescModalTitle');
const inputEl = document.getElementById('editDescInput');
const countEl = document.getElementById('editDescCount');
const saveBtn = document.getElementById('editDescSaveBtn');
let activeDisplayId = null;
function updateCount() {
if (!inputEl || !countEl) return;
countEl.textContent = String((inputEl.value || '').length);
}
if (inputEl) inputEl.addEventListener('input', updateCount);
document.querySelectorAll('.js-edit-desc').forEach((btn) => {
btn.addEventListener('click', () => {
activeDisplayId = btn.dataset.displayId;
const displayName = btn.dataset.displayName || 'Display';
const currentDesc = btn.dataset.currentDesc || '';
if (titleEl) titleEl.textContent = `Edit description — ${displayName}`;
if (inputEl) inputEl.value = currentDesc;
updateCount();
if (modal) modal.show();
});
});
async function saveDescription() {
if (!activeDisplayId || !inputEl) return;
const desc = (inputEl.value || '').trim();
saveBtn.disabled = true;
try {
const updated = await postDisplayUpdate(activeDisplayId, { description: desc });
// Update visible description
const descEl = document.querySelector(`.js-display-desc[data-display-id="${activeDisplayId}"]`);
if (descEl) descEl.textContent = updated.description ? updated.description : '—';
// Update button's stored value
const btn = document.querySelector(`.js-edit-desc[data-display-id="${activeDisplayId}"]`);
if (btn) btn.dataset.currentDesc = updated.description || '';
showToast('Description saved', 'text-bg-success');
if (modal) modal.hide();
} catch (e) {
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
} finally {
saveBtn.disabled = false;
}
}
if (saveBtn) {
saveBtn.addEventListener('click', saveDescription);
}
if (modalEl) {
modalEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
saveDescription();
}
});
}
// Playlists modal
const plModalEl = document.getElementById('editPlaylistsModal');
const plModal = plModalEl ? new bootstrap.Modal(plModalEl) : null;
@@ -352,9 +298,18 @@
const plListEl = document.getElementById('editPlaylistsList');
const plHintEl = document.getElementById('editPlaylistsHint');
const plSaveBtn = document.getElementById('editPlaylistsSaveBtn');
const plDescInputEl = document.getElementById('editPlaylistsDescInput');
const plDescCountEl = document.getElementById('editPlaylistsDescCount');
const plTransitionEl = document.getElementById('editPlaylistsTransitionSelect');
let activePlDisplayId = null;
let activePlButton = null;
function updatePlDescCount() {
if (!plDescInputEl || !plDescCountEl) return;
plDescCountEl.textContent = String((plDescInputEl.value || '').length);
}
if (plDescInputEl) plDescInputEl.addEventListener('input', updatePlDescCount);
function renderPlaylistCheckboxes(selectedIds) {
if (!plListEl) return;
plListEl.innerHTML = '';
@@ -401,7 +356,15 @@
activePlDisplayId = btn.dataset.displayId;
activePlButton = btn;
const displayName = btn.dataset.displayName || 'Display';
if (plTitleEl) plTitleEl.textContent = `Select playlists${displayName}`;
if (plTitleEl) plTitleEl.textContent = `Configure display${displayName}`;
const currentDesc = btn.dataset.currentDesc || '';
if (plDescInputEl) plDescInputEl.value = currentDesc;
updatePlDescCount();
const currentTransition = (btn.dataset.currentTransition || 'none').toLowerCase();
if (plTransitionEl) plTransitionEl.value = ['none','fade','slide'].includes(currentTransition) ? currentTransition : 'none';
const selected = computeActiveIdsFromDataset(btn);
renderPlaylistCheckboxes(selected);
if (plHintEl) {
@@ -414,13 +377,31 @@
async function savePlaylists() {
if (!activePlDisplayId || !activePlButton || !plSaveBtn) return;
const ids = getSelectedPlaylistIdsFromModal();
const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : '';
const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none';
plSaveBtn.disabled = true;
try {
const updated = await postDisplayPlaylists(activePlDisplayId, ids);
const newIds = (updated && updated.active_playlist_ids) ? updated.active_playlist_ids : ids;
const [updatedPlaylists, updatedDesc] = await Promise.all([
postDisplayPlaylists(activePlDisplayId, ids),
postDisplayUpdate(activePlDisplayId, { description: desc, transition })
]);
const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids)
? updatedPlaylists.active_playlist_ids
: ids;
setActiveIdsOnButton(activePlButton, newIds);
refreshActivePlaylistSummary(activePlDisplayId, newIds);
showToast('Playlists saved', 'text-bg-success');
const descEl = document.querySelector(`.js-display-desc[data-display-id="${activePlDisplayId}"]`);
const newDesc = updatedDesc && typeof updatedDesc.description === 'string' ? updatedDesc.description : desc;
if (descEl) descEl.textContent = newDesc ? newDesc : '—';
activePlButton.dataset.currentDesc = newDesc || '';
// Keep button dataset in sync so reopening modal shows correct value.
const newTransition = updatedDesc && typeof updatedDesc.transition === 'string' ? updatedDesc.transition : transition;
activePlButton.dataset.currentTransition = newTransition || 'none';
showToast('Display updated', 'text-bg-success');
refreshPreviewIframe(activePlDisplayId);
if (plModal) plModal.hide();
} catch (e) {

View File

@@ -61,6 +61,12 @@
/* Modal step visibility */
.step { display: none; }
.step.active { display: block; }
/* Tiny status pills/icons */
.priority-pill { color: #dc3545; font-weight: 700; }
.schedule-pill { font-weight: 700; }
.schedule-pill.active { color: #198754; }
.schedule-pill.inactive { color: #dc3545; }
</style>
<div class="d-flex justify-content-between align-items-center">
<h1 class="h3">Playlist: {{ playlist.name }}</h1>
@@ -80,6 +86,113 @@
</div>
</div>
{# Priority + schedule indicators #}
{% set has_schedule = (playlist.schedule_start is not none) or (playlist.schedule_end is not none) %}
{% set schedule_active = (not playlist.schedule_start or playlist.schedule_start <= now_utc) and (not playlist.schedule_end or now_utc <= playlist.schedule_end) %}
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-2 mt-2">
<div class="small">
{% if playlist.is_priority %}
<span class="me-2 priority-pill" title="Priority playlist">❗ Priority</span>
{% else %}
<span class="text-muted me-2">Not priority</span>
{% endif %}
{% if has_schedule %}
<span class="me-2" title="Scheduled">
<span class="schedule-pill {{ 'active' if schedule_active else 'inactive' }}">📅 Scheduled</span>
<span class="text-muted">(<span id="scheduleSummary"></span>)</span>
</span>
{% else %}
<span class="text-muted">Not scheduled</span>
{% endif %}
</div>
<div class="d-flex align-items-center gap-2">
{# Priority toggle: auto-saves (no Save button) #}
<form
id="priorityForm"
method="post"
action="{{ url_for('company.update_playlist_priority', playlist_id=playlist.id) }}"
class="d-flex align-items-center gap-2"
>
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" value="1" id="priorityMain" name="is_priority" {% if playlist.is_priority %}checked{% endif %} />
<label class="form-check-label" for="priorityMain">Priority playlist</label>
</div>
<span class="small text-muted" id="prioritySaveStatus" aria-live="polite"></span>
</form>
{# Schedule button moved to where Save button used to be #}
<button
class="btn btn-outline-secondary btn-sm"
type="button"
data-bs-toggle="modal"
data-bs-target="#playlistScheduleModal"
>
Schedule
</button>
</div>
</div>
{# Schedule Modal #}
<div class="modal fade" id="playlistScheduleModal" tabindex="-1" aria-labelledby="playlistScheduleModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="playlistScheduleModalLabel">Schedule</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="playlistScheduleForm" method="post" action="{{ url_for('company.update_playlist_schedule', playlist_id=playlist.id) }}">
<div class="modal-body">
<div class="alert alert-info py-2 mb-3" role="note">
Scheduling uses your browser's local time. Empty values mean “always active”.
</div>
<div class="row g-3">
<div class="col-6">
<label class="form-label" for="schedule_start_date">Start date</label>
<input class="form-control" type="date" id="schedule_start_date" name="schedule_start_date" />
</div>
<div class="col-6">
<label class="form-label" for="schedule_start_time">Start time</label>
<input class="form-control" type="time" id="schedule_start_time" name="schedule_start_time" />
</div>
<div class="col-6">
<label class="form-label" for="schedule_end_date">End date</label>
<input class="form-control" type="date" id="schedule_end_date" name="schedule_end_date" />
</div>
<div class="col-6">
<label class="form-label" for="schedule_end_time">End time</label>
<input class="form-control" type="time" id="schedule_end_time" name="schedule_end_time" />
</div>
</div>
{# JS uses these to populate initial values #}
<input type="hidden" id="schedule_start_iso" value="{{ playlist.schedule_start.isoformat() if playlist.schedule_start else '' }}" />
<input type="hidden" id="schedule_end_iso" value="{{ playlist.schedule_end.isoformat() if playlist.schedule_end else '' }}" />
</div>
<div class="modal-footer">
{% if has_schedule %}
<button
type="submit"
class="btn btn-outline-danger me-auto"
formaction="{{ url_for('company.clear_playlist_schedule', playlist_id=playlist.id) }}"
formmethod="post"
onclick="return confirm('Remove schedule? This playlist will become always active.');"
>
Delete schedule
</button>
{% endif %}
<button type="button" class="btn btn-outline-ink" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-brand">Save</button>
</div>
</form>
</div>
</div>
</div>
{# Rename Playlist Modal #}
<div class="modal fade" id="renamePlaylistModal" tabindex="-1" aria-labelledby="renamePlaylistModalLabel" aria-hidden="true">
<div class="modal-dialog">
@@ -341,6 +454,22 @@
</div>
<input id="video-file-input" class="form-control d-none" type="file" name="file" accept="video/*" />
<div class="text-muted small" id="video-select-status"></div>
{# Upload progress (for large videos) #}
<div id="video-upload-progress" class="d-none mt-2" aria-live="polite">
<div class="progress" style="height: 10px;">
<div
id="video-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="video-upload-progress-text">Uploading…</div>
</div>
</div>
</div>
@@ -357,6 +486,7 @@
</form>
</div>
<div class="modal-footer">
<div class="small text-danger me-auto" id="add-item-error" aria-live="polite"></div>
<button type="button" class="btn btn-outline-ink" id="add-item-back">Back</button>
<button type="button" class="btn btn-brand" id="add-item-submit">Add</button>
</div>
@@ -369,6 +499,120 @@
<script type="module">
(function() {
// -------------------------
// Priority toggle: auto-save
// -------------------------
const priorityForm = document.getElementById('priorityForm');
const priorityCb = document.getElementById('priorityMain');
const priorityStatus = document.getElementById('prioritySaveStatus');
let priorityReqId = 0;
async function savePriority() {
if (!priorityForm || !priorityCb) return;
if (priorityStatus) priorityStatus.textContent = 'Saving…';
const body = new URLSearchParams();
// Mirror server behavior: send "1" when checked, send empty when unchecked.
if (priorityCb.checked) body.set('is_priority', '1');
const reqId = ++priorityReqId;
const res = await fetch(priorityForm.action, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
},
body
});
if (reqId !== priorityReqId) return; // newer request in flight
if (!res.ok) {
if (priorityStatus) priorityStatus.textContent = 'Failed to save';
return;
}
const data = await res.json().catch(() => null);
if (!data || !data.ok) {
if (priorityStatus) priorityStatus.textContent = 'Failed to save';
return;
}
if (priorityStatus) {
priorityStatus.textContent = 'Saved';
window.setTimeout(() => {
if (priorityStatus.textContent === 'Saved') priorityStatus.textContent = '';
}, 900);
}
}
// Prevent accidental full-page POST when user hits Enter inside the form.
priorityForm?.addEventListener('submit', (e) => e.preventDefault());
priorityCb?.addEventListener('change', () => {
savePriority().catch((err) => {
console.warn('Failed to save priority', err);
if (priorityStatus) priorityStatus.textContent = 'Failed to save';
});
});
// -------------------------
// Schedule modal: populate existing UTC timestamps into local date/time inputs
// -------------------------
const schedModalEl = document.getElementById('playlistScheduleModal');
if (schedModalEl) {
const startIso = document.getElementById('schedule_start_iso')?.value || '';
const endIso = document.getElementById('schedule_end_iso')?.value || '';
const scheduleSummary = document.getElementById('scheduleSummary');
const startDate = document.getElementById('schedule_start_date');
const startTime = document.getElementById('schedule_start_time');
const endDate = document.getElementById('schedule_end_date');
const endTime = document.getElementById('schedule_end_time');
function pad2(n) { return String(n).padStart(2, '0'); }
function toLocalDateStr(d) { return `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}`; }
function toLocalTimeStr(d) { return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; }
function toSummary() {
const parts = [];
if (startIso) {
const d = new Date(startIso);
if (!isNaN(d.getTime())) parts.push(`from ${toLocalDateStr(d)} ${toLocalTimeStr(d)}`);
}
if (endIso) {
const d = new Date(endIso);
if (!isNaN(d.getTime())) parts.push(`until ${toLocalDateStr(d)} ${toLocalTimeStr(d)}`);
}
return parts.join(' ');
}
function fill() {
if (startIso) {
const d = new Date(startIso);
if (!isNaN(d.getTime())) {
if (startDate) startDate.value = toLocalDateStr(d);
if (startTime) startTime.value = toLocalTimeStr(d);
}
}
if (endIso) {
const d = new Date(endIso);
if (!isNaN(d.getTime())) {
if (endDate) endDate.value = toLocalDateStr(d);
if (endTime) endTime.value = toLocalTimeStr(d);
}
}
if (scheduleSummary) scheduleSummary.textContent = toSummary() || 'scheduled';
}
schedModalEl.addEventListener('shown.bs.modal', fill);
// Populate summary immediately on page load
if (scheduleSummary) scheduleSummary.textContent = toSummary() || 'scheduled';
}
// Keep the card layout in ONE place to ensure newly-added items match server-rendered items.
// -------------------------
// Add-item modal + steps
@@ -395,6 +639,12 @@
const stepSelect = document.getElementById('step-select');
const stepCrop = document.getElementById('step-crop');
const backBtn = document.getElementById('add-item-back');
const errorEl = document.getElementById('add-item-error');
function setError(msg) {
if (!errorEl) return;
errorEl.textContent = (msg || '').trim();
}
function showStep(which) {
stepSelect?.classList.toggle('active', which === 'select');
@@ -411,6 +661,7 @@
function setType(t) {
typeHidden.value = t;
setError('');
sectionImage.classList.toggle('d-none', t !== 'image');
sectionWebpage.classList.toggle('d-none', t !== 'webpage');
sectionYoutube.classList.toggle('d-none', t !== 'youtube');
@@ -570,6 +821,7 @@
async function submitViaAjax() {
submitBtn.disabled = true;
cropStatus.textContent = '';
setError('');
// If image, replace file with cropped version before sending.
if (typeHidden.value === 'image') {
@@ -604,30 +856,125 @@
const fd = new FormData(form);
const res = await fetch(form.action, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
},
body: fd
});
// For upload progress we need XHR (fetch does not provide upload progress reliably).
const useXhrProgress = (typeHidden.value === 'video');
if (!res.ok) {
let errText = 'Failed to add item.';
try {
const j = await res.json();
if (j && j.error) errText = j.error;
} catch (e) {}
function setVideoProgressVisible(visible) {
const wrap = document.getElementById('video-upload-progress');
wrap?.classList.toggle('d-none', !visible);
}
function setVideoProgress(percent, text) {
const bar = document.getElementById('video-upload-progress-bar');
const txt = document.getElementById('video-upload-progress-text');
const p = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
if (bar) {
bar.style.width = `${p}%`;
bar.setAttribute('aria-valuenow', String(p));
}
if (txt) txt.textContent = text || `${p}%`;
}
function resetVideoProgress() {
setVideoProgress(0, 'Uploading…');
setVideoProgressVisible(false);
}
let resOk = false;
let data = null;
let errorText = null;
if (useXhrProgress) {
// Show progress UI immediately for video uploads.
setVideoProgressVisible(true);
setVideoProgress(0, 'Uploading…');
const xhr = new XMLHttpRequest();
const xhrPromise = new Promise((resolve) => {
xhr.onreadystatechange = () => {
if (xhr.readyState !== 4) return;
resolve();
};
});
xhr.open('POST', form.action, true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.setRequestHeader('Accept', 'application/json');
xhr.upload.onprogress = (e) => {
if (!e || !e.lengthComputable) {
setVideoProgress(0, 'Uploading…');
return;
}
const pct = (e.total > 0) ? ((e.loaded / e.total) * 100) : 0;
setVideoProgress(pct, `Uploading… ${Math.round(pct)}%`);
};
xhr.onerror = () => {
errorText = 'Upload failed (network error).';
};
xhr.send(fd);
await xhrPromise;
// When upload is done, server may still process the file. Give a hint.
setVideoProgress(100, 'Processing…');
const status = xhr.status;
const text = xhr.responseText || '';
let json = null;
try { json = JSON.parse(text); } catch (e) {}
resOk = (status >= 200 && status < 300);
data = json;
if (!resOk) {
// Prefer any earlier error (e.g. xhr.onerror network failure)
errorText = errorText || ((json && json.error) ? json.error : `Failed to add item (HTTP ${status}).`);
}
} else {
const res = await fetch(form.action, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
},
body: fd
});
resOk = res.ok;
if (!resOk) {
let errText = 'Failed to add item.';
try {
const j = await res.json();
if (j && j.error) errText = j.error;
} catch (e) {}
errorText = errText;
} else {
data = await res.json();
}
}
// Error handling shared between fetch/XHR paths
if (!resOk) {
submitBtn.disabled = false;
cropStatus.textContent = errText;
setError(errorText || 'Failed to add item.');
resetVideoProgress();
return;
}
// For XHR path we may not have parsed JSON (bad response)
if (!data) {
submitBtn.disabled = false;
setError('Failed to add item.');
resetVideoProgress();
return;
}
const data = await res.json();
if (!data.ok) {
submitBtn.disabled = false;
cropStatus.textContent = data.error || 'Failed to add item.';
setError(data.error || 'Failed to add item.');
resetVideoProgress();
return;
}
@@ -652,6 +999,7 @@
destroyCropper();
showStep('select');
submitBtn.disabled = true;
resetVideoProgress();
modal?.hide();
}