1618 lines
67 KiB
HTML
1618 lines
67 KiB
HTML
{% 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; }
|
|
|
|
/* 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>
|
|
<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>
|
|
|
|
{# 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">
|
|
<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" />
|
|
|
|
{# 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="btn-group w-100" role="group" aria-label="Slide 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-video" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="type-video">Video</label>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
|
|
<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-input" class="step">
|
|
<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 #}
|
|
<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 (also used as video source) #}
|
|
<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 upload section #}
|
|
<div id="section-video" class="item-type-section d-none">
|
|
<label class="form-label">Video upload</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>
|
|
|
|
{# 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>
|
|
|
|
<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">
|
|
<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>
|
|
</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() {
|
|
// -------------------------
|
|
// 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
|
|
// -------------------------
|
|
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 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 backBtn = document.getElementById('add-item-back');
|
|
const errorEl = document.getElementById('add-item-error');
|
|
const stepInputHint = document.getElementById('step-input-hint');
|
|
|
|
function setError(msg) {
|
|
if (!errorEl) return;
|
|
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) {
|
|
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');
|
|
|
|
// Back is enabled for all steps except the first.
|
|
backBtn.disabled = (which === 'type');
|
|
|
|
// Enable Next for the initial steps.
|
|
if (which === 'type' || which === 'video-source') {
|
|
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) {
|
|
// For this UI, "video" is a top-level type, but it can map to item_type=video OR item_type=youtube.
|
|
typeHidden.value = t;
|
|
setError('');
|
|
|
|
// Visible section is decided by type + (video source)
|
|
const vs = (t === 'video') ? videoSource() : null;
|
|
const effectiveType = (t === 'video' && vs === 'youtube') ? 'youtube' : t;
|
|
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.';
|
|
}
|
|
|
|
// Reset cropper when leaving image.
|
|
if (effectiveType !== 'image') destroyCropper();
|
|
}
|
|
|
|
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');
|
|
// Stay on type step; user clicks Next.
|
|
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
|
|
// -------------------------
|
|
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
|
|
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 = '';
|
|
setError('');
|
|
|
|
// 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);
|
|
|
|
// For upload progress we need XHR (fetch does not provide upload progress reliably).
|
|
const useXhrProgress = (typeHidden.value === 'video');
|
|
|
|
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;
|
|
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;
|
|
}
|
|
|
|
if (!data.ok) {
|
|
submitBtn.disabled = false;
|
|
setError(data.error || 'Failed to add item.');
|
|
resetVideoProgress();
|
|
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
|
|
resetModalState();
|
|
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();
|
|
return;
|
|
}
|
|
|
|
// Hide webpage preview if not active
|
|
preview?.classList.add('d-none');
|
|
if (iframe) iframe.src = 'about:blank';
|
|
if (openLink) openLink.href = '#';
|
|
}
|
|
|
|
function resetModalState() {
|
|
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';
|
|
document.getElementById('crop-16-9')?.click();
|
|
|
|
// Set UI for default type, but start at type selection step
|
|
setType('image');
|
|
showStep('type');
|
|
syncEnabledInputs();
|
|
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
|
|
openBtn?.addEventListener('click', () => {
|
|
// Always start from the beginning.
|
|
resetModalState();
|
|
modal?.show();
|
|
});
|
|
|
|
// Back button: stepwise navigation
|
|
backBtn?.addEventListener('click', () => {
|
|
if (currentStep === 'crop') {
|
|
// Going back from crop returns to input for image
|
|
showStep('input');
|
|
submitBtn.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;
|
|
}
|
|
});
|
|
|
|
// 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-video','video-source-upload','video-source-youtube'].forEach((id) => {
|
|
document.getElementById(id)?.addEventListener('change', syncEnabledInputs);
|
|
});
|
|
|
|
// Add button
|
|
submitBtn?.addEventListener('click', async () => {
|
|
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();
|
|
} 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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 class="small">.</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="thumb">${thumb}</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 -->
|
|
${durationInput}
|
|
</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 %}
|