first commit

This commit is contained in:
2026-01-23 13:54:58 +01:00
commit 32312fe4f2
29 changed files with 2172 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-start">
<h1 class="h3">Company: {{ company.name }}</h1>
<div>
<a class="btn btn-outline-secondary" href="{{ url_for('admin.dashboard') }}">Back</a>
</div>
</div>
<div class="card border-danger mt-3">
<div class="card-body">
<h2 class="h6 text-danger mb-2">Danger zone</h2>
<p class="mb-2 text-muted">
Deleting a company will permanently remove <strong>all</strong> its users, displays, playlists and uploaded media.
</p>
<form
method="post"
action="{{ url_for('admin.delete_company', company_id=company.id) }}"
onsubmit="return confirm('Delete company \'{{ company.name }}\'? This will delete all its users, displays, playlists and media. This cannot be undone.');"
>
<button class="btn btn-danger" type="submit">Delete company</button>
</form>
</div>
</div>
<div class="row mt-4">
<div class="col-md-6">
<h2 class="h5">Users</h2>
<form method="post" action="{{ url_for('admin.create_company_user', company_id=company.id) }}" class="card card-body mb-3">
<div class="mb-2">
<label class="form-label">Username</label>
<input class="form-control" name="username" required />
</div>
<div class="mb-2">
<label class="form-label">Email</label>
<input class="form-control" type="email" name="email" required />
</div>
<div class="mb-2">
<label class="form-label">Password</label>
<input class="form-control" type="password" name="password" required />
</div>
<button class="btn btn-success" type="submit">Create user</button>
</form>
<div class="list-group">
{% for u in company.users %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ u.username }}</strong>
<div class="text-muted">{{ u.email or "(no email set)" }}</div>
</div>
<div class="d-flex align-items-center gap-2">
<form method="post" action="{{ url_for('admin.update_user_email', user_id=u.id) }}" class="d-flex gap-2">
<input class="form-control form-control-sm" style="width: 240px" type="email" name="email" placeholder="email" value="{{ u.email or '' }}" />
<button class="btn btn-outline-primary btn-sm" type="submit">Save</button>
</form>
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}">
<button class="btn btn-warning btn-sm" type="submit">Impersonate</button>
</form>
</div>
</div>
{% else %}
<div class="text-muted">No users.</div>
{% endfor %}
</div>
</div>
<div class="col-md-6">
<h2 class="h5">Displays</h2>
<form method="post" action="{{ url_for('admin.create_display', company_id=company.id) }}" class="card card-body mb-3">
<div class="input-group">
<input class="form-control" name="name" placeholder="Display name" />
<button class="btn btn-success" type="submit">Add display</button>
</div>
</form>
<div class="list-group">
{% for d in company.displays %}
<div class="list-group-item">
<div class="d-flex justify-content-between">
<div>
<strong>{{ d.name }}</strong>
<div class="text-muted monospace">Token: {{ d.token }}</div>
<div class="text-muted">Player URL: <a href="{{ url_for('display.display_player', token=d.token) }}" target="_blank">{{ url_for('display.display_player', token=d.token, _external=true) }}</a></div>
</div>
<div class="text-muted">Assigned: {{ d.assigned_playlist.name if d.assigned_playlist else "(none)" }}</div>
</div>
</div>
{% else %}
<div class="text-muted">No displays.</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block content %}
<div class="d-flex justify-content-between align-items-center">
<h1 class="h3">Admin dashboard</h1>
</div>
<div class="row mt-4">
<div class="col-md-6">
<h2 class="h5">Companies</h2>
<form method="post" action="{{ url_for('admin.create_company') }}" class="card card-body mb-3">
<div class="input-group">
<input class="form-control" name="name" placeholder="New company name" required />
<button class="btn btn-success" type="submit">Add</button>
</div>
</form>
<div class="list-group">
{% for c in companies %}
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.company_detail', company_id=c.id) }}">
{{ c.name }}
<span class="text-muted">(users: {{ c.users|length }}, displays: {{ c.displays|length }}, playlists: {{ c.playlists|length }})</span>
</a>
{% else %}
<div class="text-muted">No companies yet.</div>
{% endfor %}
</div>
</div>
<div class="col-md-6">
<h2 class="h5">Users</h2>
<div class="list-group">
{% for u in users %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div><strong>{{ u.username }}</strong> {% if u.is_admin %}<span class="badge bg-dark">admin</span>{% endif %}</div>
<div class="text-muted">
{% if u.company %}Company: {{ u.company.name }}{% else %}No company{% endif %}
</div>
</div>
<div>
{% if not u.is_admin %}
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}">
<button class="btn btn-warning btn-sm" type="submit">Impersonate</button>
</form>
{% endif %}
</div>
</div>
{% else %}
<div class="text-muted">No users yet.</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center mt-4">
<div class="col-12 col-md-6">
<div class="card">
<div class="card-header">
<h1 class="h5 mb-0">Change password</h1>
</div>
<div class="card-body">
<form method="post" action="{{ url_for('auth.change_password_post') }}">
<div class="mb-3">
<label for="current_password" class="form-label">Current password</label>
<input id="current_password" name="current_password" type="password" class="form-control" autocomplete="current-password" required />
</div>
<div class="mb-3">
<label for="new_password" class="form-label">New password</label>
<input id="new_password" name="new_password" type="password" class="form-control" autocomplete="new-password" required minlength="8" />
<div class="form-text">Minimum 8 characters.</div>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">Confirm new password</label>
<input id="confirm_password" name="confirm_password" type="password" class="form-control" autocomplete="new-password" required minlength="8" />
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Update password</button>
<a class="btn btn-outline-secondary" href="{{ url_for('index') }}">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<h1 class="h3 mb-3">Forgot password</h1>
<form method="post" class="card card-body">
<p class="text-muted">Enter your email address and well send you a password reset link.</p>
<div class="mb-3">
<label class="form-label">Email</label>
<input class="form-control" name="email" type="email" autocomplete="email" required />
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" type="submit">Send reset link</button>
<a class="btn btn-outline-secondary" href="{{ url_for('auth.login') }}">Back to login</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<h1 class="h3 mb-3">Login</h1>
<form method="post" class="card card-body">
<div class="mb-3">
<label class="form-label">Username</label>
<input class="form-control" name="username" autocomplete="username" required />
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input class="form-control" type="password" name="password" autocomplete="current-password" required />
</div>
<button class="btn btn-primary" type="submit">Login</button>
<div class="mt-3">
<a href="{{ url_for('auth.forgot_password') }}">Forgot password?</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<h1 class="h3 mb-3">Reset password</h1>
{% if token_error %}
<div class="alert alert-danger">{{ token_error }}</div>
<a class="btn btn-outline-secondary" href="{{ url_for('auth.forgot_password') }}">Request a new reset link</a>
{% else %}
<form method="post" class="card card-body">
<div class="mb-3">
<label class="form-label">New password</label>
<input class="form-control" type="password" name="new_password" autocomplete="new-password" minlength="8" required />
<div class="form-text">Minimum 8 characters.</div>
</div>
<div class="mb-3">
<label class="form-label">Confirm new password</label>
<input class="form-control" type="password" name="confirm_password" autocomplete="new-password" minlength="8" required />
</div>
<button class="btn btn-primary" type="submit">Set new password</button>
</form>
{% endif %}
</div>
</div>
{% endblock %}

