Merge pull request 'themes' (#2) from themes into master

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
gru
2026-05-23 00:32:07 +02:00
21 changed files with 786 additions and 118 deletions

4
.gitignore vendored
View File

@@ -42,4 +42,6 @@ data/logs/*
todo.txt todo.txt
pytorrent/static/libs/* pytorrent/static/libs/*
!pytorrent/static/libs/pytorrent-themes/
!pytorrent/static/libs/pytorrent-themes/**

View File

@@ -257,9 +257,7 @@ def torrent_file_export_link(torrent_hash: str):
if not profile: if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400 return jsonify({"ok": False, "error": "No profile"}), 400
try: try:
# Note: Export availability is checked before the UI receives a temporary /download URL. # Note: Create only a short-lived link here; the actual .torrent export runs once when the browser opens /download/<token>.
item = rtorrent.export_torrent_file(profile, torrent_hash)
_cleanup_staged_file(profile, item["path"], bool(item.get("local")))
link = pdf_preview_links.create_torrent_file_download_link(torrent_hash, int(profile.get("id") or 0), int(default_user_id() or 0)) link = pdf_preview_links.create_torrent_file_download_link(torrent_hash, int(profile.get("id") or 0), int(default_user_id() or 0))
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]}) return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
except Exception as exc: except Exception as exc:
@@ -276,15 +274,7 @@ def torrent_files_export_zip_link():
if not hashes: if not hashes:
return jsonify({"ok": False, "error": "No torrents selected"}), 400 return jsonify({"ok": False, "error": "No torrents selected"}), 400
try: try:
# Note: Each hash is checked before the temporary ZIP export link is returned to the UI. # Note: Store only the selected hashes in the temporary token; exporting each .torrent now happens once during the real ZIP download.
staged_paths = []
try:
for h in hashes:
item = rtorrent.export_torrent_file(profile, h)
staged_paths.append((item["path"], bool(item.get("local"))))
finally:
for path, is_local in staged_paths:
_cleanup_staged_file(profile, path, is_local)
link = pdf_preview_links.create_torrent_files_zip_download_link(hashes, int(profile.get("id") or 0), int(default_user_id() or 0)) link = pdf_preview_links.create_torrent_files_zip_download_link(hashes, int(profile.get("id") or 0), int(default_user_id() or 0))
return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]}) return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]})
except Exception as exc: except Exception as exc:

View File

@@ -40,18 +40,72 @@ def google_fonts_css_url() -> str:
return f"https://fonts.googleapis.com/css2?{families}&display=swap" return f"https://fonts.googleapis.com/css2?{families}&display=swap"
BOOTSTRAP_THEMES = ( DEVEXPRESS_BOOTSTRAP_THEMES = {
"default", "blazing-berry": "Blazing Berry",
"flatly", "office-white": "Office White",
"litera", "purple": "Purple",
"lumen", }
"minty",
"sketchy", PYTORRENT_APP_THEMES = {
"solar", "adaptive": "pyTorrent Adaptive",
"spacelab", "ocean": "pyTorrent Ocean",
"united", "graphite": "pyTorrent Graphite",
"zephyr", "forest": "pyTorrent Forest",
) "amber": "pyTorrent Amber",
"nord": "pyTorrent Nord",
"crimson": "pyTorrent Crimson",
"sky": "pyTorrent Sky",
}
BOOTSTRAP_THEME_DEFINITIONS = {
"default": {
"label": "Default Bootstrap",
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
},
# Bootswatch themes.
"flatly": {"label": "Bootswatch: Flatly", "provider": "bootswatch"},
"litera": {"label": "Bootswatch: Litera", "provider": "bootswatch"},
"lumen": {"label": "Bootswatch: Lumen", "provider": "bootswatch"},
"minty": {"label": "Bootswatch: Minty", "provider": "bootswatch"},
"sketchy": {"label": "Bootswatch: Sketchy", "provider": "bootswatch"},
"spacelab": {"label": "Bootswatch: Spacelab", "provider": "bootswatch"},
"united": {"label": "Bootswatch: United", "provider": "bootswatch"},
"zephyr": {"label": "Bootswatch: Zephyr", "provider": "bootswatch"},
# Complete DevExpress Bootstrap v5 dist.v5 set.
**{
f"dx-{theme}": {
"label": f"DevExpress: {label}",
"provider": "devexpress",
"local": f"{LIBS_STATIC_DIR}/devexpress-bootstrap-themes/dist.v5/{theme}/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/gh/DevExpress/bootstrap-themes@master/dist.v5/{theme}/bootstrap.min.css",
}
for theme, label in DEVEXPRESS_BOOTSTRAP_THEMES.items()
},
# App-specific Bootstrap variable overrides. These sit on top of default Bootstrap.
**{
f"pytorrent-{theme}": {
"label": f"Custom: {label}",
"provider": "pytorrent",
"local": f"{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css",
"cdn": f"/static/{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css",
}
for theme, label in PYTORRENT_APP_THEMES.items()
},
}
def _theme_definition(theme: str | None) -> dict[str, str]:
theme = theme if theme in BOOTSTRAP_THEME_DEFINITIONS else "default"
item = dict(BOOTSTRAP_THEME_DEFINITIONS[theme])
if item.get("provider") == "bootswatch":
item["local"] = f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css"
item["cdn"] = f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css"
return item
BOOTSTRAP_THEMES = tuple(BOOTSTRAP_THEME_DEFINITIONS.keys())
BOOTSTRAP_THEME_LABELS = {key: value["label"] for key, value in BOOTSTRAP_THEME_DEFINITIONS.items()}
STATIC_ASSETS = { STATIC_ASSETS = {
"bootstrap_js": { "bootstrap_js": {
@@ -86,16 +140,8 @@ STATIC_ASSETS = {
def bootstrap_css_asset(theme: str | None = None) -> dict[str, str]: def bootstrap_css_asset(theme: str | None = None) -> dict[str, str]:
theme = theme if theme in BOOTSTRAP_THEMES else "default" item = _theme_definition(theme)
if theme == "default": return {"local": item["local"], "cdn": item["cdn"]}
return {
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
}
return {
"local": f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css",
}
def asset_path(key: str) -> str: def asset_path(key: str) -> str:

View File

@@ -98,7 +98,10 @@ def record_job_event(profile_id: int, action: str, status: str, payload: dict |
severity = "danger" if status == "failed" else "info" severity = "danger" if status == "failed" else "info"
if action in {"add_magnet", "add_torrent_raw"}: if action in {"add_magnet", "add_torrent_raw"}:
name = str(payload.get("name") or payload.get("filename") or payload.get("uri") or "torrent")[:300] name = str(payload.get("name") or payload.get("filename") or payload.get("uri") or "torrent")[:300]
msg = f"{action} {status}: {name}" # Note: Keep the internal action name stable, but show a user-facing label instead of raw worker identifiers.
source_label = "Torrent file" if action == "add_torrent_raw" else "Magnet link"
status_label = {"started": "queued", "done": "added", "failed": "failed"}.get(str(status), str(status))
msg = f"{source_label} {status_label}: {name}"
record(profile_id, "torrent_added" if status == "done" else event_type, msg, severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "directory": payload.get("directory"), "label": payload.get("label"), "error": error, "result": result}, user_id=user_id) record(profile_id, "torrent_added" if status == "done" else event_type, msg, severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "directory": payload.get("directory"), "label": payload.get("label"), "error": error, "result": result}, user_id=user_id)
return return
if not hashes: if not hashes:

View File

@@ -4,19 +4,9 @@ import json
from ..db import connect, utcnow, default_user_id from ..db import connect, utcnow, default_user_id
from . import auth from . import auth
from .frontend_assets import BOOTSTRAP_THEME_LABELS
BOOTSTRAP_THEMES = { BOOTSTRAP_THEMES = BOOTSTRAP_THEME_LABELS
"default": "Default Bootstrap",
"flatly": "Flatly",
"litera": "Litera",
"lumen": "Lumen",
"minty": "Minty",
"sketchy": "Sketchy",
"solar": "Solar",
"spacelab": "Spacelab",
"united": "United",
"zephyr": "Zephyr",
}
FONT_FAMILIES = { FONT_FAMILIES = {
"default": "Theme default", "default": "Theme default",

View File

@@ -59,10 +59,17 @@ def _torrent_file_remote_path(profile: dict, torrent_hash: str, index: int) -> t
if selected is None: if selected is None:
available = ", ".join(str(f.get("index")) for f in files[:20]) or "none" available = ", ".join(str(f.get("index")) for f in files[:20]) or "none"
raise ValueError(f"File index {index} not found. Available indexes: {available}") raise ValueError(f"File index {index} not found. Available indexes: {available}")
base = _remote_clean_path(_torrent_data_path(c, torrent_hash)) base = _remote_clean_path(_torrent_data_path(c, torrent_hash))
rel = str(selected.get("path") or "").lstrip("/") rel = str(selected.get("path") or "").lstrip("/")
if len(files) == 1 and base and not base.endswith("/"):
path = base # Note: rTorrent can report d.base_path as either the payload file or the
# containing data directory for a one-file torrent. Keep both existing
# layouts working and avoid treating a directory as the media file.
if len(files) == 1 and base and rel:
base_name = posixpath.basename(base.rstrip("/"))
rel_name = posixpath.basename(rel.rstrip("/"))
path = base if base_name == rel_name else _remote_join(base, rel)
else: else:
path = _remote_join(base, rel) path = _remote_join(base, rel)
return selected, path return selected, path
@@ -176,25 +183,16 @@ def _media_info_sample_suffix(source_path: str) -> str:
def _read_file_prefix(profile: dict, source_path: str, max_bytes: int) -> bytes: def _read_file_prefix(profile: dict, source_path: str, max_bytes: int) -> bytes:
# Note: Small previews use a bounded prefix read, so text and image preview actions never load an entire large file into RAM. # Note: File info must read through rTorrent, not the pyTorrent process, because torrents may live on a remote host or under rTorrent-only permissions.
limit = max(0, int(max_bytes or 0)) limit = max(0, int(max_bytes or 0))
chunks: list[bytes] = [] chunks: list[bytes] = []
collected = 0 collected = 0
if int(profile.get("is_remote") or 0): for chunk in iter_remote_file_chunks(profile, source_path, size=limit, chunk_size=_MEDIA_INFO_CHUNK_BYTES):
for chunk in iter_remote_file_chunks(profile, source_path, size=limit, chunk_size=_MEDIA_INFO_CHUNK_BYTES): if collected >= limit:
if collected >= limit: break
break data = bytes(chunk[: max(0, limit - collected)])
data = bytes(chunk[: max(0, limit - collected)]) chunks.append(data)
chunks.append(data) collected += len(data)
collected += len(data)
else:
with open(source_path, "rb") as src:
while collected < limit:
data = src.read(min(_MEDIA_INFO_CHUNK_BYTES, limit - collected))
if not data:
break
chunks.append(data)
collected += len(data)
return b"".join(chunks) return b"".join(chunks)
@@ -340,21 +338,12 @@ def _media_info_temp_sample(profile: dict, source_path: str, max_bytes: int) ->
written = 0 written = 0
try: try:
with os.fdopen(fd, "wb") as tmp: with os.fdopen(fd, "wb") as tmp:
if int(profile.get("is_remote") or 0): for chunk in iter_remote_file_chunks(profile, source_path, size=max_bytes, chunk_size=_MEDIA_INFO_CHUNK_BYTES):
for chunk in iter_remote_file_chunks(profile, source_path, size=max_bytes, chunk_size=_MEDIA_INFO_CHUNK_BYTES): if written >= max_bytes:
if written >= max_bytes: break
break data = bytes(chunk[: max(0, max_bytes - written)])
data = bytes(chunk[: max(0, max_bytes - written)]) tmp.write(data)
tmp.write(data) written += len(data)
written += len(data)
else:
with open(source_path, "rb") as src:
while written < max_bytes:
data = src.read(min(_MEDIA_INFO_CHUNK_BYTES, max_bytes - written))
if not data:
break
tmp.write(data)
written += len(data)
return tmp_path, written return tmp_path, written
except Exception: except Exception:
try: try:
@@ -447,13 +436,25 @@ def _media_info_hachoir_imports():
) from exc ) from exc
def _torrent_file_is_complete(selected: dict) -> bool:
# Note: File info reads real file bytes, so incomplete payload files are blocked before any parser touches them.
size = int(selected.get("size") or 0)
completed_chunks = int(selected.get("completed_chunks") or 0)
size_chunks = int(selected.get("size_chunks") or 0)
progress = float(selected.get("progress") or 0)
return size <= 0 or progress >= 100.0 or (size_chunks > 0 and completed_chunks >= size_chunks)
def torrent_file_media_info(profile: dict, torrent_hash: str, index: int, max_bytes: int = _MEDIA_INFO_SAMPLE_BYTES) -> dict: def torrent_file_media_info(profile: dict, torrent_hash: str, index: int, max_bytes: int = _MEDIA_INFO_SAMPLE_BYTES) -> dict:
# Note: This additive endpoint now acts as a smart file preview: media metadata, text/NFO reader, or image preview depending on file type. # Note: This additive endpoint now acts as a smart file preview: media metadata, text/NFO reader, or image preview depending on file type.
selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index) selected, remote_path = _torrent_file_remote_path(profile, torrent_hash, index)
name = str(selected.get("path") or remote_path) name = str(selected.get("path") or remote_path)
size = int(selected.get("size") or 0) size = int(selected.get("size") or 0)
err = remote_file_readability_error(profile, remote_path) if int(profile.get("is_remote") or 0) else None if not _torrent_file_is_complete(selected):
raise RuntimeError("File info is available only after this file is fully downloaded.")
err = remote_file_readability_error(profile, remote_path)
if err: if err:
raise RuntimeError(err) raise RuntimeError(err)
@@ -541,17 +542,31 @@ def torrent_download_zip_items(profile: dict, torrent_hash: str, indexes: list[i
return items return items
def _remote_file_exists(c: ScgiRtorrentClient, source_path: str) -> bool:
# Note: Export fallback checks candidate .torrent files on the rTorrent host before staging, avoiding stale tied-file paths.
clean = _remote_clean_path(source_path)
if not clean:
return False
script = 'p=$1; [ -f "$p" ] && [ -r "$p" ] && printf OK || true'
try:
return str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-file-exists", clean) or "").strip() == "OK"
except Exception:
return False
def _remote_stage_path(c: ScgiRtorrentClient, source_path: str, suffix: str = "") -> str: def _remote_stage_path(c: ScgiRtorrentClient, source_path: str, suffix: str = "") -> str:
token = uuid.uuid4().hex token = uuid.uuid4().hex
safe_suffix = ''.join(ch if ch.isalnum() or ch in '.-_' else '_' for ch in str(suffix or ''))[:80] safe_suffix = ''.join(ch if ch.isalnum() or ch in '.-_' else '_' for ch in str(suffix or ''))[:80]
target = f"{download_tmp_dir().rstrip('/')}/pytorrent-download-{token}{safe_suffix}" target = f"{download_tmp_dir().rstrip('/')}/pytorrent-download-{token}{safe_suffix}"
script = ( script = (
'src=$1; dst=$2; ' 'src=$1; dst=$2; '
'if [ ! -f "$src" ]; then echo "ERR\tmissing source"; exit 0; fi; ' 'if [ ! -f "$src" ]; then printf "ERR\tmissing source: %s\n" "$src"; exit 0; fi; '
'if [ ! -r "$src" ]; then printf "ERR\tsource is not readable: %s\n" "$src"; exit 0; fi; '
'cp -- "$src" "$dst" 2>/tmp/pytorrent-cp-err-$$ || { rc=$?; err=$(cat /tmp/pytorrent-cp-err-$$ 2>/dev/null); rm -f /tmp/pytorrent-cp-err-$$; printf "ERR\t%s\t%s\n" "$rc" "$err"; exit 0; }; ' 'cp -- "$src" "$dst" 2>/tmp/pytorrent-cp-err-$$ || { rc=$?; err=$(cat /tmp/pytorrent-cp-err-$$ 2>/dev/null); rm -f /tmp/pytorrent-cp-err-$$; printf "ERR\t%s\t%s\n" "$rc" "$err"; exit 0; }; '
'rm -f /tmp/pytorrent-cp-err-$$; chmod 0644 "$dst" 2>/dev/null || true; printf "OK\t%s\n" "$dst"' 'rm -f /tmp/pytorrent-cp-err-$$; chmod 0644 "$dst" 2>/dev/null || true; printf "OK\t%s\n" "$dst"'
) )
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-file", source_path, target) or "").strip() clean_source = _remote_clean_path(source_path)
output = str(_rt_execute(c, "execute.capture", "sh", "-c", script, "pytorrent-stage-file", clean_source, target) or "").strip()
parts = (output.splitlines()[0] if output else "").split("\t", 2) parts = (output.splitlines()[0] if output else "").split("\t", 2)
if len(parts) >= 2 and parts[0] == "OK": if len(parts) >= 2 and parts[0] == "OK":
return parts[1] return parts[1]
@@ -643,14 +658,48 @@ def _torrent_raw_from_method(c: ScgiRtorrentClient, torrent_hash: str) -> bytes
return None return None
def _torrent_source_file(c: ScgiRtorrentClient, torrent_hash: str) -> str: def _rtorrent_session_path(c: ScgiRtorrentClient) -> str:
for method in ("session.path", "get_session"):
try:
value = str(c.call(method) or "").strip()
except Exception:
continue
if value:
return _remote_clean_path(value)
return ""
def _torrent_source_file_candidates(c: ScgiRtorrentClient, torrent_hash: str) -> list[str]:
# Note: rTorrent may keep stale watch/tied paths; session candidates preserve .torrent export when the original source was moved.
candidates: list[str] = []
for method in ("d.tied_to_file", "d.get_tied_to_file", "d.loaded_file", "d.get_loaded_file", "d.session_file", "d.get_session_file"): for method in ("d.tied_to_file", "d.get_tied_to_file", "d.loaded_file", "d.get_loaded_file", "d.session_file", "d.get_session_file"):
try: try:
value = str(c.call(method, torrent_hash) or "").strip() value = str(c.call(method, torrent_hash) or "").strip()
except Exception: except Exception:
continue continue
if value: if value:
return value candidates.append(value)
session_path = _rtorrent_session_path(c)
hash_values = []
clean_hash = str(torrent_hash or "").strip()
if clean_hash:
hash_values.extend([clean_hash, clean_hash.upper(), clean_hash.lower()])
for h in dict.fromkeys(hash_values):
if session_path:
candidates.append(_remote_join(session_path, f"{h}.torrent"))
candidates.append(f"/tmp/{h}.torrent")
result = []
for item in candidates:
clean = _remote_clean_path(item)
if clean and clean not in result:
result.append(clean)
return result
def _torrent_source_file(c: ScgiRtorrentClient, torrent_hash: str) -> str:
for source in _torrent_source_file_candidates(c, torrent_hash):
if _remote_file_exists(c, source):
return source
return "" return ""
@@ -658,16 +707,16 @@ def export_torrent_file(profile: dict, torrent_hash: str) -> dict:
c = client_for(profile) c = client_for(profile)
name = str(c.call("d.name", torrent_hash) or torrent_hash).strip() or torrent_hash name = str(c.call("d.name", torrent_hash) or torrent_hash).strip() or torrent_hash
filename = f"{name}.torrent" if not name.lower().endswith(".torrent") else name filename = f"{name}.torrent" if not name.lower().endswith(".torrent") else name
source = _torrent_source_file(c, torrent_hash)
if source:
# Note: Stream the existing .torrent source directly instead of copying it to a temporary staged file first.
return {"path": source, "download_name": filename, "local": False}
raw = _torrent_raw_from_method(c, torrent_hash) raw = _torrent_raw_from_method(c, torrent_hash)
if raw: if raw:
target = LocalPath(download_tmp_dir()) / f"pytorrent-download-{uuid.uuid4().hex}.torrent" target = LocalPath(download_tmp_dir()) / f"pytorrent-download-{uuid.uuid4().hex}.torrent"
target.write_bytes(raw) target.write_bytes(raw)
return {"path": str(target), "download_name": filename, "local": True} return {"path": str(target), "download_name": filename, "local": True}
source = _torrent_source_file(c, torrent_hash) raise RuntimeError("Cannot find torrent source file in rTorrent")
if not source:
raise RuntimeError("Cannot find torrent source file in rTorrent")
staged = _remote_stage_path(c, source, ".torrent")
return {"path": staged, "download_name": filename, "local": False}
def set_file_priorities(profile: dict, torrent_hash: str, files: list[dict]) -> dict: def set_file_priorities(profile: dict, torrent_hash: str, files: list[dict]) -> dict:

File diff suppressed because one or more lines are too long

View File

@@ -3,6 +3,8 @@ export const messagesSource = `
const APP_MESSAGES = { const APP_MESSAGES = {
actions: { actions: {
raw_torrent: 'Add torrent', raw_torrent: 'Add torrent',
add_torrent_raw: 'Add torrent file',
add_magnet: 'Add magnet link',
add: 'Add torrent', add: 'Add torrent',
start: 'Start torrent', start: 'Start torrent',
pause: 'Pause torrent', pause: 'Pause torrent',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,66 @@
/* Balanced violet-blue theme with calm defaults. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #6750a4;
--bs-primary-rgb: 103,80,164;
--bs-link-color: #6750a4;
--bs-link-color-rgb: 103,80,164;
--bs-link-hover-color: #4b2f8f;
--bs-link-hover-color-rgb: 103,80,164;
--bs-body-bg: #f6f7fb;
--bs-body-bg-rgb: 246,247,251;
--bs-body-color: #1f2937;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #eef1f7;
--bs-border-color: #d9deea;
--bs-secondary-color: #667085;
--bs-primary-bg-subtle: #ece7ff;
--bs-primary-text-emphasis: #4b2f8f;
--torrent-progress-complete: #2f9e75;
--pytorrent-page-bg: linear-gradient(180deg, #f7f8fc 0%, #eef1f7 100%);
--pytorrent-shell-shadow: 0 16px 40px rgba(39, 45, 73, 0.12);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #8f7cff;
--bs-primary-rgb: 143,124,255;
--bs-link-color: #8f7cff;
--bs-link-color-rgb: 143,124,255;
--bs-link-hover-color: #c6bdff;
--bs-link-hover-color-rgb: 143,124,255;
--bs-body-bg: #080b12;
--bs-body-bg-rgb: 8,11,18;
--bs-body-color: #dce3f0;
--bs-secondary-bg: #101624;
--bs-secondary-bg-rgb: 16,22,36;
--bs-tertiary-bg: #151d2e;
--bs-border-color: #273247;
--bs-secondary-color: #97a4ba;
--bs-primary-bg-subtle: #191735;
--bs-primary-text-emphasis: #c6bdff;
--torrent-progress-complete: #2f9e75;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(143, 124, 255, 0.16), transparent 34%), #080b12;
--pytorrent-shell-shadow: 0 18px 55px rgba(0, 0, 0, 0.48);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Warm amber theme, good for light mode and softer dark mode. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #b45309;
--bs-primary-rgb: 180,83,9;
--bs-link-color: #b45309;
--bs-link-color-rgb: 180,83,9;
--bs-link-hover-color: #92400e;
--bs-link-hover-color-rgb: 180,83,9;
--bs-body-bg: #fff8ed;
--bs-body-bg-rgb: 255,248,237;
--bs-body-color: #2c1d0b;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #ffefd6;
--bs-border-color: #f3d7aa;
--bs-secondary-color: #7a6750;
--bs-primary-bg-subtle: #ffecd0;
--bs-primary-text-emphasis: #92400e;
--torrent-progress-complete: #16a34a;
--pytorrent-page-bg: linear-gradient(180deg, #fffaf2 0%, #fff0d8 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(146, 64, 14, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #f59e0b;
--bs-primary-rgb: 245,158,11;
--bs-link-color: #f59e0b;
--bs-link-color-rgb: 245,158,11;
--bs-link-hover-color: #fde68a;
--bs-link-hover-color-rgb: 245,158,11;
--bs-body-bg: #140d05;
--bs-body-bg-rgb: 20,13,5;
--bs-body-color: #f5e9d6;
--bs-secondary-bg: #211607;
--bs-secondary-bg-rgb: 33,22,7;
--bs-tertiary-bg: #2b1d0b;
--bs-border-color: #4c3515;
--bs-secondary-color: #c3a780;
--bs-primary-bg-subtle: #3a2609;
--bs-primary-text-emphasis: #fde68a;
--torrent-progress-complete: #22c55e;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(245, 158, 11, 0.16), transparent 34%), #140d05;
--pytorrent-shell-shadow: 0 18px 55px rgba(30, 15, 0, 0.58);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* High-contrast red accent theme without the previous purple feel. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #be123c;
--bs-primary-rgb: 190,18,60;
--bs-link-color: #be123c;
--bs-link-color-rgb: 190,18,60;
--bs-link-hover-color: #9f1239;
--bs-link-hover-color-rgb: 190,18,60;
--bs-body-bg: #fff5f7;
--bs-body-bg-rgb: 255,245,247;
--bs-body-color: #2b1118;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #ffe4ea;
--bs-border-color: #f5c6d1;
--bs-secondary-color: #76545d;
--bs-primary-bg-subtle: #ffe1e9;
--bs-primary-text-emphasis: #9f1239;
--torrent-progress-complete: #16a34a;
--pytorrent-page-bg: linear-gradient(180deg, #fff8fa 0%, #ffe8ee 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(159, 18, 57, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #fb7185;
--bs-primary-rgb: 251,113,133;
--bs-link-color: #fb7185;
--bs-link-color-rgb: 251,113,133;
--bs-link-hover-color: #fecdd3;
--bs-link-hover-color-rgb: 251,113,133;
--bs-body-bg: #13070a;
--bs-body-bg-rgb: 19,7,10;
--bs-body-color: #f7dce2;
--bs-secondary-bg: #211014;
--bs-secondary-bg-rgb: 33,16,20;
--bs-tertiary-bg: #2b151b;
--bs-border-color: #4b2430;
--bs-secondary-color: #c096a1;
--bs-primary-bg-subtle: #3b111b;
--bs-primary-text-emphasis: #fecdd3;
--torrent-progress-complete: #22c55e;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(251, 113, 133, 0.15), transparent 35%), #13070a;
--pytorrent-shell-shadow: 0 18px 55px rgba(28, 0, 8, 0.58);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Green productivity theme with warm contrast. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #2f7d32;
--bs-primary-rgb: 47,125,50;
--bs-link-color: #2f7d32;
--bs-link-color-rgb: 47,125,50;
--bs-link-hover-color: #256329;
--bs-link-hover-color-rgb: 47,125,50;
--bs-body-bg: #f4faf2;
--bs-body-bg-rgb: 244,250,242;
--bs-body-color: #172417;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #e7f4e4;
--bs-border-color: #cce4c7;
--bs-secondary-color: #61735e;
--bs-primary-bg-subtle: #dcf2d7;
--bs-primary-text-emphasis: #256329;
--torrent-progress-complete: #22c55e;
--pytorrent-page-bg: linear-gradient(180deg, #fbfff9 0%, #e9f6e5 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(35, 84, 38, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #4ade80;
--bs-primary-rgb: 74,222,128;
--bs-link-color: #4ade80;
--bs-link-color-rgb: 74,222,128;
--bs-link-hover-color: #bbf7d0;
--bs-link-hover-color-rgb: 74,222,128;
--bs-body-bg: #071109;
--bs-body-bg-rgb: 7,17,9;
--bs-body-color: #d9f1dc;
--bs-secondary-bg: #0f1f12;
--bs-secondary-bg-rgb: 15,31,18;
--bs-tertiary-bg: #152b18;
--bs-border-color: #24472a;
--bs-secondary-color: #95b79a;
--bs-primary-bg-subtle: #13381a;
--bs-primary-text-emphasis: #bbf7d0;
--torrent-progress-complete: #4ade80;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(74, 222, 128, 0.14), transparent 36%), #071109;
--pytorrent-shell-shadow: 0 18px 55px rgba(0, 20, 4, 0.58);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Neutral grey theme for users who want low saturation. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #334155;
--bs-primary-rgb: 51,65,85;
--bs-link-color: #334155;
--bs-link-color-rgb: 51,65,85;
--bs-link-hover-color: #1f2937;
--bs-link-hover-color-rgb: 51,65,85;
--bs-body-bg: #f5f6f8;
--bs-body-bg-rgb: 245,246,248;
--bs-body-color: #18202f;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #eceff3;
--bs-border-color: #d7dce3;
--bs-secondary-color: #667085;
--bs-primary-bg-subtle: #e4e8ef;
--bs-primary-text-emphasis: #1f2937;
--torrent-progress-complete: #16a34a;
--pytorrent-page-bg: linear-gradient(180deg, #fafafa 0%, #edf0f4 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(31, 41, 55, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #94a3b8;
--bs-primary-rgb: 148,163,184;
--bs-link-color: #94a3b8;
--bs-link-color-rgb: 148,163,184;
--bs-link-hover-color: #e2e8f0;
--bs-link-hover-color-rgb: 148,163,184;
--bs-body-bg: #07090d;
--bs-body-bg-rgb: 7,9,13;
--bs-body-color: #d7dde7;
--bs-secondary-bg: #11151c;
--bs-secondary-bg-rgb: 17,21,28;
--bs-tertiary-bg: #171c25;
--bs-border-color: #2b3442;
--bs-secondary-color: #99a3b3;
--bs-primary-bg-subtle: #1d2531;
--bs-primary-text-emphasis: #e2e8f0;
--torrent-progress-complete: #22c55e;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(148, 163, 184, 0.11), transparent 34%), #07090d;
--pytorrent-shell-shadow: 0 18px 55px rgba(0, 0, 0, 0.52);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Cool blue-grey theme inspired by Nordic palettes. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #4c6a92;
--bs-primary-rgb: 76,106,146;
--bs-link-color: #4c6a92;
--bs-link-color-rgb: 76,106,146;
--bs-link-hover-color: #365174;
--bs-link-hover-color-rgb: 76,106,146;
--bs-body-bg: #f4f7fb;
--bs-body-bg-rgb: 244,247,251;
--bs-body-color: #182334;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #e7edf5;
--bs-border-color: #d0d9e8;
--bs-secondary-color: #607089;
--bs-primary-bg-subtle: #dfe8f4;
--bs-primary-text-emphasis: #365174;
--torrent-progress-complete: #3aa675;
--pytorrent-page-bg: linear-gradient(180deg, #fbfdff 0%, #e9eff7 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(47, 64, 87, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #88c0d0;
--bs-primary-rgb: 136,192,208;
--bs-link-color: #88c0d0;
--bs-link-color-rgb: 136,192,208;
--bs-link-hover-color: #b6e3ee;
--bs-link-hover-color-rgb: 136,192,208;
--bs-body-bg: #0b111a;
--bs-body-bg-rgb: 11,17,26;
--bs-body-color: #d8dee9;
--bs-secondary-bg: #111927;
--bs-secondary-bg-rgb: 17,25,39;
--bs-tertiary-bg: #172233;
--bs-border-color: #2e3a4d;
--bs-secondary-color: #9aa8bc;
--bs-primary-bg-subtle: #132b3a;
--bs-primary-text-emphasis: #b6e3ee;
--torrent-progress-complete: #a3be8c;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(136, 192, 208, 0.14), transparent 35%), #0b111a;
--pytorrent-shell-shadow: 0 18px 55px rgba(3, 8, 15, 0.55);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Dark teal/navy with a bright, clean light mode. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #0f766e;
--bs-primary-rgb: 15,118,110;
--bs-link-color: #0f766e;
--bs-link-color-rgb: 15,118,110;
--bs-link-hover-color: #095c55;
--bs-link-hover-color-rgb: 15,118,110;
--bs-body-bg: #f2fbfa;
--bs-body-bg-rgb: 242,251,250;
--bs-body-color: #102a2a;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #dff5f2;
--bs-border-color: #bfe4df;
--bs-secondary-color: #54706f;
--bs-primary-bg-subtle: #d6f4ef;
--bs-primary-text-emphasis: #095c55;
--torrent-progress-complete: #14b8a6;
--pytorrent-page-bg: linear-gradient(180deg, #f7fffe 0%, #e6f7f5 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(10, 78, 74, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #2dd4bf;
--bs-primary-rgb: 45,212,191;
--bs-link-color: #2dd4bf;
--bs-link-color-rgb: 45,212,191;
--bs-link-hover-color: #99f6e4;
--bs-link-hover-color-rgb: 45,212,191;
--bs-body-bg: #031316;
--bs-body-bg-rgb: 3,19,22;
--bs-body-color: #d5f4f1;
--bs-secondary-bg: #082326;
--bs-secondary-bg-rgb: 8,35,38;
--bs-tertiary-bg: #0d3034;
--bs-border-color: #17494e;
--bs-secondary-color: #8ab6b2;
--bs-primary-bg-subtle: #063a3c;
--bs-primary-text-emphasis: #99f6e4;
--torrent-progress-complete: #34d399;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(45, 212, 191, 0.16), transparent 36%), #031316;
--pytorrent-shell-shadow: 0 18px 55px rgba(0, 20, 24, 0.58);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -0,0 +1,66 @@
/* Fresh lighter blue theme with a readable dark variant. */
:root, [data-bs-theme="light"] {
color-scheme: light;
--bs-primary: #0284c7;
--bs-primary-rgb: 2,132,199;
--bs-link-color: #0284c7;
--bs-link-color-rgb: 2,132,199;
--bs-link-hover-color: #0369a1;
--bs-link-hover-color-rgb: 2,132,199;
--bs-body-bg: #f2f9ff;
--bs-body-bg-rgb: 242,249,255;
--bs-body-color: #102033;
--bs-secondary-bg: #ffffff;
--bs-secondary-bg-rgb: 255,255,255;
--bs-tertiary-bg: #dff1ff;
--bs-border-color: #bddff5;
--bs-secondary-color: #567185;
--bs-primary-bg-subtle: #d7efff;
--bs-primary-text-emphasis: #0369a1;
--torrent-progress-complete: #10b981;
--pytorrent-page-bg: linear-gradient(180deg, #f8fcff 0%, #e5f4ff 100%);
--pytorrent-shell-shadow: 0 16px 38px rgba(3, 105, 161, 0.13);
}
[data-bs-theme="dark"] {
color-scheme: dark;
--bs-primary: #38bdf8;
--bs-primary-rgb: 56,189,248;
--bs-link-color: #38bdf8;
--bs-link-color-rgb: 56,189,248;
--bs-link-hover-color: #bae6fd;
--bs-link-hover-color-rgb: 56,189,248;
--bs-body-bg: #06111a;
--bs-body-bg-rgb: 6,17,26;
--bs-body-color: #d9ecf8;
--bs-secondary-bg: #0d1c29;
--bs-secondary-bg-rgb: 13,28,41;
--bs-tertiary-bg: #12283a;
--bs-border-color: #21435b;
--bs-secondary-color: #91b4ca;
--bs-primary-bg-subtle: #0b334d;
--bs-primary-text-emphasis: #bae6fd;
--torrent-progress-complete: #34d399;
--pytorrent-page-bg: radial-gradient(circle at top left, rgba(56, 189, 248, 0.15), transparent 36%), #06111a;
--pytorrent-shell-shadow: 0 18px 55px rgba(0, 13, 24, 0.58);
}
.btn-primary {
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-bg: var(--bs-primary-text-emphasis);
--bs-btn-hover-border-color: var(--bs-primary-text-emphasis);
}
.btn-outline-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-border-color: rgba(var(--bs-primary-rgb), 0.72);
--bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary);
}
.progress,
.progress-stacked {
--bs-progress-bg: rgba(var(--bs-secondary-bg-rgb), 0.82);
}

