Initial import
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user