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_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
- 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 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
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("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.
# 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
@@ -62,6 +80,41 @@ def create_app():
db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)"))
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
company_cols = [
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.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.
# (PRAGMA returns empty if the table doesn't exist.)
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.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()]
if "storage_max_bytes" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
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()]
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)"))

View File

@@ -16,6 +16,20 @@ class Company(db.Model):
# If NULL or <=0: unlimited.
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")
displays = db.relationship("Display", 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)
# Transition animation between slides: none|fade|slide
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)
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"):
_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.
company_name = company.name
db.session.delete(company)

View File

@@ -1,12 +1,18 @@
from datetime import datetime, timedelta
import hashlib
import json
import os
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 ..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")
@@ -14,6 +20,21 @@ bp = Blueprint("api", __name__, url_prefix="/api")
MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3
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:
"""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()
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")
def display_playlist(token: str):
display = Display.query.filter_by(token=token).first()
if not display:
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.
# Player sends a stable `sid` via querystring.
sid = request.args.get("sid")
@@ -180,6 +330,18 @@ def display_playlist(token: str):
if not ok:
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.
# Otherwise fall back to the legacy assigned_playlist_id.
mapped_ids = [
@@ -200,6 +362,8 @@ def display_playlist(token: str):
{
"display": display.name,
"transition": display.transition or "none",
"overlay_src": overlay_src,
"ticker": ticker_cfg,
"playlists": [],
"items": [],
}
@@ -263,12 +427,51 @@ def display_playlist(token: str):
{
"display": display.name,
"transition": display.transition or "none",
"overlay_src": overlay_src,
"ticker": ticker_cfg,
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
"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")
def display_events(token: str):
"""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_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
MAX_VIDEO_BYTES = 250 * 1024 * 1024
@@ -140,15 +147,22 @@ def _save_compressed_image(
img = img.convert("RGB")
# 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":
img = _center_crop_to_aspect(img, 16, 9)
max_box = (1920, 1080)
max_box = (target_w, target_h)
elif cm == "9:16":
img = _center_crop_to_aspect(img, 9, 16)
max_box = (1080, 1920)
max_box = (target_h, target_w)
else:
# No crop: allow both portrait and landscape up to 1920px on the longest side.
max_box = (1920, 1920)
# No crop: allow both portrait and landscape up to target_w/target_h on the longest side.
max_box = (max(target_w, target_h),) * 2
# Resize down if very large (keeps aspect ratio)
img.thumbnail(max_box)
@@ -175,6 +189,68 @@ def _try_delete_upload(file_path: str | None, upload_root: str):
# Ignore cleanup failures
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")
@@ -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."
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")
@login_required
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()
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(
"company/my_company.html",
company=company,
users=users,
overlay_url=overlay_url,
stats={
"users": user_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")
@login_required
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))
@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")
@login_required
def delete_item(item_id: int):
@@ -975,6 +1373,64 @@ def update_display(display_id: int):
return None
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
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
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
if request.is_json:
if "playlist_id" in payload:
@@ -1044,6 +1540,15 @@ def update_display(display_id: int):
"name": display.name,
"description": display.description,
"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,
},
}

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")
@@ -10,4 +11,11 @@ def display_player(token: str):
display = Display.query.filter_by(token=token).first()
if not display:
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;
}
/* 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) {
.display-gallery-grid {
grid-template-columns: 1fr;
@@ -307,3 +314,9 @@ h1, h2, h3, .display-1, .display-2, .display-3 {
.schedule-status-dot.inactive {
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" %}
{% 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="col-12">
@@ -69,13 +69,20 @@
<div class="col-12 col-md-6 col-xl-4">
<div class="card display-gallery-card h-100">
<div class="display-preview">
<iframe
title="Preview — {{ d.name }}"
data-display-id="{{ d.id }}"
src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
loading="lazy"
referrerpolicy="no-referrer"
></iframe>
<div
class="display-preview-scale"
style="width: 1000%; height: 1000%; transform: scale(0.1); transform-origin: top left;"
>
<iframe
title="Preview — {{ d.name }}"
data-display-id="{{ d.id }}"
class="js-display-preview"
data-preview-src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
loading="lazy"
referrerpolicy="no-referrer"
style="width: 100%; height: 100%; border: 0;"
></iframe>
</div>
</div>
<div class="card-body d-flex flex-column gap-2">
@@ -96,6 +103,8 @@
data-display-name="{{ d.name }}"
data-current-desc="{{ d.description or '' }}"
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-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}"
>
@@ -157,6 +166,22 @@
</select>
<div class="form-text">Applied on the display when switching between playlist items.</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" />
<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>
@@ -217,17 +242,23 @@
function refreshPreviewIframe(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 {
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).
u.searchParams.set('preview', '1');
u.searchParams.set('_ts', String(Date.now()));
iframe.src = u.toString();
} catch (e) {
// Fallback: naive cache buster
const sep = iframe.src.includes('?') ? '&' : '?';
iframe.src = `${iframe.src}${sep}_ts=${Date.now()}`;
const baseSrc = iframe.dataset.previewSrc || iframe.src;
if (!baseSrc) return;
const sep = baseSrc.includes('?') ? '&' : '?';
iframe.src = `${baseSrc}${sep}_ts=${Date.now()}`;
}
}
@@ -301,6 +332,8 @@
const plDescInputEl = document.getElementById('editPlaylistsDescInput');
const plDescCountEl = document.getElementById('editPlaylistsDescCount');
const plTransitionEl = document.getElementById('editPlaylistsTransitionSelect');
const plShowOverlayEl = document.getElementById('editPlaylistsShowOverlayCheck');
const tickerEnabledEl = document.getElementById('editTickerEnabled');
let activePlDisplayId = null;
let activePlButton = null;
@@ -365,6 +398,16 @@
const currentTransition = (btn.dataset.currentTransition || 'none').toLowerCase();
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);
renderPlaylistCheckboxes(selected);
if (plHintEl) {
@@ -379,11 +422,18 @@
const ids = getSelectedPlaylistIdsFromModal();
const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : '';
const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none';
const showOverlay = plShowOverlayEl ? !!plShowOverlayEl.checked : false;
const tickerEnabled = tickerEnabledEl ? !!tickerEnabledEl.checked : false;
plSaveBtn.disabled = true;
try {
const [updatedPlaylists, updatedDesc] = await Promise.all([
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)
@@ -401,6 +451,16 @@
const newTransition = updatedDesc && typeof updatedDesc.transition === 'string' ? updatedDesc.transition : transition;
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');
refreshPreviewIframe(activePlDisplayId);
if (plModal) plModal.hide();
@@ -414,6 +474,31 @@
if (plSaveBtn) {
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>
{% endblock %}

View File

@@ -90,7 +90,7 @@
</div>
</div>
<div class="card card-elevated mt-4">
<div class="card card-elevated mt-4">
<div class="card-header">
<h2 class="h5 mb-0">Users</h2>
</div>
@@ -137,5 +137,178 @@
</tbody>
</table>
</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>
{% 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" %}
{% 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) #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.css" />
<style>
@@ -230,7 +237,10 @@
<h2 class="h5 mb-0">Items</h2>
<div class="text-muted small">Tip: drag items to reorder. Changes save automatically.</div>
</div>
<button class="btn btn-brand" type="button" id="open-add-item">Add item</button>
<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>
</div>
</div>
<div class="mt-3">
@@ -319,46 +329,33 @@
<input type="hidden" name="item_type" id="item_type" value="image" />
<input type="hidden" name="crop_mode" id="crop_mode" value="16:9" />
<div class="mb-2">
<label class="form-label">Title (optional)</label>
<input class="form-control" name="title" />
</div>
{# 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="btn-group w-100" role="group" aria-label="Slide type">
<input type="radio" class="btn-check" name="item_type_choice" id="type-image" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="type-image">Image</label>
<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>
<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>
<div class="mb-3">
<label class="form-label">Type</label>
<div class="btn-group w-100" role="group" aria-label="Item type">
<input type="radio" class="btn-check" name="item_type_choice" id="type-image" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="type-image">Image</label>
<input type="radio" class="btn-check" name="item_type_choice" id="type-webpage" autocomplete="off">
<label class="btn btn-outline-primary" for="type-webpage">Webpage</label>
<input type="radio" class="btn-check" name="item_type_choice" id="type-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">
<label class="btn btn-outline-primary" for="type-video">Video</label>
<input type="radio" class="btn-check" name="item_type_choice" id="type-video" autocomplete="off">
<label class="btn btn-outline-primary" for="type-video">Video</label>
</div>
</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>
{# 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="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>
<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 class="text-muted small mt-1">Cropping is optional. If enabled, we center-crop to the chosen aspect ratio.</div>
</div>
<style>
@@ -393,8 +390,33 @@
}
</style>
<div id="step-select" class="step active">
<div class="text-muted small mb-2">Select or upload your media. If you upload an image, youll crop it next.</div>
<div id="step-input" class="step">
<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 #}
<div id="section-image" class="item-type-section">
@@ -433,7 +455,7 @@
</div>
</div>
{# YouTube section #}
{# YouTube section (also used as video source) #}
<div id="section-youtube" class="item-type-section d-none">
<div class="mb-2">
<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>
{# Video section #}
{# Video upload section #}
<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><strong>Drag & drop</strong> a video here</div>
<div class="text-muted small">or click to select a file</div>
@@ -494,6 +516,65 @@
</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 #}
<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 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 backBtn = document.getElementById('add-item-back');
const errorEl = document.getElementById('add-item-error');
const stepInputHint = document.getElementById('step-input-hint');
function setError(msg) {
if (!errorEl) return;
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) {
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');
const isCrop = which === 'crop';
backBtn.disabled = !isCrop;
// Back is enabled for all steps except the first.
backBtn.disabled = (which === 'type');
// For image: allow Add only in crop step (so we always crop if image)
if (typeHidden.value === 'image') {
submitBtn.disabled = !isCrop;
// Enable Next for the initial steps.
if (which === 'type' || which === 'video-source') {
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) {
// For this UI, "video" is a top-level type, but it can map to item_type=video OR item_type=youtube.
typeHidden.value = t;
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') {
destroyCropper();
showStep('select');
backBtn.disabled = true;
// Visible section is decided by type + (video source)
const vs = (t === 'video') ? videoSource() : null;
const effectiveType = (t === 'video' && vs === 'youtube') ? 'youtube' : t;
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.
if (t === 'image') {
submitBtn.disabled = true;
backBtn.disabled = true;
}
// Reset cropper when leaving image.
if (effectiveType !== 'image') destroyCropper();
}
function currentCropMode() {
@@ -705,10 +820,39 @@
}
}
document.getElementById('type-image')?.addEventListener('change', () => setType('image'));
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage'));
document.getElementById('type-youtube')?.addEventListener('change', () => setType('youtube'));
document.getElementById('type-video')?.addEventListener('change', () => setType('video'));
document.getElementById('type-image')?.addEventListener('change', () => {
setType('image');
// Stay on type step; user clicks Next.
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
@@ -754,7 +898,7 @@
cropStatus.textContent = '';
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');
// Wait for image to be ready
@@ -837,9 +981,15 @@
cropStatus.textContent = 'Preparing cropped image…';
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({
width: isPortrait ? 720 : 1280,
height: isPortrait ? 1280 : 720,
width: isPortrait ? TARGET_H : TARGET_W,
height: isPortrait ? TARGET_W : TARGET_H,
imageSmoothingQuality: 'high',
});
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
@@ -991,15 +1141,7 @@
}
// Reset modal state + close
form.reset();
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();
resetModalState();
modal?.hide();
}
@@ -1098,32 +1240,87 @@
if (t === 'webpage') {
// Keep preview behavior
schedulePreview();
} else {
// Hide webpage preview if not active
preview?.classList.add('d-none');
if (iframe) iframe.src = 'about:blank';
if (openLink) openLink.href = '#';
return;
}
// Hide webpage preview if not active
preview?.classList.add('d-none');
if (iframe) iframe.src = 'about:blank';
if (openLink) openLink.href = '#';
}
// Set initial state
setType('image');
if (cropModeHidden) cropModeHidden.value = '16:9';
showStep('select');
syncEnabledInputs();
updateCropHint();
function resetModalState() {
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';
document.getElementById('crop-16-9')?.click();
// Set UI for default type, but start at type selection step
setType('image');
showStep('type');
syncEnabledInputs();
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
openBtn?.addEventListener('click', () => {
// Always start from the beginning.
resetModalState();
modal?.show();
});
// Back button: only relevant for image crop step
// Back button: stepwise navigation
backBtn?.addEventListener('click', () => {
if (typeHidden.value === 'image') {
showStep('select');
if (currentStep === 'crop') {
// Going back from crop returns to input for image
showStep('input');
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'));
// 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);
});
// Add button
submitBtn?.addEventListener('click', async () => {
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();
} catch (err) {
console.warn(err);
@@ -1245,6 +1472,372 @@
</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() {

View File

@@ -8,6 +8,23 @@
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
#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) */
#stage .slide {
position: absolute;
@@ -87,6 +104,41 @@
margin: 0;
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; }
/* removed bottom-left status text */
</style>
@@ -99,9 +151,16 @@
</div>
</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>
const token = "{{ display.token }}";
const stage = document.getElementById('stage');
let overlayEl = document.getElementById('overlay');
const noticeEl = document.getElementById('notice');
const noticeTitleEl = document.getElementById('noticeTitle');
const noticeTextEl = document.getElementById('noticeText');
@@ -137,6 +196,21 @@
let idx = 0;
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;
function getTransitionMode(pl) {
@@ -159,11 +233,216 @@
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() {
if (timer) { clearTimeout(timer); timer = null; }
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) {
if (item.type === 'image') {
const el = document.createElement('img');
@@ -272,6 +551,15 @@
playlist = await fetchPlaylist();
idx = 0;
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();
} catch (e) {
clearStage();
@@ -300,6 +588,39 @@
const oldStr = JSON.stringify(playlist);
const newStr = JSON.stringify(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) {
idx = 0;
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:]))