Unify upload/edit modal track-form + fix slide cropper hang & reorder loss

Track-form unification: the "Add Language Track" dialog in the edit modal now
clones the same #um-track-form-template partial that the create modal uses, so
future tweaks to markup/CSS/behaviour show up in both flows automatically.
Added window.cloneTrackFormTemplate() + window.initTrackFormNode() as the
shared cloner+init. updateTrackCard() writes to both um-tc-*-{id} and
edit-tc-*-{id} ids so one helper serves both modals. Dropped the dead
#edit-extra-track-tpl and the custom editHandleTrackAudio (handleTrackAudio
auto-fills title identically).

Cover slides cropper fixes (both modals):
  - Second batch no longer hangs. _slidesCropLoadNext now calls
    openCropperModal_* BEFORE tcPreload_*; previously preloading first measured
    canvas.clientWidth=0 against a display:none overlay and left _st.ready
    false so Save silently returned.
  - Reorder no longer loses thumbnails. Create flow stored raw Files and
    spawned a FileReader per slide per render; under memory pressure some
    readers failed silently. Now stores {file, url} with URL.createObjectURL
    called once per slide, and renderSlidesStrip assigns img.src = url
    directly. Removal revokes the URL; clear revokes everything it drops.
  - Reset .value on the slides input after processing so re-selecting the
    same filename still fires change.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-02 12:18:35 +03:00
1 parent 2d33e39b9b
commit 468820c9fe
3 files changed
+243 -196

No files matched your search

