Files
Fossign/app/templates/company/playlist_detail.html
2026-01-23 13:54:58 +01:00

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 %}