54
app/templates/base.html Normal file
View File

@@ -0,0 +1,54 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title or "Signage" }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
<style>
body { padding-top: 4.5rem; }
.monospace { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="/">Signage</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto">
{% if current_user.is_authenticated and current_user.is_admin %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.dashboard') }}">Admin</a></li>
{% elif current_user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('company.dashboard') }}">Company</a></li>
{% endif %}
</ul>
<ul class="navbar-nav ms-auto">
{% if current_user.is_authenticated %}
<li class="nav-item"><span class="navbar-text me-3">Logged in as <strong>{{ current_user.username }}</strong></span></li>
<li class="nav-item"><a class="btn btn-outline-light btn-sm me-2" href="{{ url_for('auth.change_password') }}">Change password</a></li>
{% if session.get('impersonator_admin_id') %}
<li class="nav-item"><a class="btn btn-warning btn-sm me-2" href="{{ url_for('auth.stop_impersonation') }}">Stop impersonation</a></li>
{% endif %}
<li class="nav-item"><a class="btn btn-outline-light btn-sm" href="{{ url_for('auth.logout') }}">Logout</a></li>
{% else %}
<li class="nav-item"><a class="btn btn-outline-light btn-sm" href="{{ url_for('auth.login') }}">Login</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mt-2">
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>

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

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

View File

@@ -0,0 +1,118 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ display.name }}</title>
<style>
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
.notice { position: fixed; left: 12px; bottom: 12px; color: #bbb; font: 14px/1.3 sans-serif; }
</style>
</head>
<body>
<div id="stage"></div>
<div class="notice" id="notice"></div>
<script>
const token = "{{ display.token }}";
const stage = document.getElementById('stage');
const notice = document.getElementById('notice');
// Stable session id per browser (used to enforce max concurrent viewers per display token)
const SID_KEY = `display_sid_${token}`;
function getSid() {
let sid = null;
try { sid = localStorage.getItem(SID_KEY); } catch(e) { /* ignore */ }
if (!sid) {
sid = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(16).slice(2) + Date.now().toString(16));
try { localStorage.setItem(SID_KEY, sid); } catch(e) { /* ignore */ }
}
return sid;
}
const sid = getSid();
let playlist = null;
let idx = 0;
let timer = null;
async function fetchPlaylist() {
const res = await fetch(`/api/display/${token}/playlist?sid=${encodeURIComponent(sid)}`, { cache: 'no-store' });
if (res.status === 429) {
const data = await res.json().catch(() => null);
throw Object.assign(new Error(data?.message || 'Display limit reached'), { code: 'LIMIT', data });
}
return await res.json();
}
function clearStage() {
if (timer) { clearTimeout(timer); timer = null; }
stage.innerHTML = '';
}
function next() {
if (!playlist || !playlist.items || playlist.items.length === 0) {
notice.textContent = 'No playlist assigned.';
clearStage();
return;
}
const item = playlist.items[idx % playlist.items.length];
idx = (idx + 1) % playlist.items.length;
clearStage();
notice.textContent = playlist.playlist ? `${playlist.display}${playlist.playlist.name}` : playlist.display;
if (item.type === 'image') {
const el = document.createElement('img');
el.src = item.src;
stage.appendChild(el);
timer = setTimeout(next, (item.duration || 10) * 1000);
} else if (item.type === 'video') {
const el = document.createElement('video');
el.src = item.src;
el.autoplay = true;
el.muted = true;
el.playsInline = true;
el.onended = next;
stage.appendChild(el);
} else if (item.type === 'webpage') {
const el = document.createElement('iframe');
el.src = item.url;
stage.appendChild(el);
timer = setTimeout(next, (item.duration || 10) * 1000);
} else {
timer = setTimeout(next, 5000);
}
}
async function start() {
try {
playlist = await fetchPlaylist();
idx = 0;
next();
} catch (e) {
clearStage();
notice.textContent = e && e.message ? e.message : 'Unable to load playlist.';
// keep retrying; if a slot frees up the display will start automatically.
}
// refresh playlist every 60s
setInterval(async () => {
try {
playlist = await fetchPlaylist();
if (!stage.firstChild) {
idx = 0;
next();
}
} catch(e) {
clearStage();
notice.textContent = e && e.message ? e.message : 'Unable to load playlist.';
}
}, 60000);
}
start();
</script>
</body>
</html>