Update playlist detail UI (priority/schedule/add-item)

This commit is contained in:
2026-01-25 13:50:22 +01:00
parent f4b7fb62f5
commit 47aca9d64d

View File

@@ -319,46 +319,33 @@
<input type="hidden" name="item_type" id="item_type" value="image" /> <input type="hidden" name="item_type" id="item_type" value="image" />
<input type="hidden" name="crop_mode" id="crop_mode" value="16:9" /> <input type="hidden" name="crop_mode" id="crop_mode" value="16:9" />
{# Step 1: pick type, then show relevant inputs #}
<div id="step-type" class="step active">
<div class="text-muted small mb-2">Select a slide type.</div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label">Title (optional)</label> <div class="btn-group w-100" role="group" aria-label="Slide type">
<input class="form-control" name="title" />
</div>
<div class="mb-2" id="duration-group">
<label class="form-label">Duration (seconds, for images/webpages/YouTube)</label>
<input class="form-control" type="number" name="duration_seconds" value="10" min="1" />
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<div class="btn-group w-100" role="group" aria-label="Item type">
<input type="radio" class="btn-check" name="item_type_choice" id="type-image" autocomplete="off" checked> <input type="radio" class="btn-check" name="item_type_choice" id="type-image" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="type-image">Image</label> <label class="btn btn-outline-primary" for="type-image">Image</label>
<input type="radio" class="btn-check" name="item_type_choice" id="type-webpage" autocomplete="off"> <input type="radio" class="btn-check" name="item_type_choice" id="type-webpage" autocomplete="off">
<label class="btn btn-outline-primary" for="type-webpage">Webpage</label> <label class="btn btn-outline-primary" for="type-webpage">Webpage</label>
<input type="radio" class="btn-check" name="item_type_choice" id="type-youtube" autocomplete="off">
<label class="btn btn-outline-primary" for="type-youtube">YouTube</label>
<input type="radio" class="btn-check" name="item_type_choice" id="type-video" autocomplete="off"> <input type="radio" class="btn-check" name="item_type_choice" id="type-video" autocomplete="off">
<label class="btn btn-outline-primary" for="type-video">Video</label> <label class="btn btn-outline-primary" for="type-video">Video</label>
</div> </div>
</div> </div>
<div class="mb-3" id="crop-mode-group">
<label class="form-label">Image crop</label>
<div class="btn-group w-100" role="group" aria-label="Crop mode">
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-16-9" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="crop-16-9">16:9 (landscape)</label>
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-9-16" autocomplete="off">
<label class="btn btn-outline-primary" for="crop-9-16">9:16 (portrait)</label>
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-none" autocomplete="off">
<label class="btn btn-outline-primary" for="crop-none">No crop</label>
</div> </div>
<div class="text-muted small mt-1">Cropping is optional. If enabled, we center-crop to the chosen aspect ratio.</div>
{# Step 2 (only for video): choose upload vs YouTube #}
<div id="step-video-source" class="step">
<div class="text-muted small mb-2">Choose how you want to add the video.</div>
<div class="btn-group w-100" role="group" aria-label="Video source">
<input type="radio" class="btn-check" name="video_source_choice" id="video-source-upload" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="video-source-upload">Upload video</label>
<input type="radio" class="btn-check" name="video_source_choice" id="video-source-youtube" autocomplete="off">
<label class="btn btn-outline-primary" for="video-source-youtube">YouTube</label>
</div>
</div> </div>
<style> <style>
@@ -393,8 +380,33 @@
} }
</style> </style>
<div id="step-select" class="step active"> <div id="step-input" class="step">
<div class="text-muted small mb-2">Select or upload your media. If you upload an image, youll crop it next.</div> <div class="text-muted small mb-2" id="step-input-hint">Fill in the details for the new slide.</div>
<div class="mb-2">
<label class="form-label">Title (optional)</label>
<input class="form-control" name="title" />
</div>
<div class="mb-2" id="duration-group">
<label class="form-label">Duration (seconds, for images/webpages/YouTube)</label>
<input class="form-control" type="number" name="duration_seconds" value="10" min="1" />
</div>
<div class="mb-3" id="crop-mode-group">
<label class="form-label">Image crop</label>
<div class="btn-group w-100" role="group" aria-label="Crop mode">
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-16-9" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="crop-16-9">16:9 (landscape)</label>
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-9-16" autocomplete="off">
<label class="btn btn-outline-primary" for="crop-9-16">9:16 (portrait)</label>
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-none" autocomplete="off">
<label class="btn btn-outline-primary" for="crop-none">No crop</label>
</div>
<div class="text-muted small mt-1">Cropping is optional. If enabled, we center-crop to the chosen aspect ratio.</div>
</div>
{# Image section #} {# Image section #}
<div id="section-image" class="item-type-section"> <div id="section-image" class="item-type-section">
@@ -433,7 +445,7 @@
</div> </div>
</div> </div>
{# YouTube section #} {# YouTube section (also used as video source) #}
<div id="section-youtube" class="item-type-section d-none"> <div id="section-youtube" class="item-type-section d-none">
<div class="mb-2"> <div class="mb-2">
<label class="form-label">YouTube URL</label> <label class="form-label">YouTube URL</label>
@@ -445,9 +457,9 @@
<div class="text-muted small">Tip: set a duration; YouTube embeds will advance after that time.</div> <div class="text-muted small">Tip: set a duration; YouTube embeds will advance after that time.</div>
</div> </div>
{# Video section #} {# Video upload section #}
<div id="section-video" class="item-type-section d-none"> <div id="section-video" class="item-type-section d-none">
<label class="form-label">Video</label> <label class="form-label">Video upload</label>
<div id="video-dropzone" class="dropzone mb-2"> <div id="video-dropzone" class="dropzone mb-2">
<div><strong>Drag & drop</strong> a video here</div> <div><strong>Drag & drop</strong> a video here</div>
<div class="text-muted small">or click to select a file</div> <div class="text-muted small">or click to select a file</div>
@@ -636,53 +648,87 @@
const sectionYoutube = document.getElementById('section-youtube'); const sectionYoutube = document.getElementById('section-youtube');
const sectionVideo = document.getElementById('section-video'); const sectionVideo = document.getElementById('section-video');
const stepSelect = document.getElementById('step-select'); const stepType = document.getElementById('step-type');
const stepVideoSource = document.getElementById('step-video-source');
const stepInput = document.getElementById('step-input');
const stepCrop = document.getElementById('step-crop'); const stepCrop = document.getElementById('step-crop');
const backBtn = document.getElementById('add-item-back'); const backBtn = document.getElementById('add-item-back');
const errorEl = document.getElementById('add-item-error'); const errorEl = document.getElementById('add-item-error');
const stepInputHint = document.getElementById('step-input-hint');
function setError(msg) { function setError(msg) {
if (!errorEl) return; if (!errorEl) return;
errorEl.textContent = (msg || '').trim(); errorEl.textContent = (msg || '').trim();
} }
let currentStep = 'type';
function updatePrimaryButton() {
// Primary button acts as Next in early steps, and Add in the final steps.
if (!submitBtn) return;
const isNextStep = (currentStep === 'type' || currentStep === 'video-source');
submitBtn.textContent = isNextStep ? 'Next' : 'Add';
}
function showStep(which) { function showStep(which) {
stepSelect?.classList.toggle('active', which === 'select'); currentStep = which;
stepType?.classList.toggle('active', which === 'type');
stepVideoSource?.classList.toggle('active', which === 'video-source');
stepInput?.classList.toggle('active', which === 'input');
stepCrop?.classList.toggle('active', which === 'crop'); stepCrop?.classList.toggle('active', which === 'crop');
const isCrop = which === 'crop'; // Back is enabled for all steps except the first.
backBtn.disabled = !isCrop; backBtn.disabled = (which === 'type');
// For image: allow Add only in crop step (so we always crop if image) // Enable Next for the initial steps.
if (typeHidden.value === 'image') { if (which === 'type' || which === 'video-source') {
submitBtn.disabled = !isCrop; submitBtn.disabled = false;
updatePrimaryButton();
return;
} }
// For input/crop steps: image requires crop step before enabling Add.
if (typeHidden.value === 'image') {
submitBtn.disabled = (which !== 'crop');
} else {
submitBtn.disabled = false;
}
updatePrimaryButton();
}
function videoSource() {
return document.getElementById('video-source-youtube')?.checked ? 'youtube' : 'upload';
} }
function setType(t) { function setType(t) {
// For this UI, "video" is a top-level type, but it can map to item_type=video OR item_type=youtube.
typeHidden.value = t; typeHidden.value = t;
setError(''); setError('');
sectionImage.classList.toggle('d-none', t !== 'image');
sectionWebpage.classList.toggle('d-none', t !== 'webpage');
sectionYoutube.classList.toggle('d-none', t !== 'youtube');
sectionVideo.classList.toggle('d-none', t !== 'video');
// duration applies to image/webpage/youtube. Video plays until ended.
durationGroup.classList.toggle('d-none', t === 'video');
cropModeGroup?.classList.toggle('d-none', t !== 'image');
submitBtn.disabled = false;
submitBtn.title = '';
if (t !== 'image') { // Visible section is decided by type + (video source)
destroyCropper(); const vs = (t === 'video') ? videoSource() : null;
showStep('select'); const effectiveType = (t === 'video' && vs === 'youtube') ? 'youtube' : t;
backBtn.disabled = true; typeHidden.value = effectiveType;
sectionImage.classList.toggle('d-none', effectiveType !== 'image');
sectionWebpage.classList.toggle('d-none', effectiveType !== 'webpage');
sectionYoutube.classList.toggle('d-none', effectiveType !== 'youtube');
sectionVideo.classList.toggle('d-none', effectiveType !== 'video');
// duration applies to image/webpage/youtube. Video upload plays until ended.
durationGroup.classList.toggle('d-none', effectiveType === 'video');
cropModeGroup?.classList.toggle('d-none', effectiveType !== 'image');
if (stepInputHint) {
if (effectiveType === 'image') stepInputHint.textContent = 'Select an image. After selecting, you\'ll crop it.';
else if (effectiveType === 'webpage') stepInputHint.textContent = 'Enter a webpage URL.';
else if (effectiveType === 'youtube') stepInputHint.textContent = 'Paste a YouTube URL.';
else stepInputHint.textContent = 'Upload a video file.';
} }
// For images we enforce crop step before allowing submit. // Reset cropper when leaving image.
if (t === 'image') { if (effectiveType !== 'image') destroyCropper();
submitBtn.disabled = true;
backBtn.disabled = true;
}
} }
function currentCropMode() { function currentCropMode() {
@@ -705,10 +751,39 @@
} }
} }
document.getElementById('type-image')?.addEventListener('change', () => setType('image')); document.getElementById('type-image')?.addEventListener('change', () => {
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage')); setType('image');
document.getElementById('type-youtube')?.addEventListener('change', () => setType('youtube')); // Stay on type step; user clicks Next.
document.getElementById('type-video')?.addEventListener('change', () => setType('video')); showStep('type');
});
document.getElementById('type-webpage')?.addEventListener('change', () => {
setType('webpage');
// Stay on type step; user clicks Next.
showStep('type');
});
document.getElementById('type-video')?.addEventListener('change', () => {
// We show an intermediate step for video so user chooses upload vs YouTube.
// Keep item_type unset until that choice is made.
setError('');
destroyCropper();
// Hide crop/duration while selecting source (they depend on source).
cropModeGroup?.classList.add('d-none');
durationGroup?.classList.add('d-none');
showStep('video-source');
});
document.getElementById('video-source-upload')?.addEventListener('change', () => {
// effective type becomes "video"
setType('video');
// Stay on source step; user clicks Next.
showStep('video-source');
});
document.getElementById('video-source-youtube')?.addEventListener('change', () => {
// effective type becomes "youtube"
setType('video');
// Stay on source step; user clicks Next.
showStep('video-source');
});
// ------------------------- // -------------------------
// Image: drag/drop + crop // Image: drag/drop + crop
@@ -754,7 +829,7 @@
cropStatus.textContent = ''; cropStatus.textContent = '';
if (imageSelectStatus) imageSelectStatus.textContent = `Selected: ${file.name}`; if (imageSelectStatus) imageSelectStatus.textContent = `Selected: ${file.name}`;
// Move to crop step after image selection (requested behavior) // Move to crop step after image selection
showStep('crop'); showStep('crop');
// Wait for image to be ready // Wait for image to be ready
@@ -991,15 +1066,7 @@
} }
// Reset modal state + close // Reset modal state + close
form.reset(); resetModalState();
typeHidden.value = 'image';
document.getElementById('type-image')?.click();
if (cropModeHidden) cropModeHidden.value = '16:9';
document.getElementById('crop-16-9')?.click();
destroyCropper();
showStep('select');
submitBtn.disabled = true;
resetVideoProgress();
modal?.hide(); modal?.hide();
} }
@@ -1098,32 +1165,87 @@
if (t === 'webpage') { if (t === 'webpage') {
// Keep preview behavior // Keep preview behavior
schedulePreview(); schedulePreview();
} else { return;
}
// Hide webpage preview if not active // Hide webpage preview if not active
preview?.classList.add('d-none'); preview?.classList.add('d-none');
if (iframe) iframe.src = 'about:blank'; if (iframe) iframe.src = 'about:blank';
if (openLink) openLink.href = '#'; if (openLink) openLink.href = '#';
} }
}
// Set initial state function resetModalState() {
setType('image'); setError('');
try { form.reset(); } catch (e) {}
destroyCropper();
// Default selections (without triggering change handlers)
const typeImage = document.getElementById('type-image');
const typeWebpage = document.getElementById('type-webpage');
const typeVideo = document.getElementById('type-video');
if (typeImage) typeImage.checked = true;
if (typeWebpage) typeWebpage.checked = false;
if (typeVideo) typeVideo.checked = false;
const vsUpload = document.getElementById('video-source-upload');
const vsYoutube = document.getElementById('video-source-youtube');
if (vsUpload) vsUpload.checked = true;
if (vsYoutube) vsYoutube.checked = false;
if (cropModeHidden) cropModeHidden.value = '16:9'; if (cropModeHidden) cropModeHidden.value = '16:9';
showStep('select'); document.getElementById('crop-16-9')?.click();
// Set UI for default type, but start at type selection step
setType('image');
showStep('type');
syncEnabledInputs(); syncEnabledInputs();
updateCropHint(); updateCropHint();
// Also reset video upload progress UI if present
try {
const bar = document.getElementById('video-upload-progress-bar');
if (bar) {
bar.style.width = '0%';
bar.setAttribute('aria-valuenow', '0');
}
document.getElementById('video-upload-progress-text') && (document.getElementById('video-upload-progress-text').textContent = 'Uploading…');
document.getElementById('video-upload-progress')?.classList.add('d-none');
} catch (e) {}
}
// Initialize modal state once on page load
resetModalState();
// Modal open // Modal open
openBtn?.addEventListener('click', () => { openBtn?.addEventListener('click', () => {
// Always start from the beginning.
resetModalState();
modal?.show(); modal?.show();
}); });
// Back button: only relevant for image crop step // Back button: stepwise navigation
backBtn?.addEventListener('click', () => { backBtn?.addEventListener('click', () => {
if (typeHidden.value === 'image') { if (currentStep === 'crop') {
showStep('select'); // Going back from crop returns to input for image
showStep('input');
submitBtn.disabled = true; submitBtn.disabled = true;
backBtn.disabled = true; return;
}
if (currentStep === 'input') {
// If top-level selected is video, go back to video source selection.
const isTopLevelVideo = document.getElementById('type-video')?.checked;
if (isTopLevelVideo) {
showStep('video-source');
} else {
showStep('type');
}
return;
}
if (currentStep === 'video-source') {
showStep('type');
return;
} }
}); });
@@ -1165,13 +1287,43 @@
document.getElementById('crop-none')?.addEventListener('change', () => setCropMode('none')); document.getElementById('crop-none')?.addEventListener('change', () => setCropMode('none'));
// Whenever type changes, keep enabled inputs in sync // Whenever type changes, keep enabled inputs in sync
['type-image','type-webpage','type-youtube','type-video'].forEach((id) => { ['type-image','type-webpage','type-video','video-source-upload','video-source-youtube'].forEach((id) => {
document.getElementById(id)?.addEventListener('change', syncEnabledInputs); document.getElementById(id)?.addEventListener('change', syncEnabledInputs);
}); });
// Add button // Add button
submitBtn?.addEventListener('click', async () => { submitBtn?.addEventListener('click', async () => {
try { try {
// Multi-step behavior:
// - Type step: Next -> (video ? source step : input step)
// - Video source step: Next -> input step
// - Input/crop: Add -> submit
if (currentStep === 'type') {
const isVideo = document.getElementById('type-video')?.checked;
const isWebpage = document.getElementById('type-webpage')?.checked;
if (isVideo) {
// Hide crop/duration while selecting source.
cropModeGroup?.classList.add('d-none');
durationGroup?.classList.add('d-none');
showStep('video-source');
return;
}
setType(isWebpage ? 'webpage' : 'image');
showStep('input');
syncEnabledInputs();
return;
}
if (currentStep === 'video-source') {
// Apply the chosen source (upload vs YouTube) and continue.
setType('video');
showStep('input');
syncEnabledInputs();
return;
}
await submitViaAjax(); await submitViaAjax();
} catch (err) { } catch (err) {
console.warn(err); console.warn(err);