Merge branch 'feature/custom-themes' into dev

This commit is contained in:
3252a8
2026-05-15 23:10:27 +03:00
96 changed files with 6945 additions and 264 deletions
+2 -3
View File
@@ -38,9 +38,8 @@ WEBAPP_ENABLED=True #
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
WEBAPP_TITLE="/minishop" # Mini App title WEBAPP_TITLE="/minishop" # Mini App title
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color WEBAPP_THEMES_DIR=data/themes # Folder with theme subfolders: <key>/theme.json and optional CSS/assets
WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used WEBAPP_DEFAULT_THEME= # Optional: override descriptor default theme key (e.g. light)
WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h) WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
+5 -2
View File
@@ -1,5 +1,7 @@
# Remnawave Minishop # 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-коду. 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). Проект является переработанным форком [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/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога. - [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки. - [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`). - [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека. - [Миграция с 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). Для каталога тарифов используется `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 ```bash
mkdir -p data/webapp-logo data/webapp-emoji mkdir -p data/themes data/webapp-logo data/webapp-emoji
chown -R 10001:10001 data chown -R 10001:10001 data
chmod -R u+rwX data chmod -R u+rwX data
``` ```
+2
View File
@@ -17,6 +17,7 @@ from bot.app.web.admin_api_impl import (
stats as _stats, stats as _stats,
sync as _sync, sync as _sync,
tariffs as _tariffs, tariffs as _tariffs,
themes as _themes,
users as _users, users as _users,
) )
@@ -34,6 +35,7 @@ _MODULES = (
_ads, _ads,
_settings, _settings,
_tariffs, _tariffs,
_themes,
_panel, _panel,
_routes, _routes,
) )
+4
View File
@@ -270,6 +270,10 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
path.write_text(payload, encoding="utf-8") 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: 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") 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 "" return str(uid).strip().lower() if uid else ""
+4
View File
@@ -54,4 +54,8 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/tariffs", admin_tariffs_get_route) router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
router.add_put("/api/admin/tariffs", admin_tariffs_save_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) router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
+13
View File
@@ -70,5 +70,18 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
if isinstance(cache, dict): if isinstance(cache, dict):
cache["ts"] = 0.0 cache["ts"] = 0.0
cache["data"] = {} 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)}) return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
+322
View File
@@ -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"<svg") or b"<svg" in head:
return ".svg"
if content_type == "image/x-icon" or suffix == ".ico":
if body.startswith(b"\x00\x00\x01\x00"):
return ".ico"
return suffix if suffix in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES else None
def _write_uploaded_logo(body: bytes, content_type: str = "", filename: str = "") -> 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),
}
)
+4
View File
@@ -79,6 +79,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField( SettingField(
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a" "WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
), ),
SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"),
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"), SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"), SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
SettingField( SettingField(
@@ -99,6 +100,9 @@ SETTINGS_MANIFEST: List[SettingField] = [
("noto-local", "Noto Emoji (local)"), ("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 включён"), SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
# ─── Subscription periods & pricing ──────────────────────────── # ─── Subscription periods & pricing ────────────────────────────
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"), SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
+68 -5
View File
@@ -40,6 +40,13 @@
import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js"; import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js";
import { activeTariffName, buildTariffCatalog } from "./lib/webapp/tariffs.js"; import { activeTariffName, buildTariffCatalog } from "./lib/webapp/tariffs.js";
import { premiumTrafficPercent, trafficPercent } from "./lib/webapp/traffic.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 */ /** Used-traffic percent from which top-up modals and CTAs unlock in the web app home screen */
const TRAFFIC_TOPUP_UNLOCK_PERCENT = 80; const TRAFFIC_TOPUP_UNLOCK_PERCENT = 80;
@@ -80,6 +87,7 @@
...(MOCK ? MOCK.config : {}), ...(MOCK ? MOCK.config : {}),
...(injectedConfig || {}), ...(injectedConfig || {}),
}; };
const themePreviewKey = String(CFG.themePreviewKey || query.get("theme_preview") || "").trim();
const I18N = injectedI18n || {}; const I18N = injectedI18n || {};
let telegramSdkStatus = "idle"; let telegramSdkStatus = "idle";
let telegramMiniAppInitData = ""; let telegramMiniAppInitData = "";
@@ -239,11 +247,14 @@
$: brandEmojiFont = CFG.logoEmojiFont || "system"; $: brandEmojiFont = CFG.logoEmojiFont || "system";
$: brand = normalizeBrand({ $: brand = normalizeBrand({
title: brandTitle, title: brandTitle,
logoUrl: CFG.logoUrl, logoUrl: CFG.logoUseEmoji ? "" : CFG.logoUrl,
emoji: brandEmoji, emoji: brandEmoji,
emojiFont: brandEmojiFont, 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; $: plans = data?.plans?.length ? data.plans : DEV_MOCK.data.plans;
$: methods = data?.payment_methods?.length ? data.payment_methods : []; $: methods = data?.payment_methods?.length ? data.payment_methods : [];
$: appSettings = data?.settings || DEV_MOCK.data.settings; $: appSettings = data?.settings || DEV_MOCK.data.settings;
@@ -300,6 +311,30 @@
premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT) premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT)
); );
$: user = data?.user || {}; $: 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); $: isAdmin = Boolean(user?.is_admin);
$: if (screen === "admin" && !isAdmin) { $: if (screen === "admin" && !isAdmin) {
screen = "settings"; screen = "settings";
@@ -346,7 +381,7 @@
: telegramLoginUnavailable : telegramLoginUnavailable
? t("wa_auth_telegram_not_configured") ? t("wa_auth_telegram_not_configured")
: ""; : "";
$: applyFavicon(brand); $: applyFavicon(faviconBrand);
$: syncBodyScrollLock( $: syncBodyScrollLock(
paymentModalOpen || paymentModalOpen ||
changeModalOpen || 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); invalidateWebappTariffOptionCaches(billingStore);
try { try {
await loadData(); await loadData();
} catch { } catch {
// Admin save already succeeded; a later full refresh will pick up new settings or catalog. // 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) { function selectTariff(tariff) {
@@ -873,6 +930,9 @@
<svelte:head> <svelte:head>
<title>{brandTitle}</title> <title>{brandTitle}</title>
{#if shellThemeCssHref}
<link rel="stylesheet" href={shellThemeCssHref} data-theme-css={resolvedThemeKey} />
{/if}
</svelte:head> </svelte:head>
<Tooltip.Provider> <Tooltip.Provider>
@@ -880,7 +940,7 @@
{#if isPreviewBoard} {#if isPreviewBoard}
<PreviewBoard config={CFG} mockData={DEV_MOCK.data} /> <PreviewBoard config={CFG} mockData={DEV_MOCK.data} />
{:else} {:else}
<div class="app-shell" style={`--accent: ${accent};`}> <div class="app-shell {shellToneClass} {shellThemeClass}" style={shellStyle}>
{#if mode === "loading"} {#if mode === "loading"}
<div class="loader"> <div class="loader">
<BrandMark {brand} size="md" /> <BrandMark {brand} size="md" />
@@ -931,8 +991,11 @@
onSectionChange={handleAdminSectionChange} onSectionChange={handleAdminSectionChange}
onSettingsSaved={handleAdminPersistedSaved} onSettingsSaved={handleAdminPersistedSaved}
onTariffsSaved={handleAdminPersistedSaved} onTariffsSaved={handleAdminPersistedSaved}
onThemesSaved={handleAdminPersistedSaved}
{brandTitle} {brandTitle}
{brand} {brand}
appFaviconUrl={CFG.faviconUrl}
appFaviconUseCustom={CFG.faviconUseCustom}
appVersion={CFG.appVersion} appVersion={CFG.appVersion}
appRepositoryUrl={CFG.appRepositoryUrl} appRepositoryUrl={CFG.appRepositoryUrl}
{currentLang} {currentLang}
@@ -11,6 +11,7 @@
LayoutDashboard, LayoutDashboard,
Megaphone, Megaphone,
Menu, Menu,
Paintbrush,
Plus, Plus,
RefreshCw, RefreshCw,
Save, Save,
@@ -34,6 +35,7 @@
import StatsSection from "./sections/StatsSection.svelte"; import StatsSection from "./sections/StatsSection.svelte";
import TariffEditorModal from "./sections/TariffEditorModal.svelte"; import TariffEditorModal from "./sections/TariffEditorModal.svelte";
import TariffsSection from "./sections/TariffsSection.svelte"; import TariffsSection from "./sections/TariffsSection.svelte";
import AppearanceSection from "./sections/AppearanceSection.svelte";
import UserDetailModal from "./sections/UserDetailModal.svelte"; import UserDetailModal from "./sections/UserDetailModal.svelte";
import UsersSection from "./sections/UsersSection.svelte"; import UsersSection from "./sections/UsersSection.svelte";
import { createAdsStore } from "../lib/admin/stores/adsStore.js"; import { createAdsStore } from "../lib/admin/stores/adsStore.js";
@@ -44,6 +46,7 @@
import { createSettingsStore } from "../lib/admin/stores/settingsStore.js"; import { createSettingsStore } from "../lib/admin/stores/settingsStore.js";
import { createStatsStore } from "../lib/admin/stores/statsStore.js"; import { createStatsStore } from "../lib/admin/stores/statsStore.js";
import { createTariffsStore } from "../lib/admin/stores/tariffsStore.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 { createUsersStore } from "../lib/admin/stores/usersStore.js";
import { import {
fmtDate, fmtDate,
@@ -70,8 +73,11 @@
export let onSectionChange = () => {}; export let onSectionChange = () => {};
export let onSettingsSaved = () => {}; export let onSettingsSaved = () => {};
export let onTariffsSaved = () => {}; export let onTariffsSaved = () => {};
export let onThemesSaved = () => {};
export let brand = {}; export let brand = {};
export let brandTitle = "/minishop"; export let brandTitle = "/minishop";
export let appFaviconUrl = "";
export let appFaviconUseCustom = false;
export let appVersion = "dev+local"; export let appVersion = "dev+local";
export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop"; export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop";
export let currentLang = "ru"; export let currentLang = "ru";
@@ -111,6 +117,7 @@
label: at("nav_system", {}, "Система"), label: at("nav_system", {}, "Система"),
items: [ items: [
{ id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins }, { id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins },
{ id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush },
{ id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders }, { id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders },
], ],
}, },
@@ -153,6 +160,10 @@
title: at("section_tariffs_title", {}, "Тарифы"), title: at("section_tariffs_title", {}, "Тарифы"),
subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"), subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"),
}, },
appearance: {
title: at("section_appearance_title", {}, "Внешний вид"),
subtitle: at("section_appearance_subtitle", {}, "Логотип, темы и акцентные цвета Mini App"),
},
settings: { settings: {
title: at("section_settings_title", {}, "Настройки приложения"), title: at("section_settings_title", {}, "Настройки приложения"),
subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"), subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"),
@@ -196,6 +207,7 @@
const settingsStore = createSettingsStore({ api, onToast: flash, at }); const settingsStore = createSettingsStore({ api, onToast: flash, at });
const statsStore = createStatsStore({ api, onToast: flash, at }); const statsStore = createStatsStore({ api, onToast: flash, at });
const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, 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 }); const usersStore = createUsersStore({ api, onToast: flash, at });
setContext("promosStore", promosStore); setContext("promosStore", promosStore);
@@ -207,6 +219,7 @@
setContext("settingsStore", settingsStore); setContext("settingsStore", settingsStore);
setContext("usersStore", usersStore); setContext("usersStore", usersStore);
setContext("tariffsStore", tariffsStore); setContext("tariffsStore", tariffsStore);
setContext("themesStore", themesStore);
$: usersStore.setActive(active); $: usersStore.setActive(active);
$: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length; $: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length;
@@ -635,6 +648,17 @@
<TariffsSection {at} {fmtMoney} /> <TariffsSection {at} {fmtMoney} />
{/if} {/if}
{#if active === "appearance"}
<AppearanceSection
{at}
{currentLang}
{onSettingsSaved}
{brand}
{appFaviconUrl}
{appFaviconUseCustom}
/>
{/if}
{#if active === "settings"} {#if active === "settings"}
<SettingsSection {at} {isCompact} {onSettingsSaved} {currentLang} /> <SettingsSection {at} {isCompact} {onSettingsSaved} {currentLang} />
{/if} {/if}
File diff suppressed because it is too large Load Diff
@@ -17,28 +17,32 @@
const settingsStore = getContext("settingsStore"); const settingsStore = getContext("settingsStore");
$: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore); $: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore);
$: visibleSettingsSections = settingsSections.filter((section) => section.id !== "appearance");
let settingsOpenSections = []; let settingsOpenSections = [];
let settingsOpenSubsections = {}; let settingsOpenSubsections = {};
let revealedSecrets = new Set(); let revealedSecrets = new Set();
$: settingsAllOpen = $: settingsAllOpen =
settingsSections.length > 0 && settingsOpenSections.length === settingsSections.length; visibleSettingsSections.length > 0 &&
settingsOpenSections.length === visibleSettingsSections.length;
onMount(() => { onMount(() => {
settingsStore.loadSettings().then(() => { settingsStore.loadSettings().then(() => {
if ($settingsStore.settingsSections.length) { 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(); settingsOpenSections = isCompact ? ids.slice(0, 1) : ids.slice();
} }
}); });
}); });
function toggleAllSections() { function toggleAllSections() {
if (settingsOpenSections.length === settingsSections.length) { if (settingsOpenSections.length === visibleSettingsSections.length) {
settingsOpenSections = []; settingsOpenSections = [];
} else { } else {
settingsOpenSections = settingsSections.map((s) => s.id); settingsOpenSections = visibleSettingsSections.map((s) => s.id);
} }
} }
@@ -229,7 +233,7 @@
</div> </div>
{/snippet} {/snippet}
{#if settingsLoading || !settingsSections.length} {#if settingsLoading || !visibleSettingsSections.length}
<AdminEmptyState <AdminEmptyState
>{settingsLoading >{settingsLoading
? at("loading", {}, "Загрузка…") ? at("loading", {}, "Загрузка…")
@@ -265,7 +269,7 @@
</div> </div>
</div> </div>
<Accordion.Root type="multiple" bind:value={settingsOpenSections} class="admin-accordion"> <Accordion.Root type="multiple" bind:value={settingsOpenSections} class="admin-accordion">
{#each settingsSections as section} {#each visibleSettingsSections as section}
{@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length} {@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length}
{@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length} {@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length}
<Accordion.Item value={section.id} class="admin-accordion-item admin-card"> <Accordion.Item value={section.id} class="admin-accordion-item admin-card">
@@ -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) { async function saveSettings(onSettingsSaved) {
let dirty = {}; let dirty = {};
state.update((s) => { state.update((s) => {
dirty = s.settingsDirty; dirty = s.settingsDirty;
return s; return s;
}); });
if (!Object.keys(dirty).length) return; if (!Object.keys(dirty).length) return true;
state.update((s) => ({ ...s, settingsSaving: true })); state.update((s) => ({ ...s, settingsSaving: true }));
try { try {
@@ -63,6 +80,7 @@ export function createSettingsStore({ api, onToast, at }) {
state.update((s) => ({ ...s, settingsDirty: {} })); state.update((s) => ({ ...s, settingsDirty: {} }));
if (onSettingsSaved) await onSettingsSaved({ updates, deletes }); if (onSettingsSaved) await onSettingsSaved({ updates, deletes });
await loadSettings(); await loadSettings();
return true;
} else if (res?.errors) { } else if (res?.errors) {
const summary = Object.entries(res.errors) const summary = Object.entries(res.errors)
.map(([k, v]) => `${k}: ${v}`) .map(([k, v]) => `${k}: ${v}`)
@@ -71,6 +89,7 @@ export function createSettingsStore({ api, onToast, at }) {
} else { } else {
onToast(res?.error || "Ошибка"); onToast(res?.error || "Ошибка");
} }
return false;
} finally { } finally {
state.update((s) => ({ ...s, settingsSaving: false })); state.update((s) => ({ ...s, settingsSaving: false }));
} }
@@ -91,6 +110,7 @@ export function createSettingsStore({ api, onToast, at }) {
loadSettings, loadSettings,
markDirty, markDirty,
clearDirty, clearDirty,
setFieldValue,
resetField, resetField,
saveSettings, saveSettings,
}; };
@@ -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,
};
}
@@ -24,7 +24,8 @@
function readCssColor(name, fallback) { function readCssColor(name, fallback) {
if (typeof document === "undefined") return 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; return raw || fallback;
} }
@@ -65,7 +66,11 @@
const muted = readCssColor("--admin-muted", "#9aa7a2"); const muted = readCssColor("--admin-muted", "#9aa7a2");
const border = readCssColor("--admin-border", "rgba(255,255,255,0.12)"); const border = readCssColor("--admin-border", "rgba(255,255,255,0.12)");
const accent = readCssColor("--accent", "#00fe7a"); 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 { return {
width: w, width: w,
@@ -94,7 +99,7 @@
stroke: lineStroke, stroke: lineStroke,
width: 2, width: 2,
cap: "round", cap: "round",
fill: "rgba(120, 140, 132, 0.14)", fill: lineFill,
}, },
], ],
axes: [ axes: [
@@ -35,6 +35,7 @@ export {
Menu, Menu,
MessageSquare, MessageSquare,
MousePointerClick, MousePointerClick,
Paintbrush,
Plus, Plus,
QrCode, QrCode,
Radio, Radio,
@@ -33,6 +33,7 @@
export let emojiFont = ""; export let emojiFont = "";
export let size = "sm"; export let size = "sm";
export let animate = false; export let animate = false;
export let fallbackEmoji = true;
let className = ""; let className = "";
export { className as class }; export { className as class };
@@ -206,7 +207,7 @@
clearLogoLoadTimeout(); clearLogoLoadTimeout();
}} }}
/> />
{:else if useAnimatedEmoji} {:else if fallbackEmoji && useAnimatedEmoji}
<img <img
class="brand-mark-animated-emoji loaded" class="brand-mark-animated-emoji loaded"
src={animatedEmojiStaticFallback ? animatedEmojiFallbackSrc : animatedEmojiSrc} src={animatedEmojiStaticFallback ? animatedEmojiFallbackSrc : animatedEmojiSrc}
@@ -222,7 +223,7 @@
} }
}} }}
/> />
{:else} {:else if fallbackEmoji}
<span <span
class={cn("brand-mark-emoji", getEmojiFontClass(normalizedEmojiFont))} class={cn("brand-mark-emoji", getEmojiFontClass(normalizedEmojiFont))}
style="opacity: {fontLoaded ? 1 : 0}; transition: opacity 0.2s ease;" style="opacity: {fontLoaded ? 1 : 0}; transition: opacity 0.2s ease;"
@@ -34,6 +34,7 @@ export const ADMIN_SECTIONS = new Set([
"broadcast", "broadcast",
"logs", "logs",
"tariffs", "tariffs",
"appearance",
"settings", "settings",
]); ]);
export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js"; export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js";
+135 -1
View File
@@ -195,7 +195,141 @@ export async function mockApi(path, options = {}, context = {}) {
}, },
}; };
} }
if (path === "/admin/settings") return { ok: true, sections: [] }; if (path === "/admin/themes") {
if (String(options.method || "GET").toUpperCase() === "PUT") {
try {
const body = options?.body ? JSON.parse(String(options.body)) : {};
const catalog = body.catalog || body;
if (catalog?.themes) {
DEV_MOCK.config.themesCatalog = clone(catalog);
DEV_MOCK.data.themes_catalog = clone(catalog);
}
} catch (_e) {
void _e;
}
return {
ok: true,
themes_dir: "data/themes",
catalog: clone(DEV_MOCK.config.themesCatalog),
};
}
return {
ok: true,
themes_dir: "data/themes",
catalog: clone(DEV_MOCK.config.themesCatalog),
};
}
if (path === "/admin/appearance/logo") {
return {
ok: true,
logo_url: "/webapp-uploaded-logo/logo-0000000000000000.png",
favicon_url: "/webapp-favicon/0000000000000000/icon-180.png",
};
}
if (path === "/admin/appearance/favicon") {
return {
ok: true,
favicon_url: "/webapp-favicon/1111111111111111/icon-180.png",
variants: {
"32": "/webapp-favicon/1111111111111111/icon-32.png",
apple_touch: "/webapp-favicon/1111111111111111/apple-touch-icon.png",
},
};
}
if (path === "/admin/settings" && String(options.method || "GET").toUpperCase() === "PATCH") {
try {
const body = options?.body ? JSON.parse(String(options.body)) : {};
const updates = body.updates || {};
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_URL")) {
DEV_MOCK.config.logoUrl = updates.WEBAPP_LOGO_URL || "";
}
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_USE_EMOJI")) {
DEV_MOCK.config.logoUseEmoji = Boolean(updates.WEBAPP_LOGO_USE_EMOJI);
}
if (updates.WEBAPP_LOGO_EMOJI) DEV_MOCK.config.logoEmoji = updates.WEBAPP_LOGO_EMOJI;
if (updates.WEBAPP_LOGO_EMOJI_FONT) {
DEV_MOCK.config.logoEmojiFont = updates.WEBAPP_LOGO_EMOJI_FONT;
}
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_FAVICON_URL")) {
DEV_MOCK.config.faviconUrl = updates.WEBAPP_FAVICON_URL || "";
}
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_FAVICON_URL")) {
DEV_MOCK.config.faviconUrl = updates.WEBAPP_LOGO_FAVICON_URL || DEV_MOCK.config.faviconUrl || "";
}
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_FAVICON_USE_CUSTOM")) {
DEV_MOCK.config.faviconUseCustom = Boolean(updates.WEBAPP_FAVICON_USE_CUSTOM);
}
} catch (_e) {
void _e;
}
return { ok: true, applied: 1, reverted: 0 };
}
if (path === "/admin/settings")
return {
ok: true,
sections: [
{
id: "appearance",
order: 2,
fields: [
{
key: "WEBAPP_LOGO_USE_EMOJI",
type: "bool",
section: "appearance",
label: "Emoji logo",
value: Boolean(DEV_MOCK.config.logoUseEmoji),
},
{
key: "WEBAPP_LOGO_URL",
type: "url",
section: "appearance",
label: "URL логотипа",
value: DEV_MOCK.config.logoUrl || "",
},
{
key: "WEBAPP_LOGO_EMOJI",
type: "string",
section: "appearance",
label: "Emoji",
value: DEV_MOCK.config.logoEmoji || "🫥",
},
{
key: "WEBAPP_LOGO_EMOJI_FONT",
type: "string",
section: "appearance",
label: "Emoji font",
value: DEV_MOCK.config.logoEmojiFont || "system",
choices: [
{ value: "system", label: "Системный" },
{ value: "noto-color", label: "Noto Color Emoji" },
{ value: "noto-color-animated", label: "Noto Color Emoji Animated" },
],
},
{
key: "WEBAPP_FAVICON_USE_CUSTOM",
type: "bool",
section: "appearance",
label: "Custom favicon",
value: Boolean(DEV_MOCK.config.faviconUseCustom),
},
{
key: "WEBAPP_FAVICON_URL",
type: "url",
section: "appearance",
label: "Favicon URL",
value: DEV_MOCK.config.faviconUrl || "",
},
{
key: "WEBAPP_LOGO_FAVICON_URL",
type: "url",
section: "appearance",
label: "Logo favicon URL",
value: DEV_MOCK.config.faviconUrl || "",
},
],
},
],
};
if (cleanPath.startsWith("/admin/")) if (cleanPath.startsWith("/admin/"))
return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 }; return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 };
if (path === "/me") return clone(DEV_MOCK.data); if (path === "/me") return clone(DEV_MOCK.data);
@@ -1,10 +1,37 @@
const WINDOWS_95_THEME = {
key: "windows95",
names: { ru: "Windows 95", en: "Windows 95" },
enabled: true,
default: false,
css_file: "style.css",
tokens: {
color_scheme: "light",
style_preset: "win95",
},
};
const ASCII_THEME = {
key: "ascii",
names: { ru: "ASCII", en: "ASCII" },
enabled: true,
default: false,
css_file: "style.css",
tokens: {
color_scheme: "dark",
style_preset: "ascii",
},
};
export const DEV_MOCK = { export const DEV_MOCK = {
config: { config: {
title: "/minishop", title: "/minishop",
primaryColor: "#00fe7a", primaryColor: "#00fe7a",
logoUrl: "", logoUrl: "",
logoUseEmoji: false,
logoEmoji: "🫥", logoEmoji: "🫥",
logoEmojiFont: "system", logoEmojiFont: "system",
faviconUrl: "",
faviconUseCustom: false,
apiBase: "/api", apiBase: "/api",
supportUrl: "https://t.me/support", supportUrl: "https://t.me/support",
privacyPolicyUrl: "https://example.com/privacy", privacyPolicyUrl: "https://example.com/privacy",
@@ -18,6 +45,37 @@ export const DEV_MOCK = {
telegramOAuthRequestAccess: ["write"], telegramOAuthRequestAccess: ["write"],
appVersion: "dev+local", appVersion: "dev+local",
appRepositoryUrl: "https://github.com/3252a8/remnawave-minishop", appRepositoryUrl: "https://github.com/3252a8/remnawave-minishop",
themesCatalog: {
default_theme: "dark",
themes: [
{
key: "dark",
names: { ru: "Тёмная", en: "Dark" },
enabled: true,
default: true,
tokens: {
color_scheme: "dark",
accent: "#00fe7a",
bg: "#03070b",
panel: "#111820",
text: "#f2f7f4",
muted: "#a9b4b0",
},
},
{
key: "light",
names: { ru: "Светлая", en: "Light" },
enabled: true,
default: false,
css_file: "style.css",
tokens: {
color_scheme: "light",
},
},
WINDOWS_95_THEME,
ASCII_THEME,
],
},
}, },
data: { data: {
ok: true, ok: true,
@@ -124,6 +182,35 @@ export const DEV_MOCK = {
{ months: 12, title: "12 месяцев", inviter_days: 62, friend_days: 31 }, { months: 12, title: "12 месяцев", inviter_days: 62, friend_days: 31 },
], ],
}, },
themes_catalog: {
default_theme: "dark",
themes: [
{
key: "dark",
names: { ru: "Тёмная", en: "Dark" },
enabled: true,
tokens: {
color_scheme: "dark",
accent: "#00fe7a",
bg: "#03070b",
panel: "#111820",
text: "#f2f7f4",
muted: "#a9b4b0",
},
},
{
key: "light",
names: { ru: "Светлая", en: "Light" },
enabled: true,
css_file: "style.css",
tokens: {
color_scheme: "light",
},
},
WINDOWS_95_THEME,
ASCII_THEME,
],
},
settings: { settings: {
support_url: "https://t.me/support", support_url: "https://t.me/support",
traffic_mode: false, traffic_mode: false,
@@ -143,6 +230,20 @@ export function applyPreviewMock(kind) {
const mode = String(kind || "") const mode = String(kind || "")
.trim() .trim()
.toLowerCase(); .toLowerCase();
const themeKeys = new Set((DEV_MOCK.config.themesCatalog.themes || []).map((theme) => 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") { if (mode === "traffic") {
DEV_MOCK.data.settings.traffic_mode = true; DEV_MOCK.data.settings.traffic_mode = true;
DEV_MOCK.data.settings.trial_available = false; DEV_MOCK.data.settings.trial_available = false;
@@ -8,6 +8,8 @@ export function createApiClient({
mockApi = null, mockApi = null,
getMockContext = () => ({}), getMockContext = () => ({}),
} = {}) { } = {}) {
const isFormDataBody = (body) => typeof FormData !== "undefined" && body instanceof FormData;
async function api(path, options = {}) { async function api(path, options = {}) {
if (mockApi) return mockApi(path, options, getMockContext()); if (mockApi) return mockApi(path, options, getMockContext());
@@ -18,7 +20,9 @@ export function createApiClient({
if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) { if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
headers["X-CSRF-Token"] = csrf; 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}`, { const response = await fetch(`${apiBase}${path}`, {
...options, ...options,
@@ -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 || "";
}
@@ -113,7 +113,7 @@
.admin-btn.admin-btn-primary { .admin-btn.admin-btn-primary {
background: var(--accent); background: var(--accent);
color: #02110a; color: var(--accent-contrast);
border-color: color-mix(in srgb, var(--accent) 70%, #000); border-color: color-mix(in srgb, var(--accent) 70%, #000);
font-weight: 600; font-weight: 600;
} }
@@ -129,31 +129,31 @@
} }
.admin-btn.admin-btn-ghost:hover:not(:disabled) { .admin-btn.admin-btn-ghost:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.04); background: var(--surface-hover);
color: var(--admin-text); color: var(--admin-text);
} }
.admin-btn.admin-btn-danger { .admin-btn.admin-btn-danger {
background: color-mix(in srgb, #ff5757 80%, #000); background: color-mix(in srgb, var(--danger) 80%, #000);
color: #fff; color: #fff;
border-color: color-mix(in srgb, #ff5757 60%, #000); border-color: color-mix(in srgb, var(--danger) 60%, #000);
font-weight: 600; font-weight: 600;
} }
.admin-btn.admin-btn-danger:hover:not(:disabled) { .admin-btn.admin-btn-danger:hover:not(:disabled) {
background: #ff5757; background: var(--danger);
border-color: #ff5757; border-color: var(--danger);
} }
.admin-btn.admin-btn-danger-soft { .admin-btn.admin-btn-danger-soft {
color: #ffb4b4; color: var(--danger-text);
background: var(--admin-surface-2); 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) { .admin-btn.admin-btn-danger-soft:hover:not(:disabled) {
background: color-mix(in srgb, #ff6b6b 14%, var(--admin-surface)); background: var(--danger-soft);
border-color: color-mix(in srgb, #ff6b6b 50%, transparent); border-color: color-mix(in srgb, var(--danger) 50%, transparent);
} }
.admin-btn.admin-btn-icon { .admin-btn.admin-btn-icon {
@@ -183,21 +183,21 @@
} }
.admin-badge.admin-badge-success { .admin-badge.admin-badge-success {
border-color: color-mix(in srgb, var(--accent) 36%, transparent); border-color: var(--success-border);
color: var(--accent); color: var(--success-text);
background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface)); background: var(--success-soft);
} }
.admin-badge.admin-badge-danger { .admin-badge.admin-badge-danger {
border-color: color-mix(in srgb, #ff6b6b 32%, transparent); border-color: var(--danger-border);
color: #ffb4b4; color: var(--danger-text);
background: color-mix(in srgb, #ff6b6b 12%, var(--admin-surface)); background: var(--danger-soft);
} }
.admin-badge.admin-badge-warning { .admin-badge.admin-badge-warning {
border-color: color-mix(in srgb, #ffd166 32%, transparent); border-color: var(--warning-border);
color: #ffd166; color: var(--warning-text);
background: color-mix(in srgb, #ffd166 12%, var(--admin-surface)); background: var(--warning-soft);
} }
.admin-badge.admin-badge-muted { .admin-badge.admin-badge-muted {
@@ -270,7 +270,7 @@
.admin-extend-control .admin-btn.admin-btn-primary { .admin-extend-control .admin-btn.admin-btn-primary {
background: var(--accent); background: var(--accent);
color: #02110a; color: var(--accent-contrast);
border-color: color-mix(in srgb, var(--accent) 70%, #000); border-color: color-mix(in srgb, var(--accent) 70%, #000);
font-weight: 600; font-weight: 600;
} }
+54 -43
View File
@@ -7,17 +7,14 @@
--admin-sidebar-w: var(--desktop-rail-width); --admin-sidebar-w: var(--desktop-rail-width);
--admin-header-h: 60px; --admin-header-h: 60px;
--admin-card-bg: --admin-card-bg:
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);
var(--admin-surface); --admin-card-shadow: var(--shadow-soft), inset 0 1px 0 var(--inset-highlight);
--admin-card-shadow:
0 18px 48px rgba(0, 0, 0, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
position: fixed; position: fixed;
inset: 0; inset: 0;
width: 100vw; width: 100vw;
height: 100dvh; height: 100dvh;
background: #02070b; background: var(--admin-bg);
color: var(--admin-text); color: var(--admin-text);
overflow: hidden; overflow: hidden;
overscroll-behavior: contain; overscroll-behavior: contain;
@@ -38,9 +35,9 @@
gap: 2px; gap: 2px;
padding: 28px 14px; padding: 28px 14px;
border-right: 1px solid var(--admin-border); border-right: 1px solid var(--admin-border);
background: rgba(7, 12, 17, 0.55); background: var(--rail-bg);
backdrop-filter: blur(14px); 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; overflow-y: auto;
} }
@@ -74,7 +71,8 @@
color: var(--accent); color: var(--accent);
font-size: 14px; font-size: 14px;
font-weight: 800; font-weight: 800;
letter-spacing: -0.01em; font-family: var(--font-logo);
letter-spacing: 0;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -115,7 +113,10 @@
text-align: left; text-align: left;
width: 100%; width: 100%;
cursor: pointer; 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 { .admin-nav-item > svg {
@@ -124,7 +125,7 @@
} }
.admin-nav-item:hover { .admin-nav-item:hover {
background: rgba(255, 255, 255, 0.04); background: var(--surface-hover);
color: var(--admin-text); color: var(--admin-text);
} }
@@ -169,7 +170,7 @@
gap: 8px; gap: 8px;
border: 1px solid var(--admin-border); border: 1px solid var(--admin-border);
border-radius: 10px; border-radius: 10px;
background: rgba(255, 255, 255, 0.035); background: var(--surface-muted);
color: var(--admin-text); color: var(--admin-text);
padding: 8px 10px; padding: 8px 10px;
text-align: left; text-align: left;
@@ -251,7 +252,7 @@
gap: 16px; gap: 16px;
padding: 0 28px; padding: 0 28px;
border-bottom: 1px solid var(--admin-border); 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); backdrop-filter: blur(14px);
} }
@@ -265,7 +266,7 @@
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: 0;
} }
.admin-header-title small { .admin-header-title small {
@@ -427,7 +428,7 @@
.admin-stat-card .admin-stat-value { .admin-stat-card .admin-stat-value {
font-size: 26px; font-size: 26px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: 0;
color: var(--admin-text); color: var(--admin-text);
} }
@@ -457,7 +458,7 @@
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--admin-text); color: var(--admin-text);
letter-spacing: -0.01em; letter-spacing: 0;
} }
.admin-dashboard-section-head small { .admin-dashboard-section-head small {
@@ -528,7 +529,7 @@
.admin-revenue-kpi-value { .admin-revenue-kpi-value {
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: 0;
color: var(--admin-text); color: var(--admin-text);
line-height: 1.15; line-height: 1.15;
} }
@@ -545,11 +546,11 @@
} }
.admin-revenue-kpi-growth.is-up { .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 { .admin-revenue-kpi-growth.is-down {
color: color-mix(in srgb, #ff6b6b 88%, var(--admin-text)); color: var(--danger-text);
} }
.admin-revenue-chart { .admin-revenue-chart {
@@ -606,7 +607,7 @@
border: 1px solid var(--admin-border); border: 1px solid var(--admin-border);
background: var(--admin-surface); background: var(--admin-surface);
color: var(--admin-text); 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 { .admin-revenue-range-popover__title {
@@ -946,7 +947,7 @@
.admin-panel-dash-tile-value { .admin-panel-dash-tile-value {
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.03em; letter-spacing: 0;
line-height: 1.1; line-height: 1.1;
color: color-mix(in srgb, var(--accent) 22%, var(--admin-text)); color: color-mix(in srgb, var(--accent) 22%, var(--admin-text));
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -1248,7 +1249,7 @@
} }
.admin-table tbody tr:hover { .admin-table tbody tr:hover {
background: rgba(255, 255, 255, 0.035); background: var(--surface-hover);
} }
.admin-table td.admin-cell-mono { .admin-table td.admin-cell-mono {
@@ -1531,7 +1532,9 @@
font-size: 13px; font-size: 13px;
resize: vertical; resize: vertical;
outline: none; 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, .admin-textarea:focus,
@@ -2085,11 +2088,11 @@
} }
.admin-traffic-card-premium { .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 { .admin-traffic-card-warning {
border-color: color-mix(in srgb, #ffb454 46%, var(--admin-border)); border-color: var(--warning-border);
} }
.admin-traffic-head, .admin-traffic-head,
@@ -2131,11 +2134,11 @@
} }
.admin-traffic-bar-premium span { .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 { .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 { .admin-traffic-meta {
@@ -2357,7 +2360,7 @@
z-index: 80; z-index: 80;
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 0.22s ease; 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 { .admin-screen-wrap.is-sidebar-open .admin-sidebar {
@@ -2367,7 +2370,7 @@
.admin-sidebar-backdrop { .admin-sidebar-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.5); background: var(--overlay-scrim);
z-index: 70; z-index: 70;
border: 0; border: 0;
cursor: pointer; cursor: pointer;
@@ -2501,7 +2504,9 @@
padding: 10px 12px; padding: 10px 12px;
width: 100%; width: 100%;
cursor: pointer; 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 { .settings-row.settings-row-admin:hover {
@@ -2560,7 +2565,10 @@
border-radius: 7px; border-radius: 7px;
cursor: pointer; cursor: pointer;
white-space: nowrap; 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; outline: none;
} }
@@ -2571,7 +2579,7 @@
.admin-tabs-trigger[data-state="active"] { .admin-tabs-trigger[data-state="active"] {
background: var(--admin-surface); background: var(--admin-surface);
color: var(--admin-text); 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 { .admin-tabs-trigger:focus-visible {
@@ -2641,13 +2649,15 @@
border: 1px solid var(--admin-border); border: 1px solid var(--admin-border);
background: var(--admin-elev); background: var(--admin-elev);
cursor: pointer; cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease; transition:
background 0.18s ease,
border-color 0.18s ease;
outline: none; outline: none;
} }
.admin-switch-root[data-state="checked"] { .admin-switch-root[data-state="checked"] {
background: color-mix(in srgb, var(--accent) 80%, var(--admin-elev)); background: color-mix(in srgb, var(--success) 82%, var(--admin-elev));
border-color: color-mix(in srgb, var(--accent) 50%, transparent); border-color: color-mix(in srgb, var(--success) 52%, transparent);
} }
.admin-switch-root:focus-visible { .admin-switch-root:focus-visible {
@@ -2693,7 +2703,9 @@
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
outline: none; 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 { .admin-select-trigger:hover {
@@ -2724,10 +2736,9 @@
border: 1px solid var(--admin-border); border: 1px solid var(--admin-border);
border-radius: 10px; border-radius: 10px;
background: 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);
var(--admin-surface);
padding: 4px; padding: 4px;
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45); box-shadow: var(--shadow-popover);
outline: none; outline: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -3069,9 +3080,9 @@
} }
.admin-danger-zone { .admin-danger-zone {
border: 1px solid color-mix(in srgb, #ff6b6b 32%, transparent); border: 1px solid var(--danger-border);
border-radius: 12px; border-radius: 12px;
background: color-mix(in srgb, #ff6b6b 6%, var(--admin-surface)); background: var(--danger-soft);
padding: 14px 16px; padding: 14px 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -3088,13 +3099,13 @@
.admin-danger-zone-head strong { .admin-danger-zone-head strong {
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
color: #ffb4b4; color: var(--danger-text);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.admin-danger-zone-head small { .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; font-size: 12px;
line-height: 1.45; line-height: 1.45;
} }
+37 -12
View File
@@ -4,21 +4,14 @@
format("woff2"); format("woff2");
font-display: swap; font-display: swap;
unicode-range: unicode-range:
U+1F1E6-1F1FF, U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074,
U+1F3F4, U+E0077, U+E007F;
U+E0062-E0063,
U+E0065,
U+E0067,
U+E006C,
U+E006E,
U+E0073-E0074,
U+E0077,
U+E007F;
} }
:root { :root {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace; --font-mono: "JetBrains Mono", "Fira Code", monospace;
--font-logo: var(--font-mono);
color-scheme: dark; color-scheme: dark;
--accent: #00fe7a; --accent: #00fe7a;
--bg: #03070b; --bg: #03070b;
@@ -31,8 +24,36 @@
--muted: #a9b4b0; --muted: #a9b4b0;
--dim: #68736f; --dim: #68736f;
--danger: #ff6b6b; --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; --blue: #2d9cff;
--radius: 8px; --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 /* Admin design tokens kept on :root so portal-rendered admin
surfaces (dialogs, bits-ui Select.Portal content) inherit them. */ surfaces (dialogs, bits-ui Select.Portal content) inherit them. */
@@ -59,6 +80,10 @@
font-family: var(--font-sans); font-family: var(--font-sans);
} }
.theme-light {
color-scheme: light;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -72,7 +97,7 @@ body,
} }
body { body {
background: #02070b; background: var(--bg, #02070b);
color: var(--text); color: var(--text);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
letter-spacing: 0; letter-spacing: 0;
@@ -86,7 +111,7 @@ input {
.app-shell { .app-shell {
min-height: 100dvh; min-height: 100dvh;
background: #02070b !important; background: var(--bg, #02070b) !important;
} }
:root { :root {
+14 -15
View File
@@ -26,11 +26,9 @@
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--admin-border); border: 1px solid var(--admin-border);
background: 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); var(--admin-surface);
box-shadow: box-shadow: var(--shadow-soft), inset 0 1px 0 var(--inset-highlight);
0 18px 48px rgba(0, 0, 0, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
@@ -80,7 +78,7 @@
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
letter-spacing: -0.02em; letter-spacing: 0;
color: var(--admin-text); color: var(--admin-text);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
line-height: 1.15; line-height: 1.15;
@@ -89,7 +87,7 @@
.admin-cn-card-title--section { .admin-cn-card-title--section {
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 600; font-weight: 600;
letter-spacing: -0.01em; letter-spacing: 0;
} }
.admin-cn-card-skeleton { .admin-cn-card-skeleton {
@@ -173,15 +171,15 @@
} }
.admin-cn-badge-destructive { .admin-cn-badge-destructive {
background: color-mix(in srgb, var(--danger) 16%, var(--admin-surface)); background: var(--danger-soft);
border-color: color-mix(in srgb, var(--danger) 35%, var(--admin-border)); border-color: var(--danger-border);
color: color-mix(in srgb, var(--danger) 92%, var(--admin-text)); color: var(--danger-text);
} }
.admin-cn-badge-success { .admin-cn-badge-success {
background: color-mix(in srgb, var(--accent) 14%, var(--admin-surface)); background: var(--success-soft);
border-color: color-mix(in srgb, var(--accent) 32%, var(--admin-border)); border-color: var(--success-border);
color: color-mix(in srgb, var(--accent) 88%, var(--admin-text)); color: var(--success-text);
} }
.admin-cn-badge-muted { .admin-cn-badge-muted {
@@ -227,9 +225,9 @@
display: inline-block; display: inline-block;
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
rgba(255, 255, 255, 0.08), color-mix(in srgb, var(--muted) 10%, transparent),
rgba(255, 255, 255, 0.14), color-mix(in srgb, var(--muted) 18%, transparent),
rgba(255, 255, 255, 0.08) color-mix(in srgb, var(--muted) 10%, transparent)
); );
background-size: 220% 100%; background-size: 220% 100%;
animation: ui-skeleton-pulse 1.2s ease-in-out infinite; animation: ui-skeleton-pulse 1.2s ease-in-out infinite;
@@ -293,3 +291,4 @@
background-position: -120% 0; background-position: -120% 0;
} }
} }
+5 -5
View File
@@ -23,7 +23,7 @@ button:disabled {
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.18); box-shadow: var(--shadow-soft);
transition: transition:
transform 0.16s ease, transform 0.16s ease,
border-color 0.16s ease, border-color 0.16s ease,
@@ -34,7 +34,7 @@ button:disabled {
.btn:hover:not(:disabled) { .btn:hover:not(:disabled) {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.22); box-shadow: var(--shadow-soft);
} }
.btn:active:not(:disabled) { .btn:active:not(:disabled) {
@@ -45,7 +45,7 @@ button:disabled {
outline: none; outline: none;
box-shadow: box-shadow:
0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent), 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, .btn.wide,
@@ -56,7 +56,7 @@ button:disabled {
.btn-primary { .btn-primary {
border-color: color-mix(in srgb, var(--accent) 72%, white); border-color: color-mix(in srgb, var(--accent) 72%, white);
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 76%, white)); background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 76%, white));
color: #03100a; color: var(--accent-contrast);
} }
.btn-secondary { .btn-secondary {
@@ -111,7 +111,7 @@ button:disabled {
margin-top: 12px; margin-top: 12px;
overflow: hidden; overflow: hidden;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.08); background: color-mix(in srgb, var(--muted) 18%, transparent);
} }
.progress span { .progress span {
+4 -4
View File
@@ -26,7 +26,7 @@
inset: 0; inset: 0;
z-index: 0; z-index: 0;
border: 0; border: 0;
background: rgba(0, 0, 0, 0.56); background: var(--overlay-scrim);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
cursor: pointer; cursor: pointer;
} }
@@ -43,9 +43,9 @@
padding: 18px; padding: 18px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 24px; 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); color: var(--text);
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.46); box-shadow: var(--shadow-strong);
} }
.dialog-head { .dialog-head {
@@ -92,7 +92,7 @@
.payment-submit-button { .payment-submit-button {
border-color: color-mix(in srgb, var(--accent) 72%, transparent); border-color: color-mix(in srgb, var(--accent) 72%, transparent);
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent)); background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent));
color: #031009; color: var(--accent-contrast);
box-shadow: box-shadow:
0 16px 38px color-mix(in srgb, var(--accent) 28%, transparent), 0 16px 38px color-mix(in srgb, var(--accent) 28%, transparent),
0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent),
+124 -111
View File
@@ -12,8 +12,8 @@ a {
min-height: 100dvh; min-height: 100dvh;
margin: 0 auto; margin: 0 auto;
overflow-x: hidden; overflow-x: hidden;
padding: padding: max(16px, env(safe-area-inset-top)) max(var(--screen-gutter), var(--safe-inline))
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)); max(18px, env(safe-area-inset-bottom)) max(var(--screen-gutter), var(--safe-inline));
} }
.content { .content {
@@ -30,12 +30,18 @@ a {
display: grid; display: grid;
min-height: 100dvh; min-height: 100dvh;
place-items: center; place-items: center;
align-content: center; align-content: safe center;
gap: 14px; gap: 14px;
color: var(--muted); color: var(--muted);
font-weight: 800; 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, .app-header,
.preview-header, .preview-header,
.screen-head { .screen-head {
@@ -94,7 +100,7 @@ a {
color: var(--text); color: var(--text);
font-size: 15px; font-size: 15px;
font-weight: 850; font-weight: 850;
font-family: var(--font-mono); font-family: var(--font-logo);
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -162,20 +168,19 @@ a {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: 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(--panel);
var(--panel);
box-shadow: box-shadow:
0 18px 48px rgba(0, 0, 0, 0.22), var(--shadow-soft),
inset 0 1px 0 rgba(255, 255, 255, 0.05); inset 0 1px 0 var(--inset-highlight);
padding: 14px; padding: 14px;
} }
.card-active { .card-active {
border-color: color-mix(in srgb, var(--accent) 76%, var(--border)); border-color: color-mix(in srgb, var(--accent) 76%, var(--border));
box-shadow: 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), 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 { .card-compact {
@@ -189,7 +194,11 @@ a {
.status-card-inactive { .status-card-inactive {
border-color: color-mix(in srgb, var(--danger) 58%, var(--border)); border-color: color-mix(in srgb, var(--danger) 58%, var(--border));
background: 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); var(--panel);
} }
@@ -392,7 +401,7 @@ a {
gap: 6px; gap: 6px;
} }
.premium-server-list>div { .premium-server-list > div {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
@@ -401,7 +410,7 @@ a {
.premium-server-list span { .premium-server-list span {
max-width: 100%; max-width: 100%;
padding: 4px 8px; padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--surface-subtle-border);
border-radius: 999px; border-radius: 999px;
color: var(--text); color: var(--text);
font-size: 11px; font-size: 11px;
@@ -416,7 +425,7 @@ a {
padding: 12px; padding: 12px;
} }
.topup-summary-card>div:not(.premium-server-list) { .topup-summary-card > div:not(.premium-server-list) {
display: grid; display: grid;
gap: 3px; gap: 3px;
} }
@@ -440,7 +449,7 @@ a {
gap: 11px; gap: 11px;
} }
.trial-card-head>svg { .trial-card-head > svg {
flex: 0 0 auto; flex: 0 0 auto;
color: var(--accent); color: var(--accent);
} }
@@ -475,7 +484,7 @@ a {
gap: 11px; gap: 11px;
} }
.devices-summary-head>svg { .devices-summary-head > svg {
color: var(--accent); color: var(--accent);
} }
@@ -529,7 +538,7 @@ a {
place-items: center; place-items: center;
border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border));
border-radius: var(--radius); 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); color: var(--accent);
} }
@@ -565,8 +574,8 @@ a {
.device-disconnect-button, .device-disconnect-button,
.device-danger-button { .device-danger-button {
border-color: color-mix(in srgb, var(--danger) 62%, var(--border)); border-color: var(--danger-border);
color: #ffb8b8; color: var(--danger-text);
} }
.devices-empty-card { .devices-empty-card {
@@ -604,9 +613,9 @@ a {
.settings-row { .settings-row {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); 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); 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 { .period-card {
@@ -652,10 +661,10 @@ a {
.period-card.active, .period-card.active,
.method-card.active { .method-card.active {
border-color: var(--accent); 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: box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent), 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 { .total-card {
@@ -688,11 +697,11 @@ a {
gap: 12px; gap: 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); 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); color: var(--text);
padding: 12px; padding: 12px;
text-align: left; 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, .option-row-main,
@@ -745,10 +754,10 @@ a {
.option-row.active { .option-row.active {
border-color: var(--accent); 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: box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent), 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 { .tariff-row {
@@ -764,7 +773,7 @@ a {
align-items: flex-start; align-items: flex-start;
} }
.change-action-row>svg { .change-action-row > svg {
flex: 0 0 auto; flex: 0 0 auto;
margin-top: 1px; margin-top: 1px;
color: var(--accent); color: var(--accent);
@@ -774,9 +783,9 @@ a {
display: grid; display: grid;
gap: 6px; gap: 6px;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: rgba(255, 255, 255, 0.035); background: var(--surface-muted);
} }
.topup-carryover-note p { .topup-carryover-note p {
@@ -798,11 +807,12 @@ a {
display: block; display: block;
overflow: hidden; overflow: hidden;
border-radius: 999px; border-radius: 999px;
background: background: linear-gradient(
linear-gradient(90deg, 90deg,
rgba(255, 255, 255, 0.07) 0%, color-mix(in srgb, var(--muted) 10%, transparent) 0%,
rgba(255, 255, 255, 0.14) 42%, color-mix(in srgb, var(--muted) 18%, transparent) 42%,
rgba(255, 255, 255, 0.07) 84%); color-mix(in srgb, var(--muted) 10%, transparent) 84%
);
background-size: 220% 100%; background-size: 220% 100%;
animation: skeleton-shimmer 1.15s ease-in-out infinite; animation: skeleton-shimmer 1.15s ease-in-out infinite;
} }
@@ -893,7 +903,7 @@ a {
.tariff-selected-card { .tariff-selected-card {
min-height: 58px; 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 { .tariff-action-list {
@@ -909,7 +919,7 @@ a {
gap: 10px; gap: 10px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); 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); color: var(--text);
padding: 12px; padding: 12px;
text-align: left; text-align: left;
@@ -942,12 +952,12 @@ a {
.tariff-action-card.active { .tariff-action-card.active {
border-color: var(--accent); 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 { .tariff-warning-card {
justify-content: flex-start; justify-content: flex-start;
color: var(--warning, #ffd166); color: var(--warning-text);
} }
.confirm-summary-card { .confirm-summary-card {
@@ -969,7 +979,7 @@ a {
min-height: 56px; min-height: 56px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(255, 255, 255, 0.035); background: var(--surface-muted);
padding: 10px; padding: 10px;
text-align: center; text-align: center;
} }
@@ -1057,10 +1067,10 @@ a {
.field-error-tooltip { .field-error-tooltip {
z-index: 120; z-index: 120;
max-width: 280px; max-width: 280px;
border: 1px solid color-mix(in srgb, var(--danger) 60%, var(--border)); border: 1px solid var(--danger-border);
border-radius: 8px; border-radius: 8px;
background: #2a1010; background: var(--danger-soft);
color: #ffd8d8; color: var(--danger-text);
padding: 8px 10px; padding: 8px 10px;
font-size: 12px; font-size: 12px;
line-height: 1.3; line-height: 1.3;
@@ -1105,7 +1115,7 @@ a {
overflow: hidden; overflow: hidden;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: rgba(0, 0, 0, 0.2); background: color-mix(in srgb, var(--panel-2) 86%, transparent);
color: var(--text); color: var(--text);
padding: 12px; padding: 12px;
font-size: 12px; font-size: 12px;
@@ -1132,7 +1142,7 @@ a {
gap: 14px; gap: 14px;
} }
.bonus-card-head>svg { .bonus-card-head > svg {
flex: 0 0 auto; flex: 0 0 auto;
color: var(--accent); color: var(--accent);
} }
@@ -1165,7 +1175,7 @@ a {
gap: 3px; gap: 3px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: rgba(255, 255, 255, 0.02); background: var(--surface-sheen-soft);
padding: 10px 11px; padding: 10px 11px;
} }
@@ -1193,7 +1203,7 @@ a {
overflow: hidden; overflow: hidden;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.03); background: var(--surface-muted);
} }
.settings-avatar img { .settings-avatar img {
@@ -1245,15 +1255,15 @@ a {
min-height: 50px; min-height: 50px;
padding: 9px 10px; padding: 9px 10px;
text-align: left; 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); color: var(--text);
opacity: 0.9; opacity: 0.9;
} }
.settings-row>svg:last-child { .settings-row > svg:last-child {
color: var(--muted); color: var(--muted);
} }
@@ -1276,26 +1286,22 @@ a {
.settings-row-linked { .settings-row-linked {
grid-template-columns: 28px minmax(0, 1fr); grid-template-columns: 28px minmax(0, 1fr);
border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); border-color: var(--success-border);
background: color-mix(in srgb, var(--accent) 11%, rgba(255, 255, 255, 0.03)); background: var(--success-soft);
} }
.settings-row-linked>svg:first-child { .settings-row-linked > svg:first-child {
color: var(--accent); color: var(--success-text);
} }
.settings-row-linked strong { .settings-row-linked strong {
color: var(--accent); color: var(--success-text);
} }
.emoji-flag { .emoji-flag {
font-family: font-family:
"Twemoji Country Flags", "Twemoji Country Flags", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Apple Color Emoji", "Noto Color Emoji", sans-serif;
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
sans-serif;
font-variant-emoji: emoji; font-variant-emoji: emoji;
line-height: 1; line-height: 1;
} }
@@ -1325,7 +1331,7 @@ a {
border-radius: 6px; border-radius: 6px;
} }
.language-select-trigger>svg { .language-select-trigger > svg {
color: var(--muted); color: var(--muted);
} }
@@ -1361,7 +1367,7 @@ a {
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--panel-3); background: var(--panel-3);
box-shadow: 0 20px 34px rgba(0, 0, 0, 0.42); box-shadow: var(--shadow-popover);
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
transform-origin: top right; transform-origin: top right;
@@ -1387,7 +1393,7 @@ a {
} }
.language-select-item[data-highlighted] { .language-select-item[data-highlighted] {
background: rgba(255, 255, 255, 0.06); background: var(--surface-hover);
} }
.language-select-item[data-selected] { .language-select-item[data-selected] {
@@ -1409,11 +1415,11 @@ a {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.language-select-item-main>span { .language-select-item-main > span {
display: inline-block !important; display: inline-block !important;
} }
.language-select-item-main>span:last-child { .language-select-item-main > span:last-child {
margin-left: 6px; margin-left: 6px;
white-space: nowrap !important; white-space: nowrap !important;
overflow: hidden; overflow: hidden;
@@ -1494,8 +1500,8 @@ a {
min-height: 64px; min-height: 64px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: rgba(7, 12, 17, 0.88); background: var(--nav-bg);
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.36); box-shadow: var(--shadow-soft);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
} }
@@ -1530,17 +1536,29 @@ a {
.home-layout { .home-layout {
display: grid; display: grid;
min-height: calc(100dvh - 34px); min-height: calc(100dvh - 34px);
grid-template-rows: minmax(0, 1fr) auto; grid-template-rows: minmax(min-content, 1fr) auto;
gap: 18px; gap: 18px;
padding-bottom: 86px; padding-bottom: 86px;
animation: section-enter 0.24s ease-out both; animation: section-enter 0.24s ease-out both;
} }
.home-brand { .home-brand {
align-self: center; align-self: safe center;
padding-top: 0; 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 { .home-bottom {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -1549,7 +1567,7 @@ a {
.auth-screen { .auth-screen {
display: grid; display: grid;
align-content: center; align-content: safe center;
} }
.auth-card-wrap { .auth-card-wrap {
@@ -1568,19 +1586,13 @@ a {
gap: 5px; gap: 5px;
} }
.login-brand-auth .brand-mark.brand-mark-xl {
width: 116px;
height: 116px;
font-size: 84px;
}
.login-brand h1 { .login-brand h1 {
margin: 0; margin: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
color: var(--accent); color: var(--accent);
font-size: 32px; font-size: 32px;
font-weight: 900; font-weight: 900;
font-family: var(--font-mono); font-family: var(--font-logo);
line-height: 1.04; line-height: 1.04;
} }
@@ -1739,13 +1751,13 @@ a {
place-items: center; place-items: center;
border: 1px solid color-mix(in srgb, var(--accent) 74%, var(--border)); border: 1px solid color-mix(in srgb, var(--accent) 74%, var(--border));
border-radius: var(--radius); border-radius: var(--radius);
background: rgba(255, 255, 255, 0.035); background: var(--surface-muted);
font-size: 25px; font-size: 25px;
font-weight: 900; font-weight: 900;
} }
.otp-slots span.filled { .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 { .link-button {
@@ -1760,7 +1772,7 @@ a {
} }
.status-line { .status-line {
color: var(--accent); color: var(--success-text);
font-size: 12px; font-size: 12px;
line-height: 1.4; line-height: 1.4;
} }
@@ -1788,10 +1800,10 @@ a {
bottom: 16px; bottom: 16px;
z-index: 120; z-index: 120;
max-width: min(420px, calc(100vw - 32px)); 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); border-radius: var(--radius);
background: rgba(15, 21, 27, 0.92); background: color-mix(in srgb, var(--panel) 92%, transparent);
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.42); box-shadow: var(--shadow-popover);
color: var(--text); color: var(--text);
padding: 12px 14px; padding: 12px 14px;
font-size: 13px; font-size: 13px;
@@ -1873,8 +1885,7 @@ a {
border: 1px solid rgba(255, 255, 255, 0.22); border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 18px; border-radius: 18px;
background: background:
linear-gradient(135deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.015)), linear-gradient(135deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.015)), #071017;
#071017;
box-shadow: box-shadow:
0 28px 80px rgba(0, 0, 0, 0.54), 0 28px 80px rgba(0, 0, 0, 0.54),
inset 0 1px 0 rgba(255, 255, 255, 0.08); inset 0 1px 0 rgba(255, 255, 255, 0.08);
@@ -2011,7 +2022,6 @@ a {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.content, .content,
.home-layout, .home-layout,
.language-select-content, .language-select-content,
@@ -2039,7 +2049,7 @@ a {
@media (min-width: 1024px) { @media (min-width: 1024px) {
body { body {
background: #02070b; background: var(--bg);
} }
.app-shell { .app-shell {
@@ -2060,16 +2070,16 @@ a {
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
padding: padding: max(28px, env(safe-area-inset-top)) var(--desktop-page-gutter) 40px
max(28px, env(safe-area-inset-top)) var(--desktop-page-gutter) 40px calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
overflow-x: visible; overflow-x: visible;
} }
/* Centre and cap the width of the actual content blocks. */ /* Centre and cap the width of the actual content blocks. */
.phone-screen>.app-header, .phone-screen > .app-header,
.phone-screen>main, .phone-screen > main,
.phone-screen>.home-layout, .phone-screen > .home-layout,
.phone-screen>nav.bottom-nav~* { .phone-screen > nav.bottom-nav ~ * {
max-width: 1080px; max-width: 1080px;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
@@ -2079,12 +2089,11 @@ a {
.phone-screen.auth-screen { .phone-screen.auth-screen {
width: min(100%, 460px); width: min(100%, 460px);
min-height: 100dvh; min-height: 100dvh;
padding: padding: max(16px, env(safe-area-inset-top)) clamp(24px, 4vw, 16px) 16px;
max(16px, env(safe-area-inset-top)) clamp(24px, 4vw, 16px) 16px;
margin: 0 auto; margin: 0 auto;
} }
.phone-screen.auth-screen~.bottom-nav, .phone-screen.auth-screen ~ .bottom-nav,
.auth-screen .bottom-nav { .auth-screen .bottom-nav {
display: none !important; display: none !important;
} }
@@ -2094,8 +2103,8 @@ a {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
align-items: stretch; align-items: stretch;
min-height: 100dvh; min-height: 100dvh;
padding: padding: max(32px, env(safe-area-inset-top)) var(--desktop-page-gutter) 42px
max(32px, env(safe-area-inset-top)) var(--desktop-page-gutter) 42px calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
} }
/* Home: keep the phone-style vertical flow, but center it in the /* Home: keep the phone-style vertical flow, but center it in the
@@ -2113,7 +2122,7 @@ a {
align-items: stretch; align-items: stretch;
} }
.home-layout>.home-brand { .home-layout > .home-brand {
grid-column: auto; grid-column: auto;
align-self: center; align-self: center;
justify-items: center; justify-items: center;
@@ -2132,9 +2141,9 @@ a {
.home-brand .brand-mark, .home-brand .brand-mark,
.home-brand .brand-mark-lg, .home-brand .brand-mark-lg,
.home-brand .brand-mark-xl { .home-brand .brand-mark-xl {
width: clamp(180px, 18vw, 260px); width: calc(clamp(180px, 18vw, 260px) * var(--home-logo-scale, 1));
height: clamp(180px, 18vw, 260px); height: calc(clamp(180px, 18vw, 260px) * var(--home-logo-scale, 1));
font-size: clamp(124px, 13vw, 184px); font-size: calc(clamp(124px, 13vw, 184px) * var(--home-logo-scale, 1));
} }
.home-bottom { .home-bottom {
@@ -2174,9 +2183,9 @@ a {
border: 0 !important; border: 0 !important;
border-right: 1px solid var(--border) !important; border-right: 1px solid var(--border) !important;
border-radius: 0 !important; border-radius: 0 !important;
background: rgba(7, 12, 17, 0.55) !important; background: var(--rail-bg) !important;
backdrop-filter: blur(14px) !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; z-index: 40;
} }
@@ -2197,22 +2206,25 @@ a {
font-size: 13px !important; font-size: 13px !important;
color: var(--muted); color: var(--muted);
border: 1px solid transparent; 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; width: 20px;
height: 20px; height: 20px;
} }
.bottom-nav button>span { .bottom-nav button > span {
text-align: left !important; text-align: left !important;
font-size: 13px !important; font-size: 13px !important;
font-weight: 600; font-weight: 600;
} }
.bottom-nav button:hover { .bottom-nav button:hover {
background: rgba(255, 255, 255, 0.04); background: var(--surface-hover);
color: var(--text); color: var(--text);
} }
@@ -2235,7 +2247,7 @@ a {
display: none !important; display: none !important;
} }
.phone-screen>main.content.with-nav { .phone-screen > main.content.with-nav {
padding-top: 0; padding-top: 0;
} }
@@ -2310,7 +2322,8 @@ a {
color: var(--accent); color: var(--accent);
font-size: 14px; font-size: 14px;
font-weight: 800; font-weight: 800;
letter-spacing: -0.01em; font-family: var(--font-logo);
letter-spacing: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -9,7 +9,7 @@
<link id="app-favicon" rel="icon" href="data:," sizes="any"> <link id="app-favicon" rel="icon" href="data:," sizes="any">
<title>/minishop</title> <title>/minishop</title>
<link rel="stylesheet" href="/subscription_webapp.css"> <link rel="stylesheet" href="/subscription_webapp.css">
<link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high" crossorigin="anonymous"> <link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high">
</head> </head>
<body> <body>
+953
View File
@@ -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;
}
+17
View File
@@ -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"
}
}
+27
View File
@@ -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"
}
}
+131
View File
@@ -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);
}
+16
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

File diff suppressed because it is too large Load Diff
+17
View File
@@ -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"
}
}
+17 -1
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import base64 import base64
import hashlib import hashlib
import html
import hmac import hmac
import io import io
import ipaddress import ipaddress
@@ -17,7 +18,7 @@ from collections import deque
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple 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 import Bot, Dispatcher
from aiogram.types import LabeledPrice from aiogram.types import LabeledPrice
@@ -65,6 +66,10 @@ TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscriptio
ASSET_DIR = TEMPLATE_PATH.parent ASSET_DIR = TEMPLATE_PATH.parent
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo" WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "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_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji"
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->" WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->" WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
@@ -76,6 +81,17 @@ WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30 WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
WEBAPP_EMOJI_MAX_BYTES = 4 * 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_MAX_BYTES = 128 * 1024
WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60 WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60
WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4 WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4
+385 -6
View File
@@ -1,6 +1,14 @@
# ruff: noqa: F401,F403,F405,I001 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 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: async def health_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True}) 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") 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: 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() raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url: if not raw_logo_url:
return "" return ""
@@ -26,6 +147,27 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
return "" 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: def _webapp_logo_cache_key(logo_url: str) -> str:
return hashlib.sha256(logo_url.encode("utf-8")).hexdigest() 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: async def webapp_logo_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"] 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() raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url: if not raw_logo_url:
raise web.HTTPNotFound(text="webapp_logo_not_configured") raise web.HTTPNotFound(text="webapp_logo_not_configured")
@@ -94,6 +238,82 @@ async def webapp_logo_route(request: web.Request) -> web.Response:
return 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: async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
codepoints = str(request.match_info.get("codepoints") or "").strip().lower() codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
ext = str(request.match_info.get("ext") 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: async def _warm_webapp_logo_cache(app: web.Application) -> None:
settings: Settings = app["settings"] settings: Settings = app["settings"]
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url): if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url):
return 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: async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None:
settings: Settings = app["settings"] 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": if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated":
return return
@@ -435,7 +659,7 @@ async def _security_headers_middleware(request: web.Request, handler):
"frame-ancestors https://web.telegram.org https://t.me; " "frame-ancestors https://web.telegram.org https://t.me; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " # noqa: E501 "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:; " "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; " "connect-src 'self' https://oauth.telegram.org; "
"object-src 'none'; " "object-src 'none'; "
"base-uri 'self'; " "base-uri 'self'; "
@@ -482,8 +706,10 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
cache = request.app["webapp_settings_cache"] cache = request.app["webapp_settings_cache"]
now = time.monotonic() now = time.monotonic()
if now - float(cache.get("ts", 0.0)) >= 60 or not cache.get("data"): if now - float(cache.get("ts", 0.0)) >= 60 or not cache.get("data"):
logo_url = _resolve_webapp_logo_url(settings)
cache["data"] = { 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, "subscription_options": settings.subscription_options,
"stars_subscription_options": settings.stars_subscription_options, "stars_subscription_options": settings.stars_subscription_options,
"traffic_packages": settings.traffic_packages, "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") html = TEMPLATE_PATH.read_text(encoding="utf-8")
cached = _get_cached_webapp_settings(request) 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 = { config = {
"title": settings.WEBAPP_TITLE, "title": settings.WEBAPP_TITLE,
"primaryColor": settings.WEBAPP_PRIMARY_COLOR, "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"], "logoUrl": cached["logo_url"],
"logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI),
"logoEmoji": settings.WEBAPP_LOGO_EMOJI, "logoEmoji": settings.WEBAPP_LOGO_EMOJI,
"logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT,
"faviconUrl": cached["favicon_url"],
"faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM),
"apiBase": "/api", "apiBase": "/api",
"telegramLoginBotUsername": request.app.get("bot_username") or "", "telegramLoginBotUsername": request.app.get("bot_username") or "",
"telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, "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, "appRepositoryUrl": APP_REPOSITORY_URL,
} }
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER) 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("</head>", f"{initial_theme_markup}\n</head>", 1)
i18n_instance: Optional[object] = request.app.get("i18n") i18n_instance: Optional[object] = request.app.get("i18n")
i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
nonce = request.get("csp_nonce", "") nonce = request.get("csp_nonce", "")
@@ -673,17 +919,27 @@ async def index_route(request: web.Request) -> web.Response:
WEBAPP_JS_PLACEHOLDER, WEBAPP_JS_PLACEHOLDER,
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>', f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
) )
favicon_markup = _favicon_head_markup(cached["favicon_url"])
if favicon_markup:
html = html.replace(
'<link id="app-favicon" rel="icon" href="data:," sizes="any">',
favicon_markup,
)
brand_asset_url = cached["logo_url"] 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) brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI)
if brand_asset_url: if brand_asset_url:
html = html.replace( html = html.replace(
'<link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high" crossorigin="anonymous">', # noqa: E501 '<link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high">',
f'<link rel="preload" href="{brand_asset_url}" as="image" fetchpriority="high" crossorigin="anonymous">', # noqa: E501 f'<link rel="preload" href="{brand_asset_url}" as="image" fetchpriority="high">',
) )
else: else:
html = html.replace( html = html.replace(
'<link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high" crossorigin="anonymous">', # noqa: E501 '<link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high">',
"", "",
) )
return web.Response(text=html, content_type="text/html", charset="utf-8") 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" 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'<style id="webapp-initial-theme" nonce="{nonce}">' + "".join(css_rules) + "</style>"
)
href = _theme_css_href_for_html(theme)
if not href:
return style_tag
stylesheet = (
f'<link rel="stylesheet" href="{html.escape(href, quote=True)}" '
f'data-initial-theme-css="{html.escape(str(theme.key), quote=True)}">'
)
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'<link id="app-favicon" rel="icon" href="{escaped_href}" sizes="any">\n'
f'<link rel="{rel}" href="{escaped_href}">'
)
digest = match.group(1)
base = f"{WEBAPP_FAVICON_PATH}/{digest}"
return "\n".join(
[
(
f'<link id="app-favicon" rel="icon" type="image/png" sizes="32x32" '
f'href="{base}/icon-32.png">'
),
f'<link rel="icon" type="image/x-icon" sizes="any" href="{base}/favicon.ico">',
f'<link rel="icon" type="image/png" sizes="16x16" href="{base}/icon-16.png">',
f'<link rel="icon" type="image/png" sizes="192x192" href="{base}/icon-192.png">',
f'<link rel="apple-touch-icon" sizes="180x180" href="{base}/apple-touch-icon.png">',
]
)
def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str: def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str:
start = html.find(start_marker) start = html.find(start_marker)
if start == -1: if start == -1:
+17 -1
View File
@@ -9,17 +9,33 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/devices", index_route) app.router.add_get("/devices", index_route)
app.router.add_get("/settings", index_route) app.router.add_get("/settings", index_route)
app.router.add_get("/admin", 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("/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/start", telegram_oauth_start_route)
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route) app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
app.router.add_get("/health", health_route) app.router.add_get("/health", health_route)
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_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( app.router.add_get(
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}", r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
webapp_animated_emoji_route, webapp_animated_emoji_route,
) )
app.router.add_get("/subscription_webapp.css", css_asset_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.min.{asset_hash}.js", js_asset_route)
app.router.add_get("/subscription_webapp.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) app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route)
+7
View File
@@ -1,6 +1,8 @@
# ruff: noqa: F401,F403,F405,I001 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 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]: async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
settings: Settings = request.app["settings"] 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"], stars_traffic_packages=cached["stars_traffic_packages"],
), ),
"payment_methods": _serialize_payment_methods(settings, request.app), "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": { "settings": {
"support_url": settings.SUPPORT_LINK, "support_url": settings.SUPPORT_LINK,
"traffic_mode": bool(settings.traffic_sale_mode), "traffic_mode": bool(settings.traffic_sale_mode),
+5 -3
View File
@@ -1,7 +1,7 @@
"""Branded HTML email templates that mirror the subscription Mini App look. """Branded HTML email templates that mirror the subscription Mini App look.
The web app uses a dark theme with a configurable accent colour 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 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 copy goes through the shared `JsonI18n` instance so translations live in
``locales/<lang>.json`` next to the rest of the bot strings. ``locales/<lang>.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]: def _public_logo_url(settings: Settings) -> Optional[str]:
"""Email recipients can't reach the in-app /webapp-logo proxy, so the """Email recipients can't reach the in-app /webapp-logo proxy, so only a
raw https URL from the env is used directly. Anything else is dropped.""" 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() raw = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw: if not raw:
return None return None
+78
View File
@@ -7,6 +7,10 @@ from pydantic import BaseModel, Field, ValidationError, computed_field, field_va
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from config.tariffs_config import TariffsConfig, load_tariffs_config 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]: def _split_csv(value: Optional[str]) -> List[str]:
@@ -88,8 +92,12 @@ class WebAppSettings(BaseModel):
title: str title: str
primary_color: str primary_color: str
logo_url: Optional[str] logo_url: Optional[str]
logo_use_emoji: bool
logo_emoji: str logo_emoji: str
logo_emoji_font: str logo_emoji_font: str
favicon_use_custom: bool
favicon_url: Optional[str]
logo_favicon_url: Optional[str]
session_ttl_seconds: int session_ttl_seconds: int
session_secret: str session_secret: str
webhook_secret_token: str webhook_secret_token: str
@@ -351,7 +359,21 @@ class Settings(BaseSettings):
WEBAPP_SERVER_PORT: int = Field(default=8081) WEBAPP_SERVER_PORT: int = Field(default=8081)
WEBAPP_TITLE: str = Field(default="Моя подписка") WEBAPP_TITLE: str = Field(default="Моя подписка")
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a") WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
WEBAPP_THEMES_DIR: str = Field(
default="data/themes",
description=(
"Directory with per-theme folders. Each theme lives in "
"<key>/theme.json with optional CSS/assets next to it."
),
)
WEBAPP_DEFAULT_THEME: Optional[str] = Field(
default=None,
description=(
"Override the descriptor-marked default theme when set to an existing theme key."
),
)
WEBAPP_LOGO_URL: Optional[str] = Field(default=None) WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
WEBAPP_LOGO_USE_EMOJI: bool = Field(default=False)
WEBAPP_LOGO_EMOJI: str = Field(default="🫥") WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
WEBAPP_LOGO_EMOJI_FONT: str = Field( WEBAPP_LOGO_EMOJI_FONT: str = Field(
default="system", default="system",
@@ -360,6 +382,9 @@ class Settings(BaseSettings):
"noto-emoji, twemoji, openmoji, apple, segoe, noto-local" "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)) WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBHOOK_SECRET_TOKEN: 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) WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60)
@@ -528,8 +553,12 @@ class Settings(BaseSettings):
title=self.WEBAPP_TITLE, title=self.WEBAPP_TITLE,
primary_color=self.WEBAPP_PRIMARY_COLOR, primary_color=self.WEBAPP_PRIMARY_COLOR,
logo_url=self.WEBAPP_LOGO_URL, logo_url=self.WEBAPP_LOGO_URL,
logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI,
logo_emoji=self.WEBAPP_LOGO_EMOJI, logo_emoji=self.WEBAPP_LOGO_EMOJI,
logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT, 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_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS,
session_secret=self.WEBAPP_SESSION_SECRET, session_secret=self.WEBAPP_SESSION_SECRET,
webhook_secret_token=self.WEBHOOK_SECRET_TOKEN, webhook_secret_token=self.WEBHOOK_SECRET_TOKEN,
@@ -810,6 +839,55 @@ class Settings(BaseSettings):
def tariffs_config(self) -> Optional[TariffsConfig]: def tariffs_config(self) -> Optional[TariffsConfig]:
return load_tariffs_config(self.TARIFFS_CONFIG_PATH) 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 @computed_field
@property @property
def referral_bonus_inviter(self) -> Dict[int, int]: def referral_bonus_inviter(self) -> Dict[int, int]:
+633
View File
@@ -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/<key>/theme.json."""
model_config = {"extra": "ignore"}
key: str = Field(min_length=1, max_length=64)
names: Dict[str, str] = Field(default_factory=dict)
enabled: bool = True
default: bool = False
use_primary_accent: bool = True
use_in_admin: bool = True
css_file: Optional[str] = None
assets_version: int = 1
tokens: ThemeTokens = Field(default_factory=ThemeTokens)
class WebappThemesConfig(BaseModel):
"""Runtime catalog assembled from individual theme descriptor files."""
model_config = {"extra": "ignore"}
default_theme: str = "dark"
themes: List[WebappTheme] = Field(default_factory=list)
@model_validator(mode="after")
def _validate_default_and_keys(self) -> WebappThemesConfig:
keys = [t.key for t in self.themes]
if len(keys) != len(set(keys)):
raise ValueError("duplicate theme keys")
if self.themes and self.default_theme not in keys:
raise ValueError("default_theme must match a theme key")
return self
def theme_by_key(self, key: str) -> Optional[WebappTheme]:
for theme in self.themes:
if theme.key == key:
return theme
return None
def enabled_themes(self) -> List[WebappTheme]:
return [theme for theme in self.themes if theme.enabled]
DEFAULT_THEME_KEYS = ("dark", "light", "windows95", "ascii")
THEME_DESCRIPTOR_FILENAME = "theme.json"
DEFAULT_THEMES_SOURCE_DIR = Path(__file__).resolve().parents[1] / "bot" / "app" / "web" / "themes"
def _safe_theme_key(value: str) -> Optional[str]:
key = str(value or "").strip()
if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key):
return key
return None
def _theme_dir_path(theme_dir: str | Path, key: str) -> Path:
safe_key = _safe_theme_key(key)
if not safe_key:
raise ValueError(f"invalid theme key: {key!r}")
return Path(theme_dir).expanduser() / safe_key
def _theme_file_path(theme_dir: str | Path, key: str) -> Path:
return _theme_dir_path(theme_dir, key) / THEME_DESCRIPTOR_FILENAME
def default_webapp_theme_css_files() -> Dict[str, str]:
"""Read default theme CSS from repository files."""
out: Dict[str, str] = {}
for key in DEFAULT_THEME_KEYS:
source_dir = DEFAULT_THEMES_SOURCE_DIR / key
for source_path in sorted(source_dir.rglob("*.css")):
rel_path = Path(key) / source_path.relative_to(source_dir)
try:
content = source_path.read_text(encoding="utf-8")
except OSError as exc:
logger.warning(
"Default webapp theme CSS source file is missing: %s (%s)",
source_path,
exc,
)
continue
out[rel_path.as_posix()] = content if content.endswith("\n") else f"{content}\n"
return out
def default_webapp_theme_asset_file(rel_path: str | Path) -> Optional[tuple[bytes, str]]:
"""Read a default theme asset from the repository theme folder."""
relative = Path(rel_path)
if relative.is_absolute() or len(relative.parts) < 2 or ".." in relative.parts:
return None
source_path = (DEFAULT_THEMES_SOURCE_DIR / relative).resolve()
try:
source_path.relative_to(DEFAULT_THEMES_SOURCE_DIR.resolve())
except ValueError:
return None
try:
return source_path.read_bytes(), source_path.suffix.lower()
except OSError:
return None
def default_webapp_theme_descriptors() -> Dict[str, Dict[str, Any]]:
"""Read default theme descriptors from repository files."""
out: Dict[str, Dict[str, Any]] = {}
for key in DEFAULT_THEME_KEYS:
source_path = DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME
try:
raw = json.loads(source_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.warning(
"Default webapp theme descriptor is missing or invalid: %s (%s)",
source_path,
exc,
)
continue
if not isinstance(raw, dict):
continue
safe_key = _safe_theme_key(str(raw.get("key") or source_path.parent.name))
if safe_key:
raw["key"] = safe_key
out[safe_key] = raw
return out
def _theme_from_descriptor(path: Path, raw: Any) -> Optional[WebappTheme]:
if not isinstance(raw, dict):
logger.warning("Ignoring theme descriptor %s: expected JSON object", path)
return None
data = dict(raw)
data["key"] = data.get("key") or (
path.parent.name if path.name == THEME_DESCRIPTOR_FILENAME else path.stem
)
safe_key = _safe_theme_key(str(data["key"]))
if not safe_key:
logger.warning("Ignoring theme descriptor %s: invalid theme key %r", path, data["key"])
return None
data["key"] = safe_key
try:
return WebappTheme.model_validate(data)
except ValueError as exc:
logger.warning("Ignoring theme descriptor %s: %s", path, exc)
return None
def load_webapp_theme_file(path: str | Path) -> Optional[WebappTheme]:
theme_path = Path(path).expanduser()
try:
raw = json.loads(theme_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Failed to load webapp theme descriptor from %s: %s", theme_path, exc)
return None
return _theme_from_descriptor(theme_path, raw)
def load_webapp_theme_dir(theme_dir: str | Path) -> List[WebappTheme]:
root = Path(theme_dir).expanduser()
if not root.exists():
return []
themes_by_key: Dict[str, WebappTheme] = {}
for path in sorted(root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}")):
if path.parent.name.startswith("_"):
continue
theme = load_webapp_theme_file(path)
if theme is None:
continue
if theme.key in themes_by_key:
logger.warning("Ignoring duplicate webapp theme key %s from %s", theme.key, path)
continue
themes_by_key[theme.key] = theme
return list(themes_by_key.values())
def _config_with_synced_default_flags(config: WebappThemesConfig) -> WebappThemesConfig:
data = config.model_dump(mode="json", exclude_none=True)
default_theme = str(data.get("default_theme") or "dark")
themes = data.get("themes", [])
for theme in themes:
if isinstance(theme, dict):
theme["default"] = theme.get("key") == default_theme
def _sort_key(item: tuple[int, Any]) -> tuple[int, int]:
idx, theme = item
if not isinstance(theme, dict):
return (len(THEME_DISPLAY_ORDER), idx)
try:
priority = THEME_DISPLAY_ORDER.index(str(theme.get("key") or ""))
except ValueError:
priority = len(THEME_DISPLAY_ORDER)
return (priority, idx)
data["themes"] = [theme for _, theme in sorted(enumerate(themes), key=_sort_key)]
return WebappThemesConfig.model_validate(data)
THEME_DISPLAY_ORDER = ("dark", "light")
def _theme_sort_key(theme: WebappTheme, index: int) -> tuple[int, int]:
try:
priority = THEME_DISPLAY_ORDER.index(theme.key)
except ValueError:
priority = len(THEME_DISPLAY_ORDER)
return (priority, index)
def _sorted_themes(themes: List[WebappTheme]) -> List[WebappTheme]:
return [
theme
for _, theme in sorted(
((_theme_sort_key(t, i), t) for i, t in enumerate(themes)),
key=lambda pair: pair[0],
)
]
def _themes_config_from_list(
default_theme: Optional[str],
themes: List[WebappTheme],
) -> WebappThemesConfig:
keys = {theme.key for theme in themes}
descriptor_default = next(
(theme.key for theme in themes if theme.default and theme.key not in DEFAULT_THEME_KEYS),
None,
) or next((theme.key for theme in themes if theme.default), None)
resolved_default = default_theme or descriptor_default or "dark"
if themes and resolved_default not in keys:
resolved_default = "dark" if "dark" in keys else themes[0].key
config = WebappThemesConfig(default_theme=resolved_default, themes=_sorted_themes(themes))
return _config_with_synced_default_flags(config)
def _write_webapp_theme_file(path: Path, theme: WebappTheme) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
data = theme.model_dump(mode="json", exclude_none=True)
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
try:
tmp_path.write_text(payload, encoding="utf-8")
tmp_path.replace(path)
except PermissionError:
if tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
path.write_text(payload, encoding="utf-8")
def _copy_default_theme_assets(key: str, target_dir: Path, *, overwrite: bool = False) -> None:
source_dir = DEFAULT_THEMES_SOURCE_DIR / key
if not source_dir.exists():
return
for source_path in sorted(source_dir.rglob("*")):
if not source_path.is_file() or source_path.name == THEME_DESCRIPTOR_FILENAME:
continue
rel_path = source_path.relative_to(source_dir)
target_path = target_dir / rel_path
if target_path.exists() and not overwrite:
continue
try:
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(source_path.read_bytes())
except OSError as exc:
logger.warning("Could not create default webapp theme asset %s: %s", target_path, exc)
def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
style_path = target_dir / "style.css"
try:
style = style_path.read_text(encoding="utf-8")
except OSError:
return True
if key == "light":
return "--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/<key>."""
root = Path(theme_dir).expanduser()
root.mkdir(parents=True, exist_ok=True)
normalized = _config_with_synced_default_flags(config)
keep_paths = set()
for theme in normalized.themes:
path = _theme_file_path(root, theme.key)
_write_webapp_theme_file(path, theme)
keep_paths.add(path.resolve())
if not delete_missing:
return
for path in root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}"):
if path.parent.name.startswith("_") or path.resolve() in keep_paths:
continue
try:
path.unlink()
if not any(path.parent.iterdir()):
path.parent.rmdir()
except OSError as exc:
logger.warning("Could not delete removed webapp theme descriptor %s: %s", path, exc)
def ensure_webapp_core_themes(
config: WebappThemesConfig, primary_accent: str
) -> tuple[WebappThemesConfig, bool]:
"""Keep dark, light and Windows 95 themes available without clobbering custom edits."""
data = config.model_dump(mode="json", exclude_none=True)
themes = data.setdefault("themes", [])
by_key = {str(theme.get("key")): theme for theme in themes if isinstance(theme, dict)}
changed = False
for builtin in builtin_webapp_themes_config(primary_accent).themes:
builtin_data = builtin.model_dump(mode="json", exclude_none=True)
existing = by_key.get(builtin.key)
if existing is None:
themes.append(builtin_data)
by_key[builtin.key] = builtin_data
changed = True
continue
if existing.get("enabled") is False:
existing["enabled"] = True
changed = True
if "use_primary_accent" not in existing:
existing["use_primary_accent"] = builtin_data.get("use_primary_accent", True)
changed = True
if "use_in_admin" not in existing:
existing["use_in_admin"] = builtin_data.get("use_in_admin", True)
changed = True
if int(existing.get("assets_version") or 0) < int(builtin_data.get("assets_version") or 1):
existing["assets_version"] = builtin_data.get("assets_version", 1)
changed = True
if builtin.key in {"light", "windows95", "ascii"} and not existing.get("css_file"):
existing["css_file"] = builtin_data.get("css_file")
changed = True
tokens = existing.setdefault("tokens", {})
builtin_tokens = builtin_data.get("tokens", {})
for token_key in ("color_scheme", "style_preset"):
if token_key in builtin_tokens and not tokens.get(token_key):
tokens[token_key] = builtin_tokens[token_key]
changed = True
keys = {str(theme.get("key")) for theme in themes if isinstance(theme, dict)}
if themes and data.get("default_theme") not in keys:
data["default_theme"] = "dark" if "dark" in keys else str(themes[0].get("key") or "dark")
changed = True
normalized = _config_with_synced_default_flags(WebappThemesConfig.model_validate(data))
if normalized.model_dump(mode="json", exclude_none=True) != data:
changed = True
return normalized, changed
def builtin_webapp_themes_config(primary_accent: str) -> WebappThemesConfig:
"""Default catalog backed by repository theme descriptor files."""
accent = (primary_accent or "#00fe7a").strip() or "#00fe7a"
themes: List[WebappTheme] = []
descriptors = default_webapp_theme_descriptors()
for key in DEFAULT_THEME_KEYS:
raw = descriptors.get(key)
if not raw:
continue
theme = _theme_from_descriptor(
DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME,
raw,
)
if theme is None:
continue
if theme.key == "dark" and not theme.tokens.accent:
theme.tokens.accent = accent
themes.append(theme)
return _themes_config_from_list(None, themes)
def apply_webapp_theme_env_overrides(
config: WebappThemesConfig, env_default_theme: Optional[str]
) -> WebappThemesConfig:
"""If WEBAPP_DEFAULT_THEME is set and matches a theme key, override the default theme."""
raw = (env_default_theme or "").strip()
if not raw:
return config
if config.theme_by_key(raw) is None:
logger.warning("WEBAPP_DEFAULT_THEME=%r ignored: no such theme in catalog", raw)
return config
data = config.model_dump(mode="json", exclude_none=True)
data["default_theme"] = raw
return _config_with_synced_default_flags(WebappThemesConfig.model_validate(data))
def resolved_webapp_themes_catalog(
*,
theme_dir: str | Path,
primary_accent: str,
env_default_theme: Optional[str],
) -> WebappThemesConfig:
"""Load themes from WEBAPP_THEMES_DIR, seeding defaults when possible."""
ensure_default_webapp_theme_descriptor_files(theme_dir)
themes = load_webapp_theme_dir(theme_dir)
config = _themes_config_from_list(None, themes)
config, changed = ensure_webapp_core_themes(config, primary_accent)
if changed:
try:
write_webapp_theme_dir(theme_dir, config, delete_missing=False)
except OSError as exc:
logger.warning("Could not update webapp theme descriptors in %s: %s", theme_dir, exc)
return apply_webapp_theme_env_overrides(config, env_default_theme)
def merge_primary_accent_into_theme_tokens(
theme: WebappTheme, primary_accent: str, *, only_if_token_missing: bool = True
) -> ThemeTokens:
"""Fill accent from WEBAPP_PRIMARY_COLOR when theme tokens omit accent."""
base = theme.tokens.model_copy(deep=True)
accent = (primary_accent or "").strip()
if not accent:
return base
if only_if_token_missing and base.accent:
return base
base.accent = accent
return base
def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]:
tokens = (
merge_primary_accent_into_theme_tokens(theme, primary_accent)
if theme.use_primary_accent
else theme.tokens
)
payload: Dict[str, object] = {
"key": theme.key,
"names": dict(theme.names),
"enabled": bool(theme.enabled),
"use_primary_accent": bool(theme.use_primary_accent),
"use_in_admin": bool(theme.use_in_admin),
"tokens": tokens.model_dump(mode="json", exclude_none=True),
}
if theme.css_file:
payload["css_file"] = theme.css_file
return payload
def public_themes_catalog_payload(
config: WebappThemesConfig, primary_accent: str, *, enabled_only: bool = False
) -> Dict[str, object]:
themes = [theme for theme in config.themes if not enabled_only or theme.enabled]
return {
"default_theme": config.default_theme,
"themes": [public_theme_payload(theme, primary_accent) for theme in themes],
}
+9
View File
@@ -9,6 +9,7 @@
- блокировка пользователей, рассылки, промокоды и просмотр логов; - блокировка пользователей, рассылки, промокоды и просмотр логов;
- ручная синхронизация с Remnawave; - ручная синхронизация с Remnawave;
- редактор разрешенных настроек приложения из manifest-файла; - редактор разрешенных настроек приложения из manifest-файла;
- раздел **Внешний вид** для логотипа, emoji-логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем;
- редактор JSON-каталога тарифов; - редактор JSON-каталога тарифов;
- загрузка Internal Squads из Remnawave для выбора в тарифах. - загрузка Internal Squads из Remnawave для выбора в тарифах.
@@ -46,6 +47,14 @@
Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде. Секретные поля помечены как 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=<key>` и не меняет глобальную тему до сохранения.
Подробный формат `theme.json`, CSS/asset-роуты и пошаговый пайплайн создания новой темы описаны в [webapp-themes.md](webapp-themes.md).
## Тарифы ## Тарифы
Раздел **Система -> Тарифы** работает с файлом `TARIFFS_CONFIG_PATH` (по умолчанию `data/tariffs.json`). При сохранении backend валидирует payload через `TariffsConfig`, пишет JSON в UTF-8 и сбрасывает кеш публичных данных Web App. Раздел **Система -> Тарифы** работает с файлом `TARIFFS_CONFIG_PATH` (по умолчанию `data/tariffs.json`). При сохранении backend валидирует payload через `TariffsConfig`, пишет JSON в UTF-8 и сбрасывает кеш публичных данных Web App.
+5 -7
View File
@@ -96,10 +96,10 @@ nano .env
Если файл из `TARIFFS_CONFIG_PATH` существует, бот использует каталог тарифов. Если файла нет, применяется конфигурация из переменных `.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 ```bash
mkdir -p data/webapp-logo data/webapp-emoji mkdir -p data/themes data/webapp-logo data/webapp-emoji
chown -R 10001:10001 data chown -R 10001:10001 data
chmod -R u+rwX 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`. | | `WEBAPP_SERVER_HOST` / `WEBAPP_SERVER_PORT` | Хост и порт Web App. По умолчанию порт `8081`. |
| `SUBSCRIPTION_MINI_APP_URL` | Публичный URL Web App. | | `SUBSCRIPTION_MINI_APP_URL` | Публичный URL Web App. |
| `WEBAPP_TITLE` | Заголовок Web App. | | `WEBAPP_TITLE` | Заголовок Web App. |
| `WEBAPP_PRIMARY_COLOR` | Основной цвет интерфейса. | | `WEBAPP_THEMES_DIR` | Каталог тем Web App. По умолчанию `data/themes`; внутри ожидаются папки `<key>/theme.json` и опциональные CSS/ассеты. |
| `WEBAPP_LOGO_URL` | URL логотипа Web App; если пусто — показывается emoji из `WEBAPP_LOGO_EMOJI`. | | `WEBAPP_DEFAULT_THEME` | Опциональный override темы по ключу, например `light` или `neon`. Если пусто, используется `default` из дескрипторов тем. |
| `WEBAPP_LOGO_EMOJI` | Emoji-заглушка вместо картинки логотипа. |
| `WEBAPP_LOGO_EMOJI_FONT` | Набор/шрифт для отрисовки emoji (например `system`, `twemoji`, `noto-color-animated`). |
| `WEBAPP_SESSION_SECRET` | HMAC-секрет сессий Web App. | | `WEBAPP_SESSION_SECRET` | HMAC-секрет сессий Web App. |
| `WEBHOOK_SECRET_TOKEN` | Секретный токен, с которым Telegram шлёт обновления на вебхук. | | `WEBHOOK_SECRET_TOKEN` | Секретный токен, с которым Telegram шлёт обновления на вебхук. |
| `WEBAPP_SESSION_TTL_SECONDS` | Время жизни сессии Web App. | | `WEBAPP_SESSION_TTL_SECONDS` | Время жизни сессии Web App. |
@@ -146,7 +144,7 @@ docker compose up -d --build --force-recreate
| `BRUTE_FORCE_LOCK_SECONDS` | Длительность временной блокировки. | | `BRUTE_FORCE_LOCK_SECONDS` | Длительность временной блокировки. |
| `MY_DEVICES_SECTION_ENABLED` | Показывает раздел "Мои устройства" и включает API устройств. | | `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 ### SMTP и вход по email
+3 -3
View File
@@ -26,7 +26,7 @@ IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d
Перед запуском или после добавления mount выполните на сервере из каталога проекта: Перед запуском или после добавления mount выполните на сервере из каталога проекта:
```bash ```bash
mkdir -p data/webapp-logo data/webapp-emoji mkdir -p data/themes data/webapp-logo data/webapp-emoji
chown -R 10001:10001 data chown -R 10001:10001 data
chmod -R u+rwX data chmod -R u+rwX data
docker compose up -d --force-recreate remnawave-minishop docker compose up -d --force-recreate remnawave-minishop
@@ -35,10 +35,10 @@ docker compose up -d --force-recreate remnawave-minishop
Проверка прав: Проверка прав:
```bash ```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`.
## Обновление версии ## Обновление версии
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+366
View File
@@ -0,0 +1,366 @@
# Темы и внешний вид Web App
Web App поддерживает файловые темы, предпросмотр и базовую настройку внешнего вида из админ-панели. Тема может быть простой цветовой схемой на JSON-токенах или полноценным скином с собственным CSS, шрифтами, иконками и графикой.
## Что можно поменять
Через раздел **Админка -> Внешний вид** можно:
- выбрать глобальную тему Web App;
- изменить accent-цвет конкретной темы;
- включить или выключить применение темы в админ-панели;
- настроить масштаб логотипа на главной и экране входа;
- загрузить логотип файлом или по HTTPS-ссылке;
- включить emoji-логотип и выбрать способ его отрисовки;
- открыть предпросмотр темы через `/home?theme_preview=<key>`.
Через файлы темы можно менять намного больше:
- базовые цвета 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/<key>/<css_file>
```
Например `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-<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/<key>/<path>
```
Пример:
```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/<key>/...`. Не используйте относительные пути вроде `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/<key>/theme.json`;
- ключ состоит только из латиницы, цифр, `_` и `-`;
- JSON валиден;
- тема не отключена через `enabled: false`;
- в логах нет предупреждения `Ignoring theme descriptor`.
Если CSS не применился:
- проверьте `css_file` и URL `/webapp-theme-css/<key>/<css_file>`;
- убедитесь, что файл меньше 512 KiB;
- начинайте селекторы с `.theme-key-<key>`;
- откройте `/home?theme_preview=<key>` в новом окне, чтобы исключить сохраненный старый выбор.
Если ассеты не грузятся:
- используйте путь `/webapp-theme-assets/<key>/<path>`;
- проверьте расширение: `png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `ico`;
- размер каждого файла должен быть до 1 MiB;
- путь не должен содержать пробелы, кириллицу или `..`.
+5 -5
View File
@@ -24,10 +24,8 @@ WEBAPP_SERVER_HOST=0.0.0.0
WEBAPP_SERVER_PORT=8081 WEBAPP_SERVER_PORT=8081
SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/ SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/
WEBAPP_TITLE="Моя подписка" WEBAPP_TITLE="Моя подписка"
WEBAPP_PRIMARY_COLOR="#00fe7a" WEBAPP_THEMES_DIR=data/themes
WEBAPP_LOGO_URL= WEBAPP_DEFAULT_THEME=
WEBAPP_LOGO_EMOJI="🫥"
WEBAPP_LOGO_EMOJI_FONT=system
WEBAPP_SESSION_SECRET=<stable-random-secret> WEBAPP_SESSION_SECRET=<stable-random-secret>
WEBHOOK_SECRET_TOKEN=<stable-random-secret> WEBHOOK_SECRET_TOKEN=<stable-random-secret>
WEBAPP_SESSION_TTL_SECONDS=86400 WEBAPP_SESSION_TTL_SECONDS=86400
@@ -49,7 +47,9 @@ SMTP_FROM_EMAIL=no-reply@domain.com
SMTP_FROM_NAME=Remnawave Minishop 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-авторизация ## Telegram-авторизация
+14
View File
@@ -768,6 +768,9 @@
"wa_activate": "Activate", "wa_activate": "Activate",
"wa_settings_avatar_alt": "User avatar", "wa_settings_avatar_alt": "User avatar",
"wa_settings_language": "Language", "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_link_telegram": "Telegram linked",
"wa_settings_not_linked": "Not linked", "wa_settings_not_linked": "Not linked",
"wa_settings_link_email": "Email linked", "wa_settings_link_email": "Email linked",
@@ -852,6 +855,7 @@
"admin_nav_logs": "Logs", "admin_nav_logs": "Logs",
"admin_nav_system": "System", "admin_nav_system": "System",
"admin_nav_tariffs": "Tariffs", "admin_nav_tariffs": "Tariffs",
"admin_nav_themes": "Themes",
"admin_nav_settings": "Settings", "admin_nav_settings": "Settings",
"admin_section_stats_title": "Dashboard", "admin_section_stats_title": "Dashboard",
"admin_section_stats_subtitle": "Audience, revenue, Remnawave panel, and recent payments", "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_logs_subtitle": "User events and admin actions",
"admin_section_tariffs_title": "Tariffs", "admin_section_tariffs_title": "Tariffs",
"admin_section_tariffs_subtitle": "Sales catalog, periods, packages, and limits", "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_title": "App Settings",
"admin_section_settings_subtitle": "Overrides for .env, applied instantly", "admin_section_settings_subtitle": "Overrides for .env, applied instantly",
"admin_filter_all": "All", "admin_filter_all": "All",
@@ -1047,6 +1053,7 @@
"admin_stats_queue_groups": " groups", "admin_stats_queue_groups": " groups",
"admin_id": "ID", "admin_id": "ID",
"admin_status_default": "Default", "admin_status_default": "Default",
"admin_status_current": "Current",
"admin_tariff_squads": "Squads", "admin_tariff_squads": "Squads",
"admin_tariff_premium": "Premium", "admin_tariff_premium": "Premium",
"admin_settings_badge_secret": "Secret", "admin_settings_badge_secret": "Secret",
@@ -1221,6 +1228,13 @@
"admin_tariffs_stat_disabled": "Disabled", "admin_tariffs_stat_disabled": "Disabled",
"admin_tariffs_stat_disabled_hint": "Hidden from showcase", "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_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_no_description": "No description",
"admin_tariff_model_traffic": "Traffic", "admin_tariff_model_traffic": "Traffic",
"admin_tariff_model_periods": "Periods", "admin_tariff_model_periods": "Periods",
+14
View File
@@ -768,6 +768,9 @@
"wa_activate": "Активировать", "wa_activate": "Активировать",
"wa_settings_avatar_alt": "Аватар пользователя", "wa_settings_avatar_alt": "Аватар пользователя",
"wa_settings_language": "Выбор языка", "wa_settings_language": "Выбор языка",
"wa_settings_theme": "Тема оформления",
"wa_settings_theme_follow_default": "По умолчанию (сервер)",
"wa_settings_theme_update_failed": "Не удалось обновить тему",
"wa_settings_link_telegram": "Привязка Telegram", "wa_settings_link_telegram": "Привязка Telegram",
"wa_settings_not_linked": "Не привязан", "wa_settings_not_linked": "Не привязан",
"wa_settings_link_email": "Привязка почты", "wa_settings_link_email": "Привязка почты",
@@ -852,6 +855,7 @@
"admin_nav_logs": "Логи", "admin_nav_logs": "Логи",
"admin_nav_system": "Система", "admin_nav_system": "Система",
"admin_nav_tariffs": "Тарифы", "admin_nav_tariffs": "Тарифы",
"admin_nav_themes": "Темы",
"admin_nav_settings": "Настройки", "admin_nav_settings": "Настройки",
"admin_section_stats_title": "Дашборд", "admin_section_stats_title": "Дашборд",
"admin_section_stats_subtitle": "Аудитория, доходы, панель Remnawave и последние платежи", "admin_section_stats_subtitle": "Аудитория, доходы, панель Remnawave и последние платежи",
@@ -869,6 +873,8 @@
"admin_section_logs_subtitle": "События пользователей и админ-действия", "admin_section_logs_subtitle": "События пользователей и админ-действия",
"admin_section_tariffs_title": "Тарифы", "admin_section_tariffs_title": "Тарифы",
"admin_section_tariffs_subtitle": "Каталог продаж, периоды, пакеты и лимиты", "admin_section_tariffs_subtitle": "Каталог продаж, периоды, пакеты и лимиты",
"admin_section_themes_title": "Темы Web App",
"admin_section_themes_subtitle": "Цвета, шрифты и темы оформления Mini App",
"admin_section_settings_title": "Настройки приложения", "admin_section_settings_title": "Настройки приложения",
"admin_section_settings_subtitle": "Оверрайды над .env, применяются мгновенно", "admin_section_settings_subtitle": "Оверрайды над .env, применяются мгновенно",
"admin_filter_all": "Все", "admin_filter_all": "Все",
@@ -1047,6 +1053,7 @@
"admin_stats_queue_groups": " групп", "admin_stats_queue_groups": " групп",
"admin_id": "ID", "admin_id": "ID",
"admin_status_default": "По умолчанию", "admin_status_default": "По умолчанию",
"admin_status_current": "Текущая",
"admin_tariff_squads": "Squads", "admin_tariff_squads": "Squads",
"admin_tariff_premium": "Premium", "admin_tariff_premium": "Premium",
"admin_settings_badge_secret": "Secret", "admin_settings_badge_secret": "Secret",
@@ -1221,6 +1228,13 @@
"admin_tariffs_stat_disabled": "Отключено", "admin_tariffs_stat_disabled": "Отключено",
"admin_tariffs_stat_disabled_hint": "Скрыто с витрины", "admin_tariffs_stat_disabled_hint": "Скрыто с витрины",
"admin_tariffs_catalog_empty": "Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога.", "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_no_description": "Без описания",
"admin_tariff_model_traffic": "Трафик", "admin_tariff_model_traffic": "Трафик",
"admin_tariff_model_periods": "Периоды", "admin_tariff_model_periods": "Периоды",
+1
View File
@@ -10,3 +10,4 @@ sqlalchemy[asyncio]==2.0.49
asyncpg==0.31.0 asyncpg==0.31.0
aiocryptopay==0.4.8 aiocryptopay==0.4.8
PyJWT[crypto]==2.12.1 PyJWT[crypto]==2.12.1
Pillow==12.2.0
+25
View File
@@ -28,6 +28,31 @@ class SettingsTests(unittest.TestCase):
self.assertTrue(settings.WEBHOOK_SECRET_TOKEN) self.assertTrue(settings.WEBHOOK_SECRET_TOKEN)
self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400) 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): def test_tariffs_config_missing_uses_legacy_fallback(self):
settings = Settings( settings = Settings(
_env_file=None, _env_file=None,
+244 -1
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import io
import json import json
import os import os
import tempfile import tempfile
@@ -8,9 +9,13 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from PIL import Image
from bot.app.web import subscription_webapp 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 bot.app.web.webapp import assets as webapp_assets
from config.settings import Settings from config.settings import Settings
from config.webapp_themes_config import builtin_webapp_themes_config
class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
@@ -91,6 +96,94 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
r"^/webapp-logo\?v=[0-9a-f]{12}$", 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): def test_animated_emoji_asset_path_uses_same_origin_route(self):
self.assertEqual( self.assertEqual(
subscription_webapp._webapp_animated_emoji_asset_path("🤩"), subscription_webapp._webapp_animated_emoji_asset_path("🤩"),
@@ -222,6 +315,155 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
) )
self.assertEqual(response.text, "console.log('minified');") 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): def test_webapp_logo_disk_cache_roundtrip(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
logo_url = "https://cdn.example.com/logo.png" 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_url = "https://cdn.example.com/logo.png"
logo = (b"cached-logo", "image/png") logo = (b"cached-logo", "image/png")
app = { 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": None,
"webapp_logo_cache_lock": asyncio.Lock(), "webapp_logo_cache_lock": asyncio.Lock(),
} }
@@ -276,6 +518,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
app = { app = {
"settings": SimpleNamespace( "settings": SimpleNamespace(
WEBAPP_LOGO_USE_EMOJI=True,
WEBAPP_LOGO_EMOJI="🤩", WEBAPP_LOGO_EMOJI="🤩",
WEBAPP_LOGO_EMOJI_FONT="noto-color-animated", WEBAPP_LOGO_EMOJI_FONT="noto-color-animated",
), ),
+39
View File
@@ -1,8 +1,10 @@
import asyncio
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from aiohttp import web 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 import admin_api, subscription_webapp
from bot.app.web.admin_api_impl import auth as admin_auth_routes 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", "/auth/telegram/callback"): "telegram_oauth_callback_route",
("GET", "/health"): "health_route", ("GET", "/health"): "health_route",
("GET", "/webapp-logo"): "webapp_logo_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", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route",
("GET", "/subscription_webapp.css"): "css_asset_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.min.{asset_hash}.js"): "js_asset_route",
("GET", "/subscription_webapp.js"): "js_asset_route", ("GET", "/subscription_webapp.js"): "js_asset_route",
("POST", "/api/auth/telegram/nonce"): "telegram_oauth_nonce_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", ("PATCH", "/api/admin/settings"): "admin_settings_patch_route",
("GET", "/api/admin/tariffs"): "admin_tariffs_get_route", ("GET", "/api/admin/tariffs"): "admin_tariffs_get_route",
("PUT", "/api/admin/tariffs"): "admin_tariffs_save_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", ("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route",
} }
for key, handler_name in expected.items(): for key, handler_name in expected.items():
self.assertEqual(routes.get(key), handler_name, key) 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): class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase):
def _settings(self): def _settings(self):
+424
View File
@@ -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)