From b6ee5e8790ff010cb8ccc2c2cfb8be5f5f4b5d6d Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Thu, 14 May 2026 16:33:50 +0300 Subject: [PATCH] feat: custom themes initial --- .env.example | 2 + bot/app/web/admin_api.py | 2 + bot/app/web/admin_api_impl/common.py | 4 + bot/app/web/admin_api_impl/routes.py | 2 + bot/app/web/admin_api_impl/themes.py | 63 ++ bot/app/web/frontend/src/App.svelte | 34 +- .../web/frontend/src/admin/AdminPanel.svelte | 15 + .../src/admin/sections/ThemesSection.svelte | 267 +++++ .../src/lib/admin/stores/themesStore.js | 113 +++ .../frontend/src/lib/components/ui/icons.js | 1 + .../web/frontend/src/lib/webapp/constants.js | 1 + .../web/frontend/src/lib/webapp/mockApi.js | 24 + .../frontend/src/lib/webapp/previewMock.js | 98 ++ .../web/frontend/src/lib/webapp/themeStyle.js | 141 +++ bot/app/web/frontend/src/styles/admin.css | 75 +- bot/app/web/frontend/src/styles/base.css | 36 +- .../web/frontend/src/styles/components.css | 17 +- bot/app/web/frontend/src/styles/controls.css | 10 +- bot/app/web/frontend/src/styles/dialogs.css | 8 +- bot/app/web/frontend/src/styles/webapp.css | 175 ++-- bot/app/web/themes/ascii/style.css | 953 ++++++++++++++++++ bot/app/web/themes/ascii/theme.json | 17 + bot/app/web/themes/dark/theme.json | 27 + bot/app/web/themes/light/style.css | 46 + bot/app/web/themes/light/theme.json | 16 + bot/app/web/themes/windows95/icons/README.md | 9 + .../web/themes/windows95/icons/arrow-left.png | Bin 0 -> 610 bytes .../themes/windows95/icons/arrow-right.png | Bin 0 -> 423 bytes .../web/themes/windows95/icons/bitcoin.png | Bin 0 -> 395 bytes bot/app/web/themes/windows95/icons/check.png | Bin 0 -> 340 bytes .../web/themes/windows95/icons/chevrons.png | Bin 0 -> 375 bytes bot/app/web/themes/windows95/icons/coins.png | Bin 0 -> 424 bytes .../web/themes/windows95/icons/dashboard.png | Bin 0 -> 371 bytes .../web/themes/windows95/icons/database.png | Bin 0 -> 356 bytes .../web/themes/windows95/icons/download.png | Bin 0 -> 419 bytes bot/app/web/themes/windows95/icons/error.png | Bin 0 -> 372 bytes .../web/themes/windows95/icons/file-text.png | Bin 0 -> 388 bytes bot/app/web/themes/windows95/icons/folder.png | Bin 0 -> 378 bytes bot/app/web/themes/windows95/icons/gift.png | Bin 0 -> 424 bytes bot/app/web/themes/windows95/icons/globe.png | Bin 0 -> 636 bytes bot/app/web/themes/windows95/icons/help.png | Bin 0 -> 364 bytes bot/app/web/themes/windows95/icons/home.png | Bin 0 -> 390 bytes bot/app/web/themes/windows95/icons/info.png | Bin 0 -> 385 bytes bot/app/web/themes/windows95/icons/key.png | Bin 0 -> 415 bytes bot/app/web/themes/windows95/icons/lock.png | Bin 0 -> 356 bytes .../web/themes/windows95/icons/megaphone.png | Bin 0 -> 393 bytes .../web/themes/windows95/icons/paintbrush.png | Bin 0 -> 474 bytes .../themes/windows95/icons/payment-card.png | Bin 0 -> 395 bytes bot/app/web/themes/windows95/icons/print.png | Bin 0 -> 461 bytes .../web/themes/windows95/icons/refresh.png | Bin 0 -> 327 bytes bot/app/web/themes/windows95/icons/save.png | Bin 0 -> 411 bytes bot/app/web/themes/windows95/icons/search.png | Bin 0 -> 395 bytes bot/app/web/themes/windows95/icons/send.png | Bin 0 -> 415 bytes .../web/themes/windows95/icons/settings.png | Bin 0 -> 393 bytes bot/app/web/themes/windows95/icons/shield.png | Bin 0 -> 422 bytes .../web/themes/windows95/icons/sliders.png | Bin 0 -> 478 bytes .../web/themes/windows95/icons/smartphone.png | Bin 0 -> 500 bytes .../web/themes/windows95/icons/sparkles.png | Bin 0 -> 589 bytes bot/app/web/themes/windows95/icons/tag.png | Bin 0 -> 392 bytes bot/app/web/themes/windows95/icons/ticket.png | Bin 0 -> 392 bytes bot/app/web/themes/windows95/icons/trash.png | Bin 0 -> 384 bytes bot/app/web/themes/windows95/icons/user.png | Bin 0 -> 419 bytes bot/app/web/themes/windows95/icons/users.png | Bin 0 -> 403 bytes .../web/themes/windows95/icons/warning.png | Bin 0 -> 371 bytes bot/app/web/themes/windows95/icons/x.png | Bin 0 -> 344 bytes bot/app/web/themes/windows95/style.css | 850 ++++++++++++++++ bot/app/web/themes/windows95/theme.json | 17 + bot/app/web/webapp/_runtime.py | 11 + bot/app/web/webapp/assets.py | 119 +++ bot/app/web/webapp/routes.py | 2 + bot/app/web/webapp/serializers.py | 7 + config/settings.py | 26 + config/webapp_themes_config.py | 586 +++++++++++ docs/remnawave-minishop.webp | Bin 0 -> 122284 bytes locales/en.json | 14 + locales/ru.json | 14 + tests/test_webapp_assets.py | 127 +++ tests/test_webapp_route_contract.py | 3 + tests/test_webapp_themes_config.py | 371 +++++++ 79 files changed, 4151 insertions(+), 152 deletions(-) create mode 100644 bot/app/web/admin_api_impl/themes.py create mode 100644 bot/app/web/frontend/src/admin/sections/ThemesSection.svelte create mode 100644 bot/app/web/frontend/src/lib/admin/stores/themesStore.js create mode 100644 bot/app/web/frontend/src/lib/webapp/themeStyle.js create mode 100644 bot/app/web/themes/ascii/style.css create mode 100644 bot/app/web/themes/ascii/theme.json create mode 100644 bot/app/web/themes/dark/theme.json create mode 100644 bot/app/web/themes/light/style.css create mode 100644 bot/app/web/themes/light/theme.json create mode 100644 bot/app/web/themes/windows95/icons/README.md create mode 100644 bot/app/web/themes/windows95/icons/arrow-left.png create mode 100644 bot/app/web/themes/windows95/icons/arrow-right.png create mode 100644 bot/app/web/themes/windows95/icons/bitcoin.png create mode 100644 bot/app/web/themes/windows95/icons/check.png create mode 100644 bot/app/web/themes/windows95/icons/chevrons.png create mode 100644 bot/app/web/themes/windows95/icons/coins.png create mode 100644 bot/app/web/themes/windows95/icons/dashboard.png create mode 100644 bot/app/web/themes/windows95/icons/database.png create mode 100644 bot/app/web/themes/windows95/icons/download.png create mode 100644 bot/app/web/themes/windows95/icons/error.png create mode 100644 bot/app/web/themes/windows95/icons/file-text.png create mode 100644 bot/app/web/themes/windows95/icons/folder.png create mode 100644 bot/app/web/themes/windows95/icons/gift.png create mode 100644 bot/app/web/themes/windows95/icons/globe.png create mode 100644 bot/app/web/themes/windows95/icons/help.png create mode 100644 bot/app/web/themes/windows95/icons/home.png create mode 100644 bot/app/web/themes/windows95/icons/info.png create mode 100644 bot/app/web/themes/windows95/icons/key.png create mode 100644 bot/app/web/themes/windows95/icons/lock.png create mode 100644 bot/app/web/themes/windows95/icons/megaphone.png create mode 100644 bot/app/web/themes/windows95/icons/paintbrush.png create mode 100644 bot/app/web/themes/windows95/icons/payment-card.png create mode 100644 bot/app/web/themes/windows95/icons/print.png create mode 100644 bot/app/web/themes/windows95/icons/refresh.png create mode 100644 bot/app/web/themes/windows95/icons/save.png create mode 100644 bot/app/web/themes/windows95/icons/search.png create mode 100644 bot/app/web/themes/windows95/icons/send.png create mode 100644 bot/app/web/themes/windows95/icons/settings.png create mode 100644 bot/app/web/themes/windows95/icons/shield.png create mode 100644 bot/app/web/themes/windows95/icons/sliders.png create mode 100644 bot/app/web/themes/windows95/icons/smartphone.png create mode 100644 bot/app/web/themes/windows95/icons/sparkles.png create mode 100644 bot/app/web/themes/windows95/icons/tag.png create mode 100644 bot/app/web/themes/windows95/icons/ticket.png create mode 100644 bot/app/web/themes/windows95/icons/trash.png create mode 100644 bot/app/web/themes/windows95/icons/user.png create mode 100644 bot/app/web/themes/windows95/icons/users.png create mode 100644 bot/app/web/themes/windows95/icons/warning.png create mode 100644 bot/app/web/themes/windows95/icons/x.png create mode 100644 bot/app/web/themes/windows95/style.css create mode 100644 bot/app/web/themes/windows95/theme.json create mode 100644 config/webapp_themes_config.py create mode 100644 docs/remnawave-minishop.webp create mode 100644 tests/test_webapp_themes_config.py diff --git a/.env.example b/.env.example index 7882f23..7f71da8 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,8 @@ WEBAPP_SERVER_HOST=0.0.0.0 # WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port WEBAPP_TITLE="/minishop" # Mini App title WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color +WEBAPP_THEMES_DIR=data/themes # Folder with theme subfolders: /theme.json and optional CSS/assets +WEBAPP_DEFAULT_THEME= # Optional: override descriptor default theme key (e.g. light) WEBAPP_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_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty diff --git a/bot/app/web/admin_api.py b/bot/app/web/admin_api.py index 8dcb349..385a0e3 100644 --- a/bot/app/web/admin_api.py +++ b/bot/app/web/admin_api.py @@ -17,6 +17,7 @@ from bot.app.web.admin_api_impl import ( stats as _stats, sync as _sync, tariffs as _tariffs, + themes as _themes, users as _users, ) @@ -34,6 +35,7 @@ _MODULES = ( _ads, _settings, _tariffs, + _themes, _panel, _routes, ) diff --git a/bot/app/web/admin_api_impl/common.py b/bot/app/web/admin_api_impl/common.py index 7a337c6..2ef0e05 100644 --- a/bot/app/web/admin_api_impl/common.py +++ b/bot/app/web/admin_api_impl/common.py @@ -270,6 +270,10 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None: path.write_text(payload, encoding="utf-8") +def _webapp_themes_catalog_payload(config: Any) -> Dict[str, Any]: + return config.model_dump(mode="json", exclude_none=True) + + def _panel_node_uuid_key(node: Dict[str, Any]) -> str: uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id") return str(uid).strip().lower() if uid else "" diff --git a/bot/app/web/admin_api_impl/routes.py b/bot/app/web/admin_api_impl/routes.py index ad2c89d..4f1e93a 100644 --- a/bot/app/web/admin_api_impl/routes.py +++ b/bot/app/web/admin_api_impl/routes.py @@ -54,4 +54,6 @@ 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_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route) diff --git a/bot/app/web/admin_api_impl/themes.py b/bot/app/web/admin_api_impl/themes.py new file mode 100644 index 0000000..983618d --- /dev/null +++ b/bot/app/web/admin_api_impl/themes.py @@ -0,0 +1,63 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + +from config.webapp_themes_config import ( + WebappThemesConfig, + ensure_webapp_core_themes, + resolved_webapp_themes_catalog, + write_webapp_theme_dir, +) + + +async def admin_themes_get_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + primary = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a" + catalog = resolved_webapp_themes_catalog( + primary_accent=primary, + env_default_theme=settings.WEBAPP_DEFAULT_THEME, + theme_dir=settings.WEBAPP_THEMES_DIR, + ) + + return _ok( + { + "exists": Path(settings.WEBAPP_THEMES_DIR).expanduser().exists(), + "themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()), + "catalog": _webapp_themes_catalog_payload(catalog), + } + ) + + +async def admin_themes_save_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + payload = await _read_json(request) + catalog = payload.get("catalog") if "catalog" in payload else payload + if not isinstance(catalog, dict): + return _error(400, "invalid_payload", "catalog must be an object") + + try: + config = WebappThemesConfig.model_validate(catalog) + except (ValidationError, ValueError) as exc: + return _error(400, "invalid_webapp_themes_config", str(exc)) + + config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a") + + try: + write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True) + except OSError as exc: + logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR) + return _error(500, "write_failed", str(exc)) + + cache = request.app.get("webapp_settings_cache") + if isinstance(cache, dict): + cache["ts"] = 0.0 + cache["data"] = {} + + return _ok( + { + "exists": True, + "themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()), + "catalog": _webapp_themes_catalog_payload(config), + } + ) diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index 66b2c3a..ec51a5c 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -40,6 +40,13 @@ import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js"; import { activeTariffName, buildTariffCatalog } from "./lib/webapp/tariffs.js"; import { premiumTrafficPercent, trafficPercent } from "./lib/webapp/traffic.js"; + import { + findThemeEntry, + resolveEffectiveThemeKey, + themeCssHref, + themeEntryToInlineStyle, + themeRootClass, + } from "./lib/webapp/themeStyle.js"; /** Used-traffic percent from which top-up modals and CTAs unlock in the web app home screen */ const TRAFFIC_TOPUP_UNLOCK_PERCENT = 80; @@ -243,7 +250,6 @@ emoji: brandEmoji, emojiFont: brandEmojiFont, }); - $: accent = CFG.primaryColor || "#00fe7a"; $: 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 +306,26 @@ premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT) ); $: user = data?.user || {}; + $: themesCatalog = data?.themes_catalog || + CFG.themesCatalog || { default_theme: "dark", themes: [] }; + $: resolvedThemeKey = 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"; @@ -873,6 +899,9 @@ {brandTitle} + {#if shellThemeCssHref} + + {/if} @@ -880,7 +909,7 @@ {#if isPreviewBoard} {:else} -
+
{#if mode === "loading"}
@@ -931,6 +960,7 @@ onSectionChange={handleAdminSectionChange} onSettingsSaved={handleAdminPersistedSaved} onTariffsSaved={handleAdminPersistedSaved} + onThemesSaved={handleAdminPersistedSaved} {brandTitle} {brand} appVersion={CFG.appVersion} diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte index 14fa264..91b7b46 100644 --- a/bot/app/web/frontend/src/admin/AdminPanel.svelte +++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte @@ -11,6 +11,7 @@ LayoutDashboard, Megaphone, Menu, + Paintbrush, Plus, RefreshCw, Save, @@ -34,6 +35,7 @@ import StatsSection from "./sections/StatsSection.svelte"; import TariffEditorModal from "./sections/TariffEditorModal.svelte"; import TariffsSection from "./sections/TariffsSection.svelte"; + import ThemesSection from "./sections/ThemesSection.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,6 +73,7 @@ export let onSectionChange = () => {}; export let onSettingsSaved = () => {}; export let onTariffsSaved = () => {}; + export let onThemesSaved = () => {}; export let brand = {}; export let brandTitle = "/minishop"; export let appVersion = "dev+local"; @@ -111,6 +115,7 @@ label: at("nav_system", {}, "Система"), items: [ { id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins }, + { id: "themes", label: at("nav_themes", {}, "Темы"), icon: Paintbrush }, { id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders }, ], }, @@ -153,6 +158,10 @@ title: at("section_tariffs_title", {}, "Тарифы"), subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"), }, + themes: { + title: at("section_themes_title", {}, "Темы Web App"), + subtitle: at("section_themes_subtitle", {}, "Цвета, шрифты и темы оформления Mini App"), + }, settings: { title: at("section_settings_title", {}, "Настройки приложения"), subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"), @@ -196,6 +205,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 +217,7 @@ setContext("settingsStore", settingsStore); setContext("usersStore", usersStore); setContext("tariffsStore", tariffsStore); + setContext("themesStore", themesStore); $: usersStore.setActive(active); $: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length; @@ -635,6 +646,10 @@ {/if} + {#if active === "themes"} + + {/if} + {#if active === "settings"} {/if} diff --git a/bot/app/web/frontend/src/admin/sections/ThemesSection.svelte b/bot/app/web/frontend/src/admin/sections/ThemesSection.svelte new file mode 100644 index 0000000..e10a2a8 --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/ThemesSection.svelte @@ -0,0 +1,267 @@ + + +{#if themesLoading} + {at("loading", {}, "Загрузка…")} +{:else} +
+
+
+

{at("themes_catalog_title", {}, "Темы Web App")}

+ {at( + "themes_catalog_sub", + {}, + "Текущая тема выбирается карточкой; внешний вид редактируется файлами в папке темы" + )} +
+
+ + + {at("btn_refresh", {}, "Обновить")} + + + + {at("btn_save", {}, "Сохранить")} + +
+
+
+ {#if !themesCatalog.themes.length} + + {at( + "themes_catalog_empty", + {}, + "Каталог пуст. Добавьте папку темы в data/themes и обновите список." + )} + + {:else} +
+ {#each themesCatalog.themes as theme (theme.key)} + {@const isCurrent = theme.key === activeKey} +
selectTheme(theme, event)} + onkeydown={(event) => handleCardKeydown(event, theme)} + > + + + {themeTitle(theme)} + {#if isCurrent} + {at("status_current", {}, "Текущая")} + {/if} + + {theme.key} + + + + {themeDescription(theme)} + + + + +
+ {/each} +
+ {/if} +
+
+{/if} + + diff --git a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js b/bot/app/web/frontend/src/lib/admin/stores/themesStore.js new file mode 100644 index 0000000..f0f63d8 --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/stores/themesStore.js @@ -0,0 +1,113 @@ +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 setCurrentTheme(key) { + let changed = false; + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + default_theme: key, + themes: (s.themesCatalog.themes || []).map((theme) => ({ + ...theme, + default: theme.key === key, + })), + }, + })); + state.update((s) => { + changed = s.themesCatalog.default_theme === key; + return s; + }); + if (changed) await saveThemes({ silent: true }); + } + + 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 + ), + }, + })); + } + + return { + subscribe: state.subscribe, + loadThemes, + saveThemes, + setCurrentTheme, + togglePrimaryAccent, + toggleAdminUse, + }; +} diff --git a/bot/app/web/frontend/src/lib/components/ui/icons.js b/bot/app/web/frontend/src/lib/components/ui/icons.js index b1f704d..21f1142 100644 --- a/bot/app/web/frontend/src/lib/components/ui/icons.js +++ b/bot/app/web/frontend/src/lib/components/ui/icons.js @@ -35,6 +35,7 @@ export { Menu, MessageSquare, MousePointerClick, + Paintbrush, Plus, QrCode, Radio, diff --git a/bot/app/web/frontend/src/lib/webapp/constants.js b/bot/app/web/frontend/src/lib/webapp/constants.js index 71c2f97..fd4f470 100644 --- a/bot/app/web/frontend/src/lib/webapp/constants.js +++ b/bot/app/web/frontend/src/lib/webapp/constants.js @@ -34,6 +34,7 @@ export const ADMIN_SECTIONS = new Set([ "broadcast", "logs", "tariffs", + "themes", "settings", ]); export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js"; diff --git a/bot/app/web/frontend/src/lib/webapp/mockApi.js b/bot/app/web/frontend/src/lib/webapp/mockApi.js index f354743..ef65690 100644 --- a/bot/app/web/frontend/src/lib/webapp/mockApi.js +++ b/bot/app/web/frontend/src/lib/webapp/mockApi.js @@ -195,6 +195,30 @@ export async function mockApi(path, options = {}, context = {}) { }, }; } + 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/settings") return { ok: true, sections: [] }; if (cleanPath.startsWith("/admin/")) return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 }; diff --git a/bot/app/web/frontend/src/lib/webapp/previewMock.js b/bot/app/web/frontend/src/lib/webapp/previewMock.js index 9741502..20e963d 100644 --- a/bot/app/web/frontend/src/lib/webapp/previewMock.js +++ b/bot/app/web/frontend/src/lib/webapp/previewMock.js @@ -1,3 +1,27 @@ +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", @@ -18,6 +42,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 +179,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 +227,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; diff --git a/bot/app/web/frontend/src/lib/webapp/themeStyle.js b/bot/app/web/frontend/src/lib/webapp/themeStyle.js new file mode 100644 index 0000000..e312c37 --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/themeStyle.js @@ -0,0 +1,141 @@ +/** 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", + 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", +}; + +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; + parts.push(`${cssVar}:${String(value)}`); + } + return parts.join(";"); +} + +export function findThemeEntry(themesCatalog, key) { + const themes = themesCatalog?.themes || []; + return themes.find((entry) => entry && entry.key === key) || null; +} + +export function resolveEffectiveThemeKey(themesCatalog) { + const themes = themesCatalog?.themes || []; + const byKey = (k) => themes.find((entry) => entry.key === k); + const def = themesCatalog?.default_theme || themes[0]?.key || "dark"; + return byKey(def) ? def : themes[0]?.key || "dark"; +} + +export function themePresetClass(tokens) { + const preset = String(tokens?.style_preset || "") + .trim() + .toLowerCase(); + if (!preset || preset === "none") return ""; + if (preset === "win95" || preset === "windows95") return "theme-preset-win95"; + return ""; +} + +export function themeKeyClass(key) { + const safe = String(key || "") + .trim() + .toLowerCase() + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return safe ? `theme-key-${safe}` : ""; +} + +export function themeCssClass(cssFile) { + const filename = String(cssFile || "") + .replace(/\\/g, "/") + .split("/") + .filter(Boolean) + .pop(); + const slug = String(filename || "") + .replace(/\.css$/i, "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug ? `theme-css-${slug}` : ""; +} + +export function themeRootClass(theme) { + return [ + themeKeyClass(theme?.key), + themeCssClass(theme?.css_file), + themePresetClass(theme?.tokens), + ] + .filter(Boolean) + .join(" "); +} + +export function themeEntryToInlineStyle(theme, primaryFallback = "#00fe7a") { + return themeTokensToInlineStyle(theme?.tokens, primaryFallback, { + fallbackAccent: !theme?.css_file, + }); +} + +function encodeThemeCssPath(path) { + return String(path || "") + .replace(/\\/g, "/") + .split("/") + .filter(Boolean) + .map(encodeURIComponent) + .join("/"); +} + +export function themeCssHref(theme) { + const cssFile = String(theme?.css_file || "").trim(); + if (!cssFile) return ""; + if (/^(?:https?:)?\/\//i.test(cssFile) || cssFile.startsWith("data:")) return ""; + if (cssFile.startsWith("/")) return cssFile; + const normalizedCssFile = cssFile.replace(/\\/g, "/").split("/").filter(Boolean).join("/"); + const key = String(theme?.key || "") + .trim() + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + const themedPath = + key && normalizedCssFile.split("/")[0] !== key + ? `${key}/${normalizedCssFile}` + : normalizedCssFile; + const encoded = encodeThemeCssPath(themedPath); + return encoded ? `/webapp-theme-css/${encoded}` : ""; +} + +export function localizedThemeName(theme, lang = "en") { + const names = theme?.names || {}; + const key = String(lang || "") + .trim() + .toLowerCase(); + const base = key.split("-")[0]; + return names[key] || names[base] || names.en || theme?.key || ""; +} diff --git a/bot/app/web/frontend/src/styles/admin.css b/bot/app/web/frontend/src/styles/admin.css index 7790901..821ab64 100644 --- a/bot/app/web/frontend/src/styles/admin.css +++ b/bot/app/web/frontend/src/styles/admin.css @@ -7,17 +7,14 @@ --admin-sidebar-w: var(--desktop-rail-width); --admin-header-h: 60px; --admin-card-bg: - linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), - var(--admin-surface); - --admin-card-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), - inset 0 1px 0 rgba(255, 255, 255, 0.05); + linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface); + --admin-card-shadow: var(--shadow-soft), inset 0 1px 0 var(--inset-highlight); position: fixed; inset: 0; width: 100vw; height: 100dvh; - background: #02070b; + background: var(--admin-bg); color: var(--admin-text); overflow: hidden; overscroll-behavior: contain; @@ -38,9 +35,9 @@ gap: 2px; padding: 28px 14px; border-right: 1px solid var(--admin-border); - background: rgba(7, 12, 17, 0.55); + background: var(--rail-bg); backdrop-filter: blur(14px); - box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02); + box-shadow: inset -1px 0 0 var(--admin-border); overflow-y: auto; } @@ -74,7 +71,8 @@ color: var(--accent); font-size: 14px; font-weight: 800; - letter-spacing: -0.01em; + font-family: var(--font-logo); + letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; } @@ -115,7 +113,10 @@ text-align: left; width: 100%; cursor: pointer; - transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; + transition: + background 0.12s ease, + color 0.12s ease, + border-color 0.12s ease; } .admin-nav-item > svg { @@ -124,7 +125,7 @@ } .admin-nav-item:hover { - background: rgba(255, 255, 255, 0.04); + background: var(--surface-hover); color: var(--admin-text); } @@ -169,7 +170,7 @@ gap: 8px; border: 1px solid var(--admin-border); border-radius: 10px; - background: rgba(255, 255, 255, 0.035); + background: var(--surface-muted); color: var(--admin-text); padding: 8px 10px; text-align: left; @@ -251,7 +252,7 @@ gap: 16px; padding: 0 28px; border-bottom: 1px solid var(--admin-border); - background: color-mix(in srgb, #02070b 82%, transparent); + background: color-mix(in srgb, var(--admin-bg) 82%, transparent); backdrop-filter: blur(14px); } @@ -265,7 +266,7 @@ margin: 0; font-size: 16px; font-weight: 700; - letter-spacing: -0.01em; + letter-spacing: 0; } .admin-header-title small { @@ -427,7 +428,7 @@ .admin-stat-card .admin-stat-value { font-size: 26px; font-weight: 700; - letter-spacing: -0.02em; + letter-spacing: 0; color: var(--admin-text); } @@ -457,7 +458,7 @@ font-size: 14px; font-weight: 600; color: var(--admin-text); - letter-spacing: -0.01em; + letter-spacing: 0; } .admin-dashboard-section-head small { @@ -528,7 +529,7 @@ .admin-revenue-kpi-value { font-size: 18px; font-weight: 700; - letter-spacing: -0.02em; + letter-spacing: 0; color: var(--admin-text); line-height: 1.15; } @@ -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, @@ -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,7 +2649,9 @@ 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; } @@ -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; @@ -3202,4 +3213,4 @@ grid-area: meta; white-space: normal; } -} \ No newline at end of file +} diff --git a/bot/app/web/frontend/src/styles/base.css b/bot/app/web/frontend/src/styles/base.css index 199d5cc..6a5b977 100644 --- a/bot/app/web/frontend/src/styles/base.css +++ b/bot/app/web/frontend/src/styles/base.css @@ -4,21 +4,14 @@ format("woff2"); font-display: swap; unicode-range: - U+1F1E6-1F1FF, - U+1F3F4, - U+E0062-E0063, - U+E0065, - U+E0067, - U+E006C, - U+E006E, - U+E0073-E0074, - U+E0077, - U+E007F; + U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, + U+E0077, U+E007F; } :root { --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; --font-mono: "JetBrains Mono", "Fira Code", monospace; + --font-logo: var(--font-mono); color-scheme: dark; --accent: #00fe7a; --bg: #03070b; @@ -33,6 +26,19 @@ --danger: #ff6b6b; --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 +65,10 @@ font-family: var(--font-sans); } +.theme-light { + color-scheme: light; +} + * { box-sizing: border-box; } @@ -72,7 +82,7 @@ body, } body { - background: #02070b; + background: var(--bg, #02070b); color: var(--text); -webkit-font-smoothing: antialiased; letter-spacing: 0; @@ -86,10 +96,10 @@ input { .app-shell { min-height: 100dvh; - background: #02070b !important; + background: var(--bg, #02070b) !important; } :root { --desktop-rail-width: 252px; --desktop-page-gutter: clamp(28px, 4vw, 72px); -} \ No newline at end of file +} diff --git a/bot/app/web/frontend/src/styles/components.css b/bot/app/web/frontend/src/styles/components.css index 91d8c8c..62df485 100644 --- a/bot/app/web/frontend/src/styles/components.css +++ b/bot/app/web/frontend/src/styles/components.css @@ -26,11 +26,9 @@ border-radius: 12px; border: 1px solid var(--admin-border); background: - linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), + linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface); - box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), - inset 0 1px 0 rgba(255, 255, 255, 0.05); + box-shadow: var(--shadow-soft), inset 0 1px 0 var(--inset-highlight); position: relative; overflow: hidden; } @@ -80,7 +78,7 @@ margin: 0; font-size: 1.5rem; font-weight: 600; - letter-spacing: -0.02em; + letter-spacing: 0; color: var(--admin-text); font-variant-numeric: tabular-nums; line-height: 1.15; @@ -89,7 +87,7 @@ .admin-cn-card-title--section { font-size: 1.05rem; font-weight: 600; - letter-spacing: -0.01em; + letter-spacing: 0; } .admin-cn-card-skeleton { @@ -227,9 +225,9 @@ display: inline-block; background: linear-gradient( 90deg, - rgba(255, 255, 255, 0.08), - rgba(255, 255, 255, 0.14), - rgba(255, 255, 255, 0.08) + color-mix(in srgb, var(--muted) 10%, transparent), + color-mix(in srgb, var(--muted) 18%, transparent), + color-mix(in srgb, var(--muted) 10%, transparent) ); background-size: 220% 100%; animation: ui-skeleton-pulse 1.2s ease-in-out infinite; @@ -293,3 +291,4 @@ background-position: -120% 0; } } + diff --git a/bot/app/web/frontend/src/styles/controls.css b/bot/app/web/frontend/src/styles/controls.css index 5aa9bee..0de8184 100644 --- a/bot/app/web/frontend/src/styles/controls.css +++ b/bot/app/web/frontend/src/styles/controls.css @@ -23,7 +23,7 @@ button:disabled { text-align: center; text-decoration: none; cursor: pointer; - box-shadow: 0 10px 26px rgba(0, 0, 0, 0.18); + box-shadow: var(--shadow-soft); transition: transform 0.16s ease, border-color 0.16s ease, @@ -34,7 +34,7 @@ button:disabled { .btn:hover:not(:disabled) { transform: translateY(-1px); - box-shadow: 0 14px 32px rgba(0, 0, 0, 0.22); + box-shadow: var(--shadow-soft); } .btn:active:not(:disabled) { @@ -45,7 +45,7 @@ button:disabled { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent), - 0 14px 32px rgba(0, 0, 0, 0.22); + var(--shadow-soft); } .btn.wide, @@ -56,7 +56,7 @@ button:disabled { .btn-primary { border-color: color-mix(in srgb, var(--accent) 72%, white); background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 76%, white)); - color: #03100a; + color: var(--accent-contrast); } .btn-secondary { @@ -111,7 +111,7 @@ button:disabled { margin-top: 12px; overflow: hidden; border-radius: 999px; - background: rgba(255, 255, 255, 0.08); + background: color-mix(in srgb, var(--muted) 18%, transparent); } .progress span { diff --git a/bot/app/web/frontend/src/styles/dialogs.css b/bot/app/web/frontend/src/styles/dialogs.css index f3ae77c..40cca5c 100644 --- a/bot/app/web/frontend/src/styles/dialogs.css +++ b/bot/app/web/frontend/src/styles/dialogs.css @@ -26,7 +26,7 @@ inset: 0; z-index: 0; border: 0; - background: rgba(0, 0, 0, 0.56); + background: var(--overlay-scrim); backdrop-filter: blur(10px); cursor: pointer; } @@ -43,9 +43,9 @@ padding: 18px; border: 1px solid var(--border); border-radius: 24px; - background: color-mix(in srgb, var(--panel) 94%, #07111a); + background: color-mix(in srgb, var(--panel) 96%, var(--bg)); color: var(--text); - box-shadow: 0 26px 70px rgba(0, 0, 0, 0.46); + box-shadow: var(--shadow-strong); } .dialog-head { @@ -92,7 +92,7 @@ .payment-submit-button { border-color: color-mix(in srgb, var(--accent) 72%, transparent); background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent)); - color: #031009; + color: var(--accent-contrast); box-shadow: 0 16px 38px color-mix(in srgb, var(--accent) 28%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent), diff --git a/bot/app/web/frontend/src/styles/webapp.css b/bot/app/web/frontend/src/styles/webapp.css index 2561e4e..18c1416 100644 --- a/bot/app/web/frontend/src/styles/webapp.css +++ b/bot/app/web/frontend/src/styles/webapp.css @@ -12,8 +12,8 @@ a { min-height: 100dvh; margin: 0 auto; overflow-x: hidden; - padding: - max(16px, env(safe-area-inset-top)) max(var(--screen-gutter), var(--safe-inline)) max(18px, env(safe-area-inset-bottom)) max(var(--screen-gutter), var(--safe-inline)); + padding: max(16px, env(safe-area-inset-top)) max(var(--screen-gutter), var(--safe-inline)) + max(18px, env(safe-area-inset-bottom)) max(var(--screen-gutter), var(--safe-inline)); } .content { @@ -94,7 +94,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 +162,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 +188,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 +395,7 @@ a { gap: 6px; } -.premium-server-list>div { +.premium-server-list > div { display: flex; flex-wrap: wrap; gap: 6px; @@ -401,7 +404,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 +419,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 +443,7 @@ a { gap: 11px; } -.trial-card-head>svg { +.trial-card-head > svg { flex: 0 0 auto; color: var(--accent); } @@ -475,7 +478,7 @@ a { gap: 11px; } -.devices-summary-head>svg { +.devices-summary-head > svg { color: var(--accent); } @@ -529,7 +532,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); } @@ -604,9 +607,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 +655,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 +691,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 +748,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 +767,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 +777,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 +801,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 +897,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 +913,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,7 +946,7 @@ 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 { @@ -969,7 +973,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; } @@ -1105,7 +1109,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 +1136,7 @@ a { gap: 14px; } -.bonus-card-head>svg { +.bonus-card-head > svg { flex: 0 0 auto; color: var(--accent); } @@ -1165,7 +1169,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 +1197,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 +1249,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); } @@ -1277,10 +1281,10 @@ 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)); + background: color-mix(in srgb, var(--accent) 11%, var(--surface-muted)); } -.settings-row-linked>svg:first-child { +.settings-row-linked > svg:first-child { color: var(--accent); } @@ -1290,12 +1294,8 @@ a { .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 +1325,7 @@ a { border-radius: 6px; } -.language-select-trigger>svg { +.language-select-trigger > svg { color: var(--muted); } @@ -1361,7 +1361,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 +1387,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 +1409,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 +1494,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); } @@ -1580,7 +1580,7 @@ a { 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 +1739,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 { @@ -1790,8 +1790,8 @@ a { max-width: min(420px, calc(100vw - 32px)); border: 1px solid color-mix(in srgb, var(--accent) 46%, var(--border)); 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 +1873,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 +2010,6 @@ a { } @media (prefers-reduced-motion: reduce) { - .content, .home-layout, .language-select-content, @@ -2039,7 +2037,7 @@ a { @media (min-width: 1024px) { body { - background: #02070b; + background: var(--bg); } .app-shell { @@ -2060,16 +2058,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 +2077,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 +2091,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 +2110,7 @@ a { align-items: stretch; } - .home-layout>.home-brand { + .home-layout > .home-brand { grid-column: auto; align-self: center; justify-items: center; @@ -2174,9 +2171,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 +2194,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 +2235,7 @@ a { display: none !important; } - .phone-screen>main.content.with-nav { + .phone-screen > main.content.with-nav { padding-top: 0; } @@ -2310,7 +2310,8 @@ a { color: var(--accent); font-size: 14px; font-weight: 800; - letter-spacing: -0.01em; + font-family: var(--font-logo); + letter-spacing: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/bot/app/web/themes/ascii/style.css b/bot/app/web/themes/ascii/style.css new file mode 100644 index 0000000..77d1878 --- /dev/null +++ b/bot/app/web/themes/ascii/style.css @@ -0,0 +1,953 @@ +/* + * ASCII / console theme. + * Pure black background, white foreground, monospace everything, + * 1px white borders, animated ASCII spinners and block-progress bars. + */ + +.theme-key-ascii { + color-scheme: dark; + --accent: #ffffff; + --accent-contrast: #000000; + --bg: #000000; + --panel: #000000; + --panel-2: #050505; + --panel-3: #0c0c0c; + --border: #ffffff; + --border-strong: #ffffff; + --text: #ffffff; + --muted: #b0b0b0; + --dim: #6a6a6a; + --danger: #ff5555; + --blue: #ffffff; + --radius: 0px; + --font-sans: "JetBrains Mono", "Cascadia Code", "Fira Code", "Consolas", + "Source Code Pro", "Courier New", ui-monospace, monospace; + --font-logo: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New", + ui-monospace, monospace; + --font-mono: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New", + ui-monospace, monospace; + --surface-sheen: transparent; + --surface-sheen-soft: transparent; + --surface-hover: rgba(255, 255, 255, 0.08); + --surface-muted: #0a0a0a; + --surface-subtle-border: #ffffff; + --overlay-scrim: rgba(0, 0, 0, 0.85); + --nav-bg: #000000; + --rail-bg: #000000; + --shadow-soft: none; + --shadow-strong: none; + --shadow-popover: 0 0 0 1px #ffffff; + --inset-highlight: transparent; + --admin-bg: #000000; + --admin-surface: #000000; + --admin-surface-2: #050505; + --admin-elev: #0c0c0c; + --admin-border: #ffffff; + --admin-border-strong: #ffffff; + --admin-text: #ffffff; + --admin-muted: #b0b0b0; + --admin-dim: #6a6a6a; +} + +/* ---------- Base typography ---------- */ + +.theme-key-ascii, +.theme-key-ascii body, +.theme-key-ascii button, +.theme-key-ascii input, +.theme-key-ascii textarea, +.theme-key-ascii select { + font-family: var(--font-sans); + letter-spacing: 0; + font-synthesis: none; + -webkit-font-smoothing: none; + font-smooth: never; + font-variant-ligatures: none; +} + +.theme-key-ascii.app-shell { + background: var(--bg) !important; + background-image: + repeating-linear-gradient( + 0deg, + rgba(255, 255, 255, 0.025) 0, + rgba(255, 255, 255, 0.025) 1px, + transparent 1px, + transparent 3px + ) !important; +} + +/* Slight CRT-like flicker on the shell. */ +@keyframes ascii-flicker { + 0%, 96%, 100% { opacity: 1; } + 97% { opacity: 0.96; } + 98% { opacity: 1; } + 99% { opacity: 0.94; } +} + +.theme-key-ascii.app-shell::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9998; + background: repeating-linear-gradient( + 180deg, + rgba(255, 255, 255, 0.02) 0, + rgba(255, 255, 255, 0.02) 1px, + transparent 1px, + transparent 2px + ); + animation: ascii-flicker 5s infinite; +} + +/* ---------- Panels / cards ---------- */ + +.theme-key-ascii .card, +.theme-key-ascii .period-card, +.theme-key-ascii .method-card, +.theme-key-ascii .settings-row, +.theme-key-ascii .option-row, +.theme-key-ascii .tariff-selected-card, +.theme-key-ascii .tariff-action-card, +.theme-key-ascii .tariff-warning-card, +.theme-key-ascii .topup-carryover-note, +.theme-key-ascii .input, +.theme-key-ascii .dialog-card, +.theme-key-ascii .language-select-content, +.theme-key-ascii .bottom-nav, +.theme-key-ascii .toast, +.theme-key-ascii .admin-sidebar, +.theme-key-ascii .admin-header, +.theme-key-ascii .admin-card, +.theme-key-ascii .admin-stat-card, +.theme-key-ascii .admin-revenue-panel, +.theme-key-ascii .admin-empty, +.theme-key-ascii .admin-tariff-card, +.theme-key-ascii .admin-toolbar-card, +.theme-key-ascii .admin-table-card, +.theme-key-ascii .admin-panel-dash-card, +.theme-key-ascii .admin-select-trigger, +.theme-key-ascii .admin-select-content, +.theme-key-ascii .admin-cn-card[data-slot="card"], +.theme-key-ascii .admin-dialog .dialog-card, +.theme-key-ascii .admin-theme-editor-section { + border: 1px solid #ffffff; + border-radius: 0; + background: var(--panel); + box-shadow: none; +} + +/* No ribbon/corner overlays: those caused dialog overflow scrollbars. + * The console feel comes from the crisp 1px borders, monospace text, + * and the animated marquees / glitches applied to interactive elements. */ + +/* ---------- Buttons ---------- */ + +.theme-key-ascii .btn, +.theme-key-ascii .language-select-trigger, +.theme-key-ascii .bottom-nav button, +.theme-key-ascii .admin-btn, +.theme-key-ascii .admin-chip, +.theme-key-ascii .admin-tabs-trigger, +.theme-key-ascii .admin-revenue-period-btn, +.theme-key-ascii .admin-mobile-toggle, +.theme-key-ascii .admin-nav-item { + border: 1px solid #ffffff; + border-radius: 0; + background: #000000; + color: #ffffff; + box-shadow: none; + text-transform: none; + font-family: var(--font-sans); + transform: none; + position: relative; +} + +.theme-key-ascii .btn:hover:not(:disabled), +.theme-key-ascii .admin-btn:hover:not(:disabled), +.theme-key-ascii .admin-nav-item:hover, +.theme-key-ascii .admin-tabs-trigger:hover, +.theme-key-ascii .admin-revenue-period-btn:hover, +.theme-key-ascii .bottom-nav button:hover { + background: #ffffff; + color: #000000; +} + +.theme-key-ascii .btn:active:not(:disabled), +.theme-key-ascii .bottom-nav button:active, +.theme-key-ascii .admin-btn:active:not(:disabled) { + background: #ffffff; + color: #000000; + transform: translate(1px, 1px); +} + +.theme-key-ascii .btn-primary, +.theme-key-ascii .admin-btn-primary, +.theme-key-ascii .bottom-nav button.active, +.theme-key-ascii .period-card.active, +.theme-key-ascii .method-card.active, +.theme-key-ascii .option-row.active, +.theme-key-ascii .admin-nav-item.active, +.theme-key-ascii .admin-tabs-trigger[data-state="active"], +.theme-key-ascii .admin-revenue-period-btn.is-active { + background: #ffffff; + color: #000000; + border-color: #ffffff; +} + +.theme-key-ascii .btn-primary:hover:not(:disabled), +.theme-key-ascii .admin-btn-primary:hover:not(:disabled) { + background: #000000; + color: #ffffff; + outline: 1px solid #ffffff; + outline-offset: -2px; +} + +/* Blinking caret-style focus ring. */ +@keyframes ascii-caret { + 0%, 49% { outline-color: #ffffff; } + 50%, 100% { outline-color: transparent; } +} + +.theme-key-ascii .btn:focus-visible, +.theme-key-ascii .admin-btn:focus-visible, +.theme-key-ascii .admin-nav-item:focus-visible, +.theme-key-ascii .admin-tabs-trigger:focus-visible, +.theme-key-ascii .admin-revenue-period-btn:focus-visible, +.theme-key-ascii .admin-mobile-toggle:focus-visible, +.theme-key-ascii .language-select-trigger:focus-visible, +.theme-key-ascii .bottom-nav button:focus-visible { + outline: 2px solid #ffffff; + outline-offset: 1px; + animation: ascii-caret 1s steps(1) infinite; +} + +/* ---------- Inputs ---------- */ + +.theme-key-ascii .input, +.theme-key-ascii .admin-input, +.theme-key-ascii .admin-textarea, +.theme-key-ascii .admin-screen-wrap textarea, +.theme-key-ascii .admin-dialog textarea { + border: 1px solid #ffffff; + border-radius: 0; + background: #000000; + color: #ffffff; + box-shadow: none; + font-family: var(--font-mono); + caret-color: #ffffff; +} + +.theme-key-ascii .input::placeholder, +.theme-key-ascii .admin-input::placeholder, +.theme-key-ascii .admin-textarea::placeholder, +.theme-key-ascii .admin-screen-wrap textarea::placeholder, +.theme-key-ascii .admin-dialog textarea::placeholder { + color: var(--dim); + font-style: normal; +} + +.theme-key-ascii .input:focus, +.theme-key-ascii .admin-input:focus, +.theme-key-ascii .admin-textarea:focus, +.theme-key-ascii .admin-screen-wrap textarea:focus, +.theme-key-ascii .admin-dialog textarea:focus { + outline: none; + border-color: #ffffff; + box-shadow: inset 0 0 0 1px #ffffff; +} + +/* ---------- Bottom nav (desktop rail) ---------- */ + +@media (min-width: 1024px) { + .theme-key-ascii .bottom-nav { + border-right: 1px solid #ffffff !important; + background: var(--rail-bg) !important; + backdrop-filter: none !important; + box-shadow: none !important; + } + + .theme-key-ascii .bottom-nav button { + border: 1px solid #ffffff !important; + border-radius: 0 !important; + background: #000000 !important; + color: #ffffff !important; + box-shadow: none !important; + } + + .theme-key-ascii .bottom-nav button.active { + background: #ffffff !important; + color: #000000 !important; + } +} + +/* ---------- ASCII progress bar ---------- * + * Empty track: ░░░░░░░░░░░ (low-contrast dotted fill). + * Filled span: ██████ (solid white blocks). + */ + +.theme-key-ascii .progress { + height: 14px; + border: 1px solid #ffffff; + border-radius: 0 !important; + background-color: #000000; + background-image: repeating-linear-gradient( + 90deg, + rgba(255, 255, 255, 0.22) 0, + rgba(255, 255, 255, 0.22) 1px, + transparent 1px, + transparent 4px + ); + position: relative; + overflow: hidden; + font-family: var(--font-mono); +} + +.theme-key-ascii .progress span { + border-radius: 0 !important; + background: #ffffff !important; + background-image: repeating-linear-gradient( + 90deg, + rgba(0, 0, 0, 0.0) 0, + rgba(0, 0, 0, 0.0) 5px, + rgba(0, 0, 0, 0.35) 5px, + rgba(0, 0, 0, 0.35) 6px + ) !important; + box-shadow: none; +} + +/* Indeterminate scanning effect for any progress lacking a width-set span. */ +@keyframes ascii-scan { + 0% { background-position: 0 0; } + 100% { background-position: 12px 0; } +} + +/* ---------- ASCII spinner replacement ---------- */ + +.theme-key-ascii .ui-spinner, +.theme-key-ascii .telegram-button-spinner, +.theme-key-ascii .brand-mark-spinner { + border: none !important; + border-radius: 0 !important; + width: 1ch !important; + height: 1em !important; + background: transparent !important; + position: relative; + animation: none !important; + color: currentColor; + font-family: var(--font-mono); + font-weight: 700; + text-align: center; + vertical-align: middle; + overflow: visible; +} + +.theme-key-ascii .ui-spinner::before, +.theme-key-ascii .telegram-button-spinner::before, +.theme-key-ascii .brand-mark-spinner::before { + content: "|"; + display: inline-block; + animation: ascii-spin 0.8s steps(1) infinite; + font-family: var(--font-mono); + line-height: 1; +} + +@keyframes ascii-spin { + 0% { content: "|"; } + 25% { content: "/"; } + 50% { content: "-"; } + 75% { content: "\\"; } + 100% { content: "|"; } +} + +/* Some browsers don't animate content; fallback rotation of a glyph. */ +@supports not (animation-name: ascii-spin) { + .theme-key-ascii .ui-spinner::before, + .theme-key-ascii .telegram-button-spinner::before, + .theme-key-ascii .brand-mark-spinner::before { + content: "+"; + animation: ascii-spin-rotate 0.8s steps(4) infinite; + } + @keyframes ascii-spin-rotate { + to { transform: rotate(360deg); } + } +} + +/* Blinking cursor appended to brand text. */ +.theme-key-ascii .login-brand h1::after, +.theme-key-ascii .admin-sidebar-brand strong::after, +.theme-key-ascii .brand-row strong::after { + content: "_"; + display: inline-block; + margin-left: 0.2ch; + color: #ffffff; + animation: ascii-blink 1s steps(1) infinite; +} + +@keyframes ascii-blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} + +/* Section heading prompt prefix. */ +.theme-key-ascii .admin-card-head h2::before, +.theme-key-ascii .admin-card-head h3::before, +.theme-key-ascii .card > h2:first-child::before, +.theme-key-ascii .card > h3:first-child::before { + content: "> "; + color: #ffffff; + opacity: 0.85; + font-family: var(--font-mono); +} + +/* ---------- Tables ---------- */ + +.theme-key-ascii .admin-table thead th { + background: #000000; + color: #ffffff; + border-bottom: 1px solid #ffffff; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 700; +} + +.theme-key-ascii .admin-table tbody tr { + border-bottom: 1px dashed #ffffff; +} + +.theme-key-ascii .admin-table tbody tr:hover { + background: rgba(255, 255, 255, 0.08); +} + +/* ---------- Badges / chips ---------- */ + +.theme-key-ascii .admin-badge, +.theme-key-ascii .admin-cn-badge { + border: 1px solid #ffffff; + border-radius: 0; + background: #000000; + color: #ffffff; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.theme-key-ascii .admin-badge::before, +.theme-key-ascii .admin-cn-badge::before { + content: "["; +} + +.theme-key-ascii .admin-badge::after, +.theme-key-ascii .admin-cn-badge::after { + content: "]"; +} + +/* ---------- Links ---------- */ + +.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]), +.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item) { + color: #ffffff; + text-decoration: underline; + text-underline-offset: 2px; +} + +.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):hover, +.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item):hover { + background: #ffffff; + color: #000000; + text-decoration: none; +} + +/* ---------- Selection ---------- */ + +.theme-key-ascii ::selection { + background: #ffffff; + color: #000000; +} + +/* ---------- Scrollbars ---------- */ + +.theme-key-ascii ::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +.theme-key-ascii ::-webkit-scrollbar-track { + background-color: #000000; + background-image: repeating-linear-gradient( + 0deg, + #ffffff 0, + #ffffff 1px, + transparent 1px, + transparent 4px + ); +} + +.theme-key-ascii ::-webkit-scrollbar-thumb { + background: #ffffff; + border: 1px solid #000000; +} + +.theme-key-ascii ::-webkit-scrollbar-thumb:active { + background: #b0b0b0; +} + +.theme-key-ascii ::-webkit-scrollbar-corner { + background: #000000; +} + +/* ---------- Lucide icons: render as crisp white outlines ---------- */ + +.theme-key-ascii svg.lucide, +.theme-key-ascii svg[class*="lucide-"] { + color: #ffffff !important; + stroke: #ffffff !important; + fill: none !important; + stroke-width: 1.75; + filter: none; +} + +.theme-key-ascii .btn-primary svg.lucide, +.theme-key-ascii .bottom-nav button.active svg.lucide, +.theme-key-ascii .period-card.active svg.lucide, +.theme-key-ascii .method-card.active svg.lucide, +.theme-key-ascii .option-row.active svg.lucide, +.theme-key-ascii .admin-btn-primary svg.lucide, +.theme-key-ascii .admin-nav-item.active svg.lucide, +.theme-key-ascii .admin-tabs-trigger[data-state="active"] svg.lucide, +.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide { + color: #000000 !important; + stroke: #000000 !important; +} + +/* ---------- Toast / language select polish ---------- */ + +.theme-key-ascii .toast { + background: #000000; + border: 1px solid #ffffff; + color: #ffffff; +} + +.theme-key-ascii .language-select-item { + border-radius: 0; +} + +.theme-key-ascii .language-select-item[data-highlighted], +.theme-key-ascii .language-select-item[data-selected] { + background: #ffffff; + color: #000000 !important; +} + +/* ---------- Headings: stronger console feel ---------- */ + +.theme-key-ascii h1, +.theme-key-ascii h2, +.theme-key-ascii h3, +.theme-key-ascii h4 { + font-family: var(--font-mono); + letter-spacing: 0; + text-transform: none; +} + +.theme-key-ascii .admin-header { + background: #000000; + border-bottom: 1px solid #ffffff; + color: #ffffff; +} + +.theme-key-ascii .admin-header-title h2, +.theme-key-ascii .admin-header-title small { + color: #ffffff; +} + +/* Make any element with role progressbar but no inner span show animated stripes. */ +.theme-key-ascii [role="progressbar"]:not(.progress) { + background: + repeating-linear-gradient( + 90deg, + #ffffff 0, + #ffffff 6px, + #000000 6px, + #000000 8px + ); + animation: ascii-scan 0.6s linear infinite; + border: 1px solid #ffffff; + border-radius: 0; + color: #000000; +} + +/* ============================================================ + * Console-themed extras + * ============================================================ */ + +/* ---------- ASCII skeletons ---------- * + * Subtle dark shimmer with a single bright scan line moving across. + */ + +@keyframes ascii-skeleton-scan { + 0% { background-position: -120% 0; } + 100% { background-position: 220% 0; } +} + +.theme-key-ascii .ui-skeleton, +.theme-key-ascii .admin-skeleton, +.theme-key-ascii .skeleton-line, +.theme-key-ascii .skeleton-dot, +.theme-key-ascii .skeleton-pay-button, +.theme-key-ascii .ui-skeleton-line, +.theme-key-ascii .admin-skeleton-line, +.theme-key-ascii .admin-skeleton-line-strong, +.theme-key-ascii .admin-skeleton-line-soft, +.theme-key-ascii .admin-skeleton-line-short, +.theme-key-ascii .admin-skeleton-line-tiny, +.theme-key-ascii .ui-skeleton-title, +.theme-key-ascii .ui-skeleton-short, +.theme-key-ascii .ui-skeleton-tiny, +.theme-key-ascii .ui-skeleton-badge, +.theme-key-ascii .admin-skeleton-badge, +.theme-key-ascii .admin-skeleton-avatar, +.theme-key-ascii .admin-stat-skeleton-card, +.theme-key-ascii .admin-stat-skeleton-wide, +.theme-key-ascii .admin-cn-card-skeleton--tall { + border-radius: 0 !important; + border: 1px solid #ffffff !important; + background-color: #050505 !important; + background-image: linear-gradient( + 90deg, + transparent 0%, + transparent 40%, + rgba(255, 255, 255, 0.18) 50%, + transparent 60%, + transparent 100% + ) !important; + background-size: 200% 100% !important; + background-repeat: no-repeat !important; + color: #ffffff !important; + animation: ascii-skeleton-scan 1.6s linear infinite !important; +} + +.theme-key-ascii .admin-skeleton-avatar { + width: 32px !important; + height: 32px !important; +} + +/* ---------- Empty / loading state console message ---------- */ +.theme-key-ascii .admin-empty { + position: relative; +} + +.theme-key-ascii .admin-empty::before { + content: "$ tail -f /var/log/empty.log"; + display: block; + font-family: var(--font-mono); + color: var(--muted); + margin-bottom: 8px; + letter-spacing: 0; +} + +/* ---------- Buttons: glitch on hover ---------- */ + +@keyframes ascii-glitch { + 0%, 100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); } + 20% { transform: translate(-1px, 0); clip-path: inset(20% 0 50% 0); } + 40% { transform: translate(1px, 0); clip-path: inset(40% 0 30% 0); } + 60% { transform: translate(-1px, 0); clip-path: inset(10% 0 70% 0); } + 80% { transform: translate(1px, 0); clip-path: inset(60% 0 10% 0); } +} + +.theme-key-ascii .btn:hover:not(:disabled)::after, +.theme-key-ascii .admin-btn:hover:not(:disabled)::after { + content: attr(data-label, ""); + pointer-events: none; +} + +/* Disable glitch text duplication if the button has no data-label. + * Apply a subtle scanline overlay instead, which is content-agnostic. */ +.theme-key-ascii .btn, +.theme-key-ascii .admin-btn, +.theme-key-ascii .admin-nav-item, +.theme-key-ascii .bottom-nav button { + overflow: hidden; +} + +.theme-key-ascii .btn:hover:not(:disabled)::before, +.theme-key-ascii .admin-btn:hover:not(:disabled)::before, +.theme-key-ascii .admin-nav-item:hover::before, +.theme-key-ascii .bottom-nav button:hover::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient( + 0deg, + rgba(0, 0, 0, 0.4) 0, + rgba(0, 0, 0, 0.4) 1px, + transparent 1px, + transparent 3px + ); + animation: ascii-glitch 0.6s steps(1) infinite; + z-index: 1; +} + +/* ---------- Bottom nav active markers "> item <" ---------- */ + +.theme-key-ascii .bottom-nav button.active::before, +.theme-key-ascii .admin-nav-item.active::before { + content: ">"; + position: absolute; + left: 6px; + top: 50%; + transform: translateY(-50%); + font-family: var(--font-mono); + color: #000000; + animation: ascii-blink 1s steps(1) infinite; + z-index: 2; +} + +.theme-key-ascii .bottom-nav button.active::after, +.theme-key-ascii .admin-nav-item.active::after { + content: "<"; + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + font-family: var(--font-mono); + color: #000000; + animation: ascii-blink 1s steps(1) infinite; + animation-delay: 0.5s; + z-index: 2; +} + +.theme-key-ascii .bottom-nav button.active, +.theme-key-ascii .admin-nav-item.active { + position: relative; +} + +/* On the mobile bottom-bar (compact) hide the markers to avoid overlap. */ +@media (max-width: 1023px) { + .theme-key-ascii .bottom-nav button.active::before, + .theme-key-ascii .bottom-nav button.active::after { + content: none; + } +} + +/* ---------- ASCII block progress fill ---------- * + * The actual fill renders as alternating █ blocks via the existing span + * gradient. We also overlay a slow scanning highlight to make it feel + * "live", and add a soft typed counter to the right edge. + */ + +/* (duplicate progress fill rules removed — see definition above) */ + +/* ---------- Headings: subtle CRT glitch on hover ---------- */ + +@keyframes ascii-heading-jitter { + 0%, 92%, 100% { transform: translate(0, 0); } + 93% { transform: translate(-1px, 0); } + 94% { transform: translate(1px, 0); } + 95% { transform: translate(0, -1px); } + 96% { transform: translate(0, 1px); } +} + +.theme-key-ascii h1, +.theme-key-ascii h2, +.theme-key-ascii h3, +.theme-key-ascii .login-brand h1, +.theme-key-ascii .admin-sidebar-brand strong, +.theme-key-ascii .admin-card-head h2, +.theme-key-ascii .admin-card-head h3 { + display: inline-block; + animation: ascii-heading-jitter 7s steps(1) infinite; +} + +/* ---------- App-shell boot banner ---------- * + * A non-blocking strip at the very top of the viewport that displays a + * typed "booting…" line, then settles. Pure CSS so it cannot interfere + * with any DOM. The animation runs once on mount. + */ + +@keyframes ascii-boot-type { + 0% { width: 0; } + 85% { width: 28ch; } + 100% { width: 28ch; } +} + +@keyframes ascii-boot-fade { + 0%, 70% { opacity: 1; } + 100% { opacity: 0; visibility: hidden; } +} + +.theme-key-ascii.app-shell::after { + content: "$ remnawave --start --tty=0"; + position: fixed; + top: 0; + left: 0; + z-index: 9999; + display: block; + padding: 2px 8px; + max-width: 28ch; + overflow: hidden; + white-space: nowrap; + background: #000000; + color: #ffffff; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.5; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + pointer-events: none; + animation: + ascii-boot-type 1.6s steps(28) 1 both, + ascii-boot-fade 3s linear 1.6s 1 forwards; +} + +/* ---------- Toggle / checkbox squareification (best-effort) ---------- */ + +.theme-key-ascii input[type="checkbox"], +.theme-key-ascii input[type="radio"] { + appearance: none; + -webkit-appearance: none; + width: 1em; + height: 1em; + border: 1px solid #ffffff; + background: #000000; + border-radius: 0 !important; + position: relative; + vertical-align: middle; + cursor: pointer; +} + +.theme-key-ascii input[type="checkbox"]:checked::after, +.theme-key-ascii input[type="radio"]:checked::after { + content: "x"; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-mono); + font-weight: 700; + color: #ffffff; + line-height: 1; +} + +/* ---------- Code-like "$ " prefix on toast messages ---------- */ + +.theme-key-ascii .toast::before { + content: "$ "; + color: #ffffff; + font-family: var(--font-mono); + font-weight: 700; +} + +/* ---------- Disabled state — strikethrough hatching ---------- */ + +.theme-key-ascii .btn:disabled, +.theme-key-ascii .admin-btn:disabled, +.theme-key-ascii button:disabled { + background-image: repeating-linear-gradient( + -45deg, + transparent 0, + transparent 4px, + rgba(255, 255, 255, 0.18) 4px, + rgba(255, 255, 255, 0.18) 5px + ); + color: var(--dim) !important; + border-color: var(--dim) !important; + cursor: not-allowed; +} + +/* ============================================================ + * Square everything: drop all rounded corners on touched surfaces. + * ============================================================ */ + +.theme-key-ascii :is( + .card, .dialog-card, .toast, + .btn, .input, + .period-card, .method-card, .settings-row, .option-row, + .tariff-selected-card, .tariff-action-card, .tariff-warning-card, + .topup-carryover-note, .language-select-content, .language-select-item, + .language-select-trigger, .bottom-nav, .bottom-nav button, + .field-error-tooltip, + .admin-card, .admin-card-head, .admin-card-body, + .admin-stat-card, .admin-stat-skeleton-card, .admin-stat-skeleton-wide, + .admin-revenue-panel, .admin-empty, + .admin-tariff-card, .admin-toolbar-card, .admin-table-card, + .admin-panel-dash-card, + .admin-select-trigger, .admin-select-content, .admin-select-item, + .admin-cn-card, .admin-cn-badge, .admin-badge, + .admin-cn-card-skeleton--tall, + .admin-input, .admin-textarea, .admin-btn, .admin-chip, + .admin-tabs-trigger, .admin-tabs-list, + .admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle, + .admin-header, .admin-sidebar, .admin-sidebar-brand, + .admin-dialog, + .admin-theme-editor-section, + [data-slot="card"], [data-slot="card-header"], + [data-slot="card-content"], [data-slot="card-footer"] +), +.theme-key-ascii :is( + .card, .dialog-card, .toast, + .btn, .input, + .admin-card, .admin-card-head, .admin-card-body, + .admin-stat-card, .admin-revenue-panel, .admin-empty, + .admin-tariff-card, .admin-toolbar-card, .admin-table-card, + .admin-panel-dash-card, + .admin-select-trigger, .admin-select-content, + .admin-cn-card, + .admin-input, .admin-textarea, .admin-btn, + .admin-nav-item, .admin-tabs-trigger +) * { + border-radius: 0 !important; +} + +.theme-key-ascii img, +.theme-key-ascii .admin-avatar, +.theme-key-ascii .admin-skeleton-avatar { + border-radius: 0 !important; +} + +/* ============================================================ + * Console-style tables: cell borders, header underline, + * row separator using dashed line. + * ============================================================ */ + +.theme-key-ascii .admin-table, +.theme-key-ascii table { + border-collapse: collapse; + border: 1px solid #ffffff; + font-family: var(--font-mono); +} + +.theme-key-ascii .admin-table th, +.theme-key-ascii .admin-table td, +.theme-key-ascii table th, +.theme-key-ascii table td { + border: 1px solid #ffffff; + border-radius: 0 !important; + padding: 6px 10px; +} + +.theme-key-ascii .admin-table thead th, +.theme-key-ascii table thead th { + background: #000000; + border-bottom: 2px solid #ffffff; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.theme-key-ascii .admin-table tbody tr, +.theme-key-ascii table tbody tr { + border-bottom: 1px solid #ffffff; +} + +.theme-key-ascii .admin-table tbody tr:hover, +.theme-key-ascii table tbody tr:hover { + background: rgba(255, 255, 255, 0.07); +} + +.theme-key-ascii .admin-table tbody tr:hover td, +.theme-key-ascii table tbody tr:hover td { + color: #ffffff; +} diff --git a/bot/app/web/themes/ascii/theme.json b/bot/app/web/themes/ascii/theme.json new file mode 100644 index 0000000..cd89c99 --- /dev/null +++ b/bot/app/web/themes/ascii/theme.json @@ -0,0 +1,17 @@ +{ + "key": "ascii", + "names": { + "ru": "ASCII", + "en": "ASCII" + }, + "enabled": true, + "default": false, + "use_primary_accent": false, + "use_in_admin": true, + "css_file": "style.css", + "assets_version": 1, + "tokens": { + "color_scheme": "dark", + "style_preset": "ascii" + } +} diff --git a/bot/app/web/themes/dark/theme.json b/bot/app/web/themes/dark/theme.json new file mode 100644 index 0000000..d620dcb --- /dev/null +++ b/bot/app/web/themes/dark/theme.json @@ -0,0 +1,27 @@ +{ + "key": "dark", + "names": { + "ru": "Темная", + "en": "Dark" + }, + "enabled": true, + "default": true, + "use_primary_accent": true, + "use_in_admin": true, + "assets_version": 1, + "tokens": { + "color_scheme": "dark", + "bg": "#03070b", + "panel": "#111820", + "panel_2": "#0b1118", + "panel_3": "#17212b", + "border": "rgba(255, 255, 255, 0.12)", + "border_strong": "rgba(255, 255, 255, 0.2)", + "text": "#f2f7f4", + "muted": "#a9b4b0", + "dim": "#68736f", + "danger": "#ff6b6b", + "blue": "#2d9cff", + "radius": "8px" + } +} diff --git a/bot/app/web/themes/light/style.css b/bot/app/web/themes/light/style.css new file mode 100644 index 0000000..1317e33 --- /dev/null +++ b/bot/app/web/themes/light/style.css @@ -0,0 +1,46 @@ +.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; + --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 16px 38px rgba(15, 23, 42, 0.08); + --shadow-strong: 0 24px 64px rgba(15, 23, 42, 0.16); + --shadow-popover: 0 18px 34px rgba(15, 23, 42, 0.14); + --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: #94a3b8; +} + +.theme-key-light.app-shell { + background: var(--bg) !important; +} + +.theme-key-light .phone-screen { + background: var(--bg); +} diff --git a/bot/app/web/themes/light/theme.json b/bot/app/web/themes/light/theme.json new file mode 100644 index 0000000..a228433 --- /dev/null +++ b/bot/app/web/themes/light/theme.json @@ -0,0 +1,16 @@ +{ + "key": "light", + "names": { + "ru": "Светлая", + "en": "Light" + }, + "enabled": true, + "default": false, + "use_primary_accent": true, + "use_in_admin": true, + "css_file": "style.css", + "assets_version": 1, + "tokens": { + "color_scheme": "light" + } +} diff --git a/bot/app/web/themes/windows95/icons/README.md b/bot/app/web/themes/windows95/icons/README.md new file mode 100644 index 0000000..83e5f70 --- /dev/null +++ b/bot/app/web/themes/windows95/icons/README.md @@ -0,0 +1,9 @@ +Windows 95 theme button icons +============================= + +These PNG icons were sourced from the Windows 98 Icon Viewer: + +https://win98icons.alexmeub.com/ + +Original filenames were normalized for theme CSS usage. They are loaded by +`bot/app/web/themes/windows95/style.css` through `/webapp-theme-assets/...`. diff --git a/bot/app/web/themes/windows95/icons/arrow-left.png b/bot/app/web/themes/windows95/icons/arrow-left.png new file mode 100644 index 0000000000000000000000000000000000000000..d08f5e96bcebec55887b39f029fd915e9cefc652 GIT binary patch literal 610 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnF3?v&v(vJfvmUKs7M+SzC{oH>NSwWJ?9znhg z3{`3j3=J&|48MRv4KElNN(~qoUL`OvSj}Ky5HFasE6@fgp&sB9;tEpG&~V_ufrbVk z3&dgg4+0DfKm_Cic_607#P3W%4r57>UoeBivm0q3PLj8~3%gii$7>*my~NYkmHiQ$ z6oZcZ_+ptQE9i(`n!`O=Gf{aPFZ4isjrjCthFGj*~A@0N1@1qGbb;?7CDXI*o2 zhG|T?#**jTfvOAkkx!0BwrQ+lykzpp*oS9JliZfiYkW3dxnQ-r zEqfQsWfs@mTJfJ6od@T#1?FAd=dobYly{#6S%j2sZwuv|ewSZ%Mayj)n+C6nWPVny z1)J=fdmaQt+St5nHIC!{`tXLXN%FzD`VEVs-;49i?w<9?`|^s=TottzhRdHC{`%;* zDNVU+ULE|{|Kt5X3>GUo7ARZIeF_Xo)e_f;l9a@fRIB8oR3OD*WMF8aYhb2pWENsz vU}a)rWo)QxU}9xpAh=6G3PnS1eoAIqC2kD~&u1(HYGCkm^>bP0l+XkK{50WP literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/arrow-right.png b/bot/app/web/themes/windows95/icons/arrow-right.png new file mode 100644 index 0000000000000000000000000000000000000000..422537f439b8901377a8b80064ef83dec04be08c GIT binary patch literal 423 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFS2MYfG{~t&IMIrQ!X%R_4QO1%WzhDN3XE)M7oFs2|7ml`jQ>%a+_7YEDSN2D2 zQVcrslUq$RfkFkIE{-7_*OLNGi-_8v-#!^9D07`YKZlV!w8dHS>f5L3UI&Au zZ})pQNIYt~t-a*nw`tsMT~09padCVMHY=1Gmjs_W1+-hW#5JNMC9x#cD!C{XNHG{0 z7+UBWnCTjsg%}uEnV47^8|oUESQ!`y?oyCK(U6;;l9^VCTSLP08Owkg7(8A5T-G@y GGywp$b%qN7 literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/bitcoin.png b/bot/app/web/themes/windows95/icons/bitcoin.png new file mode 100644 index 0000000000000000000000000000000000000000..a9b721218f67a85d127c464eab5bd2ff11e98489 GIT binary patch literal 395 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFO`~`P`^#J8V&xy1i|n|jK>ZF$JyT%Xt# zdDZrJ8F&{LwpTU)ty3*=jVMV;EJ?LWE=mPb3`Pcq7PgTe~DWM4fdtQEo literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/check.png b/bot/app/web/themes/windows95/icons/check.png new file mode 100644 index 0000000000000000000000000000000000000000..78e209406d19be2b16425cff49f53077f85c3e19 GIT binary patch literal 340 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!93?!50ihlx9Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBD8Uop6XFV_L7;)*|Ns9j z8yAWJ*^DJYe!&b5&u*lFI7!~_F3f^FJ|NrJOFVsD*&ne8Gb)<}EIk$u6teJiaSY+O zo}A#oaFKzH?bw>7JUlt8!z>v(Yx#JBR)6zl5Za$`=4>s(?=B zS}5|TNL0KWXohNuYeY#(Vo9o1a#1RfVlXl=w9qv$(KWOTF)*|;HnlP}0dlPj49wkT g1AT|2AvZrIGp!Q01~+Ntk3bC!p00i_>zopr06;}o-2eap literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/chevrons.png b/bot/app/web/themes/windows95/icons/chevrons.png new file mode 100644 index 0000000000000000000000000000000000000000..cb925ce5c1a131ae830f92c6a25de19f0858a16f GIT binary patch literal 375 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFEal|aXt3TLEZ)f9_K*!SccOE3_byx+9{0R;@h_H3LRQr?;+Q7#~|(c&3ebO zRdP`(kYX@0FtpG$Fw-?M2{AIZGBC62_Vx`_3TBUAY)08UoeBivm0q3PLj8~3+s>km&bt|_7YEDSN2D2 zQVhCgCv@hr0EG%YT^vI=t|uoXh_Em!vaz`(Cnu|`YdCvy9^qhR6i{BX#rTxS5f1&i zk~~Ro_zFuoZ|WX7@OGD}iAmLJf##AEc?todNeOp&MQ*aB7) z;n+lF_cCn*2UiE43A211W;t?lG%zvLTvFV;C&u;|&~nuh*NBpo#FA92@P5Lb|bhKBzP4F5rZ z;lP0d3=KdrARmGo%rq~66f>3t`2{mLJiCzw;v{*yyD$s#_-x;Tb#TsJ*q$k(92)A~?dy+NRP@k6#Hv$fR~*mgEv^j&`L-l4l^gQGnD zEZ5HsesEr5@l^4ME=~#7iD|s59NNW)H`u#Y^6$Rq-zcAGK6A^}FF`Bob9k1i)&J75 z{L;DP!GZL$Ju!SQi~{{c-+mJ);FYh;6T1=}$dV1TUA4qDq9i4;B-JXpC>2OC7#SE^ z=o*;m8kvL`8Cw~cTA3Q?8kkra7<}EkeJYBE-29Zxv`X9>S|0Fg0yQvry85}Sb4q9e E0P^^O{r~^~ literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/error.png b/bot/app/web/themes/windows95/icons/error.png new file mode 100644 index 0000000000000000000000000000000000000000..5d046a3881257e9d04975fd3b2b1d2302feb6aa3 GIT binary patch literal 372 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFYWe&iKOOKPuhx*R9wz-Ez+F)*+)F|sl-)ip4&GB9|gR~wF^AvZrIGp!Q0hRMZ8 RUjsETc)I$ztaD0e0syTmZ+8Fy literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/file-text.png b/bot/app/web/themes/windows95/icons/file-text.png new file mode 100644 index 0000000000000000000000000000000000000000..c1ab35e8dce4a852ef06f7621b5db266fae8ebed GIT binary patch literal 388 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF21sKVp+&&^1u> zw=e_>#e2FqhHzZh?LElbV8C;vDS}~w6+`IF2G$O#dp1&TFa9Y%*!Cm!Zmfvl%rJo) zei5@)WO12r>bdikn=a1u++cX}=&_Xgi5^K=|DSBLyXbUDe#)t-&V3zcM0cfRTsr-n zIl4nk)X1CX9?&Y)64!{5l*E!$tK_0oAjM#0U}&LhV5Vzi7GhvvWnyY&0_2)l85kt0 gvcE&okei>9nO2EggFwZ8P_Qz1y85}Sb4q9e05XJjmH+?% literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/folder.png b/bot/app/web/themes/windows95/icons/folder.png new file mode 100644 index 0000000000000000000000000000000000000000..437efbf3b6f7f4a6e4513567bf122c9da9548981 GIT binary patch literal 378 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF# z5u1cKqcCS>{A8ezzo(012*>s0ga&3uM@56P8IPn^FeG&>UAmN$i_6HMV@HF-45nre z4UQ>}5)U65ObqJqu!vbAqQoS^!EWf)xIpFvgCqwF7mtru!h!=0j10$IB&F8#{aykz zTD8PAq9i4;B-JXpC>2OC7#SE^=o*;m8kvL`8Cw|`S{YjE8kkra7`$zXIfkMkH$Npa XtrE9}IX9nx{KMes>gTe~DWM4f9$j!V literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/gift.png b/bot/app/web/themes/windows95/icons/gift.png new file mode 100644 index 0000000000000000000000000000000000000000..06979223c2a9e0d7ec68ebeb55c61a38b7acd708 GIT binary patch literal 424 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF2_Vx`_3TBUAY)08UoeBivm0q3PLj8~3+s>km&bt|_7YEDSN2D2 zQVhCgCv@hr0EG%YT^vI=t|uoXh_Em!vaz`(Cnu|`YdCvy9^qhR6i{BX#rTxS5f1&i zk~~Ro_zFuoZ|WX7@OGD}iAmLJf##AEc?todNeOp&MQ*aB7) z;n+lF_cCn*2UiE43A211W;t?lG%zvLTvFV;C&u;|&~nuh*NBpo#FA92NSwWJ?9znhg z3{`3j3=J&|48MRv4KElNN(~qoUL`OvSj}Ky5HFasE6@fgp%~y3;tEpG(D48N{{sgO zF#HE{KmrUvRs)E|@c+M}gxEVEgRvyYFPOpM*^M+1C&}C0g(;1@CI!f0FY)wsWq-sb z#bBZI)Omd_P}Oogu^H>6)Xd-c1YEt>ZvC$E?)LxB?mKUP#eUCmYGA|M z+0DR^RxNRjC`m~yNwrEYN(E93Mh1o!x&~&tMrI)f23DqKR>mf}1}0Vp25mnX98fgm b=BH$)RpQq0eaVhLKn)C@u6{1-oD!M<^#kqm literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/help.png b/bot/app/web/themes/windows95/icons/help.png new file mode 100644 index 0000000000000000000000000000000000000000..1aa4921a70af7591a201d2fe2810f3d693323b37 GIT binary patch literal 364 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF8RSQ6wH%;50sMjD8d21sKVp**=MsPWzzJwQ zgO{g^V+hCfn4(Dq~~{RB%4x8p3jngVETbhMbVI(pOTqYiCe>rEDlYe1_n=8KbLh* G2~7Y@*k33B literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/home.png b/bot/app/web/themes/windows95/icons/home.png new file mode 100644 index 0000000000000000000000000000000000000000..a713e61d4ea75608137884be750683b87b0476af GIT binary patch literal 390 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF|fuNuuqZY#+mIXZG;sS?naGc<3?pQIqLE_l4V@KcS zB?y*wcL{CqD6FpD%^cuzl$FIn$ssazLWfY(!Z^MZ4(6p-q8oS?SaC3OILwk^@H-&7 zX2vE{exNm~C9V-ADTyViR>?)FK#IZ0z|cb1z)aW3B*e(r%FxKl6v#EPGB9|`VC#*d dAvZrIGp!Q0hAFYF-+>w!JYD@<);T3K0RUz8Zo~ip literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/info.png b/bot/app/web/themes/windows95/icons/info.png new file mode 100644 index 0000000000000000000000000000000000000000..d540eb7dcdb9bed8d94e275eb04e37ed6365cd67 GIT binary patch literal 385 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF21sKVp+&(9)a2IC&~i zDBjb>F@)oK=-Gq32NXD1FXYEKNLf4Be_dy&mvHC6-*U}cUoO-?WMyIyn8?sQ;R=sM zQOUX98!H*v!-LFX%#tGK{y4sKVps0n34Z2T8L>zTxI1#`n zY^YhzV%Q`idig*P&+{!W71)AjeEzb@X+qXjm*nK~30ccj1gC2%C_Z&%;+dzQxQvI9 zO>Dx1B7q6dIj~>+Ba;v#V=F^5D`OK~0~0F)gSRUG4xwns%}>cptHiBgPNiENPy>Uf LtDnm{r-UW|7-xN6 literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/lock.png b/bot/app/web/themes/windows95/icons/lock.png new file mode 100644 index 0000000000000000000000000000000000000000..224052ba0df61d9b4de4bb4093401cc40332fee3 GIT binary patch literal 356 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFAf83^RCmw5WRvOi*z5Eqc$D-j5^hQZ0x z#W95AdU8TSf`CX01G8JIGqZv7iH1OlGXiYNB5ey6Y+&$Mt02(O;u6!qaE61G$$(iy z*~P&{VA)*>7KLq1j0_PUxCAr5|Je#OO|`@|q9i4;B-JXpC>2OC7#SE^=o*;m8kvL` v8Cw~eSs7XC8kkra7&t`EpM#7-sS literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/megaphone.png b/bot/app/web/themes/windows95/icons/megaphone.png new file mode 100644 index 0000000000000000000000000000000000000000..a697102535d82469f2aea9399585c272b031655a GIT binary patch literal 393 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFE;PA<-^rAi=YeM|KF%KGhP}h?11Vl2ohYqEsNoU}Ruup=)5KYh)5)WNc+?sMwD1!`dMboFyt=akR{0J?{8S^xk5 literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/paintbrush.png b/bot/app/web/themes/windows95/icons/paintbrush.png new file mode 100644 index 0000000000000000000000000000000000000000..b8957002c3ae6852fc3d70f5fa3ecda17dd8fa42 GIT binary patch literal 474 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJOS+@4BLl<6e(pbstU$g(vPY0F z14ES>14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>(?JYe*FLc|Ftu2oCm69ED7=pW^j0R17sd!lDE5y#O%ygIUt9<#M9T6{SliKgRa>L zow+PPp?Xgj#}J9jq32#pH7W40J(zoPQ3%K0|JJ!aM!)6$y-96mWcbG9PTj4yQ!0 zIT)_aj|;P1xb^HR^En%WZ)?R_XN2!AW4W^L*+$J1@pd*stO3FrM_qtUQ7v(eC`m~y zNwrEYN(E93Mh1o!x&~&tMrI)f2397fR;DJp1}0Vp23h*KAh#nlmdKI;Vst0DDE7_y7O^ literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/payment-card.png b/bot/app/web/themes/windows95/icons/payment-card.png new file mode 100644 index 0000000000000000000000000000000000000000..a9b721218f67a85d127c464eab5bd2ff11e98489 GIT binary patch literal 395 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFO`~`P`^#J8V&xy1i|n|jK>ZF$JyT%Xt# zdDZrJ8F&{LwpTU)ty3*=jVMV;EJ?LWE=mPb3`Pcq7PgTe~DWM4fdtQEo literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/print.png b/bot/app/web/themes/windows95/icons/print.png new file mode 100644 index 0000000000000000000000000000000000000000..04e2839a4bc7f3d2464806c7c18eaf3297c04e86 GIT binary patch literal 461 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJOS+@4BLl<6e(pbstU$g(vPY0F z14ES>14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>*eL;2M!!y_|K3wli|#nGyfSF&M+8Hn>KCc%$W-oEZMYa z)BpefUD@^q097-V1o;IsI6S+N2I3@nySs=?x_{CX$YC$>^mS!_#3sdHAYQHc&%Q#N&VoZ7o0Y@hb6s8q45qUM=v zPs!g@PO+BMNMl+0kS}fGapTK#+5$MzHea_kE~x0@cy{Ja`3I2~tn#%Ve0K0&?w>d# zUxA&O@r$C#2Ak^eqX+)Dd=;*H=zr8QUT^CM?qFV4i3r9Gu|SuombgZgq$HN4S|t~y z0x1R~149d412bJCvk(IVD-#PVBV%0y6DtFQfXx?`Q8eV{r(~v8;?|(}E^|Im1B0il KpUXO@geCxW&7pn( literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/refresh.png b/bot/app/web/themes/windows95/icons/refresh.png new file mode 100644 index 0000000000000000000000000000000000000000..be089258633da7b7a3805369996ae31267a7e2d4 GIT binary patch literal 327 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFp1!W^kJzLbbWH~>+BeM_#11l3#D^p`#0~0F)gY_N3T__rI^HVa@DsgML TclEUyPy>UftDnm{r-UW|Ii*)u literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/save.png b/bot/app/web/themes/windows95/icons/save.png new file mode 100644 index 0000000000000000000000000000000000000000..2e70ec12d62acf256662afe3d1ae0f4e106e8c27 GIT binary patch literal 411 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFd9# zKJ)*7Ap8IS|7U>c%$WlR4s6=A=|2O5PRUAPpgN|KAirP+hO)_LEr2}6ByV>Yw#2j@ zwLlJgiKnkC`y)09aSla+oUKtnp)^ky#}JO|wkLLTHaG~hJapV3;4Y%~eP(vvCeP1X#Sbw4Ed6gOI(e8H!lvJ%3oOg<>TRk7?)FK#IZ0 zz|cb1z)aW3B*e(r%D}?P*jU%V#LB?n`w@M66b-rgDVb@NxHYuRoZbx7z~JfX=d#Wz Gp$P!eZ-cD> literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/search.png b/bot/app/web/themes/windows95/icons/search.png new file mode 100644 index 0000000000000000000000000000000000000000..0102a91d72df614e6f5452a3a6f399197dc02e36 GIT binary patch literal 395 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF`sfBQ_}pV?L97 zDUdleDd#KJ<*kGSYQzl zq`CFKK@G!11)Gyh2770+{bakpA?v#0;fNWV4}~1~G-svbs!6^_YhnyT6_QSVojL2# zT}G3dph*I=%U=Gx1+-4J#5JNMC9x#cD!C{XNHG{07+UBWnCTjsg%}uEnHpLdn&=vs lSQ!|oG03=}Xvob^$xN%ntsy*i$q%3g22WQ%mvv4FO#m&Uc)S1r literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/send.png b/bot/app/web/themes/windows95/icons/send.png new file mode 100644 index 0000000000000000000000000000000000000000..67b9ae1b8a9989af8343e7b30c1a84d67571d495 GIT binary patch literal 415 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF%UlaPd6su>XOBdjo_%3GYu%#sN1J0@1G6i?u3q9`y|&08 zVw;aeE3e2WowIk%TN=dHpZy+m(O$JiY+=N6BN?FOswJ)wB`Jv|saDBFsX&Us$iUD- z*T78I$Rxzb*vin{$^^(Yu`)0?-~4SNiiX_$l+3hB+#2@X^V0=tVDNPHb6Mw<&;$UN Cje~9g literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/settings.png b/bot/app/web/themes/windows95/icons/settings.png new file mode 100644 index 0000000000000000000000000000000000000000..0d56f79b7f67a488e418a0ad1da2fb172d24be5b GIT binary patch literal 393 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF+#H5MV7p{ATEt&;*LOk!d_k*BC|b^!|; z7Y{@8RgsgwCVjsPv`@9fHKHUXu_V>=^B}Z7#LWY8d@2d=o*+< l85peSmtKydAvZrIGp!Q0hMSibO9M49c)I$ztaD0e0sx4nbjAPx literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/shield.png b/bot/app/web/themes/windows95/icons/shield.png new file mode 100644 index 0000000000000000000000000000000000000000..8ce794a092e7addcabb5204a781fd402f71f787b GIT binary patch literal 422 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF@P5LX}#1Pvg-@c;k+ z0|yTL|Ig3>7Gq!lvKrJa3lf1`#*!evUYmi~;Jfj|y>iKnkC`y&=% zMrD1Ib9vK&LZzNAjv*Y^lM@m|5*ip7-8jyw99XgB$dV;T4(Q}$JX$66=tRP-X<`zO zgr1y85R!_z@$d>$N=vKgt{tlwBv-C%)zi}x5DZTa?&#ROaicQx;spyhW-Me1VVD+F zSRz3fdvm;95>X)@b(IHOE&T_to?)F zK#IZ0z|cb1z(m*3GQ_~p%GlJ(#2m=AGB7yB(&&exAvZrIGp!Q0hTV~tpMV+|JYD@< J);T3K0RU}wf)oG% literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/sliders.png b/bot/app/web/themes/windows95/icons/sliders.png new file mode 100644 index 0000000000000000000000000000000000000000..d3badce15a71a6c085cbe573fb619ef856c65091 GIT binary patch literal 478 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJOS+@4BLl<6e(pbstU$g(vPY0F z14ES>14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>c-DEnfz`?>9x21DqS(#{1nBNl@?2={n~fJv`6x@(DnUOMW$6mo7Ws=JL9Wheb(cnY>G_?{NAnxBKNZf%nXS{hqRMJxR1L%V2W(|41gAX$ zIz+X^HKHUXu_V>=^B}Z7#LWY8d@0{>l&C?85rbhsA{5U$jwj5 YOsmALL8f%>RG14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>|MK#3pqc{*4$Pd%aQE)r$B!R}hK63hetpxXP5=MT zocaHZudi>~nVDz)&pZPHGaFnF{RW!LR1)L|bYt1%vlc)eW0JSK3%C6BBL{#S_7YED zSN2D265?FKuX@`efI|JAE{-7*mqX7xiZvT>uv{pKGHq+fT($Dr@B5n%2a9i-{hy;n zV4c$r(GM(nAFqBr?3mD#?EX4f$#F)qn|;cXC9f1_vbe3-FJLO1l0RSVl=DJ%L#;i| zK673eJns3VHq&U6)_eijrngMantOX+PLNSwWJ?9znhg z3{`3j3=J&|48MRv4KElNN(~qoUL`OvSj}Ky5HFasE6@fgp%UN|;tEpG(7@2Z@c;k+ z0|yQ;{09pB2MT}yPzc0rShC@FI*?{83GxeOaCmkj4a7FMGaqH%uhgpJwF4kE|S`>ad)*YIi9WryPmtj}+y*iBq_@Zr)& zK?MdSR#r>g?->0M=MX*47q+8*-TR&;FYnad{{DFP`@Q8oe<$l4FU(uF_qPD&jekEX zCvY&_+2?bhm$j0AzIp;HBl{PD^6$bsM4Q%>Gky+c=~#JSk>&ov&8)0HV&h7bH^{vX z?w#;0K5@I8+7~M`+s;Xv%;-R zj%%4ND!_14Epd$~Nl7e8wMs5Z1yT$~28I^824=cOW+4U!R;FfF#>Tn^CRPRp&T)3< eP&DM`r(~v8;?`iq+*SnCz~JfX=d#Wzp$PyY$KBch literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/tag.png b/bot/app/web/themes/windows95/icons/tag.png new file mode 100644 index 0000000000000000000000000000000000000000..13364e304b5752a8ece817fa14760cc43420d8b1 GIT binary patch literal 392 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF?NMQuI!Ikgc+5M z70w&o01Cx=x;Tb#Tu)ADV07dVNDweG3j;!-L;*f;Zf{?2Hls=1moEi1@+>U*+GDa= zWrN3RS2hnVT^;4MqAH@(rio2so8|w_YgGUM literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/ticket.png b/bot/app/web/themes/windows95/icons/ticket.png new file mode 100644 index 0000000000000000000000000000000000000000..13364e304b5752a8ece817fa14760cc43420d8b1 GIT binary patch literal 392 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF?NMQuI!Ikgc+5M z70w&o01Cx=x;Tb#Tu)ADV07dVNDweG3j;!-L;*f;Zf{?2Hls=1moEi1@+>U*+GDa= zWrN3RS2hnVT^;4MqAH@(rio2so8|w_YgGUM literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/trash.png b/bot/app/web/themes/windows95/icons/trash.png new file mode 100644 index 0000000000000000000000000000000000000000..c4ca801629c4536e76cd014d03c1a1edceea6456 GIT binary patch literal 384 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFvs9>28llhsWz2A2T!B-MrAgUn6+ zUp{^FjM>X7vs3b&QGvlZ3*pamrOVY)n4|=QW~_gAvTp4`r|Ky;mpoy=6?K69%^$u? z-)^N%0@|cn;u=wsl30>zm0Xkxq!^403@vmG%yf;+LJSP7Of0PoOmq!QtPBh+J~zKZ d(U6;;l9^VCTZ891Yfz9fc)I$ztaD0e0sv}Qc3}Vj literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/user.png b/bot/app/web/themes/windows95/icons/user.png new file mode 100644 index 0000000000000000000000000000000000000000..fa18aafa3c0da1910e296156f65c6234273e5e7c GIT binary patch literal 419 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFeA)83oRAq;NQ~9^jeKFiCO3Lk$g$HB2Fog)}r4rKFuW zkQAk%v4mwxlBO<8mj<`Nj-~C)SG5==Gc%i8B;c!?X0Etn9HzsWQNHdlM`2{mLJiCzw;v{*yyD$s#_K53|Gffl+&borHAI2X*zx zB8CGK<-|61|9SAs*Z$O%*$SzKEWECNyDg5b_tJ9Zv2>qZ7}A?`TI5}p#gtOJ#)s#E zUbAian)Ubl-(w#bIG*yyI5Rdp0NSZq;u=wsl30>zm0Xkxq!^403@vmG%yf;+LJSP7 tOiiqe&2Eal|aox7(Aa8>K5A$5!1vw9N8gB6jIDVhssp7i#x~H8a!#U>GfbA8Y%I{UZqEElr zbyQn&@1kR?Q&pB2&39_*c=WXP(@Sx0PadN#&NGMXZ)frGZ@ndP0BE>siEBhjN@7W> zRdP`(kYX@0FtpG$Fw-?M3o$UTGBL6;G1oOPu`)0y-I!B{q9HdwB{QuOw+5CsR}KL+ OFnGH9xvX!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBD8U)v6XFV_{{zwg|LKw` zazGAaNswPKgTu2MX&_FLx4R2d8h1?!ki%Z$>Fdh=h)s$?N9|0*9z~##y{C&~2*-8R zo{hW=h8)cOhoo(OFY$cO^ez6WV`k3D<;E$FCGt-UGP9QRn|FTZI4v9~yk*+`hqKhO zOn%C)`OZAsj!Udhp^+bGlxm4 web.Response: return web.json_response({"ok": True}) @@ -10,6 +17,111 @@ 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") + + response = web.Response(body=body, content_type=content_type) + response.headers["Cache-Control"] = "public, max-age=3600" + return response + + def _resolve_webapp_logo_url(settings: Settings) -> str: raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url: @@ -628,9 +740,16 @@ 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 config = { "title": settings.WEBAPP_TITLE, "primaryColor": settings.WEBAPP_PRIMARY_COLOR, + "themesCatalog": public_themes_catalog_payload( + themes_catalog, + settings.WEBAPP_PRIMARY_COLOR or "#00fe7a", + enabled_only=True, + ), + "themesDir": settings.WEBAPP_THEMES_DIR, "logoUrl": cached["logo_url"], "logoEmoji": settings.WEBAPP_LOGO_EMOJI, "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, diff --git a/bot/app/web/webapp/routes.py b/bot/app/web/webapp/routes.py index ee78959..d6a5862 100644 --- a/bot/app/web/webapp/routes.py +++ b/bot/app/web/webapp/routes.py @@ -20,6 +20,8 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: webapp_animated_emoji_route, ) app.router.add_get("/subscription_webapp.css", css_asset_route) + app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route) + app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route) app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route) app.router.add_get("/subscription_webapp.js", js_asset_route) app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route) diff --git a/bot/app/web/webapp/serializers.py b/bot/app/web/webapp/serializers.py index 174cd53..9ae8300 100644 --- a/bot/app/web/webapp/serializers.py +++ b/bot/app/web/webapp/serializers.py @@ -1,6 +1,8 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from config.webapp_themes_config import public_themes_catalog_payload + async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]: settings: Settings = request.app["settings"] @@ -96,6 +98,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A stars_traffic_packages=cached["stars_traffic_packages"], ), "payment_methods": _serialize_payment_methods(settings, request.app), + "themes_catalog": public_themes_catalog_payload( + settings.webapp_themes_catalog, + settings.WEBAPP_PRIMARY_COLOR or "#00fe7a", + enabled_only=True, + ), "settings": { "support_url": settings.SUPPORT_LINK, "traffic_mode": bool(settings.traffic_sale_mode), diff --git a/config/settings.py b/config/settings.py index 31d8302..be1078a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -7,6 +7,10 @@ from pydantic import BaseModel, Field, ValidationError, computed_field, field_va from pydantic_settings import BaseSettings, SettingsConfigDict from config.tariffs_config import TariffsConfig, load_tariffs_config +from config.webapp_themes_config import ( + WebappThemesConfig, + resolved_webapp_themes_catalog, +) def _split_csv(value: Optional[str]) -> List[str]: @@ -351,6 +355,19 @@ class Settings(BaseSettings): WEBAPP_SERVER_PORT: int = Field(default=8081) WEBAPP_TITLE: str = Field(default="Моя подписка") WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a") + WEBAPP_THEMES_DIR: str = Field( + default="data/themes", + description=( + "Directory with per-theme folders. Each theme lives in " + "/theme.json with optional CSS/assets next to it." + ), + ) + WEBAPP_DEFAULT_THEME: Optional[str] = Field( + default=None, + description=( + "Override the descriptor-marked default theme when set to an existing theme key." + ), + ) WEBAPP_LOGO_URL: Optional[str] = Field(default=None) WEBAPP_LOGO_EMOJI: str = Field(default="🫥") WEBAPP_LOGO_EMOJI_FONT: str = Field( @@ -810,6 +827,15 @@ class Settings(BaseSettings): def tariffs_config(self) -> Optional[TariffsConfig]: return load_tariffs_config(self.TARIFFS_CONFIG_PATH) + @computed_field + @property + def webapp_themes_catalog(self) -> WebappThemesConfig: + return resolved_webapp_themes_catalog( + primary_accent=self.WEBAPP_PRIMARY_COLOR or "#00fe7a", + env_default_theme=self.WEBAPP_DEFAULT_THEME, + theme_dir=self.WEBAPP_THEMES_DIR, + ) + @computed_field @property def referral_bonus_inviter(self) -> Dict[int, int]: diff --git a/config/webapp_themes_config.py b/config/webapp_themes_config.py new file mode 100644 index 0000000..e301498 --- /dev/null +++ b/config/webapp_themes_config.py @@ -0,0 +1,586 @@ +"""File-backed catalog of Web App UI themes.""" + +from __future__ import annotations + +import json +import logging +import re +from pathlib import Path +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, Field, model_validator + +logger = logging.getLogger(__name__) + +ColorScheme = Literal["light", "dark"] + + +class ThemeTokens(BaseModel): + """CSS design tokens for the subscription Mini App shell.""" + + model_config = {"extra": "ignore"} + + color_scheme: ColorScheme = "dark" + style_preset: Optional[str] = None + accent: Optional[str] = None + bg: Optional[str] = None + panel: Optional[str] = None + panel_2: Optional[str] = None + panel_3: Optional[str] = None + border: Optional[str] = None + border_strong: Optional[str] = None + text: Optional[str] = None + muted: Optional[str] = None + dim: Optional[str] = None + danger: Optional[str] = None + blue: Optional[str] = None + radius: Optional[str] = None + font_sans: Optional[str] = None + font_logo: Optional[str] = None + font_mono: Optional[str] = None + admin_bg: Optional[str] = None + admin_surface: Optional[str] = None + admin_surface_2: Optional[str] = None + admin_elev: Optional[str] = None + admin_border: Optional[str] = None + admin_border_strong: Optional[str] = None + admin_text: Optional[str] = None + admin_muted: Optional[str] = None + admin_dim: Optional[str] = None + + +class WebappTheme(BaseModel): + """Single theme descriptor loaded from WEBAPP_THEMES_DIR//theme.json.""" + + model_config = {"extra": "ignore"} + + key: str = Field(min_length=1, max_length=64) + names: Dict[str, str] = Field(default_factory=dict) + enabled: bool = True + default: bool = False + use_primary_accent: bool = True + use_in_admin: bool = True + css_file: Optional[str] = None + assets_version: int = 1 + tokens: ThemeTokens = Field(default_factory=ThemeTokens) + + +class WebappThemesConfig(BaseModel): + """Runtime catalog assembled from individual theme descriptor files.""" + + model_config = {"extra": "ignore"} + + default_theme: str = "dark" + themes: List[WebappTheme] = Field(default_factory=list) + + @model_validator(mode="after") + def _validate_default_and_keys(self) -> WebappThemesConfig: + keys = [t.key for t in self.themes] + if len(keys) != len(set(keys)): + raise ValueError("duplicate theme keys") + if self.themes and self.default_theme not in keys: + raise ValueError("default_theme must match a theme key") + return self + + def theme_by_key(self, key: str) -> Optional[WebappTheme]: + for theme in self.themes: + if theme.key == key: + return theme + return None + + def enabled_themes(self) -> List[WebappTheme]: + return [theme for theme in self.themes if theme.enabled] + + +DEFAULT_THEME_KEYS = ("dark", "light", "windows95", "ascii") +THEME_DESCRIPTOR_FILENAME = "theme.json" +DEFAULT_THEMES_SOURCE_DIR = Path(__file__).resolve().parents[1] / "bot" / "app" / "web" / "themes" + + +def _safe_theme_key(value: str) -> Optional[str]: + key = str(value or "").strip() + if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key): + return key + return None + + +def _theme_dir_path(theme_dir: str | Path, key: str) -> Path: + safe_key = _safe_theme_key(key) + if not safe_key: + raise ValueError(f"invalid theme key: {key!r}") + return Path(theme_dir).expanduser() / safe_key + + +def _theme_file_path(theme_dir: str | Path, key: str) -> Path: + return _theme_dir_path(theme_dir, key) / THEME_DESCRIPTOR_FILENAME + + +def default_webapp_theme_css_files() -> Dict[str, str]: + """Read default theme CSS from repository files.""" + out: Dict[str, str] = {} + for key in DEFAULT_THEME_KEYS: + source_dir = DEFAULT_THEMES_SOURCE_DIR / key + for source_path in sorted(source_dir.rglob("*.css")): + rel_path = Path(key) / source_path.relative_to(source_dir) + try: + content = source_path.read_text(encoding="utf-8") + except OSError as exc: + logger.warning( + "Default webapp theme CSS source file is missing: %s (%s)", + source_path, + exc, + ) + continue + out[rel_path.as_posix()] = content if content.endswith("\n") else f"{content}\n" + return out + + +def default_webapp_theme_asset_file(rel_path: str | Path) -> Optional[tuple[bytes, str]]: + """Read a default theme asset from the repository theme folder.""" + relative = Path(rel_path) + if relative.is_absolute() or len(relative.parts) < 2 or ".." in relative.parts: + return None + source_path = (DEFAULT_THEMES_SOURCE_DIR / relative).resolve() + try: + source_path.relative_to(DEFAULT_THEMES_SOURCE_DIR.resolve()) + except ValueError: + return None + try: + return source_path.read_bytes(), source_path.suffix.lower() + except OSError: + return None + + +def default_webapp_theme_descriptors() -> Dict[str, Dict[str, Any]]: + """Read default theme descriptors from repository files.""" + out: Dict[str, Dict[str, Any]] = {} + for key in DEFAULT_THEME_KEYS: + source_path = DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME + try: + raw = json.loads(source_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + logger.warning( + "Default webapp theme descriptor is missing or invalid: %s (%s)", + source_path, + exc, + ) + continue + if not isinstance(raw, dict): + continue + safe_key = _safe_theme_key(str(raw.get("key") or source_path.parent.name)) + if safe_key: + raw["key"] = safe_key + out[safe_key] = raw + return out + + +def _theme_from_descriptor(path: Path, raw: Any) -> Optional[WebappTheme]: + if not isinstance(raw, dict): + logger.warning("Ignoring theme descriptor %s: expected JSON object", path) + return None + data = dict(raw) + data["key"] = data.get("key") or ( + path.parent.name if path.name == THEME_DESCRIPTOR_FILENAME else path.stem + ) + safe_key = _safe_theme_key(str(data["key"])) + if not safe_key: + logger.warning("Ignoring theme descriptor %s: invalid theme key %r", path, data["key"]) + return None + data["key"] = safe_key + try: + return WebappTheme.model_validate(data) + except ValueError as exc: + logger.warning("Ignoring theme descriptor %s: %s", path, exc) + return None + + +def load_webapp_theme_file(path: str | Path) -> Optional[WebappTheme]: + theme_path = Path(path).expanduser() + try: + raw = json.loads(theme_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Failed to load webapp theme descriptor from %s: %s", theme_path, exc) + return None + return _theme_from_descriptor(theme_path, raw) + + +def load_webapp_theme_dir(theme_dir: str | Path) -> List[WebappTheme]: + root = Path(theme_dir).expanduser() + if not root.exists(): + return [] + themes_by_key: Dict[str, WebappTheme] = {} + for path in sorted(root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}")): + if path.parent.name.startswith("_"): + continue + theme = load_webapp_theme_file(path) + if theme is None: + continue + if theme.key in themes_by_key: + logger.warning("Ignoring duplicate webapp theme key %s from %s", theme.key, path) + continue + themes_by_key[theme.key] = theme + return list(themes_by_key.values()) + + +def _config_with_synced_default_flags(config: WebappThemesConfig) -> WebappThemesConfig: + data = config.model_dump(mode="json", exclude_none=True) + default_theme = str(data.get("default_theme") or "dark") + themes = data.get("themes", []) + for theme in themes: + if isinstance(theme, dict): + theme["default"] = theme.get("key") == default_theme + + def _sort_key(item: tuple[int, Any]) -> tuple[int, int]: + idx, theme = item + if not isinstance(theme, dict): + return (len(THEME_DISPLAY_ORDER), idx) + try: + priority = THEME_DISPLAY_ORDER.index(str(theme.get("key") or "")) + except ValueError: + priority = len(THEME_DISPLAY_ORDER) + return (priority, idx) + + data["themes"] = [theme for _, theme in sorted(enumerate(themes), key=_sort_key)] + return WebappThemesConfig.model_validate(data) + + +THEME_DISPLAY_ORDER = ("dark", "light") + + +def _theme_sort_key(theme: WebappTheme, index: int) -> tuple[int, int]: + try: + priority = THEME_DISPLAY_ORDER.index(theme.key) + except ValueError: + priority = len(THEME_DISPLAY_ORDER) + return (priority, index) + + +def _sorted_themes(themes: List[WebappTheme]) -> List[WebappTheme]: + return [theme for _, theme in sorted( + ((_theme_sort_key(t, i), t) for i, t in enumerate(themes)), + key=lambda pair: pair[0], + )] + + +def _themes_config_from_list( + default_theme: Optional[str], + themes: List[WebappTheme], +) -> WebappThemesConfig: + keys = {theme.key for theme in themes} + descriptor_default = next( + (theme.key for theme in themes if theme.default and theme.key not in DEFAULT_THEME_KEYS), + None, + ) or next((theme.key for theme in themes if theme.default), None) + resolved_default = default_theme or descriptor_default or "dark" + if themes and resolved_default not in keys: + resolved_default = "dark" if "dark" in keys else themes[0].key + config = WebappThemesConfig(default_theme=resolved_default, themes=_sorted_themes(themes)) + return _config_with_synced_default_flags(config) + + +def _write_webapp_theme_file(path: Path, theme: WebappTheme) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + data = theme.model_dump(mode="json", exclude_none=True) + payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n" + tmp_path = path.with_suffix(f"{path.suffix}.tmp") + try: + tmp_path.write_text(payload, encoding="utf-8") + tmp_path.replace(path) + except PermissionError: + if tmp_path.exists(): + try: + tmp_path.unlink() + except OSError: + pass + path.write_text(payload, encoding="utf-8") + + +def _copy_default_theme_assets(key: str, target_dir: Path, *, overwrite: bool = False) -> None: + source_dir = DEFAULT_THEMES_SOURCE_DIR / key + if not source_dir.exists(): + return + for source_path in sorted(source_dir.rglob("*")): + if not source_path.is_file() or source_path.name == THEME_DESCRIPTOR_FILENAME: + continue + rel_path = source_path.relative_to(source_dir) + target_path = target_dir / rel_path + if target_path.exists() and not overwrite: + continue + try: + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(source_path.read_bytes()) + except OSError as exc: + logger.warning("Could not create default webapp theme asset %s: %s", target_path, exc) + + +def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool: + style_path = target_dir / "style.css" + try: + style = style_path.read_text(encoding="utf-8") + except OSError: + return True + if key == "light": + return ".theme-key-light.app-shell" not in style + if key == "ascii": + return ( + ".theme-key-ascii" not in style + or "ascii-spin" not in style + or "ascii-skeleton-scan" not in style + or "ascii-boot-type" not in style + or "Console-style tables" not in style + ) + if key != "windows95": + return False + required_icons = ( + "arrow-right.png", + "dashboard.png", + "megaphone.png", + "paintbrush.png", + "sliders.png", + "sparkles.png", + "tag.png", + ) + return ( + "lucide-house" not in style + or "lucide-earth" not in style + or "lucide-circle-check" not in style + or "border-radius: 0 !important" not in style + or any(not (target_dir / "icons" / icon).exists() for icon in required_icons) + ) + + +def ensure_default_webapp_theme_descriptor_files(theme_dir: str | Path | None) -> None: + """Seed source-controlled default theme folders into the mounted data directory.""" + if not theme_dir: + return + root = Path(theme_dir).expanduser() + try: + root.mkdir(parents=True, exist_ok=True) + except OSError as exc: + logger.warning("Could not create webapp themes directory at %s: %s", root, exc) + return + + existing_has_default = any(theme.default for theme in load_webapp_theme_dir(root)) + existing_by_key = {theme.key: theme for theme in load_webapp_theme_dir(root)} + for key, descriptor in default_webapp_theme_descriptors().items(): + theme_dir_path = _theme_dir_path(root, key) + path = _theme_file_path(root, key) + existing = existing_by_key.get(key) + source_assets_version = int(descriptor.get("assets_version") or 1) + should_sync_assets = ( + existing is not None + and key in DEFAULT_THEME_KEYS + and ( + int(existing.assets_version or 0) < source_assets_version + or _builtin_theme_assets_need_refresh(key, theme_dir_path) + ) + ) + if not path.exists(): + seed_descriptor = dict(descriptor) + if existing_has_default: + seed_descriptor["default"] = False + theme = _theme_from_descriptor(path, seed_descriptor) + if theme is not None: + try: + _write_webapp_theme_file(path, theme) + except OSError as exc: + logger.warning( + "Could not create default webapp theme descriptor %s: %s", + path, + exc, + ) + elif should_sync_assets and existing is not None: + data = existing.model_dump(mode="json", exclude_none=True) + data["assets_version"] = source_assets_version + if descriptor.get("css_file"): + data["css_file"] = descriptor["css_file"] + try: + theme = WebappTheme.model_validate(data) + _write_webapp_theme_file(path, theme) + except (OSError, ValueError) as exc: + logger.warning("Could not update default webapp theme descriptor %s: %s", path, exc) + _copy_default_theme_assets(key, theme_dir_path, overwrite=should_sync_assets) + + +def write_webapp_theme_dir( + theme_dir: str | Path, + config: WebappThemesConfig, + *, + delete_missing: bool = False, +) -> None: + """Write one theme.json descriptor per theme into WEBAPP_THEMES_DIR/.""" + root = Path(theme_dir).expanduser() + root.mkdir(parents=True, exist_ok=True) + normalized = _config_with_synced_default_flags(config) + keep_paths = set() + for theme in normalized.themes: + path = _theme_file_path(root, theme.key) + _write_webapp_theme_file(path, theme) + keep_paths.add(path.resolve()) + + if not delete_missing: + return + for path in root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}"): + if path.parent.name.startswith("_") or path.resolve() in keep_paths: + continue + try: + path.unlink() + if not any(path.parent.iterdir()): + path.parent.rmdir() + except OSError as exc: + logger.warning("Could not delete removed webapp theme descriptor %s: %s", path, exc) + + +def ensure_webapp_core_themes( + config: WebappThemesConfig, primary_accent: str +) -> tuple[WebappThemesConfig, bool]: + """Keep dark, light and Windows 95 themes available without clobbering custom edits.""" + data = config.model_dump(mode="json", exclude_none=True) + themes = data.setdefault("themes", []) + by_key = {str(theme.get("key")): theme for theme in themes if isinstance(theme, dict)} + changed = False + + for builtin in builtin_webapp_themes_config(primary_accent).themes: + builtin_data = builtin.model_dump(mode="json", exclude_none=True) + existing = by_key.get(builtin.key) + if existing is None: + themes.append(builtin_data) + by_key[builtin.key] = builtin_data + changed = True + continue + + if existing.get("enabled") is False: + existing["enabled"] = True + changed = True + + if "use_primary_accent" not in existing: + existing["use_primary_accent"] = builtin_data.get("use_primary_accent", True) + changed = True + + if "use_in_admin" not in existing: + existing["use_in_admin"] = builtin_data.get("use_in_admin", True) + changed = True + + if int(existing.get("assets_version") or 0) < int(builtin_data.get("assets_version") or 1): + existing["assets_version"] = builtin_data.get("assets_version", 1) + changed = True + + if builtin.key in {"light", "windows95", "ascii"} and not existing.get("css_file"): + existing["css_file"] = builtin_data.get("css_file") + changed = True + + tokens = existing.setdefault("tokens", {}) + builtin_tokens = builtin_data.get("tokens", {}) + for token_key in ("color_scheme", "style_preset"): + if token_key in builtin_tokens and not tokens.get(token_key): + tokens[token_key] = builtin_tokens[token_key] + changed = True + + keys = {str(theme.get("key")) for theme in themes if isinstance(theme, dict)} + if themes and data.get("default_theme") not in keys: + data["default_theme"] = "dark" if "dark" in keys else str(themes[0].get("key") or "dark") + changed = True + + normalized = _config_with_synced_default_flags(WebappThemesConfig.model_validate(data)) + if normalized.model_dump(mode="json", exclude_none=True) != data: + changed = True + return normalized, changed + + +def builtin_webapp_themes_config(primary_accent: str) -> WebappThemesConfig: + """Default catalog backed by repository theme descriptor files.""" + accent = (primary_accent or "#00fe7a").strip() or "#00fe7a" + themes: List[WebappTheme] = [] + descriptors = default_webapp_theme_descriptors() + for key in DEFAULT_THEME_KEYS: + raw = descriptors.get(key) + if not raw: + continue + theme = _theme_from_descriptor( + DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME, + raw, + ) + if theme is None: + continue + if theme.key == "dark" and not theme.tokens.accent: + theme.tokens.accent = accent + themes.append(theme) + return _themes_config_from_list(None, themes) + + +def apply_webapp_theme_env_overrides( + config: WebappThemesConfig, env_default_theme: Optional[str] +) -> WebappThemesConfig: + """If WEBAPP_DEFAULT_THEME is set and matches a theme key, override the default theme.""" + raw = (env_default_theme or "").strip() + if not raw: + return config + if config.theme_by_key(raw) is None: + logger.warning("WEBAPP_DEFAULT_THEME=%r ignored: no such theme in catalog", raw) + return config + data = config.model_dump(mode="json", exclude_none=True) + data["default_theme"] = raw + return _config_with_synced_default_flags(WebappThemesConfig.model_validate(data)) + + +def resolved_webapp_themes_catalog( + *, + theme_dir: str | Path, + primary_accent: str, + env_default_theme: Optional[str], +) -> WebappThemesConfig: + """Load themes from WEBAPP_THEMES_DIR, seeding defaults when possible.""" + ensure_default_webapp_theme_descriptor_files(theme_dir) + + themes = load_webapp_theme_dir(theme_dir) + config = _themes_config_from_list(None, themes) + config, changed = ensure_webapp_core_themes(config, primary_accent) + if changed: + try: + write_webapp_theme_dir(theme_dir, config, delete_missing=False) + except OSError as exc: + logger.warning("Could not update webapp theme descriptors in %s: %s", theme_dir, exc) + return apply_webapp_theme_env_overrides(config, env_default_theme) + + +def merge_primary_accent_into_theme_tokens( + theme: WebappTheme, primary_accent: str, *, only_if_token_missing: bool = True +) -> ThemeTokens: + """Fill accent from WEBAPP_PRIMARY_COLOR when theme tokens omit accent.""" + base = theme.tokens.model_copy(deep=True) + accent = (primary_accent or "").strip() + if not accent: + return base + if only_if_token_missing and base.accent: + return base + base.accent = accent + return base + + +def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]: + tokens = ( + merge_primary_accent_into_theme_tokens(theme, primary_accent) + if theme.use_primary_accent + else theme.tokens + ) + payload: Dict[str, object] = { + "key": theme.key, + "names": dict(theme.names), + "enabled": bool(theme.enabled), + "use_primary_accent": bool(theme.use_primary_accent), + "use_in_admin": bool(theme.use_in_admin), + "tokens": tokens.model_dump(mode="json", exclude_none=True), + } + if theme.css_file: + payload["css_file"] = theme.css_file + return payload + + +def public_themes_catalog_payload( + config: WebappThemesConfig, primary_accent: str, *, enabled_only: bool = False +) -> Dict[str, object]: + themes = [theme for theme in config.themes if not enabled_only or theme.enabled] + return { + "default_theme": config.default_theme, + "themes": [public_theme_payload(theme, primary_accent) for theme in themes], + } diff --git a/docs/remnawave-minishop.webp b/docs/remnawave-minishop.webp new file mode 100644 index 0000000000000000000000000000000000000000..f864834875fea9fc84e1d9820c1996d591d3d603 GIT binary patch literal 122284 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%0DF;gKnwuzSx!&` z0K~xQ1ONi@CjbCCN4_`!^pppy(YXf6W1jCx6^}r?gie;pQLIB>kNmu%xa-Rf_0?-n z{5iiid4~clArz1U)gLn zKW;~Z!qQRRM|GKevF(loAU6kz^V~WENHUNS!iB=DuKzc0=3f_(k=+UPil1wup}`;`Sk1m$uc$1j^Z#=pd7T zL{g?UY-<*k^DEKdB?p@U^V^
s39e<~Y+%n>_8igFnxA8}nTo0pR7%Z=>)Maz@T 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 Zm&W5@U=52G;i8oSbYXndLhvn#n*hXo$T|Q3 literal 0 HcmV?d00001 diff --git a/locales/en.json b/locales/en.json index 1dc21ed..6812dc6 100644 --- a/locales/en.json +++ b/locales/en.json @@ -768,6 +768,9 @@ "wa_activate": "Activate", "wa_settings_avatar_alt": "User avatar", "wa_settings_language": "Language", + "wa_settings_theme": "Appearance theme", + "wa_settings_theme_follow_default": "Server default", + "wa_settings_theme_update_failed": "Failed to update theme", "wa_settings_link_telegram": "Telegram linked", "wa_settings_not_linked": "Not linked", "wa_settings_link_email": "Email linked", @@ -852,6 +855,7 @@ "admin_nav_logs": "Logs", "admin_nav_system": "System", "admin_nav_tariffs": "Tariffs", + "admin_nav_themes": "Themes", "admin_nav_settings": "Settings", "admin_section_stats_title": "Dashboard", "admin_section_stats_subtitle": "Audience, revenue, Remnawave panel, and recent payments", @@ -869,6 +873,8 @@ "admin_section_logs_subtitle": "User events and admin actions", "admin_section_tariffs_title": "Tariffs", "admin_section_tariffs_subtitle": "Sales catalog, periods, packages, and limits", + "admin_section_themes_title": "Web App themes", + "admin_section_themes_subtitle": "Colors, fonts, and Mini App appearance", "admin_section_settings_title": "App Settings", "admin_section_settings_subtitle": "Overrides for .env, applied instantly", "admin_filter_all": "All", @@ -1047,6 +1053,7 @@ "admin_stats_queue_groups": " groups", "admin_id": "ID", "admin_status_default": "Default", + "admin_status_current": "Current", "admin_tariff_squads": "Squads", "admin_tariff_premium": "Premium", "admin_settings_badge_secret": "Secret", @@ -1221,6 +1228,13 @@ "admin_tariffs_stat_disabled": "Disabled", "admin_tariffs_stat_disabled_hint": "Hidden from showcase", "admin_tariffs_catalog_empty": "The catalog is empty. Add your first tariff; a catalog JSON file will be created after saving.", + "admin_themes_catalog_title": "Web App themes", + "admin_themes_catalog_sub": "Select the current theme from a card; edit appearance through files in the theme folder", + "admin_themes_use_primary_accent": "Use primary accent color", + "admin_themes_use_in_admin": "Use in admin panel", + "admin_themes_catalog_empty": "The catalog is empty. Add a theme folder to data/themes and refresh.", + "admin_themes_saved": "Themes saved", + "admin_themes_save_failed": "Failed to save themes", "admin_no_description": "No description", "admin_tariff_model_traffic": "Traffic", "admin_tariff_model_periods": "Periods", diff --git a/locales/ru.json b/locales/ru.json index dc6dbdf..113ee5a 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -768,6 +768,9 @@ "wa_activate": "Активировать", "wa_settings_avatar_alt": "Аватар пользователя", "wa_settings_language": "Выбор языка", + "wa_settings_theme": "Тема оформления", + "wa_settings_theme_follow_default": "По умолчанию (сервер)", + "wa_settings_theme_update_failed": "Не удалось обновить тему", "wa_settings_link_telegram": "Привязка Telegram", "wa_settings_not_linked": "Не привязан", "wa_settings_link_email": "Привязка почты", @@ -852,6 +855,7 @@ "admin_nav_logs": "Логи", "admin_nav_system": "Система", "admin_nav_tariffs": "Тарифы", + "admin_nav_themes": "Темы", "admin_nav_settings": "Настройки", "admin_section_stats_title": "Дашборд", "admin_section_stats_subtitle": "Аудитория, доходы, панель Remnawave и последние платежи", @@ -869,6 +873,8 @@ "admin_section_logs_subtitle": "События пользователей и админ-действия", "admin_section_tariffs_title": "Тарифы", "admin_section_tariffs_subtitle": "Каталог продаж, периоды, пакеты и лимиты", + "admin_section_themes_title": "Темы Web App", + "admin_section_themes_subtitle": "Цвета, шрифты и темы оформления Mini App", "admin_section_settings_title": "Настройки приложения", "admin_section_settings_subtitle": "Оверрайды над .env, применяются мгновенно", "admin_filter_all": "Все", @@ -1047,6 +1053,7 @@ "admin_stats_queue_groups": " групп", "admin_id": "ID", "admin_status_default": "По умолчанию", + "admin_status_current": "Текущая", "admin_tariff_squads": "Squads", "admin_tariff_premium": "Premium", "admin_settings_badge_secret": "Secret", @@ -1221,6 +1228,13 @@ "admin_tariffs_stat_disabled": "Отключено", "admin_tariffs_stat_disabled_hint": "Скрыто с витрины", "admin_tariffs_catalog_empty": "Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога.", + "admin_themes_catalog_title": "Темы Web App", + "admin_themes_catalog_sub": "Текущая тема выбирается карточкой; внешний вид редактируется файлами в папке темы", + "admin_themes_use_primary_accent": "Протягивать акцентный цвет", + "admin_themes_use_in_admin": "Использовать в админке", + "admin_themes_catalog_empty": "Каталог пуст. Добавьте папку темы в data/themes и обновите список.", + "admin_themes_saved": "Темы сохранены", + "admin_themes_save_failed": "Не удалось сохранить темы", "admin_no_description": "Без описания", "admin_tariff_model_traffic": "Трафик", "admin_tariff_model_periods": "Периоды", diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 4d3be96..5e32b62 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -222,6 +222,133 @@ 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_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" diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 444b07e..0e3d597 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -59,6 +59,7 @@ class WebAppRouteContractTests(unittest.TestCase): ("GET", "/webapp-logo"): "webapp_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,6 +137,8 @@ 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", ("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route", } diff --git a/tests/test_webapp_themes_config.py b/tests/test_webapp_themes_config.py new file mode 100644 index 0000000..65b83e1 --- /dev/null +++ b/tests/test_webapp_themes_config.py @@ -0,0 +1,371 @@ +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, 1) + 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_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"], 1) + self.assertIn("lucide-house", css) + self.assertIn("lucide-earth", css) + self.assertIn("lucide-circle-check", css) + self.assertIn("lucide-circle-check-big", 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)