restyling
This commit is contained in:
@@ -496,9 +496,10 @@ def assign_playlist(display_id: int):
|
|||||||
@bp.post("/displays/<int:display_id>")
|
@bp.post("/displays/<int:display_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def update_display(display_id: int):
|
def update_display(display_id: int):
|
||||||
"""Update display metadata (description + assigned playlist).
|
"""Update display metadata.
|
||||||
|
|
||||||
Company users should be able to set a short description per display and assign a playlist.
|
Supports both form POST (full update) and JSON/AJAX (partial update).
|
||||||
|
Company users can set a short description per display and assign a playlist.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
company_user_required()
|
company_user_required()
|
||||||
@@ -507,26 +508,77 @@ def update_display(display_id: int):
|
|||||||
if not display or display.company_id != current_user.company_id:
|
if not display or display.company_id != current_user.company_id:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
wants_json = (
|
||||||
|
(request.headers.get("X-Requested-With") == "XMLHttpRequest")
|
||||||
|
or ("application/json" in (request.headers.get("Accept") or ""))
|
||||||
|
or request.is_json
|
||||||
|
)
|
||||||
|
|
||||||
|
def _json_error(message: str, status: int = 400):
|
||||||
|
return jsonify({"ok": False, "error": message}), status
|
||||||
|
|
||||||
|
# Inputs from either form or JSON
|
||||||
|
payload = request.get_json(silent=True) if request.is_json else None
|
||||||
|
|
||||||
# Description (short, optional)
|
# Description (short, optional)
|
||||||
desc = (request.form.get("description") or "").strip() or None
|
if request.is_json:
|
||||||
if desc is not None:
|
if payload is None:
|
||||||
desc = desc[:200]
|
return _json_error("Invalid JSON")
|
||||||
display.description = desc
|
if "description" in payload:
|
||||||
|
desc = (payload.get("description") or "").strip() or None
|
||||||
|
if desc is not None:
|
||||||
|
desc = desc[:200]
|
||||||
|
display.description = desc
|
||||||
|
else:
|
||||||
|
# form POST implies full update
|
||||||
|
desc = (request.form.get("description") or "").strip() or None
|
||||||
|
if desc is not None:
|
||||||
|
desc = desc[:200]
|
||||||
|
display.description = desc
|
||||||
|
|
||||||
# Playlist assignment
|
# Playlist assignment
|
||||||
playlist_id = (request.form.get("playlist_id") or "").strip()
|
if request.is_json:
|
||||||
if not playlist_id:
|
if "playlist_id" in payload:
|
||||||
display.assigned_playlist_id = None
|
playlist_id_val = payload.get("playlist_id")
|
||||||
|
if playlist_id_val in (None, ""):
|
||||||
|
display.assigned_playlist_id = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
playlist_id_int = int(playlist_id_val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _json_error("Invalid playlist_id")
|
||||||
|
playlist = db.session.get(Playlist, playlist_id_int)
|
||||||
|
if not playlist or playlist.company_id != current_user.company_id:
|
||||||
|
return _json_error("Invalid playlist")
|
||||||
|
display.assigned_playlist_id = playlist.id
|
||||||
else:
|
else:
|
||||||
try:
|
playlist_id = (request.form.get("playlist_id") or "").strip()
|
||||||
playlist_id_int = int(playlist_id)
|
if not playlist_id:
|
||||||
except ValueError:
|
display.assigned_playlist_id = None
|
||||||
abort(400)
|
else:
|
||||||
playlist = db.session.get(Playlist, playlist_id_int)
|
try:
|
||||||
if not playlist or playlist.company_id != current_user.company_id:
|
playlist_id_int = int(playlist_id)
|
||||||
abort(400)
|
except ValueError:
|
||||||
display.assigned_playlist_id = playlist.id
|
abort(400)
|
||||||
|
playlist = db.session.get(Playlist, playlist_id_int)
|
||||||
|
if not playlist or playlist.company_id != current_user.company_id:
|
||||||
|
abort(400)
|
||||||
|
display.assigned_playlist_id = playlist.id
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
if wants_json:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"display": {
|
||||||
|
"id": display.id,
|
||||||
|
"name": display.name,
|
||||||
|
"description": display.description,
|
||||||
|
"assigned_playlist_id": display.assigned_playlist_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
flash("Display updated", "success")
|
flash("Display updated", "success")
|
||||||
return redirect(url_for("company.dashboard"))
|
return redirect(url_for("company.dashboard"))
|
||||||
|
|||||||
286
app/static/styles.css
Normal file
286
app/static/styles.css
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
:root {
|
||||||
|
--brand: #f2d20b; /* warm yellow */
|
||||||
|
--brand-hover: #f6dd3d;
|
||||||
|
--ink: #111111;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-alt: #f7f7f7;
|
||||||
|
--shadow: 0 10px 30px rgba(17, 17, 17, 0.08);
|
||||||
|
--radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.app-navbar {
|
||||||
|
background: var(--surface) !important;
|
||||||
|
border-bottom: 1px solid var(--border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
padding-top: 6.25rem; /* fixed navbar offset */
|
||||||
|
padding-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.app-main {
|
||||||
|
padding-top: 6.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Branding */
|
||||||
|
.brand-mark {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--brand);
|
||||||
|
color: var(--ink);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .nav-link {
|
||||||
|
color: var(--ink) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .nav-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
h1, h2, h3, .display-1, .display-2, .display-3 {
|
||||||
|
font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: clamp(2rem, 4vw, 3rem);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards / surfaces */
|
||||||
|
.card {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border-color: var(--border);
|
||||||
|
box-shadow: 0 1px 0 rgba(0,0,0,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.card-elevated {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table {
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table thead th {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td, .table th {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent tables from breaking layouts on long tokens/URLs */
|
||||||
|
.table td,
|
||||||
|
.table th {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
border-radius: 12px;
|
||||||
|
border-color: var(--border);
|
||||||
|
padding: .75rem .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
border-color: rgba(242, 210, 11, 0.9);
|
||||||
|
box-shadow: 0 0 0 .25rem rgba(242, 210, 11, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-brand {
|
||||||
|
background: var(--brand);
|
||||||
|
border-color: var(--brand);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-brand:hover,
|
||||||
|
.btn-brand:focus {
|
||||||
|
background: var(--brand-hover);
|
||||||
|
border-color: var(--brand-hover);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ink {
|
||||||
|
background: var(--ink);
|
||||||
|
border-color: var(--ink);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ink:hover,
|
||||||
|
.btn-ink:focus {
|
||||||
|
background: #000;
|
||||||
|
border-color: #000;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-ink {
|
||||||
|
border-color: var(--ink);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-ink:hover,
|
||||||
|
.btn-outline-ink:focus {
|
||||||
|
background: var(--ink);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bootstrap outline-primary -> match brand */
|
||||||
|
.btn-outline-primary {
|
||||||
|
border-color: var(--ink);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-check:checked + .btn-outline-primary,
|
||||||
|
.btn-outline-primary:hover,
|
||||||
|
.btn-outline-primary:focus {
|
||||||
|
background: var(--brand);
|
||||||
|
border-color: var(--brand);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-secondary {
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-secondary:hover,
|
||||||
|
.btn-outline-secondary:focus {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth pages */
|
||||||
|
.auth-shell {
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Playlist gallery cards (used in company/playlist_detail.html) */
|
||||||
|
.playlist-card {
|
||||||
|
border-radius: var(--radius) !important;
|
||||||
|
border-color: var(--border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-card .card-top {
|
||||||
|
border-bottom-color: var(--border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-card .drag-handle {
|
||||||
|
color: var(--muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nice monospace blocks */
|
||||||
|
.monospace {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Company display gallery */
|
||||||
|
.display-gallery-card {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-gallery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 0.8fr 1.1fr 0.9fr;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-preview {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-top-left-radius: var(--radius);
|
||||||
|
border-top-right-radius: var(--radius);
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-preview iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.display-gallery-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
@@ -1,13 +1,112 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<h1 class="h3">Company: {{ company.name }}</h1>
|
<h1 class="page-title">Company: {{ company.name }}</h1>
|
||||||
<div>
|
<div>
|
||||||
<a class="btn btn-outline-secondary" href="{{ url_for('admin.dashboard') }}">Back</a>
|
<a class="btn btn-outline-ink" href="{{ url_for('admin.dashboard') }}">Back</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card border-danger mt-3">
|
<div class="mt-4">
|
||||||
|
<div class="card card-elevated">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h2 class="h5 mb-0">Displays</h2>
|
||||||
|
<form method="post" action="{{ url_for('admin.create_display', company_id=company.id) }}" class="d-flex gap-2">
|
||||||
|
<input class="form-control" name="name" placeholder="Display name" />
|
||||||
|
<button class="btn btn-brand" type="submit">Add display</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Token</th>
|
||||||
|
<th>Assigned</th>
|
||||||
|
<th class="text-end">Player</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in company.displays %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{{ url_for('admin.update_display_name', display_id=d.id) }}" class="d-flex gap-2 align-items-center flex-wrap">
|
||||||
|
<input
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
style="max-width: 320px"
|
||||||
|
name="name"
|
||||||
|
value="{{ d.name }}"
|
||||||
|
required
|
||||||
|
maxlength="120"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-ink btn-sm" type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="monospace small">{{ d.token }}</td>
|
||||||
|
<td class="text-muted">{{ d.assigned_playlist.name if d.assigned_playlist else "(none)" }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('display.display_player', token=d.token) }}" target="_blank">Open</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-muted">No displays.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card card-elevated">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Add user</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="{{ url_for('admin.create_company_user', company_id=company.id) }}">
|
||||||
|
<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-brand" type="submit">Create user</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6 mt-4 mt-lg-0">
|
||||||
|
<div class="card card-elevated">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Users</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for u in company.users %}
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>{{ u.email or "(no email)" }}</strong>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}">
|
||||||
|
<button class="btn btn-brand btn-sm" type="submit">Impersonate</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="list-group-item text-muted">No users.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-danger mt-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="h6 text-danger mb-2">Danger zone</h2>
|
<h2 class="h6 text-danger mb-2">Danger zone</h2>
|
||||||
<p class="mb-2 text-muted">
|
<p class="mb-2 text-muted">
|
||||||
@@ -22,80 +121,4 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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">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.email or "(no email)" }}</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>
|
|
||||||
<form method="post" action="{{ url_for('admin.update_display_name', display_id=d.id) }}" class="d-flex gap-2 align-items-center">
|
|
||||||
<input
|
|
||||||
class="form-control form-control-sm"
|
|
||||||
style="max-width: 260px"
|
|
||||||
name="name"
|
|
||||||
value="{{ d.name }}"
|
|
||||||
required
|
|
||||||
maxlength="120"
|
|
||||||
/>
|
|
||||||
<button class="btn btn-outline-primary btn-sm" type="submit">Save</button>
|
|
||||||
</form>
|
|
||||||
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,28 +1,49 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h1 class="h3">Admin dashboard</h1>
|
<h1 class="page-title">Admin</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<h2 class="h5">Companies</h2>
|
<div class="card card-elevated">
|
||||||
<form method="post" action="{{ url_for('admin.create_company') }}" class="card card-body mb-3">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<div class="input-group">
|
<h2 class="h5 mb-0">Companies</h2>
|
||||||
<input class="form-control" name="name" placeholder="New company name" required />
|
<form method="post" action="{{ url_for('admin.create_company') }}" class="d-flex gap-2">
|
||||||
<button class="btn btn-success" type="submit">Add</button>
|
<input class="form-control" name="name" placeholder="New company name" required />
|
||||||
|
<button class="btn btn-brand" type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th class="text-end">Users</th>
|
||||||
|
<th class="text-end">Displays</th>
|
||||||
|
<th class="text-end">Playlists</th>
|
||||||
|
<th class="text-end">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in companies %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ c.name }}</strong></td>
|
||||||
|
<td class="text-end">{{ c.users|length }}</td>
|
||||||
|
<td class="text-end">{{ c.displays|length }}</td>
|
||||||
|
<td class="text-end">{{ c.playlists|length }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a class="btn btn-ink btn-sm" href="{{ url_for('admin.company_detail', company_id=c.id) }}">Open</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-muted">No companies yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center mt-4">
|
<div class="row justify-content-center mt-4">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<div class="card">
|
<div class="card card-elevated">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h1 class="h5 mb-0">Change password</h1>
|
<h1 class="h5 mb-0">Change password</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">Update password</button>
|
<button type="submit" class="btn btn-brand">Update password</button>
|
||||||
<a class="btn btn-outline-secondary" href="{{ url_for('index') }}">Cancel</a>
|
<a class="btn btn-outline-ink" href="{{ url_for('index') }}">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="auth-shell">
|
||||||
<div class="col-md-6 col-lg-5">
|
<h1 class="page-title">Forgot password</h1>
|
||||||
<h1 class="h3 mb-3">Forgot password</h1>
|
<form method="post" class="card auth-card card-body">
|
||||||
<form method="post" class="card card-body">
|
|
||||||
<p class="text-muted">Enter your email address and we’ll send you a password reset link.</p>
|
<p class="text-muted">Enter your email address and we’ll send you a password reset link.</p>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Email</label>
|
<label class="form-label">Email</label>
|
||||||
<input class="form-control" name="email" type="email" autocomplete="email" required />
|
<input class="form-control" name="email" type="email" autocomplete="email" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-primary" type="submit">Send reset link</button>
|
<button class="btn btn-brand" type="submit">Send reset link</button>
|
||||||
<a class="btn btn-outline-secondary" href="{{ url_for('auth.login') }}">Back to login</a>
|
<a class="btn btn-outline-ink" href="{{ url_for('auth.login') }}">Back to login</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="auth-shell">
|
||||||
<div class="col-md-5">
|
<h1 class="page-title">Login</h1>
|
||||||
<h1 class="h3 mb-3">Login</h1>
|
<form method="post" class="card auth-card card-body">
|
||||||
<form method="post" class="card card-body">
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Email</label>
|
<label class="form-label">Email</label>
|
||||||
<input class="form-control" type="email" name="email" autocomplete="email" required />
|
<input class="form-control" type="email" name="email" autocomplete="email" required />
|
||||||
@@ -12,11 +11,10 @@
|
|||||||
<label class="form-label">Password</label>
|
<label class="form-label">Password</label>
|
||||||
<input class="form-control" type="password" name="password" autocomplete="current-password" required />
|
<input class="form-control" type="password" name="password" autocomplete="current-password" required />
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" type="submit">Login</button>
|
<button class="btn btn-brand w-100 py-2" type="submit">Login</button>
|
||||||
<div class="mt-3">
|
<div class="mt-3 text-center">
|
||||||
<a href="{{ url_for('auth.forgot_password') }}">Forgot password?</a>
|
<a class="link-secondary" href="{{ url_for('auth.forgot_password') }}">Forgot password?</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="auth-shell">
|
||||||
<div class="col-md-6 col-lg-5">
|
<h1 class="page-title">Reset password</h1>
|
||||||
<h1 class="h3 mb-3">Reset password</h1>
|
|
||||||
|
|
||||||
{% if token_error %}
|
{% if token_error %}
|
||||||
<div class="alert alert-danger">{{ token_error }}</div>
|
<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>
|
<a class="btn btn-outline-ink" href="{{ url_for('auth.forgot_password') }}">Request a new reset link</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" class="card card-body">
|
<form method="post" class="card auth-card card-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">New password</label>
|
<label class="form-label">New password</label>
|
||||||
<input class="form-control" type="password" name="new_password" autocomplete="new-password" minlength="8" required />
|
<input class="form-control" type="password" name="new_password" autocomplete="new-password" minlength="8" required />
|
||||||
@@ -19,9 +18,8 @@
|
|||||||
<label class="form-label">Confirm new password</label>
|
<label class="form-label">Confirm new password</label>
|
||||||
<input class="form-control" type="password" name="confirm_password" autocomplete="new-password" minlength="8" required />
|
<input class="form-control" type="password" name="confirm_password" autocomplete="new-password" minlength="8" required />
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" type="submit">Set new password</button>
|
<button class="btn btn-brand w-100 py-2" type="submit">Set new password</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,40 +5,46 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{{ title or "Signage" }}</title>
|
<title>{{ title or "Signage" }}</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
<style>
|
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||||
body { padding-top: 4.5rem; }
|
|
||||||
.monospace { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
|
<nav class="navbar navbar-expand-lg navbar-light fixed-top app-navbar">
|
||||||
<div class="container-fluid">
|
<div class="container">
|
||||||
<a class="navbar-brand" href="/">Signage</a>
|
<a class="navbar-brand d-flex align-items-center gap-2" href="/">
|
||||||
<div class="collapse navbar-collapse">
|
<span class="brand-mark" aria-hidden="true">S</span>
|
||||||
<ul class="navbar-nav me-auto">
|
<span>Signage</span>
|
||||||
{% if current_user.is_authenticated and current_user.is_admin %}
|
</a>
|
||||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.dashboard') }}">Admin</a></li>
|
|
||||||
{% elif current_user.is_authenticated %}
|
<button
|
||||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('company.dashboard') }}">Company</a></li>
|
class="navbar-toggler"
|
||||||
{% endif %}
|
type="button"
|
||||||
</ul>
|
data-bs-toggle="collapse"
|
||||||
<ul class="navbar-nav ms-auto">
|
data-bs-target="#mainNav"
|
||||||
|
aria-controls="mainNav"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
>
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="mainNav">
|
||||||
|
<div class="d-flex align-items-lg-center flex-column flex-lg-row gap-2 ms-lg-auto">
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<li class="nav-item"><span class="navbar-text me-3">Logged in as <strong>{{ current_user.email }}</strong></span></li>
|
<div class="small text-muted">{{ current_user.email }}</div>
|
||||||
<li class="nav-item"><a class="btn btn-outline-light btn-sm me-2" href="{{ url_for('auth.change_password') }}">Change password</a></li>
|
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('auth.change_password') }}">Change password</a>
|
||||||
{% if session.get('impersonator_admin_id') %}
|
{% 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>
|
<a class="btn btn-brand btn-sm" href="{{ url_for('auth.stop_impersonation') }}">Stop impersonation</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li class="nav-item"><a class="btn btn-outline-light btn-sm" href="{{ url_for('auth.logout') }}">Logout</a></li>
|
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('auth.logout') }}">Logout</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="nav-item"><a class="btn btn-outline-light btn-sm" href="{{ url_for('auth.login') }}">Login</a></li>
|
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('auth.login') }}">Login</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="container">
|
<main class="container app-main">
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
@@ -50,5 +56,8 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block page_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,74 +1,254 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="h3">Company dashboard</h1>
|
<h1 class="page-title">Welcome{% if current_user and current_user.email %}, {{ current_user.email }}{% endif %}!</h1>
|
||||||
|
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-md-6">
|
<div class="col-12">
|
||||||
<h2 class="h5">Playlists</h2>
|
<div class="card card-elevated">
|
||||||
<form method="post" action="{{ url_for('company.create_playlist') }}" class="card card-body mb-3">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<div class="input-group">
|
<h2 class="h5 mb-0">Playlists</h2>
|
||||||
<input class="form-control" name="name" placeholder="New playlist name" required />
|
<form method="post" action="{{ url_for('company.create_playlist') }}" class="d-flex gap-2">
|
||||||
<button class="btn btn-success" type="submit">Add</button>
|
<input class="form-control" name="name" placeholder="New playlist name" required />
|
||||||
|
<button class="btn btn-brand" type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th class="text-end">Items</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in playlists %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ p.name }}</strong></td>
|
||||||
|
<td class="text-end">{{ p.items|length }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="d-inline-flex gap-2">
|
||||||
|
<a class="btn btn-ink btn-sm" href="{{ url_for('company.playlist_detail', playlist_id=p.id) }}">Open</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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-muted">No playlists yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-12 mt-4">
|
||||||
<h2 class="h5">Displays</h2>
|
<div class="card card-elevated">
|
||||||
<div class="list-group">
|
<div class="card-header">
|
||||||
{% for d in displays %}
|
<h2 class="h5 mb-0">Displays</h2>
|
||||||
<div class="list-group-item">
|
</div>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="card-body">
|
||||||
<div>
|
<div class="row g-3">
|
||||||
<div><strong>{{ d.name }}</strong></div>
|
{% for d in displays %}
|
||||||
{% if d.description %}
|
<div class="col-12 col-md-6 col-xl-4">
|
||||||
<div class="text-muted">{{ d.description }}</div>
|
<div class="card display-gallery-card h-100">
|
||||||
{% endif %}
|
<div class="display-preview">
|
||||||
</div>
|
<iframe
|
||||||
<div style="min-width: 220px;">
|
title="Preview — {{ d.name }}"
|
||||||
<form method="post" action="{{ url_for('company.update_display', display_id=d.id) }}" class="d-flex flex-column gap-2">
|
src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
|
||||||
<input
|
loading="lazy"
|
||||||
class="form-control form-control-sm"
|
referrerpolicy="no-referrer"
|
||||||
name="description"
|
></iframe>
|
||||||
placeholder="Description (e.g. entrance, office)"
|
|
||||||
value="{{ d.description or '' }}"
|
|
||||||
maxlength="200"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div 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">Save</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
<div class="card-body d-flex flex-column gap-2">
|
||||||
|
<div>
|
||||||
|
<div class="fw-bold">{{ d.name }}</div>
|
||||||
|
<div class="text-muted small js-display-desc" data-display-id="{{ d.id }}">
|
||||||
|
{{ d.description or "—" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column gap-2 mt-auto">
|
||||||
|
<select
|
||||||
|
class="form-select form-select-sm js-playlist-select"
|
||||||
|
data-display-id="{{ d.id }}"
|
||||||
|
aria-label="Playlist selection"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ink btn-sm js-edit-desc"
|
||||||
|
data-display-id="{{ d.id }}"
|
||||||
|
data-display-name="{{ d.name }}"
|
||||||
|
data-current-desc="{{ d.description or '' }}"
|
||||||
|
>
|
||||||
|
Edit description
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% else %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="text-muted">No displays. Ask admin to add displays.</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
</div>
|
||||||
<div class="text-muted">No displays. Ask admin to add displays.</div>
|
</div>
|
||||||
{% endfor %}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast notifications -->
|
||||||
|
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1080">
|
||||||
|
<div id="companyToast" class="toast" role="alert" aria-live="polite" aria-atomic="true">
|
||||||
|
<div class="toast-body" id="companyToastBody">Saved</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit description modal -->
|
||||||
|
<div class="modal fade" id="editDescModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="editDescModalTitle">Edit description</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<label class="form-label" for="editDescInput">Description</label>
|
||||||
|
<textarea class="form-control" id="editDescInput" maxlength="200" rows="3" placeholder="Optional description (max 200 chars)"></textarea>
|
||||||
|
<div class="form-text"><span id="editDescCount">0</span>/200</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-brand" id="editDescSaveBtn">Save</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const toastEl = document.getElementById('companyToast');
|
||||||
|
const toastBodyEl = document.getElementById('companyToastBody');
|
||||||
|
const toast = toastEl ? new bootstrap.Toast(toastEl, { delay: 2200 }) : null;
|
||||||
|
|
||||||
|
function showToast(message, variant) {
|
||||||
|
if (!toast || !toastEl || !toastBodyEl) return;
|
||||||
|
toastEl.classList.remove('text-bg-success', 'text-bg-danger', 'text-bg-secondary');
|
||||||
|
if (variant) toastEl.classList.add(variant);
|
||||||
|
toastBodyEl.textContent = message;
|
||||||
|
toast.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postDisplayUpdate(displayId, payload) {
|
||||||
|
const res = await fetch(`/company/displays/${displayId}` , {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok || !data || !data.ok) {
|
||||||
|
const msg = (data && data.error) ? data.error : 'Save failed';
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return data.display;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist auto-save
|
||||||
|
document.querySelectorAll('.js-playlist-select').forEach((sel) => {
|
||||||
|
sel.addEventListener('change', async () => {
|
||||||
|
const displayId = sel.dataset.displayId;
|
||||||
|
const playlistId = sel.value || null;
|
||||||
|
sel.disabled = true;
|
||||||
|
try {
|
||||||
|
await postDisplayUpdate(displayId, { playlist_id: playlistId });
|
||||||
|
showToast('Playlist saved', 'text-bg-success');
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
|
||||||
|
} finally {
|
||||||
|
sel.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Description modal
|
||||||
|
const modalEl = document.getElementById('editDescModal');
|
||||||
|
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
||||||
|
const titleEl = document.getElementById('editDescModalTitle');
|
||||||
|
const inputEl = document.getElementById('editDescInput');
|
||||||
|
const countEl = document.getElementById('editDescCount');
|
||||||
|
const saveBtn = document.getElementById('editDescSaveBtn');
|
||||||
|
|
||||||
|
let activeDisplayId = null;
|
||||||
|
|
||||||
|
function updateCount() {
|
||||||
|
if (!inputEl || !countEl) return;
|
||||||
|
countEl.textContent = String((inputEl.value || '').length);
|
||||||
|
}
|
||||||
|
if (inputEl) inputEl.addEventListener('input', updateCount);
|
||||||
|
|
||||||
|
document.querySelectorAll('.js-edit-desc').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
activeDisplayId = btn.dataset.displayId;
|
||||||
|
const displayName = btn.dataset.displayName || 'Display';
|
||||||
|
const currentDesc = btn.dataset.currentDesc || '';
|
||||||
|
if (titleEl) titleEl.textContent = `Edit description — ${displayName}`;
|
||||||
|
if (inputEl) inputEl.value = currentDesc;
|
||||||
|
updateCount();
|
||||||
|
if (modal) modal.show();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveDescription() {
|
||||||
|
if (!activeDisplayId || !inputEl) return;
|
||||||
|
const desc = (inputEl.value || '').trim();
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const updated = await postDisplayUpdate(activeDisplayId, { description: desc });
|
||||||
|
// Update visible description
|
||||||
|
const descEl = document.querySelector(`.js-display-desc[data-display-id="${activeDisplayId}"]`);
|
||||||
|
if (descEl) descEl.textContent = updated.description ? updated.description : '—';
|
||||||
|
// Update button's stored value
|
||||||
|
const btn = document.querySelector(`.js-edit-desc[data-display-id="${activeDisplayId}"]`);
|
||||||
|
if (btn) btn.dataset.currentDesc = updated.description || '';
|
||||||
|
showToast('Description saved', 'text-bg-success');
|
||||||
|
if (modal) modal.hide();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (saveBtn) {
|
||||||
|
saveBtn.addEventListener('click', saveDescription);
|
||||||
|
}
|
||||||
|
if (modalEl) {
|
||||||
|
modalEl.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
saveDescription();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{# Cropper.js (used for image cropping) #}
|
{# Cropper.js (used for image cropping) #}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/cropperjs/cropper.min.css') }}" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.css" />
|
||||||
<style>
|
<style>
|
||||||
/* Gallery grid for playlist items */
|
/* Gallery grid for playlist items */
|
||||||
#playlist-items.playlist-gallery,
|
#playlist-items.playlist-gallery,
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.playlist-card {
|
.playlist-card {
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
border: 1px solid rgba(0,0,0,.125);
|
border: 1px solid var(--border);
|
||||||
border-radius: .5rem;
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
||||||
}
|
}
|
||||||
@@ -26,13 +26,13 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: .5rem;
|
gap: .5rem;
|
||||||
padding: .5rem .75rem;
|
padding: .5rem .75rem;
|
||||||
border-bottom: 1px solid rgba(0,0,0,.08);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.playlist-card .drag-handle {
|
.playlist-card .drag-handle {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
color: #6c757d;
|
color: var(--muted);
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.playlist-card .card-body {
|
.playlist-card .card-body {
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
<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.');">
|
<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>
|
<button class="btn btn-outline-danger btn-sm" type="submit">Delete playlist</button>
|
||||||
</form>
|
</form>
|
||||||
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('company.dashboard') }}">Back</a>
|
<a class="btn btn-outline-ink btn-sm" href="{{ url_for('company.dashboard') }}">Back</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<h2 class="h5 mb-0">Items</h2>
|
<h2 class="h5 mb-0">Items</h2>
|
||||||
<div class="text-muted small">Tip: drag items to reorder. Changes save automatically.</div>
|
<div class="text-muted small">Tip: drag items to reorder. Changes save automatically.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-success" type="button" id="open-add-item">Add item</button>
|
<button class="btn btn-brand" type="button" id="open-add-item">Add item</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
@@ -301,19 +301,17 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" id="add-item-back">Back</button>
|
<button type="button" class="btn btn-outline-ink" id="add-item-back">Back</button>
|
||||||
<button type="button" class="btn btn-success" id="add-item-submit">Add</button>
|
<button type="button" class="btn btn-brand" id="add-item-submit">Add</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Load Cropper.js BEFORE our inline script so window.Cropper is available #}
|
{# 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 src="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.js"></script>
|
||||||
{# Bootstrap JS required for modal (already included via base.html CSS only) #}
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
|
|
||||||
<script>
|
<script type="module">
|
||||||
(function() {
|
(function() {
|
||||||
// -------------------------
|
// -------------------------
|
||||||
// Add-item modal + steps
|
// Add-item modal + steps
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
const stage = document.getElementById('stage');
|
const stage = document.getElementById('stage');
|
||||||
const notice = document.getElementById('notice');
|
const notice = document.getElementById('notice');
|
||||||
|
|
||||||
|
const isPreview = new URLSearchParams(window.location.search).get('preview') === '1';
|
||||||
|
|
||||||
// Stable session id per browser (used to enforce max concurrent viewers per display token)
|
// Stable session id per browser (used to enforce max concurrent viewers per display token)
|
||||||
const SID_KEY = `display_sid_${token}`;
|
const SID_KEY = `display_sid_${token}`;
|
||||||
function getSid() {
|
function getSid() {
|
||||||
@@ -31,14 +33,15 @@
|
|||||||
return sid;
|
return sid;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sid = getSid();
|
const sid = isPreview ? null : getSid();
|
||||||
|
|
||||||
let playlist = null;
|
let playlist = null;
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
|
|
||||||
async function fetchPlaylist() {
|
async function fetchPlaylist() {
|
||||||
const res = await fetch(`/api/display/${token}/playlist?sid=${encodeURIComponent(sid)}`, { cache: 'no-store' });
|
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
|
||||||
|
const res = await fetch(`/api/display/${token}/playlist${qs}`, { cache: 'no-store' });
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
throw Object.assign(new Error(data?.message || 'Display limit reached'), { code: 'LIMIT', data });
|
throw Object.assign(new Error(data?.message || 'Display limit reached'), { code: 'LIMIT', data });
|
||||||
|
|||||||
Reference in New Issue
Block a user