Compare commits

..

9 Commits

Author SHA1 Message Date
613f53ba9e Release 1.7.1 2026-01-28 13:33:18 +01:00
5221f9f670 Release 1.7 2026-01-27 16:16:23 +01:00
0c2720618a Release 1.6.2 2026-01-26 15:34:52 +01:00
c5aa8a5156 Release 1.6.1 2026-01-25 18:35:28 +01:00
9fd3f03b87 Release 1.6 2026-01-25 18:00:12 +01:00
860679d119 Release 1.5 2026-01-25 17:14:18 +01:00
78f0f379fc Make image crop target size configurable 2026-01-25 16:54:01 +01:00
56760e380d Release v1.3 2026-01-25 15:57:38 +01:00
47aca9d64d Update playlist detail UI (priority/schedule/add-item) 2026-01-25 13:50:22 +01:00
14 changed files with 2390 additions and 118 deletions

View File

@@ -140,6 +140,34 @@ Notes:
- `GUNICORN_WORKERS` (default: 2) - `GUNICORN_WORKERS` (default: 2)
- `GUNICORN_BIND` (default: `0.0.0.0:8000`) - `GUNICORN_BIND` (default: `0.0.0.0:8000`)
## Release helper (git + docker publish)
This repo includes a small helper to:
1) ask for a **commit message** and **version**
2) commit + push to the `openslide` git remote
3) build + push Docker images:
- `git.alphen.cloud/bramval/openslide:<version>`
- `git.alphen.cloud/bramval/openslide:latest`
Run (interactive):
```bash
python scripts/release.py
```
Run (non-interactive):
```bash
python scripts/release.py --version 1.2.3 --message "Release 1.2.3"
```
Dry-run (prints commands only):
```bash
python scripts/release.py --version 1.2.3 --message "Release 1.2.3" --dry-run
```
## Notes ## Notes
- SQLite DB is stored at `instance/signage.sqlite`. - SQLite DB is stored at `instance/signage.sqlite`.
@@ -159,6 +187,34 @@ The player keeps itself up-to-date automatically:
- It listens to `GET /api/display/<token>/events` (Server-Sent Events) and reloads the playlist immediately when it changes. - It listens to `GET /api/display/<token>/events` (Server-Sent Events) and reloads the playlist immediately when it changes.
- It also does a fallback playlist refresh every 5 minutes for networks/proxies that block SSE. - It also does a fallback playlist refresh every 5 minutes for networks/proxies that block SSE.
## Ticker tape (RSS headlines)
Each display can optionally show a **bottom ticker tape** with scrolling news headlines.
Configure RSS + styling as a company user via:
- **My Company → Ticker tape (RSS)**
Company-level options:
- RSS feed URL (public http/https)
- Text color (picker)
- Background color + opacity
- Font (dropdown)
- Font size
- Speed
Per-display option:
- Enable/disable ticker on that display (Dashboard → Displays → Configure display)
Implementation notes:
- Headlines are fetched server-side via `GET /api/display/<token>/ticker` and cached in-memory.
- The player reads the company ticker settings via `GET /api/display/<token>/playlist`.
- The player auto-refreshes headlines without restart on a **long interval** (default: **12 hours**, override via `?ticker_poll=seconds`).
- Server-side cache TTL defaults to **6 hours** (override via env var `TICKER_CACHE_TTL_SECONDS`).
## SMTP / Forgot password ## SMTP / Forgot password
This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables. This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables.
@@ -228,6 +284,11 @@ If the reset email is not received:

View File

@@ -20,6 +20,24 @@ def create_app():
app.config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", False) app.config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", False)
app.config.setdefault("UPLOAD_FOLDER", os.path.join(app.root_path, "static", "uploads")) app.config.setdefault("UPLOAD_FOLDER", os.path.join(app.root_path, "static", "uploads"))
# Target output resolution for cropped images.
# This is used by the client-side cropper (to generate an upload) and by the server-side
# image processing (to cap the resulting WEBP size).
#
# Defaults to Full HD landscape (1920x1080). Portrait is derived by swapping.
# Override via env vars, e.g.:
# IMAGE_CROP_TARGET_W=1920
# IMAGE_CROP_TARGET_H=1080
def _env_int(name: str, default: int) -> int:
try:
v = int(os.environ.get(name, "") or default)
except (TypeError, ValueError):
v = default
return max(1, v)
app.config.setdefault("IMAGE_CROP_TARGET_W", _env_int("IMAGE_CROP_TARGET_W", 1920))
app.config.setdefault("IMAGE_CROP_TARGET_H", _env_int("IMAGE_CROP_TARGET_H", 1080))
# NOTE: Videos should be max 250MB. # NOTE: Videos should be max 250MB.
# Flask's MAX_CONTENT_LENGTH applies to the full request payload (multipart includes overhead). # Flask's MAX_CONTENT_LENGTH applies to the full request payload (multipart includes overhead).
# We set this slightly above 250MB to allow for multipart/form fields overhead, while still # We set this slightly above 250MB to allow for multipart/form fields overhead, while still
@@ -62,6 +80,41 @@ def create_app():
db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)")) db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)"))
db.session.commit() db.session.commit()
# Displays: per-display overlay toggle
if "show_overlay" not in display_cols:
db.session.execute(
db.text("ALTER TABLE display ADD COLUMN show_overlay BOOLEAN NOT NULL DEFAULT 0")
)
db.session.commit()
# Displays: optional ticker tape (RSS headlines)
if "ticker_enabled" not in display_cols:
db.session.execute(
db.text("ALTER TABLE display ADD COLUMN ticker_enabled BOOLEAN NOT NULL DEFAULT 0")
)
db.session.commit()
if "ticker_rss_url" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_rss_url VARCHAR(1000)"))
db.session.commit()
if "ticker_color" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_color" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_opacity" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_opacity INTEGER"))
db.session.commit()
if "ticker_font_family" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_family VARCHAR(120)"))
db.session.commit()
if "ticker_font_size_px" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_size_px INTEGER"))
db.session.commit()
if "ticker_speed" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_speed INTEGER"))
db.session.commit()
# Companies: optional per-company storage quota # Companies: optional per-company storage quota
company_cols = [ company_cols = [
r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall() r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall()
@@ -70,6 +123,34 @@ def create_app():
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT")) db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
db.session.commit() db.session.commit()
# Companies: optional overlay file path
if "overlay_file_path" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)"))
db.session.commit()
# Companies: ticker tape settings (RSS + styling)
if "ticker_rss_url" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_rss_url VARCHAR(1000)"))
db.session.commit()
if "ticker_color" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_color" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_opacity" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_opacity INTEGER"))
db.session.commit()
if "ticker_font_family" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_family VARCHAR(120)"))
db.session.commit()
if "ticker_font_size_px" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_size_px INTEGER"))
db.session.commit()
if "ticker_speed" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_speed INTEGER"))
db.session.commit()
# AppSettings: create settings table if missing. # AppSettings: create settings table if missing.
# (PRAGMA returns empty if the table doesn't exist.) # (PRAGMA returns empty if the table doesn't exist.)
settings_cols = [ settings_cols = [

View File

@@ -22,11 +22,72 @@ def _ensure_schema_and_settings() -> None:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)")) db.session.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)"))
db.session.commit() db.session.commit()
if "transition" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)"))
db.session.commit()
if "show_overlay" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN show_overlay BOOLEAN NOT NULL DEFAULT 0"))
db.session.commit()
# Optional ticker tape (RSS headlines)
if "ticker_enabled" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_enabled BOOLEAN NOT NULL DEFAULT 0"))
db.session.commit()
if "ticker_rss_url" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_rss_url VARCHAR(1000)"))
db.session.commit()
if "ticker_color" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_color" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_opacity" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_opacity INTEGER"))
db.session.commit()
if "ticker_font_family" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_family VARCHAR(120)"))
db.session.commit()
if "ticker_font_size_px" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_size_px INTEGER"))
db.session.commit()
if "ticker_speed" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_speed INTEGER"))
db.session.commit()
company_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall()] company_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall()]
if "storage_max_bytes" not in company_cols: if "storage_max_bytes" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT")) db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
db.session.commit() db.session.commit()
if "overlay_file_path" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)"))
db.session.commit()
# Companies: ticker tape settings (RSS + styling)
if "ticker_rss_url" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_rss_url VARCHAR(1000)"))
db.session.commit()
if "ticker_color" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_color" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_opacity" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_opacity INTEGER"))
db.session.commit()
if "ticker_font_family" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_family VARCHAR(120)"))
db.session.commit()
if "ticker_font_size_px" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_size_px INTEGER"))
db.session.commit()
if "ticker_speed" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_speed INTEGER"))
db.session.commit()
settings_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(app_settings)")).fetchall()] settings_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(app_settings)")).fetchall()]
if settings_cols and "public_domain" not in settings_cols: if settings_cols and "public_domain" not in settings_cols:
db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)")) db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)"))

View File

@@ -16,6 +16,20 @@ class Company(db.Model):
# If NULL or <=0: unlimited. # If NULL or <=0: unlimited.
storage_max_bytes = db.Column(db.BigInteger, nullable=True) storage_max_bytes = db.Column(db.BigInteger, nullable=True)
# Optional per-company 16:9 PNG overlay (stored under /static/uploads/...)
# Example: uploads/<company_id>/overlay_<uuid>.png
overlay_file_path = db.Column(db.String(400), nullable=True)
# Optional per-company ticker tape (RSS headlines) settings.
# Displays can enable/disable the ticker individually.
ticker_rss_url = db.Column(db.String(1000), nullable=True)
ticker_color = db.Column(db.String(32), nullable=True)
ticker_bg_color = db.Column(db.String(32), nullable=True)
ticker_bg_opacity = db.Column(db.Integer, nullable=True) # 0-100
ticker_font_family = db.Column(db.String(120), nullable=True)
ticker_font_size_px = db.Column(db.Integer, nullable=True)
ticker_speed = db.Column(db.Integer, nullable=True) # 1-100
users = db.relationship("User", back_populates="company", cascade="all, delete-orphan") users = db.relationship("User", back_populates="company", cascade="all, delete-orphan")
displays = db.relationship("Display", back_populates="company", cascade="all, delete-orphan") displays = db.relationship("Display", back_populates="company", cascade="all, delete-orphan")
playlists = db.relationship("Playlist", back_populates="company", cascade="all, delete-orphan") playlists = db.relationship("Playlist", back_populates="company", cascade="all, delete-orphan")
@@ -102,6 +116,20 @@ class Display(db.Model):
description = db.Column(db.String(200), nullable=True) description = db.Column(db.String(200), nullable=True)
# Transition animation between slides: none|fade|slide # Transition animation between slides: none|fade|slide
transition = db.Column(db.String(20), nullable=True) transition = db.Column(db.String(20), nullable=True)
# Optional ticker tape (RSS headlines) rendered on the display.
# Note: for this small project we avoid a JSON config blob; we store a few explicit columns.
ticker_enabled = db.Column(db.Boolean, default=False, nullable=False)
ticker_rss_url = db.Column(db.String(1000), nullable=True)
ticker_color = db.Column(db.String(32), nullable=True) # CSS color, e.g. "#ffffff"
ticker_bg_color = db.Column(db.String(32), nullable=True) # hex (without alpha); opacity in ticker_bg_opacity
ticker_bg_opacity = db.Column(db.Integer, nullable=True) # 0-100
ticker_font_family = db.Column(db.String(120), nullable=True) # CSS font-family
ticker_font_size_px = db.Column(db.Integer, nullable=True) # px
ticker_speed = db.Column(db.Integer, nullable=True) # 1-100 (UI slider); higher = faster
# If true, show the company's overlay PNG on top of the display content.
show_overlay = db.Column(db.Boolean, default=False, nullable=False)
token = db.Column(db.String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex) token = db.Column(db.String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex)
assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True) assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True)

View File

@@ -395,6 +395,10 @@ def delete_company(company_id: int):
if it.item_type in ("image", "video"): if it.item_type in ("image", "video"):
_try_delete_upload(it.file_path, upload_folder) _try_delete_upload(it.file_path, upload_folder)
# 3b) Clean up uploaded overlay (if any)
if company.overlay_file_path:
_try_delete_upload(company.overlay_file_path, upload_folder)
# 4) Delete the company; cascades will delete users/displays/playlists/items. # 4) Delete the company; cascades will delete users/displays/playlists/items.
company_name = company.name company_name = company.name
db.session.delete(company) db.session.delete(company)

View File

