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_WORKERS` (default: 2)
|
||||||
- `GUNICORN_BIND` (default: `0.0.0.0:8000`)
|
- `GUNICORN_BIND` (default: `0.0.0.0:8000`)
|
||||||
|
|
||||||
|
## Release helper (git + docker publish)
|
||||||
|
|
||||||
|
This repo includes a small helper to:
|
||||||
|
|
||||||
|
1) ask for a **commit message** and **version**
|
||||||
|
2) commit + push to the `openslide` git remote
|
||||||
|
3) build + push Docker images:
|
||||||
|
- `git.alphen.cloud/bramval/openslide:<version>`
|
||||||
|
- `git.alphen.cloud/bramval/openslide:latest`
|
||||||
|
|
||||||
|
Run (interactive):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/release.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Run (non-interactive):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/release.py --version 1.2.3 --message "Release 1.2.3"
|
||||||
|
```
|
||||||
|
|
||||||
|
Dry-run (prints commands only):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/release.py --version 1.2.3 --message "Release 1.2.3" --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- SQLite DB is stored at `instance/signage.sqlite`.
|
- SQLite DB is stored at `instance/signage.sqlite`.
|
||||||
@@ -159,6 +187,34 @@ The player keeps itself up-to-date automatically:
|
|||||||
- It listens to `GET /api/display/<token>/events` (Server-Sent Events) and reloads the playlist immediately when it changes.
|
- It listens to `GET /api/display/<token>/events` (Server-Sent Events) and reloads the playlist immediately when it changes.
|
||||||
- It also does a fallback playlist refresh every 5 minutes for networks/proxies that block SSE.
|
- It also does a fallback playlist refresh every 5 minutes for networks/proxies that block SSE.
|
||||||
|
|
||||||
|
## Ticker tape (RSS headlines)
|
||||||
|
|
||||||
|
Each display can optionally show a **bottom ticker tape** with scrolling news headlines.
|
||||||
|
|
||||||
|
Configure RSS + styling as a company user via:
|
||||||
|
|
||||||
|
- **My Company → Ticker tape (RSS)**
|
||||||
|
|
||||||
|
Company-level options:
|
||||||
|
|
||||||
|
- RSS feed URL (public http/https)
|
||||||
|
- Text color (picker)
|
||||||
|
- Background color + opacity
|
||||||
|
- Font (dropdown)
|
||||||
|
- Font size
|
||||||
|
- Speed
|
||||||
|
|
||||||
|
Per-display option:
|
||||||
|
|
||||||
|
- Enable/disable ticker on that display (Dashboard → Displays → Configure display)
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Headlines are fetched server-side via `GET /api/display/<token>/ticker` and cached in-memory.
|
||||||
|
- The player reads the company ticker settings via `GET /api/display/<token>/playlist`.
|
||||||
|
- The player auto-refreshes headlines without restart on a **long interval** (default: **12 hours**, override via `?ticker_poll=seconds`).
|
||||||
|
- Server-side cache TTL defaults to **6 hours** (override via env var `TICKER_CACHE_TTL_SECONDS`).
|
||||||
|
|
||||||
## SMTP / Forgot password
|
## SMTP / Forgot password
|
||||||
|
|
||||||
This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables.
|
This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables.
|
||||||
@@ -228,6 +284,11 @@ If the reset email is not received:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,24 @@ def create_app():
|
|||||||
app.config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", False)
|
app.config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", False)
|
||||||
app.config.setdefault("UPLOAD_FOLDER", os.path.join(app.root_path, "static", "uploads"))
|
app.config.setdefault("UPLOAD_FOLDER", os.path.join(app.root_path, "static", "uploads"))
|
||||||
|
|
||||||
|
# Target output resolution for cropped images.
|
||||||
|
# This is used by the client-side cropper (to generate an upload) and by the server-side
|
||||||
|
# image processing (to cap the resulting WEBP size).
|
||||||
|
#
|
||||||
|
# Defaults to Full HD landscape (1920x1080). Portrait is derived by swapping.
|
||||||
|
# Override via env vars, e.g.:
|
||||||
|
# IMAGE_CROP_TARGET_W=1920
|
||||||
|
# IMAGE_CROP_TARGET_H=1080
|
||||||
|
def _env_int(name: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
v = int(os.environ.get(name, "") or default)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
v = default
|
||||||
|
return max(1, v)
|
||||||
|
|
||||||
|
app.config.setdefault("IMAGE_CROP_TARGET_W", _env_int("IMAGE_CROP_TARGET_W", 1920))
|
||||||
|
app.config.setdefault("IMAGE_CROP_TARGET_H", _env_int("IMAGE_CROP_TARGET_H", 1080))
|
||||||
|
|
||||||
# NOTE: Videos should be max 250MB.
|
# NOTE: Videos should be max 250MB.
|
||||||
# Flask's MAX_CONTENT_LENGTH applies to the full request payload (multipart includes overhead).
|
# Flask's MAX_CONTENT_LENGTH applies to the full request payload (multipart includes overhead).
|
||||||
# We set this slightly above 250MB to allow for multipart/form fields overhead, while still
|
# We set this slightly above 250MB to allow for multipart/form fields overhead, while still
|
||||||
@@ -62,6 +80,41 @@ def create_app():
|
|||||||
db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)"))
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)"))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# Displays: per-display overlay toggle
|
||||||
|
if "show_overlay" not in display_cols:
|
||||||
|
db.session.execute(
|
||||||
|
db.text("ALTER TABLE display ADD COLUMN show_overlay BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Displays: optional ticker tape (RSS headlines)
|
||||||
|
if "ticker_enabled" not in display_cols:
|
||||||
|
db.session.execute(
|
||||||
|
db.text("ALTER TABLE display ADD COLUMN ticker_enabled BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_rss_url" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_rss_url VARCHAR(1000)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_color" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_color VARCHAR(32)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_bg_color" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_color VARCHAR(32)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_bg_opacity" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_opacity INTEGER"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_font_family" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_family VARCHAR(120)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_font_size_px" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_size_px INTEGER"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_speed" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_speed INTEGER"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
# Companies: optional per-company storage quota
|
# Companies: optional per-company storage quota
|
||||||
company_cols = [
|
company_cols = [
|
||||||
r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall()
|
r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall()
|
||||||
@@ -70,6 +123,34 @@ def create_app():
|
|||||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# Companies: optional overlay file path
|
||||||
|
if "overlay_file_path" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Companies: ticker tape settings (RSS + styling)
|
||||||
|
if "ticker_rss_url" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_rss_url VARCHAR(1000)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_color" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_color VARCHAR(32)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_bg_color" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_color VARCHAR(32)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_bg_opacity" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_opacity INTEGER"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_font_family" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_family VARCHAR(120)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_font_size_px" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_size_px INTEGER"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_speed" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_speed INTEGER"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
# AppSettings: create settings table if missing.
|
# AppSettings: create settings table if missing.
|
||||||
# (PRAGMA returns empty if the table doesn't exist.)
|
# (PRAGMA returns empty if the table doesn't exist.)
|
||||||
settings_cols = [
|
settings_cols = [
|
||||||
|
|||||||
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.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)"))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
if "transition" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if "show_overlay" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN show_overlay BOOLEAN NOT NULL DEFAULT 0"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Optional ticker tape (RSS headlines)
|
||||||
|
if "ticker_enabled" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_enabled BOOLEAN NOT NULL DEFAULT 0"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_rss_url" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_rss_url VARCHAR(1000)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_color" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_color VARCHAR(32)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_bg_color" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_color VARCHAR(32)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_bg_opacity" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_opacity INTEGER"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_font_family" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_family VARCHAR(120)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_font_size_px" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_size_px INTEGER"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_speed" not in display_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_speed INTEGER"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
company_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall()]
|
company_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall()]
|
||||||
if "storage_max_bytes" not in company_cols:
|
if "storage_max_bytes" not in company_cols:
|
||||||
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT"))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
if "overlay_file_path" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Companies: ticker tape settings (RSS + styling)
|
||||||
|
if "ticker_rss_url" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_rss_url VARCHAR(1000)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_color" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_color VARCHAR(32)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_bg_color" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_color VARCHAR(32)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_bg_opacity" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_opacity INTEGER"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_font_family" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_family VARCHAR(120)"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_font_size_px" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_size_px INTEGER"))
|
||||||
|
db.session.commit()
|
||||||
|
if "ticker_speed" not in company_cols:
|
||||||
|
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_speed INTEGER"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
settings_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(app_settings)")).fetchall()]
|
settings_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(app_settings)")).fetchall()]
|
||||||
if settings_cols and "public_domain" not in settings_cols:
|
if settings_cols and "public_domain" not in settings_cols:
|
||||||
db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)"))
|
db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)"))
|
||||||
|
|||||||
@@ -16,6 +16,20 @@ class Company(db.Model):
|
|||||||
# If NULL or <=0: unlimited.
|
# If NULL or <=0: unlimited.
|
||||||
storage_max_bytes = db.Column(db.BigInteger, nullable=True)
|
storage_max_bytes = db.Column(db.BigInteger, nullable=True)
|
||||||
|
|
||||||
|
# Optional per-company 16:9 PNG overlay (stored under /static/uploads/...)
|
||||||
|
# Example: uploads/<company_id>/overlay_<uuid>.png
|
||||||
|
overlay_file_path = db.Column(db.String(400), nullable=True)
|
||||||
|
|
||||||
|
# Optional per-company ticker tape (RSS headlines) settings.
|
||||||
|
# Displays can enable/disable the ticker individually.
|
||||||
|
ticker_rss_url = db.Column(db.String(1000), nullable=True)
|
||||||
|
ticker_color = db.Column(db.String(32), nullable=True)
|
||||||
|
ticker_bg_color = db.Column(db.String(32), nullable=True)
|
||||||
|
ticker_bg_opacity = db.Column(db.Integer, nullable=True) # 0-100
|
||||||
|
ticker_font_family = db.Column(db.String(120), nullable=True)
|
||||||
|
ticker_font_size_px = db.Column(db.Integer, nullable=True)
|
||||||
|
ticker_speed = db.Column(db.Integer, nullable=True) # 1-100
|
||||||
|
|
||||||
users = db.relationship("User", back_populates="company", cascade="all, delete-orphan")
|
users = db.relationship("User", back_populates="company", cascade="all, delete-orphan")
|
||||||
displays = db.relationship("Display", back_populates="company", cascade="all, delete-orphan")
|
displays = db.relationship("Display", back_populates="company", cascade="all, delete-orphan")
|
||||||
playlists = db.relationship("Playlist", back_populates="company", cascade="all, delete-orphan")
|
playlists = db.relationship("Playlist", back_populates="company", cascade="all, delete-orphan")
|
||||||
@@ -102,6 +116,20 @@ class Display(db.Model):
|
|||||||
description = db.Column(db.String(200), nullable=True)
|
description = db.Column(db.String(200), nullable=True)
|
||||||
# Transition animation between slides: none|fade|slide
|
# Transition animation between slides: none|fade|slide
|
||||||
transition = db.Column(db.String(20), nullable=True)
|
transition = db.Column(db.String(20), nullable=True)
|
||||||
|
|
||||||
|
# Optional ticker tape (RSS headlines) rendered on the display.
|
||||||
|
# Note: for this small project we avoid a JSON config blob; we store a few explicit columns.
|
||||||
|
ticker_enabled = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
ticker_rss_url = db.Column(db.String(1000), nullable=True)
|
||||||
|
ticker_color = db.Column(db.String(32), nullable=True) # CSS color, e.g. "#ffffff"
|
||||||
|
ticker_bg_color = db.Column(db.String(32), nullable=True) # hex (without alpha); opacity in ticker_bg_opacity
|
||||||
|
ticker_bg_opacity = db.Column(db.Integer, nullable=True) # 0-100
|
||||||
|
ticker_font_family = db.Column(db.String(120), nullable=True) # CSS font-family
|
||||||
|
ticker_font_size_px = db.Column(db.Integer, nullable=True) # px
|
||||||
|
ticker_speed = db.Column(db.Integer, nullable=True) # 1-100 (UI slider); higher = faster
|
||||||
|
|
||||||
|
# If true, show the company's overlay PNG on top of the display content.
|
||||||
|
show_overlay = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
token = db.Column(db.String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex)
|
token = db.Column(db.String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex)
|
||||||
|
|
||||||
assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True)
|
assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True)
|
||||||
|
|||||||
@@ -395,6 +395,10 @@ def delete_company(company_id: int):
|
|||||||
if it.item_type in ("image", "video"):
|
if it.item_type in ("image", "video"):
|
||||||
_try_delete_upload(it.file_path, upload_folder)
|
_try_delete_upload(it.file_path, upload_folder)
|
||||||
|
|
||||||
|
# 3b) Clean up uploaded overlay (if any)
|
||||||
|
if company.overlay_file_path:
|
||||||
|
_try_delete_upload(company.overlay_file_path, upload_folder)
|
||||||
|
|
||||||
# 4) Delete the company; cascades will delete users/displays/playlists/items.
|
# 4) Delete the company; cascades will delete users/displays/playlists/items.
|
||||||
company_name = company.name
|
company_name = company.name
|
||||||
db.session.delete(company)
|
db.session.delete(company)
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for
|
from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for
|
||||||
|
|
||||||
from ..extensions import db
|
from ..extensions import db
|
||||||
from ..models import Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem
|
from ..models import Company, Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem
|
||||||
|
from ..uploads import is_valid_upload_relpath
|
||||||
|
|
||||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||||
|
|
||||||
@@ -14,6 +20,21 @@ bp = Blueprint("api", __name__, url_prefix="/api")
|
|||||||
MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3
|
MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3
|
||||||
SESSION_TTL_SECONDS = 90
|
SESSION_TTL_SECONDS = 90
|
||||||
|
|
||||||
|
# RSS ticker cache (in-memory; OK for this small app; avoids hammering feeds)
|
||||||
|
#
|
||||||
|
# Default is intentionally long because displays can refresh headlines on a long interval
|
||||||
|
# (e.g., twice per day) and we don't want many displays to re-fetch the same feed.
|
||||||
|
# Override via env var `TICKER_CACHE_TTL_SECONDS`.
|
||||||
|
def _env_int(name: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(os.environ.get(name, "") or default)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
TICKER_CACHE_TTL_SECONDS = max(10, _env_int("TICKER_CACHE_TTL_SECONDS", 6 * 60 * 60))
|
||||||
|
_TICKER_CACHE: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def _is_playlist_active_now(p: Playlist, now_utc: datetime) -> bool:
|
def _is_playlist_active_now(p: Playlist, now_utc: datetime) -> bool:
|
||||||
"""Return True if playlist is active based on its optional schedule window."""
|
"""Return True if playlist is active based on its optional schedule window."""
|
||||||
@@ -167,12 +188,141 @@ def _playlist_signature(display: Display) -> tuple[int | None, str]:
|
|||||||
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_http_url_allowed(url: str) -> bool:
|
||||||
|
"""Basic SSRF hardening: only allow http(s) and disallow obvious local targets."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
u = urlparse(url)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if u.scheme not in {"http", "https"}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
host = (u.hostname or "").strip().lower()
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Block localhost and common local domains.
|
||||||
|
if host in {"localhost", "127.0.0.1", "::1"}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Block RFC1918-ish and link-local targets when host is an IP.
|
||||||
|
# Note: this is best-effort; proper SSRF protection would require DNS resolution too.
|
||||||
|
if re.match(r"^\d+\.\d+\.\d+\.\d+$", host):
|
||||||
|
parts = [int(x) for x in host.split(".")]
|
||||||
|
if parts[0] == 10:
|
||||||
|
return False
|
||||||
|
if parts[0] == 127:
|
||||||
|
return False
|
||||||
|
if parts[0] == 169 and parts[1] == 254:
|
||||||
|
return False
|
||||||
|
if parts[0] == 192 and parts[1] == 168:
|
||||||
|
return False
|
||||||
|
if parts[0] == 172 and 16 <= parts[1] <= 31:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_text(s: str) -> str:
|
||||||
|
s = (s or "").strip()
|
||||||
|
s = re.sub(r"\s+", " ", s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_rss_titles(url: str, *, limit: int = 20) -> list[str]:
|
||||||
|
"""Fetch RSS/Atom titles from a feed URL.
|
||||||
|
|
||||||
|
We intentionally avoid adding dependencies (feedparser) for this project.
|
||||||
|
This implementation is tolerant enough for typical RSS2/Atom feeds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
req = Request(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "SignageTicker/1.0 (+https://example.invalid)",
|
||||||
|
"Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
|
||||||
|
},
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
|
||||||
|
with urlopen(req, timeout=8) as resp:
|
||||||
|
# Basic size cap (avoid reading huge responses into memory)
|
||||||
|
raw = resp.read(2_000_000) # 2MB
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(raw)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
titles: list[str] = []
|
||||||
|
|
||||||
|
# RSS2: <rss><channel><item><title>
|
||||||
|
for el in root.findall(".//item/title"):
|
||||||
|
t = _strip_text("".join(el.itertext()))
|
||||||
|
if t:
|
||||||
|
titles.append(t)
|
||||||
|
|
||||||
|
# Atom: <feed><entry><title>
|
||||||
|
if not titles:
|
||||||
|
for el in root.findall(".//{*}entry/{*}title"):
|
||||||
|
t = _strip_text("".join(el.itertext()))
|
||||||
|
if t:
|
||||||
|
titles.append(t)
|
||||||
|
|
||||||
|
# Some feeds may have <channel><title> etc; we only want entry titles.
|
||||||
|
# Deduplicate while preserving order.
|
||||||
|
deduped: list[str] = []
|
||||||
|
seen = set()
|
||||||
|
for t in titles:
|
||||||
|
if t in seen:
|
||||||
|
continue
|
||||||
|
seen.add(t)
|
||||||
|
deduped.append(t)
|
||||||
|
if len(deduped) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ticker_titles_cached(url: str) -> tuple[list[str], bool]:
|
||||||
|
"""Return (titles, from_cache)."""
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
key = (url or "").strip()
|
||||||
|
if not key:
|
||||||
|
return [], True
|
||||||
|
|
||||||
|
entry = _TICKER_CACHE.get(key)
|
||||||
|
if entry and (now - float(entry.get("ts") or 0)) < TICKER_CACHE_TTL_SECONDS:
|
||||||
|
return (entry.get("titles") or []), True
|
||||||
|
|
||||||
|
titles: list[str] = []
|
||||||
|
try:
|
||||||
|
if _is_http_url_allowed(key):
|
||||||
|
titles = _fetch_rss_titles(key)
|
||||||
|
except Exception:
|
||||||
|
titles = []
|
||||||
|
|
||||||
|
_TICKER_CACHE[key] = {"ts": now, "titles": titles}
|
||||||
|
return titles, False
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/display/<token>/playlist")
|
@bp.get("/display/<token>/playlist")
|
||||||
def display_playlist(token: str):
|
def display_playlist(token: str):
|
||||||
display = Display.query.filter_by(token=token).first()
|
display = Display.query.filter_by(token=token).first()
|
||||||
if not display:
|
if not display:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
company = Company.query.filter_by(id=display.company_id).first()
|
||||||
|
|
||||||
|
# Optional overlay URL (per-company) when enabled on this display.
|
||||||
|
overlay_src = None
|
||||||
|
if display.show_overlay:
|
||||||
|
if company and company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path):
|
||||||
|
overlay_src = url_for("static", filename=company.overlay_file_path)
|
||||||
|
|
||||||
# Enforce: a display URL/token can be opened by max 3 concurrently active sessions.
|
# Enforce: a display URL/token can be opened by max 3 concurrently active sessions.
|
||||||
# Player sends a stable `sid` via querystring.
|
# Player sends a stable `sid` via querystring.
|
||||||
sid = request.args.get("sid")
|
sid = request.args.get("sid")
|
||||||
@@ -180,6 +330,18 @@ def display_playlist(token: str):
|
|||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
# Ticker settings are configured per-company; displays can enable/disable individually.
|
||||||
|
ticker_cfg = {
|
||||||
|
"enabled": bool(display.ticker_enabled),
|
||||||
|
"rss_url": (company.ticker_rss_url if company else None),
|
||||||
|
"color": (company.ticker_color if company else None),
|
||||||
|
"bg_color": (company.ticker_bg_color if company else None),
|
||||||
|
"bg_opacity": (company.ticker_bg_opacity if company else None),
|
||||||
|
"font_family": (company.ticker_font_family if company else None),
|
||||||
|
"font_size_px": (company.ticker_font_size_px if company else None),
|
||||||
|
"speed": (company.ticker_speed if company else None),
|
||||||
|
}
|
||||||
|
|
||||||
# Determine active playlists. If display_playlist has any rows, use those.
|
# Determine active playlists. If display_playlist has any rows, use those.
|
||||||
# Otherwise fall back to the legacy assigned_playlist_id.
|
# Otherwise fall back to the legacy assigned_playlist_id.
|
||||||
mapped_ids = [
|
mapped_ids = [
|
||||||
@@ -200,6 +362,8 @@ def display_playlist(token: str):
|
|||||||
{
|
{
|
||||||
"display": display.name,
|
"display": display.name,
|
||||||
"transition": display.transition or "none",
|
"transition": display.transition or "none",
|
||||||
|
"overlay_src": overlay_src,
|
||||||
|
"ticker": ticker_cfg,
|
||||||
"playlists": [],
|
"playlists": [],
|
||||||
"items": [],
|
"items": [],
|
||||||
}
|
}
|
||||||
@@ -263,12 +427,51 @@ def display_playlist(token: str):
|
|||||||
{
|
{
|
||||||
"display": display.name,
|
"display": display.name,
|
||||||
"transition": display.transition or "none",
|
"transition": display.transition or "none",
|
||||||
|
"overlay_src": overlay_src,
|
||||||
|
"ticker": ticker_cfg,
|
||||||
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
|
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/display/<token>/ticker")
|
||||||
|
def display_ticker(token: str):
|
||||||
|
"""Return ticker headlines for a display.
|
||||||
|
|
||||||
|
We keep it separate from /playlist so the player can refresh headlines on its own interval.
|
||||||
|
"""
|
||||||
|
|
||||||
|
display = Display.query.filter_by(token=token).first()
|
||||||
|
if not display:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
company = Company.query.filter_by(id=display.company_id).first()
|
||||||
|
|
||||||
|
# Enforce concurrent session limit the same way as /playlist.
|
||||||
|
sid = request.args.get("sid")
|
||||||
|
ok, resp = _enforce_and_touch_display_session(display, sid)
|
||||||
|
if not ok:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
if not display.ticker_enabled:
|
||||||
|
return jsonify({"enabled": False, "headlines": []})
|
||||||
|
|
||||||
|
rss_url = ((company.ticker_rss_url if company else None) or "").strip()
|
||||||
|
if not rss_url:
|
||||||
|
return jsonify({"enabled": True, "headlines": []})
|
||||||
|
|
||||||
|
titles, from_cache = _get_ticker_titles_cached(rss_url)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"rss_url": rss_url,
|
||||||
|
"headlines": titles,
|
||||||
|
"cached": bool(from_cache),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/display/<token>/events")
|
@bp.get("/display/<token>/events")
|
||||||
def display_events(token: str):
|
def display_events(token: str):
|
||||||
"""Server-Sent Events stream to notify the player when its playlist changes."""
|
"""Server-Sent Events stream to notify the player when its playlist changes."""
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ from ..auth_tokens import make_password_reset_token
|
|||||||
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"}
|
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"}
|
||||||
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".m4v"}
|
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".m4v"}
|
||||||
|
|
||||||
|
# Overlay is a transparent PNG that sits on top of a display.
|
||||||
|
ALLOWED_OVERLAY_EXTENSIONS = {".png"}
|
||||||
|
|
||||||
|
# Keep overlay size reasonable; it will be stretched to fit anyway.
|
||||||
|
# (PNG overlays are typically small-ish; 10MB is generous.)
|
||||||
|
MAX_OVERLAY_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
# Videos should have a maximum upload size of 250MB
|
# Videos should have a maximum upload size of 250MB
|
||||||
MAX_VIDEO_BYTES = 250 * 1024 * 1024
|
MAX_VIDEO_BYTES = 250 * 1024 * 1024
|
||||||
|
|
||||||
@@ -140,15 +147,22 @@ def _save_compressed_image(
|
|||||||
img = img.convert("RGB")
|
img = img.convert("RGB")
|
||||||
|
|
||||||
# Optional crop
|
# Optional crop
|
||||||
|
# NOTE: The front-end may already upload a cropped image (canvas export), but we still
|
||||||
|
# enforce aspect + maximum output size here for consistency.
|
||||||
|
target_w = int(current_app.config.get("IMAGE_CROP_TARGET_W", 1920) or 1920)
|
||||||
|
target_h = int(current_app.config.get("IMAGE_CROP_TARGET_H", 1080) or 1080)
|
||||||
|
target_w = max(1, target_w)
|
||||||
|
target_h = max(1, target_h)
|
||||||
|
|
||||||
if cm == "16:9":
|
if cm == "16:9":
|
||||||
img = _center_crop_to_aspect(img, 16, 9)
|
img = _center_crop_to_aspect(img, 16, 9)
|
||||||
max_box = (1920, 1080)
|
max_box = (target_w, target_h)
|
||||||
elif cm == "9:16":
|
elif cm == "9:16":
|
||||||
img = _center_crop_to_aspect(img, 9, 16)
|
img = _center_crop_to_aspect(img, 9, 16)
|
||||||
max_box = (1080, 1920)
|
max_box = (target_h, target_w)
|
||||||
else:
|
else:
|
||||||
# No crop: allow both portrait and landscape up to 1920px on the longest side.
|
# No crop: allow both portrait and landscape up to target_w/target_h on the longest side.
|
||||||
max_box = (1920, 1920)
|
max_box = (max(target_w, target_h),) * 2
|
||||||
|
|
||||||
# Resize down if very large (keeps aspect ratio)
|
# Resize down if very large (keeps aspect ratio)
|
||||||
img.thumbnail(max_box)
|
img.thumbnail(max_box)
|
||||||
@@ -175,6 +189,68 @@ def _try_delete_upload(file_path: str | None, upload_root: str):
|
|||||||
# Ignore cleanup failures
|
# Ignore cleanup failures
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _save_overlay_png(
|
||||||
|
uploaded_file,
|
||||||
|
upload_root: str,
|
||||||
|
company_id: int | None,
|
||||||
|
) -> str:
|
||||||
|
"""Save a company overlay as PNG under the company's upload dir.
|
||||||
|
|
||||||
|
Returns relative file path under /static (uploads/<company_id>/overlay_<uuid>.png)
|
||||||
|
"""
|
||||||
|
|
||||||
|
unique = f"overlay_{uuid.uuid4().hex}.png"
|
||||||
|
company_dir = ensure_company_upload_dir(upload_root, company_id)
|
||||||
|
save_path = os.path.join(company_dir, unique)
|
||||||
|
|
||||||
|
# Validate file is a PNG and is 16:9-ish.
|
||||||
|
# Use magic bytes (signature) instead of relying on Pillow's img.format,
|
||||||
|
# which can be unreliable if the stream position isn't at 0.
|
||||||
|
try:
|
||||||
|
if hasattr(uploaded_file, "stream"):
|
||||||
|
uploaded_file.stream.seek(0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
sig = uploaded_file.stream.read(8) if hasattr(uploaded_file, "stream") else uploaded_file.read(8)
|
||||||
|
except Exception:
|
||||||
|
sig = b""
|
||||||
|
|
||||||
|
# PNG file signature: 89 50 4E 47 0D 0A 1A 0A
|
||||||
|
if sig != b"\x89PNG\r\n\x1a\n":
|
||||||
|
raise ValueError("not_png")
|
||||||
|
|
||||||
|
# Rewind before Pillow parses.
|
||||||
|
try:
|
||||||
|
if hasattr(uploaded_file, "stream"):
|
||||||
|
uploaded_file.stream.seek(0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
img = Image.open(uploaded_file)
|
||||||
|
img = ImageOps.exif_transpose(img)
|
||||||
|
|
||||||
|
w, h = img.size
|
||||||
|
if not w or not h:
|
||||||
|
raise ValueError("invalid")
|
||||||
|
|
||||||
|
# Allow some tolerance (overlays may include extra transparent padding).
|
||||||
|
aspect = w / h
|
||||||
|
target = 16 / 9
|
||||||
|
if abs(aspect - target) > 0.15: # ~15% tolerance
|
||||||
|
raise ValueError("not_16_9")
|
||||||
|
|
||||||
|
# Ensure we preserve alpha; normalize mode.
|
||||||
|
if img.mode not in ("RGBA", "LA"):
|
||||||
|
# Convert to RGBA so transparency is supported consistently.
|
||||||
|
img = img.convert("RGBA")
|
||||||
|
|
||||||
|
img.save(save_path, format="PNG", optimize=True)
|
||||||
|
company_seg = str(int(company_id)) if company_id is not None else "0"
|
||||||
|
return f"uploads/{company_seg}/{unique}"
|
||||||
|
|
||||||
bp = Blueprint("company", __name__, url_prefix="/company")
|
bp = Blueprint("company", __name__, url_prefix="/company")
|
||||||
|
|
||||||
|
|
||||||
@@ -239,6 +315,44 @@ def _storage_limit_error_message(*, storage_max_human: str | None) -> str:
|
|||||||
return "Storage limit reached. Please delete items to free space."
|
return "Storage limit reached. Please delete items to free space."
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_css_color(val: str | None) -> str | None:
|
||||||
|
"""Accept a limited set of CSS color inputs (primarily hex + a few keywords)."""
|
||||||
|
|
||||||
|
v = (val or "").strip()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
|
||||||
|
low = v.lower()
|
||||||
|
if low in {"white", "black", "red", "green", "blue", "yellow", "orange", "purple", "gray", "grey"}:
|
||||||
|
return low
|
||||||
|
|
||||||
|
if low.startswith("#"):
|
||||||
|
h = low[1:]
|
||||||
|
if len(h) in {3, 6, 8} and all(c in "0123456789abcdef" for c in h):
|
||||||
|
return "#" + h
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_font_family(val: str | None) -> str | None:
|
||||||
|
v = (val or "").strip()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
v = v.replace("\n", " ").replace("\r", " ").replace('"', "").replace("'", "")
|
||||||
|
v = " ".join(v.split())
|
||||||
|
return v[:120] if v else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_int(val, *, min_val: int, max_val: int) -> int | None:
|
||||||
|
if val in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
n = int(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return min(max_val, max(min_val, n))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/my-company")
|
@bp.get("/my-company")
|
||||||
@login_required
|
@login_required
|
||||||
def my_company():
|
def my_company():
|
||||||
@@ -276,10 +390,15 @@ def my_company():
|
|||||||
|
|
||||||
users = User.query.filter_by(company_id=company.id, is_admin=False).order_by(User.email.asc()).all()
|
users = User.query.filter_by(company_id=company.id, is_admin=False).order_by(User.email.asc()).all()
|
||||||
|
|
||||||
|
overlay_url = None
|
||||||
|
if company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path):
|
||||||
|
overlay_url = url_for("static", filename=company.overlay_file_path)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"company/my_company.html",
|
"company/my_company.html",
|
||||||
company=company,
|
company=company,
|
||||||
users=users,
|
users=users,
|
||||||
|
overlay_url=overlay_url,
|
||||||
stats={
|
stats={
|
||||||
"users": user_count,
|
"users": user_count,
|
||||||
"displays": display_count,
|
"displays": display_count,
|
||||||
@@ -295,6 +414,142 @@ def my_company():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/my-company/ticker")
|
||||||
|
@login_required
|
||||||
|
def update_company_ticker_settings():
|
||||||
|
"""Update per-company ticker tape settings.
|
||||||
|
|
||||||
|
Note: per-display enable/disable remains on the Display model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
company_user_required()
|
||||||
|
|
||||||
|
company = db.session.get(Company, current_user.company_id)
|
||||||
|
if not company:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
rss_url = (request.form.get("ticker_rss_url") or "").strip() or None
|
||||||
|
if rss_url is not None:
|
||||||
|
rss_url = rss_url[:1000]
|
||||||
|
|
||||||
|
company.ticker_rss_url = rss_url
|
||||||
|
company.ticker_color = _normalize_css_color(request.form.get("ticker_color"))
|
||||||
|
company.ticker_bg_color = _normalize_css_color(request.form.get("ticker_bg_color"))
|
||||||
|
company.ticker_bg_opacity = _normalize_int(request.form.get("ticker_bg_opacity"), min_val=0, max_val=100)
|
||||||
|
company.ticker_font_family = _normalize_font_family(request.form.get("ticker_font_family"))
|
||||||
|
company.ticker_font_size_px = _normalize_int(request.form.get("ticker_font_size_px"), min_val=10, max_val=200)
|
||||||
|
company.ticker_speed = _normalize_int(request.form.get("ticker_speed"), min_val=1, max_val=100)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash("Ticker settings updated.", "success")
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/my-company/overlay")
|
||||||
|
@login_required
|
||||||
|
def upload_company_overlay():
|
||||||
|
"""Upload/replace the per-company 16:9 PNG overlay."""
|
||||||
|
|
||||||
|
company_user_required()
|
||||||
|
|
||||||
|
company = db.session.get(Company, current_user.company_id)
|
||||||
|
if not company:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
f = request.files.get("overlay")
|
||||||
|
if not f or not f.filename:
|
||||||
|
flash("Overlay file is required", "danger")
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
|
||||||
|
filename = secure_filename(f.filename)
|
||||||
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
if ext not in ALLOWED_OVERLAY_EXTENSIONS:
|
||||||
|
flash("Unsupported overlay type. Please upload a PNG file.", "danger")
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
|
||||||
|
# Enforce size limit best-effort.
|
||||||
|
size = None
|
||||||
|
try:
|
||||||
|
size = getattr(f, "content_length", None)
|
||||||
|
if (size is None or size <= 0) and hasattr(f, "stream"):
|
||||||
|
pos = f.stream.tell()
|
||||||
|
f.stream.seek(0, os.SEEK_END)
|
||||||
|
size = f.stream.tell()
|
||||||
|
f.stream.seek(pos, os.SEEK_SET)
|
||||||
|
except Exception:
|
||||||
|
size = None
|
||||||
|
if size is not None and size > MAX_OVERLAY_BYTES:
|
||||||
|
flash("Overlay file too large. Maximum allowed size is 10MB.", "danger")
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
|
||||||
|
# Enforce storage quota too (overlay is stored in the same uploads folder).
|
||||||
|
upload_root = current_app.config["UPLOAD_FOLDER"]
|
||||||
|
used_bytes = get_company_upload_bytes(upload_root, company.id)
|
||||||
|
usage = compute_storage_usage(used_bytes=used_bytes, max_bytes=company.storage_max_bytes)
|
||||||
|
storage_max_human = _format_bytes(usage["max_bytes"]) if usage.get("max_bytes") else None
|
||||||
|
if usage.get("is_exceeded"):
|
||||||
|
flash(_storage_limit_error_message(storage_max_human=storage_max_human), "danger")
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
|
||||||
|
old_path = company.overlay_file_path
|
||||||
|
try:
|
||||||
|
new_relpath = _save_overlay_png(f, upload_root, company.id)
|
||||||
|
except ValueError as e:
|
||||||
|
code = str(e)
|
||||||
|
if code == "not_png":
|
||||||
|
flash("Overlay must be a PNG file.", "danger")
|
||||||
|
elif code == "not_16_9":
|
||||||
|
flash("Overlay should be 16:9 (landscape).", "danger")
|
||||||
|
else:
|
||||||
|
flash("Failed to process overlay upload.", "danger")
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
except Exception:
|
||||||
|
flash("Failed to process overlay upload.", "danger")
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
|
||||||
|
# Post-save quota check (like images) because PNG size is unknown until saved.
|
||||||
|
if company.storage_max_bytes is not None and int(company.storage_max_bytes or 0) > 0:
|
||||||
|
try:
|
||||||
|
used_after = get_company_upload_bytes(upload_root, company.id)
|
||||||
|
except Exception:
|
||||||
|
used_after = None
|
||||||
|
if used_after is not None:
|
||||||
|
usage_after = compute_storage_usage(used_bytes=used_after, max_bytes=company.storage_max_bytes)
|
||||||
|
if usage_after.get("is_exceeded"):
|
||||||
|
_try_delete_upload(new_relpath, upload_root)
|
||||||
|
flash(_storage_limit_error_message(storage_max_human=storage_max_human), "danger")
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
|
||||||
|
company.overlay_file_path = new_relpath
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Clean up the old overlay file.
|
||||||
|
if old_path and old_path != new_relpath:
|
||||||
|
_try_delete_upload(old_path, upload_root)
|
||||||
|
|
||||||
|
flash("Overlay updated.", "success")
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/my-company/overlay/delete")
|
||||||
|
@login_required
|
||||||
|
def delete_company_overlay():
|
||||||
|
company_user_required()
|
||||||
|
|
||||||
|
company = db.session.get(Company, current_user.company_id)
|
||||||
|
if not company:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
upload_root = current_app.config["UPLOAD_FOLDER"]
|
||||||
|
old_path = company.overlay_file_path
|
||||||
|
company.overlay_file_path = None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
_try_delete_upload(old_path, upload_root)
|
||||||
|
flash("Overlay removed.", "success")
|
||||||
|
return redirect(url_for("company.my_company"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/my-company/invite")
|
@bp.post("/my-company/invite")
|
||||||
@login_required
|
@login_required
|
||||||
def invite_user():
|
def invite_user():
|
||||||
@@ -859,6 +1114,149 @@ def add_playlist_item(playlist_id: int):
|
|||||||
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/playlists/<int:playlist_id>/items/bulk-images")
|
||||||
|
@login_required
|
||||||
|
def bulk_upload_playlist_images(playlist_id: int):
|
||||||
|
"""Bulk upload multiple images to a playlist.
|
||||||
|
|
||||||
|
Expects multipart/form-data:
|
||||||
|
- files: multiple image files
|
||||||
|
- crop_mode: "16:9" or "9:16" (optional; defaults to 16:9)
|
||||||
|
- duration_seconds: optional (defaults to 10)
|
||||||
|
|
||||||
|
Returns JSON:
|
||||||
|
{ ok: true, items: [...] }
|
||||||
|
"""
|
||||||
|
|
||||||
|
company_user_required()
|
||||||
|
|
||||||
|
playlist = db.session.get(Playlist, playlist_id)
|
||||||
|
if not playlist or playlist.company_id != current_user.company_id:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# This endpoint is intended for AJAX.
|
||||||
|
def _json_error(message: str, status: int = 400):
|
||||||
|
return jsonify({"ok": False, "error": message}), status
|
||||||
|
|
||||||
|
files = request.files.getlist("files")
|
||||||
|
if not files:
|
||||||
|
return _json_error("No files uploaded")
|
||||||
|
|
||||||
|
crop_mode = (request.form.get("crop_mode") or "16:9").strip().lower()
|
||||||
|
if crop_mode not in {"16:9", "9:16"}:
|
||||||
|
crop_mode = "16:9"
|
||||||
|
|
||||||
|
raw_duration = request.form.get("duration_seconds")
|
||||||
|
try:
|
||||||
|
duration = int(raw_duration) if raw_duration is not None else 10
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
duration = 10
|
||||||
|
duration = max(1, duration)
|
||||||
|
|
||||||
|
# Quota check before processing.
|
||||||
|
with db.session.no_autoflush:
|
||||||
|
company = db.session.get(Company, current_user.company_id)
|
||||||
|
if not company:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
upload_root = current_app.config["UPLOAD_FOLDER"]
|
||||||
|
used_bytes = get_company_upload_bytes(upload_root, company.id)
|
||||||
|
usage = compute_storage_usage(used_bytes=used_bytes, max_bytes=company.storage_max_bytes)
|
||||||
|
storage_max_human = _format_bytes(usage["max_bytes"]) if usage.get("max_bytes") else None
|
||||||
|
|
||||||
|
if usage.get("is_exceeded"):
|
||||||
|
return _json_error(_storage_limit_error_message(storage_max_human=storage_max_human), 403)
|
||||||
|
|
||||||
|
# Determine starting position.
|
||||||
|
max_pos = (
|
||||||
|
db.session.query(db.func.max(PlaylistItem.position)).filter_by(playlist_id=playlist_id).scalar() or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
saved_relpaths: list[str] = []
|
||||||
|
items: list[PlaylistItem] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for idx, f in enumerate(files, start=1):
|
||||||
|
if not f or not f.filename:
|
||||||
|
raise ValueError("file_required")
|
||||||
|
|
||||||
|
filename = secure_filename(f.filename)
|
||||||
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
if ext not in ALLOWED_IMAGE_EXTENSIONS:
|
||||||
|
raise ValueError("unsupported")
|
||||||
|
|
||||||
|
relpath = _save_compressed_image(
|
||||||
|
f,
|
||||||
|
current_app.config["UPLOAD_FOLDER"],
|
||||||
|
current_user.company_id,
|
||||||
|
crop_mode=crop_mode,
|
||||||
|
)
|
||||||
|
saved_relpaths.append(relpath)
|
||||||
|
|
||||||
|
it = PlaylistItem(
|
||||||
|
playlist=playlist,
|
||||||
|
item_type="image",
|
||||||
|
title=None,
|
||||||
|
duration_seconds=duration,
|
||||||
|
position=max_pos + idx,
|
||||||
|
file_path=relpath,
|
||||||
|
)
|
||||||
|
items.append(it)
|
||||||
|
|
||||||
|
# Post-save quota check (like single image uploads)
|
||||||
|
if company.storage_max_bytes is not None and int(company.storage_max_bytes or 0) > 0:
|
||||||
|
used_after = get_company_upload_bytes(upload_root, company.id)
|
||||||
|
usage_after = compute_storage_usage(used_bytes=used_after, max_bytes=company.storage_max_bytes)
|
||||||
|
if usage_after.get("is_exceeded"):
|
||||||
|
# Remove all newly saved files and reject.
|
||||||
|
for p in saved_relpaths:
|
||||||
|
_try_delete_upload(p, upload_root)
|
||||||
|
return _json_error(_storage_limit_error_message(storage_max_human=storage_max_human), 403)
|
||||||
|
|
||||||
|
for it in items:
|
||||||
|
db.session.add(it)
|
||||||
|
db.session.commit()
|
||||||
|
except ValueError as e:
|
||||||
|
# Clean up any saved files.
|
||||||
|
upload_root = current_app.config["UPLOAD_FOLDER"]
|
||||||
|
for p in saved_relpaths:
|
||||||
|
_try_delete_upload(p, upload_root)
|
||||||
|
|
||||||
|
code = str(e)
|
||||||
|
if code == "unsupported":
|
||||||
|
return _json_error(
|
||||||
|
"Unsupported image type. Please upload one of: " + ", ".join(sorted(ALLOWED_IMAGE_EXTENSIONS))
|
||||||
|
)
|
||||||
|
if code == "file_required":
|
||||||
|
return _json_error("File required")
|
||||||
|
return _json_error("Failed to process image upload", 500)
|
||||||
|
except Exception:
|
||||||
|
db.session.rollback()
|
||||||
|
upload_root = current_app.config["UPLOAD_FOLDER"]
|
||||||
|
for p in saved_relpaths:
|
||||||
|
_try_delete_upload(p, upload_root)
|
||||||
|
return _json_error("Failed to process image upload", 500)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": it.id,
|
||||||
|
"playlist_id": it.playlist_id,
|
||||||
|
"position": it.position,
|
||||||
|
"item_type": it.item_type,
|
||||||
|
"title": it.title,
|
||||||
|
"file_path": it.file_path,
|
||||||
|
"url": it.url,
|
||||||
|
"duration_seconds": it.duration_seconds,
|
||||||
|
}
|
||||||
|
for it in items
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/items/<int:item_id>/delete")
|
@bp.post("/items/<int:item_id>/delete")
|
||||||
@login_required
|
@login_required
|
||||||
def delete_item(item_id: int):
|
def delete_item(item_id: int):
|
||||||
@@ -975,6 +1373,64 @@ def update_display(display_id: int):
|
|||||||
return None
|
return None
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
def _normalize_css_color(val: str | None) -> str | None:
|
||||||
|
"""Accept a limited set of CSS color inputs (primarily hex + a few keywords).
|
||||||
|
|
||||||
|
This is used to avoid storing arbitrary CSS strings while still being user friendly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
v = (val or "").strip()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
|
||||||
|
low = v.lower()
|
||||||
|
if low in {"white", "black", "red", "green", "blue", "yellow", "orange", "purple", "gray", "grey"}:
|
||||||
|
return low
|
||||||
|
|
||||||
|
# Hex colors: #RGB, #RRGGBB, #RRGGBBAA
|
||||||
|
if low.startswith("#"):
|
||||||
|
h = low[1:]
|
||||||
|
if len(h) in {3, 6, 8} and all(c in "0123456789abcdef" for c in h):
|
||||||
|
return "#" + h
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _normalize_percent(val) -> int | None:
|
||||||
|
if val in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
n = int(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return min(100, max(0, n))
|
||||||
|
|
||||||
|
def _normalize_speed(val) -> int | None:
|
||||||
|
if val in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
n = int(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return min(100, max(1, n))
|
||||||
|
|
||||||
|
def _normalize_font_family(val: str | None) -> str | None:
|
||||||
|
v = (val or "").strip()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
# keep it short and avoid quotes/newlines that could be abused in CSS.
|
||||||
|
v = v.replace("\n", " ").replace("\r", " ").replace('"', "").replace("'", "")
|
||||||
|
v = " ".join(v.split())
|
||||||
|
return v[:120] if v else None
|
||||||
|
|
||||||
|
def _normalize_font_size_px(val) -> int | None:
|
||||||
|
if val in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
n = int(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
# reasonable bounds for signage displays
|
||||||
|
return min(200, max(10, n))
|
||||||
|
|
||||||
# Inputs from either form or JSON
|
# Inputs from either form or JSON
|
||||||
payload = request.get_json(silent=True) if request.is_json else None
|
payload = request.get_json(silent=True) if request.is_json else None
|
||||||
|
|
||||||
@@ -1004,6 +1460,46 @@ def update_display(display_id: int):
|
|||||||
# Form POST implies full update
|
# Form POST implies full update
|
||||||
display.transition = _normalize_transition(request.form.get("transition"))
|
display.transition = _normalize_transition(request.form.get("transition"))
|
||||||
|
|
||||||
|
# Overlay toggle
|
||||||
|
if request.is_json:
|
||||||
|
if payload is None:
|
||||||
|
return _json_error("Invalid JSON")
|
||||||
|
if "show_overlay" in payload:
|
||||||
|
raw = payload.get("show_overlay")
|
||||||
|
# Accept common truthy representations.
|
||||||
|
if isinstance(raw, bool):
|
||||||
|
display.show_overlay = raw
|
||||||
|
elif raw in (1, 0):
|
||||||
|
display.show_overlay = bool(raw)
|
||||||
|
else:
|
||||||
|
s = ("" if raw is None else str(raw)).strip().lower()
|
||||||
|
display.show_overlay = s in {"1", "true", "yes", "on"}
|
||||||
|
else:
|
||||||
|
# Form POST implies full update
|
||||||
|
raw = request.form.get("show_overlay")
|
||||||
|
if raw is not None:
|
||||||
|
display.show_overlay = (raw or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
# Ticker tape: per-display enable/disable only.
|
||||||
|
# RSS + styling is configured per-company (see /my-company).
|
||||||
|
if request.is_json:
|
||||||
|
if payload is None:
|
||||||
|
return _json_error("Invalid JSON")
|
||||||
|
|
||||||
|
if "ticker_enabled" in payload:
|
||||||
|
raw = payload.get("ticker_enabled")
|
||||||
|
if isinstance(raw, bool):
|
||||||
|
display.ticker_enabled = raw
|
||||||
|
elif raw in (1, 0):
|
||||||
|
display.ticker_enabled = bool(raw)
|
||||||
|
else:
|
||||||
|
s = ("" if raw is None else str(raw)).strip().lower()
|
||||||
|
display.ticker_enabled = s in {"1", "true", "yes", "on"}
|
||||||
|
else:
|
||||||
|
raw = request.form.get("ticker_enabled")
|
||||||
|
if raw is not None:
|
||||||
|
display.ticker_enabled = (raw or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
# Playlist assignment
|
# Playlist assignment
|
||||||
if request.is_json:
|
if request.is_json:
|
||||||
if "playlist_id" in payload:
|
if "playlist_id" in payload:
|
||||||
@@ -1044,6 +1540,15 @@ def update_display(display_id: int):
|
|||||||
"name": display.name,
|
"name": display.name,
|
||||||
"description": display.description,
|
"description": display.description,
|
||||||
"transition": display.transition,
|
"transition": display.transition,
|
||||||
|
"show_overlay": bool(display.show_overlay),
|
||||||
|
"ticker_enabled": bool(display.ticker_enabled),
|
||||||
|
"ticker_rss_url": None,
|
||||||
|
"ticker_color": None,
|
||||||
|
"ticker_bg_color": None,
|
||||||
|
"ticker_bg_opacity": None,
|
||||||
|
"ticker_font_family": None,
|
||||||
|
"ticker_font_size_px": None,
|
||||||
|
"ticker_speed": None,
|
||||||
"assigned_playlist_id": display.assigned_playlist_id,
|
"assigned_playlist_id": display.assigned_playlist_id,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import Blueprint, abort, render_template
|
from flask import Blueprint, abort, render_template, url_for
|
||||||
|
|
||||||
from ..models import Display
|
from ..models import Company, Display
|
||||||
|
from ..uploads import is_valid_upload_relpath
|
||||||
|
|
||||||
bp = Blueprint("display", __name__, url_prefix="/display")
|
bp = Blueprint("display", __name__, url_prefix="/display")
|
||||||
|
|
||||||
@@ -10,4 +11,11 @@ def display_player(token: str):
|
|||||||
display = Display.query.filter_by(token=token).first()
|
display = Display.query.filter_by(token=token).first()
|
||||||
if not display:
|
if not display:
|
||||||
abort(404)
|
abort(404)
|
||||||
return render_template("display/player.html", display=display)
|
|
||||||
|
overlay_url = None
|
||||||
|
if display.show_overlay:
|
||||||
|
company = Company.query.filter_by(id=display.company_id).first()
|
||||||
|
if company and company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path):
|
||||||
|
overlay_url = url_for("static", filename=company.overlay_file_path)
|
||||||
|
|
||||||
|
return render_template("display/player.html", display=display, overlay_url=overlay_url)
|
||||||
|
|||||||
@@ -280,6 +280,13 @@ h1, h2, h3, .display-1, .display-2, .display-3 {
|
|||||||
background: #000;
|
background: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile: dashboard display previews are heavy (iframes). Hide them on small screens. */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.display-gallery-card .display-preview {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.display-gallery-grid {
|
.display-gallery-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -307,3 +314,9 @@ h1, h2, h3, .display-1, .display-2, .display-3 {
|
|||||||
.schedule-status-dot.inactive {
|
.schedule-status-dot.inactive {
|
||||||
background: #dc3545;
|
background: #dc3545;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dropzone disabled state (used by bulk upload) */
|
||||||
|
.dropzone.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="page-title">Welcome{% if current_user and current_user.email %}, {{ current_user.email }}{% endif %}!</h1>
|
<h1 class="page-title">Dashboard</h1>
|
||||||
|
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -69,14 +69,21 @@
|
|||||||
<div class="col-12 col-md-6 col-xl-4">
|
<div class="col-12 col-md-6 col-xl-4">
|
||||||
<div class="card display-gallery-card h-100">
|
<div class="card display-gallery-card h-100">
|
||||||
<div class="display-preview">
|
<div class="display-preview">
|
||||||
|
<div
|
||||||
|
class="display-preview-scale"
|
||||||
|
style="width: 1000%; height: 1000%; transform: scale(0.1); transform-origin: top left;"
|
||||||
|
>
|
||||||
<iframe
|
<iframe
|
||||||
title="Preview — {{ d.name }}"
|
title="Preview — {{ d.name }}"
|
||||||
data-display-id="{{ d.id }}"
|
data-display-id="{{ d.id }}"
|
||||||
src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
|
class="js-display-preview"
|
||||||
|
data-preview-src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
|
style="width: 100%; height: 100%; border: 0;"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-body d-flex flex-column gap-2">
|
<div class="card-body d-flex flex-column gap-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -96,6 +103,8 @@
|
|||||||
data-display-name="{{ d.name }}"
|
data-display-name="{{ d.name }}"
|
||||||
data-current-desc="{{ d.description or '' }}"
|
data-current-desc="{{ d.description or '' }}"
|
||||||
data-current-transition="{{ d.transition or 'none' }}"
|
data-current-transition="{{ d.transition or 'none' }}"
|
||||||
|
data-current-show-overlay="{{ '1' if d.show_overlay else '0' }}"
|
||||||
|
data-current-ticker-enabled="{{ '1' if d.ticker_enabled else '0' }}"
|
||||||
data-legacy-playlist-id="{{ d.assigned_playlist_id or '' }}"
|
data-legacy-playlist-id="{{ d.assigned_playlist_id or '' }}"
|
||||||
data-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}"
|
data-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}"
|
||||||
>
|
>
|
||||||
@@ -157,6 +166,22 @@
|
|||||||
</select>
|
</select>
|
||||||
<div class="form-text">Applied on the display when switching between playlist items.</div>
|
<div class="form-text">Applied on the display when switching between playlist items.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editPlaylistsShowOverlayCheck" />
|
||||||
|
<label class="form-check-label" for="editPlaylistsShowOverlayCheck">Show company overlay</label>
|
||||||
|
<div class="form-text">If your company has an overlay uploaded, it will be displayed on top of the content.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check form-switch mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editTickerEnabled" />
|
||||||
|
<label class="form-check-label" for="editTickerEnabled">
|
||||||
|
Enable ticker tape on this display
|
||||||
|
</label>
|
||||||
|
<div class="form-text">
|
||||||
|
RSS feed + styling is configured in <a href="{{ url_for('company.my_company') }}">My Company</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<hr class="my-3" />
|
<hr class="my-3" />
|
||||||
<div class="text-muted small mb-2">Tick the playlists that should be active on this display.</div>
|
<div class="text-muted small mb-2">Tick the playlists that should be active on this display.</div>
|
||||||
<div id="editPlaylistsList" class="d-flex flex-column gap-2"></div>
|
<div id="editPlaylistsList" class="d-flex flex-column gap-2"></div>
|
||||||
@@ -217,17 +242,23 @@
|
|||||||
|
|
||||||
function refreshPreviewIframe(displayId) {
|
function refreshPreviewIframe(displayId) {
|
||||||
const iframe = document.querySelector(`iframe[data-display-id="${displayId}"]`);
|
const iframe = document.querySelector(`iframe[data-display-id="${displayId}"]`);
|
||||||
if (!iframe || !iframe.src) return;
|
// Previews are disabled on mobile.
|
||||||
|
if (window.matchMedia && window.matchMedia('(max-width: 768px)').matches) return;
|
||||||
|
if (!iframe) return;
|
||||||
try {
|
try {
|
||||||
const u = new URL(iframe.src, window.location.origin);
|
const baseSrc = iframe.dataset.previewSrc || iframe.src;
|
||||||
|
if (!baseSrc) return;
|
||||||
|
const u = new URL(baseSrc, window.location.origin);
|
||||||
// Ensure preview flag is present (and bust cache).
|
// Ensure preview flag is present (and bust cache).
|
||||||
u.searchParams.set('preview', '1');
|
u.searchParams.set('preview', '1');
|
||||||
u.searchParams.set('_ts', String(Date.now()));
|
u.searchParams.set('_ts', String(Date.now()));
|
||||||
iframe.src = u.toString();
|
iframe.src = u.toString();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback: naive cache buster
|
// Fallback: naive cache buster
|
||||||
const sep = iframe.src.includes('?') ? '&' : '?';
|
const baseSrc = iframe.dataset.previewSrc || iframe.src;
|
||||||
iframe.src = `${iframe.src}${sep}_ts=${Date.now()}`;
|
if (!baseSrc) return;
|
||||||
|
const sep = baseSrc.includes('?') ? '&' : '?';
|
||||||
|
iframe.src = `${baseSrc}${sep}_ts=${Date.now()}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +332,8 @@
|
|||||||
const plDescInputEl = document.getElementById('editPlaylistsDescInput');
|
const plDescInputEl = document.getElementById('editPlaylistsDescInput');
|
||||||
const plDescCountEl = document.getElementById('editPlaylistsDescCount');
|
const plDescCountEl = document.getElementById('editPlaylistsDescCount');
|
||||||
const plTransitionEl = document.getElementById('editPlaylistsTransitionSelect');
|
const plTransitionEl = document.getElementById('editPlaylistsTransitionSelect');
|
||||||
|
const plShowOverlayEl = document.getElementById('editPlaylistsShowOverlayCheck');
|
||||||
|
const tickerEnabledEl = document.getElementById('editTickerEnabled');
|
||||||
let activePlDisplayId = null;
|
let activePlDisplayId = null;
|
||||||
let activePlButton = null;
|
let activePlButton = null;
|
||||||
|
|
||||||
@@ -365,6 +398,16 @@
|
|||||||
const currentTransition = (btn.dataset.currentTransition || 'none').toLowerCase();
|
const currentTransition = (btn.dataset.currentTransition || 'none').toLowerCase();
|
||||||
if (plTransitionEl) plTransitionEl.value = ['none','fade','slide'].includes(currentTransition) ? currentTransition : 'none';
|
if (plTransitionEl) plTransitionEl.value = ['none','fade','slide'].includes(currentTransition) ? currentTransition : 'none';
|
||||||
|
|
||||||
|
if (plShowOverlayEl) {
|
||||||
|
const raw = (btn.dataset.currentShowOverlay || '').toLowerCase();
|
||||||
|
plShowOverlayEl.checked = raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tickerEnabledEl) {
|
||||||
|
const raw = (btn.dataset.currentTickerEnabled || '').toLowerCase();
|
||||||
|
tickerEnabledEl.checked = raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
||||||
|
}
|
||||||
|
|
||||||
const selected = computeActiveIdsFromDataset(btn);
|
const selected = computeActiveIdsFromDataset(btn);
|
||||||
renderPlaylistCheckboxes(selected);
|
renderPlaylistCheckboxes(selected);
|
||||||
if (plHintEl) {
|
if (plHintEl) {
|
||||||
@@ -379,11 +422,18 @@
|
|||||||
const ids = getSelectedPlaylistIdsFromModal();
|
const ids = getSelectedPlaylistIdsFromModal();
|
||||||
const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : '';
|
const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : '';
|
||||||
const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none';
|
const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none';
|
||||||
|
const showOverlay = plShowOverlayEl ? !!plShowOverlayEl.checked : false;
|
||||||
|
const tickerEnabled = tickerEnabledEl ? !!tickerEnabledEl.checked : false;
|
||||||
plSaveBtn.disabled = true;
|
plSaveBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const [updatedPlaylists, updatedDesc] = await Promise.all([
|
const [updatedPlaylists, updatedDesc] = await Promise.all([
|
||||||
postDisplayPlaylists(activePlDisplayId, ids),
|
postDisplayPlaylists(activePlDisplayId, ids),
|
||||||
postDisplayUpdate(activePlDisplayId, { description: desc, transition })
|
postDisplayUpdate(activePlDisplayId, {
|
||||||
|
description: desc,
|
||||||
|
transition,
|
||||||
|
show_overlay: showOverlay,
|
||||||
|
ticker_enabled: tickerEnabled
|
||||||
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids)
|
const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids)
|
||||||
@@ -401,6 +451,16 @@
|
|||||||
const newTransition = updatedDesc && typeof updatedDesc.transition === 'string' ? updatedDesc.transition : transition;
|
const newTransition = updatedDesc && typeof updatedDesc.transition === 'string' ? updatedDesc.transition : transition;
|
||||||
activePlButton.dataset.currentTransition = newTransition || 'none';
|
activePlButton.dataset.currentTransition = newTransition || 'none';
|
||||||
|
|
||||||
|
const newShowOverlay = updatedDesc && typeof updatedDesc.show_overlay !== 'undefined'
|
||||||
|
? !!updatedDesc.show_overlay
|
||||||
|
: showOverlay;
|
||||||
|
activePlButton.dataset.currentShowOverlay = newShowOverlay ? '1' : '0';
|
||||||
|
|
||||||
|
const newTickerEnabled = updatedDesc && typeof updatedDesc.ticker_enabled !== 'undefined'
|
||||||
|
? !!updatedDesc.ticker_enabled
|
||||||
|
: tickerEnabled;
|
||||||
|
activePlButton.dataset.currentTickerEnabled = newTickerEnabled ? '1' : '0';
|
||||||
|
|
||||||
showToast('Display updated', 'text-bg-success');
|
showToast('Display updated', 'text-bg-success');
|
||||||
refreshPreviewIframe(activePlDisplayId);
|
refreshPreviewIframe(activePlDisplayId);
|
||||||
if (plModal) plModal.hide();
|
if (plModal) plModal.hide();
|
||||||
@@ -414,6 +474,31 @@
|
|||||||
if (plSaveBtn) {
|
if (plSaveBtn) {
|
||||||
plSaveBtn.addEventListener('click', savePlaylists);
|
plSaveBtn.addEventListener('click', savePlaylists);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable dashboard previews on small screens (mobile): don't even set iframe src.
|
||||||
|
function loadDashboardPreviewsIfDesktop() {
|
||||||
|
const isMobile = window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
|
||||||
|
if (isMobile) return;
|
||||||
|
document.querySelectorAll('iframe.js-display-preview[data-preview-src]').forEach((iframe) => {
|
||||||
|
if (!iframe.src || iframe.src === 'about:blank') {
|
||||||
|
iframe.src = iframe.dataset.previewSrc;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loadDashboardPreviewsIfDesktop();
|
||||||
|
|
||||||
|
// If user rotates/resizes from mobile -> desktop, load previews then.
|
||||||
|
if (window.matchMedia) {
|
||||||
|
const mql = window.matchMedia('(max-width: 768px)');
|
||||||
|
const onChange = () => {
|
||||||
|
if (!mql.matches) loadDashboardPreviewsIfDesktop();
|
||||||
|
};
|
||||||
|
if (typeof mql.addEventListener === 'function') {
|
||||||
|
mql.addEventListener('change', onChange);
|
||||||
|
} else if (typeof mql.addListener === 'function') {
|
||||||
|
mql.addListener(onChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -137,5 +137,178 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-elevated mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Overlay</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted small mb-3">
|
||||||
|
Upload a <strong>16:9 PNG</strong> overlay. It will be rendered on top of the display content.
|
||||||
|
Transparent areas will show the content underneath.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if overlay_url %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-muted small mb-2">Current overlay:</div>
|
||||||
|
<div style="max-width: 520px; border: 1px solid rgba(0,0,0,0.15); border-radius: 8px; overflow: hidden;">
|
||||||
|
<img
|
||||||
|
src="{{ overlay_url }}"
|
||||||
|
alt="Company overlay"
|
||||||
|
style="display:block; width:100%; height:auto; background: repeating-linear-gradient(45deg, #eee 0 12px, #fff 12px 24px);"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted mb-3">No overlay uploaded.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('company.upload_company_overlay') }}" enctype="multipart/form-data" class="d-flex gap-2 flex-wrap align-items-end">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Upload overlay (PNG)</label>
|
||||||
|
<input class="form-control" type="file" name="overlay" accept="image/png" required />
|
||||||
|
<div class="form-text">Tip: export at 1920×1080 (or any 16:9 size).</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-brand" type="submit">Save overlay</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if overlay_url %}
|
||||||
|
<form method="post" action="{{ url_for('company.delete_company_overlay') }}" class="mt-3" onsubmit="return confirm('Remove the overlay?');">
|
||||||
|
<button class="btn btn-outline-danger" type="submit">Remove overlay</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-elevated mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">Ticker tape (RSS)</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted small mb-3">
|
||||||
|
Configure the RSS feed and styling for the ticker tape. Individual displays can enable/disable the ticker from the Dashboard.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('company.update_company_ticker_settings') }}" class="d-flex flex-column gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="companyTickerRssUrl">RSS feed URL</label>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
id="companyTickerRssUrl"
|
||||||
|
name="ticker_rss_url"
|
||||||
|
type="url"
|
||||||
|
value="{{ company.ticker_rss_url or '' }}"
|
||||||
|
placeholder="https://example.com/feed.xml"
|
||||||
|
/>
|
||||||
|
<div class="form-text">Leave empty to disable headlines (even if displays have ticker enabled).</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<label class="form-label" for="companyTickerColor">Text color</label>
|
||||||
|
<input
|
||||||
|
class="form-control form-control-color"
|
||||||
|
id="companyTickerColor"
|
||||||
|
name="ticker_color"
|
||||||
|
type="color"
|
||||||
|
value="{{ company.ticker_color or '#ffffff' }}"
|
||||||
|
title="Choose text color"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<label class="form-label" for="companyTickerBgColor">Background color</label>
|
||||||
|
<input
|
||||||
|
class="form-control form-control-color"
|
||||||
|
id="companyTickerBgColor"
|
||||||
|
name="ticker_bg_color"
|
||||||
|
type="color"
|
||||||
|
value="{{ company.ticker_bg_color or '#000000' }}"
|
||||||
|
title="Choose background color"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<label class="form-label" for="companyTickerBgOpacity">Background opacity</label>
|
||||||
|
<input
|
||||||
|
class="form-range"
|
||||||
|
id="companyTickerBgOpacity"
|
||||||
|
name="ticker_bg_opacity"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
value="{{ company.ticker_bg_opacity if company.ticker_bg_opacity is not none else 75 }}"
|
||||||
|
/>
|
||||||
|
<div class="form-text"><span id="companyTickerBgOpacityLabel">{{ company.ticker_bg_opacity if company.ticker_bg_opacity is not none else 75 }}</span>%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label" for="companyTickerFontFamily">Font</label>
|
||||||
|
<select class="form-select" id="companyTickerFontFamily" name="ticker_font_family">
|
||||||
|
{% set ff = company.ticker_font_family or 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif' %}
|
||||||
|
<option value="system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" {{ 'selected' if ff == 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif' else '' }}>System (default)</option>
|
||||||
|
<option value="Arial, Helvetica, sans-serif" {{ 'selected' if ff == 'Arial, Helvetica, sans-serif' else '' }}>Arial</option>
|
||||||
|
<option value="Segoe UI, Arial, sans-serif" {{ 'selected' if ff == 'Segoe UI, Arial, sans-serif' else '' }}>Segoe UI</option>
|
||||||
|
<option value="Roboto, Arial, sans-serif" {{ 'selected' if ff == 'Roboto, Arial, sans-serif' else '' }}>Roboto</option>
|
||||||
|
<option value="Georgia, serif" {{ 'selected' if ff == 'Georgia, serif' else '' }}>Georgia</option>
|
||||||
|
<option value="Times New Roman, Times, serif" {{ 'selected' if ff == 'Times New Roman, Times, serif' else '' }}>Times New Roman</option>
|
||||||
|
<option value="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace" {{ 'selected' if ff == 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace' else '' }}>Monospace</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label" for="companyTickerFontSize">Font size (px)</label>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
id="companyTickerFontSize"
|
||||||
|
name="ticker_font_size_px"
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="200"
|
||||||
|
step="1"
|
||||||
|
value="{{ company.ticker_font_size_px if company.ticker_font_size_px is not none else 28 }}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label" for="companyTickerSpeed">Speed</label>
|
||||||
|
<input
|
||||||
|
class="form-range"
|
||||||
|
id="companyTickerSpeed"
|
||||||
|
name="ticker_speed"
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
value="{{ company.ticker_speed if company.ticker_speed is not none else 25 }}"
|
||||||
|
/>
|
||||||
|
<div class="form-text">Slower ⟷ Faster</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-brand" type="submit">Save ticker settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const opacityEl = document.getElementById('companyTickerBgOpacity');
|
||||||
|
const opacityLabelEl = document.getElementById('companyTickerBgOpacityLabel');
|
||||||
|
function syncOpacity() {
|
||||||
|
if (!opacityEl || !opacityLabelEl) return;
|
||||||
|
opacityLabelEl.textContent = String(opacityEl.value || '0');
|
||||||
|
}
|
||||||
|
if (opacityEl) opacityEl.addEventListener('input', syncOpacity);
|
||||||
|
syncOpacity();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{# Expose server-side crop target sizes to the JS without embedding Jinja inside JS #}
|
||||||
|
<div
|
||||||
|
id="page-config"
|
||||||
|
class="d-none"
|
||||||
|
data-image-crop-target-w="{{ config.get('IMAGE_CROP_TARGET_W', 1920) }}"
|
||||||
|
data-image-crop-target-h="{{ config.get('IMAGE_CROP_TARGET_H', 1080) }}"
|
||||||
|
></div>
|
||||||
{# Cropper.js (used for image cropping) #}
|
{# Cropper.js (used for image cropping) #}
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.css" />
|
||||||
<style>
|
<style>
|
||||||
@@ -230,8 +237,11 @@
|
|||||||
<h2 class="h5 mb-0">Items</h2>
|
<h2 class="h5 mb-0">Items</h2>
|
||||||
<div class="text-muted small">Tip: drag items to reorder. Changes save automatically.</div>
|
<div class="text-muted small">Tip: drag items to reorder. Changes save automatically.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="open-bulk-upload">Bulk upload</button>
|
||||||
<button class="btn btn-brand" type="button" id="open-add-item">Add item</button>
|
<button class="btn btn-brand" type="button" id="open-add-item">Add item</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<div class="small text-muted mb-2" id="reorder-status" aria-live="polite"></div>
|
<div class="small text-muted mb-2" id="reorder-status" aria-live="polite"></div>
|
||||||
@@ -319,46 +329,33 @@
|
|||||||
<input type="hidden" name="item_type" id="item_type" value="image" />
|
<input type="hidden" name="item_type" id="item_type" value="image" />
|
||||||
<input type="hidden" name="crop_mode" id="crop_mode" value="16:9" />
|
<input type="hidden" name="crop_mode" id="crop_mode" value="16:9" />
|
||||||
|
|
||||||
|
{# Step 1: pick type, then show relevant inputs #}
|
||||||
|
<div id="step-type" class="step active">
|
||||||
|
<div class="text-muted small mb-2">Select a slide type.</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">Title (optional)</label>
|
<div class="btn-group w-100" role="group" aria-label="Slide type">
|
||||||
<input class="form-control" name="title" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-2" id="duration-group">
|
|
||||||
<label class="form-label">Duration (seconds, for images/webpages/YouTube)</label>
|
|
||||||
<input class="form-control" type="number" name="duration_seconds" value="10" min="1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Type</label>
|
|
||||||
<div class="btn-group w-100" role="group" aria-label="Item type">
|
|
||||||
<input type="radio" class="btn-check" name="item_type_choice" id="type-image" autocomplete="off" checked>
|
<input type="radio" class="btn-check" name="item_type_choice" id="type-image" autocomplete="off" checked>
|
||||||
<label class="btn btn-outline-primary" for="type-image">Image</label>
|
<label class="btn btn-outline-primary" for="type-image">Image</label>
|
||||||
|
|
||||||
<input type="radio" class="btn-check" name="item_type_choice" id="type-webpage" autocomplete="off">
|
<input type="radio" class="btn-check" name="item_type_choice" id="type-webpage" autocomplete="off">
|
||||||
<label class="btn btn-outline-primary" for="type-webpage">Webpage</label>
|
<label class="btn btn-outline-primary" for="type-webpage">Webpage</label>
|
||||||
|
|
||||||
<input type="radio" class="btn-check" name="item_type_choice" id="type-youtube" autocomplete="off">
|
|
||||||
<label class="btn btn-outline-primary" for="type-youtube">YouTube</label>
|
|
||||||
|
|
||||||
<input type="radio" class="btn-check" name="item_type_choice" id="type-video" autocomplete="off">
|
<input type="radio" class="btn-check" name="item_type_choice" id="type-video" autocomplete="off">
|
||||||
<label class="btn btn-outline-primary" for="type-video">Video</label>
|
<label class="btn btn-outline-primary" for="type-video">Video</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3" id="crop-mode-group">
|
|
||||||
<label class="form-label">Image crop</label>
|
|
||||||
<div class="btn-group w-100" role="group" aria-label="Crop mode">
|
|
||||||
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-16-9" autocomplete="off" checked>
|
|
||||||
<label class="btn btn-outline-primary" for="crop-16-9">16:9 (landscape)</label>
|
|
||||||
|
|
||||||
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-9-16" autocomplete="off">
|
|
||||||
<label class="btn btn-outline-primary" for="crop-9-16">9:16 (portrait)</label>
|
|
||||||
|
|
||||||
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-none" autocomplete="off">
|
|
||||||
<label class="btn btn-outline-primary" for="crop-none">No crop</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted small mt-1">Cropping is optional. If enabled, we center-crop to the chosen aspect ratio.</div>
|
|
||||||
|
{# Step 2 (only for video): choose upload vs YouTube #}
|
||||||
|
<div id="step-video-source" class="step">
|
||||||
|
<div class="text-muted small mb-2">Choose how you want to add the video.</div>
|
||||||
|
<div class="btn-group w-100" role="group" aria-label="Video source">
|
||||||
|
<input type="radio" class="btn-check" name="video_source_choice" id="video-source-upload" autocomplete="off" checked>
|
||||||
|
<label class="btn btn-outline-primary" for="video-source-upload">Upload video</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="video_source_choice" id="video-source-youtube" autocomplete="off">
|
||||||
|
<label class="btn btn-outline-primary" for="video-source-youtube">YouTube</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -393,8 +390,33 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div id="step-select" class="step active">
|
<div id="step-input" class="step">
|
||||||
<div class="text-muted small mb-2">Select or upload your media. If you upload an image, you’ll crop it next.</div>
|
<div class="text-muted small mb-2" id="step-input-hint">Fill in the details for the new slide.</div>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Title (optional)</label>
|
||||||
|
<input class="form-control" name="title" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2" id="duration-group">
|
||||||
|
<label class="form-label">Duration (seconds, for images/webpages/YouTube)</label>
|
||||||
|
<input class="form-control" type="number" name="duration_seconds" value="10" min="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3" id="crop-mode-group">
|
||||||
|
<label class="form-label">Image crop</label>
|
||||||
|
<div class="btn-group w-100" role="group" aria-label="Crop mode">
|
||||||
|
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-16-9" autocomplete="off" checked>
|
||||||
|
<label class="btn btn-outline-primary" for="crop-16-9">16:9 (landscape)</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-9-16" autocomplete="off">
|
||||||
|
<label class="btn btn-outline-primary" for="crop-9-16">9:16 (portrait)</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="crop_mode_choice" id="crop-none" autocomplete="off">
|
||||||
|
<label class="btn btn-outline-primary" for="crop-none">No crop</label>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mt-1">Cropping is optional. If enabled, we center-crop to the chosen aspect ratio.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Image section #}
|
{# Image section #}
|
||||||
<div id="section-image" class="item-type-section">
|
<div id="section-image" class="item-type-section">
|
||||||
@@ -433,7 +455,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# YouTube section #}
|
{# YouTube section (also used as video source) #}
|
||||||
<div id="section-youtube" class="item-type-section d-none">
|
<div id="section-youtube" class="item-type-section d-none">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label">YouTube URL</label>
|
<label class="form-label">YouTube URL</label>
|
||||||
@@ -445,9 +467,9 @@
|
|||||||
<div class="text-muted small">Tip: set a duration; YouTube embeds will advance after that time.</div>
|
<div class="text-muted small">Tip: set a duration; YouTube embeds will advance after that time.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Video section #}
|
{# Video upload section #}
|
||||||
<div id="section-video" class="item-type-section d-none">
|
<div id="section-video" class="item-type-section d-none">
|
||||||
<label class="form-label">Video</label>
|
<label class="form-label">Video upload</label>
|
||||||
<div id="video-dropzone" class="dropzone mb-2">
|
<div id="video-dropzone" class="dropzone mb-2">
|
||||||
<div><strong>Drag & drop</strong> a video here</div>
|
<div><strong>Drag & drop</strong> a video here</div>
|
||||||
<div class="text-muted small">or click to select a file</div>
|
<div class="text-muted small">or click to select a file</div>
|
||||||
@@ -494,6 +516,65 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Bulk upload images modal #}
|
||||||
|
<div class="modal fade" id="bulkUploadModal" tabindex="-1" aria-labelledby="bulkUploadModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="bulkUploadModalLabel">Bulk upload images</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="text-muted small mb-2">Choose an aspect ratio. Images will be center-cropped automatically.</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="btn-group w-100" role="group" aria-label="Bulk crop mode">
|
||||||
|
<input type="radio" class="btn-check" name="bulk_crop_mode_choice" id="bulk-crop-16-9" autocomplete="off" checked>
|
||||||
|
<label class="btn btn-outline-primary" for="bulk-crop-16-9">16:9 (landscape)</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="bulk_crop_mode_choice" id="bulk-crop-9-16" autocomplete="off">
|
||||||
|
<label class="btn btn-outline-primary" for="bulk-crop-9-16">9:16 (portrait)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bulk-dropzone" class="dropzone">
|
||||||
|
<div><strong>Drag & drop</strong> multiple images here</div>
|
||||||
|
<div class="text-muted small">or click to select files</div>
|
||||||
|
</div>
|
||||||
|
<input id="bulk-file-input" class="form-control d-none" type="file" accept="image/*" multiple />
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="small text-muted" id="bulk-status" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<div id="bulk-upload-progress" class="d-none mt-2" aria-live="polite">
|
||||||
|
<div class="progress" style="height: 10px;">
|
||||||
|
<div
|
||||||
|
id="bulk-upload-progress-bar"
|
||||||
|
class="progress-bar"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: 0%"
|
||||||
|
aria-valuenow="0"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mt-1" id="bulk-upload-progress-text">Uploading…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="list-group mt-2" id="bulk-file-list"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div class="small text-danger me-auto" id="bulk-error" aria-live="polite"></div>
|
||||||
|
<button type="button" class="btn btn-outline-ink" data-bs-dismiss="modal">Close</button>
|
||||||
|
<button type="button" class="btn btn-brand" id="bulk-upload-submit" disabled>Upload</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Load Cropper.js BEFORE our inline script so window.Cropper is available #}
|
{# Load Cropper.js BEFORE our inline script so window.Cropper is available #}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.js"></script>
|
||||||
|
|
||||||
@@ -636,53 +717,87 @@
|
|||||||
const sectionYoutube = document.getElementById('section-youtube');
|
const sectionYoutube = document.getElementById('section-youtube');
|
||||||
const sectionVideo = document.getElementById('section-video');
|
const sectionVideo = document.getElementById('section-video');
|
||||||
|
|
||||||
const stepSelect = document.getElementById('step-select');
|
const stepType = document.getElementById('step-type');
|
||||||
|
const stepVideoSource = document.getElementById('step-video-source');
|
||||||
|
const stepInput = document.getElementById('step-input');
|
||||||
const stepCrop = document.getElementById('step-crop');
|
const stepCrop = document.getElementById('step-crop');
|
||||||
const backBtn = document.getElementById('add-item-back');
|
const backBtn = document.getElementById('add-item-back');
|
||||||
const errorEl = document.getElementById('add-item-error');
|
const errorEl = document.getElementById('add-item-error');
|
||||||
|
const stepInputHint = document.getElementById('step-input-hint');
|
||||||
|
|
||||||
function setError(msg) {
|
function setError(msg) {
|
||||||
if (!errorEl) return;
|
if (!errorEl) return;
|
||||||
errorEl.textContent = (msg || '').trim();
|
errorEl.textContent = (msg || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentStep = 'type';
|
||||||
|
|
||||||
|
function updatePrimaryButton() {
|
||||||
|
// Primary button acts as Next in early steps, and Add in the final steps.
|
||||||
|
if (!submitBtn) return;
|
||||||
|
const isNextStep = (currentStep === 'type' || currentStep === 'video-source');
|
||||||
|
submitBtn.textContent = isNextStep ? 'Next' : 'Add';
|
||||||
|
}
|
||||||
|
|
||||||
function showStep(which) {
|
function showStep(which) {
|
||||||
stepSelect?.classList.toggle('active', which === 'select');
|
currentStep = which;
|
||||||
|
stepType?.classList.toggle('active', which === 'type');
|
||||||
|
stepVideoSource?.classList.toggle('active', which === 'video-source');
|
||||||
|
stepInput?.classList.toggle('active', which === 'input');
|
||||||
stepCrop?.classList.toggle('active', which === 'crop');
|
stepCrop?.classList.toggle('active', which === 'crop');
|
||||||
|
|
||||||
const isCrop = which === 'crop';
|
// Back is enabled for all steps except the first.
|
||||||
backBtn.disabled = !isCrop;
|
backBtn.disabled = (which === 'type');
|
||||||
|
|
||||||
// For image: allow Add only in crop step (so we always crop if image)
|
// Enable Next for the initial steps.
|
||||||
if (typeHidden.value === 'image') {
|
if (which === 'type' || which === 'video-source') {
|
||||||
submitBtn.disabled = !isCrop;
|
submitBtn.disabled = false;
|
||||||
|
updatePrimaryButton();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For input/crop steps: image requires crop step before enabling Add.
|
||||||
|
if (typeHidden.value === 'image') {
|
||||||
|
submitBtn.disabled = (which !== 'crop');
|
||||||
|
} else {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePrimaryButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoSource() {
|
||||||
|
return document.getElementById('video-source-youtube')?.checked ? 'youtube' : 'upload';
|
||||||
}
|
}
|
||||||
|
|
||||||
function setType(t) {
|
function setType(t) {
|
||||||
|
// For this UI, "video" is a top-level type, but it can map to item_type=video OR item_type=youtube.
|
||||||
typeHidden.value = t;
|
typeHidden.value = t;
|
||||||
setError('');
|
setError('');
|
||||||
sectionImage.classList.toggle('d-none', t !== 'image');
|
|
||||||
sectionWebpage.classList.toggle('d-none', t !== 'webpage');
|
|
||||||
sectionYoutube.classList.toggle('d-none', t !== 'youtube');
|
|
||||||
sectionVideo.classList.toggle('d-none', t !== 'video');
|
|
||||||
// duration applies to image/webpage/youtube. Video plays until ended.
|
|
||||||
durationGroup.classList.toggle('d-none', t === 'video');
|
|
||||||
cropModeGroup?.classList.toggle('d-none', t !== 'image');
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.title = '';
|
|
||||||
|
|
||||||
if (t !== 'image') {
|
// Visible section is decided by type + (video source)
|
||||||
destroyCropper();
|
const vs = (t === 'video') ? videoSource() : null;
|
||||||
showStep('select');
|
const effectiveType = (t === 'video' && vs === 'youtube') ? 'youtube' : t;
|
||||||
backBtn.disabled = true;
|
typeHidden.value = effectiveType;
|
||||||
|
|
||||||
|
sectionImage.classList.toggle('d-none', effectiveType !== 'image');
|
||||||
|
sectionWebpage.classList.toggle('d-none', effectiveType !== 'webpage');
|
||||||
|
sectionYoutube.classList.toggle('d-none', effectiveType !== 'youtube');
|
||||||
|
sectionVideo.classList.toggle('d-none', effectiveType !== 'video');
|
||||||
|
|
||||||
|
// duration applies to image/webpage/youtube. Video upload plays until ended.
|
||||||
|
durationGroup.classList.toggle('d-none', effectiveType === 'video');
|
||||||
|
cropModeGroup?.classList.toggle('d-none', effectiveType !== 'image');
|
||||||
|
|
||||||
|
if (stepInputHint) {
|
||||||
|
if (effectiveType === 'image') stepInputHint.textContent = 'Select an image. After selecting, you\'ll crop it.';
|
||||||
|
else if (effectiveType === 'webpage') stepInputHint.textContent = 'Enter a webpage URL.';
|
||||||
|
else if (effectiveType === 'youtube') stepInputHint.textContent = 'Paste a YouTube URL.';
|
||||||
|
else stepInputHint.textContent = 'Upload a video file.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// For images we enforce crop step before allowing submit.
|
// Reset cropper when leaving image.
|
||||||
if (t === 'image') {
|
if (effectiveType !== 'image') destroyCropper();
|
||||||
submitBtn.disabled = true;
|
|
||||||
backBtn.disabled = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentCropMode() {
|
function currentCropMode() {
|
||||||
@@ -705,10 +820,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('type-image')?.addEventListener('change', () => setType('image'));
|
document.getElementById('type-image')?.addEventListener('change', () => {
|
||||||
document.getElementById('type-webpage')?.addEventListener('change', () => setType('webpage'));
|
setType('image');
|
||||||
document.getElementById('type-youtube')?.addEventListener('change', () => setType('youtube'));
|
// Stay on type step; user clicks Next.
|
||||||
document.getElementById('type-video')?.addEventListener('change', () => setType('video'));
|
showStep('type');
|
||||||
|
});
|
||||||
|
document.getElementById('type-webpage')?.addEventListener('change', () => {
|
||||||
|
setType('webpage');
|
||||||
|
// Stay on type step; user clicks Next.
|
||||||
|
showStep('type');
|
||||||
|
});
|
||||||
|
document.getElementById('type-video')?.addEventListener('change', () => {
|
||||||
|
// We show an intermediate step for video so user chooses upload vs YouTube.
|
||||||
|
// Keep item_type unset until that choice is made.
|
||||||
|
setError('');
|
||||||
|
destroyCropper();
|
||||||
|
// Hide crop/duration while selecting source (they depend on source).
|
||||||
|
cropModeGroup?.classList.add('d-none');
|
||||||
|
durationGroup?.classList.add('d-none');
|
||||||
|
showStep('video-source');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('video-source-upload')?.addEventListener('change', () => {
|
||||||
|
// effective type becomes "video"
|
||||||
|
setType('video');
|
||||||
|
// Stay on source step; user clicks Next.
|
||||||
|
showStep('video-source');
|
||||||
|
});
|
||||||
|
document.getElementById('video-source-youtube')?.addEventListener('change', () => {
|
||||||
|
// effective type becomes "youtube"
|
||||||
|
setType('video');
|
||||||
|
// Stay on source step; user clicks Next.
|
||||||
|
showStep('video-source');
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------
|
// -------------------------
|
||||||
// Image: drag/drop + crop
|
// Image: drag/drop + crop
|
||||||
@@ -754,7 +898,7 @@
|
|||||||
cropStatus.textContent = '';
|
cropStatus.textContent = '';
|
||||||
if (imageSelectStatus) imageSelectStatus.textContent = `Selected: ${file.name}`;
|
if (imageSelectStatus) imageSelectStatus.textContent = `Selected: ${file.name}`;
|
||||||
|
|
||||||
// Move to crop step after image selection (requested behavior)
|
// Move to crop step after image selection
|
||||||
showStep('crop');
|
showStep('crop');
|
||||||
|
|
||||||
// Wait for image to be ready
|
// Wait for image to be ready
|
||||||
@@ -837,9 +981,15 @@
|
|||||||
|
|
||||||
cropStatus.textContent = 'Preparing cropped image…';
|
cropStatus.textContent = 'Preparing cropped image…';
|
||||||
const isPortrait = cm === '9:16';
|
const isPortrait = cm === '9:16';
|
||||||
|
// Export at Full HD by default (or whatever the server config says).
|
||||||
|
// We still enforce a server-side max output size in _save_compressed_image.
|
||||||
|
const cfg = document.getElementById('page-config');
|
||||||
|
const TARGET_W = parseInt(cfg?.dataset?.imageCropTargetW || '1920', 10) || 1920;
|
||||||
|
const TARGET_H = parseInt(cfg?.dataset?.imageCropTargetH || '1080', 10) || 1080;
|
||||||
|
|
||||||
const canvas = cropper.getCroppedCanvas({
|
const canvas = cropper.getCroppedCanvas({
|
||||||
width: isPortrait ? 720 : 1280,
|
width: isPortrait ? TARGET_H : TARGET_W,
|
||||||
height: isPortrait ? 1280 : 720,
|
height: isPortrait ? TARGET_W : TARGET_H,
|
||||||
imageSmoothingQuality: 'high',
|
imageSmoothingQuality: 'high',
|
||||||
});
|
});
|
||||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||||
@@ -991,15 +1141,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reset modal state + close
|
// Reset modal state + close
|
||||||
form.reset();
|
resetModalState();
|
||||||
typeHidden.value = 'image';
|
|
||||||
document.getElementById('type-image')?.click();
|
|
||||||
if (cropModeHidden) cropModeHidden.value = '16:9';
|
|
||||||
document.getElementById('crop-16-9')?.click();
|
|
||||||
destroyCropper();
|
|
||||||
showStep('select');
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
resetVideoProgress();
|
|
||||||
modal?.hide();
|
modal?.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1098,32 +1240,87 @@
|
|||||||
if (t === 'webpage') {
|
if (t === 'webpage') {
|
||||||
// Keep preview behavior
|
// Keep preview behavior
|
||||||
schedulePreview();
|
schedulePreview();
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Hide webpage preview if not active
|
// Hide webpage preview if not active
|
||||||
preview?.classList.add('d-none');
|
preview?.classList.add('d-none');
|
||||||
if (iframe) iframe.src = 'about:blank';
|
if (iframe) iframe.src = 'about:blank';
|
||||||
if (openLink) openLink.href = '#';
|
if (openLink) openLink.href = '#';
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Set initial state
|
function resetModalState() {
|
||||||
setType('image');
|
setError('');
|
||||||
|
try { form.reset(); } catch (e) {}
|
||||||
|
destroyCropper();
|
||||||
|
|
||||||
|
// Default selections (without triggering change handlers)
|
||||||
|
const typeImage = document.getElementById('type-image');
|
||||||
|
const typeWebpage = document.getElementById('type-webpage');
|
||||||
|
const typeVideo = document.getElementById('type-video');
|
||||||
|
if (typeImage) typeImage.checked = true;
|
||||||
|
if (typeWebpage) typeWebpage.checked = false;
|
||||||
|
if (typeVideo) typeVideo.checked = false;
|
||||||
|
|
||||||
|
const vsUpload = document.getElementById('video-source-upload');
|
||||||
|
const vsYoutube = document.getElementById('video-source-youtube');
|
||||||
|
if (vsUpload) vsUpload.checked = true;
|
||||||
|
if (vsYoutube) vsYoutube.checked = false;
|
||||||
|
|
||||||
if (cropModeHidden) cropModeHidden.value = '16:9';
|
if (cropModeHidden) cropModeHidden.value = '16:9';
|
||||||
showStep('select');
|
document.getElementById('crop-16-9')?.click();
|
||||||
|
|
||||||
|
// Set UI for default type, but start at type selection step
|
||||||
|
setType('image');
|
||||||
|
showStep('type');
|
||||||
syncEnabledInputs();
|
syncEnabledInputs();
|
||||||
updateCropHint();
|
updateCropHint();
|
||||||
|
|
||||||
|
// Also reset video upload progress UI if present
|
||||||
|
try {
|
||||||
|
const bar = document.getElementById('video-upload-progress-bar');
|
||||||
|
if (bar) {
|
||||||
|
bar.style.width = '0%';
|
||||||
|
bar.setAttribute('aria-valuenow', '0');
|
||||||
|
}
|
||||||
|
document.getElementById('video-upload-progress-text') && (document.getElementById('video-upload-progress-text').textContent = 'Uploading…');
|
||||||
|
document.getElementById('video-upload-progress')?.classList.add('d-none');
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize modal state once on page load
|
||||||
|
resetModalState();
|
||||||
|
|
||||||
// Modal open
|
// Modal open
|
||||||
openBtn?.addEventListener('click', () => {
|
openBtn?.addEventListener('click', () => {
|
||||||
|
// Always start from the beginning.
|
||||||
|
resetModalState();
|
||||||
modal?.show();
|
modal?.show();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Back button: only relevant for image crop step
|
// Back button: stepwise navigation
|
||||||
backBtn?.addEventListener('click', () => {
|
backBtn?.addEventListener('click', () => {
|
||||||
if (typeHidden.value === 'image') {
|
if (currentStep === 'crop') {
|
||||||
showStep('select');
|
// Going back from crop returns to input for image
|
||||||
|
showStep('input');
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
backBtn.disabled = true;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'input') {
|
||||||
|
// If top-level selected is video, go back to video source selection.
|
||||||
|
const isTopLevelVideo = document.getElementById('type-video')?.checked;
|
||||||
|
if (isTopLevelVideo) {
|
||||||
|
showStep('video-source');
|
||||||
|
} else {
|
||||||
|
showStep('type');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'video-source') {
|
||||||
|
showStep('type');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1165,13 +1362,43 @@
|
|||||||
document.getElementById('crop-none')?.addEventListener('change', () => setCropMode('none'));
|
document.getElementById('crop-none')?.addEventListener('change', () => setCropMode('none'));
|
||||||
|
|
||||||
// Whenever type changes, keep enabled inputs in sync
|
// Whenever type changes, keep enabled inputs in sync
|
||||||
['type-image','type-webpage','type-youtube','type-video'].forEach((id) => {
|
['type-image','type-webpage','type-video','video-source-upload','video-source-youtube'].forEach((id) => {
|
||||||
document.getElementById(id)?.addEventListener('change', syncEnabledInputs);
|
document.getElementById(id)?.addEventListener('change', syncEnabledInputs);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add button
|
// Add button
|
||||||
submitBtn?.addEventListener('click', async () => {
|
submitBtn?.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
|
// Multi-step behavior:
|
||||||
|
// - Type step: Next -> (video ? source step : input step)
|
||||||
|
// - Video source step: Next -> input step
|
||||||
|
// - Input/crop: Add -> submit
|
||||||
|
if (currentStep === 'type') {
|
||||||
|
const isVideo = document.getElementById('type-video')?.checked;
|
||||||
|
const isWebpage = document.getElementById('type-webpage')?.checked;
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
// Hide crop/duration while selecting source.
|
||||||
|
cropModeGroup?.classList.add('d-none');
|
||||||
|
durationGroup?.classList.add('d-none');
|
||||||
|
showStep('video-source');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setType(isWebpage ? 'webpage' : 'image');
|
||||||
|
showStep('input');
|
||||||
|
syncEnabledInputs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'video-source') {
|
||||||
|
// Apply the chosen source (upload vs YouTube) and continue.
|
||||||
|
setType('video');
|
||||||
|
showStep('input');
|
||||||
|
syncEnabledInputs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await submitViaAjax();
|
await submitViaAjax();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(err);
|
console.warn(err);
|
||||||
@@ -1245,6 +1472,372 @@
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose for other scripts on this page (bulk upload appends cards).
|
||||||
|
window.__renderPlaylistCardInnerHtml = renderCardInnerHtml;
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
// -------------------------
|
||||||
|
// Bulk image upload (auto center-crop)
|
||||||
|
// -------------------------
|
||||||
|
const openBtn = document.getElementById('open-bulk-upload');
|
||||||
|
const modalEl = document.getElementById('bulkUploadModal');
|
||||||
|
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
||||||
|
|
||||||
|
const dropzone = document.getElementById('bulk-dropzone');
|
||||||
|
const fileInput = document.getElementById('bulk-file-input');
|
||||||
|
const submitBtn = document.getElementById('bulk-upload-submit');
|
||||||
|
const statusEl = document.getElementById('bulk-status');
|
||||||
|
const errorEl = document.getElementById('bulk-error');
|
||||||
|
const listEl = document.getElementById('bulk-file-list');
|
||||||
|
|
||||||
|
const crop169 = document.getElementById('bulk-crop-16-9');
|
||||||
|
const crop916 = document.getElementById('bulk-crop-9-16');
|
||||||
|
|
||||||
|
const cfg = document.getElementById('page-config');
|
||||||
|
const TARGET_W = parseInt(cfg?.dataset?.imageCropTargetW || '1920', 10) || 1920;
|
||||||
|
const TARGET_H = parseInt(cfg?.dataset?.imageCropTargetH || '1080', 10) || 1080;
|
||||||
|
|
||||||
|
const uploadUrl = `{{ url_for('company.bulk_upload_playlist_images', playlist_id=playlist.id) }}`;
|
||||||
|
|
||||||
|
let selectedFiles = [];
|
||||||
|
let processedFiles = []; // cropped output
|
||||||
|
|
||||||
|
function setError(msg) {
|
||||||
|
if (errorEl) errorEl.textContent = (msg || '').trim();
|
||||||
|
}
|
||||||
|
function setStatus(msg) {
|
||||||
|
if (statusEl) statusEl.textContent = (msg || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentCropMode() {
|
||||||
|
return crop916?.checked ? '9:16' : '16:9';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearList() {
|
||||||
|
if (listEl) listEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addListItem(name, initialText) {
|
||||||
|
if (!listEl) return null;
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||||
|
li.innerHTML = `
|
||||||
|
<div class="text-truncate" style="max-width: 70%;">${escapeHtml(name)}</div>
|
||||||
|
<div class="small text-muted">${escapeHtml(initialText || '')}</div>
|
||||||
|
`;
|
||||||
|
listEl.appendChild(li);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setListItemStatus(li, txt, kind) {
|
||||||
|
if (!li) return;
|
||||||
|
const right = li.querySelector('div.small');
|
||||||
|
if (!right) return;
|
||||||
|
right.textContent = txt || '';
|
||||||
|
right.classList.toggle('text-danger', kind === 'err');
|
||||||
|
right.classList.toggle('text-success', kind === 'ok');
|
||||||
|
right.classList.toggle('text-muted', !kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.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() {
|
(function() {
|
||||||
|
|||||||
@@ -8,6 +8,23 @@
|
|||||||
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
|
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
|
||||||
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
|
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
|
||||||
|
|
||||||
|
/* When ticker is shown, keep content from being visually covered.
|
||||||
|
(We pad the stage; video/img/iframe inside will keep aspect.) */
|
||||||
|
body.has-ticker #stage {
|
||||||
|
bottom: var(--ticker-height, 54px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional company overlay (transparent PNG) */
|
||||||
|
#overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
/* Slide transitions (applied by JS via classes) */
|
/* Slide transitions (applied by JS via classes) */
|
||||||
#stage .slide {
|
#stage .slide {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -87,6 +104,41 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
opacity: 0.95;
|
opacity: 0.95;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ticker tape */
|
||||||
|
#ticker {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: var(--ticker-height, 54px);
|
||||||
|
background: rgba(0, 0, 0, 0.75); /* overridden by JS via style */
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 6; /* above stage, below notice */
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#ticker .track {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
will-change: transform;
|
||||||
|
animation: ticker-scroll linear infinite;
|
||||||
|
animation-duration: var(--ticker-duration, 60s);
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
#ticker .item {
|
||||||
|
padding: 0 26px;
|
||||||
|
}
|
||||||
|
#ticker .sep {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
@keyframes ticker-scroll {
|
||||||
|
/* We duplicate the content twice, so shifting -50% effectively loops. */
|
||||||
|
0% { transform: translateX(0); }
|
||||||
|
100% { transform: translateX(calc(-1 * var(--ticker-shift, 50%))); }
|
||||||
|
}
|
||||||
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
|
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
|
||||||
/* removed bottom-left status text */
|
/* removed bottom-left status text */
|
||||||
</style>
|
</style>
|
||||||
@@ -99,9 +151,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="stage"></div>
|
<div id="stage"></div>
|
||||||
|
<div id="ticker" aria-hidden="true">
|
||||||
|
<div class="track" id="tickerTrack"></div>
|
||||||
|
</div>
|
||||||
|
{% if overlay_url %}
|
||||||
|
<img id="overlay" src="{{ overlay_url }}" alt="Overlay" />
|
||||||
|
{% endif %}
|
||||||
<script>
|
<script>
|
||||||
const token = "{{ display.token }}";
|
const token = "{{ display.token }}";
|
||||||
const stage = document.getElementById('stage');
|
const stage = document.getElementById('stage');
|
||||||
|
let overlayEl = document.getElementById('overlay');
|
||||||
const noticeEl = document.getElementById('notice');
|
const noticeEl = document.getElementById('notice');
|
||||||
const noticeTitleEl = document.getElementById('noticeTitle');
|
const noticeTitleEl = document.getElementById('noticeTitle');
|
||||||
const noticeTextEl = document.getElementById('noticeText');
|
const noticeTextEl = document.getElementById('noticeText');
|
||||||
@@ -137,6 +196,21 @@
|
|||||||
let idx = 0;
|
let idx = 0;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
|
|
||||||
|
// Ticker DOM
|
||||||
|
const tickerEl = document.getElementById('ticker');
|
||||||
|
const tickerTrackEl = document.getElementById('tickerTrack');
|
||||||
|
let tickerConfig = null;
|
||||||
|
let tickerInterval = null;
|
||||||
|
let tickerLastHeadlines = [];
|
||||||
|
|
||||||
|
function getTickerPollSeconds() {
|
||||||
|
// Refresh headlines on a long interval.
|
||||||
|
// Default: 12 hours (twice per day).
|
||||||
|
// Override via ?ticker_poll=seconds.
|
||||||
|
const tp = parseInt(new URLSearchParams(window.location.search).get('ticker_poll') || '', 10);
|
||||||
|
return Number.isFinite(tp) && tp > 0 ? tp : (12 * 60 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
const ANIM_MS = 420;
|
const ANIM_MS = 420;
|
||||||
|
|
||||||
function getTransitionMode(pl) {
|
function getTransitionMode(pl) {
|
||||||
@@ -159,11 +233,216 @@
|
|||||||
return await res.json();
|
return await res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchTickerHeadlines() {
|
||||||
|
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
|
||||||
|
const res = await fetch(`/api/display/${token}/ticker${qs}`, { cache: 'no-store' });
|
||||||
|
if (res.status === 429) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
throw Object.assign(new Error(data?.message || 'Display limit reached'), { code: 'LIMIT', data });
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeCss(val) {
|
||||||
|
return (val || '').toString().replace(/[\n\r"']/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTickerStyle(cfg) {
|
||||||
|
if (!tickerEl) return;
|
||||||
|
const color = safeCss(cfg && cfg.color);
|
||||||
|
const bgColor = safeCss(cfg && cfg.bg_color);
|
||||||
|
const bgOpacityRaw = parseInt((cfg && cfg.bg_opacity) || '', 10);
|
||||||
|
const bgOpacity = Number.isFinite(bgOpacityRaw) ? Math.max(0, Math.min(100, bgOpacityRaw)) : 75;
|
||||||
|
const fontFamily = safeCss(cfg && cfg.font_family);
|
||||||
|
const sizePx = parseInt((cfg && cfg.font_size_px) || '', 10);
|
||||||
|
const fontSize = Number.isFinite(sizePx) ? Math.max(10, Math.min(200, sizePx)) : 28;
|
||||||
|
|
||||||
|
// Height is slightly larger than font size.
|
||||||
|
const height = Math.max(36, Math.min(120, fontSize + 26));
|
||||||
|
|
||||||
|
tickerEl.style.color = color || '#ffffff';
|
||||||
|
tickerEl.style.fontFamily = fontFamily || 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';
|
||||||
|
tickerEl.style.fontSize = `${fontSize}px`;
|
||||||
|
tickerEl.style.setProperty('--ticker-height', `${height}px`);
|
||||||
|
|
||||||
|
// Background color + opacity
|
||||||
|
tickerEl.style.backgroundColor = toRgba(bgColor || '#000000', bgOpacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRgba(hexColor, opacityPercent) {
|
||||||
|
const s = (hexColor || '').toString().trim().toLowerCase();
|
||||||
|
const a = Math.max(0, Math.min(100, parseInt(opacityPercent || '0', 10))) / 100;
|
||||||
|
// Accept #rgb or #rrggbb. Fallback to black.
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
if (s.startsWith('#')) {
|
||||||
|
const h = s.slice(1);
|
||||||
|
if (h.length === 3) {
|
||||||
|
r = parseInt(h[0] + h[0], 16);
|
||||||
|
g = parseInt(h[1] + h[1], 16);
|
||||||
|
b = parseInt(h[2] + h[2], 16);
|
||||||
|
} else if (h.length === 6) {
|
||||||
|
r = parseInt(h.slice(0,2), 16);
|
||||||
|
g = parseInt(h.slice(2,4), 16);
|
||||||
|
b = parseInt(h.slice(4,6), 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTickerDurationPx(copyWidthPx) {
|
||||||
|
const w = Math.max(1, parseInt(copyWidthPx || '0', 10) || 0);
|
||||||
|
|
||||||
|
// Speed slider (1..100): higher => faster.
|
||||||
|
const rawSpeed = parseInt((tickerConfig && tickerConfig.speed) || '', 10);
|
||||||
|
const speed = Number.isFinite(rawSpeed) ? Math.max(1, Math.min(100, rawSpeed)) : 25;
|
||||||
|
|
||||||
|
// Map speed to pixels/second. (tuned to be readable on signage)
|
||||||
|
// speed=25 => ~38 px/s, speed=100 => ~128 px/s
|
||||||
|
const pxPerSecond = Math.max(8, Math.min(180, 8 + (speed * 1.2)));
|
||||||
|
const seconds = w / pxPerSecond;
|
||||||
|
return Math.max(12, Math.min(600, seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTickerCopyHtml(list) {
|
||||||
|
// No trailing separator at the end.
|
||||||
|
return list.map((t, i) => {
|
||||||
|
const sep = (i === list.length - 1) ? '' : '<span class="sep">•</span>';
|
||||||
|
return `<span class="item">${escapeHtml(t)}</span>${sep}`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTickerHeadlines(headlines) {
|
||||||
|
if (!tickerEl || !tickerTrackEl) return;
|
||||||
|
const list = Array.isArray(headlines) ? headlines.map(x => (x || '').toString().trim()).filter(Boolean) : [];
|
||||||
|
if (!list.length) {
|
||||||
|
tickerEl.style.display = 'none';
|
||||||
|
tickerTrackEl.innerHTML = '';
|
||||||
|
document.body.classList.remove('has-ticker');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tickerLastHeadlines = list.slice();
|
||||||
|
|
||||||
|
// Show first so measurements work.
|
||||||
|
tickerEl.style.display = 'flex';
|
||||||
|
document.body.classList.add('has-ticker');
|
||||||
|
|
||||||
|
// Build one copy.
|
||||||
|
const oneCopyHtml = buildTickerCopyHtml(list);
|
||||||
|
tickerTrackEl.innerHTML = oneCopyHtml;
|
||||||
|
|
||||||
|
// Ensure we repeat enough so there is never an empty gap, even when the
|
||||||
|
// total headline width is smaller than the viewport.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
const viewportW = tickerEl.clientWidth || 1;
|
||||||
|
const copyW = tickerTrackEl.scrollWidth || 1;
|
||||||
|
|
||||||
|
// Want at least 2x viewport width in total track content.
|
||||||
|
const repeats = Math.max(2, Math.ceil((viewportW * 2) / copyW) + 1);
|
||||||
|
tickerTrackEl.innerHTML = oneCopyHtml.repeat(repeats);
|
||||||
|
|
||||||
|
// Shift by exactly one copy width. In % of total track width that is 100/repeats.
|
||||||
|
const shiftPercent = 100 / repeats;
|
||||||
|
tickerEl.style.setProperty('--ticker-shift', `${shiftPercent}%`);
|
||||||
|
tickerEl.style.setProperty('--ticker-duration', `${computeTickerDurationPx(copyW)}s`);
|
||||||
|
} catch (e) {
|
||||||
|
// fallback: 2 copies
|
||||||
|
tickerTrackEl.innerHTML = oneCopyHtml + oneCopyHtml;
|
||||||
|
tickerEl.style.setProperty('--ticker-shift', '50%');
|
||||||
|
tickerEl.style.setProperty('--ticker-duration', `${computeTickerDurationPx(2000)}s`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return (s || '').toString()
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.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() {
|
function clearStage() {
|
||||||
if (timer) { clearTimeout(timer); timer = null; }
|
if (timer) { clearTimeout(timer); timer = null; }
|
||||||
stage.innerHTML = '';
|
stage.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setOverlaySrc(src) {
|
||||||
|
const val = (src || '').trim();
|
||||||
|
if (!val) {
|
||||||
|
if (overlayEl && overlayEl.parentNode) overlayEl.parentNode.removeChild(overlayEl);
|
||||||
|
overlayEl = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!overlayEl) {
|
||||||
|
overlayEl = document.createElement('img');
|
||||||
|
overlayEl.id = 'overlay';
|
||||||
|
overlayEl.alt = 'Overlay';
|
||||||
|
document.body.appendChild(overlayEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-bust in preview mode so changes show up instantly.
|
||||||
|
if (isPreview) {
|
||||||
|
try {
|
||||||
|
const u = new URL(val, window.location.origin);
|
||||||
|
u.searchParams.set('_ts', String(Date.now()));
|
||||||
|
overlayEl.src = u.toString();
|
||||||
|
return;
|
||||||
|
} catch(e) {
|
||||||
|
// fallthrough
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overlayEl.src = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize overlay from server-side render.
|
||||||
|
if (overlayEl && overlayEl.src) setOverlaySrc(overlayEl.src);
|
||||||
|
|
||||||
function setSlideContent(container, item) {
|
function setSlideContent(container, item) {
|
||||||
if (item.type === 'image') {
|
if (item.type === 'image') {
|
||||||
const el = document.createElement('img');
|
const el = document.createElement('img');
|
||||||
@@ -272,6 +551,15 @@
|
|||||||
playlist = await fetchPlaylist();
|
playlist = await fetchPlaylist();
|
||||||
idx = 0;
|
idx = 0;
|
||||||
applyTransitionClass(getTransitionMode(playlist));
|
applyTransitionClass(getTransitionMode(playlist));
|
||||||
|
setOverlaySrc(playlist && playlist.overlay_src);
|
||||||
|
tickerConfig = (playlist && playlist.ticker) ? playlist.ticker : null;
|
||||||
|
applyTickerStyle(tickerConfig);
|
||||||
|
await refreshTickerOnce();
|
||||||
|
if (tickerConfig && tickerConfig.enabled) {
|
||||||
|
startTickerPolling();
|
||||||
|
} else {
|
||||||
|
stopTickerPolling();
|
||||||
|
}
|
||||||
next();
|
next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
clearStage();
|
clearStage();
|
||||||
@@ -300,6 +588,39 @@
|
|||||||
const oldStr = JSON.stringify(playlist);
|
const oldStr = JSON.stringify(playlist);
|
||||||
const newStr = JSON.stringify(newPlaylist);
|
const newStr = JSON.stringify(newPlaylist);
|
||||||
playlist = newPlaylist;
|
playlist = newPlaylist;
|
||||||
|
setOverlaySrc(playlist && playlist.overlay_src);
|
||||||
|
|
||||||
|
// Apply ticker settings (and refresh if settings changed)
|
||||||
|
const newTickerCfg = (playlist && playlist.ticker) ? playlist.ticker : null;
|
||||||
|
const oldTickerStr = JSON.stringify(tickerConfig);
|
||||||
|
const newTickerStr = JSON.stringify(newTickerCfg);
|
||||||
|
const oldEnabled = !!(tickerConfig && tickerConfig.enabled);
|
||||||
|
const newEnabled = !!(newTickerCfg && newTickerCfg.enabled);
|
||||||
|
const oldRssUrl = (tickerConfig && tickerConfig.rss_url) ? String(tickerConfig.rss_url) : '';
|
||||||
|
const newRssUrl = (newTickerCfg && newTickerCfg.rss_url) ? String(newTickerCfg.rss_url) : '';
|
||||||
|
tickerConfig = newTickerCfg;
|
||||||
|
applyTickerStyle(tickerConfig);
|
||||||
|
if (oldTickerStr !== newTickerStr) {
|
||||||
|
// Ensure enable/disable toggles are applied immediately (no reload required).
|
||||||
|
if (oldEnabled !== newEnabled) {
|
||||||
|
if (!newEnabled) {
|
||||||
|
// Hide and stop polling.
|
||||||
|
tickerLastHeadlines = [];
|
||||||
|
stopTickerPolling();
|
||||||
|
setTickerHeadlines([]);
|
||||||
|
} else {
|
||||||
|
// Re-enable: fetch now and restart headline polling.
|
||||||
|
await refreshTickerOnce();
|
||||||
|
startTickerPolling();
|
||||||
|
}
|
||||||
|
} else if (oldRssUrl !== newRssUrl) {
|
||||||
|
// RSS URL changed: refetch now.
|
||||||
|
await refreshTickerOnce();
|
||||||
|
} else {
|
||||||
|
// Style/speed change only: rerender from cache to apply instantly.
|
||||||
|
if (newEnabled) rerenderTickerFromCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
if (oldStr !== newStr) {
|
if (oldStr !== newStr) {
|
||||||
idx = 0;
|
idx = 0;
|
||||||
applyTransitionClass(getTransitionMode(playlist));
|
applyTransitionClass(getTransitionMode(playlist));
|
||||||
|
|||||||
136
scripts/release.py
Normal file
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