View File

@@ -7,6 +7,8 @@
--mobile-filterbar-height: 132px; --mobile-filterbar-height: 132px;
--sidebar: calc(270px * var(--ui-scale)); --sidebar: calc(270px * var(--ui-scale));
--torrent-progress-complete: #198754; --torrent-progress-complete: #198754;
--pytorrent-page-bg: var(--bs-body-bg);
--pytorrent-shell-shadow: 0 12px 45px rgba(0, 0, 0, 0.38);
} }
[data-bs-theme="dark"] { [data-bs-theme="dark"] {
--bs-body-bg: #05070a; --bs-body-bg: #05070a;
@@ -20,6 +22,7 @@
--bs-primary-bg-subtle: #0d2238; --bs-primary-bg-subtle: #0d2238;
--bs-primary-text-emphasis: #9ecbff; --bs-primary-text-emphasis: #9ecbff;
--torrent-progress-complete: #2f9e75; --torrent-progress-complete: #2f9e75;
--pytorrent-page-bg: var(--bs-body-bg);
} }
html[data-app-font="adwaita-mono"] { html[data-app-font="adwaita-mono"] {
@@ -109,7 +112,7 @@ body {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh; min-height: 100dvh;
padding: calc(8px * var(--ui-scale)); padding: calc(8px * var(--ui-scale));
background: #05070a; background: var(--pytorrent-page-bg, var(--bs-body-bg));
font-family: var(--app-font-family); font-family: var(--app-font-family);
} }
.app-shell { .app-shell {
@@ -121,7 +124,7 @@ body {
border: 1px solid var(--bs-border-color); border: 1px solid var(--bs-border-color);
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
box-shadow: 0 12px 45px rgba(0, 0, 0, 0.38); box-shadow: var(--pytorrent-shell-shadow, 0 12px 45px rgba(0, 0, 0, 0.38));
} }
.topbar { .topbar {
display: flex; display: flex;
@@ -1524,6 +1527,10 @@ body.mobile-mode .mobile-card {
padding-bottom: 0.1rem; padding-bottom: 0.1rem;
padding-top: 0.1rem; padding-top: 0.1rem;
} }
.file-priority-table .file-media-info-blocked {
cursor: not-allowed;
opacity: 0.55;
}
.file-priority-table .file-check, .file-priority-table .file-check,
.file-priority-table #fileSelectAll { .file-priority-table #fileSelectAll {
display: block; display: block;

View File

@@ -6,6 +6,7 @@
<title>pyTorrent</title> <title>pyTorrent</title>
<link rel="icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml"> <link rel="icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml">
<link rel="shortcut icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml"> <link rel="shortcut icon" href="{{ static_url('favicon.svg') }}" type="image/svg+xml">
<link id="bootstrapBaseStylesheet" href="{{ bootstrap_theme_url('default') }}" rel="stylesheet">
<link id="bootstrapThemeStylesheet" href="{{ bootstrap_theme_url(prefs.bootstrap_theme if prefs else 'default') }}" rel="stylesheet"> <link id="bootstrapThemeStylesheet" href="{{ bootstrap_theme_url(prefs.bootstrap_theme if prefs else 'default') }}" rel="stylesheet">
<link href="{{ frontend_asset_url('fontawesome_css') }}" rel="stylesheet"> <link href="{{ frontend_asset_url('fontawesome_css') }}" rel="stylesheet">
<link href="{{ frontend_asset_url('font_css') }}" rel="stylesheet"> <link href="{{ frontend_asset_url('font_css') }}" rel="stylesheet">

View File

@@ -41,18 +41,71 @@ def google_fonts_css_url() -> str:
return f"https://fonts.googleapis.com/css2?{families}&display=swap" return f"https://fonts.googleapis.com/css2?{families}&display=swap"
BOOTSTRAP_THEMES = ( DEVEXPRESS_BOOTSTRAP_THEMES = {
"default", "blazing-berry": "Blazing Berry",
"flatly", "office-white": "Office White",
"litera", "purple": "Purple",
"lumen", }
"minty",
"sketchy", PYTORRENT_APP_THEMES = {
"solar", "adaptive": "pyTorrent Adaptive",
"spacelab", "ocean": "pyTorrent Ocean",
"united", "graphite": "pyTorrent Graphite",
"zephyr", "forest": "pyTorrent Forest",
) "amber": "pyTorrent Amber",
"nord": "pyTorrent Nord",
"crimson": "pyTorrent Crimson",
"sky": "pyTorrent Sky",
}
BOOTSTRAP_THEME_DEFINITIONS = {
"default": {
"label": "Default Bootstrap",
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
},
# Bootswatch themes.
"flatly": {"label": "Bootswatch: Flatly", "provider": "bootswatch"},
"litera": {"label": "Bootswatch: Litera", "provider": "bootswatch"},
"lumen": {"label": "Bootswatch: Lumen", "provider": "bootswatch"},
"minty": {"label": "Bootswatch: Minty", "provider": "bootswatch"},
"sketchy": {"label": "Bootswatch: Sketchy", "provider": "bootswatch"},
"spacelab": {"label": "Bootswatch: Spacelab", "provider": "bootswatch"},
"united": {"label": "Bootswatch: United", "provider": "bootswatch"},
"zephyr": {"label": "Bootswatch: Zephyr", "provider": "bootswatch"},
# Complete DevExpress Bootstrap v5 dist.v5 set.
**{
f"dx-{theme}": {
"label": f"DevExpress: {label}",
"provider": "devexpress",
"local": f"{LIBS_STATIC_DIR}/devexpress-bootstrap-themes/dist.v5/{theme}/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/gh/DevExpress/bootstrap-themes@master/dist.v5/{theme}/bootstrap.min.css",
}
for theme, label in DEVEXPRESS_BOOTSTRAP_THEMES.items()
},
# App-specific Bootstrap variable overrides. These sit on top of default Bootstrap.
**{
f"pytorrent-{theme}": {
"label": f"Custom: {label}",
"provider": "pytorrent",
"local": f"{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css",
"cdn": f"/static/{LIBS_STATIC_DIR}/pytorrent-themes/{theme}/bootstrap.min.css",
}
for theme, label in PYTORRENT_APP_THEMES.items()
},
}
def _theme_definition(theme: str | None) -> dict[str, str]:
theme = theme if theme in BOOTSTRAP_THEME_DEFINITIONS else "default"
item = dict(BOOTSTRAP_THEME_DEFINITIONS[theme])
if item.get("provider") == "bootswatch":
item["local"] = f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css"
item["cdn"] = f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css"
return item
BOOTSTRAP_THEMES = tuple(BOOTSTRAP_THEME_DEFINITIONS.keys())
STATIC_ASSETS = { STATIC_ASSETS = {
"bootstrap_js": { "bootstrap_js": {
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/js/bootstrap.bundle.min.js", "local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/js/bootstrap.bundle.min.js",
@@ -88,15 +141,8 @@ ANY_URL_RE = re.compile(r"url\((['\"]?)(?!data:)([^)'\"]+)\1\)")
def bootstrap_css_asset(theme: str) -> dict[str, str]: def bootstrap_css_asset(theme: str) -> dict[str, str]:
if theme == "default": item = _theme_definition(theme)
return { return {"local": item["local"], "cdn": item["cdn"]}
"local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css",
}
return {
"local": f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css",
"cdn": f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css",
}
def download(url: str, dest: Path) -> None: def download(url: str, dest: Path) -> None:
@@ -169,7 +215,11 @@ def main() -> None:
for item in items: for item in items:
url = item["cdn"] url = item["cdn"]
dest = ROOT / "pytorrent" / "static" / item["local"] dest = ROOT / "pytorrent" / "static" / item["local"]
if item.get("local") == STATIC_ASSETS["font_css"]["local"]: if url.startswith("/static/"):
if not dest.is_file() or dest.stat().st_size <= 0:
raise RuntimeError(f"Bundled app theme is missing: {dest.relative_to(ROOT)}")
print(f"OK {dest.relative_to(ROOT)}")
elif item.get("local") == STATIC_ASSETS["font_css"]["local"]:
download_google_fonts_css(url, dest) download_google_fonts_css(url, dest)
elif dest.suffix == ".css": elif dest.suffix == ".css":
download_css_with_assets(url, dest) download_css_with_assets(url, dest)