diff --git a/.env.example b/.env.example index 7882f23..16c6036 100644 --- a/.env.example +++ b/.env.example @@ -38,9 +38,8 @@ WEBAPP_ENABLED=True # WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port WEBAPP_TITLE="/minishop" # Mini App title -WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color -WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used -WEBAPP_LOGO_EMOJI="πŸ«₯" # Emoji logo fallback shown in the header and login screen +WEBAPP_THEMES_DIR=data/themes # Folder with theme subfolders: /theme.json and optional CSS/assets +WEBAPP_DEFAULT_THEME= # Optional: override descriptor default theme key (e.g. light) WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h) diff --git a/README.md b/README.md index d619f58..afe9b61 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Remnawave Minishop +![Remnawave Minishop](docs/remnawave-minishop.webp) + Remnawave Minishop - Telegram-Π±ΠΎΡ‚ ΠΈ Web App (Mini App) для ΠΏΡ€ΠΎΠ΄Π°ΠΆΠΈ ΠΈ управлСния подписками Remnawave. Π‘ΠΎΡ‚ ΠΎΠ±Ρ€Π°Π±Π°Ρ‚Ρ‹Π²Π°Π΅Ρ‚ Ρ€Π΅Π³ΠΈΡΡ‚Ρ€Π°Ρ†ΠΈΡŽ, ΠΎΠΏΠ»Π°Ρ‚Ρƒ, ΠΏΡ€ΠΎΠ΄Π»Π΅Π½ΠΈΠ΅, ΠΏΡ€ΠΎΠ±Π½Ρ‹ΠΉ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄, ΠΏΡ€ΠΎΠΌΠΎΠΊΠΎΠ΄Ρ‹, Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΠΎΠ² ΠΈ ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΡƒ Π² Ρ‡Π°Ρ‚Π΅. Web App ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅Ρ‚ ссылку ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ, срок дСйствия, Ρ‚Ρ€Π°Ρ„ΠΈΠΊ, ΠΎΠΏΠ»Π°Ρ‚Ρƒ, устройства ΠΈ Π²Ρ…ΠΎΠ΄ ΠΏΠΎ Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect ΠΈ ΠΎΠ΄Π½ΠΎΡ€Π°Π·ΠΎΠ²ΠΎΠΌΡƒ email-ΠΊΠΎΠ΄Ρƒ. ΠŸΡ€ΠΎΠ΅ΠΊΡ‚ являСтся ΠΏΠ΅Ρ€Π΅Ρ€Π°Π±ΠΎΡ‚Π°Π½Π½Ρ‹ΠΌ Ρ„ΠΎΡ€ΠΊΠΎΠΌ [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для пСрСноса Π΄Π°Π½Π½Ρ‹Ρ… ΠΈΠ· ΠΏΡ€Π΅ΠΆΠ½Π΅Π³ΠΎ стСка ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉΡ‚Π΅ [ΠΈΠ½ΡΡ‚Ρ€ΡƒΠΊΡ†ΠΈΡŽ ΠΏΠΎ ΠΌΠΈΠ³Ρ€Π°Ρ†ΠΈΠΈ](docs/migration-to-minishop.md). @@ -31,6 +33,7 @@ Remnawave Minishop - Telegram-Π±ΠΎΡ‚ ΠΈ Web App (Mini App) для ΠΏΡ€ΠΎΠ΄Π°ΠΆΠΈ - [Π’Π°Ρ€ΠΈΡ„Ρ‹](docs/tariffs.md) - ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³ Ρ‚Π°Ρ€ΠΈΡ„ΠΎΠ², period- ΠΈ traffic-ΠΌΠΎΠ΄Π΅Π»ΠΈ, ΠΎΠ±Ρ‹Ρ‡Π½Ρ‹Π΅ ΠΈ premium-Π΄ΠΎΠΊΡƒΠΏΠΊΠΈ, premium-сквады, смСна Ρ‚Π°Ρ€ΠΈΡ„Π°, HWID-Π»ΠΈΠΌΠΈΡ‚Ρ‹ ΠΈ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° Ρ‚Ρ€Π°Ρ„ΠΈΠΊΠ°. - [Админ-панСль](docs/admin.md) - ΠΏΡ€Π°Π²Π° доступа, настройки, Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€ Ρ‚Π°Ρ€ΠΈΡ„ΠΎΠ², premium-сквады ΠΈ сохранСниС JSON-ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³Π°. - [Web App / Mini App](docs/webapp.md) - ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½Ρ‹ΠΉ ΠΏΠΎΡ€Ρ‚, Π΄ΠΎΠΌΠ΅Π½, Telegram OAuth, email-Π²Ρ…ΠΎΠ΄ ΠΈ Ρ€Π΅Ρ„Π΅Ρ€Π°Π»ΡŒΠ½Ρ‹Π΅ ссылки. +- [Π’Π΅ΠΌΡ‹ Web App](docs/webapp-themes.md) - кастомныС Ρ‚Π΅ΠΌΡ‹, настройка внСшнСго Π²ΠΈΠ΄Π°, Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΡ‹, CSS/ассСты ΠΈ ΠΏΠ°ΠΉΠΏΠ»Π°ΠΉΠ½ создания Π½ΠΎΠ²ΠΎΠΉ Ρ‚Π΅ΠΌΡ‹. - [Π Π°Π·Π²Π΅Ρ€Ρ‚Ρ‹Π²Π°Π½ΠΈΠ΅](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, Π²Π΅Π±Ρ…ΡƒΠΊΠΈ, запуск ΠΈΠ· ΠΎΠ±Ρ€Π°Π·Π° ΠΈ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ вСрсии (`IMAGE_TAG`). - [ΠœΠΈΠ³Ρ€Π°Ρ†ΠΈΡ с remnawave-tg-shop](docs/migration-to-minishop.md) - пСрСнос Π΄Π°Π½Π½Ρ‹Ρ… ΠΈΠ· ΠΏΡ€Π΅ΠΆΠ½Π΅Π³ΠΎ стСка. @@ -80,10 +83,10 @@ docker compose logs -f remnawave-minishop Для ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³Π° Ρ‚Π°Ρ€ΠΈΡ„ΠΎΠ² ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ `TARIFFS_CONFIG_PATH` со Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ΠΌ ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ `data/tariffs.json`. ΠŸΡ€ΠΈΠΌΠ΅Ρ€ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚Π° Π»Π΅ΠΆΠΈΡ‚ Π² [data/tariffs.example.json](data/tariffs.example.json), подробности - Π² [docs/tariffs.md](docs/tariffs.md). -Если Π² Docker Compose Π²ΠΊΠ»ΡŽΡ‡Π°Π΅Ρ‚Π΅ bind mount `./data:/app/data`, Π·Π°Ρ€Π°Π½Π΅Π΅ создайтС ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³ ΠΈ ΠΎΡ‚Π΄Π°ΠΉΡ‚Π΅ Π΅Π³ΠΎ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŽ ΠΊΠΎΠ½Ρ‚Π΅ΠΉΠ½Π΅Ρ€Π°. Π­Ρ‚ΠΎ Π½ΡƒΠΆΠ½ΠΎ для сохранСния `data/tariffs.json`, кСша Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ° Web App ΠΈ animated emoji: +Если Π² Docker Compose Π²ΠΊΠ»ΡŽΡ‡Π°Π΅Ρ‚Π΅ bind mount `./data:/app/data`, Π·Π°Ρ€Π°Π½Π΅Π΅ создайтС ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³ ΠΈ ΠΎΡ‚Π΄Π°ΠΉΡ‚Π΅ Π΅Π³ΠΎ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŽ ΠΊΠΎΠ½Ρ‚Π΅ΠΉΠ½Π΅Ρ€Π°. Π­Ρ‚ΠΎ Π½ΡƒΠΆΠ½ΠΎ для сохранСния `data/tariffs.json`, ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³Π° Ρ‚Π΅ΠΌ `data/themes`, кСша Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ° Web App ΠΈ animated emoji: ```bash -mkdir -p data/webapp-logo data/webapp-emoji +mkdir -p data/themes data/webapp-logo data/webapp-emoji chown -R 10001:10001 data chmod -R u+rwX data ``` diff --git a/bot/app/web/admin_api.py b/bot/app/web/admin_api.py index 8dcb349..385a0e3 100644 --- a/bot/app/web/admin_api.py +++ b/bot/app/web/admin_api.py @@ -17,6 +17,7 @@ from bot.app.web.admin_api_impl import ( stats as _stats, sync as _sync, tariffs as _tariffs, + themes as _themes, users as _users, ) @@ -34,6 +35,7 @@ _MODULES = ( _ads, _settings, _tariffs, + _themes, _panel, _routes, ) diff --git a/bot/app/web/admin_api_impl/common.py b/bot/app/web/admin_api_impl/common.py index 7a337c6..2ef0e05 100644 --- a/bot/app/web/admin_api_impl/common.py +++ b/bot/app/web/admin_api_impl/common.py @@ -270,6 +270,10 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None: path.write_text(payload, encoding="utf-8") +def _webapp_themes_catalog_payload(config: Any) -> Dict[str, Any]: + return config.model_dump(mode="json", exclude_none=True) + + def _panel_node_uuid_key(node: Dict[str, Any]) -> str: uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id") return str(uid).strip().lower() if uid else "" diff --git a/bot/app/web/admin_api_impl/routes.py b/bot/app/web/admin_api_impl/routes.py index ad2c89d..3a46e7b 100644 --- a/bot/app/web/admin_api_impl/routes.py +++ b/bot/app/web/admin_api_impl/routes.py @@ -54,4 +54,8 @@ def setup_admin_routes(app: web.Application) -> None: router.add_get("/api/admin/tariffs", admin_tariffs_get_route) router.add_put("/api/admin/tariffs", admin_tariffs_save_route) + router.add_get("/api/admin/themes", admin_themes_get_route) + router.add_put("/api/admin/themes", admin_themes_save_route) + router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route) + router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route) router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route) diff --git a/bot/app/web/admin_api_impl/settings.py b/bot/app/web/admin_api_impl/settings.py index 787778f..e201123 100644 --- a/bot/app/web/admin_api_impl/settings.py +++ b/bot/app/web/admin_api_impl/settings.py @@ -70,5 +70,18 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response: if isinstance(cache, dict): cache["ts"] = 0.0 cache["data"] = {} + if ( + "WEBAPP_LOGO_URL" in updates + or "WEBAPP_LOGO_URL" in deletes + or "WEBAPP_LOGO_USE_EMOJI" in updates + or "WEBAPP_LOGO_USE_EMOJI" in deletes + or "WEBAPP_FAVICON_URL" in updates + or "WEBAPP_FAVICON_URL" in deletes + or "WEBAPP_FAVICON_USE_CUSTOM" in updates + or "WEBAPP_FAVICON_USE_CUSTOM" in deletes + or "WEBAPP_LOGO_FAVICON_URL" in updates + or "WEBAPP_LOGO_FAVICON_URL" in deletes + ): + request.app["webapp_logo_cache"] = None return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)}) diff --git a/bot/app/web/admin_api_impl/themes.py b/bot/app/web/admin_api_impl/themes.py new file mode 100644 index 0000000..22b89f3 --- /dev/null +++ b/bot/app/web/admin_api_impl/themes.py @@ -0,0 +1,322 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + +import asyncio +import hashlib +import ipaddress +import re +import socket + +from aiohttp import ClientSession, ClientTimeout +from PIL import Image, ImageOps, UnidentifiedImageError + +from config.webapp_themes_config import ( + WebappThemesConfig, + ensure_webapp_core_themes, + resolved_webapp_themes_catalog, + write_webapp_theme_dir, +) + + +WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 +WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" / "uploads" +WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" +WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" / "favicons" +WEBAPP_FAVICON_PATH = "/webapp-favicon" +WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512) +WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = { + ".gif": "image/gif", + ".ico": "image/x-icon", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +} + + +def _detect_logo_extension( + body: bytes, content_type: str = "", filename: str = "" +) -> Optional[str]: + content_type = (content_type or "").split(";", 1)[0].strip().lower() + suffix = Path(filename or "").suffix.lower() + if content_type == "image/png" or body.startswith(b"\x89PNG\r\n\x1a\n"): + return ".png" + if content_type == "image/jpeg" or body.startswith(b"\xff\xd8\xff"): + return ".jpg" + if content_type == "image/gif" or body.startswith((b"GIF87a", b"GIF89a")): + return ".gif" + if content_type == "image/webp" or ( + len(body) > 12 and body[:4] == b"RIFF" and body[8:12] == b"WEBP" + ): + return ".webp" + if content_type in {"image/svg+xml", "image/svg"} or suffix == ".svg": + head = body[:512].lstrip().lower() + if head.startswith(b" str: + if not body or len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("logo must be a non-empty image up to 2 MiB") + ext = _detect_logo_extension(body, content_type, filename) + if ext not in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES: + raise ValueError("unsupported image type") + digest = hashlib.sha256(body).hexdigest()[:16] + safe_name = f"logo-{digest}{ext}" + WEBAPP_UPLOADED_LOGO_DIR.mkdir(parents=True, exist_ok=True) + (WEBAPP_UPLOADED_LOGO_DIR / safe_name).write_bytes(body) + return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}" + + +def _image_to_square_icon(source: Image.Image, size: int) -> Image.Image: + fitted = source.copy() + fitted.thumbnail((size, size), Image.Resampling.LANCZOS) + canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + left = (size - fitted.width) // 2 + top = (size - fitted.height) // 2 + canvas.alpha_composite(fitted, (left, top)) + return canvas + + +def _write_favicon_set(body: bytes, content_type: str = "", filename: str = "") -> Dict[str, Any]: + if not body or len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("favicon source must be a non-empty image up to 2 MiB") + + ext = _detect_logo_extension(body, content_type, filename) + digest = hashlib.sha256(body).hexdigest()[:16] + target_dir = WEBAPP_FAVICON_DIR / digest + target_dir.mkdir(parents=True, exist_ok=True) + + if ext == ".svg": + safe_name = "favicon.svg" + (target_dir / safe_name).write_bytes(body) + return { + "favicon_url": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}", + "variants": {"svg": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}"}, + } + + try: + with Image.open(io.BytesIO(body)) as image: + image.seek(0) + source = ImageOps.exif_transpose(image).convert("RGBA") + except (OSError, UnidentifiedImageError, ValueError) as exc: + raise ValueError("favicon source must be a raster image") from exc + + if source.width < 1 or source.height < 1 or source.width > 8192 or source.height > 8192: + raise ValueError("favicon source dimensions are not supported") + + variants: Dict[str, str] = {} + png_icons: Dict[int, Image.Image] = {} + for size in WEBAPP_FAVICON_SIZES: + icon = _image_to_square_icon(source, size) + png_icons[size] = icon + filename = f"icon-{size}.png" + icon.save(target_dir / filename, format="PNG", optimize=True) + variants[f"{size}"] = f"{WEBAPP_FAVICON_PATH}/{digest}/{filename}" + + png_icons[180].save(target_dir / "apple-touch-icon.png", format="PNG", optimize=True) + variants["apple_touch"] = f"{WEBAPP_FAVICON_PATH}/{digest}/apple-touch-icon.png" + png_icons[32].save( + target_dir / "favicon.ico", + format="ICO", + sizes=[(16, 16), (32, 32), (48, 48)], + ) + variants["ico"] = f"{WEBAPP_FAVICON_PATH}/{digest}/favicon.ico" + return { + "favicon_url": variants["180"], + "variants": variants, + } + + +async def _read_uploaded_logo_file(request: web.Request) -> tuple[bytes, str, str]: + reader = await request.multipart() + async for part in reader: + if part.name != "file": + continue + body = bytearray() + while True: + chunk = await part.read_chunk(size=64 * 1024) + if not chunk: + break + body.extend(chunk) + if len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("logo must be up to 2 MiB") + return bytes(body), part.headers.get("Content-Type", ""), part.filename or "" + raise ValueError("file field is required") + + +async def _hostname_resolves_to_public_address(hostname: str) -> bool: + if not hostname: + return False + try: + ip_obj = ipaddress.ip_address(hostname) + return not ( + ip_obj.is_private + or ip_obj.is_loopback + or ip_obj.is_link_local + or ip_obj.is_unspecified + or ip_obj.is_reserved + ) + except ValueError: + pass + + loop = asyncio.get_running_loop() + try: + resolved = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) + except Exception: + return False + + found_public_ip = False + for entry in resolved: + sockaddr = entry[4] + candidate = sockaddr[0] if sockaddr else "" + try: + ip_obj = ipaddress.ip_address(candidate) + except ValueError: + continue + if ( + ip_obj.is_private + or ip_obj.is_loopback + or ip_obj.is_link_local + or ip_obj.is_unspecified + or ip_obj.is_reserved + ): + return False + found_public_ip = True + return found_public_ip + + +async def _fetch_logo_from_url(url: str) -> tuple[bytes, str, str]: + parsed = urlsplit(url) + if parsed.scheme != "https" or not parsed.hostname: + raise ValueError("only https image URLs are supported") + if not await _hostname_resolves_to_public_address(parsed.hostname): + raise ValueError("logo URL must resolve to a public address") + + timeout = ClientTimeout(total=5) + async with ClientSession(timeout=timeout, headers={"User-Agent": "Mozilla/5.0"}) as session: + async with session.get( + url, + allow_redirects=False, + headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"}, + ) as response: + if response.status != 200: + raise ValueError(f"logo URL returned HTTP {response.status}") + content_type = ( + (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() + ) + if content_type and not content_type.startswith("image/"): + raise ValueError("logo URL returned non-image content") + body = bytearray() + async for chunk in response.content.iter_chunked(64 * 1024): + body.extend(chunk) + if len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("logo must be up to 2 MiB") + return bytes(body), content_type, Path(parsed.path).name + + +async def admin_appearance_logo_upload_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + content_type = (request.headers.get("Content-Type") or "").lower() + try: + if content_type.startswith("multipart/form-data"): + body, detected_content_type, filename = await _read_uploaded_logo_file(request) + else: + payload = await _read_json(request) + source_url = str(payload.get("url") or "").strip() + if not source_url: + return _error(400, "invalid_payload", "url or file is required") + body, detected_content_type, filename = await _fetch_logo_from_url(source_url) + logo_url = _write_uploaded_logo(body, detected_content_type, filename) + try: + favicon_payload = _write_favicon_set(body, detected_content_type, filename) + except ValueError: + favicon_payload = {} + except ValueError as exc: + return _error(400, "invalid_logo", str(exc)) + except OSError as exc: + logger.exception("Failed to save uploaded webapp logo") + return _error(500, "write_failed", str(exc)) + + return _ok({"logo_url": logo_url, **favicon_payload}) + + +async def admin_appearance_favicon_upload_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + content_type = (request.headers.get("Content-Type") or "").lower() + try: + if content_type.startswith("multipart/form-data"): + body, detected_content_type, filename = await _read_uploaded_logo_file(request) + else: + payload = await _read_json(request) + source_url = str(payload.get("url") or "").strip() + if not source_url: + return _error(400, "invalid_payload", "url or file is required") + body, detected_content_type, filename = await _fetch_logo_from_url(source_url) + favicon_payload = _write_favicon_set(body, detected_content_type, filename) + except ValueError as exc: + return _error(400, "invalid_favicon", str(exc)) + except OSError as exc: + logger.exception("Failed to save uploaded webapp favicon") + return _error(500, "write_failed", str(exc)) + + return _ok(favicon_payload) + + +async def admin_themes_get_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + primary = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a" + catalog = resolved_webapp_themes_catalog( + primary_accent=primary, + env_default_theme=settings.WEBAPP_DEFAULT_THEME, + theme_dir=settings.WEBAPP_THEMES_DIR, + ) + + return _ok( + { + "exists": Path(settings.WEBAPP_THEMES_DIR).expanduser().exists(), + "themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()), + "catalog": _webapp_themes_catalog_payload(catalog), + } + ) + + +async def admin_themes_save_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + payload = await _read_json(request) + catalog = payload.get("catalog") if "catalog" in payload else payload + if not isinstance(catalog, dict): + return _error(400, "invalid_payload", "catalog must be an object") + + try: + config = WebappThemesConfig.model_validate(catalog) + except (ValidationError, ValueError) as exc: + return _error(400, "invalid_webapp_themes_config", str(exc)) + + config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a") + + try: + write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True) + except OSError as exc: + logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR) + return _error(500, "write_failed", str(exc)) + + cache = request.app.get("webapp_settings_cache") + if isinstance(cache, dict): + cache["ts"] = 0.0 + cache["data"] = {} + + return _ok( + { + "exists": True, + "themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()), + "catalog": _webapp_themes_catalog_payload(config), + } + ) diff --git a/bot/app/web/admin_settings_manifest.py b/bot/app/web/admin_settings_manifest.py index 1b4b578..457bc95 100644 --- a/bot/app/web/admin_settings_manifest.py +++ b/bot/app/web/admin_settings_manifest.py @@ -79,6 +79,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ SettingField( "WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной Ρ†Π²Π΅Ρ‚", placeholder="#00fe7a" ), + SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ эмодТи-Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ"), SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ°"), SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Π­ΠΌΠΎΠ΄ΠΆΠΈ-Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ", placeholder="πŸ«₯"), SettingField( @@ -99,6 +100,9 @@ SETTINGS_MANIFEST: List[SettingField] = [ ("noto-local", "Noto Emoji (local)"), ), ), + SettingField("WEBAPP_FAVICON_USE_CUSTOM", "bool", "appearance", "Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½ΡƒΡŽ favicon"), + SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½ΠΎΠΉ favicon"), + SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon ΠΈΠ· Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ°"), SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App Π²ΠΊΠ»ΡŽΡ‡Ρ‘Π½"), # ─── Subscription periods & pricing ──────────────────────────── SettingField("MONTH_1_ENABLED", "bool", "pricing", "Π’Π°Ρ€ΠΈΡ„ 1 мСсяц"), diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index 66b2c3a..022223c 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -40,6 +40,13 @@ import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js"; import { activeTariffName, buildTariffCatalog } from "./lib/webapp/tariffs.js"; import { premiumTrafficPercent, trafficPercent } from "./lib/webapp/traffic.js"; + import { + findThemeEntry, + resolveEffectiveThemeKey, + themeCssHref, + themeEntryToInlineStyle, + themeRootClass, + } from "./lib/webapp/themeStyle.js"; /** Used-traffic percent from which top-up modals and CTAs unlock in the web app home screen */ const TRAFFIC_TOPUP_UNLOCK_PERCENT = 80; @@ -80,6 +87,7 @@ ...(MOCK ? MOCK.config : {}), ...(injectedConfig || {}), }; + const themePreviewKey = String(CFG.themePreviewKey || query.get("theme_preview") || "").trim(); const I18N = injectedI18n || {}; let telegramSdkStatus = "idle"; let telegramMiniAppInitData = ""; @@ -239,11 +247,14 @@ $: brandEmojiFont = CFG.logoEmojiFont || "system"; $: brand = normalizeBrand({ title: brandTitle, - logoUrl: CFG.logoUrl, + logoUrl: CFG.logoUseEmoji ? "" : CFG.logoUrl, emoji: brandEmoji, emojiFont: brandEmojiFont, }); - $: accent = CFG.primaryColor || "#00fe7a"; + $: faviconBrand = normalizeBrand({ + ...brand, + logoUrl: String(CFG.faviconUrl || "").trim() || brand.logoUrl, + }); $: plans = data?.plans?.length ? data.plans : DEV_MOCK.data.plans; $: methods = data?.payment_methods?.length ? data.payment_methods : []; $: appSettings = data?.settings || DEV_MOCK.data.settings; @@ -300,6 +311,30 @@ premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT) ); $: user = data?.user || {}; + $: themesCatalog = data?.themes_catalog || + CFG.themesCatalog || { default_theme: "dark", themes: [] }; + $: previewThemeAllowed = Boolean(themePreviewKey && (!data?.user || user?.is_admin)); + $: previewThemeEntry = previewThemeAllowed + ? findThemeEntry(themesCatalog, themePreviewKey) + : null; + $: resolvedThemeKey = previewThemeEntry?.key || resolveEffectiveThemeKey(themesCatalog); + $: activeThemeEntry = findThemeEntry(themesCatalog, resolvedThemeKey); + $: darkThemeEntry = findThemeEntry(themesCatalog, "dark"); + $: effectiveThemeEntry = + screen === "admin" && activeThemeEntry?.use_in_admin === false + ? darkThemeEntry || activeThemeEntry + : activeThemeEntry; + $: shellStyle = themeEntryToInlineStyle(effectiveThemeEntry, CFG.primaryColor); + $: shellToneClass = + effectiveThemeEntry?.tokens?.color_scheme === "light" ? "theme-light" : "theme-dark"; + $: shellThemeClass = themeRootClass(effectiveThemeEntry); + $: shellThemeCssHref = themeCssHref(effectiveThemeEntry); + $: if (typeof document !== "undefined" && effectiveThemeEntry?.tokens) { + const scheme = effectiveThemeEntry.tokens.color_scheme || "dark"; + document.documentElement.style.colorScheme = scheme; + const bg = effectiveThemeEntry.tokens.bg; + if (bg) document.body.style.backgroundColor = bg; + } $: isAdmin = Boolean(user?.is_admin); $: if (screen === "admin" && !isAdmin) { screen = "settings"; @@ -346,7 +381,7 @@ : telegramLoginUnavailable ? t("wa_auth_telegram_not_configured") : ""; - $: applyFavicon(brand); + $: applyFavicon(faviconBrand); $: syncBodyScrollLock( paymentModalOpen || changeModalOpen || @@ -844,13 +879,35 @@ ); } - async function handleAdminPersistedSaved() { + function adminPayloadHasLogoChange(options = {}) { + const keys = new Set([ + ...Object.keys(options.updates || {}), + ...(Array.isArray(options.deletes) ? options.deletes : []), + ]); + return [ + "WEBAPP_LOGO_URL", + "WEBAPP_LOGO_USE_EMOJI", + "WEBAPP_LOGO_EMOJI", + "WEBAPP_LOGO_EMOJI_FONT", + "WEBAPP_FAVICON_URL", + "WEBAPP_FAVICON_USE_CUSTOM", + "WEBAPP_LOGO_FAVICON_URL", + ].some((key) => keys.has(key)); + } + + async function handleAdminPersistedSaved(options = {}) { invalidateWebappTariffOptionCaches(billingStore); try { await loadData(); } catch { // Admin save already succeeded; a later full refresh will pick up new settings or catalog. } + const shouldReloadFrontend = + options?.reloadFrontend === true || + (!options?.deferFrontendReload && adminPayloadHasLogoChange(options)); + if (shouldReloadFrontend && typeof window !== "undefined") { + window.location.reload(); + } } function selectTariff(tariff) { @@ -873,6 +930,9 @@ {brandTitle} + {#if shellThemeCssHref} + + {/if} @@ -880,7 +940,7 @@ {#if isPreviewBoard} {:else} -
+
{#if mode === "loading"}
@@ -931,8 +991,11 @@ onSectionChange={handleAdminSectionChange} onSettingsSaved={handleAdminPersistedSaved} onTariffsSaved={handleAdminPersistedSaved} + onThemesSaved={handleAdminPersistedSaved} {brandTitle} {brand} + appFaviconUrl={CFG.faviconUrl} + appFaviconUseCustom={CFG.faviconUseCustom} appVersion={CFG.appVersion} appRepositoryUrl={CFG.appRepositoryUrl} {currentLang} diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte index 14fa264..dac7d27 100644 --- a/bot/app/web/frontend/src/admin/AdminPanel.svelte +++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte @@ -11,6 +11,7 @@ LayoutDashboard, Megaphone, Menu, + Paintbrush, Plus, RefreshCw, Save, @@ -34,6 +35,7 @@ import StatsSection from "./sections/StatsSection.svelte"; import TariffEditorModal from "./sections/TariffEditorModal.svelte"; import TariffsSection from "./sections/TariffsSection.svelte"; + import AppearanceSection from "./sections/AppearanceSection.svelte"; import UserDetailModal from "./sections/UserDetailModal.svelte"; import UsersSection from "./sections/UsersSection.svelte"; import { createAdsStore } from "../lib/admin/stores/adsStore.js"; @@ -44,6 +46,7 @@ import { createSettingsStore } from "../lib/admin/stores/settingsStore.js"; import { createStatsStore } from "../lib/admin/stores/statsStore.js"; import { createTariffsStore } from "../lib/admin/stores/tariffsStore.js"; + import { createThemesStore } from "../lib/admin/stores/themesStore.js"; import { createUsersStore } from "../lib/admin/stores/usersStore.js"; import { fmtDate, @@ -70,8 +73,11 @@ export let onSectionChange = () => {}; export let onSettingsSaved = () => {}; export let onTariffsSaved = () => {}; + export let onThemesSaved = () => {}; export let brand = {}; export let brandTitle = "/minishop"; + export let appFaviconUrl = ""; + export let appFaviconUseCustom = false; export let appVersion = "dev+local"; export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop"; export let currentLang = "ru"; @@ -111,6 +117,7 @@ label: at("nav_system", {}, "БистСма"), items: [ { id: "tariffs", label: at("nav_tariffs", {}, "Π’Π°Ρ€ΠΈΡ„Ρ‹"), icon: Coins }, + { id: "appearance", label: at("nav_appearance", {}, "Π’Π½Π΅ΡˆΠ½ΠΈΠΉ Π²ΠΈΠ΄"), icon: Paintbrush }, { id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders }, ], }, @@ -153,6 +160,10 @@ title: at("section_tariffs_title", {}, "Π’Π°Ρ€ΠΈΡ„Ρ‹"), subtitle: at("section_tariffs_subtitle", {}, "ΠšΠ°Ρ‚Π°Π»ΠΎΠ³ ΠΏΡ€ΠΎΠ΄Π°ΠΆ, ΠΏΠ΅Ρ€ΠΈΠΎΠ΄Ρ‹, ΠΏΠ°ΠΊΠ΅Ρ‚Ρ‹ ΠΈ Π»ΠΈΠΌΠΈΡ‚Ρ‹"), }, + appearance: { + title: at("section_appearance_title", {}, "Π’Π½Π΅ΡˆΠ½ΠΈΠΉ Π²ΠΈΠ΄"), + subtitle: at("section_appearance_subtitle", {}, "Π›ΠΎΠ³ΠΎΡ‚ΠΈΠΏ, Ρ‚Π΅ΠΌΡ‹ ΠΈ Π°ΠΊΡ†Π΅Π½Ρ‚Π½Ρ‹Π΅ Ρ†Π²Π΅Ρ‚Π° Mini App"), + }, settings: { title: at("section_settings_title", {}, "Настройки прилоТСния"), subtitle: at("section_settings_subtitle", {}, "ΠžΠ²Π΅Ρ€Ρ€Π°ΠΉΠ΄Ρ‹ Π½Π°Π΄ .env, ΠΏΡ€ΠΈΠΌΠ΅Π½ΡΡŽΡ‚ΡΡ ΠΌΠ³Π½ΠΎΠ²Π΅Π½Π½ΠΎ"), @@ -196,6 +207,7 @@ const settingsStore = createSettingsStore({ api, onToast: flash, at }); const statsStore = createStatsStore({ api, onToast: flash, at }); const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at }); + const themesStore = createThemesStore({ api, onThemesSaved, flash, at }); const usersStore = createUsersStore({ api, onToast: flash, at }); setContext("promosStore", promosStore); @@ -207,6 +219,7 @@ setContext("settingsStore", settingsStore); setContext("usersStore", usersStore); setContext("tariffsStore", tariffsStore); + setContext("themesStore", themesStore); $: usersStore.setActive(active); $: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length; @@ -635,6 +648,17 @@ {/if} + {#if active === "appearance"} + + {/if} + {#if active === "settings"} {/if} diff --git a/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte b/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte new file mode 100644 index 0000000..a6766c1 --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte @@ -0,0 +1,1013 @@ + + +{#if themesLoading || settingsLoading} + {at("loading", {}, "Загрузка…")} +{:else} +
+
+
+
+

{at("appearance_brand_title", {}, "Π›ΠΎΠ³ΠΎΡ‚ΠΈΠΏ")}

+ {at( + "appearance_brand_sub", + {}, + "Π€Π°ΠΉΠ», ссылка ΠΈΠ»ΠΈ ΠΏΠΎΠ΄Ρ‚Π²Π΅Ρ€ΠΆΠ΄Π΅Π½Π½Ρ‹ΠΉ emoji-Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ" + )} +
+
+ {#if dirtyCount} + + {at("settings_dirty_count", { count: dirtyCount }, `ИзмСнСний: ${dirtyCount}`)} + + {/if} + + + {settingsSaving || themesSaving + ? at("btn_saving", {}, "Π‘ΠΎΡ…Ρ€Π°Π½Π΅Π½ΠΈΠ΅...") + : at("btn_save", {}, "Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ")} + +
+
+
+
+ {#if !useEmojiLogo && previewLogoUrl && !logoPreviewFailed} + { + logoPreviewFailed = true; + }} + /> + {:else if useEmojiLogo} + + {:else} + + {/if} +
+ +
+
+ + logoFileInput?.click()} + disabled={themesSaving} + > + + {at("appearance_logo_upload_file", {}, "Π—Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ Ρ„Π°ΠΉΠ»")} + +
+ + + {at("appearance_logo_upload_url", {}, "По ссылкС")} + +
+
+ +
+ +
+ + setAppearanceValue("WEBAPP_LOGO_EMOJI", event.currentTarget.value)} + /> + setAppearanceValue("WEBAPP_LOGO_EMOJI_FONT", value)} + /> +
+
+
+
+ +
+
+ {#if previewFaviconUrl && !faviconPreviewFailed} + { + faviconPreviewFailed = true; + }} + /> + {:else if !useCustomFavicon && useEmojiLogo} + + {:else} + + {/if} +
+ +
+
+ + + faviconFileInput?.click()} + disabled={themesSaving} + > + + {at("appearance_favicon_upload_file", {}, "Π—Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ favicon")} + +
+ + + {at("appearance_favicon_upload_url", {}, "По ссылкС")} + +
+
+
+
+
+ +
+
+
+

{at("appearance_themes_title", {}, "Π’Π΅ΠΌΡ‹")}

+ {at( + "appearance_themes_sub", + {}, + "Π“Π»ΠΎΠ±Π°Π»ΡŒΠ½Π°Ρ Ρ‚Π΅ΠΌΠ°, accent color ΠΈ прСдпросмотр" + )} +
+
+ + + {at("btn_refresh", {}, "ΠžΠ±Π½ΠΎΠ²ΠΈΡ‚ΡŒ")} + + + + {at("btn_save", {}, "Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ")} + +
+
+
+ {#if !themesCatalog.themes.length} + + {at( + "themes_catalog_empty", + {}, + "ΠšΠ°Ρ‚Π°Π»ΠΎΠ³ пуст. Π”ΠΎΠ±Π°Π²ΡŒΡ‚Π΅ ΠΏΠ°ΠΏΠΊΡƒ Ρ‚Π΅ΠΌΡ‹ Π² data/themes ΠΈ ΠΎΠ±Π½ΠΎΠ²ΠΈΡ‚Π΅ список." + )} + + {:else} +
+ {#each themesCatalog.themes as theme (theme.key)} + {@const isCurrent = theme.key === activeKey} +
selectTheme(theme, event)} + onkeydown={(event) => handleThemeKeydown(event, theme)} + > + + + {themeTitle(theme)} + {#if isCurrent} + {at("status_current", {}, "ВСкущая")} + {/if} + + {theme.key} + + + + {themeDescription(theme)} + + + + +
+ previewTheme(event, theme)} + > + + {at("appearance_preview_theme", {}, "ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€")} + +
+ +
+ {/each} +
+ {/if} +
+
+
+{/if} + + diff --git a/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte b/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte index 893157b..a26d1a5 100644 --- a/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte @@ -17,28 +17,32 @@ const settingsStore = getContext("settingsStore"); $: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore); + $: visibleSettingsSections = settingsSections.filter((section) => section.id !== "appearance"); let settingsOpenSections = []; let settingsOpenSubsections = {}; let revealedSecrets = new Set(); $: settingsAllOpen = - settingsSections.length > 0 && settingsOpenSections.length === settingsSections.length; + visibleSettingsSections.length > 0 && + settingsOpenSections.length === visibleSettingsSections.length; onMount(() => { settingsStore.loadSettings().then(() => { if ($settingsStore.settingsSections.length) { - const ids = $settingsStore.settingsSections.map((s) => s.id); + const ids = $settingsStore.settingsSections + .filter((s) => s.id !== "appearance") + .map((s) => s.id); settingsOpenSections = isCompact ? ids.slice(0, 1) : ids.slice(); } }); }); function toggleAllSections() { - if (settingsOpenSections.length === settingsSections.length) { + if (settingsOpenSections.length === visibleSettingsSections.length) { settingsOpenSections = []; } else { - settingsOpenSections = settingsSections.map((s) => s.id); + settingsOpenSections = visibleSettingsSections.map((s) => s.id); } } @@ -229,7 +233,7 @@
{/snippet} -{#if settingsLoading || !settingsSections.length} +{#if settingsLoading || !visibleSettingsSections.length} {settingsLoading ? at("loading", {}, "Загрузка…") @@ -265,7 +269,7 @@
- {#each settingsSections as section} + {#each visibleSettingsSections as section} {@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length} {@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length} diff --git a/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js b/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js index 448d558..e74c3e1 100644 --- a/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js +++ b/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js @@ -38,13 +38,30 @@ export function createSettingsStore({ api, onToast, at }) { }); } + function setFieldValue(key, value) { + state.update((s) => { + const nextDirty = { ...s.settingsDirty }; + delete nextDirty[key]; + return { + ...s, + settingsDirty: nextDirty, + settingsSections: (s.settingsSections || []).map((section) => ({ + ...section, + fields: (section.fields || []).map((field) => + field.key === key ? { ...field, value, overridden: true } : field + ), + })), + }; + }); + } + async function saveSettings(onSettingsSaved) { let dirty = {}; state.update((s) => { dirty = s.settingsDirty; return s; }); - if (!Object.keys(dirty).length) return; + if (!Object.keys(dirty).length) return true; state.update((s) => ({ ...s, settingsSaving: true })); try { @@ -63,6 +80,7 @@ export function createSettingsStore({ api, onToast, at }) { state.update((s) => ({ ...s, settingsDirty: {} })); if (onSettingsSaved) await onSettingsSaved({ updates, deletes }); await loadSettings(); + return true; } else if (res?.errors) { const summary = Object.entries(res.errors) .map(([k, v]) => `${k}: ${v}`) @@ -71,6 +89,7 @@ export function createSettingsStore({ api, onToast, at }) { } else { onToast(res?.error || "Ошибка"); } + return false; } finally { state.update((s) => ({ ...s, settingsSaving: false })); } @@ -91,6 +110,7 @@ export function createSettingsStore({ api, onToast, at }) { loadSettings, markDirty, clearDirty, + setFieldValue, resetField, saveSettings, }; diff --git a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js b/bot/app/web/frontend/src/lib/admin/stores/themesStore.js new file mode 100644 index 0000000..9d07793 --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/stores/themesStore.js @@ -0,0 +1,280 @@ +function cloneCatalog(catalog) { + return JSON.parse(JSON.stringify(catalog || { default_theme: "dark", themes: [] })); +} + +import { writable } from "svelte/store"; + +export function createThemesStore({ api, onThemesSaved, flash, at }) { + const state = writable({ + themesCatalog: { default_theme: "dark", themes: [] }, + themesDir: "", + themesLoading: false, + themesSaving: false, + }); + + async function loadThemes() { + state.update((s) => ({ ...s, themesLoading: true })); + try { + const data = await api("/admin/themes"); + if (data?.ok) { + state.update((s) => ({ + ...s, + themesCatalog: cloneCatalog(data.catalog), + themesDir: data.themes_dir || "", + })); + } else { + flash(data?.message || data?.error || at("load_failed", {}, "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ Ρ‚Π΅ΠΌΡ‹")); + } + } finally { + state.update((s) => ({ ...s, themesLoading: false })); + } + } + + async function saveThemes(options = {}) { + const silent = Boolean(options.silent); + let catalog = null; + state.update((s) => { + catalog = cloneCatalog(s.themesCatalog); + return { ...s, themesSaving: true }; + }); + try { + const data = await api("/admin/themes", { + method: "PUT", + body: JSON.stringify({ catalog }), + }); + if (data?.ok) { + state.update((s) => ({ + ...s, + themesCatalog: cloneCatalog(data.catalog), + themesDir: data.themes_dir || s.themesDir, + })); + if (!silent) flash(at("themes_saved", {}, "Π’Π΅ΠΌΡ‹ сохранСны")); + if (typeof onThemesSaved === "function") onThemesSaved(); + } else { + flash(data?.message || data?.error || at("themes_save_failed", {}, "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΡΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ")); + } + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + + async function uploadLogoFile(file) { + if (!file) return null; + state.update((s) => ({ ...s, themesSaving: true })); + try { + const body = new FormData(); + body.append("file", file); + const data = await api("/admin/appearance/logo", { + method: "POST", + body, + }); + if (data?.ok) { + flash( + at( + "appearance_logo_uploaded_pending", + {}, + "Π›ΠΎΠ³ΠΎΡ‚ΠΈΠΏ Π·Π°Π³Ρ€ΡƒΠΆΠ΅Π½. Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚Π΅ измСнСния, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΡ€ΠΈΠΌΠ΅Π½ΠΈΡ‚ΡŒ Π΅Π³ΠΎ." + ) + ); + return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" }; + } + flash( + data?.message || + data?.error || + at("appearance_logo_upload_failed", {}, "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + + async function uploadLogoUrl(url) { + const sourceUrl = String(url || "").trim(); + if (!sourceUrl) return null; + state.update((s) => ({ ...s, themesSaving: true })); + try { + const data = await api("/admin/appearance/logo", { + method: "POST", + body: JSON.stringify({ url: sourceUrl }), + }); + if (data?.ok) { + flash( + at( + "appearance_logo_uploaded_pending", + {}, + "Π›ΠΎΠ³ΠΎΡ‚ΠΈΠΏ Π·Π°Π³Ρ€ΡƒΠΆΠ΅Π½. Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚Π΅ измСнСния, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΡ€ΠΈΠΌΠ΅Π½ΠΈΡ‚ΡŒ Π΅Π³ΠΎ." + ) + ); + return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" }; + } + flash( + data?.message || + data?.error || + at("appearance_logo_upload_failed", {}, "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + + async function uploadFaviconFile(file) { + if (!file) return null; + state.update((s) => ({ ...s, themesSaving: true })); + try { + const body = new FormData(); + body.append("file", file); + const data = await api("/admin/appearance/favicon", { + method: "POST", + body, + }); + if (data?.ok) { + flash( + at( + "appearance_favicon_uploaded_pending", + {}, + "Favicon Π·Π°Π³Ρ€ΡƒΠΆΠ΅Π½Π°. Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚Π΅ измСнСния, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΡ€ΠΈΠΌΠ΅Π½ΠΈΡ‚ΡŒ Π΅Π΅." + ) + ); + return { faviconUrl: data.favicon_url || "", variants: data.variants || {} }; + } + flash( + data?.message || + data?.error || + at("appearance_favicon_upload_failed", {}, "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ favicon") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + + async function uploadFaviconUrl(url) { + const sourceUrl = String(url || "").trim(); + if (!sourceUrl) return null; + state.update((s) => ({ ...s, themesSaving: true })); + try { + const data = await api("/admin/appearance/favicon", { + method: "POST", + body: JSON.stringify({ url: sourceUrl }), + }); + if (data?.ok) { + flash( + at( + "appearance_favicon_uploaded_pending", + {}, + "Favicon Π·Π°Π³Ρ€ΡƒΠΆΠ΅Π½Π°. Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚Π΅ измСнСния, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΡ€ΠΈΠΌΠ΅Π½ΠΈΡ‚ΡŒ Π΅Π΅." + ) + ); + return { faviconUrl: data.favicon_url || "", variants: data.variants || {} }; + } + flash( + data?.message || + data?.error || + at("appearance_favicon_upload_failed", {}, "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ favicon") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + + function setCurrentTheme(key) { + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + default_theme: key, + themes: (s.themesCatalog.themes || []).map((theme) => ({ + ...theme, + default: theme.key === key, + })), + }, + })); + } + + function togglePrimaryAccent(key, enabled) { + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + themes: (s.themesCatalog.themes || []).map((theme) => + theme.key === key ? { ...theme, use_primary_accent: Boolean(enabled) } : theme + ), + }, + })); + } + + function toggleAdminUse(key, enabled) { + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + themes: (s.themesCatalog.themes || []).map((theme) => + theme.key === key ? { ...theme, use_in_admin: Boolean(enabled) } : theme + ), + }, + })); + } + + function setThemeAccent(key, accent) { + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + themes: (s.themesCatalog.themes || []).map((theme) => + theme.key === key + ? { + ...theme, + tokens: { + ...(theme.tokens || {}), + accent: String(accent || "").trim() || null, + }, + } + : theme + ), + }, + })); + } + + function setThemeHomeLogoScale(key, scale) { + if (String(scale ?? "").trim() === "") scale = 100; + const numeric = Number(scale); + const nextScale = Number.isFinite(numeric) + ? Math.min(300, Math.max(50, Math.round(numeric))) + : 100; + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + themes: (s.themesCatalog.themes || []).map((theme) => + theme.key === key + ? { + ...theme, + tokens: { + ...(theme.tokens || {}), + home_logo_scale: nextScale === 100 ? null : nextScale, + }, + } + : theme + ), + }, + })); + } + + return { + subscribe: state.subscribe, + loadThemes, + saveThemes, + setCurrentTheme, + setThemeAccent, + setThemeHomeLogoScale, + togglePrimaryAccent, + toggleAdminUse, + uploadLogoFile, + uploadLogoUrl, + uploadFaviconFile, + uploadFaviconUrl, + }; +} diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte index 8bd3798..1f50900 100644 --- a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte +++ b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte @@ -24,7 +24,8 @@ function readCssColor(name, fallback) { if (typeof document === "undefined") return fallback; - const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + const scope = hostEl || document.documentElement; + const raw = getComputedStyle(scope).getPropertyValue(name).trim(); return raw || fallback; } @@ -65,7 +66,11 @@ const muted = readCssColor("--admin-muted", "#9aa7a2"); const border = readCssColor("--admin-border", "rgba(255,255,255,0.12)"); const accent = readCssColor("--accent", "#00fe7a"); - const lineStroke = readCssColor("--admin-text", "#e8f0ec"); + const lineStroke = readCssColor( + "--admin-chart-stroke", + readCssColor("--admin-text", "#e8f0ec"), + ); + const lineFill = readCssColor("--admin-chart-fill", "rgba(120, 140, 132, 0.14)"); return { width: w, @@ -94,7 +99,7 @@ stroke: lineStroke, width: 2, cap: "round", - fill: "rgba(120, 140, 132, 0.14)", + fill: lineFill, }, ], axes: [ diff --git a/bot/app/web/frontend/src/lib/components/ui/icons.js b/bot/app/web/frontend/src/lib/components/ui/icons.js index b1f704d..21f1142 100644 --- a/bot/app/web/frontend/src/lib/components/ui/icons.js +++ b/bot/app/web/frontend/src/lib/components/ui/icons.js @@ -35,6 +35,7 @@ export { Menu, MessageSquare, MousePointerClick, + Paintbrush, Plus, QrCode, Radio, diff --git a/bot/app/web/frontend/src/lib/webapp/BrandMark.svelte b/bot/app/web/frontend/src/lib/webapp/BrandMark.svelte index 1cdec74..cabedee 100644 --- a/bot/app/web/frontend/src/lib/webapp/BrandMark.svelte +++ b/bot/app/web/frontend/src/lib/webapp/BrandMark.svelte @@ -33,6 +33,7 @@ export let emojiFont = ""; export let size = "sm"; export let animate = false; + export let fallbackEmoji = true; let className = ""; export { className as class }; @@ -206,7 +207,7 @@ clearLogoLoadTimeout(); }} /> - {:else if useAnimatedEmoji} + {:else if fallbackEmoji && useAnimatedEmoji} - {:else} + {:else if fallbackEmoji} theme.key)); + if (themeKeys.has(mode)) { + DEV_MOCK.config.themesCatalog.default_theme = mode; + DEV_MOCK.data.themes_catalog.default_theme = mode; + for (const theme of DEV_MOCK.config.themesCatalog.themes || []) { + theme.default = theme.key === mode; + } + for (const theme of DEV_MOCK.data.themes_catalog.themes || []) { + theme.default = theme.key === mode; + } + return; + } + if (mode === "traffic") { DEV_MOCK.data.settings.traffic_mode = true; DEV_MOCK.data.settings.trial_available = false; diff --git a/bot/app/web/frontend/src/lib/webapp/publicApi.js b/bot/app/web/frontend/src/lib/webapp/publicApi.js index 69c0f57..01c5ee8 100644 --- a/bot/app/web/frontend/src/lib/webapp/publicApi.js +++ b/bot/app/web/frontend/src/lib/webapp/publicApi.js @@ -8,6 +8,8 @@ export function createApiClient({ mockApi = null, getMockContext = () => ({}), } = {}) { + const isFormDataBody = (body) => typeof FormData !== "undefined" && body instanceof FormData; + async function api(path, options = {}) { if (mockApi) return mockApi(path, options, getMockContext()); @@ -18,7 +20,9 @@ export function createApiClient({ if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) { headers["X-CSRF-Token"] = csrf; } - if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json"; + if (options.body && !headers["Content-Type"] && !isFormDataBody(options.body)) { + headers["Content-Type"] = "application/json"; + } const response = await fetch(`${apiBase}${path}`, { ...options, diff --git a/bot/app/web/frontend/src/lib/webapp/themeStyle.js b/bot/app/web/frontend/src/lib/webapp/themeStyle.js new file mode 100644 index 0000000..15fe42d --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/themeStyle.js @@ -0,0 +1,163 @@ +/** Maps JSON theme token keys to CSS custom properties used by the Mini App shell. */ + +const TOKEN_TO_CSS_VAR = { + accent: "--accent", + bg: "--bg", + panel: "--panel", + panel_2: "--panel-2", + panel_3: "--panel-3", + border: "--border", + border_strong: "--border-strong", + text: "--text", + muted: "--muted", + dim: "--dim", + danger: "--danger", + danger_text: "--danger-text", + danger_soft: "--danger-soft", + danger_border: "--danger-border", + success: "--success", + success_text: "--success-text", + success_soft: "--success-soft", + success_border: "--success-border", + warning: "--warning", + warning_text: "--warning-text", + warning_soft: "--warning-soft", + warning_border: "--warning-border", + info: "--info", + info_text: "--info-text", + info_soft: "--info-soft", + info_border: "--info-border", + blue: "--blue", + radius: "--radius", + font_sans: "--font-sans", + font_logo: "--font-logo", + font_mono: "--font-mono", + home_logo_scale: "--home-logo-scale", + admin_bg: "--admin-bg", + admin_surface: "--admin-surface", + admin_surface_2: "--admin-surface-2", + admin_elev: "--admin-elev", + admin_border: "--admin-border", + admin_border_strong: "--admin-border-strong", + admin_text: "--admin-text", + admin_muted: "--admin-muted", + admin_dim: "--admin-dim", +}; + +export function themeTokensToInlineStyle(tokens, primaryFallback = "#00fe7a", options = {}) { + const t = tokens && typeof tokens === "object" ? tokens : {}; + const parts = []; + const useFallbackAccent = options.fallbackAccent !== false; + const accent = t.accent || (useFallbackAccent ? primaryFallback || "#00fe7a" : ""); + if (accent) parts.push(`--accent:${accent}`); + for (const [key, cssVar] of Object.entries(TOKEN_TO_CSS_VAR)) { + if (key === "accent") continue; + const value = t[key]; + if (value === undefined || value === null || value === "") continue; + if (key === "home_logo_scale") { + const scale = Number(value); + if (!Number.isFinite(scale) || scale <= 0) continue; + parts.push(`${cssVar}:${scale / 100}`); + continue; + } + parts.push(`${cssVar}:${String(value)}`); + } + return parts.join(";"); +} + +export function findThemeEntry(themesCatalog, key) { + const themes = themesCatalog?.themes || []; + return themes.find((entry) => entry && entry.key === key) || null; +} + +export function resolveEffectiveThemeKey(themesCatalog) { + const themes = themesCatalog?.themes || []; + const byKey = (k) => themes.find((entry) => entry.key === k); + const def = themesCatalog?.default_theme || themes[0]?.key || "dark"; + return byKey(def) ? def : themes[0]?.key || "dark"; +} + +export function themePresetClass(tokens) { + const preset = String(tokens?.style_preset || "") + .trim() + .toLowerCase(); + if (!preset || preset === "none") return ""; + if (preset === "win95" || preset === "windows95") return "theme-preset-win95"; + return ""; +} + +export function themeKeyClass(key) { + const safe = String(key || "") + .trim() + .toLowerCase() + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return safe ? `theme-key-${safe}` : ""; +} + +export function themeCssClass(cssFile) { + const filename = String(cssFile || "") + .replace(/\\/g, "/") + .split("/") + .filter(Boolean) + .pop(); + const slug = String(filename || "") + .replace(/\.css$/i, "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug ? `theme-css-${slug}` : ""; +} + +export function themeRootClass(theme) { + return [ + themeKeyClass(theme?.key), + themeCssClass(theme?.css_file), + themePresetClass(theme?.tokens), + ] + .filter(Boolean) + .join(" "); +} + +export function themeEntryToInlineStyle(theme, primaryFallback = "#00fe7a") { + return themeTokensToInlineStyle(theme?.tokens, primaryFallback, { + fallbackAccent: !theme?.css_file, + }); +} + +function encodeThemeCssPath(path) { + return String(path || "") + .replace(/\\/g, "/") + .split("/") + .filter(Boolean) + .map(encodeURIComponent) + .join("/"); +} + +export function themeCssHref(theme) { + const cssFile = String(theme?.css_file || "").trim(); + if (!cssFile) return ""; + if (/^(?:https?:)?\/\//i.test(cssFile) || cssFile.startsWith("data:")) return ""; + if (cssFile.startsWith("/")) return cssFile; + const normalizedCssFile = cssFile.replace(/\\/g, "/").split("/").filter(Boolean).join("/"); + const key = String(theme?.key || "") + .trim() + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + const themedPath = + key && normalizedCssFile.split("/")[0] !== key + ? `${key}/${normalizedCssFile}` + : normalizedCssFile; + const encoded = encodeThemeCssPath(themedPath); + return encoded ? `/webapp-theme-css/${encoded}` : ""; +} + +export function localizedThemeName(theme, lang = "en") { + const names = theme?.names || {}; + const key = String(lang || "") + .trim() + .toLowerCase(); + const base = key.split("-")[0]; + return names[key] || names[base] || names.en || theme?.key || ""; +} diff --git a/bot/app/web/frontend/src/styles/admin-controls.css b/bot/app/web/frontend/src/styles/admin-controls.css index e69029f..3f22104 100644 --- a/bot/app/web/frontend/src/styles/admin-controls.css +++ b/bot/app/web/frontend/src/styles/admin-controls.css @@ -113,7 +113,7 @@ .admin-btn.admin-btn-primary { background: var(--accent); - color: #02110a; + color: var(--accent-contrast); border-color: color-mix(in srgb, var(--accent) 70%, #000); font-weight: 600; } @@ -129,31 +129,31 @@ } .admin-btn.admin-btn-ghost:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.04); + background: var(--surface-hover); color: var(--admin-text); } .admin-btn.admin-btn-danger { - background: color-mix(in srgb, #ff5757 80%, #000); + background: color-mix(in srgb, var(--danger) 80%, #000); color: #fff; - border-color: color-mix(in srgb, #ff5757 60%, #000); + border-color: color-mix(in srgb, var(--danger) 60%, #000); font-weight: 600; } .admin-btn.admin-btn-danger:hover:not(:disabled) { - background: #ff5757; - border-color: #ff5757; + background: var(--danger); + border-color: var(--danger); } .admin-btn.admin-btn-danger-soft { - color: #ffb4b4; + color: var(--danger-text); background: var(--admin-surface-2); - border-color: color-mix(in srgb, #ff6b6b 30%, transparent); + border-color: var(--danger-border); } .admin-btn.admin-btn-danger-soft:hover:not(:disabled) { - background: color-mix(in srgb, #ff6b6b 14%, var(--admin-surface)); - border-color: color-mix(in srgb, #ff6b6b 50%, transparent); + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 50%, transparent); } .admin-btn.admin-btn-icon { @@ -183,21 +183,21 @@ } .admin-badge.admin-badge-success { - border-color: color-mix(in srgb, var(--accent) 36%, transparent); - color: var(--accent); - background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface)); + border-color: var(--success-border); + color: var(--success-text); + background: var(--success-soft); } .admin-badge.admin-badge-danger { - border-color: color-mix(in srgb, #ff6b6b 32%, transparent); - color: #ffb4b4; - background: color-mix(in srgb, #ff6b6b 12%, var(--admin-surface)); + border-color: var(--danger-border); + color: var(--danger-text); + background: var(--danger-soft); } .admin-badge.admin-badge-warning { - border-color: color-mix(in srgb, #ffd166 32%, transparent); - color: #ffd166; - background: color-mix(in srgb, #ffd166 12%, var(--admin-surface)); + border-color: var(--warning-border); + color: var(--warning-text); + background: var(--warning-soft); } .admin-badge.admin-badge-muted { @@ -270,7 +270,7 @@ .admin-extend-control .admin-btn.admin-btn-primary { background: var(--accent); - color: #02110a; + color: var(--accent-contrast); border-color: color-mix(in srgb, var(--accent) 70%, #000); font-weight: 600; } diff --git a/bot/app/web/frontend/src/styles/admin.css b/bot/app/web/frontend/src/styles/admin.css index 7790901..df35dd5 100644 --- a/bot/app/web/frontend/src/styles/admin.css +++ b/bot/app/web/frontend/src/styles/admin.css @@ -7,17 +7,14 @@ --admin-sidebar-w: var(--desktop-rail-width); --admin-header-h: 60px; --admin-card-bg: - linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), - var(--admin-surface); - --admin-card-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), - inset 0 1px 0 rgba(255, 255, 255, 0.05); + linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface); + --admin-card-shadow: var(--shadow-soft), inset 0 1px 0 var(--inset-highlight); position: fixed; inset: 0; width: 100vw; height: 100dvh; - background: #02070b; + background: var(--admin-bg); color: var(--admin-text); overflow: hidden; overscroll-behavior: contain; @@ -38,9 +35,9 @@ gap: 2px; padding: 28px 14px; border-right: 1px solid var(--admin-border); - background: rgba(7, 12, 17, 0.55); + background: var(--rail-bg); backdrop-filter: blur(14px); - box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02); + box-shadow: inset -1px 0 0 var(--admin-border); overflow-y: auto; } @@ -74,7 +71,8 @@ color: var(--accent); font-size: 14px; font-weight: 800; - letter-spacing: -0.01em; + font-family: var(--font-logo); + letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; } @@ -115,7 +113,10 @@ text-align: left; width: 100%; cursor: pointer; - transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; + transition: + background 0.12s ease, + color 0.12s ease, + border-color 0.12s ease; } .admin-nav-item > svg { @@ -124,7 +125,7 @@ } .admin-nav-item:hover { - background: rgba(255, 255, 255, 0.04); + background: var(--surface-hover); color: var(--admin-text); } @@ -169,7 +170,7 @@ gap: 8px; border: 1px solid var(--admin-border); border-radius: 10px; - background: rgba(255, 255, 255, 0.035); + background: var(--surface-muted); color: var(--admin-text); padding: 8px 10px; text-align: left; @@ -251,7 +252,7 @@ gap: 16px; padding: 0 28px; border-bottom: 1px solid var(--admin-border); - background: color-mix(in srgb, #02070b 82%, transparent); + background: color-mix(in srgb, var(--admin-bg) 82%, transparent); backdrop-filter: blur(14px); } @@ -265,7 +266,7 @@ margin: 0; font-size: 16px; font-weight: 700; - letter-spacing: -0.01em; + letter-spacing: 0; } .admin-header-title small { @@ -427,7 +428,7 @@ .admin-stat-card .admin-stat-value { font-size: 26px; font-weight: 700; - letter-spacing: -0.02em; + letter-spacing: 0; color: var(--admin-text); } @@ -457,7 +458,7 @@ font-size: 14px; font-weight: 600; color: var(--admin-text); - letter-spacing: -0.01em; + letter-spacing: 0; } .admin-dashboard-section-head small { @@ -528,7 +529,7 @@ .admin-revenue-kpi-value { font-size: 18px; font-weight: 700; - letter-spacing: -0.02em; + letter-spacing: 0; color: var(--admin-text); line-height: 1.15; } @@ -545,11 +546,11 @@ } .admin-revenue-kpi-growth.is-up { - color: color-mix(in srgb, #3ecf8e 92%, var(--admin-text)); + color: var(--success-text); } .admin-revenue-kpi-growth.is-down { - color: color-mix(in srgb, #ff6b6b 88%, var(--admin-text)); + color: var(--danger-text); } .admin-revenue-chart { @@ -606,7 +607,7 @@ border: 1px solid var(--admin-border); background: var(--admin-surface); color: var(--admin-text); - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); + box-shadow: var(--shadow-popover); } .admin-revenue-range-popover__title { @@ -946,7 +947,7 @@ .admin-panel-dash-tile-value { font-size: 22px; font-weight: 700; - letter-spacing: -0.03em; + letter-spacing: 0; line-height: 1.1; color: color-mix(in srgb, var(--accent) 22%, var(--admin-text)); font-variant-numeric: tabular-nums; @@ -1248,7 +1249,7 @@ } .admin-table tbody tr:hover { - background: rgba(255, 255, 255, 0.035); + background: var(--surface-hover); } .admin-table td.admin-cell-mono { @@ -1531,7 +1532,9 @@ font-size: 13px; resize: vertical; outline: none; - transition: border-color 0.12s ease, box-shadow 0.12s ease; + transition: + border-color 0.12s ease, + box-shadow 0.12s ease; } .admin-textarea:focus, @@ -2085,11 +2088,11 @@ } .admin-traffic-card-premium { - border-color: color-mix(in srgb, var(--accent) 30%, var(--admin-border)); + border-color: var(--info-border); } .admin-traffic-card-warning { - border-color: color-mix(in srgb, #ffb454 46%, var(--admin-border)); + border-color: var(--warning-border); } .admin-traffic-head, @@ -2131,11 +2134,11 @@ } .admin-traffic-bar-premium span { - background: linear-gradient(90deg, #66e3ff, var(--accent)); + background: linear-gradient(90deg, var(--info), color-mix(in srgb, var(--info) 64%, #ffffff)); } .admin-traffic-card-warning .admin-traffic-bar span { - background: linear-gradient(90deg, #ffb454, #ff6b6b); + background: linear-gradient(90deg, var(--warning), var(--danger)); } .admin-traffic-meta { @@ -2357,7 +2360,7 @@ z-index: 80; transform: translateX(-100%); transition: transform 0.22s ease; - box-shadow: 0 32px 80px rgba(0, 0, 0, 0.6); + box-shadow: var(--shadow-strong); } .admin-screen-wrap.is-sidebar-open .admin-sidebar { @@ -2367,7 +2370,7 @@ .admin-sidebar-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.5); + background: var(--overlay-scrim); z-index: 70; border: 0; cursor: pointer; @@ -2501,7 +2504,9 @@ padding: 10px 12px; width: 100%; cursor: pointer; - transition: background 0.12s ease, border-color 0.12s ease; + transition: + background 0.12s ease, + border-color 0.12s ease; } .settings-row.settings-row-admin:hover { @@ -2560,7 +2565,10 @@ border-radius: 7px; cursor: pointer; white-space: nowrap; - transition: background 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; + transition: + background 0.12s ease, + color 0.12s ease, + box-shadow 0.12s ease; outline: none; } @@ -2571,7 +2579,7 @@ .admin-tabs-trigger[data-state="active"] { background: var(--admin-surface); color: var(--admin-text); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + box-shadow: var(--shadow-soft); } .admin-tabs-trigger:focus-visible { @@ -2641,13 +2649,15 @@ border: 1px solid var(--admin-border); background: var(--admin-elev); cursor: pointer; - transition: background 0.18s ease, border-color 0.18s ease; + transition: + background 0.18s ease, + border-color 0.18s ease; outline: none; } .admin-switch-root[data-state="checked"] { - background: color-mix(in srgb, var(--accent) 80%, var(--admin-elev)); - border-color: color-mix(in srgb, var(--accent) 50%, transparent); + background: color-mix(in srgb, var(--success) 82%, var(--admin-elev)); + border-color: color-mix(in srgb, var(--success) 52%, transparent); } .admin-switch-root:focus-visible { @@ -2693,7 +2703,9 @@ cursor: pointer; text-align: left; outline: none; - transition: border-color 0.12s ease, box-shadow 0.12s ease; + transition: + border-color 0.12s ease, + box-shadow 0.12s ease; } .admin-select-trigger:hover { @@ -2724,10 +2736,9 @@ border: 1px solid var(--admin-border); border-radius: 10px; background: - linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), - var(--admin-surface); + linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface); padding: 4px; - box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45); + box-shadow: var(--shadow-popover); outline: none; display: flex; flex-direction: column; @@ -3069,9 +3080,9 @@ } .admin-danger-zone { - border: 1px solid color-mix(in srgb, #ff6b6b 32%, transparent); + border: 1px solid var(--danger-border); border-radius: 12px; - background: color-mix(in srgb, #ff6b6b 6%, var(--admin-surface)); + background: var(--danger-soft); padding: 14px 16px; display: flex; flex-direction: column; @@ -3088,13 +3099,13 @@ .admin-danger-zone-head strong { font-size: 13px; font-weight: 700; - color: #ffb4b4; + color: var(--danger-text); text-transform: uppercase; letter-spacing: 0.04em; } .admin-danger-zone-head small { - color: color-mix(in srgb, #ffb4b4 70%, var(--admin-muted)); + color: color-mix(in srgb, var(--danger-text) 70%, var(--admin-muted)); font-size: 12px; line-height: 1.45; } @@ -3202,4 +3213,4 @@ grid-area: meta; white-space: normal; } -} \ No newline at end of file +} diff --git a/bot/app/web/frontend/src/styles/base.css b/bot/app/web/frontend/src/styles/base.css index 199d5cc..a0015cf 100644 --- a/bot/app/web/frontend/src/styles/base.css +++ b/bot/app/web/frontend/src/styles/base.css @@ -4,21 +4,14 @@ format("woff2"); font-display: swap; unicode-range: - U+1F1E6-1F1FF, - U+1F3F4, - U+E0062-E0063, - U+E0065, - U+E0067, - U+E006C, - U+E006E, - U+E0073-E0074, - U+E0077, - U+E007F; + U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, + U+E0077, U+E007F; } :root { --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; --font-mono: "JetBrains Mono", "Fira Code", monospace; + --font-logo: var(--font-mono); color-scheme: dark; --accent: #00fe7a; --bg: #03070b; @@ -31,8 +24,36 @@ --muted: #a9b4b0; --dim: #68736f; --danger: #ff6b6b; + --danger-text: #ffb4b4; + --danger-soft: color-mix(in srgb, var(--danger) 12%, var(--panel)); + --danger-border: color-mix(in srgb, var(--danger) 34%, var(--border)); + --success: #3ecf8e; + --success-text: #7ff0b9; + --success-soft: color-mix(in srgb, var(--success) 12%, var(--panel)); + --success-border: color-mix(in srgb, var(--success) 34%, var(--border)); + --warning: #ffd166; + --warning-text: #ffd166; + --warning-soft: color-mix(in srgb, var(--warning) 12%, var(--panel)); + --warning-border: color-mix(in srgb, var(--warning) 34%, var(--border)); + --info: #2d9cff; + --info-text: #8ed0ff; + --info-soft: color-mix(in srgb, var(--info) 12%, var(--panel)); + --info-border: color-mix(in srgb, var(--info) 34%, var(--border)); --blue: #2d9cff; --radius: 8px; + --accent-contrast: #03100a; + --surface-sheen: rgba(255, 255, 255, 0.055); + --surface-sheen-soft: rgba(255, 255, 255, 0.018); + --surface-hover: rgba(255, 255, 255, 0.04); + --surface-muted: rgba(255, 255, 255, 0.035); + --surface-subtle-border: rgba(255, 255, 255, 0.1); + --overlay-scrim: rgba(0, 0, 0, 0.56); + --nav-bg: rgba(7, 12, 17, 0.88); + --rail-bg: rgba(7, 12, 17, 0.55); + --shadow-soft: 0 18px 48px rgba(0, 0, 0, 0.22); + --shadow-strong: 0 26px 70px rgba(0, 0, 0, 0.46); + --shadow-popover: 0 20px 34px rgba(0, 0, 0, 0.42); + --inset-highlight: rgba(255, 255, 255, 0.05); /* Admin design tokens β€” kept on :root so portal-rendered admin surfaces (dialogs, bits-ui Select.Portal content) inherit them. */ @@ -59,6 +80,10 @@ font-family: var(--font-sans); } +.theme-light { + color-scheme: light; +} + * { box-sizing: border-box; } @@ -72,7 +97,7 @@ body, } body { - background: #02070b; + background: var(--bg, #02070b); color: var(--text); -webkit-font-smoothing: antialiased; letter-spacing: 0; @@ -86,10 +111,10 @@ input { .app-shell { min-height: 100dvh; - background: #02070b !important; + background: var(--bg, #02070b) !important; } :root { --desktop-rail-width: 252px; --desktop-page-gutter: clamp(28px, 4vw, 72px); -} \ No newline at end of file +} diff --git a/bot/app/web/frontend/src/styles/components.css b/bot/app/web/frontend/src/styles/components.css index 91d8c8c..bc75e07 100644 --- a/bot/app/web/frontend/src/styles/components.css +++ b/bot/app/web/frontend/src/styles/components.css @@ -26,11 +26,9 @@ border-radius: 12px; border: 1px solid var(--admin-border); background: - linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), + linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface); - box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), - inset 0 1px 0 rgba(255, 255, 255, 0.05); + box-shadow: var(--shadow-soft), inset 0 1px 0 var(--inset-highlight); position: relative; overflow: hidden; } @@ -80,7 +78,7 @@ margin: 0; font-size: 1.5rem; font-weight: 600; - letter-spacing: -0.02em; + letter-spacing: 0; color: var(--admin-text); font-variant-numeric: tabular-nums; line-height: 1.15; @@ -89,7 +87,7 @@ .admin-cn-card-title--section { font-size: 1.05rem; font-weight: 600; - letter-spacing: -0.01em; + letter-spacing: 0; } .admin-cn-card-skeleton { @@ -173,15 +171,15 @@ } .admin-cn-badge-destructive { - background: color-mix(in srgb, var(--danger) 16%, var(--admin-surface)); - border-color: color-mix(in srgb, var(--danger) 35%, var(--admin-border)); - color: color-mix(in srgb, var(--danger) 92%, var(--admin-text)); + background: var(--danger-soft); + border-color: var(--danger-border); + color: var(--danger-text); } .admin-cn-badge-success { - background: color-mix(in srgb, var(--accent) 14%, var(--admin-surface)); - border-color: color-mix(in srgb, var(--accent) 32%, var(--admin-border)); - color: color-mix(in srgb, var(--accent) 88%, var(--admin-text)); + background: var(--success-soft); + border-color: var(--success-border); + color: var(--success-text); } .admin-cn-badge-muted { @@ -227,9 +225,9 @@ display: inline-block; background: linear-gradient( 90deg, - rgba(255, 255, 255, 0.08), - rgba(255, 255, 255, 0.14), - rgba(255, 255, 255, 0.08) + color-mix(in srgb, var(--muted) 10%, transparent), + color-mix(in srgb, var(--muted) 18%, transparent), + color-mix(in srgb, var(--muted) 10%, transparent) ); background-size: 220% 100%; animation: ui-skeleton-pulse 1.2s ease-in-out infinite; @@ -293,3 +291,4 @@ background-position: -120% 0; } } + diff --git a/bot/app/web/frontend/src/styles/controls.css b/bot/app/web/frontend/src/styles/controls.css index 5aa9bee..0de8184 100644 --- a/bot/app/web/frontend/src/styles/controls.css +++ b/bot/app/web/frontend/src/styles/controls.css @@ -23,7 +23,7 @@ button:disabled { text-align: center; text-decoration: none; cursor: pointer; - box-shadow: 0 10px 26px rgba(0, 0, 0, 0.18); + box-shadow: var(--shadow-soft); transition: transform 0.16s ease, border-color 0.16s ease, @@ -34,7 +34,7 @@ button:disabled { .btn:hover:not(:disabled) { transform: translateY(-1px); - box-shadow: 0 14px 32px rgba(0, 0, 0, 0.22); + box-shadow: var(--shadow-soft); } .btn:active:not(:disabled) { @@ -45,7 +45,7 @@ button:disabled { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent), - 0 14px 32px rgba(0, 0, 0, 0.22); + var(--shadow-soft); } .btn.wide, @@ -56,7 +56,7 @@ button:disabled { .btn-primary { border-color: color-mix(in srgb, var(--accent) 72%, white); background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 76%, white)); - color: #03100a; + color: var(--accent-contrast); } .btn-secondary { @@ -111,7 +111,7 @@ button:disabled { margin-top: 12px; overflow: hidden; border-radius: 999px; - background: rgba(255, 255, 255, 0.08); + background: color-mix(in srgb, var(--muted) 18%, transparent); } .progress span { diff --git a/bot/app/web/frontend/src/styles/dialogs.css b/bot/app/web/frontend/src/styles/dialogs.css index f3ae77c..40cca5c 100644 --- a/bot/app/web/frontend/src/styles/dialogs.css +++ b/bot/app/web/frontend/src/styles/dialogs.css @@ -26,7 +26,7 @@ inset: 0; z-index: 0; border: 0; - background: rgba(0, 0, 0, 0.56); + background: var(--overlay-scrim); backdrop-filter: blur(10px); cursor: pointer; } @@ -43,9 +43,9 @@ padding: 18px; border: 1px solid var(--border); border-radius: 24px; - background: color-mix(in srgb, var(--panel) 94%, #07111a); + background: color-mix(in srgb, var(--panel) 96%, var(--bg)); color: var(--text); - box-shadow: 0 26px 70px rgba(0, 0, 0, 0.46); + box-shadow: var(--shadow-strong); } .dialog-head { @@ -92,7 +92,7 @@ .payment-submit-button { border-color: color-mix(in srgb, var(--accent) 72%, transparent); background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent)); - color: #031009; + color: var(--accent-contrast); box-shadow: 0 16px 38px color-mix(in srgb, var(--accent) 28%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent), diff --git a/bot/app/web/frontend/src/styles/webapp.css b/bot/app/web/frontend/src/styles/webapp.css index 2561e4e..62a9001 100644 --- a/bot/app/web/frontend/src/styles/webapp.css +++ b/bot/app/web/frontend/src/styles/webapp.css @@ -12,8 +12,8 @@ a { min-height: 100dvh; margin: 0 auto; overflow-x: hidden; - padding: - max(16px, env(safe-area-inset-top)) max(var(--screen-gutter), var(--safe-inline)) max(18px, env(safe-area-inset-bottom)) max(var(--screen-gutter), var(--safe-inline)); + padding: max(16px, env(safe-area-inset-top)) max(var(--screen-gutter), var(--safe-inline)) + max(18px, env(safe-area-inset-bottom)) max(var(--screen-gutter), var(--safe-inline)); } .content { @@ -30,12 +30,18 @@ a { display: grid; min-height: 100dvh; place-items: center; - align-content: center; + align-content: safe center; gap: 14px; color: var(--muted); font-weight: 800; } +.loader .brand-mark.brand-mark-lg { + width: calc(4.125rem * var(--home-logo-scale, 1)); + height: calc(4.125rem * var(--home-logo-scale, 1)); + font-size: calc(2.875rem * var(--home-logo-scale, 1)); +} + .app-header, .preview-header, .screen-head { @@ -94,7 +100,7 @@ a { color: var(--text); font-size: 15px; font-weight: 850; - font-family: var(--font-mono); + font-family: var(--font-logo); text-overflow: ellipsis; white-space: nowrap; } @@ -162,20 +168,19 @@ a { border: 1px solid var(--border); border-radius: var(--radius); background: - linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), - var(--panel); + linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--panel); box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), - inset 0 1px 0 rgba(255, 255, 255, 0.05); + var(--shadow-soft), + inset 0 1px 0 var(--inset-highlight); padding: 14px; } .card-active { border-color: color-mix(in srgb, var(--accent) 76%, var(--border)); box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), + var(--shadow-soft), 0 0 0 1px color-mix(in srgb, var(--accent) 38%, transparent), - inset 0 1px 0 rgba(255, 255, 255, 0.05); + inset 0 1px 0 var(--inset-highlight); } .card-compact { @@ -189,7 +194,11 @@ a { .status-card-inactive { border-color: color-mix(in srgb, var(--danger) 58%, var(--border)); background: - linear-gradient(135deg, color-mix(in srgb, var(--danger) 14%, rgba(255, 255, 255, 0.03)), rgba(255, 255, 255, 0.012)), + linear-gradient( + 135deg, + color-mix(in srgb, var(--danger) 14%, var(--surface-sheen-soft)), + var(--surface-sheen-soft) + ), var(--panel); } @@ -392,7 +401,7 @@ a { gap: 6px; } -.premium-server-list>div { +.premium-server-list > div { display: flex; flex-wrap: wrap; gap: 6px; @@ -401,7 +410,7 @@ a { .premium-server-list span { max-width: 100%; padding: 4px 8px; - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid var(--surface-subtle-border); border-radius: 999px; color: var(--text); font-size: 11px; @@ -416,7 +425,7 @@ a { padding: 12px; } -.topup-summary-card>div:not(.premium-server-list) { +.topup-summary-card > div:not(.premium-server-list) { display: grid; gap: 3px; } @@ -440,7 +449,7 @@ a { gap: 11px; } -.trial-card-head>svg { +.trial-card-head > svg { flex: 0 0 auto; color: var(--accent); } @@ -475,7 +484,7 @@ a { gap: 11px; } -.devices-summary-head>svg { +.devices-summary-head > svg { color: var(--accent); } @@ -529,7 +538,7 @@ a { place-items: center; border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); border-radius: var(--radius); - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.03)); + background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted)); color: var(--accent); } @@ -565,8 +574,8 @@ a { .device-disconnect-button, .device-danger-button { - border-color: color-mix(in srgb, var(--danger) 62%, var(--border)); - color: #ffb8b8; + border-color: var(--danger-border); + color: var(--danger-text); } .devices-empty-card { @@ -604,9 +613,9 @@ a { .settings-row { border: 1px solid var(--border); border-radius: var(--radius); - background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)); + background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)); color: var(--text); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); + box-shadow: inset 0 1px 0 var(--inset-highlight); } .period-card { @@ -652,10 +661,10 @@ a { .period-card.active, .method-card.active { border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035)); + background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent), - inset 0 1px 0 rgba(255, 255, 255, 0.06); + inset 0 1px 0 var(--inset-highlight); } .total-card { @@ -688,11 +697,11 @@ a { gap: 12px; border: 1px solid var(--border); border-radius: var(--radius); - background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)); + background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)); color: var(--text); padding: 12px; text-align: left; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); + box-shadow: inset 0 1px 0 var(--inset-highlight); } .option-row-main, @@ -745,10 +754,10 @@ a { .option-row.active { border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035)); + background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent), - inset 0 1px 0 rgba(255, 255, 255, 0.06); + inset 0 1px 0 var(--inset-highlight); } .tariff-row { @@ -764,7 +773,7 @@ a { align-items: flex-start; } -.change-action-row>svg { +.change-action-row > svg { flex: 0 0 auto; margin-top: 1px; color: var(--accent); @@ -774,9 +783,9 @@ a { display: grid; gap: 6px; padding: 10px 12px; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--border); border-radius: var(--radius); - background: rgba(255, 255, 255, 0.035); + background: var(--surface-muted); } .topup-carryover-note p { @@ -798,11 +807,12 @@ a { display: block; overflow: hidden; border-radius: 999px; - background: - linear-gradient(90deg, - rgba(255, 255, 255, 0.07) 0%, - rgba(255, 255, 255, 0.14) 42%, - rgba(255, 255, 255, 0.07) 84%); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--muted) 10%, transparent) 0%, + color-mix(in srgb, var(--muted) 18%, transparent) 42%, + color-mix(in srgb, var(--muted) 10%, transparent) 84% + ); background-size: 220% 100%; animation: skeleton-shimmer 1.15s ease-in-out infinite; } @@ -893,7 +903,7 @@ a { .tariff-selected-card { min-height: 58px; - background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035)); + background: color-mix(in srgb, var(--accent) 7%, var(--surface-muted)); } .tariff-action-list { @@ -909,7 +919,7 @@ a { gap: 10px; border: 1px solid var(--border); border-radius: var(--radius); - background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)); + background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)); color: var(--text); padding: 12px; text-align: left; @@ -942,12 +952,12 @@ a { .tariff-action-card.active { border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035)); + background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted)); } .tariff-warning-card { justify-content: flex-start; - color: var(--warning, #ffd166); + color: var(--warning-text); } .confirm-summary-card { @@ -969,7 +979,7 @@ a { min-height: 56px; align-items: center; justify-content: center; - background: rgba(255, 255, 255, 0.035); + background: var(--surface-muted); padding: 10px; text-align: center; } @@ -1057,10 +1067,10 @@ a { .field-error-tooltip { z-index: 120; max-width: 280px; - border: 1px solid color-mix(in srgb, var(--danger) 60%, var(--border)); + border: 1px solid var(--danger-border); border-radius: 8px; - background: #2a1010; - color: #ffd8d8; + background: var(--danger-soft); + color: var(--danger-text); padding: 8px 10px; font-size: 12px; line-height: 1.3; @@ -1105,7 +1115,7 @@ a { overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); - background: rgba(0, 0, 0, 0.2); + background: color-mix(in srgb, var(--panel-2) 86%, transparent); color: var(--text); padding: 12px; font-size: 12px; @@ -1132,7 +1142,7 @@ a { gap: 14px; } -.bonus-card-head>svg { +.bonus-card-head > svg { flex: 0 0 auto; color: var(--accent); } @@ -1165,7 +1175,7 @@ a { gap: 3px; border: 1px solid var(--border); border-radius: var(--radius); - background: rgba(255, 255, 255, 0.02); + background: var(--surface-sheen-soft); padding: 10px 11px; } @@ -1193,7 +1203,7 @@ a { overflow: hidden; border: 1px solid var(--border); border-radius: 999px; - background: rgba(255, 255, 255, 0.03); + background: var(--surface-muted); } .settings-avatar img { @@ -1245,15 +1255,15 @@ a { min-height: 50px; padding: 9px 10px; text-align: left; - background: rgba(255, 255, 255, 0.03); + background: var(--surface-muted); } -.settings-row>svg:first-child { +.settings-row > svg:first-child { color: var(--text); opacity: 0.9; } -.settings-row>svg:last-child { +.settings-row > svg:last-child { color: var(--muted); } @@ -1276,26 +1286,22 @@ a { .settings-row-linked { grid-template-columns: 28px minmax(0, 1fr); - border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); - background: color-mix(in srgb, var(--accent) 11%, rgba(255, 255, 255, 0.03)); + border-color: var(--success-border); + background: var(--success-soft); } -.settings-row-linked>svg:first-child { - color: var(--accent); +.settings-row-linked > svg:first-child { + color: var(--success-text); } .settings-row-linked strong { - color: var(--accent); + color: var(--success-text); } .emoji-flag { font-family: - "Twemoji Country Flags", - "Apple Color Emoji", - "Segoe UI Emoji", - "Segoe UI Symbol", - "Noto Color Emoji", - sans-serif; + "Twemoji Country Flags", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji", sans-serif; font-variant-emoji: emoji; line-height: 1; } @@ -1325,7 +1331,7 @@ a { border-radius: 6px; } -.language-select-trigger>svg { +.language-select-trigger > svg { color: var(--muted); } @@ -1361,7 +1367,7 @@ a { border: 1px solid var(--border-strong); border-radius: var(--radius); background: var(--panel-3); - box-shadow: 0 20px 34px rgba(0, 0, 0, 0.42); + box-shadow: var(--shadow-popover); overflow: hidden; box-sizing: border-box; transform-origin: top right; @@ -1387,7 +1393,7 @@ a { } .language-select-item[data-highlighted] { - background: rgba(255, 255, 255, 0.06); + background: var(--surface-hover); } .language-select-item[data-selected] { @@ -1409,11 +1415,11 @@ a { text-overflow: ellipsis; } -.language-select-item-main>span { +.language-select-item-main > span { display: inline-block !important; } -.language-select-item-main>span:last-child { +.language-select-item-main > span:last-child { margin-left: 6px; white-space: nowrap !important; overflow: hidden; @@ -1494,8 +1500,8 @@ a { min-height: 64px; border: 1px solid var(--border); border-radius: var(--radius); - background: rgba(7, 12, 17, 0.88); - box-shadow: 0 16px 38px rgba(0, 0, 0, 0.36); + background: var(--nav-bg); + box-shadow: var(--shadow-soft); backdrop-filter: blur(16px); } @@ -1530,17 +1536,29 @@ a { .home-layout { display: grid; min-height: calc(100dvh - 34px); - grid-template-rows: minmax(0, 1fr) auto; + grid-template-rows: minmax(min-content, 1fr) auto; gap: 18px; padding-bottom: 86px; animation: section-enter 0.24s ease-out both; } .home-brand { - align-self: center; + align-self: safe center; padding-top: 0; } +.home-brand .brand-mark.brand-mark-xl { + width: calc(6rem * var(--home-logo-scale, 1)); + height: calc(6rem * var(--home-logo-scale, 1)); + font-size: calc(4.375rem * var(--home-logo-scale, 1)); +} + +.login-brand-auth .brand-mark.brand-mark-xl { + width: calc(116px * var(--home-logo-scale, 1)); + height: calc(116px * var(--home-logo-scale, 1)); + font-size: calc(84px * var(--home-logo-scale, 1)); +} + .home-bottom { display: grid; gap: 10px; @@ -1549,7 +1567,7 @@ a { .auth-screen { display: grid; - align-content: center; + align-content: safe center; } .auth-card-wrap { @@ -1568,19 +1586,13 @@ a { gap: 5px; } -.login-brand-auth .brand-mark.brand-mark-xl { - width: 116px; - height: 116px; - font-size: 84px; -} - .login-brand h1 { margin: 0; overflow-wrap: anywhere; color: var(--accent); font-size: 32px; font-weight: 900; - font-family: var(--font-mono); + font-family: var(--font-logo); line-height: 1.04; } @@ -1739,13 +1751,13 @@ a { place-items: center; border: 1px solid color-mix(in srgb, var(--accent) 74%, var(--border)); border-radius: var(--radius); - background: rgba(255, 255, 255, 0.035); + background: var(--surface-muted); font-size: 25px; font-weight: 900; } .otp-slots span.filled { - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.04)); + background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted)); } .link-button { @@ -1760,7 +1772,7 @@ a { } .status-line { - color: var(--accent); + color: var(--success-text); font-size: 12px; line-height: 1.4; } @@ -1788,10 +1800,10 @@ a { bottom: 16px; z-index: 120; max-width: min(420px, calc(100vw - 32px)); - border: 1px solid color-mix(in srgb, var(--accent) 46%, var(--border)); + border: 1px solid var(--border-strong); border-radius: var(--radius); - background: rgba(15, 21, 27, 0.92); - box-shadow: 0 16px 42px rgba(0, 0, 0, 0.42); + background: color-mix(in srgb, var(--panel) 92%, transparent); + box-shadow: var(--shadow-popover); color: var(--text); padding: 12px 14px; font-size: 13px; @@ -1873,8 +1885,7 @@ a { border: 1px solid rgba(255, 255, 255, 0.22); border-radius: 18px; background: - linear-gradient(135deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.015)), - #071017; + linear-gradient(135deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.015)), #071017; box-shadow: 0 28px 80px rgba(0, 0, 0, 0.54), inset 0 1px 0 rgba(255, 255, 255, 0.08); @@ -2011,7 +2022,6 @@ a { } @media (prefers-reduced-motion: reduce) { - .content, .home-layout, .language-select-content, @@ -2039,7 +2049,7 @@ a { @media (min-width: 1024px) { body { - background: #02070b; + background: var(--bg); } .app-shell { @@ -2060,16 +2070,16 @@ a { border-radius: 0; background: transparent; box-shadow: none; - padding: - max(28px, env(safe-area-inset-top)) var(--desktop-page-gutter) 40px calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); + padding: max(28px, env(safe-area-inset-top)) var(--desktop-page-gutter) 40px + calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); overflow-x: visible; } /* Centre and cap the width of the actual content blocks. */ - .phone-screen>.app-header, - .phone-screen>main, - .phone-screen>.home-layout, - .phone-screen>nav.bottom-nav~* { + .phone-screen > .app-header, + .phone-screen > main, + .phone-screen > .home-layout, + .phone-screen > nav.bottom-nav ~ * { max-width: 1080px; margin-left: auto; margin-right: auto; @@ -2079,12 +2089,11 @@ a { .phone-screen.auth-screen { width: min(100%, 460px); min-height: 100dvh; - padding: - max(16px, env(safe-area-inset-top)) clamp(24px, 4vw, 16px) 16px; + padding: max(16px, env(safe-area-inset-top)) clamp(24px, 4vw, 16px) 16px; margin: 0 auto; } - .phone-screen.auth-screen~.bottom-nav, + .phone-screen.auth-screen ~ .bottom-nav, .auth-screen .bottom-nav { display: none !important; } @@ -2094,8 +2103,8 @@ a { grid-template-columns: minmax(0, 1fr); align-items: stretch; min-height: 100dvh; - padding: - max(32px, env(safe-area-inset-top)) var(--desktop-page-gutter) 42px calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); + padding: max(32px, env(safe-area-inset-top)) var(--desktop-page-gutter) 42px + calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); } /* Home: keep the phone-style vertical flow, but center it in the @@ -2113,7 +2122,7 @@ a { align-items: stretch; } - .home-layout>.home-brand { + .home-layout > .home-brand { grid-column: auto; align-self: center; justify-items: center; @@ -2132,9 +2141,9 @@ a { .home-brand .brand-mark, .home-brand .brand-mark-lg, .home-brand .brand-mark-xl { - width: clamp(180px, 18vw, 260px); - height: clamp(180px, 18vw, 260px); - font-size: clamp(124px, 13vw, 184px); + width: calc(clamp(180px, 18vw, 260px) * var(--home-logo-scale, 1)); + height: calc(clamp(180px, 18vw, 260px) * var(--home-logo-scale, 1)); + font-size: calc(clamp(124px, 13vw, 184px) * var(--home-logo-scale, 1)); } .home-bottom { @@ -2174,9 +2183,9 @@ a { border: 0 !important; border-right: 1px solid var(--border) !important; border-radius: 0 !important; - background: rgba(7, 12, 17, 0.55) !important; + background: var(--rail-bg) !important; backdrop-filter: blur(14px) !important; - box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02) !important; + box-shadow: inset -1px 0 0 var(--border) !important; z-index: 40; } @@ -2197,22 +2206,25 @@ a { font-size: 13px !important; color: var(--muted); border: 1px solid transparent; - transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; + transition: + background 0.12s ease, + color 0.12s ease, + border-color 0.12s ease; } - .bottom-nav button>svg { + .bottom-nav button > svg { width: 20px; height: 20px; } - .bottom-nav button>span { + .bottom-nav button > span { text-align: left !important; font-size: 13px !important; font-weight: 600; } .bottom-nav button:hover { - background: rgba(255, 255, 255, 0.04); + background: var(--surface-hover); color: var(--text); } @@ -2235,7 +2247,7 @@ a { display: none !important; } - .phone-screen>main.content.with-nav { + .phone-screen > main.content.with-nav { padding-top: 0; } @@ -2310,7 +2322,8 @@ a { color: var(--accent); font-size: 14px; font-weight: 800; - letter-spacing: -0.01em; + font-family: var(--font-logo); + letter-spacing: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/bot/app/web/templates/subscription_webapp.html b/bot/app/web/templates/subscription_webapp.html index cf213f6..492c5c8 100644 --- a/bot/app/web/templates/subscription_webapp.html +++ b/bot/app/web/templates/subscription_webapp.html @@ -9,7 +9,7 @@ /minishop - + diff --git a/bot/app/web/themes/ascii/style.css b/bot/app/web/themes/ascii/style.css new file mode 100644 index 0000000..77d1878 --- /dev/null +++ b/bot/app/web/themes/ascii/style.css @@ -0,0 +1,953 @@ +/* + * ASCII / console theme. + * Pure black background, white foreground, monospace everything, + * 1px white borders, animated ASCII spinners and block-progress bars. + */ + +.theme-key-ascii { + color-scheme: dark; + --accent: #ffffff; + --accent-contrast: #000000; + --bg: #000000; + --panel: #000000; + --panel-2: #050505; + --panel-3: #0c0c0c; + --border: #ffffff; + --border-strong: #ffffff; + --text: #ffffff; + --muted: #b0b0b0; + --dim: #6a6a6a; + --danger: #ff5555; + --blue: #ffffff; + --radius: 0px; + --font-sans: "JetBrains Mono", "Cascadia Code", "Fira Code", "Consolas", + "Source Code Pro", "Courier New", ui-monospace, monospace; + --font-logo: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New", + ui-monospace, monospace; + --font-mono: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New", + ui-monospace, monospace; + --surface-sheen: transparent; + --surface-sheen-soft: transparent; + --surface-hover: rgba(255, 255, 255, 0.08); + --surface-muted: #0a0a0a; + --surface-subtle-border: #ffffff; + --overlay-scrim: rgba(0, 0, 0, 0.85); + --nav-bg: #000000; + --rail-bg: #000000; + --shadow-soft: none; + --shadow-strong: none; + --shadow-popover: 0 0 0 1px #ffffff; + --inset-highlight: transparent; + --admin-bg: #000000; + --admin-surface: #000000; + --admin-surface-2: #050505; + --admin-elev: #0c0c0c; + --admin-border: #ffffff; + --admin-border-strong: #ffffff; + --admin-text: #ffffff; + --admin-muted: #b0b0b0; + --admin-dim: #6a6a6a; +} + +/* ---------- Base typography ---------- */ + +.theme-key-ascii, +.theme-key-ascii body, +.theme-key-ascii button, +.theme-key-ascii input, +.theme-key-ascii textarea, +.theme-key-ascii select { + font-family: var(--font-sans); + letter-spacing: 0; + font-synthesis: none; + -webkit-font-smoothing: none; + font-smooth: never; + font-variant-ligatures: none; +} + +.theme-key-ascii.app-shell { + background: var(--bg) !important; + background-image: + repeating-linear-gradient( + 0deg, + rgba(255, 255, 255, 0.025) 0, + rgba(255, 255, 255, 0.025) 1px, + transparent 1px, + transparent 3px + ) !important; +} + +/* Slight CRT-like flicker on the shell. */ +@keyframes ascii-flicker { + 0%, 96%, 100% { opacity: 1; } + 97% { opacity: 0.96; } + 98% { opacity: 1; } + 99% { opacity: 0.94; } +} + +.theme-key-ascii.app-shell::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9998; + background: repeating-linear-gradient( + 180deg, + rgba(255, 255, 255, 0.02) 0, + rgba(255, 255, 255, 0.02) 1px, + transparent 1px, + transparent 2px + ); + animation: ascii-flicker 5s infinite; +} + +/* ---------- Panels / cards ---------- */ + +.theme-key-ascii .card, +.theme-key-ascii .period-card, +.theme-key-ascii .method-card, +.theme-key-ascii .settings-row, +.theme-key-ascii .option-row, +.theme-key-ascii .tariff-selected-card, +.theme-key-ascii .tariff-action-card, +.theme-key-ascii .tariff-warning-card, +.theme-key-ascii .topup-carryover-note, +.theme-key-ascii .input, +.theme-key-ascii .dialog-card, +.theme-key-ascii .language-select-content, +.theme-key-ascii .bottom-nav, +.theme-key-ascii .toast, +.theme-key-ascii .admin-sidebar, +.theme-key-ascii .admin-header, +.theme-key-ascii .admin-card, +.theme-key-ascii .admin-stat-card, +.theme-key-ascii .admin-revenue-panel, +.theme-key-ascii .admin-empty, +.theme-key-ascii .admin-tariff-card, +.theme-key-ascii .admin-toolbar-card, +.theme-key-ascii .admin-table-card, +.theme-key-ascii .admin-panel-dash-card, +.theme-key-ascii .admin-select-trigger, +.theme-key-ascii .admin-select-content, +.theme-key-ascii .admin-cn-card[data-slot="card"], +.theme-key-ascii .admin-dialog .dialog-card, +.theme-key-ascii .admin-theme-editor-section { + border: 1px solid #ffffff; + border-radius: 0; + background: var(--panel); + box-shadow: none; +} + +/* No ribbon/corner overlays: those caused dialog overflow scrollbars. + * The console feel comes from the crisp 1px borders, monospace text, + * and the animated marquees / glitches applied to interactive elements. */ + +/* ---------- Buttons ---------- */ + +.theme-key-ascii .btn, +.theme-key-ascii .language-select-trigger, +.theme-key-ascii .bottom-nav button, +.theme-key-ascii .admin-btn, +.theme-key-ascii .admin-chip, +.theme-key-ascii .admin-tabs-trigger, +.theme-key-ascii .admin-revenue-period-btn, +.theme-key-ascii .admin-mobile-toggle, +.theme-key-ascii .admin-nav-item { + border: 1px solid #ffffff; + border-radius: 0; + background: #000000; + color: #ffffff; + box-shadow: none; + text-transform: none; + font-family: var(--font-sans); + transform: none; + position: relative; +} + +.theme-key-ascii .btn:hover:not(:disabled), +.theme-key-ascii .admin-btn:hover:not(:disabled), +.theme-key-ascii .admin-nav-item:hover, +.theme-key-ascii .admin-tabs-trigger:hover, +.theme-key-ascii .admin-revenue-period-btn:hover, +.theme-key-ascii .bottom-nav button:hover { + background: #ffffff; + color: #000000; +} + +.theme-key-ascii .btn:active:not(:disabled), +.theme-key-ascii .bottom-nav button:active, +.theme-key-ascii .admin-btn:active:not(:disabled) { + background: #ffffff; + color: #000000; + transform: translate(1px, 1px); +} + +.theme-key-ascii .btn-primary, +.theme-key-ascii .admin-btn-primary, +.theme-key-ascii .bottom-nav button.active, +.theme-key-ascii .period-card.active, +.theme-key-ascii .method-card.active, +.theme-key-ascii .option-row.active, +.theme-key-ascii .admin-nav-item.active, +.theme-key-ascii .admin-tabs-trigger[data-state="active"], +.theme-key-ascii .admin-revenue-period-btn.is-active { + background: #ffffff; + color: #000000; + border-color: #ffffff; +} + +.theme-key-ascii .btn-primary:hover:not(:disabled), +.theme-key-ascii .admin-btn-primary:hover:not(:disabled) { + background: #000000; + color: #ffffff; + outline: 1px solid #ffffff; + outline-offset: -2px; +} + +/* Blinking caret-style focus ring. */ +@keyframes ascii-caret { + 0%, 49% { outline-color: #ffffff; } + 50%, 100% { outline-color: transparent; } +} + +.theme-key-ascii .btn:focus-visible, +.theme-key-ascii .admin-btn:focus-visible, +.theme-key-ascii .admin-nav-item:focus-visible, +.theme-key-ascii .admin-tabs-trigger:focus-visible, +.theme-key-ascii .admin-revenue-period-btn:focus-visible, +.theme-key-ascii .admin-mobile-toggle:focus-visible, +.theme-key-ascii .language-select-trigger:focus-visible, +.theme-key-ascii .bottom-nav button:focus-visible { + outline: 2px solid #ffffff; + outline-offset: 1px; + animation: ascii-caret 1s steps(1) infinite; +} + +/* ---------- Inputs ---------- */ + +.theme-key-ascii .input, +.theme-key-ascii .admin-input, +.theme-key-ascii .admin-textarea, +.theme-key-ascii .admin-screen-wrap textarea, +.theme-key-ascii .admin-dialog textarea { + border: 1px solid #ffffff; + border-radius: 0; + background: #000000; + color: #ffffff; + box-shadow: none; + font-family: var(--font-mono); + caret-color: #ffffff; +} + +.theme-key-ascii .input::placeholder, +.theme-key-ascii .admin-input::placeholder, +.theme-key-ascii .admin-textarea::placeholder, +.theme-key-ascii .admin-screen-wrap textarea::placeholder, +.theme-key-ascii .admin-dialog textarea::placeholder { + color: var(--dim); + font-style: normal; +} + +.theme-key-ascii .input:focus, +.theme-key-ascii .admin-input:focus, +.theme-key-ascii .admin-textarea:focus, +.theme-key-ascii .admin-screen-wrap textarea:focus, +.theme-key-ascii .admin-dialog textarea:focus { + outline: none; + border-color: #ffffff; + box-shadow: inset 0 0 0 1px #ffffff; +} + +/* ---------- Bottom nav (desktop rail) ---------- */ + +@media (min-width: 1024px) { + .theme-key-ascii .bottom-nav { + border-right: 1px solid #ffffff !important; + background: var(--rail-bg) !important; + backdrop-filter: none !important; + box-shadow: none !important; + } + + .theme-key-ascii .bottom-nav button { + border: 1px solid #ffffff !important; + border-radius: 0 !important; + background: #000000 !important; + color: #ffffff !important; + box-shadow: none !important; + } + + .theme-key-ascii .bottom-nav button.active { + background: #ffffff !important; + color: #000000 !important; + } +} + +/* ---------- ASCII progress bar ---------- * + * Empty track: β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ (low-contrast dotted fill). + * Filled span: β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ (solid white blocks). + */ + +.theme-key-ascii .progress { + height: 14px; + border: 1px solid #ffffff; + border-radius: 0 !important; + background-color: #000000; + background-image: repeating-linear-gradient( + 90deg, + rgba(255, 255, 255, 0.22) 0, + rgba(255, 255, 255, 0.22) 1px, + transparent 1px, + transparent 4px + ); + position: relative; + overflow: hidden; + font-family: var(--font-mono); +} + +.theme-key-ascii .progress span { + border-radius: 0 !important; + background: #ffffff !important; + background-image: repeating-linear-gradient( + 90deg, + rgba(0, 0, 0, 0.0) 0, + rgba(0, 0, 0, 0.0) 5px, + rgba(0, 0, 0, 0.35) 5px, + rgba(0, 0, 0, 0.35) 6px + ) !important; + box-shadow: none; +} + +/* Indeterminate scanning effect for any progress lacking a width-set span. */ +@keyframes ascii-scan { + 0% { background-position: 0 0; } + 100% { background-position: 12px 0; } +} + +/* ---------- ASCII spinner replacement ---------- */ + +.theme-key-ascii .ui-spinner, +.theme-key-ascii .telegram-button-spinner, +.theme-key-ascii .brand-mark-spinner { + border: none !important; + border-radius: 0 !important; + width: 1ch !important; + height: 1em !important; + background: transparent !important; + position: relative; + animation: none !important; + color: currentColor; + font-family: var(--font-mono); + font-weight: 700; + text-align: center; + vertical-align: middle; + overflow: visible; +} + +.theme-key-ascii .ui-spinner::before, +.theme-key-ascii .telegram-button-spinner::before, +.theme-key-ascii .brand-mark-spinner::before { + content: "|"; + display: inline-block; + animation: ascii-spin 0.8s steps(1) infinite; + font-family: var(--font-mono); + line-height: 1; +} + +@keyframes ascii-spin { + 0% { content: "|"; } + 25% { content: "/"; } + 50% { content: "-"; } + 75% { content: "\\"; } + 100% { content: "|"; } +} + +/* Some browsers don't animate content; fallback rotation of a glyph. */ +@supports not (animation-name: ascii-spin) { + .theme-key-ascii .ui-spinner::before, + .theme-key-ascii .telegram-button-spinner::before, + .theme-key-ascii .brand-mark-spinner::before { + content: "+"; + animation: ascii-spin-rotate 0.8s steps(4) infinite; + } + @keyframes ascii-spin-rotate { + to { transform: rotate(360deg); } + } +} + +/* Blinking cursor appended to brand text. */ +.theme-key-ascii .login-brand h1::after, +.theme-key-ascii .admin-sidebar-brand strong::after, +.theme-key-ascii .brand-row strong::after { + content: "_"; + display: inline-block; + margin-left: 0.2ch; + color: #ffffff; + animation: ascii-blink 1s steps(1) infinite; +} + +@keyframes ascii-blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} + +/* Section heading prompt prefix. */ +.theme-key-ascii .admin-card-head h2::before, +.theme-key-ascii .admin-card-head h3::before, +.theme-key-ascii .card > h2:first-child::before, +.theme-key-ascii .card > h3:first-child::before { + content: "> "; + color: #ffffff; + opacity: 0.85; + font-family: var(--font-mono); +} + +/* ---------- Tables ---------- */ + +.theme-key-ascii .admin-table thead th { + background: #000000; + color: #ffffff; + border-bottom: 1px solid #ffffff; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 700; +} + +.theme-key-ascii .admin-table tbody tr { + border-bottom: 1px dashed #ffffff; +} + +.theme-key-ascii .admin-table tbody tr:hover { + background: rgba(255, 255, 255, 0.08); +} + +/* ---------- Badges / chips ---------- */ + +.theme-key-ascii .admin-badge, +.theme-key-ascii .admin-cn-badge { + border: 1px solid #ffffff; + border-radius: 0; + background: #000000; + color: #ffffff; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.theme-key-ascii .admin-badge::before, +.theme-key-ascii .admin-cn-badge::before { + content: "["; +} + +.theme-key-ascii .admin-badge::after, +.theme-key-ascii .admin-cn-badge::after { + content: "]"; +} + +/* ---------- Links ---------- */ + +.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]), +.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item) { + color: #ffffff; + text-decoration: underline; + text-underline-offset: 2px; +} + +.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):hover, +.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item):hover { + background: #ffffff; + color: #000000; + text-decoration: none; +} + +/* ---------- Selection ---------- */ + +.theme-key-ascii ::selection { + background: #ffffff; + color: #000000; +} + +/* ---------- Scrollbars ---------- */ + +.theme-key-ascii ::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +.theme-key-ascii ::-webkit-scrollbar-track { + background-color: #000000; + background-image: repeating-linear-gradient( + 0deg, + #ffffff 0, + #ffffff 1px, + transparent 1px, + transparent 4px + ); +} + +.theme-key-ascii ::-webkit-scrollbar-thumb { + background: #ffffff; + border: 1px solid #000000; +} + +.theme-key-ascii ::-webkit-scrollbar-thumb:active { + background: #b0b0b0; +} + +.theme-key-ascii ::-webkit-scrollbar-corner { + background: #000000; +} + +/* ---------- Lucide icons: render as crisp white outlines ---------- */ + +.theme-key-ascii svg.lucide, +.theme-key-ascii svg[class*="lucide-"] { + color: #ffffff !important; + stroke: #ffffff !important; + fill: none !important; + stroke-width: 1.75; + filter: none; +} + +.theme-key-ascii .btn-primary svg.lucide, +.theme-key-ascii .bottom-nav button.active svg.lucide, +.theme-key-ascii .period-card.active svg.lucide, +.theme-key-ascii .method-card.active svg.lucide, +.theme-key-ascii .option-row.active svg.lucide, +.theme-key-ascii .admin-btn-primary svg.lucide, +.theme-key-ascii .admin-nav-item.active svg.lucide, +.theme-key-ascii .admin-tabs-trigger[data-state="active"] svg.lucide, +.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide { + color: #000000 !important; + stroke: #000000 !important; +} + +/* ---------- Toast / language select polish ---------- */ + +.theme-key-ascii .toast { + background: #000000; + border: 1px solid #ffffff; + color: #ffffff; +} + +.theme-key-ascii .language-select-item { + border-radius: 0; +} + +.theme-key-ascii .language-select-item[data-highlighted], +.theme-key-ascii .language-select-item[data-selected] { + background: #ffffff; + color: #000000 !important; +} + +/* ---------- Headings: stronger console feel ---------- */ + +.theme-key-ascii h1, +.theme-key-ascii h2, +.theme-key-ascii h3, +.theme-key-ascii h4 { + font-family: var(--font-mono); + letter-spacing: 0; + text-transform: none; +} + +.theme-key-ascii .admin-header { + background: #000000; + border-bottom: 1px solid #ffffff; + color: #ffffff; +} + +.theme-key-ascii .admin-header-title h2, +.theme-key-ascii .admin-header-title small { + color: #ffffff; +} + +/* Make any element with role progressbar but no inner span show animated stripes. */ +.theme-key-ascii [role="progressbar"]:not(.progress) { + background: + repeating-linear-gradient( + 90deg, + #ffffff 0, + #ffffff 6px, + #000000 6px, + #000000 8px + ); + animation: ascii-scan 0.6s linear infinite; + border: 1px solid #ffffff; + border-radius: 0; + color: #000000; +} + +/* ============================================================ + * Console-themed extras + * ============================================================ */ + +/* ---------- ASCII skeletons ---------- * + * Subtle dark shimmer with a single bright scan line moving across. + */ + +@keyframes ascii-skeleton-scan { + 0% { background-position: -120% 0; } + 100% { background-position: 220% 0; } +} + +.theme-key-ascii .ui-skeleton, +.theme-key-ascii .admin-skeleton, +.theme-key-ascii .skeleton-line, +.theme-key-ascii .skeleton-dot, +.theme-key-ascii .skeleton-pay-button, +.theme-key-ascii .ui-skeleton-line, +.theme-key-ascii .admin-skeleton-line, +.theme-key-ascii .admin-skeleton-line-strong, +.theme-key-ascii .admin-skeleton-line-soft, +.theme-key-ascii .admin-skeleton-line-short, +.theme-key-ascii .admin-skeleton-line-tiny, +.theme-key-ascii .ui-skeleton-title, +.theme-key-ascii .ui-skeleton-short, +.theme-key-ascii .ui-skeleton-tiny, +.theme-key-ascii .ui-skeleton-badge, +.theme-key-ascii .admin-skeleton-badge, +.theme-key-ascii .admin-skeleton-avatar, +.theme-key-ascii .admin-stat-skeleton-card, +.theme-key-ascii .admin-stat-skeleton-wide, +.theme-key-ascii .admin-cn-card-skeleton--tall { + border-radius: 0 !important; + border: 1px solid #ffffff !important; + background-color: #050505 !important; + background-image: linear-gradient( + 90deg, + transparent 0%, + transparent 40%, + rgba(255, 255, 255, 0.18) 50%, + transparent 60%, + transparent 100% + ) !important; + background-size: 200% 100% !important; + background-repeat: no-repeat !important; + color: #ffffff !important; + animation: ascii-skeleton-scan 1.6s linear infinite !important; +} + +.theme-key-ascii .admin-skeleton-avatar { + width: 32px !important; + height: 32px !important; +} + +/* ---------- Empty / loading state console message ---------- */ +.theme-key-ascii .admin-empty { + position: relative; +} + +.theme-key-ascii .admin-empty::before { + content: "$ tail -f /var/log/empty.log"; + display: block; + font-family: var(--font-mono); + color: var(--muted); + margin-bottom: 8px; + letter-spacing: 0; +} + +/* ---------- Buttons: glitch on hover ---------- */ + +@keyframes ascii-glitch { + 0%, 100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); } + 20% { transform: translate(-1px, 0); clip-path: inset(20% 0 50% 0); } + 40% { transform: translate(1px, 0); clip-path: inset(40% 0 30% 0); } + 60% { transform: translate(-1px, 0); clip-path: inset(10% 0 70% 0); } + 80% { transform: translate(1px, 0); clip-path: inset(60% 0 10% 0); } +} + +.theme-key-ascii .btn:hover:not(:disabled)::after, +.theme-key-ascii .admin-btn:hover:not(:disabled)::after { + content: attr(data-label, ""); + pointer-events: none; +} + +/* Disable glitch text duplication if the button has no data-label. + * Apply a subtle scanline overlay instead, which is content-agnostic. */ +.theme-key-ascii .btn, +.theme-key-ascii .admin-btn, +.theme-key-ascii .admin-nav-item, +.theme-key-ascii .bottom-nav button { + overflow: hidden; +} + +.theme-key-ascii .btn:hover:not(:disabled)::before, +.theme-key-ascii .admin-btn:hover:not(:disabled)::before, +.theme-key-ascii .admin-nav-item:hover::before, +.theme-key-ascii .bottom-nav button:hover::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient( + 0deg, + rgba(0, 0, 0, 0.4) 0, + rgba(0, 0, 0, 0.4) 1px, + transparent 1px, + transparent 3px + ); + animation: ascii-glitch 0.6s steps(1) infinite; + z-index: 1; +} + +/* ---------- Bottom nav active markers "> item <" ---------- */ + +.theme-key-ascii .bottom-nav button.active::before, +.theme-key-ascii .admin-nav-item.active::before { + content: ">"; + position: absolute; + left: 6px; + top: 50%; + transform: translateY(-50%); + font-family: var(--font-mono); + color: #000000; + animation: ascii-blink 1s steps(1) infinite; + z-index: 2; +} + +.theme-key-ascii .bottom-nav button.active::after, +.theme-key-ascii .admin-nav-item.active::after { + content: "<"; + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + font-family: var(--font-mono); + color: #000000; + animation: ascii-blink 1s steps(1) infinite; + animation-delay: 0.5s; + z-index: 2; +} + +.theme-key-ascii .bottom-nav button.active, +.theme-key-ascii .admin-nav-item.active { + position: relative; +} + +/* On the mobile bottom-bar (compact) hide the markers to avoid overlap. */ +@media (max-width: 1023px) { + .theme-key-ascii .bottom-nav button.active::before, + .theme-key-ascii .bottom-nav button.active::after { + content: none; + } +} + +/* ---------- ASCII block progress fill ---------- * + * The actual fill renders as alternating β–ˆ blocks via the existing span + * gradient. We also overlay a slow scanning highlight to make it feel + * "live", and add a soft typed counter to the right edge. + */ + +/* (duplicate progress fill rules removed β€” see definition above) */ + +/* ---------- Headings: subtle CRT glitch on hover ---------- */ + +@keyframes ascii-heading-jitter { + 0%, 92%, 100% { transform: translate(0, 0); } + 93% { transform: translate(-1px, 0); } + 94% { transform: translate(1px, 0); } + 95% { transform: translate(0, -1px); } + 96% { transform: translate(0, 1px); } +} + +.theme-key-ascii h1, +.theme-key-ascii h2, +.theme-key-ascii h3, +.theme-key-ascii .login-brand h1, +.theme-key-ascii .admin-sidebar-brand strong, +.theme-key-ascii .admin-card-head h2, +.theme-key-ascii .admin-card-head h3 { + display: inline-block; + animation: ascii-heading-jitter 7s steps(1) infinite; +} + +/* ---------- App-shell boot banner ---------- * + * A non-blocking strip at the very top of the viewport that displays a + * typed "booting…" line, then settles. Pure CSS so it cannot interfere + * with any DOM. The animation runs once on mount. + */ + +@keyframes ascii-boot-type { + 0% { width: 0; } + 85% { width: 28ch; } + 100% { width: 28ch; } +} + +@keyframes ascii-boot-fade { + 0%, 70% { opacity: 1; } + 100% { opacity: 0; visibility: hidden; } +} + +.theme-key-ascii.app-shell::after { + content: "$ remnawave --start --tty=0"; + position: fixed; + top: 0; + left: 0; + z-index: 9999; + display: block; + padding: 2px 8px; + max-width: 28ch; + overflow: hidden; + white-space: nowrap; + background: #000000; + color: #ffffff; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.5; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + pointer-events: none; + animation: + ascii-boot-type 1.6s steps(28) 1 both, + ascii-boot-fade 3s linear 1.6s 1 forwards; +} + +/* ---------- Toggle / checkbox squareification (best-effort) ---------- */ + +.theme-key-ascii input[type="checkbox"], +.theme-key-ascii input[type="radio"] { + appearance: none; + -webkit-appearance: none; + width: 1em; + height: 1em; + border: 1px solid #ffffff; + background: #000000; + border-radius: 0 !important; + position: relative; + vertical-align: middle; + cursor: pointer; +} + +.theme-key-ascii input[type="checkbox"]:checked::after, +.theme-key-ascii input[type="radio"]:checked::after { + content: "x"; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-mono); + font-weight: 700; + color: #ffffff; + line-height: 1; +} + +/* ---------- Code-like "$ " prefix on toast messages ---------- */ + +.theme-key-ascii .toast::before { + content: "$ "; + color: #ffffff; + font-family: var(--font-mono); + font-weight: 700; +} + +/* ---------- Disabled state β€” strikethrough hatching ---------- */ + +.theme-key-ascii .btn:disabled, +.theme-key-ascii .admin-btn:disabled, +.theme-key-ascii button:disabled { + background-image: repeating-linear-gradient( + -45deg, + transparent 0, + transparent 4px, + rgba(255, 255, 255, 0.18) 4px, + rgba(255, 255, 255, 0.18) 5px + ); + color: var(--dim) !important; + border-color: var(--dim) !important; + cursor: not-allowed; +} + +/* ============================================================ + * Square everything: drop all rounded corners on touched surfaces. + * ============================================================ */ + +.theme-key-ascii :is( + .card, .dialog-card, .toast, + .btn, .input, + .period-card, .method-card, .settings-row, .option-row, + .tariff-selected-card, .tariff-action-card, .tariff-warning-card, + .topup-carryover-note, .language-select-content, .language-select-item, + .language-select-trigger, .bottom-nav, .bottom-nav button, + .field-error-tooltip, + .admin-card, .admin-card-head, .admin-card-body, + .admin-stat-card, .admin-stat-skeleton-card, .admin-stat-skeleton-wide, + .admin-revenue-panel, .admin-empty, + .admin-tariff-card, .admin-toolbar-card, .admin-table-card, + .admin-panel-dash-card, + .admin-select-trigger, .admin-select-content, .admin-select-item, + .admin-cn-card, .admin-cn-badge, .admin-badge, + .admin-cn-card-skeleton--tall, + .admin-input, .admin-textarea, .admin-btn, .admin-chip, + .admin-tabs-trigger, .admin-tabs-list, + .admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle, + .admin-header, .admin-sidebar, .admin-sidebar-brand, + .admin-dialog, + .admin-theme-editor-section, + [data-slot="card"], [data-slot="card-header"], + [data-slot="card-content"], [data-slot="card-footer"] +), +.theme-key-ascii :is( + .card, .dialog-card, .toast, + .btn, .input, + .admin-card, .admin-card-head, .admin-card-body, + .admin-stat-card, .admin-revenue-panel, .admin-empty, + .admin-tariff-card, .admin-toolbar-card, .admin-table-card, + .admin-panel-dash-card, + .admin-select-trigger, .admin-select-content, + .admin-cn-card, + .admin-input, .admin-textarea, .admin-btn, + .admin-nav-item, .admin-tabs-trigger +) * { + border-radius: 0 !important; +} + +.theme-key-ascii img, +.theme-key-ascii .admin-avatar, +.theme-key-ascii .admin-skeleton-avatar { + border-radius: 0 !important; +} + +/* ============================================================ + * Console-style tables: cell borders, header underline, + * row separator using dashed line. + * ============================================================ */ + +.theme-key-ascii .admin-table, +.theme-key-ascii table { + border-collapse: collapse; + border: 1px solid #ffffff; + font-family: var(--font-mono); +} + +.theme-key-ascii .admin-table th, +.theme-key-ascii .admin-table td, +.theme-key-ascii table th, +.theme-key-ascii table td { + border: 1px solid #ffffff; + border-radius: 0 !important; + padding: 6px 10px; +} + +.theme-key-ascii .admin-table thead th, +.theme-key-ascii table thead th { + background: #000000; + border-bottom: 2px solid #ffffff; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.theme-key-ascii .admin-table tbody tr, +.theme-key-ascii table tbody tr { + border-bottom: 1px solid #ffffff; +} + +.theme-key-ascii .admin-table tbody tr:hover, +.theme-key-ascii table tbody tr:hover { + background: rgba(255, 255, 255, 0.07); +} + +.theme-key-ascii .admin-table tbody tr:hover td, +.theme-key-ascii table tbody tr:hover td { + color: #ffffff; +} diff --git a/bot/app/web/themes/ascii/theme.json b/bot/app/web/themes/ascii/theme.json new file mode 100644 index 0000000..cd89c99 --- /dev/null +++ b/bot/app/web/themes/ascii/theme.json @@ -0,0 +1,17 @@ +{ + "key": "ascii", + "names": { + "ru": "ASCII", + "en": "ASCII" + }, + "enabled": true, + "default": false, + "use_primary_accent": false, + "use_in_admin": true, + "css_file": "style.css", + "assets_version": 1, + "tokens": { + "color_scheme": "dark", + "style_preset": "ascii" + } +} diff --git a/bot/app/web/themes/dark/theme.json b/bot/app/web/themes/dark/theme.json new file mode 100644 index 0000000..d620dcb --- /dev/null +++ b/bot/app/web/themes/dark/theme.json @@ -0,0 +1,27 @@ +{ + "key": "dark", + "names": { + "ru": "ВСмная", + "en": "Dark" + }, + "enabled": true, + "default": true, + "use_primary_accent": true, + "use_in_admin": true, + "assets_version": 1, + "tokens": { + "color_scheme": "dark", + "bg": "#03070b", + "panel": "#111820", + "panel_2": "#0b1118", + "panel_3": "#17212b", + "border": "rgba(255, 255, 255, 0.12)", + "border_strong": "rgba(255, 255, 255, 0.2)", + "text": "#f2f7f4", + "muted": "#a9b4b0", + "dim": "#68736f", + "danger": "#ff6b6b", + "blue": "#2d9cff", + "radius": "8px" + } +} diff --git a/bot/app/web/themes/light/style.css b/bot/app/web/themes/light/style.css new file mode 100644 index 0000000..25f6148 --- /dev/null +++ b/bot/app/web/themes/light/style.css @@ -0,0 +1,131 @@ +.theme-key-light { + color-scheme: light; + --accent: #047857; + --bg: #f7f8fb; + --panel: #ffffff; + --panel-2: #f1f5f9; + --panel-3: #e8edf3; + --border: rgba(15, 23, 42, 0.11); + --border-strong: rgba(15, 23, 42, 0.2); + --text: #0f172a; + --muted: #475569; + --dim: #64748b; + --danger: #dc2626; + --danger-text: #b91c1c; + --danger-soft: color-mix(in srgb, var(--danger) 9%, var(--panel)); + --danger-border: color-mix(in srgb, var(--danger) 34%, var(--border)); + --success: #16a34a; + --success-text: #166534; + --success-soft: color-mix(in srgb, var(--success) 10%, var(--panel)); + --success-border: color-mix(in srgb, var(--success) 34%, var(--border)); + --warning: #d97706; + --warning-text: #92400e; + --warning-soft: color-mix(in srgb, var(--warning) 11%, var(--panel)); + --warning-border: color-mix(in srgb, var(--warning) 34%, var(--border)); + --info: #2563eb; + --info-text: #1d4ed8; + --info-soft: color-mix(in srgb, var(--info) 9%, var(--panel)); + --info-border: color-mix(in srgb, var(--info) 30%, var(--border)); + --blue: #2563eb; + --radius: 8px; + --accent-contrast: #ffffff; + --surface-sheen: rgba(15, 23, 42, 0.035); + --surface-sheen-soft: rgba(15, 23, 42, 0.012); + --surface-hover: rgba(15, 23, 42, 0.045); + --surface-muted: rgba(15, 23, 42, 0.035); + --surface-subtle-border: rgba(15, 23, 42, 0.1); + --overlay-scrim: rgba(15, 23, 42, 0.34); + --nav-bg: rgba(255, 255, 255, 0.88); + --rail-bg: rgba(255, 255, 255, 0.72); + --shadow-soft: 0 6px 18px rgba(15, 23, 42, 0.06); + --shadow-strong: 0 18px 44px rgba(15, 23, 42, 0.12); + --shadow-popover: 0 14px 28px rgba(15, 23, 42, 0.12); + --inset-highlight: rgba(255, 255, 255, 0.75); + --admin-bg: #f7f8fb; + --admin-surface: #ffffff; + --admin-surface-2: #f1f5f9; + --admin-elev: #e8edf3; + --admin-border: rgba(15, 23, 42, 0.1); + --admin-border-strong: rgba(15, 23, 42, 0.18); + --admin-text: #0f172a; + --admin-muted: #64748b; + --admin-dim: #64748b; + --admin-chart-stroke: #065f46; + --admin-chart-fill: rgba(6, 95, 70, 0.22); +} + +.theme-key-light .ui-spinner, +.theme-key-light .brand-mark-spinner { + color: inherit; +} + +.theme-key-light .telegram-button-spinner { + border-color: rgba(255, 255, 255, 0.35); + border-top-color: #ffffff; +} + +.theme-key-light .btn-primary, +.theme-key-light .admin-btn.admin-btn-primary, +.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary { + background: color-mix(in srgb, var(--accent) 50%, #000000); + border-color: color-mix(in srgb, var(--accent) 42%, #000000); + color: #ffffff; +} + +.theme-key-light .btn-primary:hover:not(:disabled), +.theme-key-light .admin-btn.admin-btn-primary:hover:not(:disabled), +.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 52%, #000000); +} + +.theme-key-light.app-shell { + background: var(--bg) !important; +} + +.theme-key-light .phone-screen { + background: var(--bg); +} + +/* Flatten Settings rows: no gradient sheen, no inset highlight that reads as a 3D bevel */ +.theme-key-light .settings-row { + background: var(--panel); + box-shadow: none; +} + +.theme-key-light .settings-row-linked { + background: var(--success-soft); +} + +/* Avatar/profile card: bigger lift, but rows below have an opaque background and + stack above, so the shadow stays visually under them instead of bleeding through. */ +.theme-key-light .settings-profile { + box-shadow: + 0 10px 24px rgba(15, 23, 42, 0.10), + inset 0 1px 0 var(--inset-highlight); +} + +.theme-key-light .settings-links-block { + position: relative; + z-index: 1; +} + +/* Slightly stronger axis/grid contrast for the revenue chart on a light surface */ +.theme-key-light .admin-revenue-svg-frame { + background: #ffffff; +} + +/* Bonus section: drop accent color from body strongs; only the bonus-system heading + and explicitly-accent card headings stay tinted β€” and they use the same darkened + accent technique as .btn-primary on light, so they remain readable on white. */ +.theme-key-light .bonus-card strong { + color: var(--text); +} + +.theme-key-light .bonus-card-head strong, +.theme-key-light .card-heading-accent { + color: color-mix(in srgb, var(--accent) 50%, #000000); +} + +.theme-key-light .bonus-card-head > svg { + color: color-mix(in srgb, var(--accent) 50%, #000000); +} diff --git a/bot/app/web/themes/light/theme.json b/bot/app/web/themes/light/theme.json new file mode 100644 index 0000000..9713516 --- /dev/null +++ b/bot/app/web/themes/light/theme.json @@ -0,0 +1,16 @@ +{ + "key": "light", + "names": { + "ru": "БвСтлая", + "en": "Light" + }, + "enabled": true, + "default": false, + "use_primary_accent": true, + "use_in_admin": true, + "css_file": "style.css", + "assets_version": 2, + "tokens": { + "color_scheme": "light" + } +} diff --git a/bot/app/web/themes/windows95/icons/arrow-left.png b/bot/app/web/themes/windows95/icons/arrow-left.png new file mode 100644 index 0000000..38a3df6 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/arrow-left.png differ diff --git a/bot/app/web/themes/windows95/icons/arrow-right.png b/bot/app/web/themes/windows95/icons/arrow-right.png new file mode 100644 index 0000000..5c1cb20 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/arrow-right.png differ diff --git a/bot/app/web/themes/windows95/icons/bitcoin.png b/bot/app/web/themes/windows95/icons/bitcoin.png new file mode 100644 index 0000000..55de9d3 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/bitcoin.png differ diff --git a/bot/app/web/themes/windows95/icons/check.png b/bot/app/web/themes/windows95/icons/check.png new file mode 100644 index 0000000..78e2094 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/check.png differ diff --git a/bot/app/web/themes/windows95/icons/chevrons.png b/bot/app/web/themes/windows95/icons/chevrons.png new file mode 100644 index 0000000..cb925ce Binary files /dev/null and b/bot/app/web/themes/windows95/icons/chevrons.png differ diff --git a/bot/app/web/themes/windows95/icons/coins.png b/bot/app/web/themes/windows95/icons/coins.png new file mode 100644 index 0000000..0697922 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/coins.png differ diff --git a/bot/app/web/themes/windows95/icons/dashboard.png b/bot/app/web/themes/windows95/icons/dashboard.png new file mode 100644 index 0000000..18f939b Binary files /dev/null and b/bot/app/web/themes/windows95/icons/dashboard.png differ diff --git a/bot/app/web/themes/windows95/icons/database.png b/bot/app/web/themes/windows95/icons/database.png new file mode 100644 index 0000000..698e6d5 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/database.png differ diff --git a/bot/app/web/themes/windows95/icons/download.png b/bot/app/web/themes/windows95/icons/download.png new file mode 100644 index 0000000..5d96a1b Binary files /dev/null and b/bot/app/web/themes/windows95/icons/download.png differ diff --git a/bot/app/web/themes/windows95/icons/error.png b/bot/app/web/themes/windows95/icons/error.png new file mode 100644 index 0000000..5d046a3 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/error.png differ diff --git a/bot/app/web/themes/windows95/icons/file-text.png b/bot/app/web/themes/windows95/icons/file-text.png new file mode 100644 index 0000000..c1ab35e Binary files /dev/null and b/bot/app/web/themes/windows95/icons/file-text.png differ diff --git a/bot/app/web/themes/windows95/icons/folder.png b/bot/app/web/themes/windows95/icons/folder.png new file mode 100644 index 0000000..437efbf Binary files /dev/null and b/bot/app/web/themes/windows95/icons/folder.png differ diff --git a/bot/app/web/themes/windows95/icons/gift.png b/bot/app/web/themes/windows95/icons/gift.png new file mode 100644 index 0000000..0697922 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/gift.png differ diff --git a/bot/app/web/themes/windows95/icons/globe.png b/bot/app/web/themes/windows95/icons/globe.png new file mode 100644 index 0000000..f740cee Binary files /dev/null and b/bot/app/web/themes/windows95/icons/globe.png differ diff --git a/bot/app/web/themes/windows95/icons/help.png b/bot/app/web/themes/windows95/icons/help.png new file mode 100644 index 0000000..1aa4921 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/help.png differ diff --git a/bot/app/web/themes/windows95/icons/home.png b/bot/app/web/themes/windows95/icons/home.png new file mode 100644 index 0000000..a713e61 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/home.png differ diff --git a/bot/app/web/themes/windows95/icons/info.png b/bot/app/web/themes/windows95/icons/info.png new file mode 100644 index 0000000..d540eb7 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/info.png differ diff --git a/bot/app/web/themes/windows95/icons/key.png b/bot/app/web/themes/windows95/icons/key.png new file mode 100644 index 0000000..eb69306 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/key.png differ diff --git a/bot/app/web/themes/windows95/icons/lock.png b/bot/app/web/themes/windows95/icons/lock.png new file mode 100644 index 0000000..224052b Binary files /dev/null and b/bot/app/web/themes/windows95/icons/lock.png differ diff --git a/bot/app/web/themes/windows95/icons/megaphone.png b/bot/app/web/themes/windows95/icons/megaphone.png new file mode 100644 index 0000000..a697102 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/megaphone.png differ diff --git a/bot/app/web/themes/windows95/icons/paintbrush.png b/bot/app/web/themes/windows95/icons/paintbrush.png new file mode 100644 index 0000000..b895700 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/paintbrush.png differ diff --git a/bot/app/web/themes/windows95/icons/payment-card.png b/bot/app/web/themes/windows95/icons/payment-card.png new file mode 100644 index 0000000..a9b7212 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/payment-card.png differ diff --git a/bot/app/web/themes/windows95/icons/print.png b/bot/app/web/themes/windows95/icons/print.png new file mode 100644 index 0000000..04e2839 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/print.png differ diff --git a/bot/app/web/themes/windows95/icons/refresh.png b/bot/app/web/themes/windows95/icons/refresh.png new file mode 100644 index 0000000..be08925 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/refresh.png differ diff --git a/bot/app/web/themes/windows95/icons/save.png b/bot/app/web/themes/windows95/icons/save.png new file mode 100644 index 0000000..2e70ec1 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/save.png differ diff --git a/bot/app/web/themes/windows95/icons/search.png b/bot/app/web/themes/windows95/icons/search.png new file mode 100644 index 0000000..0102a91 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/search.png differ diff --git a/bot/app/web/themes/windows95/icons/send.png b/bot/app/web/themes/windows95/icons/send.png new file mode 100644 index 0000000..67b9ae1 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/send.png differ diff --git a/bot/app/web/themes/windows95/icons/settings.png b/bot/app/web/themes/windows95/icons/settings.png new file mode 100644 index 0000000..0d56f79 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/settings.png differ diff --git a/bot/app/web/themes/windows95/icons/shield.png b/bot/app/web/themes/windows95/icons/shield.png new file mode 100644 index 0000000..8ce794a Binary files /dev/null and b/bot/app/web/themes/windows95/icons/shield.png differ diff --git a/bot/app/web/themes/windows95/icons/sliders.png b/bot/app/web/themes/windows95/icons/sliders.png new file mode 100644 index 0000000..d3badce Binary files /dev/null and b/bot/app/web/themes/windows95/icons/sliders.png differ diff --git a/bot/app/web/themes/windows95/icons/smartphone.png b/bot/app/web/themes/windows95/icons/smartphone.png new file mode 100644 index 0000000..e3b9b22 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/smartphone.png differ diff --git a/bot/app/web/themes/windows95/icons/sparkles.png b/bot/app/web/themes/windows95/icons/sparkles.png new file mode 100644 index 0000000..5f99ecb Binary files /dev/null and b/bot/app/web/themes/windows95/icons/sparkles.png differ diff --git a/bot/app/web/themes/windows95/icons/tag.png b/bot/app/web/themes/windows95/icons/tag.png new file mode 100644 index 0000000..13364e3 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/tag.png differ diff --git a/bot/app/web/themes/windows95/icons/ticket.png b/bot/app/web/themes/windows95/icons/ticket.png new file mode 100644 index 0000000..13364e3 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/ticket.png differ diff --git a/bot/app/web/themes/windows95/icons/trash.png b/bot/app/web/themes/windows95/icons/trash.png new file mode 100644 index 0000000..c4ca801 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/trash.png differ diff --git a/bot/app/web/themes/windows95/icons/user.png b/bot/app/web/themes/windows95/icons/user.png new file mode 100644 index 0000000..fa18aaf Binary files /dev/null and b/bot/app/web/themes/windows95/icons/user.png differ diff --git a/bot/app/web/themes/windows95/icons/users.png b/bot/app/web/themes/windows95/icons/users.png new file mode 100644 index 0000000..6d980f6 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/users.png differ diff --git a/bot/app/web/themes/windows95/icons/warning.png b/bot/app/web/themes/windows95/icons/warning.png new file mode 100644 index 0000000..ca701de Binary files /dev/null and b/bot/app/web/themes/windows95/icons/warning.png differ diff --git a/bot/app/web/themes/windows95/icons/x.png b/bot/app/web/themes/windows95/icons/x.png new file mode 100644 index 0000000..1d69477 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/x.png differ diff --git a/bot/app/web/themes/windows95/style.css b/bot/app/web/themes/windows95/style.css new file mode 100644 index 0000000..d5aaede --- /dev/null +++ b/bot/app/web/themes/windows95/style.css @@ -0,0 +1,1043 @@ +@import url("https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap&subset=cyrillic"); + +.theme-key-windows95 { + color-scheme: light; + image-rendering: pixelated; + --accent: #000080; + --bg: #008080; + --panel: #c0c0c0; + --panel-2: #dfdfdf; + --panel-3: #808080; + --border: #808080; + --border-strong: #000000; + --text: #000000; + --muted: #202020; + --dim: #404040; + --danger: #800000; + --blue: #000080; + --radius: 0px; + --font-sans: "MS Sans Serif", Tahoma, "Segoe UI", sans-serif; + --font-logo: "Press Start 2P", "Pixel Cyr", "MS Sans Serif", Tahoma, "Segoe UI", sans-serif; + --font-mono: Consolas, "Courier New", monospace; + --accent-contrast: #ffffff; + --surface-sheen: transparent; + --surface-sheen-soft: transparent; + --surface-hover: rgba(0, 0, 128, 0.14); + --surface-muted: #c0c0c0; + --surface-subtle-border: #808080; + --overlay-scrim: rgba(0, 0, 0, 0.35); + --nav-bg: #c0c0c0; + --rail-bg: #c0c0c0; + --shadow-soft: 2px 2px 0 #000000; + --shadow-strong: 3px 3px 0 #000000; + --shadow-popover: 2px 2px 0 #000000; + --inset-highlight: #ffffff; + --admin-bg: #008080; + --admin-surface: #c0c0c0; + --admin-surface-2: #dfdfdf; + --admin-elev: #808080; + --admin-border: #808080; + --admin-border-strong: #000000; + --admin-text: #000000; + --admin-muted: #202020; + --admin-dim: #404040; + --win95-icon-arrow-left: url("/webapp-theme-assets/windows95/icons/arrow-left.png?v=6"); + --win95-icon-arrow-right: url("/webapp-theme-assets/windows95/icons/arrow-right.png?v=6"); + --win95-icon-bitcoin: url("/webapp-theme-assets/windows95/icons/bitcoin.png?v=6"); + --win95-icon-check: url("/webapp-theme-assets/windows95/icons/check.png?v=6"); + --win95-icon-chevrons: url("/webapp-theme-assets/windows95/icons/chevrons.png?v=6"); + --win95-icon-coins: url("/webapp-theme-assets/windows95/icons/coins.png?v=6"); + --win95-icon-dashboard: url("/webapp-theme-assets/windows95/icons/dashboard.png?v=6"); + --win95-icon-database: url("/webapp-theme-assets/windows95/icons/database.png?v=6"); + --win95-icon-download: url("/webapp-theme-assets/windows95/icons/download.png?v=6"); + --win95-icon-error: url("/webapp-theme-assets/windows95/icons/error.png?v=6"); + --win95-icon-file-text: url("/webapp-theme-assets/windows95/icons/file-text.png?v=6"); + --win95-icon-folder: url("/webapp-theme-assets/windows95/icons/folder.png?v=6"); + --win95-icon-gift: url("/webapp-theme-assets/windows95/icons/gift.png?v=6"); + --win95-icon-globe: url("/webapp-theme-assets/windows95/icons/globe.png?v=6"); + --win95-icon-help: url("/webapp-theme-assets/windows95/icons/help.png?v=6"); + --win95-icon-home: url("/webapp-theme-assets/windows95/icons/home.png?v=6"); + --win95-icon-info: url("/webapp-theme-assets/windows95/icons/info.png?v=6"); + --win95-icon-key: url("/webapp-theme-assets/windows95/icons/key.png?v=6"); + --win95-icon-lock: url("/webapp-theme-assets/windows95/icons/lock.png?v=6"); + --win95-icon-megaphone: url("/webapp-theme-assets/windows95/icons/megaphone.png?v=6"); + --win95-icon-paintbrush: url("/webapp-theme-assets/windows95/icons/paintbrush.png?v=6"); + --win95-icon-payment-card: url("/webapp-theme-assets/windows95/icons/payment-card.png?v=6"); + --win95-icon-print: url("/webapp-theme-assets/windows95/icons/print.png?v=6"); + --win95-icon-refresh: url("/webapp-theme-assets/windows95/icons/refresh.png?v=6"); + --win95-icon-save: url("/webapp-theme-assets/windows95/icons/save.png?v=6"); + --win95-icon-search: url("/webapp-theme-assets/windows95/icons/search.png?v=6"); + --win95-icon-send: url("/webapp-theme-assets/windows95/icons/send.png?v=6"); + --win95-icon-settings: url("/webapp-theme-assets/windows95/icons/settings.png?v=6"); + --win95-icon-shield: url("/webapp-theme-assets/windows95/icons/shield.png?v=6"); + --win95-icon-smartphone: url("/webapp-theme-assets/windows95/icons/smartphone.png?v=6"); + --win95-icon-sliders: url("/webapp-theme-assets/windows95/icons/sliders.png?v=6"); + --win95-icon-sparkles: url("/webapp-theme-assets/windows95/icons/sparkles.png?v=6"); + --win95-icon-tag: url("/webapp-theme-assets/windows95/icons/tag.png?v=6"); + --win95-icon-ticket: url("/webapp-theme-assets/windows95/icons/ticket.png?v=6"); + --win95-icon-trash: url("/webapp-theme-assets/windows95/icons/trash.png?v=6"); + --win95-icon-user: url("/webapp-theme-assets/windows95/icons/user.png?v=6"); + --win95-icon-users: url("/webapp-theme-assets/windows95/icons/users.png?v=6"); + --win95-icon-warning: url("/webapp-theme-assets/windows95/icons/warning.png?v=6"); + --win95-icon-x: url("/webapp-theme-assets/windows95/icons/x.png?v=6"); +} + +.theme-key-windows95.app-shell { + background: var(--bg) !important; +} + +.theme-key-windows95 *, +.theme-key-windows95 *::before, +.theme-key-windows95 *::after { + border-radius: 0 !important; +} + +.theme-key-windows95 svg.lucide-arrow-left, +.theme-key-windows95 svg.lucide-arrow-right, +.theme-key-windows95 svg.lucide-bitcoin, +.theme-key-windows95 svg.lucide-check, +.theme-key-windows95 svg.lucide-check-circle-2, +.theme-key-windows95 svg.lucide-circle-check, +.theme-key-windows95 svg.lucide-circle-check-big, +.theme-key-windows95 svg.lucide-chevrons-up-down, +.theme-key-windows95 svg.lucide-circle-x, +.theme-key-windows95 svg.lucide-coins, +.theme-key-windows95 svg.lucide-copy, +.theme-key-windows95 svg.lucide-credit-card, +.theme-key-windows95 svg.lucide-database, +.theme-key-windows95 svg.lucide-download, +.theme-key-windows95 svg.lucide-earth, +.theme-key-windows95 svg.lucide-external-link, +.theme-key-windows95 svg.lucide-file-text, +.theme-key-windows95 svg.lucide-gift, +.theme-key-windows95 svg.lucide-globe-2, +.theme-key-windows95 svg.lucide-home, +.theme-key-windows95 svg.lucide-house, +.theme-key-windows95 svg.lucide-info, +.theme-key-windows95 svg.lucide-key, +.theme-key-windows95 svg.lucide-layout-dashboard, +.theme-key-windows95 svg.lucide-lock-keyhole, +.theme-key-windows95 svg.lucide-mail, +.theme-key-windows95 svg.lucide-megaphone, +.theme-key-windows95 svg.lucide-message-square, +.theme-key-windows95 svg.lucide-paintbrush, +.theme-key-windows95 svg.lucide-plus, +.theme-key-windows95 svg.lucide-refresh-cw, +.theme-key-windows95 svg.lucide-save, +.theme-key-windows95 svg.lucide-search, +.theme-key-windows95 svg.lucide-send, +.theme-key-windows95 svg.lucide-settings, +.theme-key-windows95 svg.lucide-shield, +.theme-key-windows95 svg.lucide-sliders, +.theme-key-windows95 svg.lucide-smartphone, +.theme-key-windows95 svg.lucide-sparkles, +.theme-key-windows95 svg.lucide-tag, +.theme-key-windows95 svg.lucide-ticket, +.theme-key-windows95 svg.lucide-trash-2, +.theme-key-windows95 svg.lucide-triangle-alert, +.theme-key-windows95 svg.lucide-user, +.theme-key-windows95 svg.lucide-user-round, +.theme-key-windows95 svg.lucide-users, +.theme-key-windows95 svg.lucide-users-round, +.theme-key-windows95 svg.lucide-wallet-cards, +.theme-key-windows95 svg.lucide-x { + flex: 0 0 auto; + width: 22px !important; + height: 22px !important; + color: transparent !important; + stroke: transparent !important; + fill: transparent !important; + background-image: var(--win95-button-icon); + background-position: center; + background-repeat: no-repeat; + background-size: contain; + image-rendering: pixelated; + filter: none !important; +} + +.theme-key-windows95 svg.lucide-arrow-left { + --win95-button-icon: var(--win95-icon-arrow-left); +} + +.theme-key-windows95 svg.lucide-arrow-right { + --win95-button-icon: var(--win95-icon-arrow-right); +} + +.theme-key-windows95 svg.lucide-bitcoin { + --win95-button-icon: var(--win95-icon-bitcoin); +} + +.theme-key-windows95 svg.lucide-check, +.theme-key-windows95 svg.lucide-check-circle-2, +.theme-key-windows95 svg.lucide-circle-check, +.theme-key-windows95 svg.lucide-circle-check-big { + --win95-button-icon: var(--win95-icon-check); +} + +.theme-key-windows95 svg.lucide-chevrons-up-down { + --win95-button-icon: var(--win95-icon-chevrons); +} + +.theme-key-windows95 svg.lucide-coins { + --win95-button-icon: var(--win95-icon-coins); +} + +.theme-key-windows95 svg.lucide-circle-x, +.theme-key-windows95 svg.lucide-x { + --win95-button-icon: var(--win95-icon-x); +} + +.theme-key-windows95 svg.lucide-copy { + --win95-button-icon: var(--win95-icon-file-text); +} + +.theme-key-windows95 svg.lucide-credit-card, +.theme-key-windows95 svg.lucide-wallet-cards { + --win95-button-icon: var(--win95-icon-payment-card); +} + +.theme-key-windows95 svg.lucide-database { + --win95-button-icon: var(--win95-icon-database); +} + +.theme-key-windows95 svg.lucide-download { + --win95-button-icon: var(--win95-icon-download); +} + +.theme-key-windows95 svg.lucide-earth, +.theme-key-windows95 svg.lucide-external-link, +.theme-key-windows95 svg.lucide-globe-2 { + --win95-button-icon: var(--win95-icon-globe); +} + +.theme-key-windows95 svg.lucide-file-text { + --win95-button-icon: var(--win95-icon-file-text); +} + +.theme-key-windows95 svg.lucide-gift { + --win95-button-icon: var(--win95-icon-gift); +} + +.theme-key-windows95 svg.lucide-home, +.theme-key-windows95 svg.lucide-house { + --win95-button-icon: var(--win95-icon-home); +} + +.theme-key-windows95 svg.lucide-info { + --win95-button-icon: var(--win95-icon-info); +} + +.theme-key-windows95 svg.lucide-key { + --win95-button-icon: var(--win95-icon-key); +} + +.theme-key-windows95 svg.lucide-layout-dashboard { + --win95-button-icon: var(--win95-icon-dashboard); +} + +.theme-key-windows95 svg.lucide-lock-keyhole { + --win95-button-icon: var(--win95-icon-lock); +} + +.theme-key-windows95 svg.lucide-megaphone { + --win95-button-icon: var(--win95-icon-megaphone); +} + +.theme-key-windows95 svg.lucide-mail, +.theme-key-windows95 svg.lucide-message-square, +.theme-key-windows95 svg.lucide-send { + --win95-button-icon: var(--win95-icon-send); +} + +.theme-key-windows95 svg.lucide-paintbrush { + --win95-button-icon: var(--win95-icon-paintbrush); +} + +.theme-key-windows95 svg.lucide-plus { + --win95-button-icon: var(--win95-icon-folder); +} + +.theme-key-windows95 svg.lucide-refresh-cw { + --win95-button-icon: var(--win95-icon-refresh); +} + +.theme-key-windows95 svg.lucide-save { + --win95-button-icon: var(--win95-icon-save); +} + +.theme-key-windows95 svg.lucide-search { + --win95-button-icon: var(--win95-icon-search); +} + +.theme-key-windows95 svg.lucide-settings { + --win95-button-icon: var(--win95-icon-settings); +} + +.theme-key-windows95 svg.lucide-shield { + --win95-button-icon: var(--win95-icon-shield); +} + +.theme-key-windows95 svg.lucide-sliders { + --win95-button-icon: var(--win95-icon-sliders); +} + +.theme-key-windows95 svg.lucide-smartphone { + --win95-button-icon: var(--win95-icon-smartphone); +} + +.theme-key-windows95 svg.lucide-sparkles { + --win95-button-icon: var(--win95-icon-sparkles); +} + +.theme-key-windows95 svg.lucide-tag { + --win95-button-icon: var(--win95-icon-tag); +} + +.theme-key-windows95 svg.lucide-ticket { + --win95-button-icon: var(--win95-icon-ticket); +} + +.theme-key-windows95 svg.lucide-trash-2 { + --win95-button-icon: var(--win95-icon-trash); +} + +.theme-key-windows95 svg.lucide-triangle-alert { + --win95-button-icon: var(--win95-icon-warning); +} + +.theme-key-windows95 svg.lucide-user, +.theme-key-windows95 svg.lucide-user-round { + --win95-button-icon: var(--win95-icon-user); +} + +.theme-key-windows95 svg.lucide-users, +.theme-key-windows95 svg.lucide-users-round { + --win95-button-icon: var(--win95-icon-users); +} + +.theme-key-windows95 :is( + svg.lucide-arrow-left, + svg.lucide-arrow-right, + svg.lucide-bitcoin, + svg.lucide-check, + svg.lucide-check-circle-2, + svg.lucide-chevrons-up-down, + svg.lucide-circle-check, + svg.lucide-circle-check-big, + svg.lucide-circle-x, + svg.lucide-coins, + svg.lucide-copy, + svg.lucide-credit-card, + svg.lucide-database, + svg.lucide-download, + svg.lucide-earth, + svg.lucide-external-link, + svg.lucide-file-text, + svg.lucide-gift, + svg.lucide-globe-2, + svg.lucide-home, + svg.lucide-house, + svg.lucide-info, + svg.lucide-key, + svg.lucide-layout-dashboard, + svg.lucide-lock-keyhole, + svg.lucide-mail, + svg.lucide-megaphone, + svg.lucide-message-square, + svg.lucide-paintbrush, + svg.lucide-plus, + svg.lucide-refresh-cw, + svg.lucide-save, + svg.lucide-search, + svg.lucide-send, + svg.lucide-settings, + svg.lucide-shield, + svg.lucide-sliders, + svg.lucide-smartphone, + svg.lucide-sparkles, + svg.lucide-tag, + svg.lucide-ticket, + svg.lucide-trash-2, + svg.lucide-triangle-alert, + svg.lucide-user, + svg.lucide-user-round, + svg.lucide-users, + svg.lucide-users-round, + svg.lucide-wallet-cards, + svg.lucide-x +) *, +.theme-key-windows95 :is( + svg.lucide-arrow-left, + svg.lucide-arrow-right, + svg.lucide-bitcoin, + svg.lucide-check, + svg.lucide-check-circle-2, + svg.lucide-chevrons-up-down, + svg.lucide-circle-check, + svg.lucide-circle-check-big, + svg.lucide-circle-x, + svg.lucide-coins, + svg.lucide-copy, + svg.lucide-credit-card, + svg.lucide-database, + svg.lucide-download, + svg.lucide-earth, + svg.lucide-external-link, + svg.lucide-file-text, + svg.lucide-gift, + svg.lucide-globe-2, + svg.lucide-home, + svg.lucide-house, + svg.lucide-info, + svg.lucide-key, + svg.lucide-layout-dashboard, + svg.lucide-lock-keyhole, + svg.lucide-mail, + svg.lucide-megaphone, + svg.lucide-message-square, + svg.lucide-paintbrush, + svg.lucide-plus, + svg.lucide-refresh-cw, + svg.lucide-save, + svg.lucide-search, + svg.lucide-send, + svg.lucide-settings, + svg.lucide-shield, + svg.lucide-sliders, + svg.lucide-smartphone, + svg.lucide-sparkles, + svg.lucide-tag, + svg.lucide-ticket, + svg.lucide-trash-2, + svg.lucide-triangle-alert, + svg.lucide-user, + svg.lucide-user-round, + svg.lucide-users, + svg.lucide-users-round, + svg.lucide-wallet-cards, + svg.lucide-x +) { + color: transparent !important; + stroke: transparent !important; + fill: transparent !important; +} + +.theme-key-windows95, +.theme-key-windows95 button, +.theme-key-windows95 input, +.theme-key-windows95 textarea { + font-synthesis: none; +} + +.theme-key-windows95 .card, +.theme-key-windows95 .period-card, +.theme-key-windows95 .method-card, +.theme-key-windows95 .settings-row, +.theme-key-windows95 .option-row, +.theme-key-windows95 .tariff-selected-card, +.theme-key-windows95 .tariff-action-card, +.theme-key-windows95 .tariff-warning-card, +.theme-key-windows95 .topup-carryover-note, +.theme-key-windows95 .input, +.theme-key-windows95 .dialog-card, +.theme-key-windows95 .language-select-content, +.theme-key-windows95 .bottom-nav, +.theme-key-windows95 .toast { + border-width: 2px; + border-style: solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0; + background: var(--panel); + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 .dialog-card, +.theme-key-windows95 .language-select-content, +.theme-key-windows95 .toast { + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080, + 2px 2px 0 #000000; +} + +body:has(.theme-key-windows95) .language-select-content, +body:has(.theme-key-windows95) .admin-select-content { + border-width: 2px; + border-style: solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0 !important; + background: #c0c0c0; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080, + 2px 2px 0 #000000; +} + +body:has(.theme-key-windows95) .language-select-item, +body:has(.theme-key-windows95) .admin-select-item { + border-radius: 0 !important; +} + +.theme-key-windows95 .card::before, +.theme-key-windows95 .dialog-card::before { + content: ""; + display: block; + height: 18px; + margin: -14px -14px 12px; + background: linear-gradient(90deg, var(--accent), #1084d0); + border-bottom: 2px solid #000000; +} + +.theme-key-windows95 .dialog-card::before { + margin: -18px -18px 14px; +} + +.theme-key-windows95 .btn, +.theme-key-windows95 .language-select-trigger, +.theme-key-windows95 .bottom-nav button { + min-height: 34px; + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0; + background: var(--panel); + color: var(--text); + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; + transform: none; +} + +@media (min-width: 1024px) { + .theme-key-windows95 .bottom-nav { + border-right: 2px solid #404040 !important; + background: var(--rail-bg) !important; + backdrop-filter: none !important; + box-shadow: + inset 1px 1px 0 #ffffff, + inset -1px -1px 0 #808080 !important; + } + + .theme-key-windows95 .bottom-nav button { + min-height: 34px !important; + border: 2px solid !important; + border-color: #ffffff #404040 #404040 #ffffff !important; + border-radius: 0 !important; + background: var(--panel) !important; + color: var(--text) !important; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080 !important; + } + + .theme-key-windows95 .bottom-nav button.active { + background: var(--accent) !important; + color: #ffffff !important; + border-color: #ffffff #404040 #404040 #ffffff !important; + } + + .theme-key-windows95 .bottom-nav button:active { + border-color: #404040 #ffffff #ffffff #404040 !important; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf !important; + } +} + +.theme-key-windows95 .btn:active:not(:disabled), +.theme-key-windows95 .bottom-nav button:active { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 .btn-primary, +.theme-key-windows95 .bottom-nav button.active, +.theme-key-windows95 .period-card.active, +.theme-key-windows95 .method-card.active, +.theme-key-windows95 .option-row.active { + background: var(--accent); + color: #ffffff; +} + +.theme-key-windows95 .card-heading-accent, +.theme-key-windows95 .brand-row strong, +.theme-key-windows95 .login-brand h1, +.theme-key-windows95 .bottom-nav button.active { + color: var(--accent); +} + +.theme-key-windows95 .traffic-top strong { + color: var(--muted); +} + +.theme-key-windows95 .btn-primary, +.theme-key-windows95 .bottom-nav button.active { + color: #ffffff; +} + +.theme-key-windows95 .progress { + height: 8px; + margin-top: 12px; + overflow: hidden; + border: 0; + border-radius: 999px !important; + background: color-mix(in srgb, var(--muted) 18%, transparent); +} + +.theme-key-windows95 .progress span { + border-radius: inherit !important; + background: linear-gradient(90deg, var(--accent), #1084d0); + box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 30%, transparent); +} + +.theme-key-windows95 .language-select-item { + border-radius: 0; +} + +.theme-key-windows95 .settings-profile { + padding-left: 14px; +} + +.theme-key-windows95 .settings-profile-meta strong { + color: var(--accent); +} + +.theme-key-windows95 .language-select-copy { + grid-template-columns: minmax(0, auto) minmax(0, 1fr); + align-items: center; + gap: 8px; +} + +.theme-key-windows95 .language-select-copy strong, +.theme-key-windows95 .language-select-current { + min-width: 0; + white-space: nowrap; +} + +.theme-key-windows95 .language-select-current { + overflow: hidden; + text-overflow: ellipsis; +} + +.theme-key-windows95 .language-select-item[data-highlighted], +.theme-key-windows95 .language-select-item[data-selected] { + background: var(--accent); + color: #ffffff !important; +} + +.theme-key-windows95 .field-error-tooltip { + border-radius: 0; +} + +.theme-key-windows95 .admin-screen-wrap { + background: var(--admin-bg); +} + +.theme-key-windows95 .admin-sidebar, +.theme-key-windows95 .admin-header, +.theme-key-windows95 .admin-card, +.theme-key-windows95 .admin-stat-card, +.theme-key-windows95 .admin-revenue-panel, +.theme-key-windows95 .admin-empty, +.theme-key-windows95 .admin-tariff-card, +.theme-key-windows95 .admin-toolbar-card, +.theme-key-windows95 .admin-table-card, +.theme-key-windows95 .admin-panel-dash-card, +.theme-key-windows95 .admin-select-trigger, +.theme-key-windows95 .admin-select-content, +.theme-key-windows95 .admin-cn-card[data-slot="card"], +.theme-key-windows95 .admin-dialog .dialog-card, +.theme-key-windows95 .admin-theme-editor-section { + border-width: 2px; + border-style: solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0; + background: var(--admin-surface); + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 .admin-header, +.theme-key-windows95 .admin-sidebar-brand, +.theme-key-windows95 .admin-card-head { + border-bottom: 2px solid #000000; +} + +.theme-key-windows95 .admin-header { + background: linear-gradient(90deg, var(--accent), #1084d0); + color: #ffffff; +} + +.theme-key-windows95 .admin-header-title h2, +.theme-key-windows95 .admin-header-title small { + color: #ffffff; +} + +.theme-key-windows95 .admin-nav-item, +.theme-key-windows95 .admin-btn, +.theme-key-windows95 .admin-chip, +.theme-key-windows95 .admin-tabs-trigger, +.theme-key-windows95 .admin-revenue-period-btn, +.theme-key-windows95 .admin-mobile-toggle, +.theme-key-windows95 .admin-language-trigger { + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0; + background: var(--admin-surface); + color: var(--admin-text); + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 .admin-language-switch { + grid-template-columns: 18px minmax(0, 1fr); +} + +.theme-key-windows95 .admin-language-trigger { + min-height: 34px; + height: auto; +} + +.theme-key-windows95 .admin-language-trigger > span { + display: grid; + grid-template-columns: minmax(0, auto) minmax(0, 1fr); + align-items: center; + gap: 8px; +} + +.theme-key-windows95 .admin-language-trigger strong, +.theme-key-windows95 .admin-language-trigger small { + min-width: 0; + margin: 0; + white-space: nowrap; +} + +.theme-key-windows95 .admin-language-trigger small { + display: inline-flex; + align-items: center; + gap: 5px; + overflow: hidden; + text-overflow: ellipsis; +} + +.theme-key-windows95 .admin-nav-item:hover, +.theme-key-windows95 .admin-select-item[data-highlighted], +.theme-key-windows95 .admin-select-item:hover, +.theme-key-windows95 .admin-table tbody tr:hover { + background: color-mix(in srgb, var(--accent) 20%, var(--admin-surface)); +} + +.theme-key-windows95 .admin-nav-item.active, +.theme-key-windows95 .admin-btn-primary, +.theme-key-windows95 .admin-tabs-trigger[data-state="active"], +.theme-key-windows95 .admin-revenue-period-btn.is-active { + background: var(--accent); + color: #ffffff; + border-color: #ffffff #404040 #404040 #ffffff; +} + +.theme-key-windows95 .admin-input, +.theme-key-windows95 .admin-textarea, +.theme-key-windows95 .admin-screen-wrap textarea, +.theme-key-windows95 .admin-dialog textarea, +.theme-key-windows95 .input, +.theme-key-windows95 input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), +.theme-key-windows95 textarea, +.theme-key-windows95 select { + min-height: 28px; + padding: 3px 6px; + border: 2px solid; + border-color: #404040 #ffffff #ffffff #404040; + border-radius: 0; + background: #ffffff; + color: #000000; + font: 13px "MS Sans Serif", Tahoma, "Segoe UI", sans-serif; + line-height: 1.35; + box-shadow: + inset 1px 1px 0 #000000, + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; + transition: none; +} + +.theme-key-windows95 input[type="color"] { + min-height: 28px; + padding: 2px; +} + +.theme-key-windows95 input[type="file"] { + background: var(--panel); +} + +.theme-key-windows95 input[type="range"] { + width: 100%; + height: 22px; + padding: 0; + border: 0; + background: transparent; + accent-color: var(--accent); + cursor: pointer; + -webkit-appearance: none; + appearance: none; +} + +.theme-key-windows95 input[type="range"]::-webkit-slider-runnable-track { + height: 6px; + border: 2px solid; + border-color: #404040 #ffffff #ffffff #404040; + border-radius: 0 !important; + background: #ffffff; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 input[type="range"]::-webkit-slider-thumb { + width: 14px; + height: 20px; + margin-top: -9px; + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0 !important; + background: #c0c0c0; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; + -webkit-appearance: none; + appearance: none; +} + +.theme-key-windows95 input[type="range"]:active::-webkit-slider-thumb { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 input[type="range"]::-moz-range-track { + height: 6px; + border: 2px solid; + border-color: #404040 #ffffff #ffffff #404040; + border-radius: 0 !important; + background: #ffffff; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 input[type="range"]::-moz-range-progress { + height: 6px; + border-radius: 0 !important; + background: var(--accent); +} + +.theme-key-windows95 input[type="range"]::-moz-range-thumb { + width: 14px; + height: 20px; + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0 !important; + background: #c0c0c0; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 input[type="range"]:active::-moz-range-thumb { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 input[type="range"]:focus-visible { + outline: 1px dotted #000000; + outline-offset: 2px; +} + +.theme-key-windows95 input[type="range"]:disabled { + cursor: not-allowed; + opacity: 0.52; +} + +.theme-key-windows95 input::placeholder, +.theme-key-windows95 textarea::placeholder, +.theme-key-windows95 .input::placeholder { + color: #808080; +} + +.theme-key-windows95 .admin-cn-badge, +.theme-key-windows95 .admin-badge { + border-radius: 0; +} + +.theme-key-windows95 ::selection { + background: var(--accent); + color: #ffffff; +} + +.theme-key-windows95 .btn:focus-visible, +.theme-key-windows95 .admin-btn:focus-visible, +.theme-key-windows95 .admin-nav-item:focus-visible, +.theme-key-windows95 .admin-tabs-trigger:focus-visible, +.theme-key-windows95 .admin-revenue-period-btn:focus-visible, +.theme-key-windows95 .admin-mobile-toggle:focus-visible, +.theme-key-windows95 .language-select-trigger:focus-visible, +.theme-key-windows95 .bottom-nav button:focus-visible { + outline: 1px dotted #000000; + outline-offset: -4px; +} + +.theme-key-windows95 .admin-input:focus, +.theme-key-windows95 .admin-textarea:focus, +.theme-key-windows95 .admin-screen-wrap textarea:focus, +.theme-key-windows95 .admin-dialog textarea:focus, +.theme-key-windows95 .input:focus, +.theme-key-windows95 input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):focus, +.theme-key-windows95 textarea:focus, +.theme-key-windows95 select:focus { + outline: none; + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #000000, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 .admin-header-title h2, +.theme-key-windows95 .admin-sidebar-brand strong, +.theme-key-windows95 .admin-card-head h3, +.theme-key-windows95 .admin-card-head h2, +.theme-key-windows95 .login-brand h1, +.theme-key-windows95 .brand-row strong { + font-weight: 700; + letter-spacing: 0; +} + +.theme-key-windows95 .admin-nav-item:hover, +.theme-key-windows95 .admin-select-item[data-highlighted], +.theme-key-windows95 .admin-select-item:hover { + background: color-mix(in srgb, var(--accent) 30%, var(--admin-surface)); +} + +.theme-key-windows95 .admin-nav-item.active, +.theme-key-windows95 .admin-tabs-trigger[data-state="active"], +.theme-key-windows95 .admin-revenue-period-btn.is-active, +.theme-key-windows95 .admin-btn:active:not(:disabled), +.theme-key-windows95 .admin-btn[aria-pressed="true"] { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #000000, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 .btn-primary svg.lucide, +.theme-key-windows95 .bottom-nav button.active svg.lucide, +.theme-key-windows95 .period-card.active svg.lucide, +.theme-key-windows95 .method-card.active svg.lucide, +.theme-key-windows95 .option-row.active svg.lucide, +.theme-key-windows95 .admin-btn-primary svg.lucide, +.theme-key-windows95 .admin-nav-item.active svg.lucide, +.theme-key-windows95 .admin-tabs-trigger[data-state="active"] svg.lucide, +.theme-key-windows95 .admin-revenue-period-btn.is-active svg.lucide, +.theme-key-windows95 .admin-header svg.lucide { + filter: brightness(0) invert(1); +} + +.theme-key-windows95 svg.lucide-activity, +.theme-key-windows95 svg.lucide-calendar-days, +.theme-key-windows95 svg.lucide-chevron-down, +.theme-key-windows95 svg.lucide-chevron-left, +.theme-key-windows95 svg.lucide-chevron-right, +.theme-key-windows95 svg.lucide-chevron-up, +.theme-key-windows95 svg.lucide-circle, +.theme-key-windows95 svg.lucide-crown, +.theme-key-windows95 svg.lucide-eye, +.theme-key-windows95 svg.lucide-eye-off, +.theme-key-windows95 svg.lucide-map, +.theme-key-windows95 svg.lucide-menu, +.theme-key-windows95 svg.lucide-mouse-pointer-click, +.theme-key-windows95 svg.lucide-qr-code, +.theme-key-windows95 svg.lucide-radio, +.theme-key-windows95 svg.lucide-repeat-2, +.theme-key-windows95 svg.lucide-server, +.theme-key-windows95 svg.lucide-trending-down, +.theme-key-windows95 svg.lucide-trending-up, +.theme-key-windows95 svg.lucide-user-minus, +.theme-key-windows95 svg.lucide-user-plus, +.theme-key-windows95 svg.lucide-zap { + color: var(--text); + stroke-width: 2.5; +} + +.theme-key-windows95 ::-webkit-scrollbar { + width: 16px; + height: 16px; +} + +.theme-key-windows95 ::-webkit-scrollbar-track { + background-color: #c0c0c0; + background-image: + linear-gradient(45deg, #ffffff 25%, transparent 25%, transparent 75%, #ffffff 75%), + linear-gradient(45deg, #ffffff 25%, transparent 25%, transparent 75%, #ffffff 75%); + background-size: 4px 4px; + background-position: 0 0, 2px 2px; +} + +.theme-key-windows95 ::-webkit-scrollbar-thumb { + background: var(--panel); + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 ::-webkit-scrollbar-thumb:active { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 ::-webkit-scrollbar-corner { + background: var(--panel); +} + +.theme-key-windows95 .ui-spinner, +.theme-key-windows95 .telegram-button-spinner { + border-radius: 999px !important; + color: var(--accent); +} + +.theme-key-windows95 .card::before, +.theme-key-windows95 .dialog-card::before { + height: 20px; +} + +.theme-key-windows95 .admin-table tbody tr { + border-bottom: 1px solid #808080; +} + +.theme-key-windows95 .admin-badge, +.theme-key-windows95 .admin-cn-badge { + border: 1px solid #000000; + background: var(--panel); + color: var(--text); + font-weight: 700; +} + +.theme-key-windows95 .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item), +.theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]) { + color: var(--accent); + text-decoration: underline; +} + +.theme-key-windows95 .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item):visited, +.theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):visited { + color: #800080; +} diff --git a/bot/app/web/themes/windows95/theme.json b/bot/app/web/themes/windows95/theme.json new file mode 100644 index 0000000..73583f0 --- /dev/null +++ b/bot/app/web/themes/windows95/theme.json @@ -0,0 +1,17 @@ +{ + "key": "windows95", + "names": { + "ru": "Windows 95", + "en": "Windows 95" + }, + "enabled": true, + "default": false, + "use_primary_accent": false, + "use_in_admin": true, + "css_file": "style.css", + "assets_version": 6, + "tokens": { + "color_scheme": "light", + "style_preset": "win95" + } +} diff --git a/bot/app/web/webapp/_runtime.py b/bot/app/web/webapp/_runtime.py index e9b4ee0..a148340 100644 --- a/bot/app/web/webapp/_runtime.py +++ b/bot/app/web/webapp/_runtime.py @@ -2,6 +2,7 @@ import asyncio import base64 import hashlib +import html import hmac import io import ipaddress @@ -17,7 +18,7 @@ from collections import deque from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit from aiogram import Bot, Dispatcher from aiogram.types import LabeledPrice @@ -65,6 +66,10 @@ TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscriptio ASSET_DIR = TEMPLATE_PATH.parent WEBAPP_LOGO_PROXY_PATH = "/webapp-logo" WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" +WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads" +WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" +WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons" +WEBAPP_FAVICON_PATH = "/webapp-favicon" WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji" WEBAPP_CONFIG_PLACEHOLDER = "" WEBAPP_I18N_PLACEHOLDER = "" @@ -76,6 +81,17 @@ WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60 WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30 WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024 +WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024 +WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024 +WEBAPP_THEME_ASSET_CONTENT_TYPES = { + ".gif": "image/gif", + ".ico": "image/x-icon", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +} WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024 WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60 WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4 diff --git a/bot/app/web/webapp/assets.py b/bot/app/web/webapp/assets.py index f879a30..ca3beee 100644 --- a/bot/app/web/webapp/assets.py +++ b/bot/app/web/webapp/assets.py @@ -1,6 +1,14 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from config.webapp_themes_config import ( + default_webapp_theme_asset_file, + default_webapp_theme_css_files, + ensure_default_webapp_theme_descriptor_files, + public_theme_payload, + public_themes_catalog_payload, +) + async def health_route(request: web.Request) -> web.Response: return web.json_response({"ok": True}) @@ -10,7 +18,120 @@ async def css_asset_route(request: web.Request) -> web.Response: return await _serve_template_asset(request, "subscription_webapp.css", "text/css") +def _safe_theme_css_relative_path(raw_path: str) -> Optional[Path]: + return _safe_theme_relative_path(raw_path, allowed_suffixes={".css"}, max_length=180) + + +def _safe_theme_asset_relative_path(raw_path: str) -> Optional[Path]: + return _safe_theme_relative_path( + raw_path, + allowed_suffixes=set(WEBAPP_THEME_ASSET_CONTENT_TYPES), + max_length=220, + ) + + +def _safe_theme_relative_path( + raw_path: str, + *, + allowed_suffixes: set[str], + max_length: int, +) -> Optional[Path]: + value = str(raw_path or "").replace("\\", "/").strip().lstrip("/") + if not value or len(value) > max_length or "\x00" in value: + return None + parts = [part for part in value.split("/") if part] + if len(parts) < 2 or any(part in {".", ".."} for part in parts): + return None + if any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", part) for part in parts): + return None + rel_path = Path(*parts) + if rel_path.suffix.lower() not in allowed_suffixes: + return None + return rel_path + + +async def theme_css_asset_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + ensure_default_webapp_theme_descriptor_files(settings.WEBAPP_THEMES_DIR) + rel_path = _safe_theme_css_relative_path(request.match_info.get("path") or "") + if rel_path is None: + raise web.HTTPNotFound(text="theme_css_not_found") + + root = Path(settings.WEBAPP_THEMES_DIR).expanduser().resolve() + path = (root / rel_path).resolve() + try: + path.relative_to(root) + except ValueError: + raise web.HTTPNotFound(text="theme_css_not_found") from None + + try: + if path.stat().st_size > WEBAPP_THEME_CSS_MAX_BYTES: + raise web.HTTPNotFound(text="theme_css_too_large") + text = path.read_text(encoding="utf-8") + except OSError: + defaults = default_webapp_theme_css_files() + text = defaults.get(rel_path.as_posix()) + if text is None: + raise web.HTTPNotFound(text="theme_css_not_found") from None + + response = web.Response(text=text, content_type="text/css", charset="utf-8") + response.headers["Cache-Control"] = "no-cache" + return response + + +async def theme_asset_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + ensure_default_webapp_theme_descriptor_files(settings.WEBAPP_THEMES_DIR) + rel_path = _safe_theme_asset_relative_path(request.match_info.get("path") or "") + if rel_path is None: + raise web.HTTPNotFound(text="theme_asset_not_found") + + root = Path(settings.WEBAPP_THEMES_DIR).expanduser().resolve() + path = (root / rel_path).resolve() + try: + path.relative_to(root) + except ValueError: + raise web.HTTPNotFound(text="theme_asset_not_found") from None + + suffix = rel_path.suffix.lower() + content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(suffix) + if not content_type: + raise web.HTTPNotFound(text="theme_asset_not_found") + + try: + if path.stat().st_size > WEBAPP_THEME_ASSET_MAX_BYTES: + raise web.HTTPNotFound(text="theme_asset_too_large") + body = path.read_bytes() + except OSError: + fallback = default_webapp_theme_asset_file(rel_path) + if fallback is None: + raise web.HTTPNotFound(text="theme_asset_not_found") from None + body, fallback_suffix = fallback + content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(fallback_suffix, content_type) + + if not body or len(body) > WEBAPP_THEME_ASSET_MAX_BYTES: + raise web.HTTPNotFound(text="theme_asset_not_found") + + query = getattr(request, "query", {}) + response = web.Response(body=body, content_type=content_type) + response.headers["Cache-Control"] = ( + "public, max-age=31536000, immutable" + if query.get("v") + else "public, max-age=3600" + ) + return response + + def _resolve_webapp_logo_url(settings: Settings) -> str: + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return "" + raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url: return "" @@ -26,6 +147,27 @@ def _resolve_webapp_logo_url(settings: Settings) -> str: return "" +def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str: + raw_custom_url = (getattr(settings, "WEBAPP_FAVICON_URL", None) or "").strip() + raw_logo_favicon_url = (getattr(settings, "WEBAPP_LOGO_FAVICON_URL", None) or "").strip() + if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False) and raw_custom_url: + return _resolve_webapp_asset_url(raw_custom_url) + if logo_url and raw_logo_favicon_url: + resolved = _resolve_webapp_asset_url(raw_logo_favicon_url) + if resolved: + return resolved + return logo_url or "" + + +def _resolve_webapp_asset_url(raw_url: str) -> str: + parsed_url = urlsplit(raw_url) + if parsed_url.scheme in {"https", "http", "data"}: + return raw_url + if raw_url.startswith("/"): + return raw_url + return "" + + def _webapp_logo_cache_key(logo_url: str) -> str: return hashlib.sha256(logo_url.encode("utf-8")).hexdigest() @@ -61,6 +203,8 @@ def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str: async def webapp_logo_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + raise web.HTTPNotFound(text="webapp_logo_disabled") raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url: raise web.HTTPNotFound(text="webapp_logo_not_configured") @@ -94,6 +238,82 @@ async def webapp_logo_route(request: web.Request) -> web.Response: return response +async def webapp_uploaded_logo_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + filename = str(request.match_info.get("filename") or "").strip() + if not re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename): + raise web.HTTPNotFound(text="webapp_logo_not_found") + + root = WEBAPP_UPLOADED_LOGO_DIR.expanduser().resolve() + path = (root / filename).resolve() + try: + path.relative_to(root) + except ValueError: + raise web.HTTPNotFound(text="webapp_logo_not_found") from None + + content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower()) + if not content_type: + raise web.HTTPNotFound(text="webapp_logo_not_found") + + try: + if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES: + raise web.HTTPNotFound(text="webapp_logo_too_large") + body = path.read_bytes() + except OSError: + raise web.HTTPNotFound(text="webapp_logo_not_found") from None + + if not body: + raise web.HTTPNotFound(text="webapp_logo_not_found") + + response = web.Response(body=body, content_type=content_type) + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + +async def webapp_favicon_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + digest = str(request.match_info.get("digest") or "").strip().lower() + filename = str(request.match_info.get("filename") or "").strip() + if not re.fullmatch(r"[0-9a-f]{16}", digest): + raise web.HTTPNotFound(text="webapp_favicon_not_found") + if not re.fullmatch( + r"(?:icon-(?:16|32|48|180|192|512)\.png|apple-touch-icon\.png|favicon\.(?:ico|svg))", + filename, + ): + raise web.HTTPNotFound(text="webapp_favicon_not_found") + + root = WEBAPP_FAVICON_DIR.expanduser().resolve() + path = (root / digest / filename).resolve() + try: + path.relative_to(root) + except ValueError: + raise web.HTTPNotFound(text="webapp_favicon_not_found") from None + + content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower()) + if not content_type: + raise web.HTTPNotFound(text="webapp_favicon_not_found") + + try: + if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES: + raise web.HTTPNotFound(text="webapp_favicon_too_large") + body = path.read_bytes() + except OSError: + raise web.HTTPNotFound(text="webapp_favicon_not_found") from None + + if not body: + raise web.HTTPNotFound(text="webapp_favicon_not_found") + + response = web.Response(body=body, content_type=content_type) + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + async def webapp_animated_emoji_route(request: web.Request) -> web.Response: codepoints = str(request.match_info.get("codepoints") or "").strip().lower() ext = str(request.match_info.get("ext") or "").strip().lower() @@ -123,6 +343,8 @@ async def webapp_animated_emoji_route(request: web.Request) -> web.Response: async def _warm_webapp_logo_cache(app: web.Application) -> None: settings: Settings = app["settings"] + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url): return @@ -146,6 +368,8 @@ async def _warm_webapp_logo_cache(app: web.Application) -> None: async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None: settings: Settings = app["settings"] + if not getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated": return @@ -435,7 +659,7 @@ async def _security_headers_middleware(request: web.Request, handler): "frame-ancestors https://web.telegram.org https://t.me; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " # noqa: E501 "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; " - "img-src 'self' data: https:; " + "img-src 'self' data: blob: https:; " "connect-src 'self' https://oauth.telegram.org; " "object-src 'none'; " "base-uri 'self'; " @@ -482,8 +706,10 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: cache = request.app["webapp_settings_cache"] now = time.monotonic() if now - float(cache.get("ts", 0.0)) >= 60 or not cache.get("data"): + logo_url = _resolve_webapp_logo_url(settings) cache["data"] = { - "logo_url": _resolve_webapp_logo_url(settings), + "logo_url": logo_url, + "favicon_url": _resolve_webapp_favicon_url(settings, logo_url), "subscription_options": settings.subscription_options, "stars_subscription_options": settings.stars_subscription_options, "traffic_packages": settings.traffic_packages, @@ -628,12 +854,29 @@ async def index_route(request: web.Request) -> web.Response: html = TEMPLATE_PATH.read_text(encoding="utf-8") cached = _get_cached_webapp_settings(request) + themes_catalog = settings.webapp_themes_catalog + primary_color = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a" + initial_theme = _initial_theme_for_request(request, themes_catalog) + preview_key = str(request.query.get("theme_preview") or "").strip() + preview_theme = themes_catalog.theme_by_key(preview_key) if preview_key else None + if preview_theme is None or not preview_theme.enabled: + preview_key = "" config = { "title": settings.WEBAPP_TITLE, "primaryColor": settings.WEBAPP_PRIMARY_COLOR, + "themesCatalog": public_themes_catalog_payload( + themes_catalog, + primary_color, + enabled_only=True, + ), + "themesDir": settings.WEBAPP_THEMES_DIR, + "themePreviewKey": preview_key, "logoUrl": cached["logo_url"], + "logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI), "logoEmoji": settings.WEBAPP_LOGO_EMOJI, "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, + "faviconUrl": cached["favicon_url"], + "faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM), "apiBase": "/api", "telegramLoginBotUsername": request.app.get("bot_username") or "", "telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, @@ -650,6 +893,9 @@ async def index_route(request: web.Request) -> web.Response: "appRepositoryUrl": APP_REPOSITORY_URL, } html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER) + initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color) + if initial_theme_markup: + html = html.replace("", f"{initial_theme_markup}\n", 1) i18n_instance: Optional[object] = request.app.get("i18n") i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} nonce = request.get("csp_nonce", "") @@ -673,17 +919,27 @@ async def index_route(request: web.Request) -> web.Response: WEBAPP_JS_PLACEHOLDER, f'', ) + favicon_markup = _favicon_head_markup(cached["favicon_url"]) + if favicon_markup: + html = html.replace( + '', + favicon_markup, + ) brand_asset_url = cached["logo_url"] - if not brand_asset_url and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated": + if ( + not brand_asset_url + and settings.WEBAPP_LOGO_USE_EMOJI + and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated" + ): brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI) if brand_asset_url: html = html.replace( - '', # noqa: E501 - f'', # noqa: E501 + '', + f'', ) else: html = html.replace( - '', # noqa: E501 + '', "", ) return web.Response(text=html, content_type="text/html", charset="utf-8") @@ -724,6 +980,129 @@ def _resolve_webapp_js_asset_name() -> str: return "subscription_webapp.js" +_INITIAL_THEME_TOKEN_CSS_MAP = { + "accent": "--accent", + "bg": "--bg", + "panel": "--panel", + "panel_2": "--panel-2", + "panel_3": "--panel-3", + "border": "--border", + "border_strong": "--border-strong", + "text": "--text", + "muted": "--muted", + "dim": "--dim", + "danger": "--danger", + "blue": "--blue", + "radius": "--radius", + "font_sans": "--font-sans", + "font_logo": "--font-logo", + "font_mono": "--font-mono", + "admin_bg": "--admin-bg", + "admin_surface": "--admin-surface", + "admin_surface_2": "--admin-surface-2", + "admin_elev": "--admin-elev", + "admin_border": "--admin-border", + "admin_border_strong": "--admin-border-strong", + "admin_text": "--admin-text", + "admin_muted": "--admin-muted", + "admin_dim": "--admin-dim", +} + + +def _theme_css_href_for_html(theme: Any) -> str: + css_file = str(getattr(theme, "css_file", "") or "").strip() + key = str(getattr(theme, "key", "") or "").strip() + if not css_file or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key): + return "" + parts = [part for part in css_file.replace("\\", "/").split("/") if part] + if any(part in {".", ".."} for part in parts): + return "" + themed_path = "/".join([key, *parts]) + encoded = "/".join(quote(part, safe="") for part in themed_path.split("/")) + return f"/webapp-theme-css/{encoded}" if encoded else "" + + +def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any: + preview_key = str(request.query.get("theme_preview") or "").strip() + if preview_key: + preview_theme = catalog.theme_by_key(preview_key) + if preview_theme is not None and preview_theme.enabled: + return preview_theme + + theme = catalog.theme_by_key(catalog.default_theme) + if theme is not None: + return theme + return catalog.enabled_themes()[0] if catalog.enabled_themes() else None + + +def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: str) -> str: + if theme is None: + return "" + + payload = public_theme_payload(theme, primary_color) + tokens = payload.get("tokens") if isinstance(payload, dict) else {} + tokens = tokens if isinstance(tokens, dict) else {} + declarations = [] + for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items(): + value = str(tokens.get(token_key) or "").strip() + if value: + declarations.append(f"{css_name}:{value}") + + scheme = "light" if tokens.get("color_scheme") == "light" else "dark" + bg = str(tokens.get("bg") or "").strip() + css_rules = [f"html{{color-scheme:{scheme};}}"] + if bg: + css_rules.append(f"body{{background-color:{bg};}}") + if declarations: + css_rules.append(f".app-shell{{{';'.join(declarations)}}}") + + nonce = html.escape(str(request.get("csp_nonce", "")), quote=True) + style_tag = ( + f'" + ) + href = _theme_css_href_for_html(theme) + if not href: + return style_tag + stylesheet = ( + f'' + ) + return stylesheet + "\n" + style_tag + + +def _favicon_head_markup(favicon_url: str) -> str: + href = str(favicon_url or "").strip() + if not href: + return "" + + escaped_href = html.escape(href, quote=True) + match = re.fullmatch( + rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/icon-(?:16|32|48|180|192|512)\.png", + href, + ) + if not match: + rel = "apple-touch-icon" if href.endswith(".png") else "icon" + return ( + f'\n' + f'' + ) + + digest = match.group(1) + base = f"{WEBAPP_FAVICON_PATH}/{digest}" + return "\n".join( + [ + ( + f'' + ), + f'', + f'', + f'', + f'', + ] + ) + + def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str: start = html.find(start_marker) if start == -1: diff --git a/bot/app/web/webapp/routes.py b/bot/app/web/webapp/routes.py index ee78959..d8a0b59 100644 --- a/bot/app/web/webapp/routes.py +++ b/bot/app/web/webapp/routes.py @@ -9,17 +9,33 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_get("/devices", index_route) app.router.add_get("/settings", index_route) app.router.add_get("/admin", index_route) - app.router.add_get("/admin/{section:[a-z][a-z0-9_-]*}", index_route) + app.router.add_get( + ( + "/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|" + "appearance|settings}" + ), + index_route, + ) app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route) app.router.add_get("/auth/telegram/start", telegram_oauth_start_route) app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route) app.router.add_get("/health", health_route) app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route) + app.router.add_get( + rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}", + webapp_uploaded_logo_route, + ) + app.router.add_get( + rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}", + webapp_favicon_route, + ) app.router.add_get( r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}", webapp_animated_emoji_route, ) app.router.add_get("/subscription_webapp.css", css_asset_route) + app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route) + app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route) app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route) app.router.add_get("/subscription_webapp.js", js_asset_route) app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route) diff --git a/bot/app/web/webapp/serializers.py b/bot/app/web/webapp/serializers.py index 174cd53..9ae8300 100644 --- a/bot/app/web/webapp/serializers.py +++ b/bot/app/web/webapp/serializers.py @@ -1,6 +1,8 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from config.webapp_themes_config import public_themes_catalog_payload + async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]: settings: Settings = request.app["settings"] @@ -96,6 +98,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A stars_traffic_packages=cached["stars_traffic_packages"], ), "payment_methods": _serialize_payment_methods(settings, request.app), + "themes_catalog": public_themes_catalog_payload( + settings.webapp_themes_catalog, + settings.WEBAPP_PRIMARY_COLOR or "#00fe7a", + enabled_only=True, + ), "settings": { "support_url": settings.SUPPORT_LINK, "traffic_mode": bool(settings.traffic_sale_mode), diff --git a/bot/services/email_templates.py b/bot/services/email_templates.py index bca9a36..a17ac85 100644 --- a/bot/services/email_templates.py +++ b/bot/services/email_templates.py @@ -1,7 +1,7 @@ """Branded HTML email templates that mirror the subscription Mini App look. The web app uses a dark theme with a configurable accent colour -(`WEBAPP_PRIMARY_COLOR`) and an optional logo (`WEBAPP_LOGO_URL`). The same +admin-configured accent colour and logo. The same accent + logo are reused here so emails feel like part of the product. All copy goes through the shared `JsonI18n` instance so translations live in ``locales/.json`` next to the rest of the bot strings. @@ -45,8 +45,10 @@ def _safe_color(value: Optional[str]) -> str: def _public_logo_url(settings: Settings) -> Optional[str]: - """Email recipients can't reach the in-app /webapp-logo proxy, so the - raw https URL from the env is used directly. Anything else is dropped.""" + """Email recipients can't reach the in-app /webapp-logo proxy, so only a + stored public https URL can be used directly. Anything else is dropped.""" + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return None raw = (settings.WEBAPP_LOGO_URL or "").strip() if not raw: return None diff --git a/config/settings.py b/config/settings.py index 31d8302..99ea47a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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]: @@ -88,8 +92,12 @@ class WebAppSettings(BaseModel): title: str primary_color: str logo_url: Optional[str] + logo_use_emoji: bool logo_emoji: str logo_emoji_font: str + favicon_use_custom: bool + favicon_url: Optional[str] + logo_favicon_url: Optional[str] session_ttl_seconds: int session_secret: str webhook_secret_token: str @@ -351,7 +359,21 @@ 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 " + "/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_USE_EMOJI: bool = Field(default=False) WEBAPP_LOGO_EMOJI: str = Field(default="πŸ«₯") WEBAPP_LOGO_EMOJI_FONT: str = Field( default="system", @@ -360,6 +382,9 @@ class Settings(BaseSettings): "noto-emoji, twemoji, openmoji, apple, segoe, noto-local" ), ) + WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False) + WEBAPP_FAVICON_URL: Optional[str] = Field(default=None) + WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None) WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60) @@ -528,8 +553,12 @@ class Settings(BaseSettings): title=self.WEBAPP_TITLE, primary_color=self.WEBAPP_PRIMARY_COLOR, logo_url=self.WEBAPP_LOGO_URL, + logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI, logo_emoji=self.WEBAPP_LOGO_EMOJI, logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT, + favicon_use_custom=self.WEBAPP_FAVICON_USE_CUSTOM, + favicon_url=self.WEBAPP_FAVICON_URL, + logo_favicon_url=self.WEBAPP_LOGO_FAVICON_URL, session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS, session_secret=self.WEBAPP_SESSION_SECRET, webhook_secret_token=self.WEBHOOK_SECRET_TOKEN, @@ -810,6 +839,55 @@ 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, + ) + + @field_validator("WEBAPP_PRIMARY_COLOR", mode="before") + @classmethod + def ignore_deprecated_webapp_primary_color_env(cls, _value): + return "#00fe7a" + + @field_validator("WEBAPP_LOGO_URL", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_url_env(cls, _value): + return None + + @field_validator("WEBAPP_LOGO_USE_EMOJI", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_use_emoji_env(cls, _value): + return False + + @field_validator("WEBAPP_LOGO_EMOJI", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_emoji_env(cls, _value): + return "πŸ«₯" + + @field_validator("WEBAPP_LOGO_EMOJI_FONT", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value): + return "system" + + @field_validator("WEBAPP_FAVICON_USE_CUSTOM", mode="before") + @classmethod + def ignore_deprecated_webapp_favicon_use_custom_env(cls, _value): + return False + + @field_validator("WEBAPP_FAVICON_URL", mode="before") + @classmethod + def ignore_deprecated_webapp_favicon_url_env(cls, _value): + return None + + @field_validator("WEBAPP_LOGO_FAVICON_URL", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_favicon_url_env(cls, _value): + return None + @computed_field @property def referral_bonus_inviter(self) -> Dict[int, int]: diff --git a/config/webapp_themes_config.py b/config/webapp_themes_config.py new file mode 100644 index 0000000..e6cc7f7 --- /dev/null +++ b/config/webapp_themes_config.py @@ -0,0 +1,633 @@ +"""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, field_validator, 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 + danger_text: Optional[str] = None + danger_soft: Optional[str] = None + danger_border: Optional[str] = None + success: Optional[str] = None + success_text: Optional[str] = None + success_soft: Optional[str] = None + success_border: Optional[str] = None + warning: Optional[str] = None + warning_text: Optional[str] = None + warning_soft: Optional[str] = None + warning_border: Optional[str] = None + info: Optional[str] = None + info_text: Optional[str] = None + info_soft: Optional[str] = None + info_border: 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 + home_logo_scale: Optional[int] = 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 + + @field_validator("accent") + @classmethod + def _normalize_accent_hex(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + raw = str(value).strip() + if not raw: + return None + match = re.fullmatch(r"#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})", raw) + if not match: + raise ValueError("accent must be a hex color (#RGB or #RRGGBB)") + hex_value = match.group(1).lower() + if len(hex_value) == 3: + hex_value = "".join(char * 2 for char in hex_value) + return f"#{hex_value}" + + @field_validator("home_logo_scale") + @classmethod + def _normalize_home_logo_scale(cls, value: Optional[int]) -> Optional[int]: + if value is None: + return None + scale = int(value) + if scale < 50 or scale > 300: + raise ValueError("home_logo_scale must be between 50 and 300 percent") + return scale + + +class WebappTheme(BaseModel): + """Single theme descriptor loaded from WEBAPP_THEMES_DIR//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 "--success-text" not in style or ".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 "::-webkit-slider-thumb" not in style + or "?v=6" 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/.""" + 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], + } diff --git a/docs/admin.md b/docs/admin.md index 6e24843..9ccee4e 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -9,6 +9,7 @@ - Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²ΠΊΠ° ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ, рассылки, ΠΏΡ€ΠΎΠΌΠΎΠΊΠΎΠ΄Ρ‹ ΠΈ просмотр Π»ΠΎΠ³ΠΎΠ²; - ручная синхронизация с Remnawave; - Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€ Ρ€Π°Π·Ρ€Π΅ΡˆΠ΅Π½Π½Ρ‹Ρ… настроСк прилоТСния ΠΈΠ· manifest-Ρ„Π°ΠΉΠ»Π°; +- Ρ€Π°Π·Π΄Π΅Π» **Π’Π½Π΅ΡˆΠ½ΠΈΠΉ Π²ΠΈΠ΄** для Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ°, emoji-Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ°, Π²Ρ‹Π±ΠΎΡ€Π° Ρ‚Π΅ΠΌΡ‹, accent-Ρ†Π²Π΅Ρ‚Π°, ΠΌΠ°ΡΡˆΡ‚Π°Π±Π° Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ° ΠΈ прСдпросмотра Ρ‚Π΅ΠΌ; - Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€ JSON-ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³Π° Ρ‚Π°Ρ€ΠΈΡ„ΠΎΠ²; - Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠ° Internal Squads ΠΈΠ· Remnawave для Π²Ρ‹Π±ΠΎΡ€Π° Π² Ρ‚Π°Ρ€ΠΈΡ„Π°Ρ…. @@ -46,6 +47,14 @@ Π‘Π΅ΠΊΡ€Π΅Ρ‚Π½Ρ‹Π΅ поля ΠΏΠΎΠΌΠ΅Ρ‡Π΅Π½Ρ‹ ΠΊΠ°ΠΊ secret ΠΈ Π½Π΅ Π΄ΠΎΠ»ΠΆΠ½Ρ‹ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒΡΡ для ΠΏΡ€ΠΎΠΈΠ·Π²ΠΎΠ»ΡŒΠ½ΠΎΠ³ΠΎ просмотра старых Π·Π½Π°Ρ‡Π΅Π½ΠΈΠΉ. Настройки, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Ρ… Π½Π΅Ρ‚ Π² manifest, ΠΎΡΡ‚Π°ΡŽΡ‚ΡΡ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π² `.env` ΠΈΠ»ΠΈ ΠΊΠΎΠ΄Π΅. +## Π’Π½Π΅ΡˆΠ½ΠΈΠΉ Π²ΠΈΠ΄ + +Π Π°Π·Π΄Π΅Π» **Π’Π½Π΅ΡˆΠ½ΠΈΠΉ Π²ΠΈΠ΄** ΠΎΠ±ΡŠΠ΅Π΄ΠΈΠ½ΡΠ΅Ρ‚ настройки Π±Ρ€Π΅Π½Π΄Π° ΠΈ Ρ‚Π΅ΠΌΡ‹ Web App. Π›ΠΎΠ³ΠΎΡ‚ΠΈΠΏ ΠΌΠΎΠΆΠ½ΠΎ Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ Ρ„Π°ΠΉΠ»ΠΎΠΌ ΠΈΠ»ΠΈ ΠΏΠΎ HTTPS-ссылкС; backend сохраняСт Ρ„Π°ΠΉΠ» Π² `data/webapp-logo/uploads` ΠΈ подставляСт Π»ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ URL. Если Π²ΠΊΠ»ΡŽΡ‡Π΅Π½ emoji-Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ, ΠΊΠ°Ρ€Ρ‚ΠΈΠ½ΠΊΠ° скрываСтся, Π° для emoji ΠΌΠΎΠΆΠ½ΠΎ Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ систСмный, Twemoji, Noto Color, animated Noto ΠΈ Π΄Ρ€ΡƒΠ³ΠΈΠ΅ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚Ρ‹ отрисовки. + +Π’ Π±Π»ΠΎΠΊΠ΅ Ρ‚Π΅ΠΌ Π°Π΄ΠΌΠΈΠ½ΠΊΠ° Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³ ΠΈΠ· `WEBAPP_THEMES_DIR`, ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅Ρ‚ встроСнныС ΠΈ кастомныС Ρ‚Π΅ΠΌΡ‹, позволяСт Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ Ρ‚Π΅ΠΊΡƒΡ‰ΡƒΡŽ Ρ‚Π΅ΠΌΡƒ, ΠΈΠ·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ accent, Π²ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ ΠΈΠ»ΠΈ Π²Ρ‹ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ Ρ‚Π΅ΠΌΡƒ для Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ ΠΈ Π½Π°ΡΡ‚Ρ€ΠΎΠΈΡ‚ΡŒ ΠΌΠ°ΡΡˆΡ‚Π°Π± Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ° Π½Π° Π³Π»Π°Π²Π½ΠΎΠΉ ΠΈ экранС Π²Ρ…ΠΎΠ΄Π°. Кнопка прСдпросмотра ΠΎΡ‚ΠΊΡ€Ρ‹Π²Π°Π΅Ρ‚ `/home?theme_preview=` ΠΈ Π½Π΅ мСняСт Π³Π»ΠΎΠ±Π°Π»ΡŒΠ½ΡƒΡŽ Ρ‚Π΅ΠΌΡƒ Π΄ΠΎ сохранСния. + +ΠŸΠΎΠ΄Ρ€ΠΎΠ±Π½Ρ‹ΠΉ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ `theme.json`, CSS/asset-Ρ€ΠΎΡƒΡ‚Ρ‹ ΠΈ ΠΏΠΎΡˆΠ°Π³ΠΎΠ²Ρ‹ΠΉ ΠΏΠ°ΠΉΠΏΠ»Π°ΠΉΠ½ создания Π½ΠΎΠ²ΠΎΠΉ Ρ‚Π΅ΠΌΡ‹ описаны Π² [webapp-themes.md](webapp-themes.md). + ## Π’Π°Ρ€ΠΈΡ„Ρ‹ Π Π°Π·Π΄Π΅Π» **БистСма -> Π’Π°Ρ€ΠΈΡ„Ρ‹** Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚ с Ρ„Π°ΠΉΠ»ΠΎΠΌ `TARIFFS_CONFIG_PATH` (ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ `data/tariffs.json`). ΠŸΡ€ΠΈ сохранСнии backend Π²Π°Π»ΠΈΠ΄ΠΈΡ€ΡƒΠ΅Ρ‚ payload Ρ‡Π΅Ρ€Π΅Π· `TariffsConfig`, ΠΏΠΈΡˆΠ΅Ρ‚ JSON Π² UTF-8 ΠΈ сбрасываСт кСш ΠΏΡƒΠ±Π»ΠΈΡ‡Π½Ρ‹Ρ… Π΄Π°Π½Π½Ρ‹Ρ… Web App. diff --git a/docs/configuration.md b/docs/configuration.md index f4b2f41..9864358 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -96,10 +96,10 @@ nano .env Если Ρ„Π°ΠΉΠ» ΠΈΠ· `TARIFFS_CONFIG_PATH` сущСствуСт, Π±ΠΎΡ‚ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³ Ρ‚Π°Ρ€ΠΈΡ„ΠΎΠ². Если Ρ„Π°ΠΉΠ»Π° Π½Π΅Ρ‚, примСняСтся конфигурация ΠΈΠ· ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Ρ… `.env`. -Π’ ΡˆΡ‚Π°Ρ‚Π½ΠΎΠΌ `docker-compose.yml` Ρ‚ΠΎΠΌ `./data:/app/data` Ρƒ сСрвиса прилоТСния **Π·Π°ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½ ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ**. РаскоммСнтируйтС Π±Π»ΠΎΠΊ `volumes`, Ρ‡Ρ‚ΠΎΠ±Ρ‹ Π°Π΄ΠΌΠΈΠ½ΠΊΠ° сохраняла `data/tariffs.json`, кСш Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ° Web App (`data/webapp-logo`) ΠΈ animated emoji (`data/webapp-emoji`). ΠžΡ‚Π΄Π΅Π»ΡŒΠ½Ρ‹ΠΉ `docker-compose-dev.yml` Π² Ρ€Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€ΠΈΠΉ Π½Π΅ Π²Ρ…ΠΎΠ΄ΠΈΡ‚ (ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ Ρƒ вас локально); Π»ΠΎΠ³ΠΈΠΊΠ° Ρ‚Π° ΠΆΠ΅ β€” ΠΌΠΎΠ½Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ `./data` Π² `/app/data`. Если bind mount Π²ΠΊΠ»ΡŽΡ‡Ρ‘Π½ Π½Π° Ubuntu-сСрвСрС, создайтС ΠΏΠΎΠ΄ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³ΠΈ ΠΈ ΠΎΡ‚Π΄Π°ΠΉΡ‚Π΅ `data` UID `10001`, ΠΏΠΎΠ΄ ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΌ Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚ ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ Π²Π½ΡƒΡ‚Ρ€ΠΈ ΠΊΠΎΠ½Ρ‚Π΅ΠΉΠ½Π΅Ρ€Π°: +Π’ ΡˆΡ‚Π°Ρ‚Π½ΠΎΠΌ `docker-compose.yml` Ρ‚ΠΎΠΌ `./data:/app/data` Ρƒ сСрвиса прилоТСния **Π·Π°ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½ ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ**. РаскоммСнтируйтС Π±Π»ΠΎΠΊ `volumes`, Ρ‡Ρ‚ΠΎΠ±Ρ‹ Π°Π΄ΠΌΠΈΠ½ΠΊΠ° сохраняла `data/tariffs.json`, ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³ Ρ‚Π΅ΠΌ (`data/themes`), кСш Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ° Web App (`data/webapp-logo`) ΠΈ animated emoji (`data/webapp-emoji`). ΠžΡ‚Π΄Π΅Π»ΡŒΠ½Ρ‹ΠΉ `docker-compose-dev.yml` Π² Ρ€Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€ΠΈΠΉ Π½Π΅ Π²Ρ…ΠΎΠ΄ΠΈΡ‚ (ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ Ρƒ вас локально); Π»ΠΎΠ³ΠΈΠΊΠ° Ρ‚Π° ΠΆΠ΅ β€” ΠΌΠΎΠ½Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ `./data` Π² `/app/data`. Если bind mount Π²ΠΊΠ»ΡŽΡ‡Ρ‘Π½ Π½Π° Ubuntu-сСрвСрС, создайтС ΠΏΠΎΠ΄ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³ΠΈ ΠΈ ΠΎΡ‚Π΄Π°ΠΉΡ‚Π΅ `data` UID `10001`, ΠΏΠΎΠ΄ ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΌ Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚ ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ Π²Π½ΡƒΡ‚Ρ€ΠΈ ΠΊΠΎΠ½Ρ‚Π΅ΠΉΠ½Π΅Ρ€Π°: ```bash -mkdir -p data/webapp-logo data/webapp-emoji +mkdir -p data/themes data/webapp-logo data/webapp-emoji chown -R 10001:10001 data chmod -R u+rwX data ``` @@ -122,10 +122,8 @@ docker compose up -d --build --force-recreate | `WEBAPP_SERVER_HOST` / `WEBAPP_SERVER_PORT` | Π₯ост ΠΈ ΠΏΠΎΡ€Ρ‚ Web App. По ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ ΠΏΠΎΡ€Ρ‚ `8081`. | | `SUBSCRIPTION_MINI_APP_URL` | ΠŸΡƒΠ±Π»ΠΈΡ‡Π½Ρ‹ΠΉ URL Web App. | | `WEBAPP_TITLE` | Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ Web App. | -| `WEBAPP_PRIMARY_COLOR` | Основной Ρ†Π²Π΅Ρ‚ интСрфСйса. | -| `WEBAPP_LOGO_URL` | URL Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ° Web App; Ссли пусто β€” показываСтся emoji ΠΈΠ· `WEBAPP_LOGO_EMOJI`. | -| `WEBAPP_LOGO_EMOJI` | Emoji-Π·Π°Π³Π»ΡƒΡˆΠΊΠ° вмСсто ΠΊΠ°Ρ€Ρ‚ΠΈΠ½ΠΊΠΈ Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ°. | -| `WEBAPP_LOGO_EMOJI_FONT` | Набор/ΡˆΡ€ΠΈΡ„Ρ‚ для отрисовки emoji (Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ `system`, `twemoji`, `noto-color-animated`). | +| `WEBAPP_THEMES_DIR` | ΠšΠ°Ρ‚Π°Π»ΠΎΠ³ Ρ‚Π΅ΠΌ Web App. По ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ `data/themes`; Π²Π½ΡƒΡ‚Ρ€ΠΈ ΠΎΠΆΠΈΠ΄Π°ΡŽΡ‚ΡΡ ΠΏΠ°ΠΏΠΊΠΈ `/theme.json` ΠΈ ΠΎΠΏΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½Ρ‹Π΅ CSS/ассСты. | +| `WEBAPP_DEFAULT_THEME` | ΠžΠΏΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½Ρ‹ΠΉ override Ρ‚Π΅ΠΌΡ‹ ΠΏΠΎ ΠΊΠ»ΡŽΡ‡Ρƒ, Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ `light` ΠΈΠ»ΠΈ `neon`. Если пусто, ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ `default` ΠΈΠ· дСскрипторов Ρ‚Π΅ΠΌ. | | `WEBAPP_SESSION_SECRET` | HMAC-сСкрСт сСссий Web App. | | `WEBHOOK_SECRET_TOKEN` | Π‘Π΅ΠΊΡ€Π΅Ρ‚Π½Ρ‹ΠΉ Ρ‚ΠΎΠΊΠ΅Π½, с ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΌ Telegram ΡˆΠ»Ρ‘Ρ‚ обновлСния Π½Π° Π²Π΅Π±Ρ…ΡƒΠΊ. | | `WEBAPP_SESSION_TTL_SECONDS` | ВрСмя ΠΆΠΈΠ·Π½ΠΈ сСссии Web App. | @@ -146,7 +144,7 @@ docker compose up -d --build --force-recreate | `BRUTE_FORCE_LOCK_SECONDS` | Π”Π»ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ Π²Ρ€Π΅ΠΌΠ΅Π½Π½ΠΎΠΉ Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²ΠΊΠΈ. | | `MY_DEVICES_SECTION_ENABLED` | ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅Ρ‚ Ρ€Π°Π·Π΄Π΅Π» "Мои устройства" ΠΈ Π²ΠΊΠ»ΡŽΡ‡Π°Π΅Ρ‚ API устройств. | -Настройка Π΄ΠΎΠΌΠ΅Π½Π°, BotFather ΠΈ callback URL описана Π² [webapp.md](webapp.md). +Π›ΠΎΠ³ΠΎΡ‚ΠΈΠΏ, emoji-Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ, основной accent-Ρ†Π²Π΅Ρ‚ ΠΈ Ρ‚Π΅ΠΌΠ° Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΡƒΡŽΡ‚ΡΡ Π² Ρ€Π°Π·Π΄Π΅Π»Π΅ **Админка -> Π’Π½Π΅ΡˆΠ½ΠΈΠΉ Π²ΠΈΠ΄** ΠΈ ΡΠΎΡ…Ρ€Π°Π½ΡΡŽΡ‚ΡΡ ΠΊΠ°ΠΊ overrides Π² Π±Π°Π·Π΅. ΠŸΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Π΅ `WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_URL`, `WEBAPP_LOGO_USE_EMOJI`, `WEBAPP_LOGO_EMOJI` ΠΈ `WEBAPP_LOGO_EMOJI_FONT` Π² `.env` ΡΡ‡ΠΈΡ‚Π°ΡŽΡ‚ΡΡ ΡƒΡΡ‚Π°Ρ€Π΅Π²ΡˆΠΈΠΌΠΈ для ΠΏΠ΅Ρ€Π²ΠΈΡ‡Π½ΠΎΠΉ настройки ΠΈ ΠΈΠ³Π½ΠΎΡ€ΠΈΡ€ΡƒΡŽΡ‚ΡΡ ΠΏΡ€ΠΈ Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠ΅ env. Настройка Π΄ΠΎΠΌΠ΅Π½Π°, BotFather ΠΈ callback URL описана Π² [webapp.md](webapp.md), Π° созданиС кастомных Ρ‚Π΅ΠΌ - Π² [webapp-themes.md](webapp-themes.md). ### SMTP ΠΈ Π²Ρ…ΠΎΠ΄ ΠΏΠΎ email diff --git a/docs/deployment.md b/docs/deployment.md index a4292cb..e15dc52 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -26,7 +26,7 @@ IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d ΠŸΠ΅Ρ€Π΅Π΄ запуском ΠΈΠ»ΠΈ послС добавлСния mount Π²Ρ‹ΠΏΠΎΠ»Π½ΠΈΡ‚Π΅ Π½Π° сСрвСрС ΠΈΠ· ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³Π° ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°: ```bash -mkdir -p data/webapp-logo data/webapp-emoji +mkdir -p data/themes data/webapp-logo data/webapp-emoji chown -R 10001:10001 data chmod -R u+rwX data docker compose up -d --force-recreate remnawave-minishop @@ -35,10 +35,10 @@ docker compose up -d --force-recreate remnawave-minishop ΠŸΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° ΠΏΡ€Π°Π²: ```bash -docker compose exec remnawave-minishop sh -lc 'id; ls -ldn /app/data /app/data/webapp-emoji; touch /app/data/webapp-emoji/test && rm /app/data/webapp-emoji/test' +docker compose exec remnawave-minishop sh -lc 'id; ls -ldn /app/data /app/data/themes /app/data/webapp-emoji; touch /app/data/themes/test /app/data/webapp-emoji/test && rm /app/data/themes/test /app/data/webapp-emoji/test' ``` -Если ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΎΡ‡Π½Ρ‹ΠΉ `touch` ΠΏΡ€ΠΎΡ…ΠΎΠ΄ΠΈΡ‚ Π±Π΅Π· `Permission denied`, Web App смоТСт ΡΠΎΡ…Ρ€Π°Π½ΡΡ‚ΡŒ ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³ Ρ‚Π°Ρ€ΠΈΡ„ΠΎΠ², кСш `WEBAPP_LOGO_URL` Π² `/app/data/webapp-logo` ΠΈ кСш animated emoji Π² `/app/data/webapp-emoji`. +Если ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΎΡ‡Π½Ρ‹ΠΉ `touch` ΠΏΡ€ΠΎΡ…ΠΎΠ΄ΠΈΡ‚ Π±Π΅Π· `Permission denied`, Web App смоТСт ΡΠΎΡ…Ρ€Π°Π½ΡΡ‚ΡŒ ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³ Ρ‚Π°Ρ€ΠΈΡ„ΠΎΠ², Ρ‚Π΅ΠΌΡ‹ Π² `/app/data/themes`, кСш Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠΎΠ² Π² `/app/data/webapp-logo` ΠΈ кСш animated emoji Π² `/app/data/webapp-emoji`. ## ОбновлСниС вСрсии diff --git a/docs/remnawave-minishop.webp b/docs/remnawave-minishop.webp new file mode 100644 index 0000000..0c10799 Binary files /dev/null and b/docs/remnawave-minishop.webp differ diff --git a/docs/webapp-themes.md b/docs/webapp-themes.md new file mode 100644 index 0000000..2df8e37 --- /dev/null +++ b/docs/webapp-themes.md @@ -0,0 +1,366 @@ +# Π’Π΅ΠΌΡ‹ ΠΈ внСшний Π²ΠΈΠ΄ Web App + +Web App ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΈΠ²Π°Π΅Ρ‚ Ρ„Π°ΠΉΠ»ΠΎΠ²Ρ‹Π΅ Ρ‚Π΅ΠΌΡ‹, прСдпросмотр ΠΈ Π±Π°Π·ΠΎΠ²ΡƒΡŽ настройку внСшнСго Π²ΠΈΠ΄Π° ΠΈΠ· Π°Π΄ΠΌΠΈΠ½-ΠΏΠ°Π½Π΅Π»ΠΈ. Π’Π΅ΠΌΠ° ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ простой Ρ†Π²Π΅Ρ‚ΠΎΠ²ΠΎΠΉ схСмой Π½Π° JSON-Ρ‚ΠΎΠΊΠ΅Π½Π°Ρ… ΠΈΠ»ΠΈ ΠΏΠΎΠ»Π½ΠΎΡ†Π΅Π½Π½Ρ‹ΠΌ скином с собствСнным CSS, ΡˆΡ€ΠΈΡ„Ρ‚Π°ΠΌΠΈ, ΠΈΠΊΠΎΠ½ΠΊΠ°ΠΌΠΈ ΠΈ Π³Ρ€Π°Ρ„ΠΈΠΊΠΎΠΉ. + +## Π§Ρ‚ΠΎ ΠΌΠΎΠΆΠ½ΠΎ ΠΏΠΎΠΌΠ΅Π½ΡΡ‚ΡŒ + +Π§Π΅Ρ€Π΅Π· Ρ€Π°Π·Π΄Π΅Π» **Админка -> Π’Π½Π΅ΡˆΠ½ΠΈΠΉ Π²ΠΈΠ΄** ΠΌΠΎΠΆΠ½ΠΎ: + +- Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ Π³Π»ΠΎΠ±Π°Π»ΡŒΠ½ΡƒΡŽ Ρ‚Π΅ΠΌΡƒ Web App; +- ΠΈΠ·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ accent-Ρ†Π²Π΅Ρ‚ ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½ΠΎΠΉ Ρ‚Π΅ΠΌΡ‹; +- Π²ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ ΠΈΠ»ΠΈ Π²Ρ‹ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ ΠΏΡ€ΠΈΠΌΠ΅Π½Π΅Π½ΠΈΠ΅ Ρ‚Π΅ΠΌΡ‹ Π² Π°Π΄ΠΌΠΈΠ½-ΠΏΠ°Π½Π΅Π»ΠΈ; +- Π½Π°ΡΡ‚Ρ€ΠΎΠΈΡ‚ΡŒ ΠΌΠ°ΡΡˆΡ‚Π°Π± Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ° Π½Π° Π³Π»Π°Π²Π½ΠΎΠΉ ΠΈ экранС Π²Ρ…ΠΎΠ΄Π°; +- Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ Ρ„Π°ΠΉΠ»ΠΎΠΌ ΠΈΠ»ΠΈ ΠΏΠΎ HTTPS-ссылкС; +- Π²ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ emoji-Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ ΠΈ Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ способ Π΅Π³ΠΎ отрисовки; +- ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ прСдпросмотр Ρ‚Π΅ΠΌΡ‹ Ρ‡Π΅Ρ€Π΅Π· `/home?theme_preview=`. + +Π§Π΅Ρ€Π΅Π· Ρ„Π°ΠΉΠ»Ρ‹ Ρ‚Π΅ΠΌΡ‹ ΠΌΠΎΠΆΠ½ΠΎ ΠΌΠ΅Π½ΡΡ‚ΡŒ Π½Π°ΠΌΠ½ΠΎΠ³ΠΎ большС: + +- Π±Π°Π·ΠΎΠ²Ρ‹Π΅ Ρ†Π²Π΅Ρ‚Π° Mini App ΠΈ Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ; +- радиусы, сСмСйства ΡˆΡ€ΠΈΡ„Ρ‚ΠΎΠ² ΠΈ Ρ€Π°Π·ΠΌΠ΅Ρ€ Π³Π»Π°Π²Π½ΠΎΠ³ΠΎ Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ°; +- Π»ΡŽΠ±Ρ‹Π΅ ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚Ρ‹ Ρ‡Π΅Ρ€Π΅Π· CSS: ΠΊΠ°Ρ€Ρ‚ΠΎΡ‡ΠΊΠΈ, Π½Π°Π²ΠΈΠ³Π°Ρ†ΠΈΡŽ, Ρ‚Π°Π±Π»ΠΈΡ†Ρ‹, ΠΌΠΎΠ΄Π°Π»ΠΊΠΈ, ΠΊΠ½ΠΎΠΏΠΊΠΈ, скСлСтоны, прогрСсс-Π±Π°Ρ€Ρ‹, состояния hover/active ΠΈ ΠΌΠΎΠ±ΠΈΠ»ΡŒΠ½ΡƒΡŽ/desktop-вСрстку; +- ΠΈΠΊΠΎΠ½ΠΊΠΈ ΠΈ изобраТСния, Ссли CSS ссылаСтся Π½Π° ассСты Ρ‚Π΅ΠΌΡ‹; +- стили Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΡΠΊΠΎΠΉ части, Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ ΠΈΠ»ΠΈ ΠΎΠ±Π΅ΠΈΡ… частСй сразу. + +Π“ΠΎΡ‚ΠΎΠ²Ρ‹Π΅ Ρ‚Π΅ΠΌΡ‹ Π»Π΅ΠΆΠ°Ρ‚ Π² `bot/app/web/themes`: `dark`, `light`, `windows95`, `ascii`. ΠŸΡ€ΠΈ ΠΏΠ΅Ρ€Π²ΠΎΠΌ запускС ΠΎΠ½ΠΈ ΠΊΠΎΠΏΠΈΡ€ΡƒΡŽΡ‚ΡΡ Π² `WEBAPP_THEMES_DIR`, ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ `data/themes`. + +## Π“Π΄Π΅ ΠΆΠΈΠ²ΡƒΡ‚ Ρ‚Π΅ΠΌΡ‹ + +КаТдая Ρ‚Π΅ΠΌΠ° - ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½Π°Ρ ΠΏΠ°ΠΏΠΊΠ°: + +```text +data/themes/ + neon/ + theme.json + style.css + icons/ + save.png +``` + +ΠŸΡƒΡ‚ΡŒ настраиваСтся ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½ΠΎΠΉ: + +```env +WEBAPP_THEMES_DIR=data/themes +WEBAPP_DEFAULT_THEME= +``` + +`WEBAPP_DEFAULT_THEME` ΠΎΠΏΡ†ΠΈΠΎΠ½Π°Π»Π΅Π½. Если ΠΎΠ½ Π·Π°Π΄Π°Π½ ΠΈ совпадаСт с ΠΊΠ»ΡŽΡ‡ΠΎΠΌ Ρ‚Π΅ΠΌΡ‹, ΠΎΠ½ пСрСопрСдСляСт `default: true` Π² `theme.json`. Если пСрСмСнная пустая, Π΄Π΅Ρ„ΠΎΠ»Ρ‚ выбираСтся ΠΈΠ· дСскрипторов Ρ‚Π΅ΠΌ. + +Π’Π°ΠΆΠ½ΠΎ: `WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_URL`, `WEBAPP_LOGO_USE_EMOJI`, `WEBAPP_LOGO_EMOJI` ΠΈ `WEBAPP_LOGO_EMOJI_FONT` большС Π½Π΅ ΡΠ²Π»ΡΡŽΡ‚ΡΡ Ρ€Π°Π±ΠΎΡ‡ΠΈΠΌ способом ΠΏΠ΅Ρ€Π²ΠΈΡ‡Π½ΠΎΠΉ настройки Ρ‡Π΅Ρ€Π΅Π· `.env`. Π­Ρ‚ΠΈ значСния Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΡƒΡŽΡ‚ΡΡ Π² Π°Π΄ΠΌΠΈΠ½ΠΊΠ΅ ΠΈ ΡΠΎΡ…Ρ€Π°Π½ΡΡŽΡ‚ΡΡ ΠΊΠ°ΠΊ overrides Π² Π±Π°Π·Π΅. Π’Π΅ΠΌΠ° ΠΏΡ€ΠΈ этом ΠΌΠΎΠΆΠ΅Ρ‚ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ сохранСнный primary color ΠΊΠ°ΠΊ fallback accent. + +## ΠšΠΎΠ½Ρ‚Ρ€Π°ΠΊΡ‚ `theme.json` + +Минимальная Ρ‚Π΅ΠΌΠ°: + +```json +{ + "key": "neon", + "names": { + "ru": "НСон", + "en": "Neon" + }, + "enabled": true, + "default": true, + "use_primary_accent": true, + "use_in_admin": true, + "tokens": { + "color_scheme": "dark", + "bg": "#05040a", + "panel": "#11101c", + "text": "#f8f7ff", + "muted": "#b8b2d8", + "accent": "#a855f7", + "radius": "14px" + } +} +``` + +Π’Π΅ΠΌΠ° с CSS: + +```json +{ + "key": "neon", + "names": { + "ru": "НСон", + "en": "Neon" + }, + "enabled": true, + "default": false, + "use_primary_accent": false, + "use_in_admin": true, + "css_file": "style.css", + "assets_version": 1, + "tokens": { + "color_scheme": "dark", + "style_preset": "none", + "accent": "#a855f7", + "bg": "#05040a", + "panel": "#11101c", + "panel_2": "#090815", + "panel_3": "#1a1830", + "border": "rgba(168, 85, 247, 0.28)", + "border_strong": "rgba(168, 85, 247, 0.48)", + "text": "#f8f7ff", + "muted": "#b8b2d8", + "dim": "#756f9b", + "danger": "#ff6b8a", + "blue": "#38bdf8", + "radius": "14px", + "font_sans": "Inter, system-ui, sans-serif", + "font_logo": "Inter, system-ui, sans-serif", + "font_mono": "\"JetBrains Mono\", \"Fira Code\", monospace", + "home_logo_scale": 120, + "admin_bg": "#05040a", + "admin_surface": "#11101c", + "admin_surface_2": "#090815", + "admin_elev": "#1a1830", + "admin_border": "rgba(168, 85, 247, 0.28)", + "admin_border_strong": "rgba(168, 85, 247, 0.48)", + "admin_text": "#f8f7ff", + "admin_muted": "#b8b2d8", + "admin_dim": "#756f9b" + } +} +``` + +Поля Π²Π΅Ρ€Ρ…Π½Π΅Π³ΠΎ уровня: + +| ПолС | НазначСниС | +| --- | --- | +| `key` | Π£Π½ΠΈΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ ΠΊΠ»ΡŽΡ‡ Ρ‚Π΅ΠΌΡ‹, 1-64 символа: Π»Π°Ρ‚ΠΈΠ½ΠΈΡ†Π°, Ρ†ΠΈΡ„Ρ€Ρ‹, `_` ΠΈ `-`. Если ΠΊΠ»ΡŽΡ‡ Π½Π΅ ΡƒΠΊΠ°Π·Π°Π½, бСрСтся имя ΠΏΠ°ΠΏΠΊΠΈ. | +| `names` | Π›ΠΎΠΊΠ°Π»ΠΈΠ·ΠΎΠ²Π°Π½Π½Ρ‹Π΅ названия, Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ `ru` ΠΈ `en`. | +| `enabled` | ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Ρ‚ΡŒ Ρ‚Π΅ΠΌΡƒ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡΠΌ. ΠžΡ‚ΠΊΠ»ΡŽΡ‡Π΅Π½Π½Π°Ρ Ρ‚Π΅ΠΌΠ° Π½Π΅ ΠΏΠΎΠΏΠ°Π΄Π°Π΅Ρ‚ Π² ΠΏΡƒΠ±Π»ΠΈΡ‡Π½Ρ‹ΠΉ ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³. | +| `default` | Π”Π΅Π»Π°Π΅Ρ‚ Ρ‚Π΅ΠΌΡƒ Π²Ρ‹Π±Ρ€Π°Π½Π½ΠΎΠΉ ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ, Ссли `WEBAPP_DEFAULT_THEME` Π½Π΅ Π·Π°Π΄Π°Π½. | +| `use_primary_accent` | Если `true`, Ρ‚Π΅ΠΌΠ° ΠΌΠΎΠΆΠ΅Ρ‚ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ accent ΠΈΠ· настройки внСшнСго Π²ΠΈΠ΄Π°, ΠΊΠΎΠ³Π΄Π° Π² `tokens.accent` Π½ΠΈΡ‡Π΅Π³ΠΎ Π½Π΅Ρ‚. | +| `use_in_admin` | Если `false`, ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΡΠΊΠ°Ρ Ρ‡Π°ΡΡ‚ΡŒ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ Ρ‚Π΅ΠΌΡƒ, Π½ΠΎ Π°Π΄ΠΌΠΈΠ½ΠΊΠ° откатываСтся Π½Π° `dark`. | +| `css_file` | CSS-Ρ„Π°ΠΉΠ» Π²Π½ΡƒΡ‚Ρ€ΠΈ ΠΏΠ°ΠΏΠΊΠΈ Ρ‚Π΅ΠΌΡ‹. ΠœΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ `style.css` ΠΈΠ»ΠΈ Π²Π»ΠΎΠΆΠ΅Π½Π½Ρ‹ΠΉ ΠΏΡƒΡ‚ΡŒ Π²Ρ€ΠΎΠ΄Π΅ `css/theme.css`. | +| `assets_version` | ВСрсия ассСтов. Для встроСнных Ρ‚Π΅ΠΌ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ для обновлСния старых Ρ„Π°ΠΉΠ»ΠΎΠ² Π² `data/themes`. | +| `tokens` | Π”ΠΈΠ·Π°ΠΉΠ½-Ρ‚ΠΎΠΊΠ΅Π½Ρ‹, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ ΠΏΡ€Π΅Π²Ρ€Π°Ρ‰Π°ΡŽΡ‚ΡΡ Π² CSS-ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Π΅ Π½Π° `.app-shell`. | + +## Π’ΠΎΠΊΠ΅Π½Ρ‹ + +ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΈΠ²Π°Π΅ΠΌΡ‹Π΅ Ρ‚ΠΎΠΊΠ΅Π½Ρ‹: + +| Π’ΠΎΠΊΠ΅Π½ | CSS-пСрСмСнная | Π§Ρ‚ΠΎ мСняСт | +| --- | --- | --- | +| `color_scheme` | `color-scheme` | Нативная свСтлая/тСмная схСма Π±Ρ€Π°ΡƒΠ·Π΅Ρ€Π°: `dark` ΠΈΠ»ΠΈ `light`. | +| `style_preset` | CSS-класс прСсСта | БСйчас `win95`/`windows95` добавляСт `theme-preset-win95`; ΠΎΡΡ‚Π°Π»ΡŒΠ½Ρ‹Π΅ значСния Π½Π΅ Π΄Π°ΡŽΡ‚ ΡΠΏΠ΅Ρ†ΠΈΠ°Π»ΡŒΠ½ΠΎΠ³ΠΎ класса. | +| `accent` | `--accent` | Π“Π»Π°Π²Π½Ρ‹ΠΉ Π°ΠΊΡ†Π΅Π½Ρ‚: Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Π΅ элСмСнты, ΠΊΠ½ΠΎΠΏΠΊΠΈ, прогрСсс, фокус. Волько hex `#RGB` ΠΈΠ»ΠΈ `#RRGGBB`. | +| `bg` | `--bg` | Основной Ρ„ΠΎΠ½ прилоТСния. | +| `panel` | `--panel` | ΠžΡΠ½ΠΎΠ²Π½Ρ‹Π΅ ΠΊΠ°Ρ€Ρ‚ΠΎΡ‡ΠΊΠΈ ΠΈ повСрхности. | +| `panel_2` | `--panel-2` | Π’Ρ‚ΠΎΡ€ΠΈΡ‡Π½Ρ‹Π΅ повСрхности. | +| `panel_3` | `--panel-3` | ΠŸΠΎΠ²Π΅Ρ€Ρ…Π½ΠΎΡΡ‚ΠΈ ΠΏΠΎΠ²Ρ‹ΡˆΠ΅Π½Π½ΠΎΠΉ влоТСнности, dropdown/popover. | +| `border` | `--border` | ΠžΠ±Ρ‹Ρ‡Π½Ρ‹Π΅ Π³Ρ€Π°Π½ΠΈΡ†Ρ‹. | +| `border_strong` | `--border-strong` | УсилСнныС Π³Ρ€Π°Π½ΠΈΡ†Ρ‹ ΠΈ hover-состояния. | +| `text` | `--text` | Основной тСкст. | +| `muted` | `--muted` | Π’Ρ‚ΠΎΡ€ΠΈΡ‡Π½Ρ‹ΠΉ тСкст. | +| `dim` | `--dim` | Π•Ρ‰Π΅ Π±ΠΎΠ»Π΅Π΅ Ρ‚ΠΈΡ…ΠΈΠΉ тСкст ΠΈ слуТСбныС подписи. | +| `danger` | `--danger` | Ошибки ΠΈ опасныС дСйствия. | +| `blue` | `--blue` | Π‘ΠΈΠ½ΠΈΠΉ Π²ΡΠΏΠΎΠΌΠΎΠ³Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹ΠΉ Ρ†Π²Π΅Ρ‚. | +| `radius` | `--radius` | Π‘Π°Π·ΠΎΠ²Ρ‹ΠΉ радиус ΠΊΠ°Ρ€Ρ‚ΠΎΡ‡Π΅ΠΊ, ΠΊΠ½ΠΎΠΏΠΎΠΊ ΠΈ ΠΊΠΎΠ½Ρ‚Ρ€ΠΎΠ»ΠΎΠ². | +| `font_sans` | `--font-sans` | Основной ΡˆΡ€ΠΈΡ„Ρ‚ интСрфСйса. | +| `font_logo` | `--font-logo` | Π¨Ρ€ΠΈΡ„Ρ‚ Π±Ρ€Π΅Π½Π΄Π° ΠΈ Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠ°. | +| `font_mono` | `--font-mono` | ΠœΠΎΠ½ΠΎΡˆΠΈΡ€ΠΈΠ½Π½Ρ‹ΠΉ ΡˆΡ€ΠΈΡ„Ρ‚. | +| `home_logo_scale` | `--home-logo-scale` | ΠœΠ°ΡΡˆΡ‚Π°Π± Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ° Π½Π° Π³Π»Π°Π²Π½ΠΎΠΉ ΠΈ Π²Ρ…ΠΎΠ΄Π΅, ΠΎΡ‚ `50` Π΄ΠΎ `300` ΠΏΡ€ΠΎΡ†Π΅Π½Ρ‚ΠΎΠ². | +| `admin_bg` | `--admin-bg` | Π€ΠΎΠ½ Π°Π΄ΠΌΠΈΠ½-ΠΏΠ°Π½Π΅Π»ΠΈ. | +| `admin_surface` | `--admin-surface` | ΠžΡΠ½ΠΎΠ²Π½Ρ‹Π΅ ΠΊΠ°Ρ€Ρ‚ΠΎΡ‡ΠΊΠΈ Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ. | +| `admin_surface_2` | `--admin-surface-2` | Π’Ρ‚ΠΎΡ€ΠΈΡ‡Π½Ρ‹Π΅ повСрхности Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ. | +| `admin_elev` | `--admin-elev` | Elevated-повСрхности Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ. | +| `admin_border` | `--admin-border` | Π“Ρ€Π°Π½ΠΈΡ†Ρ‹ Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ. | +| `admin_border_strong` | `--admin-border-strong` | УсилСнныС Π³Ρ€Π°Π½ΠΈΡ†Ρ‹ Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ. | +| `admin_text` | `--admin-text` | Основной тСкст Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ. | +| `admin_muted` | `--admin-muted` | Π’Ρ‚ΠΎΡ€ΠΈΡ‡Π½Ρ‹ΠΉ тСкст Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ. | +| `admin_dim` | `--admin-dim` | Π’ΠΈΡ…ΠΈΠ΅ подписи Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ. | + +Если `css_file` Π½Π΅ Π·Π°Π΄Π°Π½, интСрфСйс ΠΏΠΎΠ»Π½ΠΎΡΡ‚ΡŒΡŽ строится Π½Π° Ρ‚ΠΎΠΊΠ΅Π½Π°Ρ… ΠΈ ΠΎΠ±Ρ‰ΠΈΡ… стилях. Если `css_file` Π·Π°Π΄Π°Π½, Ρ‚ΠΎΠΊΠ΅Π½Ρ‹ всС Ρ€Π°Π²Π½ΠΎ ΠΏΡ€ΠΈΠΌΠ΅Π½ΡΡŽΡ‚ΡΡ ΠΏΠ΅Ρ€Π²Ρ‹ΠΌΠΈ, Π° CSS Ρ‚Π΅ΠΌΡ‹ ΠΌΠΎΠΆΠ΅Ρ‚ ΡƒΡ‚ΠΎΡ‡Π½ΠΈΡ‚ΡŒ ΠΈΠ»ΠΈ ΠΏΠΎΠ»Π½ΠΎΡΡ‚ΡŒΡŽ ΠΏΠ΅Ρ€Π΅ΠΎΠΏΡ€Π΅Π΄Π΅Π»ΠΈΡ‚ΡŒ внСшний Π²ΠΈΠ΄. + +## CSS-слой Ρ‚Π΅ΠΌΡ‹ + +CSS Ρ‚Π΅ΠΌΡ‹ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π°Π΅Ρ‚ΡΡ ΠΊΠ°ΠΊ: + +```text +/webapp-theme-css// +``` + +НапримСр `data/themes/neon/style.css` Π±ΡƒΠ΄Π΅Ρ‚ доступСн ΠΊΠ°ΠΊ `/webapp-theme-css/neon/style.css`. + +ΠšΠΎΡ€Π½Π΅Π²ΠΎΠΉ ΠΊΠΎΠ½Ρ‚Π΅ΠΉΠ½Π΅Ρ€ ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚ классы: + +```text +app-shell theme-dark theme-key-neon theme-css-style +``` + +Для свСтлой схСмы Π±ΡƒΠ΄Π΅Ρ‚ `theme-light`. Класс `theme-key-` - основной ΡΠΊΠΎΡ€ΡŒ для CSS Ρ‚Π΅ΠΌΡ‹. ВсСгда Π½Π°Ρ‡ΠΈΠ½Π°ΠΉΡ‚Π΅ сСлСкторы с Π½Π΅Π³ΠΎ, Ρ‡Ρ‚ΠΎΠ±Ρ‹ Ρ‚Π΅ΠΌΠ° Π½Π΅ Π·Π°Π΄Π΅Π²Π°Π»Π° Π΄Ρ€ΡƒΠ³ΠΈΠ΅ Ρ€Π΅ΠΆΠΈΠΌΡ‹: + +```css +.theme-key-neon.app-shell { + --surface-sheen: rgba(168, 85, 247, 0.12); + --shadow-soft: 0 18px 48px rgba(12, 5, 30, 0.44); +} + +.theme-key-neon .card { + border-color: color-mix(in srgb, var(--accent) 34%, var(--border)); + background: + linear-gradient(135deg, rgba(168, 85, 247, 0.14), rgba(56, 189, 248, 0.05)), + var(--panel); +} + +.theme-key-neon .bottom-nav button.active, +.theme-key-neon .admin-nav-item.active { + box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 24%, transparent); +} +``` + +CSS ΠΌΠΎΠΆΠ½ΠΎ ΠΏΠΈΡΠ°Ρ‚ΡŒ для ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΡΠΊΠΎΠΉ части ΠΈ Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ ΠΎΠ΄Π½ΠΎΠ²Ρ€Π΅ΠΌΠ΅Π½Π½ΠΎ: + +```css +.theme-key-neon .period-card, +.theme-key-neon .method-card, +.theme-key-neon .option-row { + border-radius: 16px; +} + +.theme-key-neon .admin-card, +.theme-key-neon .admin-stat-card, +.theme-key-neon .admin-revenue-panel { + border-radius: 16px; +} +``` + +ΠžΠ³Ρ€Π°Π½ΠΈΡ‡Π΅Π½ΠΈΡ: + +- CSS-Ρ„Π°ΠΉΠ» Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ Π²Π½ΡƒΡ‚Ρ€ΠΈ ΠΏΠ°ΠΏΠΊΠΈ Ρ‚Π΅ΠΌΡ‹; +- Ρ€Π°Π·ΠΌΠ΅Ρ€ CSS - Π΄ΠΎ 512 KiB; +- ΠΏΡƒΡ‚ΡŒ Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚ ΡΠΎΠ΄Π΅Ρ€ΠΆΠ°Ρ‚ΡŒ `..`; +- ΡƒΠ΄Π°Π»Π΅Π½Π½Ρ‹Π΅ CSS, `data:` ΠΈ protocol-relative URL Π² `css_file` Π½Π΅ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π°ΡŽΡ‚ΡΡ. + +## АссСты Ρ‚Π΅ΠΌΡ‹ + +ΠšΠ°Ρ€Ρ‚ΠΈΠ½ΠΊΠΈ Ρ‚Π΅ΠΌΡ‹ кладутся рядом с `theme.json` ΠΈ ΠΎΡ‚Π΄Π°ΡŽΡ‚ΡΡ Ρ‡Π΅Ρ€Π΅Π·: + +```text +/webapp-theme-assets// +``` + +ΠŸΡ€ΠΈΠΌΠ΅Ρ€: + +```css +.theme-key-neon .btn-primary::before { + content: ""; + width: 16px; + height: 16px; + background: url("/webapp-theme-assets/neon/icons/spark.png") center / contain no-repeat; +} +``` + +Π Π°Π·Ρ€Π΅ΡˆΠ΅Π½Ρ‹ `png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `ico`. Один asset - Π΄ΠΎ 1 MiB. Для ΡˆΡ€ΠΈΡ„Ρ‚ΠΎΠ² Π»ΡƒΡ‡ΡˆΠ΅ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ внСшниС источники, ΡƒΠΆΠ΅ Ρ€Π°Π·Ρ€Π΅ΡˆΠ΅Π½Π½Ρ‹Π΅ CSP (`fonts.googleapis.com`, `fonts.gstatic.com`, `cdn.jsdelivr.net`) ΠΈΠ»ΠΈ систСмныС fallback-Ρ†Π΅ΠΏΠΎΡ‡ΠΊΠΈ Π² `font_*` Ρ‚ΠΎΠΊΠ΅Π½Π°Ρ…. + +## Пайплайн создания Π½ΠΎΠ²ΠΎΠΉ Ρ‚Π΅ΠΌΡ‹ + +1. Π’Ρ‹Π±Π΅Ρ€ΠΈΡ‚Π΅ ΠΊΠ»ΡŽΡ‡ Ρ‚Π΅ΠΌΡ‹. + + ΠšΠ»ΡŽΡ‡ Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ ΡΡ‚Π°Π±ΠΈΠ»ΡŒΠ½Ρ‹ΠΌ: ΠΏΠΎ Π½Π΅ΠΌΡƒ сохраняСтся выбранная Ρ‚Π΅ΠΌΠ° ΠΈ строятся URL ассСтов. Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉΡ‚Π΅ ΠΊΠΎΡ€ΠΎΡ‚ΠΊΠΈΠΉ slug: `neon`, `brand_dark`, `terminal-blue`. НС ΠΏΠ΅Ρ€Π΅ΠΈΠΌΠ΅Π½ΠΎΠ²Ρ‹Π²Π°ΠΉΡ‚Π΅ ΠΊΠ»ΡŽΡ‡ послС ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠΈ Π±Π΅Π· ΠΌΠΈΠ³Ρ€Π°Ρ†ΠΈΠΈ Ρ„Π°ΠΉΠ»ΠΎΠ² ΠΈ сохранСнных настроСк. + +2. Π‘ΠΎΠ·Π΄Π°ΠΉΡ‚Π΅ ΠΏΠ°ΠΏΠΊΡƒ Π² `WEBAPP_THEMES_DIR`. + + Π’ Docker ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ это `data/themes`. Если Π²ΠΊΠ»ΡŽΡ‡Π΅Π½ bind mount `./data:/app/data`, ΡƒΠ±Π΅Π΄ΠΈΡ‚Π΅ΡΡŒ, Ρ‡Ρ‚ΠΎ ΠΊΠΎΠ½Ρ‚Π΅ΠΉΠ½Π΅Ρ€ ΠΌΠΎΠΆΠ΅Ρ‚ ΠΏΠΈΡΠ°Ρ‚ΡŒ Π² `data`. + + ```bash + mkdir -p data/themes/neon + ``` + +3. Π‘ΠΊΠΎΠΏΠΈΡ€ΡƒΠΉΡ‚Π΅ Π±Π»ΠΈΠΆΠ°ΠΉΡˆΡƒΡŽ Π±Π°Π·ΠΎΠ²ΡƒΡŽ Ρ‚Π΅ΠΌΡƒ. + + Для ΠΎΠ±Ρ‹Ρ‡Π½ΠΎΠΉ Π±Ρ€Π΅Π½Π΄ΠΎΠ²ΠΎΠΉ Ρ‚Π΅ΠΌΡ‹ Ρ‡Π°Ρ‰Π΅ всСго ΡƒΠ΄ΠΎΠ±Π½Π΅Π΅ Π½Π°Ρ‡Π°Ρ‚ΡŒ с `dark` ΠΈΠ»ΠΈ `light`. Для Π³Π»ΡƒΠ±ΠΎΠΊΠΎΠ³ΠΎ CSS-скина ΠΌΠΎΠΆΠ½ΠΎ Π²Π·ΡΡ‚ΡŒ `ascii` ΠΈΠ»ΠΈ `windows95` ΠΊΠ°ΠΊ ΠΏΡ€ΠΈΠΌΠ΅Ρ€ Ρ‚ΠΎΠ³ΠΎ, насколько Π΄Π°Π»Π΅ΠΊΠΎ ΠΌΠΎΠΆΠ½ΠΎ ΡƒΠΉΡ‚ΠΈ ΠΎΡ‚ стандартного Π²ΠΈΠ΄Π°. + + ```bash + cp bot/app/web/themes/dark/theme.json data/themes/neon/theme.json + ``` + +4. ΠžΡ‚Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΡƒΠΉΡ‚Π΅ `theme.json`. + + Π‘Π½Π°Ρ‡Π°Π»Π° помСняйтС `key`, `names`, `default`, `use_primary_accent` ΠΈ Π±Π°Π·ΠΎΠ²Ρ‹Π΅ Ρ‚ΠΎΠΊΠ΅Π½Ρ‹. На этом этапС ΠΌΠΎΠΆΠ½ΠΎ Π²ΠΎΠΎΠ±Ρ‰Π΅ Π½Π΅ ΡΠΎΠ·Π΄Π°Π²Π°Ρ‚ΡŒ CSS: ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΡƒΠΆΠ΅ ΡƒΠ²ΠΈΠ΄ΠΈΡ‚ Ρ‚Π΅ΠΌΡƒ ΠΊΠ°ΠΊ Π½ΠΎΠ²Ρ‹ΠΉ Π½Π°Π±ΠΎΡ€ Ρ‚ΠΎΠΊΠ΅Π½ΠΎΠ². + +5. ЗапуститС ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΈ ΠΎΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ Π°Π΄ΠΌΠΈΠ½ΠΊΡƒ. + + Π Π°Π·Π΄Π΅Π» **Π’Π½Π΅ΡˆΠ½ΠΈΠΉ Π²ΠΈΠ΄** Π·Π°Π³Ρ€ΡƒΠΆΠ°Π΅Ρ‚ `/api/admin/themes`, backend Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ `WEBAPP_THEMES_DIR`, добавляСт ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ Π±Π°Π·ΠΎΠ²Ρ‹Π΅ Ρ‚Π΅ΠΌΡ‹ ΠΈ Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³. НаТмитС **ΠžΠ±Π½ΠΎΠ²ΠΈΡ‚ΡŒ**, Ссли ΠΏΠ°ΠΏΠΊΠ° Π±Ρ‹Π»Π° создана Π²ΠΎ врСмя Ρ€Π°Π±ΠΎΡ‚Ρ‹ прилоТСния. + +6. ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ Ρ‚Π΅ΠΌΡƒ Ρ‡Π΅Ρ€Π΅Π· прСдпросмотр. + + Π’ ΠΊΠ°Ρ€Ρ‚ΠΎΡ‡ΠΊΠ΅ Ρ‚Π΅ΠΌΡ‹ Π½Π°ΠΆΠΌΠΈΡ‚Π΅ **ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€** ΠΈΠ»ΠΈ ΠΎΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅: + + ```text + https://app.domain.com/home?theme_preview=neon + ``` + + ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€ Π½Π΅ мСняСт Π³Π»ΠΎΠ±Π°Π»ΡŒΠ½ΡƒΡŽ Ρ‚Π΅ΠΌΡƒ ΠΈ ΡƒΠ΄ΠΎΠ±Π΅Π½ для ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ CSS Π΄ΠΎ ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠΈ. + +7. ΠŸΠΎΠ΄Π±Π΅Ρ€ΠΈΡ‚Π΅ accent ΠΈ ΠΌΠ°ΡΡˆΡ‚Π°Π± Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ°. + + Π’ Π°Π΄ΠΌΠΈΠ½ΠΊΠ΅ ΠΌΠΎΠΆΠ½ΠΎ ΠΌΠ΅Π½ΡΡ‚ΡŒ accent ΠΈ `home_logo_scale` Π±Π΅Π· Ρ€ΡƒΡ‡Π½ΠΎΠ³ΠΎ рСдактирования JSON. ΠŸΡ€ΠΈ сохранСнии backend ΠΏΠ΅Ρ€Π΅ΠΏΠΈΡˆΠ΅Ρ‚ `theme.json` Π² `WEBAPP_THEMES_DIR`, выставит Ρ€ΠΎΠ²Π½ΠΎ ΠΎΠ΄ΠΈΠ½ `default` ΠΈ сбросит кСш ΠΏΡƒΠ±Π»ΠΈΡ‡Π½Ρ‹Ρ… настроСк. + +8. Π”ΠΎΠ±Π°Π²ΡŒΡ‚Π΅ `style.css`, Ссли Ρ‚ΠΎΠΊΠ΅Π½ΠΎΠ² ΠΌΠ°Π»ΠΎ. + + Π‘ΠΎΠ·Π΄Π°ΠΉΡ‚Π΅ Ρ„Π°ΠΉΠ», ΡƒΠΊΠ°ΠΆΠΈΡ‚Π΅ Π΅Π³ΠΎ Π² `theme.json`: + + ```json + { + "css_file": "style.css" + } + ``` + + НачинайтС с пСрСопрСдСлСния CSS-ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Ρ… Π½Π° `.theme-key-neon.app-shell`, Π·Π°Ρ‚Π΅ΠΌ ΠΏΠ΅Ρ€Π΅Ρ…ΠΎΠ΄ΠΈΡ‚Π΅ ΠΊ ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½Ρ‹ΠΌ ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚Π°ΠΌ. ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠΉΡ‚Π΅ ΠΌΠΈΠ½ΠΈΠΌΡƒΠΌ: главная, ΠΎΠΏΠ»Π°Ρ‚Π°, настройки, ΠΌΠΎΠ΄Π°Π»ΠΊΠΈ, админский Π΄Π°ΡˆΠ±ΠΎΡ€Π΄, Ρ‚Π°Π±Π»ΠΈΡ†Π° ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ, Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€ Ρ‚Π°Ρ€ΠΈΡ„ΠΎΠ². + +9. Π”ΠΎΠ±Π°Π²ΡŒΡ‚Π΅ ассСты ΠΏΡ€ΠΈ нСобходимости. + + ΠŸΠΎΠ»ΠΎΠΆΠΈΡ‚Π΅ ΠΊΠ°Ρ€Ρ‚ΠΈΠ½ΠΊΠΈ Π² ΠΏΠΎΠ΄ΠΏΠ°ΠΏΠΊΡƒ Ρ‚Π΅ΠΌΡ‹ ΠΈ ΡΡΡ‹Π»Π°ΠΉΡ‚Π΅ΡΡŒ Π½Π° Π½ΠΈΡ… Ρ‡Π΅Ρ€Π΅Π· `/webapp-theme-assets//...`. НС ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉΡ‚Π΅ ΠΎΡ‚Π½ΠΎΡΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ ΠΏΡƒΡ‚ΠΈ Π²Ρ€ΠΎΠ΄Π΅ `url("icons/x.png")`, Ссли CSS ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ с Π΄Ρ€ΡƒΠ³ΠΎΠ³ΠΎ URL-уровня; явный `/webapp-theme-assets/neon/icons/x.png` Π½Π°Π΄Π΅ΠΆΠ½Π΅Π΅. + +10. НастройтС ΠΏΠΎΠ²Π΅Π΄Π΅Π½ΠΈΠ΅ Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ. + + Если Ρ‚Π΅ΠΌΠ° сильно дСкоративная ΠΈ ΠΌΠ΅ΡˆΠ°Π΅Ρ‚ Ρ€Π°Π±ΠΎΡ‡Π΅ΠΉ Π°Π΄ΠΌΠΈΠ½ΠΊΠ΅, Π²Ρ‹ΡΡ‚Π°Π²ΡŒΡ‚Π΅ `use_in_admin: false`. ΠŸΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΠΈ увидят Ρ‚Π΅ΠΌΡƒ, Π° администраторы Π² Ρ€Π°Π·Π΄Π΅Π»Π΅ Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ ΠΏΠΎΠ»ΡƒΡ‡Π°Ρ‚ `dark` ΠΊΠ°ΠΊ fallback. + +11. Π‘Π΄Π΅Π»Π°ΠΉΡ‚Π΅ Ρ‚Π΅ΠΌΡƒ Π΄Π΅Ρ„ΠΎΠ»Ρ‚Π½ΠΎΠΉ. + + Π•ΡΡ‚ΡŒ Π΄Π²Π° способа: + + - Π² Π°Π΄ΠΌΠΈΠ½ΠΊΠ΅ Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ Ρ‚Π΅ΠΌΡƒ ΠΈ ΡΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ; + - ΡƒΠΊΠ°Π·Π°Ρ‚ΡŒ `WEBAPP_DEFAULT_THEME=neon` Π² `.env`, Ссли Π½ΡƒΠΆΠ΅Π½ ТСсткий override Π½Π° ΡƒΡ€ΠΎΠ²Π½Π΅ окруТСния. + +12. ЗафиксируйтС Ρ‚Π΅ΠΌΡƒ. + + Для Ρ‚Π΅ΠΌΡ‹, которая Π΄ΠΎΠ»ΠΆΠ½Π° Π΅Ρ…Π°Ρ‚ΡŒ вмСстС с ΠΏΡ€ΠΎΠ΅ΠΊΡ‚ΠΎΠΌ, Π΄ΠΎΠ±Π°Π²ΡŒΡ‚Π΅ Π΅Π΅ Π² Ρ€Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€ΠΈΠΉ Π² `bot/app/web/themes` ΠΈ ΠΏΡ€ΠΈ нСобходимости Ρ€Π°ΡΡˆΠΈΡ€ΡŒΡ‚Π΅ `DEFAULT_THEME_KEYS` Π² `config/webapp_themes_config.py`. Для ΠΏΡ€ΠΈΠ²Π°Ρ‚Π½ΠΎΠΉ инсталляции достаточно Ρ…Ρ€Π°Π½ΠΈΡ‚ΡŒ Π΅Π΅ Π² `data/themes`. + +## Насколько Π³Π»ΡƒΠ±ΠΎΠΊΠΎ ΠΌΠΎΠΆΠ½ΠΎ ΠΌΠ΅Π½ΡΡ‚ΡŒ Π²ΠΈΠ΄ + +Π£Ρ€ΠΎΠ²Π½ΠΈ кастомизации: + +1. **Быстрый Π±Ρ€Π΅Π½Π΄** - Ρ‚ΠΎΠΊΠ΅Π½Ρ‹ `accent`, `bg`, `panel`, `text`, `radius`, Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ Π² Π°Π΄ΠΌΠΈΠ½ΠΊΠ΅. Код Π½Π΅ Π½ΡƒΠΆΠ΅Π½. +2. **Полная ΠΏΠ°Π»ΠΈΡ‚Ρ€Π°** - всС ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΡΠΊΠΈΠ΅ ΠΈ admin-Ρ‚ΠΎΠΊΠ΅Π½Ρ‹, ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½Ρ‹Π΅ ΡˆΡ€ΠΈΡ„Ρ‚Ρ‹, ΠΌΠ°ΡΡˆΡ‚Π°Π± Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ°. +3. **CSS-скин** - ΠΏΠ΅Ρ€Π΅ΠΎΠΏΡ€Π΅Π΄Π΅Π»Π΅Π½ΠΈΠ΅ ΠΊΠ°Ρ€Ρ‚ΠΎΡ‡Π΅ΠΊ, Π½Π°Π²ΠΈΠ³Π°Ρ†ΠΈΠΈ, Ρ‚Π°Π±Π»ΠΈΡ†, ΠΌΠΎΠ΄Π°Π»ΠΎΠΊ, progress/skeleton/toast, desktop/mobile раскладок. +4. **ΠŸΠΎΡ‡Ρ‚ΠΈ Π½ΠΎΠ²Ρ‹ΠΉ UI** - Ρ‚Π΅ΠΌΠ° Π²Ρ€ΠΎΠ΄Π΅ `windows95` ΠΈΠ»ΠΈ `ascii`: ΠΌΠΎΠΆΠ½ΠΎ ΠΌΠ΅Π½ΡΡ‚ΡŒ Ρ„ΠΎΡ€ΠΌΡƒ ΠΊΠΎΠ½Ρ‚Ρ€ΠΎΠ»ΠΎΠ², ΠΈΠΊΠΎΠ½ΠΊΠΈ, эффСкты, Ρ‚Π°Π±Π»ΠΈΡ†Ρ‹ ΠΈ Π²ΠΈΠ·ΡƒΠ°Π»ΡŒΠ½Ρ‹ΠΉ язык Ρ†Π΅Π»ΠΈΠΊΠΎΠΌ, ΠΏΠΎΠΊΠ° сохраняСтся DOM ΠΈ ΠΈΠ½Ρ‚Π΅Ρ€Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Π΅ состояния. + +НС стоит ΠΌΠ΅Π½ΡΡ‚ΡŒ Ρ‡Π΅Ρ€Π΅Π· CSS смысловыС состояния: ΡΠΊΡ€Ρ‹Π²Π°Ρ‚ΡŒ ошибки, ΠΎΡ‚ΠΊΠ»ΡŽΡ‡Π°Ρ‚ΡŒ фокус, ΠΏΠ΅Ρ€Π΅ΠΊΡ€Ρ‹Π²Π°Ρ‚ΡŒ ΠΊΠ½ΠΎΠΏΠΊΠΈ Π½Π΅Π²ΠΈΠ΄ΠΈΠΌΡ‹ΠΌΠΈ слоями ΠΈΠ»ΠΈ Π΄Π΅Π»Π°Ρ‚ΡŒ `display: none` для ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹Ρ… дСйствий ΠΎΠΏΠ»Π°Ρ‚Ρ‹ ΠΈ Π°Π²Ρ‚ΠΎΡ€ΠΈΠ·Π°Ρ†ΠΈΠΈ. Π’Π΅ΠΌΠ° Π΄ΠΎΠ»ΠΆΠ½Π° ΠΌΠ΅Π½ΡΡ‚ΡŒ внСшний Π²ΠΈΠ΄, Π° Π½Π΅ бизнСс-Π»ΠΎΠ³ΠΈΠΊΡƒ. + +## Диагностика + +Если Ρ‚Π΅ΠΌΠ° Π½Π΅ появилась: + +- ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅, Ρ‡Ρ‚ΠΎ `theme.json` Π»Π΅ΠΆΠΈΡ‚ Ρ€ΠΎΠ²Π½ΠΎ Π² `WEBAPP_THEMES_DIR//theme.json`; +- ΠΊΠ»ΡŽΡ‡ состоит Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΈΠ· Π»Π°Ρ‚ΠΈΠ½ΠΈΡ†Ρ‹, Ρ†ΠΈΡ„Ρ€, `_` ΠΈ `-`; +- JSON Π²Π°Π»ΠΈΠ΄Π΅Π½; +- Ρ‚Π΅ΠΌΠ° Π½Π΅ ΠΎΡ‚ΠΊΠ»ΡŽΡ‡Π΅Π½Π° Ρ‡Π΅Ρ€Π΅Π· `enabled: false`; +- Π² Π»ΠΎΠ³Π°Ρ… Π½Π΅Ρ‚ прСдупрСТдСния `Ignoring theme descriptor`. + +Если CSS Π½Π΅ примСнился: + +- ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ `css_file` ΠΈ URL `/webapp-theme-css//`; +- ΡƒΠ±Π΅Π΄ΠΈΡ‚Π΅ΡΡŒ, Ρ‡Ρ‚ΠΎ Ρ„Π°ΠΉΠ» мСньшС 512 KiB; +- Π½Π°Ρ‡ΠΈΠ½Π°ΠΉΡ‚Π΅ сСлСкторы с `.theme-key-`; +- ΠΎΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ `/home?theme_preview=` Π² Π½ΠΎΠ²ΠΎΠΌ ΠΎΠΊΠ½Π΅, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΈΡΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ сохранСнный старый Π²Ρ‹Π±ΠΎΡ€. + +Если ассСты Π½Π΅ грузятся: + +- ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠΉΡ‚Π΅ ΠΏΡƒΡ‚ΡŒ `/webapp-theme-assets//`; +- ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ Ρ€Π°ΡΡˆΠΈΡ€Π΅Π½ΠΈΠ΅: `png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `ico`; +- Ρ€Π°Π·ΠΌΠ΅Ρ€ ΠΊΠ°ΠΆΠ΄ΠΎΠ³ΠΎ Ρ„Π°ΠΉΠ»Π° Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ Π΄ΠΎ 1 MiB; +- ΠΏΡƒΡ‚ΡŒ Π½Π΅ Π΄ΠΎΠ»ΠΆΠ΅Π½ ΡΠΎΠ΄Π΅Ρ€ΠΆΠ°Ρ‚ΡŒ ΠΏΡ€ΠΎΠ±Π΅Π»Ρ‹, ΠΊΠΈΡ€ΠΈΠ»Π»ΠΈΡ†Ρƒ ΠΈΠ»ΠΈ `..`. diff --git a/docs/webapp.md b/docs/webapp.md index b018d64..d0878f3 100644 --- a/docs/webapp.md +++ b/docs/webapp.md @@ -24,10 +24,8 @@ WEBAPP_SERVER_HOST=0.0.0.0 WEBAPP_SERVER_PORT=8081 SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/ WEBAPP_TITLE="Моя подписка" -WEBAPP_PRIMARY_COLOR="#00fe7a" -WEBAPP_LOGO_URL= -WEBAPP_LOGO_EMOJI="πŸ«₯" -WEBAPP_LOGO_EMOJI_FONT=system +WEBAPP_THEMES_DIR=data/themes +WEBAPP_DEFAULT_THEME= WEBAPP_SESSION_SECRET= WEBHOOK_SECRET_TOKEN= WEBAPP_SESSION_TTL_SECONDS=86400 @@ -49,7 +47,9 @@ SMTP_FROM_EMAIL=no-reply@domain.com SMTP_FROM_NAME=Remnawave Minishop ``` -Если `WEBAPP_LOGO_URL` пустой, Π² шапкС ΠΈ Π½Π° экранС Π²Ρ…ΠΎΠ΄Π° показываСтся запасной **emoji-Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ** (`WEBAPP_LOGO_EMOJI`) ΠΈ ΠΏΡ€ΠΈ нСобходимости ΡΡ‚ΠΈΠ»ΡŒ отрисовки (`WEBAPP_LOGO_EMOJI_FONT`: Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ `system`, `noto-color`, `noto-color-animated`, `twemoji`). Если SMTP-настройки Π½Π΅ Π·Π°ΠΏΠΎΠ»Π½Π΅Π½Ρ‹, Π²Ρ…ΠΎΠ΄ ΠΏΠΎ email скрываСтся. +Π’Π½Π΅ΡˆΠ½ΠΈΠΉ Π²ΠΈΠ΄ настраиваСтся Π² Π°Π΄ΠΌΠΈΠ½ΠΊΠ΅: Ρ€Π°Π·Π΄Π΅Π» **Π’Π½Π΅ΡˆΠ½ΠΈΠΉ Π²ΠΈΠ΄** управляСт Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠΎΠΌ, emoji-Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠΎΠΌ, accent-Ρ†Π²Π΅Ρ‚ΠΎΠΌ, Π²Ρ‹Π±Ρ€Π°Π½Π½ΠΎΠΉ Ρ‚Π΅ΠΌΠΎΠΉ ΠΈ ΠΌΠ°ΡΡˆΡ‚Π°Π±ΠΎΠΌ Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ°. ΠšΠ°ΡΡ‚ΠΎΠΌΠ½Ρ‹Π΅ Ρ‚Π΅ΠΌΡ‹ Ρ‡ΠΈΡ‚Π°ΡŽΡ‚ΡΡ ΠΈΠ· `WEBAPP_THEMES_DIR`, Π° `WEBAPP_DEFAULT_THEME` ΠΌΠΎΠΆΠ΅Ρ‚ ΠΏΡ€ΠΈΠ½ΡƒΠ΄ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎ Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ Ρ‚Π΅ΠΌΡƒ ΠΏΠΎ ΠΊΠ»ΡŽΡ‡Ρƒ. ΠŸΠΎΠ΄Ρ€ΠΎΠ±Π½Ρ‹ΠΉ ΠΊΠΎΠ½Ρ‚Ρ€Π°ΠΊΡ‚ `theme.json`, CSS/asset-Ρ€ΠΎΡƒΡ‚Ρ‹ ΠΈ ΠΏΠ°ΠΉΠΏΠ»Π°ΠΉΠ½ создания Ρ‚Π΅ΠΌΡ‹ описаны Π² [webapp-themes.md](webapp-themes.md). + +Если SMTP-настройки Π½Π΅ Π·Π°ΠΏΠΎΠ»Π½Π΅Π½Ρ‹, Π²Ρ…ΠΎΠ΄ ΠΏΠΎ email скрываСтся. ## Telegram-авторизация diff --git a/locales/en.json b/locales/en.json index 1dc21ed..6812dc6 100644 --- a/locales/en.json +++ b/locales/en.json @@ -768,6 +768,9 @@ "wa_activate": "Activate", "wa_settings_avatar_alt": "User avatar", "wa_settings_language": "Language", + "wa_settings_theme": "Appearance theme", + "wa_settings_theme_follow_default": "Server default", + "wa_settings_theme_update_failed": "Failed to update theme", "wa_settings_link_telegram": "Telegram linked", "wa_settings_not_linked": "Not linked", "wa_settings_link_email": "Email linked", @@ -852,6 +855,7 @@ "admin_nav_logs": "Logs", "admin_nav_system": "System", "admin_nav_tariffs": "Tariffs", + "admin_nav_themes": "Themes", "admin_nav_settings": "Settings", "admin_section_stats_title": "Dashboard", "admin_section_stats_subtitle": "Audience, revenue, Remnawave panel, and recent payments", @@ -869,6 +873,8 @@ "admin_section_logs_subtitle": "User events and admin actions", "admin_section_tariffs_title": "Tariffs", "admin_section_tariffs_subtitle": "Sales catalog, periods, packages, and limits", + "admin_section_themes_title": "Web App themes", + "admin_section_themes_subtitle": "Colors, fonts, and Mini App appearance", "admin_section_settings_title": "App Settings", "admin_section_settings_subtitle": "Overrides for .env, applied instantly", "admin_filter_all": "All", @@ -1047,6 +1053,7 @@ "admin_stats_queue_groups": " groups", "admin_id": "ID", "admin_status_default": "Default", + "admin_status_current": "Current", "admin_tariff_squads": "Squads", "admin_tariff_premium": "Premium", "admin_settings_badge_secret": "Secret", @@ -1221,6 +1228,13 @@ "admin_tariffs_stat_disabled": "Disabled", "admin_tariffs_stat_disabled_hint": "Hidden from showcase", "admin_tariffs_catalog_empty": "The catalog is empty. Add your first tariff; a catalog JSON file will be created after saving.", + "admin_themes_catalog_title": "Web App themes", + "admin_themes_catalog_sub": "Select the current theme from a card; edit appearance through files in the theme folder", + "admin_themes_use_primary_accent": "Use primary accent color", + "admin_themes_use_in_admin": "Use in admin panel", + "admin_themes_catalog_empty": "The catalog is empty. Add a theme folder to data/themes and refresh.", + "admin_themes_saved": "Themes saved", + "admin_themes_save_failed": "Failed to save themes", "admin_no_description": "No description", "admin_tariff_model_traffic": "Traffic", "admin_tariff_model_periods": "Periods", diff --git a/locales/ru.json b/locales/ru.json index dc6dbdf..113ee5a 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -768,6 +768,9 @@ "wa_activate": "ΠΠΊΡ‚ΠΈΠ²ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ", "wa_settings_avatar_alt": "Аватар ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ", "wa_settings_language": "Π’Ρ‹Π±ΠΎΡ€ языка", + "wa_settings_theme": "Π’Π΅ΠΌΠ° оформлСния", + "wa_settings_theme_follow_default": "По ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ (сСрвСр)", + "wa_settings_theme_update_failed": "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΠ±Π½ΠΎΠ²ΠΈΡ‚ΡŒ Ρ‚Π΅ΠΌΡƒ", "wa_settings_link_telegram": "ΠŸΡ€ΠΈΠ²ΡΠ·ΠΊΠ° Telegram", "wa_settings_not_linked": "НС привязан", "wa_settings_link_email": "ΠŸΡ€ΠΈΠ²ΡΠ·ΠΊΠ° ΠΏΠΎΡ‡Ρ‚Ρ‹", @@ -852,6 +855,7 @@ "admin_nav_logs": "Π›ΠΎΠ³ΠΈ", "admin_nav_system": "БистСма", "admin_nav_tariffs": "Π’Π°Ρ€ΠΈΡ„Ρ‹", + "admin_nav_themes": "Π’Π΅ΠΌΡ‹", "admin_nav_settings": "Настройки", "admin_section_stats_title": "Π”Π°ΡˆΠ±ΠΎΡ€Π΄", "admin_section_stats_subtitle": "Аудитория, Π΄ΠΎΡ…ΠΎΠ΄Ρ‹, панСль Remnawave ΠΈ послСдниС ΠΏΠ»Π°Ρ‚Π΅ΠΆΠΈ", @@ -869,6 +873,8 @@ "admin_section_logs_subtitle": "Бобытия ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ ΠΈ Π°Π΄ΠΌΠΈΠ½-дСйствия", "admin_section_tariffs_title": "Π’Π°Ρ€ΠΈΡ„Ρ‹", "admin_section_tariffs_subtitle": "ΠšΠ°Ρ‚Π°Π»ΠΎΠ³ ΠΏΡ€ΠΎΠ΄Π°ΠΆ, ΠΏΠ΅Ρ€ΠΈΠΎΠ΄Ρ‹, ΠΏΠ°ΠΊΠ΅Ρ‚Ρ‹ ΠΈ Π»ΠΈΠΌΠΈΡ‚Ρ‹", + "admin_section_themes_title": "Π’Π΅ΠΌΡ‹ Web App", + "admin_section_themes_subtitle": "Π¦Π²Π΅Ρ‚Π°, ΡˆΡ€ΠΈΡ„Ρ‚Ρ‹ ΠΈ Ρ‚Π΅ΠΌΡ‹ оформлСния Mini App", "admin_section_settings_title": "Настройки прилоТСния", "admin_section_settings_subtitle": "ΠžΠ²Π΅Ρ€Ρ€Π°ΠΉΠ΄Ρ‹ Π½Π°Π΄ .env, ΠΏΡ€ΠΈΠΌΠ΅Π½ΡΡŽΡ‚ΡΡ ΠΌΠ³Π½ΠΎΠ²Π΅Π½Π½ΠΎ", "admin_filter_all": "ВсС", @@ -1047,6 +1053,7 @@ "admin_stats_queue_groups": " Π³Ρ€ΡƒΠΏΠΏ", "admin_id": "ID", "admin_status_default": "По ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ", + "admin_status_current": "ВСкущая", "admin_tariff_squads": "Squads", "admin_tariff_premium": "Premium", "admin_settings_badge_secret": "Secret", @@ -1221,6 +1228,13 @@ "admin_tariffs_stat_disabled": "ΠžΡ‚ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΎ", "admin_tariffs_stat_disabled_hint": "Π‘ΠΊΡ€Ρ‹Ρ‚ΠΎ с Π²ΠΈΡ‚Ρ€ΠΈΠ½Ρ‹", "admin_tariffs_catalog_empty": "ΠšΠ°Ρ‚Π°Π»ΠΎΠ³ пуст. Π”ΠΎΠ±Π°Π²ΡŒΡ‚Π΅ ΠΏΠ΅Ρ€Π²Ρ‹ΠΉ Ρ‚Π°Ρ€ΠΈΡ„, послС сохранСния Π±ΡƒΠ΄Π΅Ρ‚ создан JSON-Ρ„Π°ΠΉΠ» ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³Π°.", + "admin_themes_catalog_title": "Π’Π΅ΠΌΡ‹ Web App", + "admin_themes_catalog_sub": "ВСкущая Ρ‚Π΅ΠΌΠ° выбираСтся ΠΊΠ°Ρ€Ρ‚ΠΎΡ‡ΠΊΠΎΠΉ; внСшний Π²ΠΈΠ΄ рСдактируСтся Ρ„Π°ΠΉΠ»Π°ΠΌΠΈ Π² ΠΏΠ°ΠΏΠΊΠ΅ Ρ‚Π΅ΠΌΡ‹", + "admin_themes_use_primary_accent": "ΠŸΡ€ΠΎΡ‚ΡΠ³ΠΈΠ²Π°Ρ‚ΡŒ Π°ΠΊΡ†Π΅Π½Ρ‚Π½Ρ‹ΠΉ Ρ†Π²Π΅Ρ‚", + "admin_themes_use_in_admin": "Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ Π² Π°Π΄ΠΌΠΈΠ½ΠΊΠ΅", + "admin_themes_catalog_empty": "ΠšΠ°Ρ‚Π°Π»ΠΎΠ³ пуст. Π”ΠΎΠ±Π°Π²ΡŒΡ‚Π΅ ΠΏΠ°ΠΏΠΊΡƒ Ρ‚Π΅ΠΌΡ‹ Π² data/themes ΠΈ ΠΎΠ±Π½ΠΎΠ²ΠΈΡ‚Π΅ список.", + "admin_themes_saved": "Π’Π΅ΠΌΡ‹ сохранСны", + "admin_themes_save_failed": "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΡΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ Ρ‚Π΅ΠΌΡ‹", "admin_no_description": "Π‘Π΅Π· описания", "admin_tariff_model_traffic": "Π’Ρ€Π°Ρ„ΠΈΠΊ", "admin_tariff_model_periods": "ΠŸΠ΅Ρ€ΠΈΠΎΠ΄Ρ‹", diff --git a/requirements.txt b/requirements.txt index 9ce503e..55c818f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ sqlalchemy[asyncio]==2.0.49 asyncpg==0.31.0 aiocryptopay==0.4.8 PyJWT[crypto]==2.12.1 +Pillow==12.2.0 diff --git a/tests/test_settings.py b/tests/test_settings.py index 9dbf44b..d3ec935 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -28,6 +28,31 @@ class SettingsTests(unittest.TestCase): self.assertTrue(settings.WEBHOOK_SECRET_TOKEN) self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400) + def test_deprecated_webapp_appearance_env_values_are_ignored(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + WEBAPP_PRIMARY_COLOR="#ff0000", + WEBAPP_LOGO_URL="https://cdn.example.com/logo.png", + WEBAPP_LOGO_USE_EMOJI=True, + WEBAPP_LOGO_EMOJI="πŸ”₯", + WEBAPP_LOGO_EMOJI_FONT="twemoji", + WEBAPP_FAVICON_USE_CUSTOM=True, + WEBAPP_FAVICON_URL="https://cdn.example.com/favicon.png", + WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png", + ) + + self.assertEqual(settings.WEBAPP_PRIMARY_COLOR, "#00fe7a") + self.assertIsNone(settings.WEBAPP_LOGO_URL) + self.assertFalse(settings.WEBAPP_LOGO_USE_EMOJI) + self.assertEqual(settings.WEBAPP_LOGO_EMOJI, "πŸ«₯") + self.assertEqual(settings.WEBAPP_LOGO_EMOJI_FONT, "system") + self.assertFalse(settings.WEBAPP_FAVICON_USE_CUSTOM) + self.assertIsNone(settings.WEBAPP_FAVICON_URL) + self.assertIsNone(settings.WEBAPP_LOGO_FAVICON_URL) + def test_tariffs_config_missing_uses_legacy_fallback(self): settings = Settings( _env_file=None, diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 4d3be96..664ae12 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -1,4 +1,5 @@ import asyncio +import io import json import os import tempfile @@ -8,9 +9,13 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import patch +from PIL import Image + from bot.app.web import subscription_webapp +from bot.app.web.admin_api_impl import themes as admin_themes from bot.app.web.webapp import assets as webapp_assets from config.settings import Settings +from config.webapp_themes_config import builtin_webapp_themes_config class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): @@ -91,6 +96,94 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): r"^/webapp-logo\?v=[0-9a-f]{12}$", ) + def test_uploaded_webapp_logo_url_is_served_directly(self): + settings = SimpleNamespace( + WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-abcdef1234567890.png" + ) + + self.assertEqual( + subscription_webapp._resolve_webapp_logo_url(settings), + "/webapp-uploaded-logo/logo-abcdef1234567890.png", + ) + + def test_webapp_logo_is_hidden_when_emoji_logo_is_enabled(self): + settings = SimpleNamespace( + WEBAPP_LOGO_USE_EMOJI=True, + WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-abcdef1234567890.png", + ) + + self.assertEqual(subscription_webapp._resolve_webapp_logo_url(settings), "") + + def test_custom_webapp_favicon_takes_precedence(self): + settings = SimpleNamespace( + WEBAPP_FAVICON_USE_CUSTOM=True, + WEBAPP_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png", + WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/1111111111111111/icon-180.png", + ) + + self.assertEqual( + subscription_webapp._resolve_webapp_favicon_url(settings, "/logo.png"), + "/webapp-favicon/abcdef1234567890/icon-180.png", + ) + + def test_logo_generated_favicon_is_used_when_custom_disabled(self): + settings = SimpleNamespace( + WEBAPP_FAVICON_USE_CUSTOM=False, + WEBAPP_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png", + WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/1111111111111111/icon-180.png", + ) + + self.assertEqual( + subscription_webapp._resolve_webapp_favicon_url(settings, "/logo.png"), + "/webapp-favicon/1111111111111111/icon-180.png", + ) + + def test_logo_generated_favicon_is_not_used_without_logo(self): + settings = SimpleNamespace( + WEBAPP_FAVICON_USE_CUSTOM=False, + WEBAPP_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png", + WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/1111111111111111/icon-180.png", + ) + + self.assertEqual(subscription_webapp._resolve_webapp_favicon_url(settings, ""), "") + + def test_favicon_head_markup_includes_touch_icon(self): + markup = subscription_webapp._favicon_head_markup( + "/webapp-favicon/abcdef1234567890/icon-180.png" + ) + + self.assertIn('rel="apple-touch-icon"', markup) + self.assertIn("/webapp-favicon/abcdef1234567890/icon-32.png", markup) + + def test_favicon_set_generation_writes_common_icon_sizes(self): + buffer = io.BytesIO() + Image.new("RGBA", (2, 2), (0, 254, 122, 255)).save(buffer, format="PNG") + png_body = buffer.getvalue() + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(admin_themes, "WEBAPP_FAVICON_DIR", Path(tmpdir)): + payload = admin_themes._write_favicon_set(png_body, "image/png", "icon.png") + + self.assertRegex( + payload["favicon_url"], + r"^/webapp-favicon/[0-9a-f]{16}/icon-180\.png$", + ) + digest = payload["favicon_url"].split("/")[2] + self.assertTrue((Path(tmpdir) / digest / "icon-32.png").exists()) + self.assertTrue((Path(tmpdir) / digest / "apple-touch-icon.png").exists()) + self.assertTrue((Path(tmpdir) / digest / "favicon.ico").exists()) + + def test_initial_theme_head_markup_includes_css_and_tokens(self): + cfg = builtin_webapp_themes_config("#123456") + theme = cfg.theme_by_key("light") + request = SimpleNamespace(get=lambda key, default="": "nonce-value") + + markup = subscription_webapp._initial_theme_head_markup(request, theme, "#123456") + + self.assertIn("/webapp-theme-css/light/style.css", markup) + self.assertIn('nonce="nonce-value"', markup) + self.assertIn("--accent:#123456", markup) + self.assertIn("color-scheme:light", markup) + def test_animated_emoji_asset_path_uses_same_origin_route(self): self.assertEqual( subscription_webapp._webapp_animated_emoji_asset_path("🀩"), @@ -222,6 +315,155 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): ) self.assertEqual(response.text, "console.log('minified');") + async def test_theme_css_asset_route_serves_file_from_configured_directory(self): + with tempfile.TemporaryDirectory() as tmpdir: + themes_dir = Path(tmpdir) + (themes_dir / "custom").mkdir() + (themes_dir / "custom" / "theme.css").write_text( + ".theme-key-custom { --bg: red; }", encoding="utf-8" + ) + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=str(themes_dir), + ) + }, + match_info={"path": "custom/theme.css"}, + ) + + response = await subscription_webapp.theme_css_asset_route(request) + + self.assertEqual(response.content_type, "text/css") + self.assertEqual(response.headers["Cache-Control"], "no-cache") + self.assertIn("--bg: red", response.text) + + async def test_theme_css_asset_route_serves_default_theme_asset_from_theme_folder(self): + with tempfile.TemporaryDirectory() as tmpdir: + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=tmpdir, + ) + }, + match_info={"path": "light/style.css"}, + ) + + response = await subscription_webapp.theme_css_asset_route(request) + + self.assertEqual(response.content_type, "text/css") + self.assertIn(".theme-key-light", response.text) + self.assertTrue((Path(tmpdir) / "light" / "theme.json").exists()) + self.assertTrue((Path(tmpdir) / "light" / "style.css").exists()) + + async def test_theme_css_asset_route_rejects_path_traversal(self): + with tempfile.TemporaryDirectory() as tmpdir: + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=tmpdir, + ) + }, + match_info={"path": "../secret.css"}, + ) + + with self.assertRaises(webapp_assets.web.HTTPNotFound): + await subscription_webapp.theme_css_asset_route(request) + + async def test_theme_asset_route_serves_image_from_configured_directory(self): + with tempfile.TemporaryDirectory() as tmpdir: + themes_dir = Path(tmpdir) + (themes_dir / "custom" / "icons").mkdir(parents=True) + (themes_dir / "custom" / "icons" / "save.png").write_bytes(b"png-bytes") + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=str(themes_dir), + ) + }, + match_info={"path": "custom/icons/save.png"}, + ) + + response = await subscription_webapp.theme_asset_route(request) + + self.assertEqual(response.content_type, "image/png") + self.assertEqual(response.headers["Cache-Control"], "public, max-age=3600") + self.assertEqual(response.body, b"png-bytes") + + async def test_theme_asset_route_uses_immutable_cache_for_versioned_assets(self): + with tempfile.TemporaryDirectory() as tmpdir: + themes_dir = Path(tmpdir) + (themes_dir / "custom" / "icons").mkdir(parents=True) + (themes_dir / "custom" / "icons" / "save.png").write_bytes(b"png-bytes") + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=str(themes_dir), + ) + }, + match_info={"path": "custom/icons/save.png"}, + query={"v": "6"}, + ) + + response = await subscription_webapp.theme_asset_route(request) + + self.assertEqual( + response.headers["Cache-Control"], "public, max-age=31536000, immutable" + ) + + async def test_theme_asset_route_serves_default_theme_icon_from_theme_folder(self): + with tempfile.TemporaryDirectory() as tmpdir: + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=tmpdir, + ) + }, + match_info={"path": "windows95/icons/save.png"}, + ) + + response = await subscription_webapp.theme_asset_route(request) + + self.assertEqual(response.content_type, "image/png") + self.assertGreater(len(response.body), 0) + self.assertTrue((Path(tmpdir) / "windows95" / "theme.json").exists()) + self.assertTrue((Path(tmpdir) / "windows95" / "icons" / "save.png").exists()) + + async def test_theme_asset_route_rejects_path_traversal(self): + with tempfile.TemporaryDirectory() as tmpdir: + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=tmpdir, + ) + }, + match_info={"path": "../secret.png"}, + ) + + with self.assertRaises(webapp_assets.web.HTTPNotFound): + await subscription_webapp.theme_asset_route(request) + + async def test_theme_asset_route_rejects_non_image_suffix(self): + with tempfile.TemporaryDirectory() as tmpdir: + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=tmpdir, + ) + }, + match_info={"path": "custom/icons/readme.txt"}, + ) + + with self.assertRaises(webapp_assets.web.HTTPNotFound): + await subscription_webapp.theme_asset_route(request) + def test_webapp_logo_disk_cache_roundtrip(self): with tempfile.TemporaryDirectory() as tmpdir: logo_url = "https://cdn.example.com/logo.png" @@ -237,7 +479,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): logo_url = "https://cdn.example.com/logo.png" logo = (b"cached-logo", "image/png") app = { - "settings": SimpleNamespace(WEBAPP_LOGO_URL=logo_url), + "settings": SimpleNamespace(WEBAPP_LOGO_URL=logo_url, WEBAPP_LOGO_USE_EMOJI=False), "webapp_logo_cache": None, "webapp_logo_cache_lock": asyncio.Lock(), } @@ -276,6 +518,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): with tempfile.TemporaryDirectory() as tmpdir: app = { "settings": SimpleNamespace( + WEBAPP_LOGO_USE_EMOJI=True, WEBAPP_LOGO_EMOJI="🀩", WEBAPP_LOGO_EMOJI_FONT="noto-color-animated", ), diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 444b07e..a511d9b 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -1,8 +1,10 @@ +import asyncio import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, patch from aiohttp import web +from aiohttp.test_utils import make_mocked_request from bot.app.web import admin_api, subscription_webapp from bot.app.web.admin_api_impl import auth as admin_auth_routes @@ -57,8 +59,10 @@ class WebAppRouteContractTests(unittest.TestCase): ("GET", "/auth/telegram/callback"): "telegram_oauth_callback_route", ("GET", "/health"): "health_route", ("GET", "/webapp-logo"): "webapp_logo_route", + ("GET", "/webapp-uploaded-logo/{filename}"): "webapp_uploaded_logo_route", ("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route", ("GET", "/subscription_webapp.css"): "css_asset_route", + ("GET", "/webapp-theme-css/{path}"): "theme_css_asset_route", ("GET", "/subscription_webapp.min.{asset_hash}.js"): "js_asset_route", ("GET", "/subscription_webapp.js"): "js_asset_route", ("POST", "/api/auth/telegram/nonce"): "telegram_oauth_nonce_route", @@ -136,12 +140,47 @@ class WebAppRouteContractTests(unittest.TestCase): ("PATCH", "/api/admin/settings"): "admin_settings_patch_route", ("GET", "/api/admin/tariffs"): "admin_tariffs_get_route", ("PUT", "/api/admin/tariffs"): "admin_tariffs_save_route", + ("GET", "/api/admin/themes"): "admin_themes_get_route", + ("PUT", "/api/admin/themes"): "admin_themes_save_route", + ("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route", + ("POST", "/api/admin/appearance/favicon"): "admin_appearance_favicon_upload_route", ("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route", } for key, handler_name in expected.items(): self.assertEqual(routes.get(key), handler_name, key) + def test_admin_themes_page_route_is_not_registered(self): + app = web.Application() + subscription_webapp.setup_subscription_webapp_routes(app) + + request = make_mocked_request("GET", "/admin/themes", app=app) + match_info = asyncio.run(app.router.resolve(request)) + + self.assertEqual(match_info.http_exception.status, 404) + + def test_admin_appearance_page_route_is_registered(self): + app = web.Application() + subscription_webapp.setup_subscription_webapp_routes(app) + + request = make_mocked_request("GET", "/admin/appearance", app=app) + match_info = asyncio.run(app.router.resolve(request)) + + self.assertEqual(match_info.handler.__name__, "index_route") + + def test_webapp_favicon_asset_route_is_registered(self): + app = web.Application() + subscription_webapp.setup_subscription_webapp_routes(app) + + request = make_mocked_request( + "GET", + "/webapp-favicon/abcdef1234567890/icon-180.png", + app=app, + ) + match_info = asyncio.run(app.router.resolve(request)) + + self.assertEqual(match_info.handler.__name__, "webapp_favicon_route") + class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase): def _settings(self): diff --git a/tests/test_webapp_themes_config.py b/tests/test_webapp_themes_config.py new file mode 100644 index 0000000..9e430c7 --- /dev/null +++ b/tests/test_webapp_themes_config.py @@ -0,0 +1,424 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from config.webapp_themes_config import ( + WebappThemesConfig, + apply_webapp_theme_env_overrides, + builtin_webapp_themes_config, + default_webapp_theme_descriptors, + ensure_webapp_core_themes, + load_webapp_theme_dir, + public_themes_catalog_payload, + resolved_webapp_themes_catalog, + write_webapp_theme_dir, +) + + +class WebappThemesConfigTests(unittest.TestCase): + def test_builtin_has_core_themes(self): + cfg = builtin_webapp_themes_config("#abcdef") + self.assertEqual(cfg.default_theme, "dark") + keys = {theme.key for theme in cfg.themes} + self.assertEqual(keys, {"dark", "light", "windows95", "ascii"}) + dark = cfg.theme_by_key("dark") + self.assertIsNotNone(dark) + self.assertTrue(dark.default) + self.assertEqual(dark.tokens.accent, "#abcdef") + win95 = cfg.theme_by_key("windows95") + self.assertIsNotNone(win95) + self.assertEqual(cfg.theme_by_key("light").css_file, "style.css") + self.assertEqual(win95.css_file, "style.css") + self.assertEqual(win95.tokens.style_preset, "win95") + self.assertFalse(win95.use_primary_accent) + self.assertTrue(win95.use_in_admin) + self.assertEqual(win95.assets_version, 6) + ascii_theme = cfg.theme_by_key("ascii") + self.assertIsNotNone(ascii_theme) + self.assertEqual(ascii_theme.css_file, "style.css") + self.assertFalse(ascii_theme.use_primary_accent) + self.assertTrue(ascii_theme.use_in_admin) + self.assertEqual(ascii_theme.assets_version, 1) + + def test_env_override_default_theme(self): + cfg = builtin_webapp_themes_config("#00fe7a") + out = apply_webapp_theme_env_overrides(cfg, "light") + self.assertEqual(out.default_theme, "light") + self.assertTrue(out.theme_by_key("light").default) + self.assertFalse(out.theme_by_key("dark").default) + + def test_core_themes_are_merged_when_missing(self): + cfg = WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "custom", + "enabled": True, + "default": True, + "use_primary_accent": False, + "tokens": {"color_scheme": "dark"}, + } + ], + ) + + merged, changed = ensure_webapp_core_themes(cfg, "#00fe7a") + + self.assertTrue(changed) + self.assertEqual( + {theme.key for theme in merged.themes}, + {"custom", "dark", "light", "windows95", "ascii"}, + ) + self.assertEqual(merged.default_theme, "custom") + self.assertTrue(merged.theme_by_key("custom").default) + self.assertEqual(merged.theme_by_key("light").css_file, "style.css") + self.assertTrue(merged.theme_by_key("custom").use_in_admin) + self.assertFalse(merged.theme_by_key("custom").use_primary_accent) + + def test_default_theme_descriptors_are_read_from_source_files(self): + descriptors = default_webapp_theme_descriptors() + + self.assertEqual(set(descriptors), {"dark", "light", "windows95", "ascii"}) + self.assertTrue(descriptors["dark"]["default"]) + self.assertEqual(descriptors["windows95"]["css_file"], "style.css") + self.assertEqual(descriptors["ascii"]["css_file"], "style.css") + + def test_resolved_creates_default_files_when_missing(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + cfg = resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#abc123", + env_default_theme=None, + ) + + self.assertTrue((themes_dir / "dark" / "theme.json").exists()) + self.assertTrue((themes_dir / "light" / "theme.json").exists()) + self.assertTrue((themes_dir / "light" / "style.css").exists()) + self.assertTrue((themes_dir / "windows95" / "theme.json").exists()) + self.assertTrue((themes_dir / "windows95" / "style.css").exists()) + self.assertTrue((themes_dir / "windows95" / "icons" / "save.png").exists()) + self.assertTrue((themes_dir / "ascii" / "theme.json").exists()) + self.assertTrue((themes_dir / "ascii" / "style.css").exists()) + self.assertEqual(cfg.default_theme, "dark") + self.assertIsNone(cfg.theme_by_key("dark").tokens.accent) + + def test_load_theme_dir_uses_filename_as_key_when_missing(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + themes_dir.mkdir() + (themes_dir / "custom").mkdir() + (themes_dir / "custom" / "theme.json").write_text( + json.dumps( + { + "names": {"en": "Custom"}, + "enabled": True, + "css_file": "style.css", + "tokens": {"color_scheme": "light"}, + } + ), + encoding="utf-8", + ) + + themes = load_webapp_theme_dir(themes_dir) + + self.assertEqual(len(themes), 1) + self.assertEqual(themes[0].key, "custom") + self.assertEqual(themes[0].names["en"], "Custom") + + def test_resolved_catalog_includes_custom_mounted_theme_descriptor(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + themes_dir.mkdir() + (themes_dir / "neon").mkdir() + (themes_dir / "neon" / "theme.json").write_text( + json.dumps( + { + "names": {"en": "Neon"}, + "enabled": True, + "default": True, + "css_file": "style.css", + "tokens": {"color_scheme": "dark"}, + } + ), + encoding="utf-8", + ) + + cfg = resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#00fe7a", + env_default_theme=None, + ) + + self.assertEqual(cfg.default_theme, "neon") + self.assertIsNotNone(cfg.theme_by_key("neon")) + self.assertEqual( + {theme.key for theme in cfg.themes}, + {"dark", "light", "windows95", "ascii", "neon"}, + ) + + def test_env_default_overrides_descriptor_default(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + themes_dir.mkdir() + (themes_dir / "neon").mkdir() + (themes_dir / "neon" / "theme.json").write_text( + json.dumps( + { + "names": {"en": "Neon"}, + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark"}, + } + ), + encoding="utf-8", + ) + + cfg = resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#00fe7a", + env_default_theme="windows95", + ) + + self.assertEqual(cfg.default_theme, "windows95") + self.assertTrue(cfg.theme_by_key("windows95").default) + self.assertFalse(cfg.theme_by_key("neon").default) + + def test_custom_descriptor_default_wins_over_seeded_core_default(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + themes_dir.mkdir() + (themes_dir / "dark").mkdir() + (themes_dir / "dark" / "theme.json").write_text( + json.dumps( + { + "key": "dark", + "names": {"en": "Dark"}, + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark"}, + } + ), + encoding="utf-8", + ) + (themes_dir / "neon").mkdir() + (themes_dir / "neon" / "theme.json").write_text( + json.dumps( + { + "names": {"en": "Neon"}, + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark"}, + } + ), + encoding="utf-8", + ) + + cfg = resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#00fe7a", + env_default_theme=None, + ) + + self.assertEqual(cfg.default_theme, "neon") + self.assertTrue(cfg.theme_by_key("neon").default) + self.assertFalse(cfg.theme_by_key("dark").default) + + def test_theme_dir_writer_writes_descriptors_with_single_default(self): + with tempfile.TemporaryDirectory() as tmp: + cfg = builtin_webapp_themes_config("#00fe7a") + cfg = WebappThemesConfig(default_theme="windows95", themes=cfg.themes) + themes_dir = Path(tmp) / "themes" + + write_webapp_theme_dir(themes_dir, cfg) + + dark = json.loads((themes_dir / "dark" / "theme.json").read_text(encoding="utf-8")) + win95 = json.loads( + (themes_dir / "windows95" / "theme.json").read_text(encoding="utf-8") + ) + self.assertFalse(dark["default"]) + self.assertTrue(win95["default"]) + + resolved = resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#00fe7a", + env_default_theme=None, + ) + self.assertEqual(resolved.default_theme, "windows95") + + def test_resolved_falls_back_to_dark_when_saved_theme_is_missing(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + cfg = builtin_webapp_themes_config("#00fe7a") + cfg = WebappThemesConfig(default_theme="windows95", themes=cfg.themes) + write_webapp_theme_dir(themes_dir, cfg) + windows_theme = themes_dir / "windows95" / "theme.json" + windows_theme.unlink() + + resolved = resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#00fe7a", + env_default_theme=None, + ) + + self.assertEqual(resolved.default_theme, "dark") + self.assertTrue(resolved.theme_by_key("dark").default) + + def test_public_payload_injects_primary_accent_when_enabled(self): + cfg = builtin_webapp_themes_config("#abc123") + + payload = public_themes_catalog_payload(cfg, "#abc123") + light = next(theme for theme in payload["themes"] if theme["key"] == "light") + dark = next(theme for theme in payload["themes"] if theme["key"] == "dark") + win95 = next(theme for theme in payload["themes"] if theme["key"] == "windows95") + + self.assertEqual(light["css_file"], "style.css") + self.assertEqual(light["tokens"]["accent"], "#abc123") + self.assertEqual(dark["tokens"]["accent"], "#abc123") + self.assertFalse(win95["use_primary_accent"]) + self.assertTrue(win95["use_in_admin"]) + self.assertNotIn("accent", win95["tokens"]) + + def test_theme_accent_is_normalized_to_hex(self): + cfg = WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "custom", + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark", "accent": "0F8"}, + } + ], + ) + + self.assertEqual(cfg.theme_by_key("custom").tokens.accent, "#00ff88") + + def test_theme_home_logo_scale_is_public_token(self): + cfg = WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "custom", + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark", "home_logo_scale": 135}, + } + ], + ) + + payload = public_themes_catalog_payload(cfg, "#abc123") + custom = payload["themes"][0] + + self.assertEqual(cfg.theme_by_key("custom").tokens.home_logo_scale, 135) + self.assertEqual(custom["tokens"]["home_logo_scale"], 135) + + def test_theme_accent_rejects_non_hex_values(self): + with self.assertRaises(ValueError): + WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "custom", + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark", "accent": "lime"}, + } + ], + ) + + def test_public_payload_keeps_admin_usage_flag(self): + cfg = WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "custom", + "names": {"en": "Custom"}, + "enabled": True, + "default": True, + "use_in_admin": False, + "tokens": {"color_scheme": "dark"}, + } + ], + ) + + payload = public_themes_catalog_payload(cfg, "#abc123") + + self.assertFalse(payload["themes"][0]["use_in_admin"]) + + def test_resolved_refreshes_stale_builtin_windows95_assets(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + stale_theme_dir = themes_dir / "windows95" + stale_theme_dir.mkdir(parents=True) + (stale_theme_dir / "theme.json").write_text( + json.dumps( + { + "key": "windows95", + "names": {"en": "Windows 95"}, + "enabled": True, + "default": False, + "use_primary_accent": True, + "css_file": "style.css", + "assets_version": 1, + "tokens": {"color_scheme": "light", "style_preset": "win95"}, + } + ), + encoding="utf-8", + ) + (stale_theme_dir / "style.css").write_text("/* stale */", encoding="utf-8") + + cfg = resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#00fe7a", + env_default_theme=None, + ) + + descriptor = json.loads((stale_theme_dir / "theme.json").read_text(encoding="utf-8")) + css = (stale_theme_dir / "style.css").read_text(encoding="utf-8") + self.assertEqual( + descriptor["assets_version"], + cfg.theme_by_key("windows95").assets_version, + ) + self.assertEqual(descriptor["assets_version"], 6) + self.assertIn("lucide-house", css) + self.assertIn("lucide-earth", css) + self.assertIn("lucide-circle-check", css) + self.assertIn("lucide-circle-check-big", css) + self.assertIn("filter: none !important", css) + self.assertIn("Press Start 2P", css) + self.assertIn("::-webkit-slider-thumb", css) + self.assertIn("?v=6", css) + self.assertIn(".theme-key-windows95 .traffic-top strong", css) + self.assertTrue((stale_theme_dir / "icons" / "dashboard.png").exists()) + + def test_resolved_refreshes_stale_builtin_light_assets(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + stale_theme_dir = themes_dir / "light" + stale_theme_dir.mkdir(parents=True) + (stale_theme_dir / "theme.json").write_text( + json.dumps( + { + "key": "light", + "names": {"en": "Light"}, + "enabled": True, + "default": False, + "use_primary_accent": True, + "css_file": "style.css", + "assets_version": 1, + "tokens": {"color_scheme": "light"}, + } + ), + encoding="utf-8", + ) + (stale_theme_dir / "style.css").write_text("/* stale */", encoding="utf-8") + + resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#00fe7a", + env_default_theme=None, + ) + + css = (stale_theme_dir / "style.css").read_text(encoding="utf-8") + self.assertIn(".theme-key-light.app-shell", css)