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_PORT=8081 # Internal/published Mini App port
WEBAPP_TITLE="/minishop" # Mini App title
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used
WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen
WEBAPP_THEMES_DIR=data/themes # Folder with theme subfolders: <key>/theme.json and optional CSS/assets
WEBAPP_DEFAULT_THEME= # Optional: override descriptor default theme key (e.g. light)
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
+5 -2
View File
@@ -1,5 +1,7 @@
# Remnawave Minishop
![Remnawave Minishop](docs/remnawave-minishop.webp)
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
@@ -31,6 +33,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
@@ -80,10 +83,10 @@ docker compose logs -f remnawave-minishop
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, кеша логотипа Web App и animated emoji:
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
```bash
mkdir -p data/webapp-logo data/webapp-emoji
mkdir -p data/themes data/webapp-logo data/webapp-emoji
chown -R 10001:10001 data
chmod -R u+rwX data
```
+2
View File
@@ -17,6 +17,7 @@ from bot.app.web.admin_api_impl import (
stats as _stats,
sync as _sync,
tariffs as _tariffs,
themes as _themes,
users as _users,
)
@@ -34,6 +35,7 @@ _MODULES = (
_ads,
_settings,
_tariffs,
_themes,
_panel,
_routes,
)
+4
View File
@@ -270,6 +270,10 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
path.write_text(payload, encoding="utf-8")
def _webapp_themes_catalog_payload(config: Any) -> Dict[str, Any]:
return config.model_dump(mode="json", exclude_none=True)
def _panel_node_uuid_key(node: Dict[str, Any]) -> str:
uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id")
return str(uid).strip().lower() if uid else ""
+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_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)
+13
View File
@@ -70,5 +70,18 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
if (
"WEBAPP_LOGO_URL" in updates
or "WEBAPP_LOGO_URL" in deletes
or "WEBAPP_LOGO_USE_EMOJI" in updates
or "WEBAPP_LOGO_USE_EMOJI" in deletes
or "WEBAPP_FAVICON_URL" in updates
or "WEBAPP_FAVICON_URL" in deletes
or "WEBAPP_FAVICON_USE_CUSTOM" in updates
or "WEBAPP_FAVICON_USE_CUSTOM" in deletes
or "WEBAPP_LOGO_FAVICON_URL" in updates
or "WEBAPP_LOGO_FAVICON_URL" in deletes
):
request.app["webapp_logo_cache"] = None
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
+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(
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
),
SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"),
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
SettingField(
@@ -99,6 +100,9 @@ SETTINGS_MANIFEST: List[SettingField] = [
("noto-local", "Noto Emoji (local)"),
),
),
SettingField("WEBAPP_FAVICON_USE_CUSTOM", "bool", "appearance", "Использовать отдельную favicon"),
SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"),
SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"),
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
# ─── Subscription periods & pricing ────────────────────────────
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
+68 -5
View File
@@ -40,6 +40,13 @@
import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js";
import { activeTariffName, buildTariffCatalog } from "./lib/webapp/tariffs.js";
import { premiumTrafficPercent, trafficPercent } from "./lib/webapp/traffic.js";
import {
findThemeEntry,
resolveEffectiveThemeKey,
themeCssHref,
themeEntryToInlineStyle,
themeRootClass,
} from "./lib/webapp/themeStyle.js";
/** Used-traffic percent from which top-up modals and CTAs unlock in the web app home screen */
const TRAFFIC_TOPUP_UNLOCK_PERCENT = 80;
@@ -80,6 +87,7 @@
...(MOCK ? MOCK.config : {}),
...(injectedConfig || {}),
};
const themePreviewKey = String(CFG.themePreviewKey || query.get("theme_preview") || "").trim();
const I18N = injectedI18n || {};
let telegramSdkStatus = "idle";
let telegramMiniAppInitData = "";
@@ -239,11 +247,14 @@
$: brandEmojiFont = CFG.logoEmojiFont || "system";
$: brand = normalizeBrand({
title: brandTitle,
logoUrl: CFG.logoUrl,
logoUrl: CFG.logoUseEmoji ? "" : CFG.logoUrl,
emoji: brandEmoji,
emojiFont: brandEmojiFont,
});
$: accent = CFG.primaryColor || "#00fe7a";
$: faviconBrand = normalizeBrand({
...brand,
logoUrl: String(CFG.faviconUrl || "").trim() || brand.logoUrl,
});
$: plans = data?.plans?.length ? data.plans : DEV_MOCK.data.plans;
$: methods = data?.payment_methods?.length ? data.payment_methods : [];
$: appSettings = data?.settings || DEV_MOCK.data.settings;
@@ -300,6 +311,30 @@
premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT)
);
$: user = data?.user || {};
$: themesCatalog = data?.themes_catalog ||
CFG.themesCatalog || { default_theme: "dark", themes: [] };
$: previewThemeAllowed = Boolean(themePreviewKey && (!data?.user || user?.is_admin));
$: previewThemeEntry = previewThemeAllowed
? findThemeEntry(themesCatalog, themePreviewKey)
: null;
$: resolvedThemeKey = previewThemeEntry?.key || resolveEffectiveThemeKey(themesCatalog);
$: activeThemeEntry = findThemeEntry(themesCatalog, resolvedThemeKey);
$: darkThemeEntry = findThemeEntry(themesCatalog, "dark");
$: effectiveThemeEntry =
screen === "admin" && activeThemeEntry?.use_in_admin === false
? darkThemeEntry || activeThemeEntry
: activeThemeEntry;
$: shellStyle = themeEntryToInlineStyle(effectiveThemeEntry, CFG.primaryColor);
$: shellToneClass =
effectiveThemeEntry?.tokens?.color_scheme === "light" ? "theme-light" : "theme-dark";
$: shellThemeClass = themeRootClass(effectiveThemeEntry);
$: shellThemeCssHref = themeCssHref(effectiveThemeEntry);
$: if (typeof document !== "undefined" && effectiveThemeEntry?.tokens) {
const scheme = effectiveThemeEntry.tokens.color_scheme || "dark";
document.documentElement.style.colorScheme = scheme;
const bg = effectiveThemeEntry.tokens.bg;
if (bg) document.body.style.backgroundColor = bg;
}
$: isAdmin = Boolean(user?.is_admin);
$: if (screen === "admin" && !isAdmin) {
screen = "settings";
@@ -346,7 +381,7 @@
: telegramLoginUnavailable
? t("wa_auth_telegram_not_configured")
: "";
$: applyFavicon(brand);
$: applyFavicon(faviconBrand);
$: syncBodyScrollLock(
paymentModalOpen ||
changeModalOpen ||
@@ -844,13 +879,35 @@
);
}
async function handleAdminPersistedSaved() {
function adminPayloadHasLogoChange(options = {}) {
const keys = new Set([
...Object.keys(options.updates || {}),
...(Array.isArray(options.deletes) ? options.deletes : []),
]);
return [
"WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
"WEBAPP_FAVICON_URL",
"WEBAPP_FAVICON_USE_CUSTOM",
"WEBAPP_LOGO_FAVICON_URL",
].some((key) => keys.has(key));
}
async function handleAdminPersistedSaved(options = {}) {
invalidateWebappTariffOptionCaches(billingStore);
try {
await loadData();
} catch {
// Admin save already succeeded; a later full refresh will pick up new settings or catalog.
}
const shouldReloadFrontend =
options?.reloadFrontend === true ||
(!options?.deferFrontendReload && adminPayloadHasLogoChange(options));
if (shouldReloadFrontend && typeof window !== "undefined") {
window.location.reload();
}
}
function selectTariff(tariff) {
@@ -873,6 +930,9 @@
<svelte:head>
<title>{brandTitle}</title>
{#if shellThemeCssHref}
<link rel="stylesheet" href={shellThemeCssHref} data-theme-css={resolvedThemeKey} />
{/if}
</svelte:head>
<Tooltip.Provider>
@@ -880,7 +940,7 @@
{#if isPreviewBoard}
<PreviewBoard config={CFG} mockData={DEV_MOCK.data} />
{:else}
<div class="app-shell" style={`--accent: ${accent};`}>
<div class="app-shell {shellToneClass} {shellThemeClass}" style={shellStyle}>
{#if mode === "loading"}
<div class="loader">
<BrandMark {brand} size="md" />
@@ -931,8 +991,11 @@
onSectionChange={handleAdminSectionChange}
onSettingsSaved={handleAdminPersistedSaved}
onTariffsSaved={handleAdminPersistedSaved}
onThemesSaved={handleAdminPersistedSaved}
{brandTitle}
{brand}
appFaviconUrl={CFG.faviconUrl}
appFaviconUseCustom={CFG.faviconUseCustom}
appVersion={CFG.appVersion}
appRepositoryUrl={CFG.appRepositoryUrl}
{currentLang}
@@ -11,6 +11,7 @@
LayoutDashboard,
Megaphone,
Menu,
Paintbrush,
Plus,
RefreshCw,
Save,
@@ -34,6 +35,7 @@
import StatsSection from "./sections/StatsSection.svelte";
import TariffEditorModal from "./sections/TariffEditorModal.svelte";
import TariffsSection from "./sections/TariffsSection.svelte";
import AppearanceSection from "./sections/AppearanceSection.svelte";
import UserDetailModal from "./sections/UserDetailModal.svelte";
import UsersSection from "./sections/UsersSection.svelte";
import { createAdsStore } from "../lib/admin/stores/adsStore.js";
@@ -44,6 +46,7 @@
import { createSettingsStore } from "../lib/admin/stores/settingsStore.js";
import { createStatsStore } from "../lib/admin/stores/statsStore.js";
import { createTariffsStore } from "../lib/admin/stores/tariffsStore.js";
import { createThemesStore } from "../lib/admin/stores/themesStore.js";
import { createUsersStore } from "../lib/admin/stores/usersStore.js";
import {
fmtDate,
@@ -70,8 +73,11 @@
export let onSectionChange = () => {};
export let onSettingsSaved = () => {};
export let onTariffsSaved = () => {};
export let onThemesSaved = () => {};
export let brand = {};
export let brandTitle = "/minishop";
export let appFaviconUrl = "";
export let appFaviconUseCustom = false;
export let appVersion = "dev+local";
export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop";
export let currentLang = "ru";
@@ -111,6 +117,7 @@
label: at("nav_system", {}, "Система"),
items: [
{ id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins },
{ id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush },
{ id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders },
],
},
@@ -153,6 +160,10 @@
title: at("section_tariffs_title", {}, "Тарифы"),
subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"),
},
appearance: {
title: at("section_appearance_title", {}, "Внешний вид"),
subtitle: at("section_appearance_subtitle", {}, "Логотип, темы и акцентные цвета Mini App"),
},
settings: {
title: at("section_settings_title", {}, "Настройки приложения"),
subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"),
@@ -196,6 +207,7 @@
const settingsStore = createSettingsStore({ api, onToast: flash, at });
const statsStore = createStatsStore({ api, onToast: flash, at });
const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at });
const themesStore = createThemesStore({ api, onThemesSaved, flash, at });
const usersStore = createUsersStore({ api, onToast: flash, at });
setContext("promosStore", promosStore);
@@ -207,6 +219,7 @@
setContext("settingsStore", settingsStore);
setContext("usersStore", usersStore);
setContext("tariffsStore", tariffsStore);
setContext("themesStore", themesStore);
$: usersStore.setActive(active);
$: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length;
@@ -635,6 +648,17 @@
<TariffsSection {at} {fmtMoney} />
{/if}
{#if active === "appearance"}
<AppearanceSection
{at}
{currentLang}
{onSettingsSaved}
{brand}
{appFaviconUrl}
{appFaviconUseCustom}
/>
{/if}
{#if active === "settings"}
<SettingsSection {at} {isCompact} {onSettingsSaved} {currentLang} />
{/if}
File diff suppressed because it is too large Load Diff
@@ -17,28 +17,32 @@
const settingsStore = getContext("settingsStore");
$: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore);
$: visibleSettingsSections = settingsSections.filter((section) => section.id !== "appearance");
let settingsOpenSections = [];
let settingsOpenSubsections = {};
let revealedSecrets = new Set();
$: settingsAllOpen =
settingsSections.length > 0 && settingsOpenSections.length === settingsSections.length;
visibleSettingsSections.length > 0 &&
settingsOpenSections.length === visibleSettingsSections.length;
onMount(() => {
settingsStore.loadSettings().then(() => {
if ($settingsStore.settingsSections.length) {
const ids = $settingsStore.settingsSections.map((s) => s.id);
const ids = $settingsStore.settingsSections
.filter((s) => s.id !== "appearance")
.map((s) => s.id);
settingsOpenSections = isCompact ? ids.slice(0, 1) : ids.slice();
}
});
});
function toggleAllSections() {
if (settingsOpenSections.length === settingsSections.length) {
if (settingsOpenSections.length === visibleSettingsSections.length) {
settingsOpenSections = [];
} else {
settingsOpenSections = settingsSections.map((s) => s.id);
settingsOpenSections = visibleSettingsSections.map((s) => s.id);
}
}
@@ -229,7 +233,7 @@
</div>
{/snippet}
{#if settingsLoading || !settingsSections.length}
{#if settingsLoading || !visibleSettingsSections.length}
<AdminEmptyState
>{settingsLoading
? at("loading", {}, "Загрузка…")
@@ -265,7 +269,7 @@
</div>
</div>
<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 overriddenInSection = section.fields.filter((f) => isOverridden(f)).length}
<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) {
let dirty = {};
state.update((s) => {
dirty = s.settingsDirty;
return s;
});
if (!Object.keys(dirty).length) return;
if (!Object.keys(dirty).length) return true;
state.update((s) => ({ ...s, settingsSaving: true }));
try {
@@ -63,6 +80,7 @@ export function createSettingsStore({ api, onToast, at }) {
state.update((s) => ({ ...s, settingsDirty: {} }));
if (onSettingsSaved) await onSettingsSaved({ updates, deletes });
await loadSettings();
return true;
} else if (res?.errors) {
const summary = Object.entries(res.errors)
.map(([k, v]) => `${k}: ${v}`)
@@ -71,6 +89,7 @@ export function createSettingsStore({ api, onToast, at }) {
} else {
onToast(res?.error || "Ошибка");
}
return false;
} finally {
state.update((s) => ({ ...s, settingsSaving: false }));
}
@@ -91,6 +110,7 @@ export function createSettingsStore({ api, onToast, at }) {
loadSettings,
markDirty,
clearDirty,
setFieldValue,
resetField,
saveSettings,
};
@@ -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) {
if (typeof document === "undefined") return fallback;
const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const scope = hostEl || document.documentElement;
const raw = getComputedStyle(scope).getPropertyValue(name).trim();
return raw || fallback;
}
@@ -65,7 +66,11 @@
const muted = readCssColor("--admin-muted", "#9aa7a2");
const border = readCssColor("--admin-border", "rgba(255,255,255,0.12)");
const accent = readCssColor("--accent", "#00fe7a");
const lineStroke = readCssColor("--admin-text", "#e8f0ec");
const lineStroke = readCssColor(
"--admin-chart-stroke",
readCssColor("--admin-text", "#e8f0ec"),
);
const lineFill = readCssColor("--admin-chart-fill", "rgba(120, 140, 132, 0.14)");
return {
width: w,
@@ -94,7 +99,7 @@
stroke: lineStroke,
width: 2,
cap: "round",
fill: "rgba(120, 140, 132, 0.14)",
fill: lineFill,
},
],
axes: [
@@ -35,6 +35,7 @@ export {
Menu,
MessageSquare,
MousePointerClick,
Paintbrush,
Plus,
QrCode,
Radio,
@@ -33,6 +33,7 @@
export let emojiFont = "";
export let size = "sm";
export let animate = false;
export let fallbackEmoji = true;
let className = "";
export { className as class };
@@ -206,7 +207,7 @@
clearLogoLoadTimeout();
}}
/>
{:else if useAnimatedEmoji}
{:else if fallbackEmoji && useAnimatedEmoji}
<img
class="brand-mark-animated-emoji loaded"
src={animatedEmojiStaticFallback ? animatedEmojiFallbackSrc : animatedEmojiSrc}
@@ -222,7 +223,7 @@
}
}}
/>
{:else}
{:else if fallbackEmoji}
<span
class={cn("brand-mark-emoji", getEmojiFontClass(normalizedEmojiFont))}
style="opacity: {fontLoaded ? 1 : 0}; transition: opacity 0.2s ease;"
@@ -34,6 +34,7 @@ export const ADMIN_SECTIONS = new Set([
"broadcast",
"logs",
"tariffs",
"appearance",
"settings",
]);
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/"))
return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 };
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 = {
config: {
title: "/minishop",
primaryColor: "#00fe7a",
logoUrl: "",
logoUseEmoji: false,
logoEmoji: "🫥",
logoEmojiFont: "system",
faviconUrl: "",
faviconUseCustom: false,
apiBase: "/api",
supportUrl: "https://t.me/support",
privacyPolicyUrl: "https://example.com/privacy",
@@ -18,6 +45,37 @@ export const DEV_MOCK = {
telegramOAuthRequestAccess: ["write"],
appVersion: "dev+local",
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: {
ok: true,
@@ -124,6 +182,35 @@ export const DEV_MOCK = {
{ 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: {
support_url: "https://t.me/support",
traffic_mode: false,
@@ -143,6 +230,20 @@ export function applyPreviewMock(kind) {
const mode = String(kind || "")
.trim()
.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") {
DEV_MOCK.data.settings.traffic_mode = true;
DEV_MOCK.data.settings.trial_available = false;
@@ -8,6 +8,8 @@ export function createApiClient({
mockApi = null,
getMockContext = () => ({}),
} = {}) {
const isFormDataBody = (body) => typeof FormData !== "undefined" && body instanceof FormData;
async function api(path, options = {}) {
if (mockApi) return mockApi(path, options, getMockContext());
@@ -18,7 +20,9 @@ export function createApiClient({
if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
headers["X-CSRF-Token"] = csrf;
}
if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
if (options.body && !headers["Content-Type"] && !isFormDataBody(options.body)) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(`${apiBase}${path}`, {
...options,
@@ -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 {
background: var(--accent);
color: #02110a;
color: var(--accent-contrast);
border-color: color-mix(in srgb, var(--accent) 70%, #000);
font-weight: 600;
}
@@ -129,31 +129,31 @@
}
.admin-btn.admin-btn-ghost:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.04);
background: var(--surface-hover);
color: var(--admin-text);
}
.admin-btn.admin-btn-danger {
background: color-mix(in srgb, #ff5757 80%, #000);
background: color-mix(in srgb, var(--danger) 80%, #000);
color: #fff;
border-color: color-mix(in srgb, #ff5757 60%, #000);
border-color: color-mix(in srgb, var(--danger) 60%, #000);
font-weight: 600;
}
.admin-btn.admin-btn-danger:hover:not(:disabled) {
background: #ff5757;
border-color: #ff5757;
background: var(--danger);
border-color: var(--danger);
}
.admin-btn.admin-btn-danger-soft {
color: #ffb4b4;
color: var(--danger-text);
background: var(--admin-surface-2);
border-color: color-mix(in srgb, #ff6b6b 30%, transparent);
border-color: var(--danger-border);
}
.admin-btn.admin-btn-danger-soft:hover:not(:disabled) {
background: color-mix(in srgb, #ff6b6b 14%, var(--admin-surface));
border-color: color-mix(in srgb, #ff6b6b 50%, transparent);
background: var(--danger-soft);
border-color: color-mix(in srgb, var(--danger) 50%, transparent);
}
.admin-btn.admin-btn-icon {
@@ -183,21 +183,21 @@
}
.admin-badge.admin-badge-success {
border-color: color-mix(in srgb, var(--accent) 36%, transparent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface));
border-color: var(--success-border);
color: var(--success-text);
background: var(--success-soft);
}
.admin-badge.admin-badge-danger {
border-color: color-mix(in srgb, #ff6b6b 32%, transparent);
color: #ffb4b4;
background: color-mix(in srgb, #ff6b6b 12%, var(--admin-surface));
border-color: var(--danger-border);
color: var(--danger-text);
background: var(--danger-soft);
}
.admin-badge.admin-badge-warning {
border-color: color-mix(in srgb, #ffd166 32%, transparent);
color: #ffd166;
background: color-mix(in srgb, #ffd166 12%, var(--admin-surface));
border-color: var(--warning-border);
color: var(--warning-text);
background: var(--warning-soft);
}
.admin-badge.admin-badge-muted {
@@ -270,7 +270,7 @@
.admin-extend-control .admin-btn.admin-btn-primary {
background: var(--accent);
color: #02110a;
color: var(--accent-contrast);
border-color: color-mix(in srgb, var(--accent) 70%, #000);
font-weight: 600;
}
+54 -43
View File
@@ -7,17 +7,14 @@
--admin-sidebar-w: var(--desktop-rail-width);
--admin-header-h: 60px;
--admin-card-bg:
linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
var(--admin-surface);
--admin-card-shadow:
0 18px 48px rgba(0, 0, 0, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface);
--admin-card-shadow: var(--shadow-soft), inset 0 1px 0 var(--inset-highlight);
position: fixed;
inset: 0;
width: 100vw;
height: 100dvh;
background: #02070b;
background: var(--admin-bg);
color: var(--admin-text);
overflow: hidden;
overscroll-behavior: contain;
@@ -38,9 +35,9 @@
gap: 2px;
padding: 28px 14px;
border-right: 1px solid var(--admin-border);
background: rgba(7, 12, 17, 0.55);
background: var(--rail-bg);
backdrop-filter: blur(14px);
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02);
box-shadow: inset -1px 0 0 var(--admin-border);
overflow-y: auto;
}
@@ -74,7 +71,8 @@
color: var(--accent);
font-size: 14px;
font-weight: 800;
letter-spacing: -0.01em;
font-family: var(--font-logo);
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -115,7 +113,10 @@
text-align: left;
width: 100%;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
transition:
background 0.12s ease,
color 0.12s ease,
border-color 0.12s ease;
}
.admin-nav-item > svg {
@@ -124,7 +125,7 @@
}
.admin-nav-item:hover {
background: rgba(255, 255, 255, 0.04);
background: var(--surface-hover);
color: var(--admin-text);
}
@@ -169,7 +170,7 @@
gap: 8px;
border: 1px solid var(--admin-border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.035);
background: var(--surface-muted);
color: var(--admin-text);
padding: 8px 10px;
text-align: left;
@@ -251,7 +252,7 @@
gap: 16px;
padding: 0 28px;
border-bottom: 1px solid var(--admin-border);
background: color-mix(in srgb, #02070b 82%, transparent);
background: color-mix(in srgb, var(--admin-bg) 82%, transparent);
backdrop-filter: blur(14px);
}
@@ -265,7 +266,7 @@
margin: 0;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
letter-spacing: 0;
}
.admin-header-title small {
@@ -427,7 +428,7 @@
.admin-stat-card .admin-stat-value {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.02em;
letter-spacing: 0;
color: var(--admin-text);
}
@@ -457,7 +458,7 @@
font-size: 14px;
font-weight: 600;
color: var(--admin-text);
letter-spacing: -0.01em;
letter-spacing: 0;
}
.admin-dashboard-section-head small {
@@ -528,7 +529,7 @@
.admin-revenue-kpi-value {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.02em;
letter-spacing: 0;
color: var(--admin-text);
line-height: 1.15;
}
@@ -545,11 +546,11 @@
}
.admin-revenue-kpi-growth.is-up {
color: color-mix(in srgb, #3ecf8e 92%, var(--admin-text));
color: var(--success-text);
}
.admin-revenue-kpi-growth.is-down {
color: color-mix(in srgb, #ff6b6b 88%, var(--admin-text));
color: var(--danger-text);
}
.admin-revenue-chart {
@@ -606,7 +607,7 @@
border: 1px solid var(--admin-border);
background: var(--admin-surface);
color: var(--admin-text);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
box-shadow: var(--shadow-popover);
}
.admin-revenue-range-popover__title {
@@ -946,7 +947,7 @@
.admin-panel-dash-tile-value {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
letter-spacing: 0;
line-height: 1.1;
color: color-mix(in srgb, var(--accent) 22%, var(--admin-text));
font-variant-numeric: tabular-nums;
@@ -1248,7 +1249,7 @@
}
.admin-table tbody tr:hover {
background: rgba(255, 255, 255, 0.035);
background: var(--surface-hover);
}
.admin-table td.admin-cell-mono {
@@ -1531,7 +1532,9 @@
font-size: 13px;
resize: vertical;
outline: none;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
transition:
border-color 0.12s ease,
box-shadow 0.12s ease;
}
.admin-textarea:focus,
@@ -2085,11 +2088,11 @@
}
.admin-traffic-card-premium {
border-color: color-mix(in srgb, var(--accent) 30%, var(--admin-border));
border-color: var(--info-border);
}
.admin-traffic-card-warning {
border-color: color-mix(in srgb, #ffb454 46%, var(--admin-border));
border-color: var(--warning-border);
}
.admin-traffic-head,
@@ -2131,11 +2134,11 @@
}
.admin-traffic-bar-premium span {
background: linear-gradient(90deg, #66e3ff, var(--accent));
background: linear-gradient(90deg, var(--info), color-mix(in srgb, var(--info) 64%, #ffffff));
}
.admin-traffic-card-warning .admin-traffic-bar span {
background: linear-gradient(90deg, #ffb454, #ff6b6b);
background: linear-gradient(90deg, var(--warning), var(--danger));
}
.admin-traffic-meta {
@@ -2357,7 +2360,7 @@
z-index: 80;
transform: translateX(-100%);
transition: transform 0.22s ease;
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.6);
box-shadow: var(--shadow-strong);
}
.admin-screen-wrap.is-sidebar-open .admin-sidebar {
@@ -2367,7 +2370,7 @@
.admin-sidebar-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
background: var(--overlay-scrim);
z-index: 70;
border: 0;
cursor: pointer;
@@ -2501,7 +2504,9 @@
padding: 10px 12px;
width: 100%;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
transition:
background 0.12s ease,
border-color 0.12s ease;
}
.settings-row.settings-row-admin:hover {
@@ -2560,7 +2565,10 @@
border-radius: 7px;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
transition:
background 0.12s ease,
color 0.12s ease,
box-shadow 0.12s ease;
outline: none;
}
@@ -2571,7 +2579,7 @@
.admin-tabs-trigger[data-state="active"] {
background: var(--admin-surface);
color: var(--admin-text);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
box-shadow: var(--shadow-soft);
}
.admin-tabs-trigger:focus-visible {
@@ -2641,13 +2649,15 @@
border: 1px solid var(--admin-border);
background: var(--admin-elev);
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease;
transition:
background 0.18s ease,
border-color 0.18s ease;
outline: none;
}
.admin-switch-root[data-state="checked"] {
background: color-mix(in srgb, var(--accent) 80%, var(--admin-elev));
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
background: color-mix(in srgb, var(--success) 82%, var(--admin-elev));
border-color: color-mix(in srgb, var(--success) 52%, transparent);
}
.admin-switch-root:focus-visible {
@@ -2693,7 +2703,9 @@
cursor: pointer;
text-align: left;
outline: none;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
transition:
border-color 0.12s ease,
box-shadow 0.12s ease;
}
.admin-select-trigger:hover {
@@ -2724,10 +2736,9 @@
border: 1px solid var(--admin-border);
border-radius: 10px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
var(--admin-surface);
linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface);
padding: 4px;
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45);
box-shadow: var(--shadow-popover);
outline: none;
display: flex;
flex-direction: column;
@@ -3069,9 +3080,9 @@
}
.admin-danger-zone {
border: 1px solid color-mix(in srgb, #ff6b6b 32%, transparent);
border: 1px solid var(--danger-border);
border-radius: 12px;
background: color-mix(in srgb, #ff6b6b 6%, var(--admin-surface));
background: var(--danger-soft);
padding: 14px 16px;
display: flex;
flex-direction: column;
@@ -3088,13 +3099,13 @@
.admin-danger-zone-head strong {
font-size: 13px;
font-weight: 700;
color: #ffb4b4;
color: var(--danger-text);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.admin-danger-zone-head small {
color: color-mix(in srgb, #ffb4b4 70%, var(--admin-muted));
color: color-mix(in srgb, var(--danger-text) 70%, var(--admin-muted));
font-size: 12px;
line-height: 1.45;
}
+37 -12
View File
@@ -4,21 +4,14 @@
format("woff2");
font-display: swap;
unicode-range:
U+1F1E6-1F1FF,
U+1F3F4,
U+E0062-E0063,
U+E0065,
U+E0067,
U+E006C,
U+E006E,
U+E0073-E0074,
U+E0077,
U+E007F;
U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074,
U+E0077, U+E007F;
}
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
--font-logo: var(--font-mono);
color-scheme: dark;
--accent: #00fe7a;
--bg: #03070b;
@@ -31,8 +24,36 @@
--muted: #a9b4b0;
--dim: #68736f;
--danger: #ff6b6b;
--danger-text: #ffb4b4;
--danger-soft: color-mix(in srgb, var(--danger) 12%, var(--panel));
--danger-border: color-mix(in srgb, var(--danger) 34%, var(--border));
--success: #3ecf8e;
--success-text: #7ff0b9;
--success-soft: color-mix(in srgb, var(--success) 12%, var(--panel));
--success-border: color-mix(in srgb, var(--success) 34%, var(--border));
--warning: #ffd166;
--warning-text: #ffd166;
--warning-soft: color-mix(in srgb, var(--warning) 12%, var(--panel));
--warning-border: color-mix(in srgb, var(--warning) 34%, var(--border));
--info: #2d9cff;
--info-text: #8ed0ff;
--info-soft: color-mix(in srgb, var(--info) 12%, var(--panel));
--info-border: color-mix(in srgb, var(--info) 34%, var(--border));
--blue: #2d9cff;
--radius: 8px;
--accent-contrast: #03100a;
--surface-sheen: rgba(255, 255, 255, 0.055);
--surface-sheen-soft: rgba(255, 255, 255, 0.018);
--surface-hover: rgba(255, 255, 255, 0.04);
--surface-muted: rgba(255, 255, 255, 0.035);
--surface-subtle-border: rgba(255, 255, 255, 0.1);
--overlay-scrim: rgba(0, 0, 0, 0.56);
--nav-bg: rgba(7, 12, 17, 0.88);
--rail-bg: rgba(7, 12, 17, 0.55);
--shadow-soft: 0 18px 48px rgba(0, 0, 0, 0.22);
--shadow-strong: 0 26px 70px rgba(0, 0, 0, 0.46);
--shadow-popover: 0 20px 34px rgba(0, 0, 0, 0.42);
--inset-highlight: rgba(255, 255, 255, 0.05);
/* Admin design tokens kept on :root so portal-rendered admin
surfaces (dialogs, bits-ui Select.Portal content) inherit them. */
@@ -59,6 +80,10 @@
font-family: var(--font-sans);
}
.theme-light {
color-scheme: light;
}
* {
box-sizing: border-box;
}
@@ -72,7 +97,7 @@ body,
}
body {
background: #02070b;
background: var(--bg, #02070b);
color: var(--text);
-webkit-font-smoothing: antialiased;
letter-spacing: 0;
@@ -86,7 +111,7 @@ input {
.app-shell {
min-height: 100dvh;
background: #02070b !important;
background: var(--bg, #02070b) !important;
}
:root {
+14 -15
View File
@@ -26,11 +26,9 @@
border-radius: 12px;
border: 1px solid var(--admin-border);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)),
var(--admin-surface);
box-shadow:
0 18px 48px rgba(0, 0, 0, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
box-shadow: var(--shadow-soft), inset 0 1px 0 var(--inset-highlight);
position: relative;
overflow: hidden;
}
@@ -80,7 +78,7 @@
margin: 0;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.02em;
letter-spacing: 0;
color: var(--admin-text);
font-variant-numeric: tabular-nums;
line-height: 1.15;
@@ -89,7 +87,7 @@
.admin-cn-card-title--section {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
letter-spacing: 0;
}
.admin-cn-card-skeleton {
@@ -173,15 +171,15 @@
}
.admin-cn-badge-destructive {
background: color-mix(in srgb, var(--danger) 16%, var(--admin-surface));
border-color: color-mix(in srgb, var(--danger) 35%, var(--admin-border));
color: color-mix(in srgb, var(--danger) 92%, var(--admin-text));
background: var(--danger-soft);
border-color: var(--danger-border);
color: var(--danger-text);
}
.admin-cn-badge-success {
background: color-mix(in srgb, var(--accent) 14%, var(--admin-surface));
border-color: color-mix(in srgb, var(--accent) 32%, var(--admin-border));
color: color-mix(in srgb, var(--accent) 88%, var(--admin-text));
background: var(--success-soft);
border-color: var(--success-border);
color: var(--success-text);
}
.admin-cn-badge-muted {
@@ -227,9 +225,9 @@
display: inline-block;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.08),
rgba(255, 255, 255, 0.14),
rgba(255, 255, 255, 0.08)
color-mix(in srgb, var(--muted) 10%, transparent),
color-mix(in srgb, var(--muted) 18%, transparent),
color-mix(in srgb, var(--muted) 10%, transparent)
);
background-size: 220% 100%;
animation: ui-skeleton-pulse 1.2s ease-in-out infinite;
@@ -293,3 +291,4 @@
background-position: -120% 0;
}
}
+5 -5
View File
@@ -23,7 +23,7 @@ button:disabled {
text-align: center;
text-decoration: none;
cursor: pointer;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.18);
box-shadow: var(--shadow-soft);
transition:
transform 0.16s ease,
border-color 0.16s ease,
@@ -34,7 +34,7 @@ button:disabled {
.btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.22);
box-shadow: var(--shadow-soft);
}
.btn:active:not(:disabled) {
@@ -45,7 +45,7 @@ button:disabled {
outline: none;
box-shadow:
0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent),
0 14px 32px rgba(0, 0, 0, 0.22);
var(--shadow-soft);
}
.btn.wide,
@@ -56,7 +56,7 @@ button:disabled {
.btn-primary {
border-color: color-mix(in srgb, var(--accent) 72%, white);
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 76%, white));
color: #03100a;
color: var(--accent-contrast);
}
.btn-secondary {
@@ -111,7 +111,7 @@ button:disabled {
margin-top: 12px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
background: color-mix(in srgb, var(--muted) 18%, transparent);
}
.progress span {
+4 -4
View File
@@ -26,7 +26,7 @@
inset: 0;
z-index: 0;
border: 0;
background: rgba(0, 0, 0, 0.56);
background: var(--overlay-scrim);
backdrop-filter: blur(10px);
cursor: pointer;
}
@@ -43,9 +43,9 @@
padding: 18px;
border: 1px solid var(--border);
border-radius: 24px;
background: color-mix(in srgb, var(--panel) 94%, #07111a);
background: color-mix(in srgb, var(--panel) 96%, var(--bg));
color: var(--text);
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.46);
box-shadow: var(--shadow-strong);
}
.dialog-head {
@@ -92,7 +92,7 @@
.payment-submit-button {
border-color: color-mix(in srgb, var(--accent) 72%, transparent);
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent));
color: #031009;
color: var(--accent-contrast);
box-shadow:
0 16px 38px color-mix(in srgb, var(--accent) 28%, transparent),
0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent),
+124 -111
View File
@@ -12,8 +12,8 @@ a {
min-height: 100dvh;
margin: 0 auto;
overflow-x: hidden;
padding:
max(16px, env(safe-area-inset-top)) max(var(--screen-gutter), var(--safe-inline)) max(18px, env(safe-area-inset-bottom)) max(var(--screen-gutter), var(--safe-inline));
padding: max(16px, env(safe-area-inset-top)) max(var(--screen-gutter), var(--safe-inline))
max(18px, env(safe-area-inset-bottom)) max(var(--screen-gutter), var(--safe-inline));
}
.content {
@@ -30,12 +30,18 @@ a {
display: grid;
min-height: 100dvh;
place-items: center;
align-content: center;
align-content: safe center;
gap: 14px;
color: var(--muted);
font-weight: 800;
}
.loader .brand-mark.brand-mark-lg {
width: calc(4.125rem * var(--home-logo-scale, 1));
height: calc(4.125rem * var(--home-logo-scale, 1));
font-size: calc(2.875rem * var(--home-logo-scale, 1));
}
.app-header,
.preview-header,
.screen-head {
@@ -94,7 +100,7 @@ a {
color: var(--text);
font-size: 15px;
font-weight: 850;
font-family: var(--font-mono);
font-family: var(--font-logo);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -162,20 +168,19 @@ a {
border: 1px solid var(--border);
border-radius: var(--radius);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
var(--panel);
linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--panel);
box-shadow:
0 18px 48px rgba(0, 0, 0, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
var(--shadow-soft),
inset 0 1px 0 var(--inset-highlight);
padding: 14px;
}
.card-active {
border-color: color-mix(in srgb, var(--accent) 76%, var(--border));
box-shadow:
0 18px 48px rgba(0, 0, 0, 0.22),
var(--shadow-soft),
0 0 0 1px color-mix(in srgb, var(--accent) 38%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
inset 0 1px 0 var(--inset-highlight);
}
.card-compact {
@@ -189,7 +194,11 @@ a {
.status-card-inactive {
border-color: color-mix(in srgb, var(--danger) 58%, var(--border));
background:
linear-gradient(135deg, color-mix(in srgb, var(--danger) 14%, rgba(255, 255, 255, 0.03)), rgba(255, 255, 255, 0.012)),
linear-gradient(
135deg,
color-mix(in srgb, var(--danger) 14%, var(--surface-sheen-soft)),
var(--surface-sheen-soft)
),
var(--panel);
}
@@ -392,7 +401,7 @@ a {
gap: 6px;
}
.premium-server-list>div {
.premium-server-list > div {
display: flex;
flex-wrap: wrap;
gap: 6px;
@@ -401,7 +410,7 @@ a {
.premium-server-list span {
max-width: 100%;
padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid var(--surface-subtle-border);
border-radius: 999px;
color: var(--text);
font-size: 11px;
@@ -416,7 +425,7 @@ a {
padding: 12px;
}
.topup-summary-card>div:not(.premium-server-list) {
.topup-summary-card > div:not(.premium-server-list) {
display: grid;
gap: 3px;
}
@@ -440,7 +449,7 @@ a {
gap: 11px;
}
.trial-card-head>svg {
.trial-card-head > svg {
flex: 0 0 auto;
color: var(--accent);
}
@@ -475,7 +484,7 @@ a {
gap: 11px;
}
.devices-summary-head>svg {
.devices-summary-head > svg {
color: var(--accent);
}
@@ -529,7 +538,7 @@ a {
place-items: center;
border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border));
border-radius: var(--radius);
background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.03));
background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted));
color: var(--accent);
}
@@ -565,8 +574,8 @@ a {
.device-disconnect-button,
.device-danger-button {
border-color: color-mix(in srgb, var(--danger) 62%, var(--border));
color: #ffb8b8;
border-color: var(--danger-border);
color: var(--danger-text);
}
.devices-empty-card {
@@ -604,9 +613,9 @@ a {
.settings-row {
border: 1px solid var(--border);
border-radius: var(--radius);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018));
background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft));
color: var(--text);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045);
box-shadow: inset 0 1px 0 var(--inset-highlight);
}
.period-card {
@@ -652,10 +661,10 @@ a {
.period-card.active,
.method-card.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035));
background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted));
box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
inset 0 1px 0 var(--inset-highlight);
}
.total-card {
@@ -688,11 +697,11 @@ a {
gap: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018));
background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft));
color: var(--text);
padding: 12px;
text-align: left;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045);
box-shadow: inset 0 1px 0 var(--inset-highlight);
}
.option-row-main,
@@ -745,10 +754,10 @@ a {
.option-row.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035));
background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted));
box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
inset 0 1px 0 var(--inset-highlight);
}
.tariff-row {
@@ -764,7 +773,7 @@ a {
align-items: flex-start;
}
.change-action-row>svg {
.change-action-row > svg {
flex: 0 0 auto;
margin-top: 1px;
color: var(--accent);
@@ -774,9 +783,9 @@ a {
display: grid;
gap: 6px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.035);
background: var(--surface-muted);
}
.topup-carryover-note p {
@@ -798,11 +807,12 @@ a {
display: block;
overflow: hidden;
border-radius: 999px;
background:
linear-gradient(90deg,
rgba(255, 255, 255, 0.07) 0%,
rgba(255, 255, 255, 0.14) 42%,
rgba(255, 255, 255, 0.07) 84%);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--muted) 10%, transparent) 0%,
color-mix(in srgb, var(--muted) 18%, transparent) 42%,
color-mix(in srgb, var(--muted) 10%, transparent) 84%
);
background-size: 220% 100%;
animation: skeleton-shimmer 1.15s ease-in-out infinite;
}
@@ -893,7 +903,7 @@ a {
.tariff-selected-card {
min-height: 58px;
background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035));
background: color-mix(in srgb, var(--accent) 7%, var(--surface-muted));
}
.tariff-action-list {
@@ -909,7 +919,7 @@ a {
gap: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018));
background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft));
color: var(--text);
padding: 12px;
text-align: left;
@@ -942,12 +952,12 @@ a {
.tariff-action-card.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035));
background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted));
}
.tariff-warning-card {
justify-content: flex-start;
color: var(--warning, #ffd166);
color: var(--warning-text);
}
.confirm-summary-card {
@@ -969,7 +979,7 @@ a {
min-height: 56px;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.035);
background: var(--surface-muted);
padding: 10px;
text-align: center;
}
@@ -1057,10 +1067,10 @@ a {
.field-error-tooltip {
z-index: 120;
max-width: 280px;
border: 1px solid color-mix(in srgb, var(--danger) 60%, var(--border));
border: 1px solid var(--danger-border);
border-radius: 8px;
background: #2a1010;
color: #ffd8d8;
background: var(--danger-soft);
color: var(--danger-text);
padding: 8px 10px;
font-size: 12px;
line-height: 1.3;
@@ -1105,7 +1115,7 @@ a {
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(0, 0, 0, 0.2);
background: color-mix(in srgb, var(--panel-2) 86%, transparent);
color: var(--text);
padding: 12px;
font-size: 12px;
@@ -1132,7 +1142,7 @@ a {
gap: 14px;
}
.bonus-card-head>svg {
.bonus-card-head > svg {
flex: 0 0 auto;
color: var(--accent);
}
@@ -1165,7 +1175,7 @@ a {
gap: 3px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.02);
background: var(--surface-sheen-soft);
padding: 10px 11px;
}
@@ -1193,7 +1203,7 @@ a {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.03);
background: var(--surface-muted);
}
.settings-avatar img {
@@ -1245,15 +1255,15 @@ a {
min-height: 50px;
padding: 9px 10px;
text-align: left;
background: rgba(255, 255, 255, 0.03);
background: var(--surface-muted);
}
.settings-row>svg:first-child {
.settings-row > svg:first-child {
color: var(--text);
opacity: 0.9;
}
.settings-row>svg:last-child {
.settings-row > svg:last-child {
color: var(--muted);
}
@@ -1276,26 +1286,22 @@ a {
.settings-row-linked {
grid-template-columns: 28px minmax(0, 1fr);
border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
background: color-mix(in srgb, var(--accent) 11%, rgba(255, 255, 255, 0.03));
border-color: var(--success-border);
background: var(--success-soft);
}
.settings-row-linked>svg:first-child {
color: var(--accent);
.settings-row-linked > svg:first-child {
color: var(--success-text);
}
.settings-row-linked strong {
color: var(--accent);
color: var(--success-text);
}
.emoji-flag {
font-family:
"Twemoji Country Flags",
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
sans-serif;
"Twemoji Country Flags", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji", sans-serif;
font-variant-emoji: emoji;
line-height: 1;
}
@@ -1325,7 +1331,7 @@ a {
border-radius: 6px;
}
.language-select-trigger>svg {
.language-select-trigger > svg {
color: var(--muted);
}
@@ -1361,7 +1367,7 @@ a {
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: var(--panel-3);
box-shadow: 0 20px 34px rgba(0, 0, 0, 0.42);
box-shadow: var(--shadow-popover);
overflow: hidden;
box-sizing: border-box;
transform-origin: top right;
@@ -1387,7 +1393,7 @@ a {
}
.language-select-item[data-highlighted] {
background: rgba(255, 255, 255, 0.06);
background: var(--surface-hover);
}
.language-select-item[data-selected] {
@@ -1409,11 +1415,11 @@ a {
text-overflow: ellipsis;
}
.language-select-item-main>span {
.language-select-item-main > span {
display: inline-block !important;
}
.language-select-item-main>span:last-child {
.language-select-item-main > span:last-child {
margin-left: 6px;
white-space: nowrap !important;
overflow: hidden;
@@ -1494,8 +1500,8 @@ a {
min-height: 64px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(7, 12, 17, 0.88);
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.36);
background: var(--nav-bg);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(16px);
}
@@ -1530,17 +1536,29 @@ a {
.home-layout {
display: grid;
min-height: calc(100dvh - 34px);
grid-template-rows: minmax(0, 1fr) auto;
grid-template-rows: minmax(min-content, 1fr) auto;
gap: 18px;
padding-bottom: 86px;
animation: section-enter 0.24s ease-out both;
}
.home-brand {
align-self: center;
align-self: safe center;
padding-top: 0;
}
.home-brand .brand-mark.brand-mark-xl {
width: calc(6rem * var(--home-logo-scale, 1));
height: calc(6rem * var(--home-logo-scale, 1));
font-size: calc(4.375rem * var(--home-logo-scale, 1));
}
.login-brand-auth .brand-mark.brand-mark-xl {
width: calc(116px * var(--home-logo-scale, 1));
height: calc(116px * var(--home-logo-scale, 1));
font-size: calc(84px * var(--home-logo-scale, 1));
}
.home-bottom {
display: grid;
gap: 10px;
@@ -1549,7 +1567,7 @@ a {
.auth-screen {
display: grid;
align-content: center;
align-content: safe center;
}
.auth-card-wrap {
@@ -1568,19 +1586,13 @@ a {
gap: 5px;
}
.login-brand-auth .brand-mark.brand-mark-xl {
width: 116px;
height: 116px;
font-size: 84px;
}
.login-brand h1 {
margin: 0;
overflow-wrap: anywhere;
color: var(--accent);
font-size: 32px;
font-weight: 900;
font-family: var(--font-mono);
font-family: var(--font-logo);
line-height: 1.04;
}
@@ -1739,13 +1751,13 @@ a {
place-items: center;
border: 1px solid color-mix(in srgb, var(--accent) 74%, var(--border));
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.035);
background: var(--surface-muted);
font-size: 25px;
font-weight: 900;
}
.otp-slots span.filled {
background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.04));
background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted));
}
.link-button {
@@ -1760,7 +1772,7 @@ a {
}
.status-line {
color: var(--accent);
color: var(--success-text);
font-size: 12px;
line-height: 1.4;
}
@@ -1788,10 +1800,10 @@ a {
bottom: 16px;
z-index: 120;
max-width: min(420px, calc(100vw - 32px));
border: 1px solid color-mix(in srgb, var(--accent) 46%, var(--border));
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: rgba(15, 21, 27, 0.92);
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.42);
background: color-mix(in srgb, var(--panel) 92%, transparent);
box-shadow: var(--shadow-popover);
color: var(--text);
padding: 12px 14px;
font-size: 13px;
@@ -1873,8 +1885,7 @@ a {
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 18px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.015)),
#071017;
linear-gradient(135deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.015)), #071017;
box-shadow:
0 28px 80px rgba(0, 0, 0, 0.54),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
@@ -2011,7 +2022,6 @@ a {
}
@media (prefers-reduced-motion: reduce) {
.content,
.home-layout,
.language-select-content,
@@ -2039,7 +2049,7 @@ a {
@media (min-width: 1024px) {
body {
background: #02070b;
background: var(--bg);
}
.app-shell {
@@ -2060,16 +2070,16 @@ a {
border-radius: 0;
background: transparent;
box-shadow: none;
padding:
max(28px, env(safe-area-inset-top)) var(--desktop-page-gutter) 40px calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
padding: max(28px, env(safe-area-inset-top)) var(--desktop-page-gutter) 40px
calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
overflow-x: visible;
}
/* Centre and cap the width of the actual content blocks. */
.phone-screen>.app-header,
.phone-screen>main,
.phone-screen>.home-layout,
.phone-screen>nav.bottom-nav~* {
.phone-screen > .app-header,
.phone-screen > main,
.phone-screen > .home-layout,
.phone-screen > nav.bottom-nav ~ * {
max-width: 1080px;
margin-left: auto;
margin-right: auto;
@@ -2079,12 +2089,11 @@ a {
.phone-screen.auth-screen {
width: min(100%, 460px);
min-height: 100dvh;
padding:
max(16px, env(safe-area-inset-top)) clamp(24px, 4vw, 16px) 16px;
padding: max(16px, env(safe-area-inset-top)) clamp(24px, 4vw, 16px) 16px;
margin: 0 auto;
}
.phone-screen.auth-screen~.bottom-nav,
.phone-screen.auth-screen ~ .bottom-nav,
.auth-screen .bottom-nav {
display: none !important;
}
@@ -2094,8 +2103,8 @@ a {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
min-height: 100dvh;
padding:
max(32px, env(safe-area-inset-top)) var(--desktop-page-gutter) 42px calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
padding: max(32px, env(safe-area-inset-top)) var(--desktop-page-gutter) 42px
calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
}
/* Home: keep the phone-style vertical flow, but center it in the
@@ -2113,7 +2122,7 @@ a {
align-items: stretch;
}
.home-layout>.home-brand {
.home-layout > .home-brand {
grid-column: auto;
align-self: center;
justify-items: center;
@@ -2132,9 +2141,9 @@ a {
.home-brand .brand-mark,
.home-brand .brand-mark-lg,
.home-brand .brand-mark-xl {
width: clamp(180px, 18vw, 260px);
height: clamp(180px, 18vw, 260px);
font-size: clamp(124px, 13vw, 184px);
width: calc(clamp(180px, 18vw, 260px) * var(--home-logo-scale, 1));
height: calc(clamp(180px, 18vw, 260px) * var(--home-logo-scale, 1));
font-size: calc(clamp(124px, 13vw, 184px) * var(--home-logo-scale, 1));
}
.home-bottom {
@@ -2174,9 +2183,9 @@ a {
border: 0 !important;
border-right: 1px solid var(--border) !important;
border-radius: 0 !important;
background: rgba(7, 12, 17, 0.55) !important;
background: var(--rail-bg) !important;
backdrop-filter: blur(14px) !important;
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02) !important;
box-shadow: inset -1px 0 0 var(--border) !important;
z-index: 40;
}
@@ -2197,22 +2206,25 @@ a {
font-size: 13px !important;
color: var(--muted);
border: 1px solid transparent;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
transition:
background 0.12s ease,
color 0.12s ease,
border-color 0.12s ease;
}
.bottom-nav button>svg {
.bottom-nav button > svg {
width: 20px;
height: 20px;
}
.bottom-nav button>span {
.bottom-nav button > span {
text-align: left !important;
font-size: 13px !important;
font-weight: 600;
}
.bottom-nav button:hover {
background: rgba(255, 255, 255, 0.04);
background: var(--surface-hover);
color: var(--text);
}
@@ -2235,7 +2247,7 @@ a {
display: none !important;
}
.phone-screen>main.content.with-nav {
.phone-screen > main.content.with-nav {
padding-top: 0;
}
@@ -2310,7 +2322,8 @@ a {
color: var(--accent);
font-size: 14px;
font-weight: 800;
letter-spacing: -0.01em;
font-family: var(--font-logo);
letter-spacing: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -9,7 +9,7 @@
<link id="app-favicon" rel="icon" href="data:," sizes="any">
<title>/minishop</title>
<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>
<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 base64
import hashlib
import html
import hmac
import io
import ipaddress
@@ -17,7 +18,7 @@ from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
from aiogram import Bot, Dispatcher
from aiogram.types import LabeledPrice
@@ -65,6 +66,10 @@ TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscriptio
ASSET_DIR = TEMPLATE_PATH.parent
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo"
WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads"
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons"
WEBAPP_FAVICON_PATH = "/webapp-favicon"
WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji"
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
@@ -76,6 +81,17 @@ WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024
WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024
WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024
WEBAPP_THEME_ASSET_CONTENT_TYPES = {
".gif": "image/gif",
".ico": "image/x-icon",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
}
WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024
WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60
WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4
+385 -6
View File
@@ -1,6 +1,14 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from config.webapp_themes_config import (
default_webapp_theme_asset_file,
default_webapp_theme_css_files,
ensure_default_webapp_theme_descriptor_files,
public_theme_payload,
public_themes_catalog_payload,
)
async def health_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True})
@@ -10,7 +18,120 @@ async def css_asset_route(request: web.Request) -> web.Response:
return await _serve_template_asset(request, "subscription_webapp.css", "text/css")
def _safe_theme_css_relative_path(raw_path: str) -> Optional[Path]:
return _safe_theme_relative_path(raw_path, allowed_suffixes={".css"}, max_length=180)
def _safe_theme_asset_relative_path(raw_path: str) -> Optional[Path]:
return _safe_theme_relative_path(
raw_path,
allowed_suffixes=set(WEBAPP_THEME_ASSET_CONTENT_TYPES),
max_length=220,
)
def _safe_theme_relative_path(
raw_path: str,
*,
allowed_suffixes: set[str],
max_length: int,
) -> Optional[Path]:
value = str(raw_path or "").replace("\\", "/").strip().lstrip("/")
if not value or len(value) > max_length or "\x00" in value:
return None
parts = [part for part in value.split("/") if part]
if len(parts) < 2 or any(part in {".", ".."} for part in parts):
return None
if any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", part) for part in parts):
return None
rel_path = Path(*parts)
if rel_path.suffix.lower() not in allowed_suffixes:
return None
return rel_path
async def theme_css_asset_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
ensure_default_webapp_theme_descriptor_files(settings.WEBAPP_THEMES_DIR)
rel_path = _safe_theme_css_relative_path(request.match_info.get("path") or "")
if rel_path is None:
raise web.HTTPNotFound(text="theme_css_not_found")
root = Path(settings.WEBAPP_THEMES_DIR).expanduser().resolve()
path = (root / rel_path).resolve()
try:
path.relative_to(root)
except ValueError:
raise web.HTTPNotFound(text="theme_css_not_found") from None
try:
if path.stat().st_size > WEBAPP_THEME_CSS_MAX_BYTES:
raise web.HTTPNotFound(text="theme_css_too_large")
text = path.read_text(encoding="utf-8")
except OSError:
defaults = default_webapp_theme_css_files()
text = defaults.get(rel_path.as_posix())
if text is None:
raise web.HTTPNotFound(text="theme_css_not_found") from None
response = web.Response(text=text, content_type="text/css", charset="utf-8")
response.headers["Cache-Control"] = "no-cache"
return response
async def theme_asset_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
ensure_default_webapp_theme_descriptor_files(settings.WEBAPP_THEMES_DIR)
rel_path = _safe_theme_asset_relative_path(request.match_info.get("path") or "")
if rel_path is None:
raise web.HTTPNotFound(text="theme_asset_not_found")
root = Path(settings.WEBAPP_THEMES_DIR).expanduser().resolve()
path = (root / rel_path).resolve()
try:
path.relative_to(root)
except ValueError:
raise web.HTTPNotFound(text="theme_asset_not_found") from None
suffix = rel_path.suffix.lower()
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(suffix)
if not content_type:
raise web.HTTPNotFound(text="theme_asset_not_found")
try:
if path.stat().st_size > WEBAPP_THEME_ASSET_MAX_BYTES:
raise web.HTTPNotFound(text="theme_asset_too_large")
body = path.read_bytes()
except OSError:
fallback = default_webapp_theme_asset_file(rel_path)
if fallback is None:
raise web.HTTPNotFound(text="theme_asset_not_found") from None
body, fallback_suffix = fallback
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(fallback_suffix, content_type)
if not body or len(body) > WEBAPP_THEME_ASSET_MAX_BYTES:
raise web.HTTPNotFound(text="theme_asset_not_found")
query = getattr(request, "query", {})
response = web.Response(body=body, content_type=content_type)
response.headers["Cache-Control"] = (
"public, max-age=31536000, immutable"
if query.get("v")
else "public, max-age=3600"
)
return response
def _resolve_webapp_logo_url(settings: Settings) -> str:
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return ""
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url:
return ""
@@ -26,6 +147,27 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
return ""
def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
raw_custom_url = (getattr(settings, "WEBAPP_FAVICON_URL", None) or "").strip()
raw_logo_favicon_url = (getattr(settings, "WEBAPP_LOGO_FAVICON_URL", None) or "").strip()
if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False) and raw_custom_url:
return _resolve_webapp_asset_url(raw_custom_url)
if logo_url and raw_logo_favicon_url:
resolved = _resolve_webapp_asset_url(raw_logo_favicon_url)
if resolved:
return resolved
return logo_url or ""
def _resolve_webapp_asset_url(raw_url: str) -> str:
parsed_url = urlsplit(raw_url)
if parsed_url.scheme in {"https", "http", "data"}:
return raw_url
if raw_url.startswith("/"):
return raw_url
return ""
def _webapp_logo_cache_key(logo_url: str) -> str:
return hashlib.sha256(logo_url.encode("utf-8")).hexdigest()
@@ -61,6 +203,8 @@ def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str:
async def webapp_logo_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
raise web.HTTPNotFound(text="webapp_logo_disabled")
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url:
raise web.HTTPNotFound(text="webapp_logo_not_configured")
@@ -94,6 +238,82 @@ async def webapp_logo_route(request: web.Request) -> web.Response:
return response
async def webapp_uploaded_logo_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
filename = str(request.match_info.get("filename") or "").strip()
if not re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename):
raise web.HTTPNotFound(text="webapp_logo_not_found")
root = WEBAPP_UPLOADED_LOGO_DIR.expanduser().resolve()
path = (root / filename).resolve()
try:
path.relative_to(root)
except ValueError:
raise web.HTTPNotFound(text="webapp_logo_not_found") from None
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower())
if not content_type:
raise web.HTTPNotFound(text="webapp_logo_not_found")
try:
if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES:
raise web.HTTPNotFound(text="webapp_logo_too_large")
body = path.read_bytes()
except OSError:
raise web.HTTPNotFound(text="webapp_logo_not_found") from None
if not body:
raise web.HTTPNotFound(text="webapp_logo_not_found")
response = web.Response(body=body, content_type=content_type)
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
async def webapp_favicon_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
digest = str(request.match_info.get("digest") or "").strip().lower()
filename = str(request.match_info.get("filename") or "").strip()
if not re.fullmatch(r"[0-9a-f]{16}", digest):
raise web.HTTPNotFound(text="webapp_favicon_not_found")
if not re.fullmatch(
r"(?:icon-(?:16|32|48|180|192|512)\.png|apple-touch-icon\.png|favicon\.(?:ico|svg))",
filename,
):
raise web.HTTPNotFound(text="webapp_favicon_not_found")
root = WEBAPP_FAVICON_DIR.expanduser().resolve()
path = (root / digest / filename).resolve()
try:
path.relative_to(root)
except ValueError:
raise web.HTTPNotFound(text="webapp_favicon_not_found") from None
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower())
if not content_type:
raise web.HTTPNotFound(text="webapp_favicon_not_found")
try:
if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES:
raise web.HTTPNotFound(text="webapp_favicon_too_large")
body = path.read_bytes()
except OSError:
raise web.HTTPNotFound(text="webapp_favicon_not_found") from None
if not body:
raise web.HTTPNotFound(text="webapp_favicon_not_found")
response = web.Response(body=body, content_type=content_type)
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
ext = str(request.match_info.get("ext") or "").strip().lower()
@@ -123,6 +343,8 @@ async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
async def _warm_webapp_logo_cache(app: web.Application) -> None:
settings: Settings = app["settings"]
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url):
return
@@ -146,6 +368,8 @@ async def _warm_webapp_logo_cache(app: web.Application) -> None:
async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None:
settings: Settings = app["settings"]
if not getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return
if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated":
return
@@ -435,7 +659,7 @@ async def _security_headers_middleware(request: web.Request, handler):
"frame-ancestors https://web.telegram.org https://t.me; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " # noqa: E501
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; "
"img-src 'self' data: https:; "
"img-src 'self' data: blob: https:; "
"connect-src 'self' https://oauth.telegram.org; "
"object-src 'none'; "
"base-uri 'self'; "
@@ -482,8 +706,10 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
cache = request.app["webapp_settings_cache"]
now = time.monotonic()
if now - float(cache.get("ts", 0.0)) >= 60 or not cache.get("data"):
logo_url = _resolve_webapp_logo_url(settings)
cache["data"] = {
"logo_url": _resolve_webapp_logo_url(settings),
"logo_url": logo_url,
"favicon_url": _resolve_webapp_favicon_url(settings, logo_url),
"subscription_options": settings.subscription_options,
"stars_subscription_options": settings.stars_subscription_options,
"traffic_packages": settings.traffic_packages,
@@ -628,12 +854,29 @@ async def index_route(request: web.Request) -> web.Response:
html = TEMPLATE_PATH.read_text(encoding="utf-8")
cached = _get_cached_webapp_settings(request)
themes_catalog = settings.webapp_themes_catalog
primary_color = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a"
initial_theme = _initial_theme_for_request(request, themes_catalog)
preview_key = str(request.query.get("theme_preview") or "").strip()
preview_theme = themes_catalog.theme_by_key(preview_key) if preview_key else None
if preview_theme is None or not preview_theme.enabled:
preview_key = ""
config = {
"title": settings.WEBAPP_TITLE,
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
"themesCatalog": public_themes_catalog_payload(
themes_catalog,
primary_color,
enabled_only=True,
),
"themesDir": settings.WEBAPP_THEMES_DIR,
"themePreviewKey": preview_key,
"logoUrl": cached["logo_url"],
"logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI),
"logoEmoji": settings.WEBAPP_LOGO_EMOJI,
"logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT,
"faviconUrl": cached["favicon_url"],
"faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM),
"apiBase": "/api",
"telegramLoginBotUsername": request.app.get("bot_username") or "",
"telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0,
@@ -650,6 +893,9 @@ async def index_route(request: web.Request) -> web.Response:
"appRepositoryUrl": APP_REPOSITORY_URL,
}
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
if initial_theme_markup:
html = html.replace("</head>", f"{initial_theme_markup}\n</head>", 1)
i18n_instance: Optional[object] = request.app.get("i18n")
i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
nonce = request.get("csp_nonce", "")
@@ -673,17 +919,27 @@ async def index_route(request: web.Request) -> web.Response:
WEBAPP_JS_PLACEHOLDER,
f'<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"]
if not brand_asset_url and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated":
if (
not brand_asset_url
and settings.WEBAPP_LOGO_USE_EMOJI
and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated"
):
brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI)
if brand_asset_url:
html = html.replace(
'<link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high" crossorigin="anonymous">', # noqa: E501
f'<link rel="preload" href="{brand_asset_url}" 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">',
)
else:
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")
@@ -724,6 +980,129 @@ def _resolve_webapp_js_asset_name() -> str:
return "subscription_webapp.js"
_INITIAL_THEME_TOKEN_CSS_MAP = {
"accent": "--accent",
"bg": "--bg",
"panel": "--panel",
"panel_2": "--panel-2",
"panel_3": "--panel-3",
"border": "--border",
"border_strong": "--border-strong",
"text": "--text",
"muted": "--muted",
"dim": "--dim",
"danger": "--danger",
"blue": "--blue",
"radius": "--radius",
"font_sans": "--font-sans",
"font_logo": "--font-logo",
"font_mono": "--font-mono",
"admin_bg": "--admin-bg",
"admin_surface": "--admin-surface",
"admin_surface_2": "--admin-surface-2",
"admin_elev": "--admin-elev",
"admin_border": "--admin-border",
"admin_border_strong": "--admin-border-strong",
"admin_text": "--admin-text",
"admin_muted": "--admin-muted",
"admin_dim": "--admin-dim",
}
def _theme_css_href_for_html(theme: Any) -> str:
css_file = str(getattr(theme, "css_file", "") or "").strip()
key = str(getattr(theme, "key", "") or "").strip()
if not css_file or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key):
return ""
parts = [part for part in css_file.replace("\\", "/").split("/") if part]
if any(part in {".", ".."} for part in parts):
return ""
themed_path = "/".join([key, *parts])
encoded = "/".join(quote(part, safe="") for part in themed_path.split("/"))
return f"/webapp-theme-css/{encoded}" if encoded else ""
def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any:
preview_key = str(request.query.get("theme_preview") or "").strip()
if preview_key:
preview_theme = catalog.theme_by_key(preview_key)
if preview_theme is not None and preview_theme.enabled:
return preview_theme
theme = catalog.theme_by_key(catalog.default_theme)
if theme is not None:
return theme
return catalog.enabled_themes()[0] if catalog.enabled_themes() else None
def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: str) -> str:
if theme is None:
return ""
payload = public_theme_payload(theme, primary_color)
tokens = payload.get("tokens") if isinstance(payload, dict) else {}
tokens = tokens if isinstance(tokens, dict) else {}
declarations = []
for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items():
value = str(tokens.get(token_key) or "").strip()
if value:
declarations.append(f"{css_name}:{value}")
scheme = "light" if tokens.get("color_scheme") == "light" else "dark"
bg = str(tokens.get("bg") or "").strip()
css_rules = [f"html{{color-scheme:{scheme};}}"]
if bg:
css_rules.append(f"body{{background-color:{bg};}}")
if declarations:
css_rules.append(f".app-shell{{{';'.join(declarations)}}}")
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
style_tag = (
f'<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:
start = html.find(start_marker)
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("/settings", index_route)
app.router.add_get("/admin", index_route)
app.router.add_get("/admin/{section:[a-z][a-z0-9_-]*}", index_route)
app.router.add_get(
(
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
"appearance|settings}"
),
index_route,
)
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
app.router.add_get("/health", health_route)
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
app.router.add_get(
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
webapp_uploaded_logo_route,
)
app.router.add_get(
rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}",
webapp_favicon_route,
)
app.router.add_get(
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
webapp_animated_emoji_route,
)
app.router.add_get("/subscription_webapp.css", css_asset_route)
app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route)
app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route)
app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route)
app.router.add_get("/subscription_webapp.js", js_asset_route)
app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route)
+7
View File
@@ -1,6 +1,8 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from config.webapp_themes_config import public_themes_catalog_payload
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
@@ -96,6 +98,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
stars_traffic_packages=cached["stars_traffic_packages"],
),
"payment_methods": _serialize_payment_methods(settings, request.app),
"themes_catalog": public_themes_catalog_payload(
settings.webapp_themes_catalog,
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
enabled_only=True,
),
"settings": {
"support_url": settings.SUPPORT_LINK,
"traffic_mode": bool(settings.traffic_sale_mode),
+5 -3
View File
@@ -1,7 +1,7 @@
"""Branded HTML email templates that mirror the subscription Mini App look.
The web app uses a dark theme with a configurable accent colour
(`WEBAPP_PRIMARY_COLOR`) and an optional logo (`WEBAPP_LOGO_URL`). The same
admin-configured accent colour and logo. The same
accent + logo are reused here so emails feel like part of the product. All
copy goes through the shared `JsonI18n` instance so translations live in
``locales/<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]:
"""Email recipients can't reach the in-app /webapp-logo proxy, so the
raw https URL from the env is used directly. Anything else is dropped."""
"""Email recipients can't reach the in-app /webapp-logo proxy, so only a
stored public https URL can be used directly. Anything else is dropped."""
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return None
raw = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw:
return None
+78
View File
@@ -7,6 +7,10 @@ from pydantic import BaseModel, Field, ValidationError, computed_field, field_va
from pydantic_settings import BaseSettings, SettingsConfigDict
from config.tariffs_config import TariffsConfig, load_tariffs_config
from config.webapp_themes_config import (
WebappThemesConfig,
resolved_webapp_themes_catalog,
)
def _split_csv(value: Optional[str]) -> List[str]:
@@ -88,8 +92,12 @@ class WebAppSettings(BaseModel):
title: str
primary_color: str
logo_url: Optional[str]
logo_use_emoji: bool
logo_emoji: str
logo_emoji_font: str
favicon_use_custom: bool
favicon_url: Optional[str]
logo_favicon_url: Optional[str]
session_ttl_seconds: int
session_secret: str
webhook_secret_token: str
@@ -351,7 +359,21 @@ class Settings(BaseSettings):
WEBAPP_SERVER_PORT: int = Field(default=8081)
WEBAPP_TITLE: str = Field(default="Моя подписка")
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
WEBAPP_THEMES_DIR: str = Field(
default="data/themes",
description=(
"Directory with per-theme folders. Each theme lives in "
"<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_USE_EMOJI: bool = Field(default=False)
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
WEBAPP_LOGO_EMOJI_FONT: str = Field(
default="system",
@@ -360,6 +382,9 @@ class Settings(BaseSettings):
"noto-emoji, twemoji, openmoji, apple, segoe, noto-local"
),
)
WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False)
WEBAPP_FAVICON_URL: Optional[str] = Field(default=None)
WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None)
WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60)
@@ -528,8 +553,12 @@ class Settings(BaseSettings):
title=self.WEBAPP_TITLE,
primary_color=self.WEBAPP_PRIMARY_COLOR,
logo_url=self.WEBAPP_LOGO_URL,
logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI,
logo_emoji=self.WEBAPP_LOGO_EMOJI,
logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT,
favicon_use_custom=self.WEBAPP_FAVICON_USE_CUSTOM,
favicon_url=self.WEBAPP_FAVICON_URL,
logo_favicon_url=self.WEBAPP_LOGO_FAVICON_URL,
session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS,
session_secret=self.WEBAPP_SESSION_SECRET,
webhook_secret_token=self.WEBHOOK_SECRET_TOKEN,
@@ -810,6 +839,55 @@ class Settings(BaseSettings):
def tariffs_config(self) -> Optional[TariffsConfig]:
return load_tariffs_config(self.TARIFFS_CONFIG_PATH)
@computed_field
@property
def webapp_themes_catalog(self) -> WebappThemesConfig:
return resolved_webapp_themes_catalog(
primary_accent=self.WEBAPP_PRIMARY_COLOR or "#00fe7a",
env_default_theme=self.WEBAPP_DEFAULT_THEME,
theme_dir=self.WEBAPP_THEMES_DIR,
)
@field_validator("WEBAPP_PRIMARY_COLOR", mode="before")
@classmethod
def ignore_deprecated_webapp_primary_color_env(cls, _value):
return "#00fe7a"
@field_validator("WEBAPP_LOGO_URL", mode="before")
@classmethod
def ignore_deprecated_webapp_logo_url_env(cls, _value):
return None
@field_validator("WEBAPP_LOGO_USE_EMOJI", mode="before")
@classmethod
def ignore_deprecated_webapp_logo_use_emoji_env(cls, _value):
return False
@field_validator("WEBAPP_LOGO_EMOJI", mode="before")
@classmethod
def ignore_deprecated_webapp_logo_emoji_env(cls, _value):
return "🫥"
@field_validator("WEBAPP_LOGO_EMOJI_FONT", mode="before")
@classmethod
def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value):
return "system"
@field_validator("WEBAPP_FAVICON_USE_CUSTOM", mode="before")
@classmethod
def ignore_deprecated_webapp_favicon_use_custom_env(cls, _value):
return False
@field_validator("WEBAPP_FAVICON_URL", mode="before")
@classmethod
def ignore_deprecated_webapp_favicon_url_env(cls, _value):
return None
@field_validator("WEBAPP_LOGO_FAVICON_URL", mode="before")
@classmethod
def ignore_deprecated_webapp_logo_favicon_url_env(cls, _value):
return None
@computed_field
@property
def referral_bonus_inviter(self) -> Dict[int, int]:
+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;
- редактор разрешенных настроек приложения из manifest-файла;
- раздел **Внешний вид** для логотипа, emoji-логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем;
- редактор JSON-каталога тарифов;
- загрузка Internal Squads из Remnawave для выбора в тарифах.
@@ -46,6 +47,14 @@
Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде.
## Внешний вид
Раздел **Внешний вид** объединяет настройки бренда и темы Web App. Логотип можно загрузить файлом или по HTTPS-ссылке; backend сохраняет файл в `data/webapp-logo/uploads` и подставляет локальный URL. Если включен emoji-логотип, картинка скрывается, а для emoji можно выбрать системный, Twemoji, Noto Color, animated Noto и другие варианты отрисовки.
В блоке тем админка читает каталог из `WEBAPP_THEMES_DIR`, показывает встроенные и кастомные темы, позволяет выбрать текущую тему, изменить accent, включить или выключить тему для админки и настроить масштаб логотипа на главной и экране входа. Кнопка предпросмотра открывает `/home?theme_preview=<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.
+5 -7
View File
@@ -96,10 +96,10 @@ nano .env
Если файл из `TARIFFS_CONFIG_PATH` существует, бот использует каталог тарифов. Если файла нет, применяется конфигурация из переменных `.env`.
В штатном `docker-compose.yml` том `./data:/app/data` у сервиса приложения **закомментирован по умолчанию**. Раскомментируйте блок `volumes`, чтобы админка сохраняла `data/tariffs.json`, кеш логотипа Web App (`data/webapp-logo`) и animated emoji (`data/webapp-emoji`). Отдельный `docker-compose-dev.yml` в репозиторий не входит (может быть у вас локально); логика та же — монтирование `./data` в `/app/data`. Если bind mount включён на Ubuntu-сервере, создайте подкаталоги и отдайте `data` UID `10001`, под которым работает приложение внутри контейнера:
В штатном `docker-compose.yml` том `./data:/app/data` у сервиса приложения **закомментирован по умолчанию**. Раскомментируйте блок `volumes`, чтобы админка сохраняла `data/tariffs.json`, каталог тем (`data/themes`), кеш логотипа Web App (`data/webapp-logo`) и animated emoji (`data/webapp-emoji`). Отдельный `docker-compose-dev.yml` в репозиторий не входит (может быть у вас локально); логика та же — монтирование `./data` в `/app/data`. Если bind mount включён на Ubuntu-сервере, создайте подкаталоги и отдайте `data` UID `10001`, под которым работает приложение внутри контейнера:
```bash
mkdir -p data/webapp-logo data/webapp-emoji
mkdir -p data/themes data/webapp-logo data/webapp-emoji
chown -R 10001:10001 data
chmod -R u+rwX data
```
@@ -122,10 +122,8 @@ docker compose up -d --build --force-recreate
| `WEBAPP_SERVER_HOST` / `WEBAPP_SERVER_PORT` | Хост и порт Web App. По умолчанию порт `8081`. |
| `SUBSCRIPTION_MINI_APP_URL` | Публичный URL Web App. |
| `WEBAPP_TITLE` | Заголовок Web App. |
| `WEBAPP_PRIMARY_COLOR` | Основной цвет интерфейса. |
| `WEBAPP_LOGO_URL` | URL логотипа Web App; если пусто — показывается emoji из `WEBAPP_LOGO_EMOJI`. |
| `WEBAPP_LOGO_EMOJI` | Emoji-заглушка вместо картинки логотипа. |
| `WEBAPP_LOGO_EMOJI_FONT` | Набор/шрифт для отрисовки emoji (например `system`, `twemoji`, `noto-color-animated`). |
| `WEBAPP_THEMES_DIR` | Каталог тем Web App. По умолчанию `data/themes`; внутри ожидаются папки `<key>/theme.json` и опциональные CSS/ассеты. |
| `WEBAPP_DEFAULT_THEME` | Опциональный override темы по ключу, например `light` или `neon`. Если пусто, используется `default` из дескрипторов тем. |
| `WEBAPP_SESSION_SECRET` | HMAC-секрет сессий Web App. |
| `WEBHOOK_SECRET_TOKEN` | Секретный токен, с которым Telegram шлёт обновления на вебхук. |
| `WEBAPP_SESSION_TTL_SECONDS` | Время жизни сессии Web App. |
@@ -146,7 +144,7 @@ docker compose up -d --build --force-recreate
| `BRUTE_FORCE_LOCK_SECONDS` | Длительность временной блокировки. |
| `MY_DEVICES_SECTION_ENABLED` | Показывает раздел "Мои устройства" и включает API устройств. |
Настройка домена, BotFather и callback URL описана в [webapp.md](webapp.md).
Логотип, emoji-логотип, основной accent-цвет и тема редактируются в разделе **Админка -> Внешний вид** и сохраняются как overrides в базе. Переменные `WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_URL`, `WEBAPP_LOGO_USE_EMOJI`, `WEBAPP_LOGO_EMOJI` и `WEBAPP_LOGO_EMOJI_FONT` в `.env` считаются устаревшими для первичной настройки и игнорируются при загрузке env. Настройка домена, BotFather и callback URL описана в [webapp.md](webapp.md), а создание кастомных тем - в [webapp-themes.md](webapp-themes.md).
### SMTP и вход по email
+3 -3
View File
@@ -26,7 +26,7 @@ IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d
Перед запуском или после добавления mount выполните на сервере из каталога проекта:
```bash
mkdir -p data/webapp-logo data/webapp-emoji
mkdir -p data/themes data/webapp-logo data/webapp-emoji
chown -R 10001:10001 data
chmod -R u+rwX data
docker compose up -d --force-recreate remnawave-minishop
@@ -35,10 +35,10 @@ docker compose up -d --force-recreate remnawave-minishop
Проверка прав:
```bash
docker compose exec remnawave-minishop sh -lc 'id; ls -ldn /app/data /app/data/webapp-emoji; touch /app/data/webapp-emoji/test && rm /app/data/webapp-emoji/test'
docker compose exec remnawave-minishop sh -lc 'id; ls -ldn /app/data /app/data/themes /app/data/webapp-emoji; touch /app/data/themes/test /app/data/webapp-emoji/test && rm /app/data/themes/test /app/data/webapp-emoji/test'
```
Если проверочный `touch` проходит без `Permission denied`, Web App сможет сохранять каталог тарифов, кеш `WEBAPP_LOGO_URL` в `/app/data/webapp-logo` и кеш animated emoji в `/app/data/webapp-emoji`.
Если проверочный `touch` проходит без `Permission denied`, Web App сможет сохранять каталог тарифов, темы в `/app/data/themes`, кеш логотипов в `/app/data/webapp-logo` и кеш animated emoji в `/app/data/webapp-emoji`.
## Обновление версии
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
SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/
WEBAPP_TITLE="Моя подписка"
WEBAPP_PRIMARY_COLOR="#00fe7a"
WEBAPP_LOGO_URL=
WEBAPP_LOGO_EMOJI="🫥"
WEBAPP_LOGO_EMOJI_FONT=system
WEBAPP_THEMES_DIR=data/themes
WEBAPP_DEFAULT_THEME=
WEBAPP_SESSION_SECRET=<stable-random-secret>
WEBHOOK_SECRET_TOKEN=<stable-random-secret>
WEBAPP_SESSION_TTL_SECONDS=86400
@@ -49,7 +47,9 @@ SMTP_FROM_EMAIL=no-reply@domain.com
SMTP_FROM_NAME=Remnawave Minishop
```
Если `WEBAPP_LOGO_URL` пустой, в шапке и на экране входа показывается запасной **emoji-логотип** (`WEBAPP_LOGO_EMOJI`) и при необходимости стиль отрисовки (`WEBAPP_LOGO_EMOJI_FONT`: например `system`, `noto-color`, `noto-color-animated`, `twemoji`). Если SMTP-настройки не заполнены, вход по email скрывается.
Внешний вид настраивается в админке: раздел **Внешний вид** управляет логотипом, emoji-логотипом, accent-цветом, выбранной темой и масштабом логотипа. Кастомные темы читаются из `WEBAPP_THEMES_DIR`, а `WEBAPP_DEFAULT_THEME` может принудительно выбрать тему по ключу. Подробный контракт `theme.json`, CSS/asset-роуты и пайплайн создания темы описаны в [webapp-themes.md](webapp-themes.md).
Если SMTP-настройки не заполнены, вход по email скрывается.
## Telegram-авторизация
+14
View File
@@ -768,6 +768,9 @@
"wa_activate": "Activate",
"wa_settings_avatar_alt": "User avatar",
"wa_settings_language": "Language",
"wa_settings_theme": "Appearance theme",
"wa_settings_theme_follow_default": "Server default",
"wa_settings_theme_update_failed": "Failed to update theme",
"wa_settings_link_telegram": "Telegram linked",
"wa_settings_not_linked": "Not linked",
"wa_settings_link_email": "Email linked",
@@ -852,6 +855,7 @@
"admin_nav_logs": "Logs",
"admin_nav_system": "System",
"admin_nav_tariffs": "Tariffs",
"admin_nav_themes": "Themes",
"admin_nav_settings": "Settings",
"admin_section_stats_title": "Dashboard",
"admin_section_stats_subtitle": "Audience, revenue, Remnawave panel, and recent payments",
@@ -869,6 +873,8 @@
"admin_section_logs_subtitle": "User events and admin actions",
"admin_section_tariffs_title": "Tariffs",
"admin_section_tariffs_subtitle": "Sales catalog, periods, packages, and limits",
"admin_section_themes_title": "Web App themes",
"admin_section_themes_subtitle": "Colors, fonts, and Mini App appearance",
"admin_section_settings_title": "App Settings",
"admin_section_settings_subtitle": "Overrides for .env, applied instantly",
"admin_filter_all": "All",
@@ -1047,6 +1053,7 @@
"admin_stats_queue_groups": " groups",
"admin_id": "ID",
"admin_status_default": "Default",
"admin_status_current": "Current",
"admin_tariff_squads": "Squads",
"admin_tariff_premium": "Premium",
"admin_settings_badge_secret": "Secret",
@@ -1221,6 +1228,13 @@
"admin_tariffs_stat_disabled": "Disabled",
"admin_tariffs_stat_disabled_hint": "Hidden from showcase",
"admin_tariffs_catalog_empty": "The catalog is empty. Add your first tariff; a catalog JSON file will be created after saving.",
"admin_themes_catalog_title": "Web App themes",
"admin_themes_catalog_sub": "Select the current theme from a card; edit appearance through files in the theme folder",
"admin_themes_use_primary_accent": "Use primary accent color",
"admin_themes_use_in_admin": "Use in admin panel",
"admin_themes_catalog_empty": "The catalog is empty. Add a theme folder to data/themes and refresh.",
"admin_themes_saved": "Themes saved",
"admin_themes_save_failed": "Failed to save themes",
"admin_no_description": "No description",
"admin_tariff_model_traffic": "Traffic",
"admin_tariff_model_periods": "Periods",
+14
View File
@@ -768,6 +768,9 @@
"wa_activate": "Активировать",
"wa_settings_avatar_alt": "Аватар пользователя",
"wa_settings_language": "Выбор языка",
"wa_settings_theme": "Тема оформления",
"wa_settings_theme_follow_default": "По умолчанию (сервер)",
"wa_settings_theme_update_failed": "Не удалось обновить тему",
"wa_settings_link_telegram": "Привязка Telegram",
"wa_settings_not_linked": "Не привязан",
"wa_settings_link_email": "Привязка почты",
@@ -852,6 +855,7 @@
"admin_nav_logs": "Логи",
"admin_nav_system": "Система",
"admin_nav_tariffs": "Тарифы",
"admin_nav_themes": "Темы",
"admin_nav_settings": "Настройки",
"admin_section_stats_title": "Дашборд",
"admin_section_stats_subtitle": "Аудитория, доходы, панель Remnawave и последние платежи",
@@ -869,6 +873,8 @@
"admin_section_logs_subtitle": "События пользователей и админ-действия",
"admin_section_tariffs_title": "Тарифы",
"admin_section_tariffs_subtitle": "Каталог продаж, периоды, пакеты и лимиты",
"admin_section_themes_title": "Темы Web App",
"admin_section_themes_subtitle": "Цвета, шрифты и темы оформления Mini App",
"admin_section_settings_title": "Настройки приложения",
"admin_section_settings_subtitle": "Оверрайды над .env, применяются мгновенно",
"admin_filter_all": "Все",
@@ -1047,6 +1053,7 @@
"admin_stats_queue_groups": " групп",
"admin_id": "ID",
"admin_status_default": "По умолчанию",
"admin_status_current": "Текущая",
"admin_tariff_squads": "Squads",
"admin_tariff_premium": "Premium",
"admin_settings_badge_secret": "Secret",
@@ -1221,6 +1228,13 @@
"admin_tariffs_stat_disabled": "Отключено",
"admin_tariffs_stat_disabled_hint": "Скрыто с витрины",
"admin_tariffs_catalog_empty": "Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога.",
"admin_themes_catalog_title": "Темы Web App",
"admin_themes_catalog_sub": "Текущая тема выбирается карточкой; внешний вид редактируется файлами в папке темы",
"admin_themes_use_primary_accent": "Протягивать акцентный цвет",
"admin_themes_use_in_admin": "Использовать в админке",
"admin_themes_catalog_empty": "Каталог пуст. Добавьте папку темы в data/themes и обновите список.",
"admin_themes_saved": "Темы сохранены",
"admin_themes_save_failed": "Не удалось сохранить темы",
"admin_no_description": "Без описания",
"admin_tariff_model_traffic": "Трафик",
"admin_tariff_model_periods": "Периоды",
+1
View File
@@ -10,3 +10,4 @@ sqlalchemy[asyncio]==2.0.49
asyncpg==0.31.0
aiocryptopay==0.4.8
PyJWT[crypto]==2.12.1
Pillow==12.2.0
+25
View File
@@ -28,6 +28,31 @@ class SettingsTests(unittest.TestCase):
self.assertTrue(settings.WEBHOOK_SECRET_TOKEN)
self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400)
def test_deprecated_webapp_appearance_env_values_are_ignored(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
WEBAPP_PRIMARY_COLOR="#ff0000",
WEBAPP_LOGO_URL="https://cdn.example.com/logo.png",
WEBAPP_LOGO_USE_EMOJI=True,
WEBAPP_LOGO_EMOJI="🔥",
WEBAPP_LOGO_EMOJI_FONT="twemoji",
WEBAPP_FAVICON_USE_CUSTOM=True,
WEBAPP_FAVICON_URL="https://cdn.example.com/favicon.png",
WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png",
)
self.assertEqual(settings.WEBAPP_PRIMARY_COLOR, "#00fe7a")
self.assertIsNone(settings.WEBAPP_LOGO_URL)
self.assertFalse(settings.WEBAPP_LOGO_USE_EMOJI)
self.assertEqual(settings.WEBAPP_LOGO_EMOJI, "🫥")
self.assertEqual(settings.WEBAPP_LOGO_EMOJI_FONT, "system")
self.assertFalse(settings.WEBAPP_FAVICON_USE_CUSTOM)
self.assertIsNone(settings.WEBAPP_FAVICON_URL)
self.assertIsNone(settings.WEBAPP_LOGO_FAVICON_URL)
def test_tariffs_config_missing_uses_legacy_fallback(self):
settings = Settings(
_env_file=None,
+244 -1
View File
@@ -1,4 +1,5 @@
import asyncio
import io
import json
import os
import tempfile
@@ -8,9 +9,13 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from PIL import Image
from bot.app.web import subscription_webapp
from bot.app.web.admin_api_impl import themes as admin_themes
from bot.app.web.webapp import assets as webapp_assets
from config.settings import Settings
from config.webapp_themes_config import builtin_webapp_themes_config
class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
@@ -91,6 +96,94 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
r"^/webapp-logo\?v=[0-9a-f]{12}$",
)
def test_uploaded_webapp_logo_url_is_served_directly(self):
settings = SimpleNamespace(
WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-abcdef1234567890.png"
)
self.assertEqual(
subscription_webapp._resolve_webapp_logo_url(settings),
"/webapp-uploaded-logo/logo-abcdef1234567890.png",
)
def test_webapp_logo_is_hidden_when_emoji_logo_is_enabled(self):
settings = SimpleNamespace(
WEBAPP_LOGO_USE_EMOJI=True,
WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-abcdef1234567890.png",
)
self.assertEqual(subscription_webapp._resolve_webapp_logo_url(settings), "")
def test_custom_webapp_favicon_takes_precedence(self):
settings = SimpleNamespace(
WEBAPP_FAVICON_USE_CUSTOM=True,
WEBAPP_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png",
WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/1111111111111111/icon-180.png",
)
self.assertEqual(
subscription_webapp._resolve_webapp_favicon_url(settings, "/logo.png"),
"/webapp-favicon/abcdef1234567890/icon-180.png",
)
def test_logo_generated_favicon_is_used_when_custom_disabled(self):
settings = SimpleNamespace(
WEBAPP_FAVICON_USE_CUSTOM=False,
WEBAPP_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png",
WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/1111111111111111/icon-180.png",
)
self.assertEqual(
subscription_webapp._resolve_webapp_favicon_url(settings, "/logo.png"),
"/webapp-favicon/1111111111111111/icon-180.png",
)
def test_logo_generated_favicon_is_not_used_without_logo(self):
settings = SimpleNamespace(
WEBAPP_FAVICON_USE_CUSTOM=False,
WEBAPP_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png",
WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/1111111111111111/icon-180.png",
)
self.assertEqual(subscription_webapp._resolve_webapp_favicon_url(settings, ""), "")
def test_favicon_head_markup_includes_touch_icon(self):
markup = subscription_webapp._favicon_head_markup(
"/webapp-favicon/abcdef1234567890/icon-180.png"
)
self.assertIn('rel="apple-touch-icon"', markup)
self.assertIn("/webapp-favicon/abcdef1234567890/icon-32.png", markup)
def test_favicon_set_generation_writes_common_icon_sizes(self):
buffer = io.BytesIO()
Image.new("RGBA", (2, 2), (0, 254, 122, 255)).save(buffer, format="PNG")
png_body = buffer.getvalue()
with tempfile.TemporaryDirectory() as tmpdir:
with patch.object(admin_themes, "WEBAPP_FAVICON_DIR", Path(tmpdir)):
payload = admin_themes._write_favicon_set(png_body, "image/png", "icon.png")
self.assertRegex(
payload["favicon_url"],
r"^/webapp-favicon/[0-9a-f]{16}/icon-180\.png$",
)
digest = payload["favicon_url"].split("/")[2]
self.assertTrue((Path(tmpdir) / digest / "icon-32.png").exists())
self.assertTrue((Path(tmpdir) / digest / "apple-touch-icon.png").exists())
self.assertTrue((Path(tmpdir) / digest / "favicon.ico").exists())
def test_initial_theme_head_markup_includes_css_and_tokens(self):
cfg = builtin_webapp_themes_config("#123456")
theme = cfg.theme_by_key("light")
request = SimpleNamespace(get=lambda key, default="": "nonce-value")
markup = subscription_webapp._initial_theme_head_markup(request, theme, "#123456")
self.assertIn("/webapp-theme-css/light/style.css", markup)
self.assertIn('nonce="nonce-value"', markup)
self.assertIn("--accent:#123456", markup)
self.assertIn("color-scheme:light", markup)
def test_animated_emoji_asset_path_uses_same_origin_route(self):
self.assertEqual(
subscription_webapp._webapp_animated_emoji_asset_path("🤩"),
@@ -222,6 +315,155 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(response.text, "console.log('minified');")
async def test_theme_css_asset_route_serves_file_from_configured_directory(self):
with tempfile.TemporaryDirectory() as tmpdir:
themes_dir = Path(tmpdir)
(themes_dir / "custom").mkdir()
(themes_dir / "custom" / "theme.css").write_text(
".theme-key-custom { --bg: red; }", encoding="utf-8"
)
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=str(themes_dir),
)
},
match_info={"path": "custom/theme.css"},
)
response = await subscription_webapp.theme_css_asset_route(request)
self.assertEqual(response.content_type, "text/css")
self.assertEqual(response.headers["Cache-Control"], "no-cache")
self.assertIn("--bg: red", response.text)
async def test_theme_css_asset_route_serves_default_theme_asset_from_theme_folder(self):
with tempfile.TemporaryDirectory() as tmpdir:
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=tmpdir,
)
},
match_info={"path": "light/style.css"},
)
response = await subscription_webapp.theme_css_asset_route(request)
self.assertEqual(response.content_type, "text/css")
self.assertIn(".theme-key-light", response.text)
self.assertTrue((Path(tmpdir) / "light" / "theme.json").exists())
self.assertTrue((Path(tmpdir) / "light" / "style.css").exists())
async def test_theme_css_asset_route_rejects_path_traversal(self):
with tempfile.TemporaryDirectory() as tmpdir:
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=tmpdir,
)
},
match_info={"path": "../secret.css"},
)
with self.assertRaises(webapp_assets.web.HTTPNotFound):
await subscription_webapp.theme_css_asset_route(request)
async def test_theme_asset_route_serves_image_from_configured_directory(self):
with tempfile.TemporaryDirectory() as tmpdir:
themes_dir = Path(tmpdir)
(themes_dir / "custom" / "icons").mkdir(parents=True)
(themes_dir / "custom" / "icons" / "save.png").write_bytes(b"png-bytes")
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=str(themes_dir),
)
},
match_info={"path": "custom/icons/save.png"},
)
response = await subscription_webapp.theme_asset_route(request)
self.assertEqual(response.content_type, "image/png")
self.assertEqual(response.headers["Cache-Control"], "public, max-age=3600")
self.assertEqual(response.body, b"png-bytes")
async def test_theme_asset_route_uses_immutable_cache_for_versioned_assets(self):
with tempfile.TemporaryDirectory() as tmpdir:
themes_dir = Path(tmpdir)
(themes_dir / "custom" / "icons").mkdir(parents=True)
(themes_dir / "custom" / "icons" / "save.png").write_bytes(b"png-bytes")
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=str(themes_dir),
)
},
match_info={"path": "custom/icons/save.png"},
query={"v": "6"},
)
response = await subscription_webapp.theme_asset_route(request)
self.assertEqual(
response.headers["Cache-Control"], "public, max-age=31536000, immutable"
)
async def test_theme_asset_route_serves_default_theme_icon_from_theme_folder(self):
with tempfile.TemporaryDirectory() as tmpdir:
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=tmpdir,
)
},
match_info={"path": "windows95/icons/save.png"},
)
response = await subscription_webapp.theme_asset_route(request)
self.assertEqual(response.content_type, "image/png")
self.assertGreater(len(response.body), 0)
self.assertTrue((Path(tmpdir) / "windows95" / "theme.json").exists())
self.assertTrue((Path(tmpdir) / "windows95" / "icons" / "save.png").exists())
async def test_theme_asset_route_rejects_path_traversal(self):
with tempfile.TemporaryDirectory() as tmpdir:
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=tmpdir,
)
},
match_info={"path": "../secret.png"},
)
with self.assertRaises(webapp_assets.web.HTTPNotFound):
await subscription_webapp.theme_asset_route(request)
async def test_theme_asset_route_rejects_non_image_suffix(self):
with tempfile.TemporaryDirectory() as tmpdir:
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=tmpdir,
)
},
match_info={"path": "custom/icons/readme.txt"},
)
with self.assertRaises(webapp_assets.web.HTTPNotFound):
await subscription_webapp.theme_asset_route(request)
def test_webapp_logo_disk_cache_roundtrip(self):
with tempfile.TemporaryDirectory() as tmpdir:
logo_url = "https://cdn.example.com/logo.png"
@@ -237,7 +479,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
logo_url = "https://cdn.example.com/logo.png"
logo = (b"cached-logo", "image/png")
app = {
"settings": SimpleNamespace(WEBAPP_LOGO_URL=logo_url),
"settings": SimpleNamespace(WEBAPP_LOGO_URL=logo_url, WEBAPP_LOGO_USE_EMOJI=False),
"webapp_logo_cache": None,
"webapp_logo_cache_lock": asyncio.Lock(),
}
@@ -276,6 +518,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as tmpdir:
app = {
"settings": SimpleNamespace(
WEBAPP_LOGO_USE_EMOJI=True,
WEBAPP_LOGO_EMOJI="🤩",
WEBAPP_LOGO_EMOJI_FONT="noto-color-animated",
),
+39
View File
@@ -1,8 +1,10 @@
import asyncio
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
from bot.app.web import admin_api, subscription_webapp
from bot.app.web.admin_api_impl import auth as admin_auth_routes
@@ -57,8 +59,10 @@ class WebAppRouteContractTests(unittest.TestCase):
("GET", "/auth/telegram/callback"): "telegram_oauth_callback_route",
("GET", "/health"): "health_route",
("GET", "/webapp-logo"): "webapp_logo_route",
("GET", "/webapp-uploaded-logo/{filename}"): "webapp_uploaded_logo_route",
("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route",
("GET", "/subscription_webapp.css"): "css_asset_route",
("GET", "/webapp-theme-css/{path}"): "theme_css_asset_route",
("GET", "/subscription_webapp.min.{asset_hash}.js"): "js_asset_route",
("GET", "/subscription_webapp.js"): "js_asset_route",
("POST", "/api/auth/telegram/nonce"): "telegram_oauth_nonce_route",
@@ -136,12 +140,47 @@ class WebAppRouteContractTests(unittest.TestCase):
("PATCH", "/api/admin/settings"): "admin_settings_patch_route",
("GET", "/api/admin/tariffs"): "admin_tariffs_get_route",
("PUT", "/api/admin/tariffs"): "admin_tariffs_save_route",
("GET", "/api/admin/themes"): "admin_themes_get_route",
("PUT", "/api/admin/themes"): "admin_themes_save_route",
("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route",
("POST", "/api/admin/appearance/favicon"): "admin_appearance_favicon_upload_route",
("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route",
}
for key, handler_name in expected.items():
self.assertEqual(routes.get(key), handler_name, key)
def test_admin_themes_page_route_is_not_registered(self):
app = web.Application()
subscription_webapp.setup_subscription_webapp_routes(app)
request = make_mocked_request("GET", "/admin/themes", app=app)
match_info = asyncio.run(app.router.resolve(request))
self.assertEqual(match_info.http_exception.status, 404)
def test_admin_appearance_page_route_is_registered(self):
app = web.Application()
subscription_webapp.setup_subscription_webapp_routes(app)
request = make_mocked_request("GET", "/admin/appearance", app=app)
match_info = asyncio.run(app.router.resolve(request))
self.assertEqual(match_info.handler.__name__, "index_route")
def test_webapp_favicon_asset_route_is_registered(self):
app = web.Application()
subscription_webapp.setup_subscription_webapp_routes(app)
request = make_mocked_request(
"GET",
"/webapp-favicon/abcdef1234567890/icon-180.png",
app=app,
)
match_info = asyncio.run(app.router.resolve(request))
self.assertEqual(match_info.handler.__name__, "webapp_favicon_route")
class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase):
def _settings(self):
+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)