@@ -1,12 +1,18 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
import hashlib import hashlib
import json import json
import os
import time import time
import re
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from xml.etree import ElementTree as ET
from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for
from ..extensions import db from ..extensions import db
from ..models import Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem from ..models import Company, Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem
from ..uploads import is_valid_upload_relpath
bp = Blueprint("api", __name__, url_prefix="/api") bp = Blueprint("api", __name__, url_prefix="/api")
@@ -14,6 +20,21 @@ bp = Blueprint("api", __name__, url_prefix="/api")
MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3 MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3
SESSION_TTL_SECONDS = 90 SESSION_TTL_SECONDS = 90
# RSS ticker cache (in-memory; OK for this small app; avoids hammering feeds)
#
# Default is intentionally long because displays can refresh headlines on a long interval
# (e.g., twice per day) and we don't want many displays to re-fetch the same feed.
# Override via env var `TICKER_CACHE_TTL_SECONDS`.
def _env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, "") or default)
except (TypeError, ValueError):
return default
TICKER_CACHE_TTL_SECONDS = max(10, _env_int("TICKER_CACHE_TTL_SECONDS", 6 * 60 * 60))
_TICKER_CACHE: dict[str, dict] = {}
def _is_playlist_active_now(p: Playlist, now_utc: datetime) -> bool: def _is_playlist_active_now(p: Playlist, now_utc: datetime) -> bool:
"""Return True if playlist is active based on its optional schedule window.""" """Return True if playlist is active based on its optional schedule window."""
@@ -167,12 +188,141 @@ def _playlist_signature(display: Display) -> tuple[int | None, str]:
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest() return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
def _is_http_url_allowed(url: str) -> bool:
"""Basic SSRF hardening: only allow http(s) and disallow obvious local targets."""
try:
u = urlparse(url)
except Exception:
return False
if u.scheme not in {"http", "https"}:
return False
host = (u.hostname or "").strip().lower()
if not host:
return False
# Block localhost and common local domains.
if host in {"localhost", "127.0.0.1", "::1"}:
return False
# Block RFC1918-ish and link-local targets when host is an IP.
# Note: this is best-effort; proper SSRF protection would require DNS resolution too.
if re.match(r"^\d+\.\d+\.\d+\.\d+$", host):
parts = [int(x) for x in host.split(".")]
if parts[0] == 10:
return False
if parts[0] == 127:
return False
if parts[0] == 169 and parts[1] == 254:
return False
if parts[0] == 192 and parts[1] == 168:
return False
if parts[0] == 172 and 16 <= parts[1] <= 31:
return False
return True
def _strip_text(s: str) -> str:
s = (s or "").strip()
s = re.sub(r"\s+", " ", s)
return s
def _fetch_rss_titles(url: str, *, limit: int = 20) -> list[str]:
"""Fetch RSS/Atom titles from a feed URL.
We intentionally avoid adding dependencies (feedparser) for this project.
This implementation is tolerant enough for typical RSS2/Atom feeds.
"""
req = Request(
url,
headers={
"User-Agent": "SignageTicker/1.0 (+https://example.invalid)",
"Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
},
method="GET",
)
with urlopen(req, timeout=8) as resp:
# Basic size cap (avoid reading huge responses into memory)
raw = resp.read(2_000_000) # 2MB
try:
root = ET.fromstring(raw)
except Exception:
return []
titles: list[str] = []
# RSS2: <rss><channel><item><title>
for el in root.findall(".//item/title"):
t = _strip_text("".join(el.itertext()))
if t:
titles.append(t)
# Atom: <feed><entry><title>
if not titles:
for el in root.findall(".//{*}entry/{*}title"):
t = _strip_text("".join(el.itertext()))
if t:
titles.append(t)
# Some feeds may have <channel><title> etc; we only want entry titles.
# Deduplicate while preserving order.
deduped: list[str] = []
seen = set()
for t in titles:
if t in seen:
continue
seen.add(t)
deduped.append(t)
if len(deduped) >= limit:
break
return deduped
def _get_ticker_titles_cached(url: str) -> tuple[list[str], bool]:
"""Return (titles, from_cache)."""
now = time.time()
key = (url or "").strip()
if not key:
return [], True
entry = _TICKER_CACHE.get(key)
if entry and (now - float(entry.get("ts") or 0)) < TICKER_CACHE_TTL_SECONDS:
return (entry.get("titles") or []), True
titles: list[str] = []
try:
if _is_http_url_allowed(key):
titles = _fetch_rss_titles(key)
except Exception:
titles = []
_TICKER_CACHE[key] = {"ts": now, "titles": titles}
return titles, False
@bp.get("/display/<token>/playlist") @bp.get("/display/<token>/playlist")
def display_playlist(token: str): def display_playlist(token: str):
display = Display.query.filter_by(token=token).first() display = Display.query.filter_by(token=token).first()
if not display: if not display:
abort(404) abort(404)
company = Company.query.filter_by(id=display.company_id).first()
# Optional overlay URL (per-company) when enabled on this display.
overlay_src = None
if display.show_overlay:
if company and company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path):
overlay_src = url_for("static", filename=company.overlay_file_path)
# Enforce: a display URL/token can be opened by max 3 concurrently active sessions. # Enforce: a display URL/token can be opened by max 3 concurrently active sessions.
# Player sends a stable `sid` via querystring. # Player sends a stable `sid` via querystring.
sid = request.args.get("sid") sid = request.args.get("sid")
@@ -180,6 +330,18 @@ def display_playlist(token: str):
if not ok: if not ok:
return resp return resp
# Ticker settings are configured per-company; displays can enable/disable individually.
ticker_cfg = {
"enabled": bool(display.ticker_enabled),
"rss_url": (company.ticker_rss_url if company else None),
"color": (company.ticker_color if company else None),
"bg_color": (company.ticker_bg_color if company else None),
"bg_opacity": (company.ticker_bg_opacity if company else None),
"font_family": (company.ticker_font_family if company else None),
"font_size_px": (company.ticker_font_size_px if company else None),
"speed": (company.ticker_speed if company else None),
}
# Determine active playlists. If display_playlist has any rows, use those. # Determine active playlists. If display_playlist has any rows, use those.
# Otherwise fall back to the legacy assigned_playlist_id. # Otherwise fall back to the legacy assigned_playlist_id.
mapped_ids = [ mapped_ids = [
@@ -200,6 +362,8 @@ def display_playlist(token: str):
{ {
"display": display.name, "display": display.name,
"transition": display.transition or "none", "transition": display.transition or "none",
"overlay_src": overlay_src,
"ticker": ticker_cfg,
"playlists": [], "playlists": [],
"items": [], "items": [],
} }
@@ -263,12 +427,51 @@ def display_playlist(token: str):
{ {
"display": display.name, "display": display.name,
"transition": display.transition or "none", "transition": display.transition or "none",
"overlay_src": overlay_src,
"ticker": ticker_cfg,
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists], "playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
"items": items, "items": items,
} }
) )
@bp.get("/display/<token>/ticker")
def display_ticker(token: str):
"""Return ticker headlines for a display.
We keep it separate from /playlist so the player can refresh headlines on its own interval.
"""
display = Display.query.filter_by(token=token).first()
if not display:
abort(404)
company = Company.query.filter_by(id=display.company_id).first()
# Enforce concurrent session limit the same way as /playlist.
sid = request.args.get("sid")
ok, resp = _enforce_and_touch_display_session(display, sid)
if not ok:
return resp
if not display.ticker_enabled:
return jsonify({"enabled": False, "headlines": []})
rss_url = ((company.ticker_rss_url if company else None) or "").strip()
if not rss_url:
return jsonify({"enabled": True, "headlines": []})
titles, from_cache = _get_ticker_titles_cached(rss_url)
return jsonify(
{
"enabled": True,
"rss_url": rss_url,
"headlines": titles,
"cached": bool(from_cache),
}
)
@bp.get("/display/<token>/events") @bp.get("/display/<token>/events")
def display_events(token: str): def display_events(token: str):
"""Server-Sent Events stream to notify the player when its playlist changes.""" """Server-Sent Events stream to notify the player when its playlist changes."""

View File

