first commit
This commit is contained in:
62
app/templates/company/dashboard.html
Normal file
62
app/templates/company/dashboard.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1 class="h3">Company dashboard</h1>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<h2 class="h5">Playlists</h2>
|
||||
<form method="post" action="{{ url_for('company.create_playlist') }}" class="card card-body mb-3">
|
||||
<div class="input-group">
|
||||
<input class="form-control" name="name" placeholder="New playlist name" required />
|
||||
<button class="btn btn-success" type="submit">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="list-group">
|
||||
{% for p in playlists %}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<a class="text-decoration-none" href="{{ url_for('company.playlist_detail', playlist_id=p.id) }}">
|
||||
<strong>{{ p.name }}</strong> <span class="text-muted">({{ p.items|length }} items)</span>
|
||||
</a>
|
||||
<form method="post" action="{{ url_for('company.delete_playlist', playlist_id=p.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</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted">No playlists yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<h2 class="h5">Displays</h2>
|
||||
<div class="list-group">
|
||||
{% for d in displays %}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div><strong>{{ d.name }}</strong></div>
|
||||
<div class="text-muted">Player URL: <a href="{{ url_for('display.display_player', token=d.token, _external=true) }}" target="_blank">open</a></div>
|
||||
</div>
|
||||
<div style="min-width: 220px;">
|
||||
<form method="post" action="{{ url_for('company.assign_playlist', display_id=d.id) }}" class="d-flex gap-2">
|
||||
<select class="form-select form-select-sm" name="playlist_id">
|
||||
<option value="">(none)</option>
|
||||
{% for p in playlists %}
|
||||
<option value="{{ p.id }}" {% if d.assigned_playlist_id == p.id %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" type="submit">Assign</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted">No displays. Ask admin to add displays.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
467
app/templates/company/playlist_detail.html
Normal file
467
app/templates/company/playlist_detail.html
Normal file
@@ -0,0 +1,467 @@
|
||||
{% 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 %}
|
||||
Reference in New Issue
Block a user