587 lines
21 KiB
Python
587 lines
21 KiB
Python
"""File-backed catalog of Web App UI themes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Literal, Optional
|
|
|
|
from pydantic import BaseModel, Field, model_validator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ColorScheme = Literal["light", "dark"]
|
|
|
|
|
|
class ThemeTokens(BaseModel):
|
|
"""CSS design tokens for the subscription Mini App shell."""
|
|
|
|
model_config = {"extra": "ignore"}
|
|
|
|
color_scheme: ColorScheme = "dark"
|
|
style_preset: Optional[str] = None
|
|
accent: Optional[str] = None
|
|
bg: Optional[str] = None
|
|
panel: Optional[str] = None
|
|
panel_2: Optional[str] = None
|
|
panel_3: Optional[str] = None
|
|
border: Optional[str] = None
|
|
border_strong: Optional[str] = None
|
|
text: Optional[str] = None
|
|
muted: Optional[str] = None
|
|
dim: Optional[str] = None
|
|
danger: Optional[str] = None
|
|
blue: Optional[str] = None
|
|
radius: Optional[str] = None
|
|
font_sans: Optional[str] = None
|
|
font_logo: Optional[str] = None
|
|
font_mono: Optional[str] = None
|
|
admin_bg: Optional[str] = None
|
|
admin_surface: Optional[str] = None
|
|
admin_surface_2: Optional[str] = None
|
|
admin_elev: Optional[str] = None
|
|
admin_border: Optional[str] = None
|
|
admin_border_strong: Optional[str] = None
|
|
admin_text: Optional[str] = None
|
|
admin_muted: Optional[str] = None
|
|
admin_dim: Optional[str] = None
|
|
|
|
|
|
class WebappTheme(BaseModel):
|
|
"""Single theme descriptor loaded from WEBAPP_THEMES_DIR/<key>/theme.json."""
|
|
|
|
model_config = {"extra": "ignore"}
|
|
|
|
key: str = Field(min_length=1, max_length=64)
|
|
names: Dict[str, str] = Field(default_factory=dict)
|
|
enabled: bool = True
|
|
default: bool = False
|
|
use_primary_accent: bool = True
|
|
use_in_admin: bool = True
|
|
css_file: Optional[str] = None
|
|
assets_version: int = 1
|
|
tokens: ThemeTokens = Field(default_factory=ThemeTokens)
|
|
|
|
|
|
class WebappThemesConfig(BaseModel):
|
|
"""Runtime catalog assembled from individual theme descriptor files."""
|
|
|
|
model_config = {"extra": "ignore"}
|
|
|
|
default_theme: str = "dark"
|
|
themes: List[WebappTheme] = Field(default_factory=list)
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_default_and_keys(self) -> WebappThemesConfig:
|
|
keys = [t.key for t in self.themes]
|
|
if len(keys) != len(set(keys)):
|
|
raise ValueError("duplicate theme keys")
|
|
if self.themes and self.default_theme not in keys:
|
|
raise ValueError("default_theme must match a theme key")
|
|
return self
|
|
|
|
def theme_by_key(self, key: str) -> Optional[WebappTheme]:
|
|
for theme in self.themes:
|
|
if theme.key == key:
|
|
return theme
|
|
return None
|
|
|
|
def enabled_themes(self) -> List[WebappTheme]:
|
|
return [theme for theme in self.themes if theme.enabled]
|
|
|
|
|
|
DEFAULT_THEME_KEYS = ("dark", "light", "windows95", "ascii")
|
|
THEME_DESCRIPTOR_FILENAME = "theme.json"
|
|
DEFAULT_THEMES_SOURCE_DIR = Path(__file__).resolve().parents[1] / "bot" / "app" / "web" / "themes"
|
|
|
|
|
|
def _safe_theme_key(value: str) -> Optional[str]:
|
|
key = str(value or "").strip()
|
|
if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key):
|
|
return key
|
|
return None
|
|
|
|
|
|
def _theme_dir_path(theme_dir: str | Path, key: str) -> Path:
|
|
safe_key = _safe_theme_key(key)
|
|
if not safe_key:
|
|
raise ValueError(f"invalid theme key: {key!r}")
|
|
return Path(theme_dir).expanduser() / safe_key
|
|
|
|
|
|
def _theme_file_path(theme_dir: str | Path, key: str) -> Path:
|
|
return _theme_dir_path(theme_dir, key) / THEME_DESCRIPTOR_FILENAME
|
|
|
|
|
|
def default_webapp_theme_css_files() -> Dict[str, str]:
|
|
"""Read default theme CSS from repository files."""
|
|
out: Dict[str, str] = {}
|
|
for key in DEFAULT_THEME_KEYS:
|
|
source_dir = DEFAULT_THEMES_SOURCE_DIR / key
|
|
for source_path in sorted(source_dir.rglob("*.css")):
|
|
rel_path = Path(key) / source_path.relative_to(source_dir)
|
|
try:
|
|
content = source_path.read_text(encoding="utf-8")
|
|
except OSError as exc:
|
|
logger.warning(
|
|
"Default webapp theme CSS source file is missing: %s (%s)",
|
|
source_path,
|
|
exc,
|
|
)
|
|
continue
|
|
out[rel_path.as_posix()] = content if content.endswith("\n") else f"{content}\n"
|
|
return out
|
|
|
|
|
|
def default_webapp_theme_asset_file(rel_path: str | Path) -> Optional[tuple[bytes, str]]:
|
|
"""Read a default theme asset from the repository theme folder."""
|
|
relative = Path(rel_path)
|
|
if relative.is_absolute() or len(relative.parts) < 2 or ".." in relative.parts:
|
|
return None
|
|
source_path = (DEFAULT_THEMES_SOURCE_DIR / relative).resolve()
|
|
try:
|
|
source_path.relative_to(DEFAULT_THEMES_SOURCE_DIR.resolve())
|
|
except ValueError:
|
|
return None
|
|
try:
|
|
return source_path.read_bytes(), source_path.suffix.lower()
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def default_webapp_theme_descriptors() -> Dict[str, Dict[str, Any]]:
|
|
"""Read default theme descriptors from repository files."""
|
|
out: Dict[str, Dict[str, Any]] = {}
|
|
for key in DEFAULT_THEME_KEYS:
|
|
source_path = DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME
|
|
try:
|
|
raw = json.loads(source_path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
logger.warning(
|
|
"Default webapp theme descriptor is missing or invalid: %s (%s)",
|
|
source_path,
|
|
exc,
|
|
)
|
|
continue
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
safe_key = _safe_theme_key(str(raw.get("key") or source_path.parent.name))
|
|
if safe_key:
|
|
raw["key"] = safe_key
|
|
out[safe_key] = raw
|
|
return out
|
|
|
|
|
|
def _theme_from_descriptor(path: Path, raw: Any) -> Optional[WebappTheme]:
|
|
if not isinstance(raw, dict):
|
|
logger.warning("Ignoring theme descriptor %s: expected JSON object", path)
|
|
return None
|
|
data = dict(raw)
|
|
data["key"] = data.get("key") or (
|
|
path.parent.name if path.name == THEME_DESCRIPTOR_FILENAME else path.stem
|
|
)
|
|
safe_key = _safe_theme_key(str(data["key"]))
|
|
if not safe_key:
|
|
logger.warning("Ignoring theme descriptor %s: invalid theme key %r", path, data["key"])
|
|
return None
|
|
data["key"] = safe_key
|
|
try:
|
|
return WebappTheme.model_validate(data)
|
|
except ValueError as exc:
|
|
logger.warning("Ignoring theme descriptor %s: %s", path, exc)
|
|
return None
|
|
|
|
|
|
def load_webapp_theme_file(path: str | Path) -> Optional[WebappTheme]:
|
|
theme_path = Path(path).expanduser()
|
|
try:
|
|
raw = json.loads(theme_path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
logger.warning("Failed to load webapp theme descriptor from %s: %s", theme_path, exc)
|
|
return None
|
|
return _theme_from_descriptor(theme_path, raw)
|
|
|
|
|
|
def load_webapp_theme_dir(theme_dir: str | Path) -> List[WebappTheme]:
|
|
root = Path(theme_dir).expanduser()
|
|
if not root.exists():
|
|
return []
|
|
themes_by_key: Dict[str, WebappTheme] = {}
|
|
for path in sorted(root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}")):
|
|
if path.parent.name.startswith("_"):
|
|
continue
|
|
theme = load_webapp_theme_file(path)
|
|
if theme is None:
|
|
continue
|
|
if theme.key in themes_by_key:
|
|
logger.warning("Ignoring duplicate webapp theme key %s from %s", theme.key, path)
|
|
continue
|
|
themes_by_key[theme.key] = theme
|
|
return list(themes_by_key.values())
|
|
|
|
|
|
def _config_with_synced_default_flags(config: WebappThemesConfig) -> WebappThemesConfig:
|
|
data = config.model_dump(mode="json", exclude_none=True)
|
|
default_theme = str(data.get("default_theme") or "dark")
|
|
themes = data.get("themes", [])
|
|
for theme in themes:
|
|
if isinstance(theme, dict):
|
|
theme["default"] = theme.get("key") == default_theme
|
|
|
|
def _sort_key(item: tuple[int, Any]) -> tuple[int, int]:
|
|
idx, theme = item
|
|
if not isinstance(theme, dict):
|
|
return (len(THEME_DISPLAY_ORDER), idx)
|
|
try:
|
|
priority = THEME_DISPLAY_ORDER.index(str(theme.get("key") or ""))
|
|
except ValueError:
|
|
priority = len(THEME_DISPLAY_ORDER)
|
|
return (priority, idx)
|
|
|
|
data["themes"] = [theme for _, theme in sorted(enumerate(themes), key=_sort_key)]
|
|
return WebappThemesConfig.model_validate(data)
|
|
|
|
|
|
THEME_DISPLAY_ORDER = ("dark", "light")
|
|
|
|
|
|
def _theme_sort_key(theme: WebappTheme, index: int) -> tuple[int, int]:
|
|
try:
|
|
priority = THEME_DISPLAY_ORDER.index(theme.key)
|
|
except ValueError:
|
|
priority = len(THEME_DISPLAY_ORDER)
|
|
return (priority, index)
|
|
|
|
|
|
def _sorted_themes(themes: List[WebappTheme]) -> List[WebappTheme]:
|
|
return [theme for _, theme in sorted(
|
|
((_theme_sort_key(t, i), t) for i, t in enumerate(themes)),
|
|
key=lambda pair: pair[0],
|
|
)]
|
|
|
|
|
|
def _themes_config_from_list(
|
|
default_theme: Optional[str],
|
|
themes: List[WebappTheme],
|
|
) -> WebappThemesConfig:
|
|
keys = {theme.key for theme in themes}
|
|
descriptor_default = next(
|
|
(theme.key for theme in themes if theme.default and theme.key not in DEFAULT_THEME_KEYS),
|
|
None,
|
|
) or next((theme.key for theme in themes if theme.default), None)
|
|
resolved_default = default_theme or descriptor_default or "dark"
|
|
if themes and resolved_default not in keys:
|
|
resolved_default = "dark" if "dark" in keys else themes[0].key
|
|
config = WebappThemesConfig(default_theme=resolved_default, themes=_sorted_themes(themes))
|
|
return _config_with_synced_default_flags(config)
|
|
|
|
|
|
def _write_webapp_theme_file(path: Path, theme: WebappTheme) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
data = theme.model_dump(mode="json", exclude_none=True)
|
|
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
|
|
try:
|
|
tmp_path.write_text(payload, encoding="utf-8")
|
|
tmp_path.replace(path)
|
|
except PermissionError:
|
|
if tmp_path.exists():
|
|
try:
|
|
tmp_path.unlink()
|
|
except OSError:
|
|
pass
|
|
path.write_text(payload, encoding="utf-8")
|
|
|
|
|
|
def _copy_default_theme_assets(key: str, target_dir: Path, *, overwrite: bool = False) -> None:
|
|
source_dir = DEFAULT_THEMES_SOURCE_DIR / key
|
|
if not source_dir.exists():
|
|
return
|
|
for source_path in sorted(source_dir.rglob("*")):
|
|
if not source_path.is_file() or source_path.name == THEME_DESCRIPTOR_FILENAME:
|
|
continue
|
|
rel_path = source_path.relative_to(source_dir)
|
|
target_path = target_dir / rel_path
|
|
if target_path.exists() and not overwrite:
|
|
continue
|
|
try:
|
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
target_path.write_bytes(source_path.read_bytes())
|
|
except OSError as exc:
|
|
logger.warning("Could not create default webapp theme asset %s: %s", target_path, exc)
|
|
|
|
|
|
def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
|
|
style_path = target_dir / "style.css"
|
|
try:
|
|
style = style_path.read_text(encoding="utf-8")
|
|
except OSError:
|
|
return True
|
|
if key == "light":
|
|
return ".theme-key-light.app-shell" not in style
|
|
if key == "ascii":
|
|
return (
|
|
".theme-key-ascii" not in style
|
|
or "ascii-spin" not in style
|
|
or "ascii-skeleton-scan" not in style
|
|
or "ascii-boot-type" not in style
|
|
or "Console-style tables" not in style
|
|
)
|
|
if key != "windows95":
|
|
return False
|
|
required_icons = (
|
|
"arrow-right.png",
|
|
"dashboard.png",
|
|
"megaphone.png",
|
|
"paintbrush.png",
|
|
"sliders.png",
|
|
"sparkles.png",
|
|
"tag.png",
|
|
)
|
|
return (
|
|
"lucide-house" not in style
|
|
or "lucide-earth" not in style
|
|
or "lucide-circle-check" not in style
|
|
or "border-radius: 0 !important" not in style
|
|
or any(not (target_dir / "icons" / icon).exists() for icon in required_icons)
|
|
)
|
|
|
|
|
|
def ensure_default_webapp_theme_descriptor_files(theme_dir: str | Path | None) -> None:
|
|
"""Seed source-controlled default theme folders into the mounted data directory."""
|
|
if not theme_dir:
|
|
return
|
|
root = Path(theme_dir).expanduser()
|
|
try:
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
except OSError as exc:
|
|
logger.warning("Could not create webapp themes directory at %s: %s", root, exc)
|
|
return
|
|
|
|
existing_has_default = any(theme.default for theme in load_webapp_theme_dir(root))
|
|
existing_by_key = {theme.key: theme for theme in load_webapp_theme_dir(root)}
|
|
for key, descriptor in default_webapp_theme_descriptors().items():
|
|
theme_dir_path = _theme_dir_path(root, key)
|
|
path = _theme_file_path(root, key)
|
|
existing = existing_by_key.get(key)
|
|
source_assets_version = int(descriptor.get("assets_version") or 1)
|
|
should_sync_assets = (
|
|
existing is not None
|
|
and key in DEFAULT_THEME_KEYS
|
|
and (
|
|
int(existing.assets_version or 0) < source_assets_version
|
|
or _builtin_theme_assets_need_refresh(key, theme_dir_path)
|
|
)
|
|
)
|
|
if not path.exists():
|
|
seed_descriptor = dict(descriptor)
|
|
if existing_has_default:
|
|
seed_descriptor["default"] = False
|
|
theme = _theme_from_descriptor(path, seed_descriptor)
|
|
if theme is not None:
|
|
try:
|
|
_write_webapp_theme_file(path, theme)
|
|
except OSError as exc:
|
|
logger.warning(
|
|
"Could not create default webapp theme descriptor %s: %s",
|
|
path,
|
|
exc,
|
|
)
|
|
elif should_sync_assets and existing is not None:
|
|
data = existing.model_dump(mode="json", exclude_none=True)
|
|
data["assets_version"] = source_assets_version
|
|
if descriptor.get("css_file"):
|
|
data["css_file"] = descriptor["css_file"]
|
|
try:
|
|
theme = WebappTheme.model_validate(data)
|
|
_write_webapp_theme_file(path, theme)
|
|
except (OSError, ValueError) as exc:
|
|
logger.warning("Could not update default webapp theme descriptor %s: %s", path, exc)
|
|
_copy_default_theme_assets(key, theme_dir_path, overwrite=should_sync_assets)
|
|
|
|
|
|
def write_webapp_theme_dir(
|
|
theme_dir: str | Path,
|
|
config: WebappThemesConfig,
|
|
*,
|
|
delete_missing: bool = False,
|
|
) -> None:
|
|
"""Write one theme.json descriptor per theme into WEBAPP_THEMES_DIR/<key>."""
|
|
root = Path(theme_dir).expanduser()
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
normalized = _config_with_synced_default_flags(config)
|
|
keep_paths = set()
|
|
for theme in normalized.themes:
|
|
path = _theme_file_path(root, theme.key)
|
|
_write_webapp_theme_file(path, theme)
|
|
keep_paths.add(path.resolve())
|
|
|
|
if not delete_missing:
|
|
return
|
|
for path in root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}"):
|
|
if path.parent.name.startswith("_") or path.resolve() in keep_paths:
|
|
continue
|
|
try:
|
|
path.unlink()
|
|
if not any(path.parent.iterdir()):
|
|
path.parent.rmdir()
|
|
except OSError as exc:
|
|
logger.warning("Could not delete removed webapp theme descriptor %s: %s", path, exc)
|
|
|
|
|
|
def ensure_webapp_core_themes(
|
|
config: WebappThemesConfig, primary_accent: str
|
|
) -> tuple[WebappThemesConfig, bool]:
|
|
"""Keep dark, light and Windows 95 themes available without clobbering custom edits."""
|
|
data = config.model_dump(mode="json", exclude_none=True)
|
|
themes = data.setdefault("themes", [])
|
|
by_key = {str(theme.get("key")): theme for theme in themes if isinstance(theme, dict)}
|
|
changed = False
|
|
|
|
for builtin in builtin_webapp_themes_config(primary_accent).themes:
|
|
builtin_data = builtin.model_dump(mode="json", exclude_none=True)
|
|
existing = by_key.get(builtin.key)
|
|
if existing is None:
|
|
themes.append(builtin_data)
|
|
by_key[builtin.key] = builtin_data
|
|
changed = True
|
|
continue
|
|
|
|
if existing.get("enabled") is False:
|
|
existing["enabled"] = True
|
|
changed = True
|
|
|
|
if "use_primary_accent" not in existing:
|
|
existing["use_primary_accent"] = builtin_data.get("use_primary_accent", True)
|
|
changed = True
|
|
|
|
if "use_in_admin" not in existing:
|
|
existing["use_in_admin"] = builtin_data.get("use_in_admin", True)
|
|
changed = True
|
|
|
|
if int(existing.get("assets_version") or 0) < int(builtin_data.get("assets_version") or 1):
|
|
existing["assets_version"] = builtin_data.get("assets_version", 1)
|
|
changed = True
|
|
|
|
if builtin.key in {"light", "windows95", "ascii"} and not existing.get("css_file"):
|
|
existing["css_file"] = builtin_data.get("css_file")
|
|
changed = True
|
|
|
|
tokens = existing.setdefault("tokens", {})
|
|
builtin_tokens = builtin_data.get("tokens", {})
|
|
for token_key in ("color_scheme", "style_preset"):
|
|
if token_key in builtin_tokens and not tokens.get(token_key):
|
|
tokens[token_key] = builtin_tokens[token_key]
|
|
changed = True
|
|
|
|
keys = {str(theme.get("key")) for theme in themes if isinstance(theme, dict)}
|
|
if themes and data.get("default_theme") not in keys:
|
|
data["default_theme"] = "dark" if "dark" in keys else str(themes[0].get("key") or "dark")
|
|
changed = True
|
|
|
|
normalized = _config_with_synced_default_flags(WebappThemesConfig.model_validate(data))
|
|
if normalized.model_dump(mode="json", exclude_none=True) != data:
|
|
changed = True
|
|
return normalized, changed
|
|
|
|
|
|
def builtin_webapp_themes_config(primary_accent: str) -> WebappThemesConfig:
|
|
"""Default catalog backed by repository theme descriptor files."""
|
|
accent = (primary_accent or "#00fe7a").strip() or "#00fe7a"
|
|
themes: List[WebappTheme] = []
|
|
descriptors = default_webapp_theme_descriptors()
|
|
for key in DEFAULT_THEME_KEYS:
|
|
raw = descriptors.get(key)
|
|
if not raw:
|
|
continue
|
|
theme = _theme_from_descriptor(
|
|
DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME,
|
|
raw,
|
|
)
|
|
if theme is None:
|
|
continue
|
|
if theme.key == "dark" and not theme.tokens.accent:
|
|
theme.tokens.accent = accent
|
|
themes.append(theme)
|
|
return _themes_config_from_list(None, themes)
|
|
|
|
|
|
def apply_webapp_theme_env_overrides(
|
|
config: WebappThemesConfig, env_default_theme: Optional[str]
|
|
) -> WebappThemesConfig:
|
|
"""If WEBAPP_DEFAULT_THEME is set and matches a theme key, override the default theme."""
|
|
raw = (env_default_theme or "").strip()
|
|
if not raw:
|
|
return config
|
|
if config.theme_by_key(raw) is None:
|
|
logger.warning("WEBAPP_DEFAULT_THEME=%r ignored: no such theme in catalog", raw)
|
|
return config
|
|
data = config.model_dump(mode="json", exclude_none=True)
|
|
data["default_theme"] = raw
|
|
return _config_with_synced_default_flags(WebappThemesConfig.model_validate(data))
|
|
|
|
|
|
def resolved_webapp_themes_catalog(
|
|
*,
|
|
theme_dir: str | Path,
|
|
primary_accent: str,
|
|
env_default_theme: Optional[str],
|
|
) -> WebappThemesConfig:
|
|
"""Load themes from WEBAPP_THEMES_DIR, seeding defaults when possible."""
|
|
ensure_default_webapp_theme_descriptor_files(theme_dir)
|
|
|
|
themes = load_webapp_theme_dir(theme_dir)
|
|
config = _themes_config_from_list(None, themes)
|
|
config, changed = ensure_webapp_core_themes(config, primary_accent)
|
|
if changed:
|
|
try:
|
|
write_webapp_theme_dir(theme_dir, config, delete_missing=False)
|
|
except OSError as exc:
|
|
logger.warning("Could not update webapp theme descriptors in %s: %s", theme_dir, exc)
|
|
return apply_webapp_theme_env_overrides(config, env_default_theme)
|
|
|
|
|
|
def merge_primary_accent_into_theme_tokens(
|
|
theme: WebappTheme, primary_accent: str, *, only_if_token_missing: bool = True
|
|
) -> ThemeTokens:
|
|
"""Fill accent from WEBAPP_PRIMARY_COLOR when theme tokens omit accent."""
|
|
base = theme.tokens.model_copy(deep=True)
|
|
accent = (primary_accent or "").strip()
|
|
if not accent:
|
|
return base
|
|
if only_if_token_missing and base.accent:
|
|
return base
|
|
base.accent = accent
|
|
return base
|
|
|
|
|
|
def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]:
|
|
tokens = (
|
|
merge_primary_accent_into_theme_tokens(theme, primary_accent)
|
|
if theme.use_primary_accent
|
|
else theme.tokens
|
|
)
|
|
payload: Dict[str, object] = {
|
|
"key": theme.key,
|
|
"names": dict(theme.names),
|
|
"enabled": bool(theme.enabled),
|
|
"use_primary_accent": bool(theme.use_primary_accent),
|
|
"use_in_admin": bool(theme.use_in_admin),
|
|
"tokens": tokens.model_dump(mode="json", exclude_none=True),
|
|
}
|
|
if theme.css_file:
|
|
payload["css_file"] = theme.css_file
|
|
return payload
|
|
|
|
|
|
def public_themes_catalog_payload(
|
|
config: WebappThemesConfig, primary_accent: str, *, enabled_only: bool = False
|
|
) -> Dict[str, object]:
|
|
themes = [theme for theme in config.themes if not enabled_only or theme.enabled]
|
|
return {
|
|
"default_theme": config.default_theme,
|
|
"themes": [public_theme_payload(theme, primary_accent) for theme in themes],
|
|
}
|