@@ -26,6 +26,13 @@ from ..auth_tokens import make_password_reset_token
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"} ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"}
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".m4v"} ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".m4v"}
# Overlay is a transparent PNG that sits on top of a display.
ALLOWED_OVERLAY_EXTENSIONS = {".png"}
# Keep overlay size reasonable; it will be stretched to fit anyway.
# (PNG overlays are typically small-ish; 10MB is generous.)
MAX_OVERLAY_BYTES = 10 * 1024 * 1024
# Videos should have a maximum upload size of 250MB # Videos should have a maximum upload size of 250MB
MAX_VIDEO_BYTES = 250 * 1024 * 1024 MAX_VIDEO_BYTES = 250 * 1024 * 1024
@@ -140,15 +147,22 @@ def _save_compressed_image(
img = img.convert("RGB") img = img.convert("RGB")
# Optional crop # Optional crop
# NOTE: The front-end may already upload a cropped image (canvas export), but we still
# enforce aspect + maximum output size here for consistency.
target_w = int(current_app.config.get("IMAGE_CROP_TARGET_W", 1920) or 1920)
target_h = int(current_app.config.get("IMAGE_CROP_TARGET_H", 1080) or 1080)
target_w = max(1, target_w)
target_h = max(1, target_h)
if cm == "16:9": if cm == "16:9":
img = _center_crop_to_aspect(img, 16, 9) img = _center_crop_to_aspect(img, 16, 9)
max_box = (1920, 1080) max_box = (target_w, target_h)
elif cm == "9:16": elif cm == "9:16":
img = _center_crop_to_aspect(img, 9, 16) img = _center_crop_to_aspect(img, 9, 16)
max_box = (1080, 1920) max_box = (target_h, target_w)
else: else:
# No crop: allow both portrait and landscape up to 1920px on the longest side. # No crop: allow both portrait and landscape up to target_w/target_h on the longest side.
max_box = (1920, 1920) max_box = (max(target_w, target_h),) * 2
# Resize down if very large (keeps aspect ratio) # Resize down if very large (keeps aspect ratio)
img.thumbnail(max_box) img.thumbnail(max_box)
@@ -175,6 +189,68 @@ def _try_delete_upload(file_path: str | None, upload_root: str):
# Ignore cleanup failures # Ignore cleanup failures
pass pass
def _save_overlay_png(
uploaded_file,
upload_root: str,
company_id: int | None,
) -> str:
"""Save a company overlay as PNG under the company's upload dir.
Returns relative file path under /static (uploads/<company_id>/overlay_<uuid>.png)
"""
unique = f"overlay_{uuid.uuid4().hex}.png"
company_dir = ensure_company_upload_dir(upload_root, company_id)
save_path = os.path.join(company_dir, unique)
# Validate file is a PNG and is 16:9-ish.
# Use magic bytes (signature) instead of relying on Pillow's img.format,
# which can be unreliable if the stream position isn't at 0.
try:
if hasattr(uploaded_file, "stream"):
uploaded_file.stream.seek(0)
except Exception:
pass
try:
sig = uploaded_file.stream.read(8) if hasattr(uploaded_file, "stream") else uploaded_file.read(8)
except Exception:
sig = b""
# PNG file signature: 89 50 4E 47 0D 0A 1A 0A
if sig != b"\x89PNG\r\n\x1a\n":
raise ValueError("not_png")
# Rewind before Pillow parses.
try:
if hasattr(uploaded_file, "stream"):
uploaded_file.stream.seek(0)
except Exception:
pass
img = Image.open(uploaded_file)
img = ImageOps.exif_transpose(img)
w, h = img.size
if not w or not h:
raise ValueError("invalid")
# Allow some tolerance (overlays may include extra transparent padding).
aspect = w / h
target = 16 / 9
if abs(aspect - target) > 0.15: # ~15% tolerance
raise ValueError("not_16_9")
# Ensure we preserve alpha; normalize mode.
if img.mode not in ("RGBA", "LA"):
# Convert to RGBA so transparency is supported consistently.
img = img.convert("RGBA")
img.save(save_path, format="PNG", optimize=True)
company_seg = str(int(company_id)) if company_id is not None else "0"
return f"uploads/{company_seg}/{unique}"
bp = Blueprint("company", __name__, url_prefix="/company") bp = Blueprint("company", __name__, url_prefix="/company")
@@ -239,6 +315,44 @@ def _storage_limit_error_message(*, storage_max_human: str | None) -> str:
return "Storage limit reached. Please delete items to free space." return "Storage limit reached. Please delete items to free space."
def _normalize_css_color(val: str | None) -> str | None:
"""Accept a limited set of CSS color inputs (primarily hex + a few keywords)."""
v = (val or "").strip()
if not v:
return None
low = v.lower()
if low in {"white", "black", "red", "green", "blue", "yellow", "orange", "purple", "gray", "grey"}:
return low
if low.startswith("#"):
h = low[1:]
if len(h) in {3, 6, 8} and all(c in "0123456789abcdef" for c in h):
return "#" + h
return None
def _normalize_font_family(val: str | None) -> str | None:
v = (val or "").strip()
if not v:
return None
v = v.replace("\n", " ").replace("\r", " ").replace('"', "").replace("'", "")
v = " ".join(v.split())
return v[:120] if v else None
def _normalize_int(val, *, min_val: int, max_val: int) -> int | None:
if val in (None, ""):
return None
try:
n = int(val)
except (TypeError, ValueError):
return None
return min(max_val, max(min_val, n))
@bp.get("/my-company") @bp.get("/my-company")
@login_required @login_required
def my_company(): def my_company():
@@ -276,10 +390,15 @@ def my_company():
users = User.query.filter_by(company_id=company.id, is_admin=False).order_by(User.email.asc()).all() users = User.query.filter_by(company_id=company.id, is_admin=False).order_by(User.email.asc()).all()
overlay_url = None
if company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path):
overlay_url = url_for("static", filename=company.overlay_file_path)
return render_template( return render_template(
"company/my_company.html", "company/my_company.html",
company=company, company=company,
users=users, users=users,
overlay_url=overlay_url,
stats={ stats={
"users": user_count, "users": user_count,
"displays": display_count, "displays": display_count,
@@ -295,6 +414,142 @@ def my_company():
) )
@bp.post("/my-company/ticker")
@login_required
def update_company_ticker_settings():
"""Update per-company ticker tape settings.
Note: per-display enable/disable remains on the Display model.
"""
company_user_required()
company = db.session.get(Company, current_user.company_id)
if not company:
abort(404)
rss_url = (request.form.get("ticker_rss_url") or "").strip() or None
if rss_url is not None:
rss_url = rss_url[:1000]
company.ticker_rss_url = rss_url
company.ticker_color = _normalize_css_color(request.form.get("ticker_color"))
company.ticker_bg_color = _normalize_css_color(request.form.get("ticker_bg_color"))
company.ticker_bg_opacity = _normalize_int(request.form.get("ticker_bg_opacity"), min_val=0, max_val=100)
company.ticker_font_family = _normalize_font_family(request.form.get("ticker_font_family"))
company.ticker_font_size_px = _normalize_int(request.form.get("ticker_font_size_px"), min_val=10, max_val=200)
company.ticker_speed = _normalize_int(request.form.get("ticker_speed"), min_val=1, max_val=100)
db.session.commit()
flash("Ticker settings updated.", "success")
return redirect(url_for("company.my_company"))
@bp.post("/my-company/overlay")
@login_required
def upload_company_overlay():
"""Upload/replace the per-company 16:9 PNG overlay."""
company_user_required()
company = db.session.get(Company, current_user.company_id)
if not company:
abort(404)
f = request.files.get("overlay")
if not f or not f.filename:
flash("Overlay file is required", "danger")
return redirect(url_for("company.my_company"))
filename = secure_filename(f.filename)
ext = os.path.splitext(filename)[1].lower()
if ext not in ALLOWED_OVERLAY_EXTENSIONS:
flash("Unsupported overlay type. Please upload a PNG file.", "danger")
return redirect(url_for("company.my_company"))
# Enforce size limit best-effort.
size = None
try:
size = getattr(f, "content_length", None)
if (size is None or size <= 0) and hasattr(f, "stream"):
pos = f.stream.tell()
f.stream.seek(0, os.SEEK_END)
size = f.stream.tell()
f.stream.seek(pos, os.SEEK_SET)
except Exception:
size = None
if size is not None and size > MAX_OVERLAY_BYTES:
flash("Overlay file too large. Maximum allowed size is 10MB.", "danger")
return redirect(url_for("company.my_company"))
# Enforce storage quota too (overlay is stored in the same uploads folder).
upload_root = current_app.config["UPLOAD_FOLDER"]
used_bytes = get_company_upload_bytes(upload_root, company.id)
usage = compute_storage_usage(used_bytes=used_bytes, max_bytes=company.storage_max_bytes)
storage_max_human = _format_bytes(usage["max_bytes"]) if usage.get("max_bytes") else None
if usage.get("is_exceeded"):
flash(_storage_limit_error_message(storage_max_human=storage_max_human), "danger")
return redirect(url_for("company.my_company"))
old_path = company.overlay_file_path
try:
new_relpath = _save_overlay_png(f, upload_root, company.id)
except ValueError as e:
code = str(e)
if code == "not_png":
flash("Overlay must be a PNG file.", "danger")
elif code == "not_16_9":
flash("Overlay should be 16:9 (landscape).", "danger")
else:
flash("Failed to process overlay upload.", "danger")
return redirect(url_for("company.my_company"))
except Exception:
flash("Failed to process overlay upload.", "danger")
return redirect(url_for("company.my_company"))
# Post-save quota check (like images) because PNG size is unknown until saved.
if company.storage_max_bytes is not None and int(company.storage_max_bytes or 0) > 0:
try:
used_after = get_company_upload_bytes(upload_root, company.id)
except Exception:
used_after = None
if used_after is not None:
usage_after = compute_storage_usage(used_bytes=used_after, max_bytes=company.storage_max_bytes)
if usage_after.get("is_exceeded"):
_try_delete_upload(new_relpath, upload_root)
flash(_storage_limit_error_message(storage_max_human=storage_max_human), "danger")
return redirect(url_for("company.my_company"))
company.overlay_file_path = new_relpath
db.session.commit()
# Clean up the old overlay file.
if old_path and old_path != new_relpath:
_try_delete_upload(old_path, upload_root)
flash("Overlay updated.", "success")
return redirect(url_for("company.my_company"))
@bp.post("/my-company/overlay/delete")
@login_required
def delete_company_overlay():
company_user_required()
company = db.session.get(Company, current_user.company_id)
if not company:
abort(404)
upload_root = current_app.config["UPLOAD_FOLDER"]
old_path = company.overlay_file_path
company.overlay_file_path = None
db.session.commit()
_try_delete_upload(old_path, upload_root)
flash("Overlay removed.", "success")
return redirect(url_for("company.my_company"))
@bp.post("/my-company/invite") @bp.post("/my-company/invite")
@login_required @login_required
def invite_user(): def invite_user():
@@ -859,6 +1114,149 @@ def add_playlist_item(playlist_id: int):
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
@bp.post("/playlists/<int:playlist_id>/items/bulk-images")
@login_required
def bulk_upload_playlist_images(playlist_id: int):
"""Bulk upload multiple images to a playlist.
Expects multipart/form-data:
- files: multiple image files
- crop_mode: "16:9" or "9:16" (optional; defaults to 16:9)
- duration_seconds: optional (defaults to 10)
Returns JSON:
{ ok: true, items: [...] }
"""
company_user_required()
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
abort(404)
# This endpoint is intended for AJAX.
def _json_error(message: str, status: int = 400):
return jsonify({"ok": False, "error": message}), status
files = request.files.getlist("files")
if not files:
return _json_error("No files uploaded")
crop_mode = (request.form.get("crop_mode") or "16:9").strip().lower()
if crop_mode not in {"16:9", "9:16"}:
crop_mode = "16:9"
raw_duration = request.form.get("duration_seconds")
try:
duration = int(raw_duration) if raw_duration is not None else 10
except (TypeError, ValueError):
duration = 10
duration = max(1, duration)
# Quota check before processing.
with db.session.no_autoflush:
company = db.session.get(Company, current_user.company_id)
if not company:
abort(404)
upload_root = current_app.config["UPLOAD_FOLDER"]
used_bytes = get_company_upload_bytes(upload_root, company.id)
usage = compute_storage_usage(used_bytes=used_bytes, max_bytes=company.storage_max_bytes)
storage_max_human = _format_bytes(usage["max_bytes"]) if usage.get("max_bytes") else None
if usage.get("is_exceeded"):
return _json_error(_storage_limit_error_message(storage_max_human=storage_max_human), 403)
# Determine starting position.
max_pos = (
db.session.query(db.func.max(PlaylistItem.position)).filter_by(playlist_id=playlist_id).scalar() or 0
)
saved_relpaths: list[str] = []
items: list[PlaylistItem] = []
try:
for idx, f in enumerate(files, start=1):
if not f or not f.filename:
raise ValueError("file_required")
filename = secure_filename(f.filename)
ext = os.path.splitext(filename)[1].lower()
if ext not in ALLOWED_IMAGE_EXTENSIONS:
raise ValueError("unsupported")
relpath = _save_compressed_image(
f,
current_app.config["UPLOAD_FOLDER"],
current_user.company_id,
crop_mode=crop_mode,
)
saved_relpaths.append(relpath)
it = PlaylistItem(
playlist=playlist,
item_type="image",
title=None,
duration_seconds=duration,
position=max_pos + idx,
file_path=relpath,
)
items.append(it)
# Post-save quota check (like single image uploads)
if company.storage_max_bytes is not None and int(company.storage_max_bytes or 0) > 0:
used_after = get_company_upload_bytes(upload_root, company.id)
usage_after = compute_storage_usage(used_bytes=used_after, max_bytes=company.storage_max_bytes)
if usage_after.get("is_exceeded"):
# Remove all newly saved files and reject.
for p in saved_relpaths:
_try_delete_upload(p, upload_root)
return _json_error(_storage_limit_error_message(storage_max_human=storage_max_human), 403)
for it in items:
db.session.add(it)
db.session.commit()
except ValueError as e:
# Clean up any saved files.
upload_root = current_app.config["UPLOAD_FOLDER"]
for p in saved_relpaths:
_try_delete_upload(p, upload_root)
code = str(e)
if code == "unsupported":
return _json_error(
"Unsupported image type. Please upload one of: " + ", ".join(sorted(ALLOWED_IMAGE_EXTENSIONS))
)
if code == "file_required":
return _json_error("File required")
return _json_error("Failed to process image upload", 500)
except Exception:
db.session.rollback()
upload_root = current_app.config["UPLOAD_FOLDER"]
for p in saved_relpaths:
_try_delete_upload(p, upload_root)
return _json_error("Failed to process image upload", 500)
return jsonify(
{
"ok": True,
"items": [
{
"id": it.id,
"playlist_id": it.playlist_id,
"position": it.position,
"item_type": it.item_type,
"title": it.title,
"file_path": it.file_path,
"url": it.url,
"duration_seconds": it.duration_seconds,
}
for it in items
],
}
)
@bp.post("/items/<int:item_id>/delete") @bp.post("/items/<int:item_id>/delete")
@login_required @login_required
def delete_item(item_id: int): def delete_item(item_id: int):
@@ -975,6 +1373,64 @@ def update_display(display_id: int):
return None return None
return v return v
def _normalize_css_color(val: str | None) -> str | None:
"""Accept a limited set of CSS color inputs (primarily hex + a few keywords).
This is used to avoid storing arbitrary CSS strings while still being user friendly.
"""
v = (val or "").strip()
if not v:
return None
low = v.lower()
if low in {"white", "black", "red", "green", "blue", "yellow", "orange", "purple", "gray", "grey"}:
return low
# Hex colors: #RGB, #RRGGBB, #RRGGBBAA
if low.startswith("#"):
h = low[1:]
if len(h) in {3, 6, 8} and all(c in "0123456789abcdef" for c in h):
return "#" + h
return None
def _normalize_percent(val) -> int | None:
if val in (None, ""):
return None
try:
n = int(val)
except (TypeError, ValueError):
return None
return min(100, max(0, n))
def _normalize_speed(val) -> int | None:
if val in (None, ""):
return None
try:
n = int(val)
except (TypeError, ValueError):
return None
return min(100, max(1, n))
def _normalize_font_family(val: str | None) -> str | None:
v = (val or "").strip()
if not v:
return None
# keep it short and avoid quotes/newlines that could be abused in CSS.
v = v.replace("\n", " ").replace("\r", " ").replace('"', "").replace("'", "")
v = " ".join(v.split())
return v[:120] if v else None
def _normalize_font_size_px(val) -> int | None:
if val in (None, ""):
return None
try:
n = int(val)
except (TypeError, ValueError):
return None
# reasonable bounds for signage displays
return min(200, max(10, n))
# Inputs from either form or JSON # Inputs from either form or JSON
payload = request.get_json(silent=True) if request.is_json else None payload = request.get_json(silent=True) if request.is_json else None
@@ -1004,6 +1460,46 @@ def update_display(display_id: int):
# Form POST implies full update # Form POST implies full update
display.transition = _normalize_transition(request.form.get("transition")) display.transition = _normalize_transition(request.form.get("transition"))
# Overlay toggle
if request.is_json:
if payload is None:
return _json_error("Invalid JSON")
if "show_overlay" in payload:
raw = payload.get("show_overlay")
# Accept common truthy representations.
if isinstance(raw, bool):
display.show_overlay = raw
elif raw in (1, 0):
display.show_overlay = bool(raw)
else:
s = ("" if raw is None else str(raw)).strip().lower()
display.show_overlay = s in {"1", "true", "yes", "on"}
else:
# Form POST implies full update
raw = request.form.get("show_overlay")
if raw is not None:
display.show_overlay = (raw or "").strip().lower() in {"1", "true", "yes", "on"}
# Ticker tape: per-display enable/disable only.
# RSS + styling is configured per-company (see /my-company).
if request.is_json:
if payload is None:
return _json_error("Invalid JSON")
if "ticker_enabled" in payload:
raw = payload.get("ticker_enabled")
if isinstance(raw, bool):
display.ticker_enabled = raw
elif raw in (1, 0):
display.ticker_enabled = bool(raw)
else:
s = ("" if raw is None else str(raw)).strip().lower()
display.ticker_enabled = s in {"1", "true", "yes", "on"}
else:
raw = request.form.get("ticker_enabled")
if raw is not None:
display.ticker_enabled = (raw or "").strip().lower() in {"1", "true", "yes", "on"}
# Playlist assignment # Playlist assignment
if request.is_json: if request.is_json:
if "playlist_id" in payload: if "playlist_id" in payload:
@@ -1044,6 +1540,15 @@ def update_display(display_id: int):
"name": display.name, "name": display.name,
"description": display.description, "description": display.description,
"transition": display.transition, "transition": display.transition,
"show_overlay": bool(display.show_overlay),
"ticker_enabled": bool(display.ticker_enabled),
"ticker_rss_url": None,
"ticker_color": None,
"ticker_bg_color": None,
"ticker_bg_opacity": None,
"ticker_font_family": None,
"ticker_font_size_px": None,
"ticker_speed": None,
"assigned_playlist_id": display.assigned_playlist_id, "assigned_playlist_id": display.assigned_playlist_id,
}, },
} }

