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 0000000..d08f5e9 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/arrow-left.png differ diff --git a/bot/app/web/themes/windows95/icons/arrow-right.png b/bot/app/web/themes/windows95/icons/arrow-right.png new file mode 100644 index 0000000..422537f Binary files /dev/null and b/bot/app/web/themes/windows95/icons/arrow-right.png differ diff --git a/bot/app/web/themes/windows95/icons/bitcoin.png b/bot/app/web/themes/windows95/icons/bitcoin.png new file mode 100644 index 0000000..a9b7212 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/bitcoin.png differ diff --git a/bot/app/web/themes/windows95/icons/check.png b/bot/app/web/themes/windows95/icons/check.png new file mode 100644 index 0000000..78e2094 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/check.png differ diff --git a/bot/app/web/themes/windows95/icons/chevrons.png b/bot/app/web/themes/windows95/icons/chevrons.png new file mode 100644 index 0000000..cb925ce Binary files /dev/null and b/bot/app/web/themes/windows95/icons/chevrons.png differ diff --git a/bot/app/web/themes/windows95/icons/coins.png b/bot/app/web/themes/windows95/icons/coins.png new file mode 100644 index 0000000..0697922 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/coins.png differ diff --git a/bot/app/web/themes/windows95/icons/dashboard.png b/bot/app/web/themes/windows95/icons/dashboard.png new file mode 100644 index 0000000..7bd6f79 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/dashboard.png differ diff --git a/bot/app/web/themes/windows95/icons/database.png b/bot/app/web/themes/windows95/icons/database.png new file mode 100644 index 0000000..698e6d5 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/database.png differ diff --git a/bot/app/web/themes/windows95/icons/download.png b/bot/app/web/themes/windows95/icons/download.png new file mode 100644 index 0000000..5d96a1b Binary files /dev/null and b/bot/app/web/themes/windows95/icons/download.png differ diff --git a/bot/app/web/themes/windows95/icons/error.png b/bot/app/web/themes/windows95/icons/error.png new file mode 100644 index 0000000..5d046a3 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/error.png differ diff --git a/bot/app/web/themes/windows95/icons/file-text.png b/bot/app/web/themes/windows95/icons/file-text.png new file mode 100644 index 0000000..c1ab35e Binary files /dev/null and b/bot/app/web/themes/windows95/icons/file-text.png differ diff --git a/bot/app/web/themes/windows95/icons/folder.png b/bot/app/web/themes/windows95/icons/folder.png new file mode 100644 index 0000000..437efbf Binary files /dev/null and b/bot/app/web/themes/windows95/icons/folder.png differ diff --git a/bot/app/web/themes/windows95/icons/gift.png b/bot/app/web/themes/windows95/icons/gift.png new file mode 100644 index 0000000..0697922 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/gift.png differ diff --git a/bot/app/web/themes/windows95/icons/globe.png b/bot/app/web/themes/windows95/icons/globe.png new file mode 100644 index 0000000..f740cee Binary files /dev/null and b/bot/app/web/themes/windows95/icons/globe.png differ diff --git a/bot/app/web/themes/windows95/icons/help.png b/bot/app/web/themes/windows95/icons/help.png new file mode 100644 index 0000000..1aa4921 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/help.png differ diff --git a/bot/app/web/themes/windows95/icons/home.png b/bot/app/web/themes/windows95/icons/home.png new file mode 100644 index 0000000..a713e61 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/home.png differ diff --git a/bot/app/web/themes/windows95/icons/info.png b/bot/app/web/themes/windows95/icons/info.png new file mode 100644 index 0000000..d540eb7 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/info.png differ diff --git a/bot/app/web/themes/windows95/icons/key.png b/bot/app/web/themes/windows95/icons/key.png new file mode 100644 index 0000000..eb69306 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/key.png differ diff --git a/bot/app/web/themes/windows95/icons/lock.png b/bot/app/web/themes/windows95/icons/lock.png new file mode 100644 index 0000000..224052b Binary files /dev/null and b/bot/app/web/themes/windows95/icons/lock.png differ diff --git a/bot/app/web/themes/windows95/icons/megaphone.png b/bot/app/web/themes/windows95/icons/megaphone.png new file mode 100644 index 0000000..a697102 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/megaphone.png differ diff --git a/bot/app/web/themes/windows95/icons/paintbrush.png b/bot/app/web/themes/windows95/icons/paintbrush.png new file mode 100644 index 0000000..b895700 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/paintbrush.png differ diff --git a/bot/app/web/themes/windows95/icons/payment-card.png b/bot/app/web/themes/windows95/icons/payment-card.png new file mode 100644 index 0000000..a9b7212 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/payment-card.png differ diff --git a/bot/app/web/themes/windows95/icons/print.png b/bot/app/web/themes/windows95/icons/print.png new file mode 100644 index 0000000..04e2839 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/print.png differ diff --git a/bot/app/web/themes/windows95/icons/refresh.png b/bot/app/web/themes/windows95/icons/refresh.png new file mode 100644 index 0000000..be08925 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/refresh.png differ diff --git a/bot/app/web/themes/windows95/icons/save.png b/bot/app/web/themes/windows95/icons/save.png new file mode 100644 index 0000000..2e70ec1 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/save.png differ diff --git a/bot/app/web/themes/windows95/icons/search.png b/bot/app/web/themes/windows95/icons/search.png new file mode 100644 index 0000000..0102a91 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/search.png differ diff --git a/bot/app/web/themes/windows95/icons/send.png b/bot/app/web/themes/windows95/icons/send.png new file mode 100644 index 0000000..67b9ae1 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/send.png differ diff --git a/bot/app/web/themes/windows95/icons/settings.png b/bot/app/web/themes/windows95/icons/settings.png new file mode 100644 index 0000000..0d56f79 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/settings.png differ diff --git a/bot/app/web/themes/windows95/icons/shield.png b/bot/app/web/themes/windows95/icons/shield.png new file mode 100644 index 0000000..8ce794a Binary files /dev/null and b/bot/app/web/themes/windows95/icons/shield.png differ diff --git a/bot/app/web/themes/windows95/icons/sliders.png b/bot/app/web/themes/windows95/icons/sliders.png new file mode 100644 index 0000000..d3badce Binary files /dev/null and b/bot/app/web/themes/windows95/icons/sliders.png differ diff --git a/bot/app/web/themes/windows95/icons/smartphone.png b/bot/app/web/themes/windows95/icons/smartphone.png new file mode 100644 index 0000000..e3b9b22 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/smartphone.png differ diff --git a/bot/app/web/themes/windows95/icons/sparkles.png b/bot/app/web/themes/windows95/icons/sparkles.png new file mode 100644 index 0000000..5f99ecb Binary files /dev/null and b/bot/app/web/themes/windows95/icons/sparkles.png differ diff --git a/bot/app/web/themes/windows95/icons/tag.png b/bot/app/web/themes/windows95/icons/tag.png new file mode 100644 index 0000000..13364e3 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/tag.png differ diff --git a/bot/app/web/themes/windows95/icons/ticket.png b/bot/app/web/themes/windows95/icons/ticket.png new file mode 100644 index 0000000..13364e3 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/ticket.png differ diff --git a/bot/app/web/themes/windows95/icons/trash.png b/bot/app/web/themes/windows95/icons/trash.png new file mode 100644 index 0000000..c4ca801 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/trash.png differ diff --git a/bot/app/web/themes/windows95/icons/user.png b/bot/app/web/themes/windows95/icons/user.png new file mode 100644 index 0000000..fa18aaf Binary files /dev/null and b/bot/app/web/themes/windows95/icons/user.png differ diff --git a/bot/app/web/themes/windows95/icons/users.png b/bot/app/web/themes/windows95/icons/users.png new file mode 100644 index 0000000..6d980f6 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/users.png differ diff --git a/bot/app/web/themes/windows95/icons/warning.png b/bot/app/web/themes/windows95/icons/warning.png new file mode 100644 index 0000000..ca701de Binary files /dev/null and b/bot/app/web/themes/windows95/icons/warning.png differ diff --git a/bot/app/web/themes/windows95/icons/x.png b/bot/app/web/themes/windows95/icons/x.png new file mode 100644 index 0000000..1d69477 Binary files /dev/null and b/bot/app/web/themes/windows95/icons/x.png differ diff --git a/bot/app/web/themes/windows95/style.css b/bot/app/web/themes/windows95/style.css new file mode 100644 index 0000000..68a5709 --- /dev/null +++ b/bot/app/web/themes/windows95/style.css @@ -0,0 +1,850 @@ +.theme-key-windows95 { + color-scheme: light; + image-rendering: pixelated; + --accent: #000080; + --bg: #008080; + --panel: #c0c0c0; + --panel-2: #dfdfdf; + --panel-3: #808080; + --border: #808080; + --border-strong: #000000; + --text: #000000; + --muted: #202020; + --dim: #404040; + --danger: #800000; + --blue: #000080; + --radius: 0px; + --font-sans: "MS Sans Serif", Tahoma, "Segoe UI", sans-serif; + --font-logo: "MS Sans Serif", Tahoma, "Segoe UI", sans-serif; + --font-mono: Consolas, "Courier New", monospace; + --accent-contrast: #ffffff; + --surface-sheen: transparent; + --surface-sheen-soft: transparent; + --surface-hover: rgba(0, 0, 128, 0.14); + --surface-muted: #c0c0c0; + --surface-subtle-border: #808080; + --overlay-scrim: rgba(0, 0, 0, 0.35); + --nav-bg: #c0c0c0; + --rail-bg: #c0c0c0; + --shadow-soft: 2px 2px 0 #000000; + --shadow-strong: 3px 3px 0 #000000; + --shadow-popover: 2px 2px 0 #000000; + --inset-highlight: #ffffff; + --admin-bg: #008080; + --admin-surface: #c0c0c0; + --admin-surface-2: #dfdfdf; + --admin-elev: #808080; + --admin-border: #808080; + --admin-border-strong: #000000; + --admin-text: #000000; + --admin-muted: #202020; + --admin-dim: #404040; + --win95-icon-arrow-left: url("/webapp-theme-assets/windows95/icons/arrow-left.png"); + --win95-icon-arrow-right: url("/webapp-theme-assets/windows95/icons/arrow-right.png"); + --win95-icon-bitcoin: url("/webapp-theme-assets/windows95/icons/bitcoin.png"); + --win95-icon-check: url("/webapp-theme-assets/windows95/icons/check.png"); + --win95-icon-chevrons: url("/webapp-theme-assets/windows95/icons/chevrons.png"); + --win95-icon-coins: url("/webapp-theme-assets/windows95/icons/coins.png"); + --win95-icon-dashboard: url("/webapp-theme-assets/windows95/icons/dashboard.png"); + --win95-icon-database: url("/webapp-theme-assets/windows95/icons/database.png"); + --win95-icon-download: url("/webapp-theme-assets/windows95/icons/download.png"); + --win95-icon-error: url("/webapp-theme-assets/windows95/icons/error.png"); + --win95-icon-file-text: url("/webapp-theme-assets/windows95/icons/file-text.png"); + --win95-icon-folder: url("/webapp-theme-assets/windows95/icons/folder.png"); + --win95-icon-gift: url("/webapp-theme-assets/windows95/icons/gift.png"); + --win95-icon-globe: url("/webapp-theme-assets/windows95/icons/globe.png"); + --win95-icon-help: url("/webapp-theme-assets/windows95/icons/help.png"); + --win95-icon-home: url("/webapp-theme-assets/windows95/icons/home.png"); + --win95-icon-info: url("/webapp-theme-assets/windows95/icons/info.png"); + --win95-icon-key: url("/webapp-theme-assets/windows95/icons/key.png"); + --win95-icon-lock: url("/webapp-theme-assets/windows95/icons/lock.png"); + --win95-icon-megaphone: url("/webapp-theme-assets/windows95/icons/megaphone.png"); + --win95-icon-paintbrush: url("/webapp-theme-assets/windows95/icons/paintbrush.png"); + --win95-icon-payment-card: url("/webapp-theme-assets/windows95/icons/payment-card.png"); + --win95-icon-print: url("/webapp-theme-assets/windows95/icons/print.png"); + --win95-icon-refresh: url("/webapp-theme-assets/windows95/icons/refresh.png"); + --win95-icon-save: url("/webapp-theme-assets/windows95/icons/save.png"); + --win95-icon-search: url("/webapp-theme-assets/windows95/icons/search.png"); + --win95-icon-send: url("/webapp-theme-assets/windows95/icons/send.png"); + --win95-icon-settings: url("/webapp-theme-assets/windows95/icons/settings.png"); + --win95-icon-shield: url("/webapp-theme-assets/windows95/icons/shield.png"); + --win95-icon-smartphone: url("/webapp-theme-assets/windows95/icons/smartphone.png"); + --win95-icon-sliders: url("/webapp-theme-assets/windows95/icons/sliders.png"); + --win95-icon-sparkles: url("/webapp-theme-assets/windows95/icons/sparkles.png"); + --win95-icon-tag: url("/webapp-theme-assets/windows95/icons/tag.png"); + --win95-icon-ticket: url("/webapp-theme-assets/windows95/icons/ticket.png"); + --win95-icon-trash: url("/webapp-theme-assets/windows95/icons/trash.png"); + --win95-icon-user: url("/webapp-theme-assets/windows95/icons/user.png"); + --win95-icon-users: url("/webapp-theme-assets/windows95/icons/users.png"); + --win95-icon-warning: url("/webapp-theme-assets/windows95/icons/warning.png"); + --win95-icon-x: url("/webapp-theme-assets/windows95/icons/x.png"); +} + +.theme-key-windows95.app-shell { + background: var(--bg) !important; +} + +.theme-key-windows95 svg.lucide-arrow-left, +.theme-key-windows95 svg.lucide-arrow-right, +.theme-key-windows95 svg.lucide-bitcoin, +.theme-key-windows95 svg.lucide-check, +.theme-key-windows95 svg.lucide-check-circle-2, +.theme-key-windows95 svg.lucide-chevrons-up-down, +.theme-key-windows95 svg.lucide-circle-x, +.theme-key-windows95 svg.lucide-coins, +.theme-key-windows95 svg.lucide-copy, +.theme-key-windows95 svg.lucide-credit-card, +.theme-key-windows95 svg.lucide-database, +.theme-key-windows95 svg.lucide-download, +.theme-key-windows95 svg.lucide-earth, +.theme-key-windows95 svg.lucide-external-link, +.theme-key-windows95 svg.lucide-file-text, +.theme-key-windows95 svg.lucide-gift, +.theme-key-windows95 svg.lucide-globe-2, +.theme-key-windows95 svg.lucide-home, +.theme-key-windows95 svg.lucide-house, +.theme-key-windows95 svg.lucide-info, +.theme-key-windows95 svg.lucide-key, +.theme-key-windows95 svg.lucide-layout-dashboard, +.theme-key-windows95 svg.lucide-lock-keyhole, +.theme-key-windows95 svg.lucide-mail, +.theme-key-windows95 svg.lucide-megaphone, +.theme-key-windows95 svg.lucide-message-square, +.theme-key-windows95 svg.lucide-paintbrush, +.theme-key-windows95 svg.lucide-plus, +.theme-key-windows95 svg.lucide-refresh-cw, +.theme-key-windows95 svg.lucide-save, +.theme-key-windows95 svg.lucide-search, +.theme-key-windows95 svg.lucide-send, +.theme-key-windows95 svg.lucide-settings, +.theme-key-windows95 svg.lucide-shield, +.theme-key-windows95 svg.lucide-sliders, +.theme-key-windows95 svg.lucide-smartphone, +.theme-key-windows95 svg.lucide-sparkles, +.theme-key-windows95 svg.lucide-tag, +.theme-key-windows95 svg.lucide-ticket, +.theme-key-windows95 svg.lucide-trash-2, +.theme-key-windows95 svg.lucide-triangle-alert, +.theme-key-windows95 svg.lucide-user, +.theme-key-windows95 svg.lucide-user-round, +.theme-key-windows95 svg.lucide-users, +.theme-key-windows95 svg.lucide-users-round, +.theme-key-windows95 svg.lucide-wallet-cards, +.theme-key-windows95 svg.lucide-x { + flex: 0 0 auto; + width: 22px !important; + height: 22px !important; + color: transparent !important; + stroke: transparent !important; + fill: transparent !important; + background-image: var(--win95-button-icon); + background-position: center; + background-repeat: no-repeat; + background-size: contain; + image-rendering: pixelated; +} + +.theme-key-windows95 svg.lucide-arrow-left { + --win95-button-icon: var(--win95-icon-arrow-left); +} + +.theme-key-windows95 svg.lucide-arrow-right { + --win95-button-icon: var(--win95-icon-arrow-right); +} + +.theme-key-windows95 svg.lucide-bitcoin { + --win95-button-icon: var(--win95-icon-bitcoin); +} + +.theme-key-windows95 svg.lucide-check, +.theme-key-windows95 svg.lucide-check-circle-2, +.theme-key-windows95 svg.lucide-circle-check, +.theme-key-windows95 svg.lucide-circle-check-big { + --win95-button-icon: var(--win95-icon-check); +} + +.theme-key-windows95 svg.lucide-chevrons-up-down { + --win95-button-icon: var(--win95-icon-chevrons); +} + +.theme-key-windows95 svg.lucide-coins { + --win95-button-icon: var(--win95-icon-coins); +} + +.theme-key-windows95 svg.lucide-circle-x, +.theme-key-windows95 svg.lucide-x { + --win95-button-icon: var(--win95-icon-x); +} + +.theme-key-windows95 svg.lucide-copy { + --win95-button-icon: var(--win95-icon-file-text); +} + +.theme-key-windows95 svg.lucide-credit-card, +.theme-key-windows95 svg.lucide-wallet-cards { + --win95-button-icon: var(--win95-icon-payment-card); +} + +.theme-key-windows95 svg.lucide-database { + --win95-button-icon: var(--win95-icon-database); +} + +.theme-key-windows95 svg.lucide-download { + --win95-button-icon: var(--win95-icon-download); +} + +.theme-key-windows95 svg.lucide-earth, +.theme-key-windows95 svg.lucide-external-link, +.theme-key-windows95 svg.lucide-globe-2 { + --win95-button-icon: var(--win95-icon-globe); +} + +.theme-key-windows95 svg.lucide-file-text { + --win95-button-icon: var(--win95-icon-file-text); +} + +.theme-key-windows95 svg.lucide-gift { + --win95-button-icon: var(--win95-icon-gift); +} + +.theme-key-windows95 svg.lucide-home, +.theme-key-windows95 svg.lucide-house { + --win95-button-icon: var(--win95-icon-home); +} + +.theme-key-windows95 svg.lucide-info { + --win95-button-icon: var(--win95-icon-info); +} + +.theme-key-windows95 svg.lucide-key { + --win95-button-icon: var(--win95-icon-key); +} + +.theme-key-windows95 svg.lucide-layout-dashboard { + --win95-button-icon: var(--win95-icon-dashboard); +} + +.theme-key-windows95 svg.lucide-lock-keyhole { + --win95-button-icon: var(--win95-icon-lock); +} + +.theme-key-windows95 svg.lucide-megaphone { + --win95-button-icon: var(--win95-icon-megaphone); +} + +.theme-key-windows95 svg.lucide-mail, +.theme-key-windows95 svg.lucide-message-square, +.theme-key-windows95 svg.lucide-send { + --win95-button-icon: var(--win95-icon-send); +} + +.theme-key-windows95 svg.lucide-paintbrush { + --win95-button-icon: var(--win95-icon-paintbrush); +} + +.theme-key-windows95 svg.lucide-plus { + --win95-button-icon: var(--win95-icon-folder); +} + +.theme-key-windows95 svg.lucide-refresh-cw { + --win95-button-icon: var(--win95-icon-refresh); +} + +.theme-key-windows95 svg.lucide-save { + --win95-button-icon: var(--win95-icon-save); +} + +.theme-key-windows95 svg.lucide-search { + --win95-button-icon: var(--win95-icon-search); +} + +.theme-key-windows95 svg.lucide-settings { + --win95-button-icon: var(--win95-icon-settings); +} + +.theme-key-windows95 svg.lucide-shield { + --win95-button-icon: var(--win95-icon-shield); +} + +.theme-key-windows95 svg.lucide-sliders { + --win95-button-icon: var(--win95-icon-sliders); +} + +.theme-key-windows95 svg.lucide-smartphone { + --win95-button-icon: var(--win95-icon-smartphone); +} + +.theme-key-windows95 svg.lucide-sparkles { + --win95-button-icon: var(--win95-icon-sparkles); +} + +.theme-key-windows95 svg.lucide-tag { + --win95-button-icon: var(--win95-icon-tag); +} + +.theme-key-windows95 svg.lucide-ticket { + --win95-button-icon: var(--win95-icon-ticket); +} + +.theme-key-windows95 svg.lucide-trash-2 { + --win95-button-icon: var(--win95-icon-trash); +} + +.theme-key-windows95 svg.lucide-triangle-alert { + --win95-button-icon: var(--win95-icon-warning); +} + +.theme-key-windows95 svg.lucide-user, +.theme-key-windows95 svg.lucide-user-round { + --win95-button-icon: var(--win95-icon-user); +} + +.theme-key-windows95 svg.lucide-users, +.theme-key-windows95 svg.lucide-users-round { + --win95-button-icon: var(--win95-icon-users); +} + +.theme-key-windows95 :is( + svg.lucide-arrow-left, + svg.lucide-arrow-right, + svg.lucide-bitcoin, + svg.lucide-check, + svg.lucide-check-circle-2, + svg.lucide-chevrons-up-down, + svg.lucide-circle-check, + svg.lucide-circle-check-big, + svg.lucide-circle-x, + svg.lucide-coins, + svg.lucide-copy, + svg.lucide-credit-card, + svg.lucide-database, + svg.lucide-download, + svg.lucide-earth, + svg.lucide-external-link, + svg.lucide-file-text, + svg.lucide-gift, + svg.lucide-globe-2, + svg.lucide-home, + svg.lucide-house, + svg.lucide-info, + svg.lucide-key, + svg.lucide-layout-dashboard, + svg.lucide-lock-keyhole, + svg.lucide-mail, + svg.lucide-megaphone, + svg.lucide-message-square, + svg.lucide-paintbrush, + svg.lucide-plus, + svg.lucide-refresh-cw, + svg.lucide-save, + svg.lucide-search, + svg.lucide-send, + svg.lucide-settings, + svg.lucide-shield, + svg.lucide-sliders, + svg.lucide-smartphone, + svg.lucide-sparkles, + svg.lucide-tag, + svg.lucide-ticket, + svg.lucide-trash-2, + svg.lucide-triangle-alert, + svg.lucide-user, + svg.lucide-user-round, + svg.lucide-users, + svg.lucide-users-round, + svg.lucide-wallet-cards, + svg.lucide-x +) *, +.theme-key-windows95 :is( + svg.lucide-arrow-left, + svg.lucide-arrow-right, + svg.lucide-bitcoin, + svg.lucide-check, + svg.lucide-check-circle-2, + svg.lucide-chevrons-up-down, + svg.lucide-circle-check, + svg.lucide-circle-check-big, + svg.lucide-circle-x, + svg.lucide-coins, + svg.lucide-copy, + svg.lucide-credit-card, + svg.lucide-database, + svg.lucide-download, + svg.lucide-earth, + svg.lucide-external-link, + svg.lucide-file-text, + svg.lucide-gift, + svg.lucide-globe-2, + svg.lucide-home, + svg.lucide-house, + svg.lucide-info, + svg.lucide-key, + svg.lucide-layout-dashboard, + svg.lucide-lock-keyhole, + svg.lucide-mail, + svg.lucide-megaphone, + svg.lucide-message-square, + svg.lucide-paintbrush, + svg.lucide-plus, + svg.lucide-refresh-cw, + svg.lucide-save, + svg.lucide-search, + svg.lucide-send, + svg.lucide-settings, + svg.lucide-shield, + svg.lucide-sliders, + svg.lucide-smartphone, + svg.lucide-sparkles, + svg.lucide-tag, + svg.lucide-ticket, + svg.lucide-trash-2, + svg.lucide-triangle-alert, + svg.lucide-user, + svg.lucide-user-round, + svg.lucide-users, + svg.lucide-users-round, + svg.lucide-wallet-cards, + svg.lucide-x +) { + color: transparent !important; + stroke: transparent !important; + fill: transparent !important; +} + +.theme-key-windows95, +.theme-key-windows95 button, +.theme-key-windows95 input, +.theme-key-windows95 textarea { + font-synthesis: none; +} + +.theme-key-windows95 .phone-screen { + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px), + linear-gradient(0deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px); + background-size: 8px 8px; +} + +.theme-key-windows95 .card, +.theme-key-windows95 .period-card, +.theme-key-windows95 .method-card, +.theme-key-windows95 .settings-row, +.theme-key-windows95 .option-row, +.theme-key-windows95 .tariff-selected-card, +.theme-key-windows95 .tariff-action-card, +.theme-key-windows95 .tariff-warning-card, +.theme-key-windows95 .topup-carryover-note, +.theme-key-windows95 .input, +.theme-key-windows95 .dialog-card, +.theme-key-windows95 .language-select-content, +.theme-key-windows95 .bottom-nav, +.theme-key-windows95 .toast { + border-width: 2px; + border-style: solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0; + background: var(--panel); + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 .dialog-card, +.theme-key-windows95 .language-select-content, +.theme-key-windows95 .toast { + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080, + 2px 2px 0 #000000; +} + +.theme-key-windows95 .card::before, +.theme-key-windows95 .dialog-card::before { + content: ""; + display: block; + height: 18px; + margin: -14px -14px 12px; + background: linear-gradient(90deg, var(--accent), #1084d0); + border-bottom: 2px solid #000000; +} + +.theme-key-windows95 .dialog-card::before { + margin: -18px -18px 14px; +} + +.theme-key-windows95 .btn, +.theme-key-windows95 .language-select-trigger, +.theme-key-windows95 .bottom-nav button { + min-height: 34px; + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0; + background: var(--panel); + color: var(--text); + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; + transform: none; +} + +@media (min-width: 1024px) { + .theme-key-windows95 .bottom-nav { + border-right: 2px solid #404040 !important; + background: var(--rail-bg) !important; + backdrop-filter: none !important; + box-shadow: + inset 1px 1px 0 #ffffff, + inset -1px -1px 0 #808080 !important; + } + + .theme-key-windows95 .bottom-nav button { + min-height: 34px !important; + border: 2px solid !important; + border-color: #ffffff #404040 #404040 #ffffff !important; + border-radius: 0 !important; + background: var(--panel) !important; + color: var(--text) !important; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080 !important; + } + + .theme-key-windows95 .bottom-nav button.active { + background: var(--accent) !important; + color: #ffffff !important; + border-color: #ffffff #404040 #404040 #ffffff !important; + } + + .theme-key-windows95 .bottom-nav button:active { + border-color: #404040 #ffffff #ffffff #404040 !important; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf !important; + } +} + +.theme-key-windows95 .btn:active:not(:disabled), +.theme-key-windows95 .bottom-nav button:active { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 .btn-primary, +.theme-key-windows95 .bottom-nav button.active, +.theme-key-windows95 .period-card.active, +.theme-key-windows95 .method-card.active, +.theme-key-windows95 .option-row.active { + background: var(--accent); + color: #ffffff; +} + +.theme-key-windows95 .card-heading-accent, +.theme-key-windows95 .brand-row strong, +.theme-key-windows95 .login-brand h1, +.theme-key-windows95 .bottom-nav button.active { + color: var(--accent); +} + +.theme-key-windows95 .btn-primary, +.theme-key-windows95 .bottom-nav button.active { + color: #ffffff; +} + +.theme-key-windows95 .progress { + height: 14px; + border: 2px solid; + border-color: #404040 #ffffff #ffffff #404040; + border-radius: 0; + background: #ffffff; +} + +.theme-key-windows95 .progress span { + border-radius: 0; + background: repeating-linear-gradient( + 90deg, + var(--accent) 0, + var(--accent) 8px, + #ffffff 8px, + #ffffff 10px + ); + box-shadow: none; +} + +.theme-key-windows95 .language-select-item { + border-radius: 0; +} + +.theme-key-windows95 .language-select-item[data-highlighted], +.theme-key-windows95 .language-select-item[data-selected] { + background: var(--accent); + color: #ffffff !important; +} + +.theme-key-windows95 .field-error-tooltip { + border-radius: 0; +} + +.theme-key-windows95 .admin-screen-wrap { + background: var(--admin-bg); +} + +.theme-key-windows95 .admin-sidebar, +.theme-key-windows95 .admin-header, +.theme-key-windows95 .admin-card, +.theme-key-windows95 .admin-stat-card, +.theme-key-windows95 .admin-revenue-panel, +.theme-key-windows95 .admin-empty, +.theme-key-windows95 .admin-tariff-card, +.theme-key-windows95 .admin-toolbar-card, +.theme-key-windows95 .admin-table-card, +.theme-key-windows95 .admin-panel-dash-card, +.theme-key-windows95 .admin-select-trigger, +.theme-key-windows95 .admin-select-content, +.theme-key-windows95 .admin-cn-card[data-slot="card"], +.theme-key-windows95 .admin-dialog .dialog-card, +.theme-key-windows95 .admin-theme-editor-section { + border-width: 2px; + border-style: solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0; + background: var(--admin-surface); + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 .admin-header, +.theme-key-windows95 .admin-sidebar-brand, +.theme-key-windows95 .admin-card-head { + border-bottom: 2px solid #000000; +} + +.theme-key-windows95 .admin-header { + background: linear-gradient(90deg, var(--accent), #1084d0); + color: #ffffff; +} + +.theme-key-windows95 .admin-header-title h2, +.theme-key-windows95 .admin-header-title small { + color: #ffffff; +} + +.theme-key-windows95 .admin-nav-item, +.theme-key-windows95 .admin-btn, +.theme-key-windows95 .admin-chip, +.theme-key-windows95 .admin-tabs-trigger, +.theme-key-windows95 .admin-revenue-period-btn, +.theme-key-windows95 .admin-mobile-toggle { + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0; + background: var(--admin-surface); + color: var(--admin-text); + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 .admin-nav-item:hover, +.theme-key-windows95 .admin-select-item[data-highlighted], +.theme-key-windows95 .admin-select-item:hover, +.theme-key-windows95 .admin-table tbody tr:hover { + background: color-mix(in srgb, var(--accent) 20%, var(--admin-surface)); +} + +.theme-key-windows95 .admin-nav-item.active, +.theme-key-windows95 .admin-btn-primary, +.theme-key-windows95 .admin-tabs-trigger[data-state="active"], +.theme-key-windows95 .admin-revenue-period-btn.is-active { + background: var(--accent); + color: #ffffff; + border-color: #ffffff #404040 #404040 #ffffff; +} + +.theme-key-windows95 .admin-input, +.theme-key-windows95 .admin-textarea, +.theme-key-windows95 .admin-screen-wrap textarea, +.theme-key-windows95 .admin-dialog textarea, +.theme-key-windows95 .input { + border: 2px solid; + border-color: #404040 #ffffff #ffffff #404040; + border-radius: 0; + background: #ffffff; + color: #000000; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 .admin-cn-badge, +.theme-key-windows95 .admin-badge { + border-radius: 0; +} + +.theme-key-windows95 ::selection { + background: var(--accent); + color: #ffffff; +} + +.theme-key-windows95 .btn:focus-visible, +.theme-key-windows95 .admin-btn:focus-visible, +.theme-key-windows95 .admin-nav-item:focus-visible, +.theme-key-windows95 .admin-tabs-trigger:focus-visible, +.theme-key-windows95 .admin-revenue-period-btn:focus-visible, +.theme-key-windows95 .admin-mobile-toggle:focus-visible, +.theme-key-windows95 .language-select-trigger:focus-visible, +.theme-key-windows95 .bottom-nav button:focus-visible { + outline: 1px dotted #000000; + outline-offset: -4px; +} + +.theme-key-windows95 .admin-input:focus, +.theme-key-windows95 .admin-textarea:focus, +.theme-key-windows95 .admin-screen-wrap textarea:focus, +.theme-key-windows95 .admin-dialog textarea:focus, +.theme-key-windows95 .input:focus { + outline: none; + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #000000, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 .admin-header-title h2, +.theme-key-windows95 .admin-sidebar-brand strong, +.theme-key-windows95 .admin-card-head h3, +.theme-key-windows95 .admin-card-head h2, +.theme-key-windows95 .login-brand h1, +.theme-key-windows95 .brand-row strong { + font-weight: 700; + letter-spacing: 0; +} + +.theme-key-windows95 .admin-nav-item:hover, +.theme-key-windows95 .admin-select-item[data-highlighted], +.theme-key-windows95 .admin-select-item:hover { + background: color-mix(in srgb, var(--accent) 30%, var(--admin-surface)); +} + +.theme-key-windows95 .admin-nav-item.active, +.theme-key-windows95 .admin-btn-primary, +.theme-key-windows95 .admin-tabs-trigger[data-state="active"], +.theme-key-windows95 .admin-revenue-period-btn.is-active { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #000000, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 .btn-primary svg.lucide, +.theme-key-windows95 .bottom-nav button.active svg.lucide, +.theme-key-windows95 .period-card.active svg.lucide, +.theme-key-windows95 .method-card.active svg.lucide, +.theme-key-windows95 .option-row.active svg.lucide, +.theme-key-windows95 .admin-btn-primary svg.lucide, +.theme-key-windows95 .admin-nav-item.active svg.lucide, +.theme-key-windows95 .admin-tabs-trigger[data-state="active"] svg.lucide, +.theme-key-windows95 .admin-revenue-period-btn.is-active svg.lucide, +.theme-key-windows95 .admin-header svg.lucide { + filter: brightness(0) invert(1); +} + +.theme-key-windows95 svg.lucide-activity, +.theme-key-windows95 svg.lucide-calendar-days, +.theme-key-windows95 svg.lucide-chevron-down, +.theme-key-windows95 svg.lucide-chevron-left, +.theme-key-windows95 svg.lucide-chevron-right, +.theme-key-windows95 svg.lucide-chevron-up, +.theme-key-windows95 svg.lucide-circle, +.theme-key-windows95 svg.lucide-crown, +.theme-key-windows95 svg.lucide-eye, +.theme-key-windows95 svg.lucide-eye-off, +.theme-key-windows95 svg.lucide-map, +.theme-key-windows95 svg.lucide-menu, +.theme-key-windows95 svg.lucide-mouse-pointer-click, +.theme-key-windows95 svg.lucide-qr-code, +.theme-key-windows95 svg.lucide-radio, +.theme-key-windows95 svg.lucide-repeat-2, +.theme-key-windows95 svg.lucide-server, +.theme-key-windows95 svg.lucide-trending-down, +.theme-key-windows95 svg.lucide-trending-up, +.theme-key-windows95 svg.lucide-user-minus, +.theme-key-windows95 svg.lucide-user-plus, +.theme-key-windows95 svg.lucide-zap { + color: var(--text); + stroke-width: 2.5; +} + +.theme-key-windows95 ::-webkit-scrollbar { + width: 16px; + height: 16px; +} + +.theme-key-windows95 ::-webkit-scrollbar-track { + background-color: #c0c0c0; + background-image: + linear-gradient(45deg, #ffffff 25%, transparent 25%, transparent 75%, #ffffff 75%), + linear-gradient(45deg, #ffffff 25%, transparent 25%, transparent 75%, #ffffff 75%); + background-size: 4px 4px; + background-position: 0 0, 2px 2px; +} + +.theme-key-windows95 ::-webkit-scrollbar-thumb { + background: var(--panel); + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 ::-webkit-scrollbar-thumb:active { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 ::-webkit-scrollbar-corner { + background: var(--panel); +} + +.theme-key-windows95 .progress span { + background: repeating-linear-gradient( + 90deg, + var(--accent) 0, + var(--accent) 6px, + #1084d0 6px, + #1084d0 8px + ); +} + +.theme-key-windows95 .card::before, +.theme-key-windows95 .dialog-card::before { + height: 20px; +} + +.theme-key-windows95 .admin-table tbody tr { + border-bottom: 1px solid #808080; +} + +.theme-key-windows95 .admin-badge, +.theme-key-windows95 .admin-cn-badge { + border: 1px solid #000000; + background: var(--panel); + color: var(--text); + font-weight: 700; +} + +.theme-key-windows95 .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item), +.theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]) { + color: var(--accent); + text-decoration: underline; +} + +.theme-key-windows95 .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item):visited, +.theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):visited { + color: #800080; +} diff --git a/bot/app/web/themes/windows95/theme.json b/bot/app/web/themes/windows95/theme.json new file mode 100644 index 0000000..f390472 --- /dev/null +++ b/bot/app/web/themes/windows95/theme.json @@ -0,0 +1,17 @@ +{ + "key": "windows95", + "names": { + "ru": "Windows 95", + "en": "Windows 95" + }, + "enabled": true, + "default": false, + "use_primary_accent": false, + "use_in_admin": true, + "css_file": "style.css", + "assets_version": 1, + "tokens": { + "color_scheme": "light", + "style_preset": "win95" + } +} diff --git a/bot/app/web/webapp/_runtime.py b/bot/app/web/webapp/_runtime.py index e9b4ee0..5e10534 100644 --- a/bot/app/web/webapp/_runtime.py +++ b/bot/app/web/webapp/_runtime.py @@ -76,6 +76,17 @@ WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60 WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30 WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024 +WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024 +WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024 +WEBAPP_THEME_ASSET_CONTENT_TYPES = { + ".gif": "image/gif", + ".ico": "image/x-icon", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +} WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024 WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60 WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4 diff --git a/bot/app/web/webapp/assets.py b/bot/app/web/webapp/assets.py index f879a30..b27caa9 100644 --- a/bot/app/web/webapp/assets.py +++ b/bot/app/web/webapp/assets.py @@ -1,6 +1,13 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from config.webapp_themes_config import ( + default_webapp_theme_asset_file, + default_webapp_theme_css_files, + ensure_default_webapp_theme_descriptor_files, + public_themes_catalog_payload, +) + async def health_route(request: web.Request) -> 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 0000000..f864834 Binary files /dev/null and b/docs/remnawave-minishop.webp differ 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)