Files
Fossign/app/templates/company/playlist_detail.html

1117 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block content %}
{# Cropper.js (used for image cropping) #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.css" />
<style>
/* Gallery grid for playlist items */
#playlist-items.playlist-gallery,
#playlist-items.playlist-gallery:not(.list-group) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.playlist-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: 0 1px 2px rgba(0,0,0,.05);
}
.playlist-card.dragging {
opacity: .5;
}
.playlist-card .card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: .5rem;
padding: .5rem .75rem;
border-bottom: 1px solid var(--border);
}
.playlist-card .drag-handle {
width: 26px;
cursor: grab;
user-select: none;
color: var(--muted);
flex: 0 0 auto;
}
.playlist-card .card-body {
padding: .5rem .75rem .75rem;
}
.playlist-card .thumb {
width: 100%;
aspect-ratio: 16 / 9;
background: #111;
border-radius: .35rem;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
margin-top: .5rem;
}
.playlist-card .thumb img,
.playlist-card .thumb video,
.playlist-card .thumb iframe {
width: 100%;
height: 100%;
object-fit: cover;
border: 0;
}
/* Modal step visibility */
.step { display: none; }
.step.active { display: block; }
</style>
<div class="d-flex justify-content-between align-items-center">
<h1 class="h3">Playlist: {{ playlist.name }}</h1>
<div class="d-flex gap-2">
<button
class="btn btn-outline-primary btn-sm"
type="button"
data-bs-toggle="modal"
data-bs-target="#renamePlaylistModal"
>
Rename
</button>
<form method="post" action="{{ url_for('company.delete_playlist', playlist_id=playlist.id) }}" onsubmit="return confirm('Delete playlist? This will remove all items and unassign it from displays.');">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete playlist</button>
</form>
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('company.dashboard') }}">Back</a>
</div>
</div>
{# Rename Playlist Modal #}
<div class="modal fade" id="renamePlaylistModal" tabindex="-1" aria-labelledby="renamePlaylistModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="renamePlaylistModalLabel">Rename playlist</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="{{ url_for('company.update_playlist', playlist_id=playlist.id) }}">
<div class="modal-body">
<label class="form-label" for="playlist-name">Name</label>
<input
id="playlist-name"
class="form-control"
name="name"
value="{{ playlist.name }}"
placeholder="Playlist name"
maxlength="120"
required
autofocus
/>
<div class="text-muted small mt-2">Rename only changes the playlist title; items and assignments stay the same.</div>
</div>
<div class="modal-footer">
<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>
<div class="d-flex justify-content-between align-items-center mt-4">
<div>
<h2 class="h5 mb-0">Items</h2>
<div class="text-muted small">Tip: drag items to reorder. Changes save automatically.</div>
</div>
<button class="btn btn-brand" type="button" id="open-add-item">Add item</button>
</div>
<div class="mt-3">
<div class="small text-muted mb-2" id="reorder-status" aria-live="polite"></div>
<div
class="playlist-gallery"
id="playlist-items"
data-reorder-url="{{ url_for('company.reorder_playlist_items', playlist_id=playlist.id) }}"
data-delete-base="{{ url_for('company.delete_item', item_id=0) }}"
data-duration-base="{{ url_for('company.update_item_duration', item_id=0) }}"
>
{% for i in playlist.items %}
<div class="playlist-card" draggable="true" data-item-id="{{ i.id }}">
<div class="card-top">
<div class="d-flex gap-2">
<div class="drag-handle" title="Drag to reorder"></div>
<div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<strong>#{{ i.position }}</strong>
<span class="badge bg-secondary">{{ i.item_type }}</span>
</div>
{% if i.title %}
<div class="small">{{ i.title }}</div>
{% else %}
<div class="small">.</div>
{% endif %}
</div>
</div>
<form method="post" action="{{ url_for('company.delete_item', item_id=i.id) }}" onsubmit="return confirm('Delete item?');">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
</div>
<div class="card-body">
<div class="thumb">
{% if i.item_type == 'image' and i.file_path %}
<img src="{{ url_for('static', filename=i.file_path) }}" alt="{{ i.title or 'image' }}" loading="lazy" />
{% elif i.item_type == 'video' and i.file_path %}
<video src="{{ url_for('static', filename=i.file_path) }}" muted controls preload="metadata"></video>
{% elif i.item_type == 'webpage' and i.url %}
<iframe src="{{ i.url }}" title="{{ i.title or 'webpage' }}" loading="lazy" referrerpolicy="no-referrer"></iframe>
{% elif i.item_type == 'youtube' and i.url %}
<iframe src="{{ i.url }}" title="{{ i.title or 'youtube' }}" loading="lazy" referrerpolicy="no-referrer"></iframe>
{% else %}
<div class="text-muted">No preview</div>
{% endif %}
</div>
<div class="text-muted small d-flex align-items-center gap-2 flex-wrap">
{# Intentionally do NOT show file names or URLs for privacy/clean UI #}
{% if i.item_type != 'video' %}
<label class="text-nowrap" style="margin: 0;">
<span class="me-1">Duration</span>
<input
class="form-control form-control-sm d-inline-block js-duration-input"
style="width: 92px;"
type="number"
min="1"
value="{{ i.duration_seconds }}"
data-item-id="{{ i.id }}"
aria-label="Duration seconds"
/>
<span class="ms-1">s</span>
</label>
<span class="small text-muted js-duration-status" data-item-id="{{ i.id }}"></span>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="text-muted">No items.</div>
{% endfor %}
</div>
{# Add Item Modal (multi-step) #}
<div class="modal fade" id="addItemModal" tabindex="-1" aria-labelledby="addItemModalLabel" 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="addItemModalLabel">Add item</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="add-item-form" method="post" action="{{ url_for('company.add_playlist_item', playlist_id=playlist.id) }}" enctype="multipart/form-data">
<input type="hidden" name="response" value="json" />
<input type="hidden" name="item_type" id="item_type" value="image" />
<input type="hidden" name="crop_mode" id="crop_mode" value="16:9" />
<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">
<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>
<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">
<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">
<label class="btn btn-outline-primary" for="type-video">Video</label>
</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 class="text-muted small mt-1">Cropping is optional. If enabled, we center-crop to the chosen aspect ratio.</div>
</div>
<style>
.dropzone {
border: 2px dashed #6c757d;
border-radius: .5rem;
padding: 1rem;
text-align: center;
background: rgba(0,0,0,.02);
cursor: pointer;
user-select: none;
}
.dropzone.dragover {
border-color: #0d6efd;
background: rgba(13,110,253,.08);
}
.webpage-preview-frame {
width: 1200px;
height: 675px; /* 16:9 */
border: 0;
transform: scale(0.25);
transform-origin: 0 0;
background: #111;
}
.webpage-preview-wrap {
width: 100%;
height: 170px; /* 675 * 0.25 = ~168.75 */
overflow: hidden;
border: 1px solid #333;
border-radius: .25rem;
background: #111;
}
</style>
<div id="step-select" class="step active">
<div class="text-muted small mb-2">Select or upload your media. If you upload an image, youll crop it next.</div>
{# Image section #}
<div id="section-image" class="item-type-section">
<label class="form-label">Image</label>
<div id="image-dropzone" class="dropzone mb-2">
<div><strong>Drag & drop</strong> an image here</div>
<div class="text-muted small">or click to select a file</div>
</div>
<input id="image-file-input" class="form-control d-none" type="file" name="file" accept="image/*" />
<div class="text-muted small" id="image-select-status"></div>
</div>
{# Webpage section #}
<div id="section-webpage" class="item-type-section d-none">
<div class="mb-2">
<label class="form-label">URL</label>
<input id="webpage-url" class="form-control" name="url" placeholder="https://..." inputmode="url" />
<div class="text-muted small mt-1">Preview might not work for all sites (some block embedding).</div>
</div>
<div id="webpage-preview" class="d-none">
<div class="d-flex justify-content-between align-items-center mb-1">
<div class="text-muted small">Preview</div>
<a id="webpage-open" href="#" target="_blank" rel="noopener noreferrer" class="small">Open</a>
</div>
<div class="webpage-preview-wrap">
<iframe
id="webpage-iframe"
class="webpage-preview-frame"
src="about:blank"
title="Webpage preview"
loading="lazy"
referrerpolicy="no-referrer"
></iframe>
</div>
</div>
</div>
{# YouTube section #}
<div id="section-youtube" class="item-type-section d-none">
<div class="mb-2">
<label class="form-label">YouTube URL</label>
<input id="youtube-url" class="form-control" name="url" placeholder="https://www.youtube.com/watch?v=..." inputmode="url" />
<div class="text-muted small mt-1">
Paste a YouTube link (watch / shorts / youtu.be). We embed using youtube-nocookie.com.
</div>
</div>
<div class="text-muted small">Tip: set a duration; YouTube embeds will advance after that time.</div>
</div>
{# Video section #}
<div id="section-video" class="item-type-section d-none">
<label class="form-label">Video</label>
<div id="video-dropzone" class="dropzone mb-2">
<div><strong>Drag & drop</strong> a video here</div>
<div class="text-muted small">or click to select a file</div>
</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>
</div>
</div>
<div id="step-crop" class="step">
<div class="text-muted small mb-2" id="crop-step-hint">Crop to <strong>16:9</strong> (recommended for display screens).</div>
<div style="width: 100%; background: #111; border-radius: .25rem; overflow: hidden;">
<img id="image-crop-target" alt="Crop" style="max-width: 100%; display: block;" />
</div>
<div class="d-flex gap-2 mt-2">
<button class="btn btn-outline-secondary btn-sm" type="button" id="image-crop-reset">Reset crop</button>
<div class="text-muted small align-self-center" id="image-crop-status"></div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<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>
</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>
<script type="module">
(function() {
// -------------------------
// Add-item modal + steps
// -------------------------
const openBtn = document.getElementById('open-add-item');
const modalEl = document.getElementById('addItemModal');
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
const form = document.getElementById('add-item-form');
if (!form) return;
const typeHidden = document.getElementById('item_type');
const cropModeHidden = document.getElementById('crop_mode');
const submitBtn = document.getElementById('add-item-submit');
const durationGroup = document.getElementById('duration-group');
const cropModeGroup = document.getElementById('crop-mode-group');
const cropHint = document.getElementById('crop-step-hint');
const sectionImage = document.getElementById('section-image');
const sectionWebpage = document.getElementById('section-webpage');
const sectionYoutube = document.getElementById('section-youtube');
const sectionVideo = document.getElementById('section-video');
const stepSelect = document.getElementById('step-select');
const stepCrop = document.getElementById('step-crop');
const backBtn = document.getElementById('add-item-back');
function showStep(which) {
stepSelect?.classList.toggle('active', which === 'select');
stepCrop?.classList.toggle('active', which === 'crop');
const isCrop = which === 'crop';
backBtn.disabled = !isCrop;
// For image: allow Add only in crop step (so we always crop if image)
if (typeHidden.value === 'image') {
submitBtn.disabled = !isCrop;
}
}
function setType(t) {
typeHidden.value = t;
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') {
destroyCropper();
showStep('select');
backBtn.disabled = true;
}
// For images we enforce crop step before allowing submit.
if (t === 'image') {
submitBtn.disabled = true;
backBtn.disabled = true;
}
}
function currentCropMode() {
// crop_mode_choice is only UI; we submit hidden crop_mode for server fallback
return (cropModeHidden?.value || '16:9').toLowerCase();
}
function updateCropHint() {
const cm = currentCropMode();
if (!cropHint) return;
if (cm === 'none') {
cropHint.innerHTML = 'No crop selected. The image will be resized/compressed, keeping its original aspect ratio.';
if (cropResetBtn) cropResetBtn.disabled = true;
} else if (cm === '9:16') {
cropHint.innerHTML = 'Crop to <strong>9:16</strong> (portrait).';
if (cropResetBtn) cropResetBtn.disabled = false;
} else {
cropHint.innerHTML = 'Crop to <strong>16:9</strong> (landscape, recommended for display screens).';
if (cropResetBtn) cropResetBtn.disabled = false;
}
}
document.getElementById('type-image')?.addEventListener('change', () => setType('image'));
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage'));
document.getElementById('type-youtube')?.addEventListener('change', () => setType('youtube'));
document.getElementById('type-video')?.addEventListener('change', () => setType('video'));
// -------------------------
// Image: drag/drop + crop
// -------------------------
const dropzone = document.getElementById('image-dropzone');
const fileInput = document.getElementById('image-file-input');
const cropImg = document.getElementById('image-crop-target');
const cropResetBtn = document.getElementById('image-crop-reset');
const cropStatus = document.getElementById('image-crop-status');
const imageSelectStatus = document.getElementById('image-select-status');
let cropper = null;
let currentObjectUrl = null;
function destroyCropper() {
try {
if (cropper) cropper.destroy();
} catch (e) {}
cropper = null;
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = null;
}
if (cropStatus) cropStatus.textContent = '';
if (imageSelectStatus) imageSelectStatus.textContent = '';
}
function setFileOnInput(input, file) {
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
}
async function loadImageFile(file) {
if (!file || !file.type || !file.type.startsWith('image/')) {
if (imageSelectStatus) imageSelectStatus.textContent = 'Please choose an image file.';
return;
}
destroyCropper();
currentObjectUrl = URL.createObjectURL(file);
cropImg.src = currentObjectUrl;
cropStatus.textContent = '';
if (imageSelectStatus) imageSelectStatus.textContent = `Selected: ${file.name}`;
// Move to crop step after image selection (requested behavior)
showStep('crop');
// Wait for image to be ready
await new Promise((resolve, reject) => {
cropImg.onload = () => resolve();
cropImg.onerror = () => reject(new Error('Failed to load image'));
});
// Cropper.js is loaded from CDN, so window.Cropper should exist
if (!window.Cropper) {
cropStatus.textContent = 'Cropper failed to load. Check your network connection.';
return;
}
// Create cropper only when cropping is enabled
const cm = currentCropMode();
if (cm !== 'none') {
cropper = new window.Cropper(cropImg, {
aspectRatio: (cm === '9:16') ? (9 / 16) : (16 / 9),
viewMode: 1,
autoCropArea: 1,
responsive: true,
background: false,
});
}
updateCropHint();
// Enable Add button now that cropper exists
if (typeHidden.value === 'image') submitBtn.disabled = false;
}
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 f = e.dataTransfer?.files?.[0];
if (!f) return;
// Put the original file in the input (will be replaced by cropped version on submit)
setFileOnInput(fileInput, f);
await loadImageFile(f);
});
fileInput?.addEventListener('change', async () => {
const f = fileInput.files?.[0];
if (!f) return;
await loadImageFile(f);
});
cropResetBtn?.addEventListener('click', () => {
cropper?.reset();
});
async function submitViaAjax() {
submitBtn.disabled = true;
cropStatus.textContent = '';
// If image, replace file with cropped version before sending.
if (typeHidden.value === 'image') {
const cm = currentCropMode();
// If no crop is selected, just upload the original file.
if (cm !== 'none') {
if (!cropper) {
cropStatus.textContent = 'Please select an image first.';
submitBtn.disabled = false;
return;
}
cropStatus.textContent = 'Preparing cropped image…';
const isPortrait = cm === '9:16';
const canvas = cropper.getCroppedCanvas({
width: isPortrait ? 720 : 1280,
height: isPortrait ? 1280 : 720,
imageSmoothingQuality: 'high',
});
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) {
cropStatus.textContent = 'Failed to crop image.';
submitBtn.disabled = false;
return;
}
const croppedFile = new File([blob], 'cropped.png', { type: 'image/png' });
setFileOnInput(fileInput, croppedFile);
cropStatus.textContent = '';
}
}
const fd = new FormData(form);
const res = await fetch(form.action, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
},
body: fd
});
if (!res.ok) {
let errText = 'Failed to add item.';
try {
const j = await res.json();
if (j && j.error) errText = j.error;
} catch (e) {}
submitBtn.disabled = false;
cropStatus.textContent = errText;
return;
}
const data = await res.json();
if (!data.ok) {
submitBtn.disabled = false;
cropStatus.textContent = data.error || 'Failed to add item.';
return;
}
// Add the new item card to the gallery (append at end)
const item = data.item;
const list = document.getElementById('playlist-items');
if (list && item) {
const el = document.createElement('div');
el.className = 'playlist-card';
el.setAttribute('draggable', 'true');
el.setAttribute('data-item-id', item.id);
el.innerHTML = renderCardInnerHtml(item);
list.appendChild(el);
}
// Reset modal state + close
form.reset();
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;
modal?.hide();
}
function setEnabled(el, enabled) {
if (!el) return;
el.disabled = !enabled;
}
// -------------------------
// Webpage: live preview
// -------------------------
const urlInput = document.getElementById('webpage-url');
const youtubeUrlInput = document.getElementById('youtube-url');
const preview = document.getElementById('webpage-preview');
const iframe = document.getElementById('webpage-iframe');
const openLink = document.getElementById('webpage-open');
function normalizeUrl(raw) {
const val = (raw || '').trim();
if (!val) return '';
if (/^https?:\/\//i.test(val)) return val;
// Be forgiving: if user enters "example.com", treat it as https://example.com
return 'https://' + val;
}
let previewTimer = null;
function schedulePreview() {
if (previewTimer) window.clearTimeout(previewTimer);
previewTimer = window.setTimeout(() => {
const url = normalizeUrl(urlInput.value);
if (!url) {
preview.classList.add('d-none');
iframe.src = 'about:blank';
openLink.href = '#';
return;
}
preview.classList.remove('d-none');
iframe.src = url;
openLink.href = url;
}, 450);
}
urlInput?.addEventListener('input', schedulePreview);
// -------------------------
// Video: drag/drop select
// -------------------------
const videoDropzone = document.getElementById('video-dropzone');
const videoInput = document.getElementById('video-file-input');
const videoStatus = document.getElementById('video-select-status');
function setVideoOnInput(file) {
const dt = new DataTransfer();
dt.items.add(file);
videoInput.files = dt.files;
}
function loadVideoFile(file) {
if (!file || !file.type || !file.type.startsWith('video/')) {
if (videoStatus) videoStatus.textContent = 'Please choose a video file.';
return;
}
if (videoStatus) videoStatus.textContent = `Selected: ${file.name}`;
}
videoDropzone?.addEventListener('click', () => videoInput?.click());
videoDropzone?.addEventListener('dragover', (e) => { e.preventDefault(); videoDropzone.classList.add('dragover'); });
videoDropzone?.addEventListener('dragleave', () => videoDropzone.classList.remove('dragover'));
videoDropzone?.addEventListener('drop', (e) => {
e.preventDefault();
videoDropzone.classList.remove('dragover');
const f = e.dataTransfer?.files?.[0];
if (!f) return;
setVideoOnInput(f);
loadVideoFile(f);
});
videoInput?.addEventListener('change', () => {
const f = videoInput.files?.[0];
if (!f) return;
loadVideoFile(f);
});
// Ensure only inputs from the active section are enabled (so form fields don't clash)
function syncEnabledInputs() {
const t = typeHidden.value;
// Disable all optional inputs by default
setEnabled(fileInput, t === 'image');
setEnabled(videoInput, t === 'video');
setEnabled(urlInput, t === 'webpage');
setEnabled(youtubeUrlInput, t === 'youtube');
// Crop mode only applies to images
setEnabled(cropModeHidden, t === 'image');
if (t === 'webpage') {
// Keep preview behavior
schedulePreview();
} else {
// Hide webpage preview if not active
preview?.classList.add('d-none');
if (iframe) iframe.src = 'about:blank';
if (openLink) openLink.href = '#';
}
}
// Set initial state
setType('image');
if (cropModeHidden) cropModeHidden.value = '16:9';
showStep('select');
syncEnabledInputs();
updateCropHint();
// Modal open
openBtn?.addEventListener('click', () => {
modal?.show();
});
// Back button: only relevant for image crop step
backBtn?.addEventListener('click', () => {
if (typeHidden.value === 'image') {
showStep('select');
submitBtn.disabled = true;
backBtn.disabled = true;
}
});
// Crop mode selection
function setCropMode(mode) {
if (!cropModeHidden) return;
cropModeHidden.value = mode;
// If cropper exists, update aspect ratio or destroy it
const cm = currentCropMode();
if (typeHidden.value !== 'image') return;
if (!cropImg?.src) {
updateCropHint();
return;
}
// Ensure cropper state matches selection
if (cm === 'none') {
destroyCropper();
// Re-load the selected file into the preview without cropper.
const f = fileInput?.files?.[0];
if (f) loadImageFile(f);
return;
}
if (cropper) {
cropper.setAspectRatio((cm === '9:16') ? (9 / 16) : (16 / 9));
updateCropHint();
return;
}
// No cropper yet (e.g. user changed crop mode after selecting file but before cropper init)
const f = fileInput?.files?.[0];
if (f) loadImageFile(f);
}
document.getElementById('crop-16-9')?.addEventListener('change', () => setCropMode('16:9'));
document.getElementById('crop-9-16')?.addEventListener('change', () => setCropMode('9:16'));
document.getElementById('crop-none')?.addEventListener('change', () => setCropMode('none'));
// Whenever type changes, keep enabled inputs in sync
['type-image','type-webpage','type-youtube','type-video'].forEach((id) => {
document.getElementById(id)?.addEventListener('change', syncEnabledInputs);
});
// Add button
submitBtn?.addEventListener('click', async () => {
try {
await submitViaAjax();
} catch (err) {
console.warn(err);
submitBtn.disabled = false;
cropStatus.textContent = 'Failed to add item.';
}
});
// Render helper for newly appended card
function renderCardInnerHtml(i) {
const safeTitle = (i.title || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const type = i.item_type;
const badge = `<span class="badge bg-secondary">${type}</span>`;
const durationInput = (type === 'video')
? ''
: `
<label class="text-nowrap" style="margin: 0;">
<span class="me-1">Duration</span>
<input
class="form-control form-control-sm d-inline-block js-duration-input"
style="width: 92px;"
type="number"
min="1"
value="${i.duration_seconds || 10}"
data-item-id="${i.id}"
aria-label="Duration seconds"
/>
<span class="ms-1">s</span>
</label>
<span class="small text-muted js-duration-status" data-item-id="${i.id}"></span>
`;
let thumb = `<div class="text-muted">No preview</div>`;
if (type === 'image' && i.file_path) {
thumb = `<img src="/static/${i.file_path}" alt="${safeTitle || 'image'}" loading="lazy" />`;
} else if (type === 'video' && i.file_path) {
thumb = `<video src="/static/${i.file_path}" muted controls preload="metadata"></video>`;
} else if (type === 'webpage' && i.url) {
thumb = `<iframe src="${i.url}" title="${safeTitle || 'webpage'}" loading="lazy" referrerpolicy="no-referrer"></iframe>`;
} else if (type === 'youtube' && i.url) {
thumb = `<iframe src="${i.url}" title="${safeTitle || 'youtube'}" loading="lazy" referrerpolicy="no-referrer"></iframe>`;
}
const list = document.getElementById('playlist-items');
const base = list?.getAttribute('data-delete-base') || '';
// base will be something like /company/items/0/delete
const deleteAction = base.replace(/0\/?delete$/, `${i.id}/delete`).replace(/0\/delete$/, `${i.id}/delete`);
return `
<div class="card-top">
<div class="d-flex gap-2">
<div class="drag-handle" title="Drag to reorder">≡</div>
<div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<strong>#${i.position}</strong>
${badge}
</div>
${safeTitle ? `<div class="small">${safeTitle}</div>` : ''}
</div>
</div>
<form method="post" action="${deleteAction}" onsubmit="return confirm('Delete item?');">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
</div>
<div class="card-body">
<div class="text-muted small d-flex align-items-center gap-2 flex-wrap">
<!-- Intentionally do NOT show file names or URLs for privacy/clean UI -->
${durationInput}
</div>
<div class="thumb">${thumb}</div>
</div>
`;
}
})();
(function() {
const list = document.getElementById('playlist-items');
if (!list) return;
const statusEl = document.getElementById('reorder-status');
let dragged = null;
let lastSavedOrder = null;
let persistTimer = null;
function setStatus(msg, kind) {
if (!statusEl) return;
statusEl.textContent = msg || '';
statusEl.classList.toggle('text-success', kind === 'ok');
statusEl.classList.toggle('text-danger', kind === 'err');
statusEl.classList.toggle('text-muted', !kind);
}
function items() {
// Only the draggable cards should participate in ordering.
// (duration inputs/status spans also have data-item-id)
return Array.from(list.querySelectorAll('.playlist-card[data-item-id]'));
}
function computeOrder() {
return items().map(el => el.getAttribute('data-item-id')).join(',');
}
function refreshVisiblePositions() {
items().forEach((el, idx) => {
const posEl = el.querySelector('.card-top strong');
if (posEl) posEl.textContent = `#${idx + 1}`;
});
}
async function persist() {
const url = list.getAttribute('data-reorder-url');
const body = new URLSearchParams();
body.set('order', computeOrder());
setStatus('Saving order…');
const res = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
},
body
});
if (!res.ok) {
let details = '';
try {
const txt = await res.text();
details = (txt || '').slice(0, 140).replace(/\s+/g, ' ').trim();
} catch (e) {}
throw new Error(`Failed to persist: ${res.status}${details ? ` (${details})` : ''}`);
}
lastSavedOrder = computeOrder();
setStatus('Order saved', 'ok');
window.setTimeout(() => {
if (statusEl && statusEl.textContent === 'Order saved') setStatus('');
}, 900);
}
function schedulePersistSoon() {
// Debounce to avoid spamming on rapid reorder.
if (persistTimer) window.clearTimeout(persistTimer);
persistTimer = window.setTimeout(async () => {
const current = computeOrder();
if (current && current !== lastSavedOrder) {
try {
await persist();
} catch (err) {
console.warn('Failed to persist order', err);
setStatus('Failed to save order', 'err');
}
}
}, 200);
}
// Initialize baseline order
lastSavedOrder = computeOrder();
list.addEventListener('dragstart', (e) => {
const el = e.target.closest('.playlist-card[data-item-id]');
if (!el) return;
dragged = el;
el.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
// Some browsers (Safari) require data to be set.
try { e.dataTransfer.setData('text/plain', el.getAttribute('data-item-id') || ''); } catch (err) {}
});
list.addEventListener('dragend', (e) => {
const el = e.target.closest('.playlist-card[data-item-id]');
if (el) el.classList.remove('dragging');
dragged = null;
// Persist on dragend as well; drop may not fire if released outside container.
refreshVisiblePositions();
schedulePersistSoon();
});
list.addEventListener('dragover', (e) => {
e.preventDefault();
const over = e.target.closest('.playlist-card[data-item-id]');
if (!dragged || !over || over === dragged) return;
const rect = over.getBoundingClientRect();
const after = (e.clientY - rect.top) > (rect.height / 2);
if (after) {
if (over.nextSibling !== dragged) {
list.insertBefore(dragged, over.nextSibling);
}
} else {
if (over.previousSibling !== dragged) {
list.insertBefore(dragged, over);
}
}
});
list.addEventListener('drop', async (e) => {
e.preventDefault();
refreshVisiblePositions();
// Debounced persist (drop is not reliable cross-browser)
schedulePersistSoon();
});
})();
(function() {
// Inline duration editing
const list = document.getElementById('playlist-items');
if (!list) return;
const durationBase = list.getAttribute('data-duration-base') || '';
function durationAction(itemId) {
// base will be something like /company/items/0/duration
return durationBase.replace(/0\/?duration$/, `${itemId}/duration`).replace(/0\/duration$/, `${itemId}/duration`);
}
function statusEl(itemId) {
return list.querySelector(`.js-duration-status[data-item-id="${itemId}"]`);
}
let saveTimer = null;
let lastRequestId = 0;
async function saveDuration(itemId, value) {
const st = statusEl(itemId);
if (st) st.textContent = 'Saving…';
const body = new URLSearchParams();
body.set('duration_seconds', String(value));
const reqId = ++lastRequestId;
const res = await fetch(durationAction(itemId), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
},
body
});
if (reqId !== lastRequestId) {
// newer request is in flight; ignore this response
return;
}
if (!res.ok) {
if (st) st.textContent = 'Failed to save';
return;
}
let data = null;
try { data = await res.json(); } catch (e) {}
if (!data || !data.ok) {
if (st) st.textContent = (data && data.error) ? data.error : 'Failed to save';
return;
}
if (st) {
st.textContent = 'Saved';
window.setTimeout(() => {
if (st.textContent === 'Saved') st.textContent = '';
}, 900);
}
}
list.addEventListener('input', (e) => {
const input = e.target.closest('.js-duration-input');
if (!input) return;
const itemId = input.getAttribute('data-item-id');
if (!itemId) return;
const v = Math.max(1, parseInt(input.value || '1', 10));
if (String(v) !== String(input.value)) input.value = String(v);
if (saveTimer) window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
saveDuration(itemId, v).catch((err) => {
console.warn('Failed to save duration', err);
const st = statusEl(itemId);
if (st) st.textContent = 'Failed to save';
});
}, 450);
});
})();
</script>
</div>
{% endblock %}