468 lines
19 KiB
HTML
468 lines
19 KiB
HTML
{% extends "base.html" %}
|
|
{% block content %}
|
|
{# Cropper.js (used for image cropping) #}
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/cropperjs/cropper.min.css') }}" />
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<h1 class="h3">Playlist: {{ playlist.name }}</h1>
|
|
<div class="d-flex gap-2">
|
|
<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-secondary btn-sm" href="{{ url_for('company.dashboard') }}">Back</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mt-4">
|
|
<div class="col-md-5">
|
|
<h2 class="h5">Add item</h2>
|
|
<form id="add-item-form" method="post" action="{{ url_for('company.add_playlist_item', playlist_id=playlist.id) }}" enctype="multipart/form-data" class="card card-body">
|
|
<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 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-video" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="type-video">Video</label>
|
|
</div>
|
|
<input type="hidden" name="item_type" id="item_type" value="image" />
|
|
</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)</label>
|
|
<input class="form-control" type="number" name="duration_seconds" value="10" min="1" />
|
|
</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 id="image-crop-container" class="d-none">
|
|
<div class="text-muted small mb-2">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>
|
|
</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>
|
|
|
|
{# Video section #}
|
|
<div id="section-video" class="item-type-section d-none">
|
|
<div class="alert alert-warning mb-2">
|
|
<strong>In production:</strong> video support is currently being worked on.
|
|
</div>
|
|
</div>
|
|
|
|
<button class="btn btn-success" id="add-item-submit" type="submit">Add</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="col-md-7">
|
|
<h2 class="h5">Items</h2>
|
|
<div class="text-muted small mb-2">Tip: drag items to reorder. Changes save automatically.</div>
|
|
<div class="list-group" id="playlist-items" data-reorder-url="{{ url_for('company.reorder_playlist_items', playlist_id=playlist.id) }}">
|
|
{% for i in playlist.items %}
|
|
<div class="list-group-item" draggable="true" data-item-id="{{ i.id }}">
|
|
<div class="d-flex justify-content-between align-items-start gap-3">
|
|
<div style="width: 26px; cursor: grab;" class="text-muted" title="Drag to reorder">≡</div>
|
|
<div class="flex-grow-1">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<strong>#{{ i.position }}</strong>
|
|
<span class="badge bg-secondary">{{ i.item_type }}</span>
|
|
<span>{{ i.title or '' }}</span>
|
|
</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="text-muted small">
|
|
{% if i.item_type in ['image','video'] %}
|
|
File: {{ i.file_path }}
|
|
{% else %}
|
|
URL: {{ i.url }}
|
|
{% endif %}
|
|
· Duration: {{ i.duration_seconds }}s
|
|
</div>
|
|
|
|
<div class="mt-2">
|
|
{% if i.item_type == 'image' and i.file_path %}
|
|
<img
|
|
src="{{ url_for('static', filename=i.file_path) }}"
|
|
alt="{{ i.title or 'image' }}"
|
|
style="max-width: 100%; max-height: 200px; display: block; background: #111;"
|
|
loading="lazy"
|
|
/>
|
|
{% elif i.item_type == 'video' and i.file_path %}
|
|
<video
|
|
src="{{ url_for('static', filename=i.file_path) }}"
|
|
style="max-width: 100%; max-height: 220px; display: block; background: #111;"
|
|
muted
|
|
controls
|
|
preload="metadata"
|
|
></video>
|
|
{% elif i.item_type == 'webpage' and i.url %}
|
|
<div class="d-flex gap-2 align-items-center">
|
|
<a href="{{ i.url }}" target="_blank" rel="noopener noreferrer">Open URL</a>
|
|
<span class="text-muted">(opens in new tab)</span>
|
|
</div>
|
|
<iframe
|
|
src="{{ i.url }}"
|
|
title="{{ i.title or i.url }}"
|
|
style="width: 100%; height: 200px; border: 1px solid #333; background: #111;"
|
|
loading="lazy"
|
|
referrerpolicy="no-referrer"
|
|
></iframe>
|
|
{% else %}
|
|
<div class="text-muted">No preview available.</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="text-muted">No items.</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
{# Load Cropper.js BEFORE our inline script so window.Cropper is available #}
|
|
<script src="{{ url_for('static', filename='vendor/cropperjs/cropper.min.js') }}"></script>
|
|
|
|
<script>
|
|
(function() {
|
|
// -------------------------
|
|
// Add-item UI enhancements
|
|
// -------------------------
|
|
const form = document.getElementById('add-item-form');
|
|
if (!form) return;
|
|
|
|
const typeHidden = document.getElementById('item_type');
|
|
const submitBtn = document.getElementById('add-item-submit');
|
|
const durationGroup = document.getElementById('duration-group');
|
|
|
|
const sectionImage = document.getElementById('section-image');
|
|
const sectionWebpage = document.getElementById('section-webpage');
|
|
const sectionVideo = document.getElementById('section-video');
|
|
|
|
function setType(t) {
|
|
typeHidden.value = t;
|
|
sectionImage.classList.toggle('d-none', t !== 'image');
|
|
sectionWebpage.classList.toggle('d-none', t !== 'webpage');
|
|
sectionVideo.classList.toggle('d-none', t !== 'video');
|
|
durationGroup.classList.toggle('d-none', t === 'video');
|
|
submitBtn.disabled = (t === 'video');
|
|
submitBtn.title = (t === 'video') ? 'Video is in production' : '';
|
|
|
|
if (t !== 'image') {
|
|
destroyCropper();
|
|
}
|
|
}
|
|
|
|
document.getElementById('type-image')?.addEventListener('change', () => setType('image'));
|
|
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage'));
|
|
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 cropContainer = document.getElementById('image-crop-container');
|
|
const cropImg = document.getElementById('image-crop-target');
|
|
const cropResetBtn = document.getElementById('image-crop-reset');
|
|
const cropStatus = document.getElementById('image-crop-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 (cropContainer) cropContainer.classList.add('d-none');
|
|
if (cropStatus) cropStatus.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/')) {
|
|
cropStatus.textContent = 'Please choose an image file.';
|
|
return;
|
|
}
|
|
destroyCropper();
|
|
|
|
currentObjectUrl = URL.createObjectURL(file);
|
|
cropImg.src = currentObjectUrl;
|
|
cropContainer.classList.remove('d-none');
|
|
cropStatus.textContent = '';
|
|
|
|
// 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;
|
|
}
|
|
|
|
cropper = new window.Cropper(cropImg, {
|
|
aspectRatio: 16 / 9,
|
|
viewMode: 1,
|
|
autoCropArea: 1,
|
|
responsive: true,
|
|
background: 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();
|
|
});
|
|
|
|
// On submit: if image selected and we have a cropper, replace the file with the cropped 16:9 output.
|
|
form.addEventListener('submit', (e) => {
|
|
if (typeHidden.value !== 'image') return;
|
|
if (!cropper) return; // no cropper initialized; let the form submit normally
|
|
|
|
e.preventDefault();
|
|
submitBtn.disabled = true;
|
|
cropStatus.textContent = 'Preparing cropped image…';
|
|
|
|
const canvas = cropper.getCroppedCanvas({
|
|
width: 1280,
|
|
height: 720,
|
|
imageSmoothingQuality: 'high',
|
|
});
|
|
canvas.toBlob((blob) => {
|
|
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 = '';
|
|
form.submit();
|
|
}, 'image/png');
|
|
});
|
|
|
|
// -------------------------
|
|
// Webpage: live preview
|
|
// -------------------------
|
|
const urlInput = document.getElementById('webpage-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);
|
|
|
|
// Set initial state
|
|
setType('image');
|
|
})();
|
|
|
|
(function() {
|
|
const list = document.getElementById('playlist-items');
|
|
if (!list) return;
|
|
|
|
let dragged = null;
|
|
|
|
function items() {
|
|
return Array.from(list.querySelectorAll('[data-item-id]'));
|
|
}
|
|
|
|
function computeOrder() {
|
|
return items().map(el => el.getAttribute('data-item-id')).join(',');
|
|
}
|
|
|
|
async function persist() {
|
|
const url = list.getAttribute('data-reorder-url');
|
|
const body = new URLSearchParams();
|
|
body.set('order', computeOrder());
|
|
await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body
|
|
});
|
|
}
|
|
|
|
list.addEventListener('dragstart', (e) => {
|
|
const el = e.target.closest('[data-item-id]');
|
|
if (!el) return;
|
|
dragged = el;
|
|
el.style.opacity = '0.5';
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
});
|
|
|
|
list.addEventListener('dragend', (e) => {
|
|
const el = e.target.closest('[data-item-id]');
|
|
if (el) el.style.opacity = '';
|
|
dragged = null;
|
|
});
|
|
|
|
list.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
const over = e.target.closest('[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();
|
|
try { await persist(); } catch (err) { console.warn('Failed to persist order', err); }
|
|
});
|
|
})();
|
|
</script>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|