@@ -306,7 +306,7 @@
@if($embedAudioInput) @if($embedAudioInput)
<input type="file" id="edit-{{ $p }}-audio-input" name="{{ $audioInputName }}" <input type="file" id="edit-{{ $p }}-audio-input" name="{{ $audioInputName }}"
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;" accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
onchange="document.getElementById('edit-{{ $p }}-fname').textContent=this.files[0]?.name||'Keep existing / choose new…'"> onchange="editHandleTrackAudio(this, '{{ $p }}', '{{ $titleId }}')">
@endif @endif
</div> </div>
</div> </div>
@@ -178,22 +178,9 @@
</div> </div>
</div> </div>
{{-- ── Secondary track form template (cloned by JS for each extra track) ──── --}} {{-- Secondary tracks in this modal are cloned from the SHARED
<div id="edit-extra-track-tpl" style="display:none;" aria-hidden="true"> #um-track-form-template defined in layouts.partials.upload-modal. One
<x-track-editor-form template → identical UX in both the create (upload) modal and this modal. --}}
prefix="__TPL__"
:is-primary="false"
language-name="__LANGNAME__"
language-id="csd_v___TPL__"
title-name="__TITLENAME__"
title-id="edit-__TPL__-title"
desc-name="__DESCNAME__"
desc-id="edit-__TPL__-desc"
:embed-audio-input="true"
audio-input-name="__AUDIONAME__"
video-file-input-id=""
/>
</div>
<style> <style>
#editVideoModal .modal-dialog { opacity: 0; transition: opacity .25s ease; } #editVideoModal .modal-dialog { opacity: 0; transition: opacity .25s ease; }
@@ -720,6 +707,8 @@ function editSlidesZoneDrop(e, tid) { tid=tid||'t1'; e.preventDefault(); doc
function editHandleSlides(fileList, tid) { function editHandleSlides(fileList, tid) {
tid = tid || 't1'; tid = tid || 't1';
_editSlidesCropStart(fileList, tid, _editRenderSlides); _editSlidesCropStart(fileList, tid, _editRenderSlides);
const inp = document.getElementById('edit-' + tid + '-slides-input');
if (inp) inp.value = '';
} }
// ── Slides crop queue: every added image is cropped before it enters the strip ── // ── Slides crop queue: every added image is cropped before it enters the strip ──
@@ -740,8 +729,12 @@ function _editSlidesCropLoadNext() {
if (!_editSlidesCropQueue.length) { window.closeCropperModal('slides_edit'); return; } if (!_editSlidesCropQueue.length) { window.closeCropperModal('slides_edit'); return; }
const f = _editSlidesCropQueue.shift(); const f = _editSlidesCropQueue.shift();
if (typeof window.tcPreload_slides_edit === 'function') { if (typeof window.tcPreload_slides_edit === 'function') {
window.tcPreload_slides_edit(f); // Open modal first so the cropper canvas has a real layout box when
// initCropper measures. Preloading first (prior order) caused the
// second batch to open with stale zero-sized measurements and a Save
// button that silently did nothing.
window.openCropperModal_slides_edit(); window.openCropperModal_slides_edit();
requestAnimationFrame(() => window.tcPreload_slides_edit(f));
} }
} }
@@ -827,6 +820,8 @@ function editSlidesZoneDropE(e, tid) { e.preventDefault(); document.getEleme
function editHandleSlidesForTrack(tid, fileList) { function editHandleSlidesForTrack(tid, fileList) {
_editSlidesCropStart(fileList, tid, _editRenderSlidesForTrack); _editSlidesCropStart(fileList, tid, _editRenderSlidesForTrack);
const inp = document.getElementById('edit-slides-input-' + tid);
if (inp) inp.value = '';
} }
function editClearSlidesForTrack(e, tid) { function editClearSlidesForTrack(e, tid) {
@@ -885,7 +880,9 @@ function _editRenderSlidesForTrack(tid) {
// ── Track popup ─────────────────────────────────────────────────────────────── // ── Track popup ───────────────────────────────────────────────────────────────
function editOpenTrackPopup(trackId) { function editOpenTrackPopup(trackId) {
document.querySelectorAll('#edit-track-popup .um-track-form').forEach(f => f.style.display = 'none'); document.querySelectorAll('#edit-track-popup .um-track-form').forEach(f => f.style.display = 'none');
const form = document.getElementById('edit-tf-' + trackId); // Primary track form uses the legacy id (edit-tf-t1); extra tracks are
// cloned from the SHARED #um-track-form-template and use id um-tf-{id}.
const form = document.getElementById('edit-tf-' + trackId) || document.getElementById('um-tf-' + trackId);
if (form) form.style.display = 'block'; if (form) form.style.display = 'block';
const popup = document.getElementById('edit-track-popup'); const popup = document.getElementById('edit-track-popup');
popup.style.opacity = '0'; popup.style.opacity = '0';
@@ -902,6 +899,10 @@ function editCloseTrackPopup() {
} }
} }
// For t1 the inputs are the primary-track Blade ids. For extra tracks (both
// newly-added and existing-track rows) the inputs come from the SHARED
// #um-track-form-template, so they use the shared ids track-lang-{id} /
// track-title-{id}. One function reads whichever is present.
function editUpdateTrackCard(trackId) { function editUpdateTrackCard(trackId) {
let langCode = '', title = ''; let langCode = '', title = '';
if (trackId === 't1') { if (trackId === 't1') {
@@ -910,9 +911,8 @@ function editUpdateTrackCard(trackId) {
langCode = li ? li.value : ''; langCode = li ? li.value : '';
title = ti ? ti.value.trim() : ''; title = ti ? ti.value.trim() : '';
} else { } else {
const n = trackId.replace('e',''); const li = document.getElementById('track-lang-' + trackId);
const li = document.getElementById('csd_v_e' + n); const ti = document.getElementById('track-title-' + trackId);
const ti = document.getElementById('edit-e' + n + '-title');
langCode = li ? li.value : ''; langCode = li ? li.value : '';
title = ti ? ti.value.trim() : ''; title = ti ? ti.value.trim() : '';
} }
@@ -932,24 +932,26 @@ function editUpdateTrackCard(trackId) {
// ── Extra track management ──────────────────────────────────────────────────── // ── Extra track management ────────────────────────────────────────────────────
const LANG_OPTIONS_EDIT_MODAL = @json(\App\Data\Languages::forLanguage()); const LANG_OPTIONS_EDIT_MODAL = @json(\App\Data\Languages::forLanguage());
// Clone the SHARED upload-modal track-form partial into the edit popup. Both
// "add new track" and "load existing track" go through here, so there is one
// source of truth for the form markup. The caller picks the submission names:
// - new track → extra_track_*[]
// - existing track → track_*_updates[trackId]
function _editCloneTrackForm(prefix, langName, titleName, descName, audioName) { function _editCloneTrackForm(prefix, langName, titleName, descName, audioName) {
const tpl = document.getElementById('edit-extra-track-tpl'); const frag = window.cloneTrackFormTemplate({
if (!tpl) return null; key: prefix, langName, titleName, descName, audioName,
const html = tpl.innerHTML });
.replaceAll('__TPL__', prefix) if (!frag) return null;
.replaceAll('__LANGNAME__', langName) const host = document.getElementById('edit-tf-extra');
.replaceAll('__TITLENAME__', titleName) host.appendChild(frag);
.replaceAll('__DESCNAME__', descName) // _slidesData is the shared state used by the shared slides-zone handlers.
.replaceAll('__AUDIONAME__', audioName); // Mirror the handle under _editSlidesData so the edit submit code stays
const tmp = document.createElement('div'); // oblivious to the migration (one array, two aliases).
tmp.innerHTML = html; window._slidesData = window._slidesData || {};
const formEl = tmp.querySelector('.um-track-form'); _slidesData[prefix] = [];
if (!formEl) return null; _editSlidesData[prefix] = _slidesData[prefix];
formEl.style.display = 'none'; window.initTrackFormNode(prefix, pfx => editUpdateTrackCard(pfx));
document.getElementById('edit-tf-extra').appendChild(formEl); return document.getElementById('um-tf-' + prefix);
_editSlidesData[prefix] = [];
if (window.CSD) new CSD('csd_' + prefix);
return formEl;
} }
function _editTrackCard(n, trackId, isExisting) { function _editTrackCard(n, trackId, isExisting) {
@@ -990,34 +992,47 @@ function _editAddExistingTrack(track) {
'track_description_updates[' + track.id + ']', 'track_description_updates[' + track.id + ']',
'track_file_updates[' + track.id + ']' 'track_file_updates[' + track.id + ']'
); );
const titleEl = document.getElementById('edit-' + pfx + '-title'); // Shared partial field ids: track-title-{pfx} / track-desc-{pfx} / track-lang-{pfx}
const titleEl = document.getElementById('track-title-' + pfx);
if (titleEl) titleEl.value = track.title || ''; if (titleEl) titleEl.value = track.title || '';
const descEl = document.getElementById('edit-' + pfx + '-desc'); const descEl = document.getElementById('track-desc-' + pfx);
if (descEl) { descEl.value = track.description || ''; descEl.dispatchEvent(new Event('rte:refresh')); } if (descEl) { descEl.value = track.description || ''; descEl.dispatchEvent(new Event('rte:refresh')); }
if (track.language && window.CSD) { if (track.language && window.CSD) {
const hi = document.getElementById('csd_v_' + pfx); const hi = document.getElementById('track-lang-' + pfx);
const wrap = document.getElementById('csd_' + pfx); const wrap = document.getElementById('lsd_track_' + pfx);
if (hi) hi.value = track.language; if (hi) hi.value = track.language;
if (wrap) _editSetLangCsd(wrap, track.language); if (wrap) _editSetLangCsd(wrap, track.language);
} }
const hi = document.getElementById('csd_v_' + pfx);
if (hi) hi.addEventListener('change', () => editUpdateTrackCard(pfx));
editUpdateTrackCard(pfx); editUpdateTrackCard(pfx);
_editUpdateTrackPositions(); _editUpdateTrackPositions();
} }
function editDeleteTrack(trackId, n) { function _editRemoveTrackForms(n) {
_editDeleteTrackIds.push(trackId); const pfx = 'e' + n;
document.getElementById('edit-tc-e'+n)?.remove(); document.getElementById('edit-tc-' + pfx)?.remove();
document.getElementById('edit-tf-e'+n)?.remove(); // Form id depends on which cloner built it: shared template → um-tf-{pfx},
// legacy edit template → edit-tf-{pfx}. Clean up whichever exists.
document.getElementById('um-tf-' + pfx)?.remove();
document.getElementById('edit-tf-' + pfx)?.remove();
delete _editSlidesData[pfx];
delete (window._slidesData || {})[pfx];
const popup = document.getElementById('edit-track-popup'); const popup = document.getElementById('edit-track-popup');
if (popup.style.display !== 'none') { if (popup.style.display !== 'none') {
const vis = popup.querySelector('.um-track-form:not([style*="none"])'); const vis = popup.querySelector('.um-track-form:not([style*="none"])');
if (!vis || vis.id === 'edit-tf-e'+n) popup.style.display = 'none'; if (!vis || vis.id === 'edit-tf-' + pfx || vis.id === 'um-tf-' + pfx) popup.style.display = 'none';
} }
}
function editDeleteTrack(trackId, n) {
_editDeleteTrackIds.push(trackId);
_editRemoveTrackForms(n);
_editUpdateTrackPositions(); _editUpdateTrackPositions();
} }
function editRemoveNewTrack(n) {
_editRemoveTrackForms(n);
}
function editAddExtraTrack() { function editAddExtraTrack() {
const n = ++_editExtraCount; const n = ++_editExtraCount;
const pfx = 'e' + n; const pfx = 'e' + n;
@@ -1029,23 +1044,10 @@ function editAddExtraTrack() {
'extra_track_descriptions[]', 'extra_track_descriptions[]',
'extra_track_files[]' 'extra_track_files[]'
); );
const hi = document.getElementById('csd_v_' + pfx);
if (hi) hi.addEventListener('change', () => editUpdateTrackCard(pfx));
_editUpdateTrackPositions(); _editUpdateTrackPositions();
editOpenTrackPopup(pfx); editOpenTrackPopup(pfx);
} }
function editRemoveNewTrack(n) {
document.getElementById('edit-tc-e'+n)?.remove();
document.getElementById('edit-tf-e'+n)?.remove();
delete _editSlidesData['e'+n];
const popup = document.getElementById('edit-track-popup');
if (popup.style.display !== 'none') {
const vis = popup.querySelector('.um-track-form:not([style*="none"])');
if (!vis || vis.id === 'edit-tf-e'+n) popup.style.display = 'none';
}
}
// Wire primary language → update card // Wire primary language → update card
;(function() { ;(function() {
const li = document.getElementById('edit_primary_language'); const li = document.getElementById('edit_primary_language');
@@ -338,9 +338,31 @@
</div> </div>
</div>{{-- /um-tf-t1 --}} </div>{{-- /um-tf-t1 --}}
{{-- Extra track forms appended by JS --}} {{-- Extra track forms appended by JS (cloned from the template below) --}}
<div id="um-tf-extra"></div> <div id="um-tf-extra"></div>
{{-- SHARED track form template.
This is the single source of truth for the "Add Language Track"
dialog used by BOTH the create (upload) modal AND the edit modal.
Any CSS/markup/behaviour change here automatically applies to both.
Cloners replace the placeholders at clone time:
__N__ → track key (e.g. "e1" for create, "editE1" for edit)
__LANGNAME__ → form field name for the language select
__TITLENAME__ → form field name for the title input
__DESCNAME__ → form field name for the description textarea
__AUDIONAME__ → form field name for the audio file input --}}
<template id="um-track-form-template">
@include('layouts.partials.upload-modal.track-form', [
'key' => '__N__',
'showPrimaryNote' => false,
'langName' => '__LANGNAME__',
'titleName' => '__TITLENAME__',
'descName' => '__DESCNAME__',
'audioName' => '__AUDIONAME__',
'langRequired' => true,
])
</template>
</div>{{-- /um-tp-body --}} </div>{{-- /um-tp-body --}}
<div class="um-tp-footer"> <div class="um-tp-footer">
<button type="button" class="action-btn action-btn-primary" onclick="closeTrackPopup()"> <button type="button" class="action-btn action-btn-primary" onclick="closeTrackPopup()">
@@ -1372,6 +1394,10 @@ function slidesZoneDrop(e, tid) {
function handleSlidesForTrack(tid, fileList) { function handleSlidesForTrack(tid, fileList) {
_slidesCropStart(fileList, tid); _slidesCropStart(fileList, tid);
// Reset the file input so picking the SAME filename a second time still
// fires change (browsers suppress change when FileList is identical).
const inp = document.getElementById('slides-input-' + tid);
if (inp) inp.value = '';
} }
// ── Slides crop queue: every added image is cropped before it enters the strip ── // ── Slides crop queue: every added image is cropped before it enters the strip ──
@@ -1390,8 +1416,12 @@ function _slidesCropLoadNext() {
if (!_slidesCropQueue.length) { window.closeCropperModal('slides_upload'); return; } if (!_slidesCropQueue.length) { window.closeCropperModal('slides_upload'); return; }
const f = _slidesCropQueue.shift(); const f = _slidesCropQueue.shift();
if (typeof window.tcPreload_slides_upload === 'function') { if (typeof window.tcPreload_slides_upload === 'function') {
window.tcPreload_slides_upload(f); // Open the modal FIRST so the canvas has a non-zero layout box when
// initCropper measures clientWidth/clientHeight. Preloading first
// (the previous order) caused the second batch to open with stale
// zero-sized measurements and a Save button that silently did nothing.
window.openCropperModal_slides_upload(); window.openCropperModal_slides_upload();
requestAnimationFrame(() => window.tcPreload_slides_upload(f));
} }
} }
@@ -1400,7 +1430,13 @@ function uploadSlidesCropDone(file) {
if (tid && file) { if (tid && file) {
if (!_slidesData[tid]) _slidesData[tid] = []; if (!_slidesData[tid]) _slidesData[tid] = [];
if (_slidesData[tid].length < 10) { if (_slidesData[tid].length < 10) {
_slidesData[tid].push(file); // Cache a per-file object URL once so renderSlidesStrip can reuse it
// on every reorder without spawning a fresh FileReader per slide.
// The old approach (new FileReader per file per render) ran dozens
// of readers after a reorder and silently dropped some under memory
// pressure, leaving the strip with broken thumbnails.
const url = URL.createObjectURL(file);
_slidesData[tid].push({ file, url });
renderSlidesStrip(tid); renderSlidesStrip(tid);
} }
} }
@@ -1422,7 +1458,10 @@ function renderSlidesStrip(tid) {
if (zone) zone.style.borderColor = ''; if (zone) zone.style.borderColor = '';
return; return;
} }
files.forEach((f, index) => { files.forEach((item, index) => {
// Entries are {file, url}; cached url is stable across reorders.
const url = item.url || (item.file ? URL.createObjectURL(item.file) : '');
if (item.file && !item.url) item.url = url;
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;flex-shrink:0;cursor:grab;'; wrap.style.cssText = 'position:relative;flex-shrink:0;cursor:grab;';
wrap.draggable = true; wrap.dataset.index = index; wrap.dataset.trackId = tid; wrap.draggable = true; wrap.dataset.index = index; wrap.dataset.trackId = tid;
@@ -1441,10 +1480,15 @@ function renderSlidesStrip(tid) {
const rb = document.createElement('button'); const rb = document.createElement('button');
rb.type='button'; rb.style.cssText='position:absolute;top:-4px;right:-4px;width:16px;height:16px;background:#e61e1e;border:none;border-radius:50%;color:#fff;font-size:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;z-index:1;'; rb.type='button'; rb.style.cssText='position:absolute;top:-4px;right:-4px;width:16px;height:16px;background:#e61e1e;border:none;border-radius:50%;color:#fff;font-size:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;z-index:1;';
rb.innerHTML='<i class="bi bi-x"></i>'; rb.innerHTML='<i class="bi bi-x"></i>';
rb.addEventListener('click', function(ev) { ev.stopPropagation(); _slidesData[tid].splice(index,1); renderSlidesStrip(tid); }); rb.addEventListener('click', function(ev) {
ev.stopPropagation();
const removed = _slidesData[tid].splice(index,1)[0];
if (removed && removed.url) URL.revokeObjectURL(removed.url);
renderSlidesStrip(tid);
});
const img = document.createElement('img'); const img = document.createElement('img');
img.style.cssText='width:52px;height:52px;object-fit:cover;border-radius:6px;border:1px solid rgba(255,255,255,.12);display:block;'; img.alt=''; img.style.cssText='width:52px;height:52px;object-fit:cover;border-radius:6px;border:1px solid rgba(255,255,255,.12);display:block;'; img.alt='';
const reader = new FileReader(); reader.onload = ev2 => { img.src=ev2.target.result; }; reader.readAsDataURL(f); img.src = url;
wrap.appendChild(img); wrap.appendChild(rb); strip.appendChild(wrap); wrap.appendChild(img); wrap.appendChild(rb); strip.appendChild(wrap);
}); });
if (cnt) cnt.textContent = files.length===1 ? '1 image — static cover' : files.length+' images — will crossfade during playback'; if (cnt) cnt.textContent = files.length===1 ? '1 image — static cover' : files.length+' images — will crossfade during playback';
@@ -1455,6 +1499,7 @@ function renderSlidesStrip(tid) {
function clearSlidesForTrack(e, tid) { function clearSlidesForTrack(e, tid) {
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation();
(_slidesData[tid] || []).forEach(s => { if (s && s.url) URL.revokeObjectURL(s.url); });
_slidesData[tid] = []; _slidesData[tid] = [];
const input = document.getElementById('slides-input-' + tid); const input = document.getElementById('slides-input-' + tid);
if (input) { const fresh=input.cloneNode(false); fresh.addEventListener('change',function(){handleSlidesForTrack(tid,this.files);}); input.parentNode.replaceChild(fresh,input); } if (input) { const fresh=input.cloneNode(false); fresh.addEventListener('change',function(){handleSlidesForTrack(tid,this.files);}); input.parentNode.replaceChild(fresh,input); }
@@ -1494,12 +1539,10 @@ function updateTrackCard(trackId) {
langCode = li ? li.value : ''; langCode = li ? li.value : '';
title = ti ? ti.value.trim() : ''; title = ti ? ti.value.trim() : '';
} else { } else {
const n = trackId.replace('e', ''); const li = document.getElementById('track-lang-' + trackId);
const uid = 'ltme_' + n; const ti = document.getElementById('track-title-' + trackId);
const li = document.getElementById('csd_v_' + uid); langCode = li ? li.value : '';
const ti = document.getElementById('um-tf-title-' + trackId); title = ti ? ti.value.trim() : '';
langCode = li ? li.value : '';
title = ti ? ti.value.trim() : '';
} }
const lang = LANG_OPTIONS_MODAL.find(o => o.value === langCode); const lang = LANG_OPTIONS_MODAL.find(o => o.value === langCode);
const hasContent = !!(lang || title); const hasContent = !!(lang || title);
@@ -1512,49 +1555,111 @@ function updateTrackCard(trackId) {
if (card) card.style.display = hasContent ? '' : 'none'; if (card) card.style.display = hasContent ? '' : 'none';
} }
const flagEl = document.getElementById('um-tc-flag-' + trackId); // Update WHICHEVER card id convention exists — the create modal uses
const titleEl = document.getElementById('um-tc-title-' + trackId); // `um-tc-*-${id}` and the edit modal uses `edit-tc-*-${id}`. Writing to
if (flagEl) flagEl.className = 'fi fi-' + (lang ? lang.flag : 'xx') + ' um-tc-flag'; // both keeps this helper a single source of truth for any shared template.
if (titleEl) { const flagEls = [
if (lang && title) titleEl.textContent = title + ' — ' + lang.label; document.getElementById('um-tc-flag-' + trackId),
else if (lang) titleEl.textContent = lang.label; document.getElementById('edit-tc-flag-' + trackId),
else if (title) titleEl.textContent = title; ];
else titleEl.textContent = ''; const titleEls = [
} document.getElementById('um-tc-title-' + trackId),
document.getElementById('edit-tc-title-' + trackId),
];
const flagClass = 'fi fi-' + (lang ? lang.flag : 'xx') + ' um-tc-flag';
let display;
if (lang && title) display = title + ' — ' + lang.label;
else if (lang) display = lang.label;
else if (title) display = title;
else display = '';
flagEls.forEach(el => { if (el) el.className = flagClass; });
titleEls.forEach(el => { if (el) el.textContent = display; });
} }
// ── Extra track management ──────────────────────────────────── // ── Extra track management ────────────────────────────────────
const LANG_OPTIONS_MODAL = @json(\App\Data\Languages::forLanguage()); const LANG_OPTIONS_MODAL = @json(\App\Data\Languages::forLanguage());
function _ltBuildOptsHtml() { // Audio picker handler shared by every track form (primary + extras) cloned
return LANG_OPTIONS_MODAL.map(o => // from the <template id="um-track-form-template"> blade partial.
`<li class="csd-opt" role="option" tabindex="-1" data-v="${o.value}" data-s="${o.search}" aria-selected="false"> function handleTrackAudio(key, input) {
<span class="csd-opt-ico"><span class="fi fi-${o.flag} lsd-flag"></span></span> const file = input.files && input.files[0];
<span class="csd-opt-main">${o.label}</span> const fnameEl = document.getElementById('track-audio-fname-' + key);
<span class="csd-opt-sub">${o.native}</span> const box = document.getElementById('track-audio-box-' + key);
</li>` if (fnameEl) fnameEl.textContent = file ? file.name : 'Click to choose file…';
).join(''); if (box) box.style.borderColor = file ? '#22c55e' : '';
// Auto-fill the track title from the filename (same behavior as primary track)
if (file) {
const titleEl = document.getElementById('track-title-' + key);
if (titleEl && !titleEl.value) {
titleEl.value = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]/g, ' ');
updateTrackCard(key);
}
}
} }
// Shared cloner for #um-track-form-template. Both the create modal and the
// edit modal call this so there is ONE source for the track-form markup.
// Returns a DocumentFragment with every placeholder (__N__, __LANGNAME__, …)
// substituted. The caller is responsible for appending it + initialising the
// language picker + RTE on the new nodes.
window.cloneTrackFormTemplate = window.cloneTrackFormTemplate || function (opts) {
const tpl = document.getElementById('um-track-form-template');
if (!tpl) { console.error('[track-form] missing #um-track-form-template'); return null; }
const frag = tpl.content.cloneNode(true);
const subs = {
'__N__': opts.key,
'__LANGNAME__': opts.langName,
'__TITLENAME__': opts.titleName,
'__DESCNAME__': opts.descName,
'__AUDIONAME__': opts.audioName,
};
const walker = document.createTreeWalker(frag, NodeFilter.SHOW_ELEMENT, null);
const nodes = [];
for (let el = walker.nextNode(); el; el = walker.nextNode()) nodes.push(el);
nodes.forEach(node => {
for (const attr of Array.from(node.attributes)) {
let v = attr.value;
for (const k in subs) if (v.indexOf(k) !== -1) v = v.split(k).join(subs[k]);
if (v !== attr.value) node.setAttribute(attr.name, v);
}
});
return frag;
};
// Shared post-clone wiring: CSD language picker + RTE on fresh markup, plus
// change listeners that keep whichever card is on-screen in sync with the form.
window.initTrackFormNode = window.initTrackFormNode || function (key, onChange) {
if (window.CSD) new CSD('lsd_track_' + key);
const rteWrap = document.querySelector('#um-tf-' + key + ' .rte-wrap:not([data-rte-ready])');
if (rteWrap && window.RTE && typeof window.RTE.init === 'function') window.RTE.init(rteWrap);
const langInput = document.getElementById('track-lang-' + key);
const titleInput = document.getElementById('track-title-' + key);
const cb = onChange || (() => updateTrackCard(key));
if (langInput) langInput.addEventListener('change', () => cb(key));
if (titleInput) titleInput.addEventListener('input', () => cb(key));
};
function addExtraTrackModal() { function addExtraTrackModal() {
const n = ++_umExtraCount; const n = ++_umExtraCount;
const trackNum = n + 1; const trackNum = n + 1;
const uid = 'ltme_' + n; const key = 'e' + n;
// Track card (shown in the list on the main modal body)
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'um-track-card'; card.className = 'um-track-card';
card.id = 'um-tc-e' + n; card.id = 'um-tc-' + key;
card.innerHTML = ` card.innerHTML = `
<div class="um-tc-body"> <div class="um-tc-body">
<div class="um-tc-left"> <div class="um-tc-left">
<div class="um-tc-num">${trackNum}</div> <div class="um-tc-num">${trackNum}</div>
<span class="fi fi-xx um-tc-flag" id="um-tc-flag-e${n}"></span> <span class="fi fi-xx um-tc-flag" id="um-tc-flag-${key}"></span>
<div class="um-tc-info"> <div class="um-tc-info">
<span class="um-tc-title" id="um-tc-title-e${n}">Track — select language</span> <span class="um-tc-title" id="um-tc-title-${key}">Track — select language</span>
</div> </div>
</div> </div>
<div class="um-tc-right"> <div class="um-tc-right">
<button type="button" class="action-btn" onclick="openTrackPopup('e${n}')"> <button type="button" class="action-btn" onclick="openTrackPopup('${key}')">
<i class="bi bi-pencil"></i> <span>Edit</span> <i class="bi bi-pencil"></i> <span>Edit</span>
</button> </button>
<button type="button" class="action-btn action-btn-danger icon-only" onclick="removeExtraTrackModal(${n})" title="Remove track"> <button type="button" class="action-btn action-btn-danger icon-only" onclick="removeExtraTrackModal(${n})" title="Remove track">
@@ -1564,100 +1669,24 @@ function addExtraTrackModal() {
</div>`; </div>`;
document.getElementById('um-tc-extra').appendChild(card); document.getElementById('um-tc-extra').appendChild(card);
const formEl = document.createElement('div'); // Clone the SHARED track-form template and substitute placeholders. The same
formEl.className = 'um-track-form'; // template is cloned by the edit modal (editAddExtraTrack / existing-track
formEl.id = 'um-tf-e' + n; // loader), so a change here automatically shows up in both flows.
formEl.style.display = 'none'; const frag = window.cloneTrackFormTemplate({
formEl.innerHTML = ` key: key,
<div class="um-tf-row2" style="margin-bottom:14px;"> langName: 'extra_track_languages[]',
<div> titleName: 'extra_track_titles[]',
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Language <span class="um-req">*</span></label> descName: 'extra_track_descriptions[]',
<div class="csd-wrap" id="${uid}"> audioName: 'extra_track_files[]',
<button type="button" class="csd-btn" aria-haspopup="listbox" aria-expanded="false" aria-label="Select language"> });
<span class="csd-ico"><span class="fi fi-xx lsd-flag"></span></span> if (!frag) return;
<span class="csd-val ph">Select language</span>
<i class="bi bi-chevron-down csd-arr"></i>
</button>
<div class="csd-panel" hidden role="listbox">
<div class="csd-srch"><i class="bi bi-search"></i>
<input class="csd-sinput" type="text" placeholder="Search language…" autocomplete="off" spellcheck="false">
</div>
<ul class="csd-list">${_ltBuildOptsHtml()}</ul>
<p class="csd-empty" hidden>No results</p>
</div>
<input type="hidden" name="extra_track_languages[]" id="csd_v_${uid}">
</div>
</div>
<div>
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
Title <span class="um-lbl-hint">optional</span>
</label>
<input type="text" name="extra_track_titles[]" id="um-tf-title-e${n}" class="um-input"
style="font-size:13px;padding:9px 12px;" placeholder="Title in this language…">
</div>
</div>
<div style="margin-bottom:14px;">
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
Description <span class="um-lbl-hint">optional</span>
</label>
<div class="rte-wrap" data-min-height="100px">
<textarea class="rte-source" name="extra_track_descriptions[]" id="um-tf-desc-e${n}"
placeholder="Description in this language…"></textarea>
</div>
</div>
<div style="margin-bottom:14px;">
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
<i class="bi bi-music-note-beamed"></i> Audio File
</label>
<label class="lta-file-label"
onmouseenter="this.style.borderColor='#e61e1e'"
onmouseleave="this.style.borderColor='#222'">
<i class="bi bi-music-note-beamed" style="color:#e61e1e;font-size:15px;flex-shrink:0;"></i>
<span class="lta-file-name" id="um-tf-fname-e${n}">Choose audio file…</span>
<input type="file" name="extra_track_files[]"
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
onchange="document.getElementById('um-tf-fname-e${n}').textContent=this.files[0]?.name||'Choose audio file…'">
</label>
</div>
<div>
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
Cover Slides <span class="um-lbl-hint">optional · drag to reorder</span>
</label>
<div id="slides-zone-e${n}" class="um-slides-zone"
onclick="slidesZoneClick(event,'e${n}')"
ondragover="slidesZoneDragover(event,'e${n}')"
ondragleave="slidesZoneDragleave(event,'e${n}')"
ondrop="slidesZoneDrop(event,'e${n}')">
<input type="file" accept="image/*" multiple style="display:none" id="slides-input-e${n}"
onchange="handleSlidesForTrack('e${n}',this.files)">
<div class="um-slides-ph" id="slides-ph-e${n}">
<i class="bi bi-images"></i>
<span>Click or drag to add cover images</span>
</div>
<div id="slides-preview-e${n}" style="display:none;padding:10px 12px;width:100%;box-sizing:border-box;">
<div id="slides-strip-e${n}" class="slides-strip" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px;"></div>
<div style="display:flex;align-items:center;justify-content:space-between;">
<span id="slides-count-e${n}" style="font-size:12px;color:var(--text-secondary);"></span>
<button type="button" onclick="clearSlidesForTrack(event,'e${n}')"
style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:12px;padding:0;display:flex;align-items:center;gap:4px;">
<i class="bi bi-x-lg"></i> Clear
</button>
</div>
</div>
</div>
</div>`;
document.getElementById('um-tf-extra').appendChild(formEl); const host = document.getElementById('um-tf-extra');
_slidesData['e' + n] = []; host.appendChild(frag);
_slidesData[key] = [];
if (window.CSD) new CSD(uid); window.initTrackFormNode(key);
openTrackPopup(key);
const hiddenInput = document.getElementById('csd_v_' + uid);
if (hiddenInput) {
hiddenInput.addEventListener('change', function() { updateTrackCard('e' + n); });
}
openTrackPopup('e' + n);
} }
function removeExtraTrackModal(n) { function removeExtraTrackModal(n) {
@@ -1686,13 +1715,15 @@ function removeExtraTrackModal(n) {
// ── Chunked upload helper (bypasses Cloudflare's 100 MB per-request cap) ─ // ── Chunked upload helper (bypasses Cloudflare's 100 MB per-request cap) ─
async function _chunkedUploadVideo(file, onProgress) { async function _chunkedUploadVideo(file, onProgress) {
const CHUNK = 8 * 1024 * 1024; // 8 MB — well under CF 100 MB limit const CHUNK = 2 * 1024 * 1024; // 2 MB per chunk
const CONCURRENCY = 3; // parallel in-flight chunks
const total = Math.max(1, Math.ceil(file.size / CHUNK)); const total = Math.max(1, Math.ceil(file.size / CHUNK));
const uploadId = (window.crypto && crypto.randomUUID) const uploadId = (window.crypto && crypto.randomUUID)
? crypto.randomUUID() ? crypto.randomUUID()
: (Date.now() + '-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)); : (Date.now() + '-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
for (let i = 0; i < total; i++) { let uploadedBytes = 0;
const uploadOne = async (i) => {
const start = i * CHUNK; const start = i * CHUNK;
const end = Math.min(start + CHUNK, file.size); const end = Math.min(start + CHUNK, file.size);
const blob = file.slice(start, end); const blob = file.slice(start, end);
@@ -1700,6 +1731,7 @@ async function _chunkedUploadVideo(file, onProgress) {
const fd = new FormData(); const fd = new FormData();
fd.append('upload_id', uploadId); fd.append('upload_id', uploadId);
fd.append('chunk_index', i); fd.append('chunk_index', i);
fd.append('chunk_offset', start);
fd.append('total_chunks', total); fd.append('total_chunks', total);
fd.append('filename', file.name); fd.append('filename', file.name);
fd.append('chunk', blob, 'chunk'); fd.append('chunk', blob, 'chunk');
@@ -1715,8 +1747,20 @@ async function _chunkedUploadVideo(file, onProgress) {
try { const j = await res.json(); if (j && j.message) msg = j.message; } catch (_) {} try { const j = await res.json(); if (j && j.message) msg = j.message; } catch (_) {}
throw new Error(msg); throw new Error(msg);
} }
if (onProgress) onProgress({ loaded: end, total: file.size }); uploadedBytes += (end - start);
} if (onProgress) onProgress({ loaded: Math.min(uploadedBytes, file.size), total: file.size });
};
let next = 0;
const workers = Array.from({ length: Math.min(CONCURRENCY, total) }, async () => {
while (true) {
const i = next++;
if (i >= total) return;
await uploadOne(i);
}
});
await Promise.all(workers);
return { uploadId: uploadId, filename: file.name }; return { uploadId: uploadId, filename: file.name };
} }
@@ -1753,8 +1797,9 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
} }
const formData = new FormData(this); const formData = new FormData(this);
const _slideFile = s => (s && s.file) ? s.file : s; // tolerate legacy raw-File entries
if (_isMusicMode) { if (_isMusicMode) {
(_slidesData['t1'] || []).forEach(f => formData.append('slides[]', f)); (_slidesData['t1'] || []).forEach(s => formData.append('slides[]', _slideFile(s)));
// Walk extra-track form sections in DOM order so the slide index lines up // Walk extra-track form sections in DOM order so the slide index lines up
// with the positional `extra_track_files[]` the form already submits. // with the positional `extra_track_files[]` the form already submits.
var extraForms = document.querySelectorAll('#um-tf-extra .um-track-form'); var extraForms = document.querySelectorAll('#um-tf-extra .um-track-form');
@@ -1763,8 +1808,8 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
if (!m) return; if (!m) return;
var tid = m[1]; var tid = m[1];
var files = _slidesData[tid] || []; var files = _slidesData[tid] || [];
files.forEach(function (f) { files.forEach(function (s) {
formData.append('extra_track_slides[' + domIdx + '][]', f); formData.append('extra_track_slides[' + domIdx + '][]', _slideFile(s));
}); });
}); });
} }