View File

@@ -1,6 +1,7 @@
from flask import Blueprint, abort, render_template from flask import Blueprint, abort, render_template, url_for
from ..models import Display from ..models import Company, Display
from ..uploads import is_valid_upload_relpath
bp = Blueprint("display", __name__, url_prefix="/display") bp = Blueprint("display", __name__, url_prefix="/display")
@@ -10,4 +11,11 @@ def display_player(token: str):
display = Display.query.filter_by(token=token).first() display = Display.query.filter_by(token=token).first()
if not display: if not display:
abort(404) abort(404)
return render_template("display/player.html", display=display)
overlay_url = None
if display.show_overlay:
company = Company.query.filter_by(id=display.company_id).first()
if company and company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path):
overlay_url = url_for("static", filename=company.overlay_file_path)
return render_template("display/player.html", display=display, overlay_url=overlay_url)

View File

@@ -280,6 +280,13 @@ h1, h2, h3, .display-1, .display-2, .display-3 {
background: #000; background: #000;
} }
/* Mobile: dashboard display previews are heavy (iframes). Hide them on small screens. */
@media (max-width: 768px) {
.display-gallery-card .display-preview {
display: none;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.display-gallery-grid { .display-gallery-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -307,3 +314,9 @@ h1, h2, h3, .display-1, .display-2, .display-3 {
.schedule-status-dot.inactive { .schedule-status-dot.inactive {
background: #dc3545; background: #dc3545;
} }
/* Dropzone disabled state (used by bulk upload) */
.dropzone.disabled {
opacity: 0.6;
pointer-events: none;
}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<h1 class="page-title">Welcome{% if current_user and current_user.email %}, {{ current_user.email }}{% endif %}!</h1> <h1 class="page-title">Dashboard</h1>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12"> <div class="col-12">
@@ -69,14 +69,21 @@
<div class="col-12 col-md-6 col-xl-4"> <div class="col-12 col-md-6 col-xl-4">
<div class="card display-gallery-card h-100"> <div class="card display-gallery-card h-100">
<div class="display-preview"> <div class="display-preview">
<div
class="display-preview-scale"
style="width: 1000%; height: 1000%; transform: scale(0.1); transform-origin: top left;"
>
<iframe <iframe
title="Preview — {{ d.name }}" title="Preview — {{ d.name }}"
data-display-id="{{ d.id }}" data-display-id="{{ d.id }}"
src="{{ url_for('display.display_player', token=d.token) }}?preview=1" class="js-display-preview"
data-preview-src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
loading="lazy" loading="lazy"
referrerpolicy="no-referrer" referrerpolicy="no-referrer"
style="width: 100%; height: 100%; border: 0;"
></iframe> ></iframe>
</div> </div>
</div>
<div class="card-body d-flex flex-column gap-2"> <div class="card-body d-flex flex-column gap-2">
<div> <div>
@@ -96,6 +103,8 @@
data-display-name="{{ d.name }}" data-display-name="{{ d.name }}"
data-current-desc="{{ d.description or '' }}" data-current-desc="{{ d.description or '' }}"
data-current-transition="{{ d.transition or 'none' }}" data-current-transition="{{ d.transition or 'none' }}"
data-current-show-overlay="{{ '1' if d.show_overlay else '0' }}"
data-current-ticker-enabled="{{ '1' if d.ticker_enabled else '0' }}"
data-legacy-playlist-id="{{ d.assigned_playlist_id or '' }}" data-legacy-playlist-id="{{ d.assigned_playlist_id or '' }}"
data-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}" data-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}"
> >
@@ -157,6 +166,22 @@
</select> </select>
<div class="form-text">Applied on the display when switching between playlist items.</div> <div class="form-text">Applied on the display when switching between playlist items.</div>
</div> </div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="editPlaylistsShowOverlayCheck" />
<label class="form-check-label" for="editPlaylistsShowOverlayCheck">Show company overlay</label>
<div class="form-text">If your company has an overlay uploaded, it will be displayed on top of the content.</div>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="editTickerEnabled" />
<label class="form-check-label" for="editTickerEnabled">
Enable ticker tape on this display
</label>
<div class="form-text">
RSS feed + styling is configured in <a href="{{ url_for('company.my_company') }}">My Company</a>.
</div>
</div>
<hr class="my-3" /> <hr class="my-3" />
<div class="text-muted small mb-2">Tick the playlists that should be active on this display.</div> <div class="text-muted small mb-2">Tick the playlists that should be active on this display.</div>
<div id="editPlaylistsList" class="d-flex flex-column gap-2"></div> <div id="editPlaylistsList" class="d-flex flex-column gap-2"></div>
@@ -217,17 +242,23 @@
function refreshPreviewIframe(displayId) { function refreshPreviewIframe(displayId) {
const iframe = document.querySelector(`iframe[data-display-id="${displayId}"]`); const iframe = document.querySelector(`iframe[data-display-id="${displayId}"]`);
if (!iframe || !iframe.src) return; // Previews are disabled on mobile.
if (window.matchMedia && window.matchMedia('(max-width: 768px)').matches) return;
if (!iframe) return;
try { try {
const u = new URL(iframe.src, window.location.origin); const baseSrc = iframe.dataset.previewSrc || iframe.src;
if (!baseSrc) return;
const u = new URL(baseSrc, window.location.origin);
// Ensure preview flag is present (and bust cache). // Ensure preview flag is present (and bust cache).
u.searchParams.set('preview', '1'); u.searchParams.set('preview', '1');
u.searchParams.set('_ts', String(Date.now())); u.searchParams.set('_ts', String(Date.now()));
iframe.src = u.toString(); iframe.src = u.toString();
} catch (e) { } catch (e) {
// Fallback: naive cache buster // Fallback: naive cache buster
const sep = iframe.src.includes('?') ? '&' : '?'; const baseSrc = iframe.dataset.previewSrc || iframe.src;
iframe.src = `${iframe.src}${sep}_ts=${Date.now()}`; if (!baseSrc) return;
const sep = baseSrc.includes('?') ? '&' : '?';
iframe.src = `${baseSrc}${sep}_ts=${Date.now()}`;
} }
} }
@@ -301,6 +332,8 @@
const plDescInputEl = document.getElementById('editPlaylistsDescInput'); const plDescInputEl = document.getElementById('editPlaylistsDescInput');
const plDescCountEl = document.getElementById('editPlaylistsDescCount'); const plDescCountEl = document.getElementById('editPlaylistsDescCount');
const plTransitionEl = document.getElementById('editPlaylistsTransitionSelect'); const plTransitionEl = document.getElementById('editPlaylistsTransitionSelect');
const plShowOverlayEl = document.getElementById('editPlaylistsShowOverlayCheck');
const tickerEnabledEl = document.getElementById('editTickerEnabled');
let activePlDisplayId = null; let activePlDisplayId = null;
let activePlButton = null; let activePlButton = null;
@@ -365,6 +398,16 @@
const currentTransition = (btn.dataset.currentTransition || 'none').toLowerCase(); const currentTransition = (btn.dataset.currentTransition || 'none').toLowerCase();
if (plTransitionEl) plTransitionEl.value = ['none','fade','slide'].includes(currentTransition) ? currentTransition : 'none'; if (plTransitionEl) plTransitionEl.value = ['none','fade','slide'].includes(currentTransition) ? currentTransition : 'none';
if (plShowOverlayEl) {
const raw = (btn.dataset.currentShowOverlay || '').toLowerCase();
plShowOverlayEl.checked = raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
if (tickerEnabledEl) {
const raw = (btn.dataset.currentTickerEnabled || '').toLowerCase();
tickerEnabledEl.checked = raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
const selected = computeActiveIdsFromDataset(btn); const selected = computeActiveIdsFromDataset(btn);
renderPlaylistCheckboxes(selected); renderPlaylistCheckboxes(selected);
if (plHintEl) { if (plHintEl) {
@@ -379,11 +422,18 @@
const ids = getSelectedPlaylistIdsFromModal(); const ids = getSelectedPlaylistIdsFromModal();
const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : ''; const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : '';
const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none'; const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none';
const showOverlay = plShowOverlayEl ? !!plShowOverlayEl.checked : false;
const tickerEnabled = tickerEnabledEl ? !!tickerEnabledEl.checked : false;
plSaveBtn.disabled = true; plSaveBtn.disabled = true;
try { try {
const [updatedPlaylists, updatedDesc] = await Promise.all([ const [updatedPlaylists, updatedDesc] = await Promise.all([
postDisplayPlaylists(activePlDisplayId, ids), postDisplayPlaylists(activePlDisplayId, ids),
postDisplayUpdate(activePlDisplayId, { description: desc, transition }) postDisplayUpdate(activePlDisplayId, {
description: desc,
transition,
show_overlay: showOverlay,
ticker_enabled: tickerEnabled
})
]); ]);
const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids) const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids)
@@ -401,6 +451,16 @@
const newTransition = updatedDesc && typeof updatedDesc.transition === 'string' ? updatedDesc.transition : transition; const newTransition = updatedDesc && typeof updatedDesc.transition === 'string' ? updatedDesc.transition : transition;
activePlButton.dataset.currentTransition = newTransition || 'none'; activePlButton.dataset.currentTransition = newTransition || 'none';
const newShowOverlay = updatedDesc && typeof updatedDesc.show_overlay !== 'undefined'
? !!updatedDesc.show_overlay
: showOverlay;
activePlButton.dataset.currentShowOverlay = newShowOverlay ? '1' : '0';
const newTickerEnabled = updatedDesc && typeof updatedDesc.ticker_enabled !== 'undefined'
? !!updatedDesc.ticker_enabled
: tickerEnabled;
activePlButton.dataset.currentTickerEnabled = newTickerEnabled ? '1' : '0';
showToast('Display updated', 'text-bg-success'); showToast('Display updated', 'text-bg-success');
refreshPreviewIframe(activePlDisplayId); refreshPreviewIframe(activePlDisplayId);
if (plModal) plModal.hide(); if (plModal) plModal.hide();
@@ -414,6 +474,31 @@
if (plSaveBtn) { if (plSaveBtn) {
plSaveBtn.addEventListener('click', savePlaylists); plSaveBtn.addEventListener('click', savePlaylists);
} }
// Disable dashboard previews on small screens (mobile): don't even set iframe src.
function loadDashboardPreviewsIfDesktop() {
const isMobile = window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
if (isMobile) return;
document.querySelectorAll('iframe.js-display-preview[data-preview-src]').forEach((iframe) => {
if (!iframe.src || iframe.src === 'about:blank') {
iframe.src = iframe.dataset.previewSrc;
}
});
}
loadDashboardPreviewsIfDesktop();
// If user rotates/resizes from mobile -> desktop, load previews then.
if (window.matchMedia) {
const mql = window.matchMedia('(max-width: 768px)');
const onChange = () => {
if (!mql.matches) loadDashboardPreviewsIfDesktop();
};
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', onChange);
} else if (typeof mql.addListener === 'function') {
mql.addListener(onChange);
}
}
})(); })();
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -137,5 +137,178 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<div class="card card-elevated mt-4">
<div class="card-header">
<h2 class="h5 mb-0">Overlay</h2>
</div>
<div class="card-body">
<div class="text-muted small mb-3">
Upload a <strong>16:9 PNG</strong> overlay. It will be rendered on top of the display content.
Transparent areas will show the content underneath.
</div>
{% if overlay_url %}
<div class="mb-3">
<div class="text-muted small mb-2">Current overlay:</div>
<div style="max-width: 520px; border: 1px solid rgba(0,0,0,0.15); border-radius: 8px; overflow: hidden;">
<img
src="{{ overlay_url }}"
alt="Company overlay"
style="display:block; width:100%; height:auto; background: repeating-linear-gradient(45deg, #eee 0 12px, #fff 12px 24px);"
/>
</div>
</div>
{% else %}
<div class="text-muted mb-3">No overlay uploaded.</div>
{% endif %}
<form method="post" action="{{ url_for('company.upload_company_overlay') }}" enctype="multipart/form-data" class="d-flex gap-2 flex-wrap align-items-end">
<div>
<label class="form-label">Upload overlay (PNG)</label>
<input class="form-control" type="file" name="overlay" accept="image/png" required />
<div class="form-text">Tip: export at 1920×1080 (or any 16:9 size).</div>
</div>
<div>
<button class="btn btn-brand" type="submit">Save overlay</button>
</div>
</form>
{% if overlay_url %}
<form method="post" action="{{ url_for('company.delete_company_overlay') }}" class="mt-3" onsubmit="return confirm('Remove the overlay?');">
<button class="btn btn-outline-danger" type="submit">Remove overlay</button>
</form>
{% endif %}
</div>
</div>
<div class="card card-elevated mt-4">
<div class="card-header">
<h2 class="h5 mb-0">Ticker tape (RSS)</h2>
</div>
<div class="card-body">
<div class="text-muted small mb-3">
Configure the RSS feed and styling for the ticker tape. Individual displays can enable/disable the ticker from the Dashboard.
</div>
<form method="post" action="{{ url_for('company.update_company_ticker_settings') }}" class="d-flex flex-column gap-3">
<div>
<label class="form-label" for="companyTickerRssUrl">RSS feed URL</label>
<input
class="form-control"
id="companyTickerRssUrl"
name="ticker_rss_url"
type="url"
value="{{ company.ticker_rss_url or '' }}"
placeholder="https://example.com/feed.xml"
/>
<div class="form-text">Leave empty to disable headlines (even if displays have ticker enabled).</div>
</div>
<div class="row g-2">
<div class="col-12 col-md-4">
<label class="form-label" for="companyTickerColor">Text color</label>
<input
class="form-control form-control-color"
id="companyTickerColor"
name="ticker_color"
type="color"
value="{{ company.ticker_color or '#ffffff' }}"
title="Choose text color"
/>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="companyTickerBgColor">Background color</label>
<input
class="form-control form-control-color"
id="companyTickerBgColor"
name="ticker_bg_color"
type="color"
value="{{ company.ticker_bg_color or '#000000' }}"
title="Choose background color"
/>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="companyTickerBgOpacity">Background opacity</label>
<input
class="form-range"
id="companyTickerBgOpacity"
name="ticker_bg_opacity"
type="range"
min="0"
max="100"
step="1"
value="{{ company.ticker_bg_opacity if company.ticker_bg_opacity is not none else 75 }}"
/>
<div class="form-text"><span id="companyTickerBgOpacityLabel">{{ company.ticker_bg_opacity if company.ticker_bg_opacity is not none else 75 }}</span>%</div>
</div>
</div>
<div class="row g-2">
<div class="col-12 col-md-6">
<label class="form-label" for="companyTickerFontFamily">Font</label>
<select class="form-select" id="companyTickerFontFamily" name="ticker_font_family">
{% set ff = company.ticker_font_family or 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif' %}
<option value="system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" {{ 'selected' if ff == 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif' else '' }}>System (default)</option>
<option value="Arial, Helvetica, sans-serif" {{ 'selected' if ff == 'Arial, Helvetica, sans-serif' else '' }}>Arial</option>
<option value="Segoe UI, Arial, sans-serif" {{ 'selected' if ff == 'Segoe UI, Arial, sans-serif' else '' }}>Segoe UI</option>
<option value="Roboto, Arial, sans-serif" {{ 'selected' if ff == 'Roboto, Arial, sans-serif' else '' }}>Roboto</option>
<option value="Georgia, serif" {{ 'selected' if ff == 'Georgia, serif' else '' }}>Georgia</option>
<option value="Times New Roman, Times, serif" {{ 'selected' if ff == 'Times New Roman, Times, serif' else '' }}>Times New Roman</option>
<option value="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace" {{ 'selected' if ff == 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace' else '' }}>Monospace</option>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label" for="companyTickerFontSize">Font size (px)</label>
<input
class="form-control"
id="companyTickerFontSize"
name="ticker_font_size_px"
type="number"
min="10"
max="200"
step="1"
value="{{ company.ticker_font_size_px if company.ticker_font_size_px is not none else 28 }}"
/>
</div>
<div class="col-12 col-md-3">
<label class="form-label" for="companyTickerSpeed">Speed</label>
<input
class="form-range"
id="companyTickerSpeed"
name="ticker_speed"
type="range"
min="1"
max="100"
step="1"
value="{{ company.ticker_speed if company.ticker_speed is not none else 25 }}"
/>
<div class="form-text">Slower ⟷ Faster</div>
</div>
</div>
<div>
<button class="btn btn-brand" type="submit">Save ticker settings</button>
</div>
</form>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block page_scripts %}
<script>
(function () {
const opacityEl = document.getElementById('companyTickerBgOpacity');
const opacityLabelEl = document.getElementById('companyTickerBgOpacityLabel');
function syncOpacity() {
if (!opacityEl || !opacityLabelEl) return;
opacityLabelEl.textContent = String(opacityEl.value || '0');
}
if (opacityEl) opacityEl.addEventListener('input', syncOpacity);
syncOpacity();
})();
</script>
{% endblock %}

View File

@@ -1,5 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
{# Expose server-side crop target sizes to the JS without embedding Jinja inside JS #}
<div
id="page-config"
class="d-none"
data-image-crop-target-w="{{ config.get('IMAGE_CROP_TARGET_W', 1920) }}"
data-image-crop-target-h="{{ config.get('IMAGE_CROP_TARGET_H', 1080) }}"
></div>
{# Cropper.js (used for image cropping) #} {# Cropper.js (used for image cropping) #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.css" />
<style> <style>
@@ -230,8 +237,11 @@
<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>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" type="button" id="open-bulk-upload">Bulk upload</button>
<button class="btn btn-brand" 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>
<div class="mt-3"> <div class="mt-3">
<div class="small text-muted mb-2" id="reorder-status" aria-live="polite"></div> <div class="small text-muted mb-2" id="reorder-status" aria-live="polite"></div>
@@ -319,46 +329,33 @@
<input type="hidden" name="item_type" id="item_type" value="image" /> <input type="hidden" name="item_type" id="item_type" value="image" />
<input type="hidden" name="crop_mode" id="crop_mode" value="16:9" /> <input type="hidden" name="crop_mode" id="crop_mode" value="16:9" />
{# Step 1: pick type, then show relevant inputs #}
<div id="step-type" class="step active">
<div class="text-muted small mb-2">Select a slide type.</div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label">Title (optional)</label> <div class="btn-group w-100" role="group" aria-label="Slide type">
<input class="form-control" name="title" />
</div>
<div class="mb-2" id="duration-group">
<label class="form-label">Duration (seconds, for images/webpages/YouTube)</label>
<input class="form-control" type="number" name="duration_seconds" value="10" min="1" />
</div>
<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> <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> <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"> <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> <label class="btn btn-outline-primary" for="type-webpage">Webpage</label>
<input type="radio" class="btn-check" name="item_type_choice" id="type-youtube" autocomplete="off">
<label class="btn btn-outline-primary" for="type-youtube">YouTube</label>
<input type="radio" class="btn-check" name="item_type_choice" id="type-video" autocomplete="off"> <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> <label class="btn btn-outline-primary" for="type-video">Video</label>
</div> </div>
</div> </div>
<div class="mb-3" id="crop-mode-group">
<label class="form-label">Image crop</label>
<div class="btn-group w-100" role="group" aria-label="Crop mode">
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-16-9" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="crop-16-9">16:9 (landscape)</label>
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-9-16" autocomplete="off">
<label class="btn btn-outline-primary" for="crop-9-16">9:16 (portrait)</label>
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-none" autocomplete="off">
<label class="btn btn-outline-primary" for="crop-none">No crop</label>
</div> </div>
<div class="text-muted small mt-1">Cropping is optional. If enabled, we center-crop to the chosen aspect ratio.</div>
{# Step 2 (only for video): choose upload vs YouTube #}
<div id="step-video-source" class="step">
<div class="text-muted small mb-2">Choose how you want to add the video.</div>
<div class="btn-group w-100" role="group" aria-label="Video source">
<input type="radio" class="btn-check" name="video_source_choice" id="video-source-upload" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="video-source-upload">Upload video</label>
<input type="radio" class="btn-check" name="video_source_choice" id="video-source-youtube" autocomplete="off">
<label class="btn btn-outline-primary" for="video-source-youtube">YouTube</label>
</div>
</div> </div>
<style> <style>
@@ -393,8 +390,33 @@
} }
</style> </style>
<div id="step-select" class="step active"> <div id="step-input" class="step">
<div class="text-muted small mb-2">Select or upload your media. If you upload an image, youll crop it next.</div> <div class="text-muted small mb-2" id="step-input-hint">Fill in the details for the new slide.</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/YouTube)</label>
<input class="form-control" type="number" name="duration_seconds" value="10" min="1" />
</div>
<div class="mb-3" id="crop-mode-group">
<label class="form-label">Image crop</label>
<div class="btn-group w-100" role="group" aria-label="Crop mode">
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-16-9" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="crop-16-9">16:9 (landscape)</label>
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-9-16" autocomplete="off">
<label class="btn btn-outline-primary" for="crop-9-16">9:16 (portrait)</label>
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-none" autocomplete="off">
<label class="btn btn-outline-primary" for="crop-none">No crop</label>
</div>
<div class="text-muted small mt-1">Cropping is optional. If enabled, we center-crop to the chosen aspect ratio.</div>
</div>
{# Image section #} {# Image section #}
<div id="section-image" class="item-type-section"> <div id="section-image" class="item-type-section">
@@ -433,7 +455,7 @@
</div> </div>
</div> </div>
{# YouTube section #} {# YouTube section (also used as video source) #}
<div id="section-youtube" class="item-type-section d-none"> <div id="section-youtube" class="item-type-section d-none">
<div class="mb-2"> <div class="mb-2">
<label class="form-label">YouTube URL</label> <label class="form-label">YouTube URL</label>
@@ -445,9 +467,9 @@
<div class="text-muted small">Tip: set a duration; YouTube embeds will advance after that time.</div> <div class="text-muted small">Tip: set a duration; YouTube embeds will advance after that time.</div>
</div> </div>
{# Video section #} {# Video upload section #}
<div id="section-video" class="item-type-section d-none"> <div id="section-video" class="item-type-section d-none">
<label class="form-label">Video</label> <label class="form-label">Video upload</label>
<div id="video-dropzone" class="dropzone mb-2"> <div id="video-dropzone" class="dropzone mb-2">
<div><strong>Drag & drop</strong> a video here</div> <div><strong>Drag & drop</strong> a video here</div>
<div class="text-muted small">or click to select a file</div> <div class="text-muted small">or click to select a file</div>
@@ -494,6 +516,65 @@
</div> </div>
</div> </div>
{# Bulk upload images modal #}
<div class="modal fade" id="bulkUploadModal" tabindex="-1" aria-labelledby="bulkUploadModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bulkUploadModalLabel">Bulk upload images</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="text-muted small mb-2">Choose an aspect ratio. Images will be center-cropped automatically.</div>
<div class="mb-3">
<div class="btn-group w-100" role="group" aria-label="Bulk crop mode">
<input type="radio" class="btn-check" name="bulk_crop_mode_choice" id="bulk-crop-16-9" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="bulk-crop-16-9">16:9 (landscape)</label>
<input type="radio" class="btn-check" name="bulk_crop_mode_choice" id="bulk-crop-9-16" autocomplete="off">
<label class="btn btn-outline-primary" for="bulk-crop-9-16">9:16 (portrait)</label>
</div>
</div>
<div id="bulk-dropzone" class="dropzone">
<div><strong>Drag & drop</strong> multiple images here</div>
<div class="text-muted small">or click to select files</div>
</div>
<input id="bulk-file-input" class="form-control d-none" type="file" accept="image/*" multiple />
<div class="mt-3">
<div class="small text-muted" id="bulk-status" aria-live="polite"></div>
<div id="bulk-upload-progress" class="d-none mt-2" aria-live="polite">
<div class="progress" style="height: 10px;">
<div
id="bulk-upload-progress-bar"
class="progress-bar"
role="progressbar"
style="width: 0%"
aria-valuenow="0"
aria-valuemin="0"
aria-valuemax="100"
></div>
</div>
<div class="text-muted small mt-1" id="bulk-upload-progress-text">Uploading…</div>
</div>
<ul class="list-group mt-2" id="bulk-file-list"></ul>
</div>
</div>
<div class="modal-footer">
<div class="small text-danger me-auto" id="bulk-error" aria-live="polite"></div>
<button type="button" class="btn btn-outline-ink" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-brand" id="bulk-upload-submit" disabled>Upload</button>
</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="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.js"></script>
@@ -636,53 +717,87 @@
const sectionYoutube = document.getElementById('section-youtube'); const sectionYoutube = document.getElementById('section-youtube');
const sectionVideo = document.getElementById('section-video'); const sectionVideo = document.getElementById('section-video');
const stepSelect = document.getElementById('step-select'); const stepType = document.getElementById('step-type');
const stepVideoSource = document.getElementById('step-video-source');
const stepInput = document.getElementById('step-input');
const stepCrop = document.getElementById('step-crop'); const stepCrop = document.getElementById('step-crop');
const backBtn = document.getElementById('add-item-back'); const backBtn = document.getElementById('add-item-back');
const errorEl = document.getElementById('add-item-error'); const errorEl = document.getElementById('add-item-error');
const stepInputHint = document.getElementById('step-input-hint');
function setError(msg) { function setError(msg) {
if (!errorEl) return; if (!errorEl) return;
errorEl.textContent = (msg || '').trim(); errorEl.textContent = (msg || '').trim();
} }
let currentStep = 'type';
function updatePrimaryButton() {
// Primary button acts as Next in early steps, and Add in the final steps.
if (!submitBtn) return;
const isNextStep = (currentStep === 'type' || currentStep === 'video-source');
submitBtn.textContent = isNextStep ? 'Next' : 'Add';
}
function showStep(which) { function showStep(which) {
stepSelect?.classList.toggle('active', which === 'select'); currentStep = which;
stepType?.classList.toggle('active', which === 'type');
stepVideoSource?.classList.toggle('active', which === 'video-source');
stepInput?.classList.toggle('active', which === 'input');
stepCrop?.classList.toggle('active', which === 'crop'); stepCrop?.classList.toggle('active', which === 'crop');
const isCrop = which === 'crop'; // Back is enabled for all steps except the first.
backBtn.disabled = !isCrop; backBtn.disabled = (which === 'type');
// For image: allow Add only in crop step (so we always crop if image) // Enable Next for the initial steps.
if (typeHidden.value === 'image') { if (which === 'type' || which === 'video-source') {
submitBtn.disabled = !isCrop; submitBtn.disabled = false;
updatePrimaryButton();
return;
} }
// For input/crop steps: image requires crop step before enabling Add.
if (typeHidden.value === 'image') {
submitBtn.disabled = (which !== 'crop');
} else {
submitBtn.disabled = false;
}
updatePrimaryButton();
}
function videoSource() {
return document.getElementById('video-source-youtube')?.checked ? 'youtube' : 'upload';
} }
function setType(t) { function setType(t) {
// For this UI, "video" is a top-level type, but it can map to item_type=video OR item_type=youtube.
typeHidden.value = t; typeHidden.value = t;
setError(''); setError('');
sectionImage.classList.toggle('d-none', t !== 'image');
sectionWebpage.classList.toggle('d-none', t !== 'webpage');
sectionYoutube.classList.toggle('d-none', t !== 'youtube');
sectionVideo.classList.toggle('d-none', t !== 'video');
// duration applies to image/webpage/youtube. Video plays until ended.
durationGroup.classList.toggle('d-none', t === 'video');
cropModeGroup?.classList.toggle('d-none', t !== 'image');
submitBtn.disabled = false;
submitBtn.title = '';
if (t !== 'image') { // Visible section is decided by type + (video source)
destroyCropper(); const vs = (t === 'video') ? videoSource() : null;
showStep('select'); const effectiveType = (t === 'video' && vs === 'youtube') ? 'youtube' : t;
backBtn.disabled = true; typeHidden.value = effectiveType;
sectionImage.classList.toggle('d-none', effectiveType !== 'image');
sectionWebpage.classList.toggle('d-none', effectiveType !== 'webpage');
sectionYoutube.classList.toggle('d-none', effectiveType !== 'youtube');
sectionVideo.classList.toggle('d-none', effectiveType !== 'video');
// duration applies to image/webpage/youtube. Video upload plays until ended.
durationGroup.classList.toggle('d-none', effectiveType === 'video');
cropModeGroup?.classList.toggle('d-none', effectiveType !== 'image');
if (stepInputHint) {
if (effectiveType === 'image') stepInputHint.textContent = 'Select an image. After selecting, you\'ll crop it.';
else if (effectiveType === 'webpage') stepInputHint.textContent = 'Enter a webpage URL.';
else if (effectiveType === 'youtube') stepInputHint.textContent = 'Paste a YouTube URL.';
else stepInputHint.textContent = 'Upload a video file.';
} }
// For images we enforce crop step before allowing submit. // Reset cropper when leaving image.
if (t === 'image') { if (effectiveType !== 'image') destroyCropper();
submitBtn.disabled = true;
backBtn.disabled = true;
}
} }
function currentCropMode() { function currentCropMode() {
@@ -705,10 +820,39 @@
} }
} }
document.getElementById('type-image')?.addEventListener('change', () => setType('image')); document.getElementById('type-image')?.addEventListener('change', () => {
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage')); setType('image');
document.getElementById('type-youtube')?.addEventListener('change', () => setType('youtube')); // Stay on type step; user clicks Next.
document.getElementById('type-video')?.addEventListener('change', () => setType('video')); showStep('type');
});
document.getElementById('type-webpage')?.addEventListener('change', () => {
setType('webpage');
// Stay on type step; user clicks Next.
showStep('type');
});
document.getElementById('type-video')?.addEventListener('change', () => {
// We show an intermediate step for video so user chooses upload vs YouTube.
// Keep item_type unset until that choice is made.
setError('');
destroyCropper();
// Hide crop/duration while selecting source (they depend on source).
cropModeGroup?.classList.add('d-none');
durationGroup?.classList.add('d-none');
showStep('video-source');
});
document.getElementById('video-source-upload')?.addEventListener('change', () => {
// effective type becomes "video"
setType('video');
// Stay on source step; user clicks Next.
showStep('video-source');
});
document.getElementById('video-source-youtube')?.addEventListener('change', () => {
// effective type becomes "youtube"
setType('video');
// Stay on source step; user clicks Next.
showStep('video-source');
});
// ------------------------- // -------------------------
// Image: drag/drop + crop // Image: drag/drop + crop
@@ -754,7 +898,7 @@
cropStatus.textContent = ''; cropStatus.textContent = '';
if (imageSelectStatus) imageSelectStatus.textContent = `Selected: ${file.name}`; if (imageSelectStatus) imageSelectStatus.textContent = `Selected: ${file.name}`;
// Move to crop step after image selection (requested behavior) // Move to crop step after image selection
showStep('crop'); showStep('crop');
// Wait for image to be ready // Wait for image to be ready
@@ -837,9 +981,15 @@
cropStatus.textContent = 'Preparing cropped image…'; cropStatus.textContent = 'Preparing cropped image…';
const isPortrait = cm === '9:16'; const isPortrait = cm === '9:16';
// Export at Full HD by default (or whatever the server config says).
// We still enforce a server-side max output size in _save_compressed_image.
const cfg = document.getElementById('page-config');
const TARGET_W = parseInt(cfg?.dataset?.imageCropTargetW || '1920', 10) || 1920;
const TARGET_H = parseInt(cfg?.dataset?.imageCropTargetH || '1080', 10) || 1080;
const canvas = cropper.getCroppedCanvas({ const canvas = cropper.getCroppedCanvas({
width: isPortrait ? 720 : 1280, width: isPortrait ? TARGET_H : TARGET_W,
height: isPortrait ? 1280 : 720, height: isPortrait ? TARGET_W : TARGET_H,
imageSmoothingQuality: 'high', imageSmoothingQuality: 'high',
}); });
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
@@ -991,15 +1141,7 @@
} }
// Reset modal state + close // Reset modal state + close
form.reset(); resetModalState();
typeHidden.value = 'image';
document.getElementById('type-image')?.click();
if (cropModeHidden) cropModeHidden.value = '16:9';
document.getElementById('crop-16-9')?.click();
destroyCropper();
showStep('select');
submitBtn.disabled = true;
resetVideoProgress();
modal?.hide(); modal?.hide();
} }
@@ -1098,32 +1240,87 @@
if (t === 'webpage') { if (t === 'webpage') {
// Keep preview behavior // Keep preview behavior
schedulePreview(); schedulePreview();
} else { return;
}
// Hide webpage preview if not active // Hide webpage preview if not active
preview?.classList.add('d-none'); preview?.classList.add('d-none');
if (iframe) iframe.src = 'about:blank'; if (iframe) iframe.src = 'about:blank';
if (openLink) openLink.href = '#'; if (openLink) openLink.href = '#';
} }
}
// Set initial state function resetModalState() {
setType('image'); setError('');
try { form.reset(); } catch (e) {}
destroyCropper();
// Default selections (without triggering change handlers)
const typeImage = document.getElementById('type-image');
const typeWebpage = document.getElementById('type-webpage');
const typeVideo = document.getElementById('type-video');
if (typeImage) typeImage.checked = true;
if (typeWebpage) typeWebpage.checked = false;
if (typeVideo) typeVideo.checked = false;
const vsUpload = document.getElementById('video-source-upload');
const vsYoutube = document.getElementById('video-source-youtube');
if (vsUpload) vsUpload.checked = true;
if (vsYoutube) vsYoutube.checked = false;
if (cropModeHidden) cropModeHidden.value = '16:9'; if (cropModeHidden) cropModeHidden.value = '16:9';
showStep('select'); document.getElementById('crop-16-9')?.click();
// Set UI for default type, but start at type selection step
setType('image');
showStep('type');
syncEnabledInputs(); syncEnabledInputs();
updateCropHint(); updateCropHint();
// Also reset video upload progress UI if present
try {
const bar = document.getElementById('video-upload-progress-bar');
if (bar) {
bar.style.width = '0%';
bar.setAttribute('aria-valuenow', '0');
}
document.getElementById('video-upload-progress-text') && (document.getElementById('video-upload-progress-text').textContent = 'Uploading…');
document.getElementById('video-upload-progress')?.classList.add('d-none');
} catch (e) {}
}
// Initialize modal state once on page load
resetModalState();
// Modal open // Modal open
openBtn?.addEventListener('click', () => { openBtn?.addEventListener('click', () => {
// Always start from the beginning.
resetModalState();
modal?.show(); modal?.show();
}); });
// Back button: only relevant for image crop step // Back button: stepwise navigation
backBtn?.addEventListener('click', () => { backBtn?.addEventListener('click', () => {
if (typeHidden.value === 'image') { if (currentStep === 'crop') {
showStep('select'); // Going back from crop returns to input for image
showStep('input');
submitBtn.disabled = true; submitBtn.disabled = true;
backBtn.disabled = true; return;
}
if (currentStep === 'input') {
// If top-level selected is video, go back to video source selection.
const isTopLevelVideo = document.getElementById('type-video')?.checked;
if (isTopLevelVideo) {
showStep('video-source');
} else {
showStep('type');
}
return;
}
if (currentStep === 'video-source') {
showStep('type');
return;
} }
}); });
@@ -1165,13 +1362,43 @@
document.getElementById('crop-none')?.addEventListener('change', () => setCropMode('none')); document.getElementById('crop-none')?.addEventListener('change', () => setCropMode('none'));
// Whenever type changes, keep enabled inputs in sync // Whenever type changes, keep enabled inputs in sync
['type-image','type-webpage','type-youtube','type-video'].forEach((id) => { ['type-image','type-webpage','type-video','video-source-upload','video-source-youtube'].forEach((id) => {
document.getElementById(id)?.addEventListener('change', syncEnabledInputs); document.getElementById(id)?.addEventListener('change', syncEnabledInputs);
}); });
// Add button // Add button
submitBtn?.addEventListener('click', async () => { submitBtn?.addEventListener('click', async () => {
try { try {
// Multi-step behavior:
// - Type step: Next -> (video ? source step : input step)
// - Video source step: Next -> input step
// - Input/crop: Add -> submit
if (currentStep === 'type') {
const isVideo = document.getElementById('type-video')?.checked;
const isWebpage = document.getElementById('type-webpage')?.checked;
if (isVideo) {
// Hide crop/duration while selecting source.
cropModeGroup?.classList.add('d-none');
durationGroup?.classList.add('d-none');
showStep('video-source');
return;
}
setType(isWebpage ? 'webpage' : 'image');
showStep('input');
syncEnabledInputs();
return;
}
if (currentStep === 'video-source') {
// Apply the chosen source (upload vs YouTube) and continue.
setType('video');
showStep('input');
syncEnabledInputs();
return;
}
await submitViaAjax(); await submitViaAjax();
} catch (err) { } catch (err) {
console.warn(err); console.warn(err);
@@ -1245,6 +1472,372 @@
</div> </div>
`; `;
} }
// Expose for other scripts on this page (bulk upload appends cards).
window.__renderPlaylistCardInnerHtml = renderCardInnerHtml;
})();
(function() {
// -------------------------
// Bulk image upload (auto center-crop)
// -------------------------
const openBtn = document.getElementById('open-bulk-upload');
const modalEl = document.getElementById('bulkUploadModal');
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
const dropzone = document.getElementById('bulk-dropzone');
const fileInput = document.getElementById('bulk-file-input');
const submitBtn = document.getElementById('bulk-upload-submit');
const statusEl = document.getElementById('bulk-status');
const errorEl = document.getElementById('bulk-error');
const listEl = document.getElementById('bulk-file-list');
const crop169 = document.getElementById('bulk-crop-16-9');
const crop916 = document.getElementById('bulk-crop-9-16');
const cfg = document.getElementById('page-config');
const TARGET_W = parseInt(cfg?.dataset?.imageCropTargetW || '1920', 10) || 1920;
const TARGET_H = parseInt(cfg?.dataset?.imageCropTargetH || '1080', 10) || 1080;
const uploadUrl = `{{ url_for('company.bulk_upload_playlist_images', playlist_id=playlist.id) }}`;
let selectedFiles = [];
let processedFiles = []; // cropped output
function setError(msg) {
if (errorEl) errorEl.textContent = (msg || '').trim();
}
function setStatus(msg) {
if (statusEl) statusEl.textContent = (msg || '').trim();
}
function currentCropMode() {
return crop916?.checked ? '9:16' : '16:9';
}
function clearList() {
if (listEl) listEl.innerHTML = '';
}
function addListItem(name, initialText) {
if (!listEl) return null;
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center';
li.innerHTML = `
<div class="text-truncate" style="max-width: 70%;">${escapeHtml(name)}</div>
<div class="small text-muted">${escapeHtml(initialText || '')}</div>
`;
listEl.appendChild(li);
return li;
}
function setListItemStatus(li, txt, kind) {
if (!li) return;
const right = li.querySelector('div.small');
if (!right) return;
right.textContent = txt || '';
right.classList.toggle('text-danger', kind === 'err');
right.classList.toggle('text-success', kind === 'ok');
right.classList.toggle('text-muted', !kind);
}
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
async function loadImageFromFile(file) {
return await new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load image'));
};
img.src = url;
});
}
async function centerCropToAspect(file, cropMode) {
// cropMode is "16:9" or "9:16"
const img = await loadImageFromFile(file);
const srcW = img.naturalWidth || img.width;
const srcH = img.naturalHeight || img.height;
if (!srcW || !srcH) throw new Error('Invalid image');
const targetAspect = (cropMode === '9:16') ? (9 / 16) : (16 / 9);
const srcAspect = srcW / srcH;
// Compute center crop rect in source pixels
let cropW = srcW;
let cropH = srcH;
if (srcAspect > targetAspect) {
// too wide -> crop width
cropW = Math.max(1, Math.round(srcH * targetAspect));
cropH = srcH;
} else {
// too tall -> crop height
cropW = srcW;
cropH = Math.max(1, Math.round(srcW / targetAspect));
}
const cropX = Math.max(0, Math.round((srcW - cropW) / 2));
const cropY = Math.max(0, Math.round((srcH - cropH) / 2));
const outW = (cropMode === '9:16') ? TARGET_H : TARGET_W;
const outH = (cropMode === '9:16') ? TARGET_W : TARGET_H;
const canvas = document.createElement('canvas');
canvas.width = outW;
canvas.height = outH;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas not supported');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, cropX, cropY, cropW, cropH, 0, 0, outW, outH);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) throw new Error('Failed to encode cropped image');
const base = (file.name || 'image').replace(/\.[^/.]+$/, '');
const outName = `${base}_${cropMode.replace(':','x')}.png`;
return new File([blob], outName, { type: 'image/png' });
}
async function processSelection(files) {
setError('');
processedFiles = [];
clearList();
submitBtn.disabled = true;
selectedFiles = (files || []).filter(f => f && f.type && f.type.startsWith('image/'));
if (!selectedFiles.length) {
setStatus('');
setError('Please select one or more image files.');
return;
}
const mode = currentCropMode();
setStatus(`Preparing ${selectedFiles.length} image(s)…`);
// Process sequentially to keep memory use stable.
for (let i = 0; i < selectedFiles.length; i++) {
const f = selectedFiles[i];
const li = addListItem(f.name, 'Cropping…');
try {
const cropped = await centerCropToAspect(f, mode);
processedFiles.push(cropped);
setListItemStatus(li, 'Ready', 'ok');
} catch (e) {
console.warn('Failed to crop', f.name, e);
setListItemStatus(li, 'Failed to crop', 'err');
}
}
if (!processedFiles.length) {
setStatus('');
setError('No images could be processed.');
return;
}
setStatus(`Ready to upload ${processedFiles.length} image(s).`);
submitBtn.disabled = false;
}
async function upload() {
setError('');
if (!processedFiles.length) {
setError('Please add some images first.');
return;
}
submitBtn.disabled = true;
dropzone?.classList.add('disabled');
const progressWrap = document.getElementById('bulk-upload-progress');
const progressBar = document.getElementById('bulk-upload-progress-bar');
const progressText = document.getElementById('bulk-upload-progress-text');
function setProgressVisible(visible) {
progressWrap?.classList.toggle('d-none', !visible);
}
function setProgress(pct, text) {
const p = Math.max(0, Math.min(100, Math.round(Number(pct) || 0)));
if (progressBar) {
progressBar.style.width = `${p}%`;
progressBar.setAttribute('aria-valuenow', String(p));
}
if (progressText) progressText.textContent = text || `${p}%`;
}
setProgressVisible(true);
setProgress(0, 'Uploading…');
const mode = currentCropMode();
setStatus(`Uploading ${processedFiles.length} image(s)…`);
const fd = new FormData();
fd.set('crop_mode', mode);
fd.set('duration_seconds', '10');
for (const f of processedFiles) fd.append('files', f);
// Use XHR so we can track upload progress.
const xhr = new XMLHttpRequest();
const xhrPromise = new Promise((resolve) => {
xhr.onreadystatechange = () => {
if (xhr.readyState !== 4) return;
resolve();
};
});
xhr.open('POST', uploadUrl, true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.setRequestHeader('Accept', 'application/json');
xhr.upload.onprogress = (e) => {
if (!e || !e.lengthComputable) {
setProgress(0, 'Uploading…');
return;
}
const pct = (e.total > 0) ? ((e.loaded / e.total) * 100) : 0;
setProgress(pct, `Uploading… ${Math.round(pct)}%`);
};
xhr.onerror = () => {
// Network error
setError('Upload failed (network error).');
};
xhr.send(fd);
await xhrPromise;
const status = xhr.status;
const text = xhr.responseText || '';
let data = null;
try { data = JSON.parse(text); } catch (e) {}
const resOk = (status >= 200 && status < 300);
if (!resOk) {
let msg = (data && data.error) ? data.error : `Upload failed (HTTP ${status}).`;
setError(msg);
submitBtn.disabled = false;
dropzone?.classList.remove('disabled');
setStatus('');
setProgressVisible(false);
return;
}
if (!data || !data.ok) {
setError((data && data.error) ? data.error : 'Upload failed.');
submitBtn.disabled = false;
dropzone?.classList.remove('disabled');
setStatus('');
setProgressVisible(false);
return;
}
setProgress(100, 'Processing…');
// Append cards in returned order.
const list = document.getElementById('playlist-items');
const render = window.__renderPlaylistCardInnerHtml;
if (list && Array.isArray(data.items) && render) {
for (const item of data.items) {
const el = document.createElement('div');
el.className = 'playlist-card';
el.setAttribute('draggable', 'true');
el.setAttribute('data-item-id', item.id);
el.innerHTML = render(item);
list.appendChild(el);
}
}
setStatus(`Uploaded ${data.items?.length || processedFiles.length} image(s).`);
// Reset & close.
selectedFiles = [];
processedFiles = [];
clearList();
submitBtn.disabled = true;
window.setTimeout(() => {
modal?.hide();
setStatus('');
setError('');
}, 500);
dropzone?.classList.remove('disabled');
setProgressVisible(false);
}
function resetModal() {
setError('');
setStatus('');
selectedFiles = [];
processedFiles = [];
clearList();
submitBtn.disabled = true;
try { if (fileInput) fileInput.value = ''; } catch (e) {}
// Reset progress UI
try {
document.getElementById('bulk-upload-progress')?.classList.add('d-none');
const bar = document.getElementById('bulk-upload-progress-bar');
if (bar) {
bar.style.width = '0%';
bar.setAttribute('aria-valuenow', '0');
}
const txt = document.getElementById('bulk-upload-progress-text');
if (txt) txt.textContent = 'Uploading…';
} catch (e) {}
// default to landscape
if (crop169) crop169.checked = true;
if (crop916) crop916.checked = false;
}
openBtn?.addEventListener('click', () => {
resetModal();
modal?.show();
});
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 files = Array.from(e.dataTransfer?.files || []);
await processSelection(files);
});
fileInput?.addEventListener('change', async () => {
const files = Array.from(fileInput.files || []);
await processSelection(files);
});
// If user switches aspect ratio after selecting files, re-process automatically.
[crop169, crop916].forEach((el) => {
el?.addEventListener('change', async () => {
if (selectedFiles.length) await processSelection(selectedFiles);
});
});
submitBtn?.addEventListener('click', () => {
upload().catch((err) => {
console.warn('Bulk upload failed', err);
setError('Bulk upload failed.');
submitBtn.disabled = false;
setStatus('');
});
});
})(); })();
(function() { (function() {

View File

@@ -8,6 +8,23 @@
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; } html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; } #stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
/* When ticker is shown, keep content from being visually covered.
(We pad the stage; video/img/iframe inside will keep aspect.) */
body.has-ticker #stage {
bottom: var(--ticker-height, 54px);
}
/* Optional company overlay (transparent PNG) */
#overlay {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 5;
object-fit: contain;
}
/* Slide transitions (applied by JS via classes) */ /* Slide transitions (applied by JS via classes) */
#stage .slide { #stage .slide {
position: absolute; position: absolute;
@@ -87,6 +104,41 @@
margin: 0; margin: 0;
opacity: 0.95; opacity: 0.95;
} }
/* Ticker tape */
#ticker {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: var(--ticker-height, 54px);
background: rgba(0, 0, 0, 0.75); /* overridden by JS via style */
display: none;
align-items: center;
overflow: hidden;
z-index: 6; /* above stage, below notice */
pointer-events: none;
}
#ticker .track {
display: inline-flex;
align-items: center;
white-space: nowrap;
will-change: transform;
animation: ticker-scroll linear infinite;
animation-duration: var(--ticker-duration, 60s);
transform: translateX(0);
}
#ticker .item {
padding: 0 26px;
}
#ticker .sep {
opacity: 0.65;
}
@keyframes ticker-scroll {
/* We duplicate the content twice, so shifting -50% effectively loops. */
0% { transform: translateX(0); }
100% { transform: translateX(calc(-1 * var(--ticker-shift, 50%))); }
}
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; } img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
/* removed bottom-left status text */ /* removed bottom-left status text */
</style> </style>
@@ -99,9 +151,16 @@
</div> </div>
</div> </div>
<div id="stage"></div> <div id="stage"></div>
<div id="ticker" aria-hidden="true">
<div class="track" id="tickerTrack"></div>
</div>
{% if overlay_url %}
<img id="overlay" src="{{ overlay_url }}" alt="Overlay" />
{% endif %}
<script> <script>
const token = "{{ display.token }}"; const token = "{{ display.token }}";
const stage = document.getElementById('stage'); const stage = document.getElementById('stage');
let overlayEl = document.getElementById('overlay');
const noticeEl = document.getElementById('notice'); const noticeEl = document.getElementById('notice');
const noticeTitleEl = document.getElementById('noticeTitle'); const noticeTitleEl = document.getElementById('noticeTitle');
const noticeTextEl = document.getElementById('noticeText'); const noticeTextEl = document.getElementById('noticeText');
@@ -137,6 +196,21 @@
let idx = 0; let idx = 0;
let timer = null; let timer = null;
// Ticker DOM
const tickerEl = document.getElementById('ticker');
const tickerTrackEl = document.getElementById('tickerTrack');
let tickerConfig = null;
let tickerInterval = null;
let tickerLastHeadlines = [];
function getTickerPollSeconds() {
// Refresh headlines on a long interval.
// Default: 12 hours (twice per day).
// Override via ?ticker_poll=seconds.
const tp = parseInt(new URLSearchParams(window.location.search).get('ticker_poll') || '', 10);
return Number.isFinite(tp) && tp > 0 ? tp : (12 * 60 * 60);
}
const ANIM_MS = 420; const ANIM_MS = 420;
function getTransitionMode(pl) { function getTransitionMode(pl) {
@@ -159,11 +233,216 @@
return await res.json(); return await res.json();
} }
async function fetchTickerHeadlines() {
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
const res = await fetch(`/api/display/${token}/ticker${qs}`, { 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 safeCss(val) {
return (val || '').toString().replace(/[\n\r"']/g, ' ').trim();
}
function applyTickerStyle(cfg) {
if (!tickerEl) return;
const color = safeCss(cfg && cfg.color);
const bgColor = safeCss(cfg && cfg.bg_color);
const bgOpacityRaw = parseInt((cfg && cfg.bg_opacity) || '', 10);
const bgOpacity = Number.isFinite(bgOpacityRaw) ? Math.max(0, Math.min(100, bgOpacityRaw)) : 75;
const fontFamily = safeCss(cfg && cfg.font_family);
const sizePx = parseInt((cfg && cfg.font_size_px) || '', 10);
const fontSize = Number.isFinite(sizePx) ? Math.max(10, Math.min(200, sizePx)) : 28;
// Height is slightly larger than font size.
const height = Math.max(36, Math.min(120, fontSize + 26));
tickerEl.style.color = color || '#ffffff';
tickerEl.style.fontFamily = fontFamily || 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';
tickerEl.style.fontSize = `${fontSize}px`;
tickerEl.style.setProperty('--ticker-height', `${height}px`);
// Background color + opacity
tickerEl.style.backgroundColor = toRgba(bgColor || '#000000', bgOpacity);
}
function toRgba(hexColor, opacityPercent) {
const s = (hexColor || '').toString().trim().toLowerCase();
const a = Math.max(0, Math.min(100, parseInt(opacityPercent || '0', 10))) / 100;
// Accept #rgb or #rrggbb. Fallback to black.
let r = 0, g = 0, b = 0;
if (s.startsWith('#')) {
const h = s.slice(1);
if (h.length === 3) {
r = parseInt(h[0] + h[0], 16);
g = parseInt(h[1] + h[1], 16);
b = parseInt(h[2] + h[2], 16);
} else if (h.length === 6) {
r = parseInt(h.slice(0,2), 16);
g = parseInt(h.slice(2,4), 16);
b = parseInt(h.slice(4,6), 16);
}
}
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
function computeTickerDurationPx(copyWidthPx) {
const w = Math.max(1, parseInt(copyWidthPx || '0', 10) || 0);
// Speed slider (1..100): higher => faster.
const rawSpeed = parseInt((tickerConfig && tickerConfig.speed) || '', 10);
const speed = Number.isFinite(rawSpeed) ? Math.max(1, Math.min(100, rawSpeed)) : 25;
// Map speed to pixels/second. (tuned to be readable on signage)
// speed=25 => ~38 px/s, speed=100 => ~128 px/s
const pxPerSecond = Math.max(8, Math.min(180, 8 + (speed * 1.2)));
const seconds = w / pxPerSecond;
return Math.max(12, Math.min(600, seconds));
}
function buildTickerCopyHtml(list) {
// No trailing separator at the end.
return list.map((t, i) => {
const sep = (i === list.length - 1) ? '' : '<span class="sep">•</span>';
return `<span class="item">${escapeHtml(t)}</span>${sep}`;
}).join('');
}
function setTickerHeadlines(headlines) {
if (!tickerEl || !tickerTrackEl) return;
const list = Array.isArray(headlines) ? headlines.map(x => (x || '').toString().trim()).filter(Boolean) : [];
if (!list.length) {
tickerEl.style.display = 'none';
tickerTrackEl.innerHTML = '';
document.body.classList.remove('has-ticker');
return;
}
tickerLastHeadlines = list.slice();
// Show first so measurements work.
tickerEl.style.display = 'flex';
document.body.classList.add('has-ticker');
// Build one copy.
const oneCopyHtml = buildTickerCopyHtml(list);
tickerTrackEl.innerHTML = oneCopyHtml;
// Ensure we repeat enough so there is never an empty gap, even when the
// total headline width is smaller than the viewport.
requestAnimationFrame(() => {
try {
const viewportW = tickerEl.clientWidth || 1;
const copyW = tickerTrackEl.scrollWidth || 1;
// Want at least 2x viewport width in total track content.
const repeats = Math.max(2, Math.ceil((viewportW * 2) / copyW) + 1);
tickerTrackEl.innerHTML = oneCopyHtml.repeat(repeats);
// Shift by exactly one copy width. In % of total track width that is 100/repeats.
const shiftPercent = 100 / repeats;
tickerEl.style.setProperty('--ticker-shift', `${shiftPercent}%`);
tickerEl.style.setProperty('--ticker-duration', `${computeTickerDurationPx(copyW)}s`);
} catch (e) {
// fallback: 2 copies
tickerTrackEl.innerHTML = oneCopyHtml + oneCopyHtml;
tickerEl.style.setProperty('--ticker-shift', '50%');
tickerEl.style.setProperty('--ticker-duration', `${computeTickerDurationPx(2000)}s`);
}
});
}
function escapeHtml(s) {
return (s || '').toString()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function refreshTickerOnce() {
// Disabled or missing URL: hide and clear immediately.
if (!tickerConfig || !tickerConfig.enabled) {
setTickerHeadlines([]);
return;
}
if (!tickerConfig.rss_url || !String(tickerConfig.rss_url).trim()) {
setTickerHeadlines([]);
return;
}
try {
const data = await fetchTickerHeadlines();
if (!data || !data.enabled) {
setTickerHeadlines([]);
return;
}
setTickerHeadlines(data.headlines || []);
} catch (e) {
// Soft-fail: keep old headlines if any.
}
}
function rerenderTickerFromCache() {
if (!tickerLastHeadlines || !tickerLastHeadlines.length) return;
setTickerHeadlines(tickerLastHeadlines);
}
function startTickerPolling() {
if (tickerInterval) {
clearInterval(tickerInterval);
tickerInterval = null;
}
tickerInterval = setInterval(refreshTickerOnce, getTickerPollSeconds() * 1000);
}
function stopTickerPolling() {
if (tickerInterval) {
clearInterval(tickerInterval);
tickerInterval = null;
}
}
function clearStage() { function clearStage() {
if (timer) { clearTimeout(timer); timer = null; } if (timer) { clearTimeout(timer); timer = null; }
stage.innerHTML = ''; stage.innerHTML = '';
} }
function setOverlaySrc(src) {
const val = (src || '').trim();
if (!val) {
if (overlayEl && overlayEl.parentNode) overlayEl.parentNode.removeChild(overlayEl);
overlayEl = null;
return;
}
if (!overlayEl) {
overlayEl = document.createElement('img');
overlayEl.id = 'overlay';
overlayEl.alt = 'Overlay';
document.body.appendChild(overlayEl);
}
// Cache-bust in preview mode so changes show up instantly.
if (isPreview) {
try {
const u = new URL(val, window.location.origin);
u.searchParams.set('_ts', String(Date.now()));
overlayEl.src = u.toString();
return;
} catch(e) {
// fallthrough
}
}
overlayEl.src = val;
}
// Initialize overlay from server-side render.
if (overlayEl && overlayEl.src) setOverlaySrc(overlayEl.src);
function setSlideContent(container, item) { function setSlideContent(container, item) {
if (item.type === 'image') { if (item.type === 'image') {
const el = document.createElement('img'); const el = document.createElement('img');
@@ -272,6 +551,15 @@
playlist = await fetchPlaylist(); playlist = await fetchPlaylist();
idx = 0; idx = 0;
applyTransitionClass(getTransitionMode(playlist)); applyTransitionClass(getTransitionMode(playlist));
setOverlaySrc(playlist && playlist.overlay_src);
tickerConfig = (playlist && playlist.ticker) ? playlist.ticker : null;
applyTickerStyle(tickerConfig);
await refreshTickerOnce();
if (tickerConfig && tickerConfig.enabled) {
startTickerPolling();
} else {
stopTickerPolling();
}
next(); next();
} catch (e) { } catch (e) {
clearStage(); clearStage();
@@ -300,6 +588,39 @@
const oldStr = JSON.stringify(playlist); const oldStr = JSON.stringify(playlist);
const newStr = JSON.stringify(newPlaylist); const newStr = JSON.stringify(newPlaylist);
playlist = newPlaylist; playlist = newPlaylist;
setOverlaySrc(playlist && playlist.overlay_src);
// Apply ticker settings (and refresh if settings changed)
const newTickerCfg = (playlist && playlist.ticker) ? playlist.ticker : null;
const oldTickerStr = JSON.stringify(tickerConfig);
const newTickerStr = JSON.stringify(newTickerCfg);
const oldEnabled = !!(tickerConfig && tickerConfig.enabled);
const newEnabled = !!(newTickerCfg && newTickerCfg.enabled);
const oldRssUrl = (tickerConfig && tickerConfig.rss_url) ? String(tickerConfig.rss_url) : '';
const newRssUrl = (newTickerCfg && newTickerCfg.rss_url) ? String(newTickerCfg.rss_url) : '';
tickerConfig = newTickerCfg;
applyTickerStyle(tickerConfig);
if (oldTickerStr !== newTickerStr) {
// Ensure enable/disable toggles are applied immediately (no reload required).
if (oldEnabled !== newEnabled) {
if (!newEnabled) {
// Hide and stop polling.
tickerLastHeadlines = [];
stopTickerPolling();
setTickerHeadlines([]);
} else {
// Re-enable: fetch now and restart headline polling.
await refreshTickerOnce();
startTickerPolling();
}
} else if (oldRssUrl !== newRssUrl) {
// RSS URL changed: refetch now.
await refreshTickerOnce();
} else {
// Style/speed change only: rerender from cache to apply instantly.
if (newEnabled) rerenderTickerFromCache();
}
}
if (oldStr !== newStr) { if (oldStr !== newStr) {
idx = 0; idx = 0;
applyTransitionClass(getTransitionMode(playlist)); applyTransitionClass(getTransitionMode(playlist));

136
scripts/release.py Normal file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Release helper.
What it does (in order):
1) Ask/provide a commit message and version.
2) Commit & push to the `openslide` git remote.
3) Build + push Docker image tags:
- git.alphen.cloud/bramval/openslide:<version>
- git.alphen.cloud/bramval/openslide:latest
Usage examples:
python scripts/release.py --version 1.2.3 --message "Release 1.2.3"
python scripts/release.py # interactive prompts
Notes:
- Assumes you are already authenticated for git + the Docker registry (docker login).
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from dataclasses import dataclass
from typing import Iterable, Sequence
DEFAULT_GIT_REMOTE = "openslide"
DEFAULT_IMAGE = "git.alphen.cloud/bramval/openslide"
@dataclass(frozen=True)
class ReleaseInfo:
version: str
message: str
_DOCKER_TAG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
def _run(cmd: Sequence[str], *, dry_run: bool = False) -> None:
printable = " ".join(cmd)
print(f"> {printable}")
if dry_run:
return
subprocess.run(cmd, check=True)
def _capture(cmd: Sequence[str]) -> str:
return subprocess.check_output(cmd, text=True).strip()
def _require_tool(name: str) -> None:
try:
subprocess.run([name, "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
except FileNotFoundError as e:
raise SystemExit(f"Required tool not found in PATH: {name}") from e
def _validate_version(tag: str) -> str:
tag = tag.strip()
if not tag:
raise ValueError("Version may not be empty")
if not _DOCKER_TAG_RE.match(tag):
raise ValueError(
"Invalid docker tag for version. Use only letters, digits, '.', '_' or '-'. "
"(1..128 chars, must start with [A-Za-z0-9])"
)
return tag
def get_release_info(*, version: str | None, message: str | None) -> ReleaseInfo:
"""Collect commit message + version before doing the rest."""
if version is None:
version = input("Version tag (e.g. 1.2.3): ").strip()
version = _validate_version(version)
if message is None:
message = input(f"Commit message [Release {version}]: ").strip() or f"Release {version}"
return ReleaseInfo(version=version, message=message)
def git_commit_and_push(*, remote: str, message: str, dry_run: bool = False) -> None:
# Stage all changes
_run(["git", "add", "-A"], dry_run=dry_run)
# Only commit if there is something to commit
porcelain = _capture(["git", "status", "--porcelain"]) # empty => clean
if porcelain:
_run(["git", "commit", "-m", message], dry_run=dry_run)
else:
print("No working tree changes detected; skipping git commit.")
branch = _capture(["git", "rev-parse", "--abbrev-ref", "HEAD"])
_run(["git", "push", remote, branch], dry_run=dry_run)
def docker_build_and_push(*, image: str, version: str, dry_run: bool = False) -> None:
version_tag = f"{image}:{version}"
latest_tag = f"{image}:latest"
_run(["docker", "build", "-t", version_tag, "-t", latest_tag, "."], dry_run=dry_run)
_run(["docker", "push", version_tag], dry_run=dry_run)
_run(["docker", "push", latest_tag], dry_run=dry_run)
def main(argv: Iterable[str]) -> int:
parser = argparse.ArgumentParser(description="Commit + push + docker publish helper.")
parser.add_argument("--version", "-v", help="Docker version tag (e.g. 1.2.3)")
parser.add_argument("--message", "-m", help="Git commit message")
parser.add_argument("--remote", default=DEFAULT_GIT_REMOTE, help=f"Git remote to push to (default: {DEFAULT_GIT_REMOTE})")
parser.add_argument("--image", default=DEFAULT_IMAGE, help=f"Docker image name (default: {DEFAULT_IMAGE})")
parser.add_argument("--dry-run", action="store_true", help="Print commands without executing")
args = parser.parse_args(list(argv))
_require_tool("git")
_require_tool("docker")
info = get_release_info(version=args.version, message=args.message)
print(f"\nReleasing version: {info.version}")
print(f"Commit message: {info.message}")
print(f"Git remote: {args.remote}")
print(f"Docker image: {args.image}\n")
git_commit_and_push(remote=args.remote, message=info.message, dry_run=args.dry_run)
docker_build_and_push(image=args.image, version=info.version, dry_run=args.dry_run)
print("\nDone.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))