Compare commits
9 Commits
f4b7fb62f5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 613f53ba9e | |||
| 5221f9f670 | |||
| 0c2720618a | |||
| c5aa8a5156 | |||
| 9fd3f03b87 | |||
| 860679d119 | |||
| 78f0f379fc | |||
| 56760e380d | |||
| 47aca9d64d |
61
README.md
61
README.md
@@ -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:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
61
app/cli.py
61
app/cli.py
@@ -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)"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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, you’ll 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
136
scripts/release.py
Normal 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:]))
|
||||
Reference in New Issue
Block a user