feat: custom themes initial
This commit is contained in:
@@ -7,6 +7,10 @@ from pydantic import BaseModel, Field, ValidationError, computed_field, field_va
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from config.tariffs_config import TariffsConfig, load_tariffs_config
|
||||
from config.webapp_themes_config import (
|
||||
WebappThemesConfig,
|
||||
resolved_webapp_themes_catalog,
|
||||
)
|
||||
|
||||
|
||||
def _split_csv(value: Optional[str]) -> List[str]:
|
||||
@@ -351,6 +355,19 @@ class Settings(BaseSettings):
|
||||
WEBAPP_SERVER_PORT: int = Field(default=8081)
|
||||
WEBAPP_TITLE: str = Field(default="Моя подписка")
|
||||
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
|
||||
WEBAPP_THEMES_DIR: str = Field(
|
||||
default="data/themes",
|
||||
description=(
|
||||
"Directory with per-theme folders. Each theme lives in "
|
||||
"<key>/theme.json with optional CSS/assets next to it."
|
||||
),
|
||||
)
|
||||
WEBAPP_DEFAULT_THEME: Optional[str] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Override the descriptor-marked default theme when set to an existing theme key."
|
||||
),
|
||||
)
|
||||
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
|
||||
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
|
||||
WEBAPP_LOGO_EMOJI_FONT: str = Field(
|
||||
@@ -810,6 +827,15 @@ class Settings(BaseSettings):
|
||||
def tariffs_config(self) -> Optional[TariffsConfig]:
|
||||
return load_tariffs_config(self.TARIFFS_CONFIG_PATH)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def webapp_themes_catalog(self) -> WebappThemesConfig:
|
||||
return resolved_webapp_themes_catalog(
|
||||
primary_accent=self.WEBAPP_PRIMARY_COLOR or "#00fe7a",
|
||||
env_default_theme=self.WEBAPP_DEFAULT_THEME,
|
||||
theme_dir=self.WEBAPP_THEMES_DIR,
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def referral_bonus_inviter(self) -> Dict[int, int]:
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
"""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],
|
||||
}
|
||||
Reference in New Issue
Block a user