feat: custom themes initial
@@ -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: <key>/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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
@@ -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 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>{brandTitle}</title>
|
||||
{#if shellThemeCssHref}
|
||||
<link rel="stylesheet" href={shellThemeCssHref} data-theme-css={resolvedThemeKey} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<Tooltip.Provider>
|
||||
@@ -880,7 +909,7 @@
|
||||
{#if isPreviewBoard}
|
||||
<PreviewBoard config={CFG} mockData={DEV_MOCK.data} />
|
||||
{:else}
|
||||
<div class="app-shell" style={`--accent: ${accent};`}>
|
||||
<div class="app-shell {shellToneClass} {shellThemeClass}" style={shellStyle}>
|
||||
{#if mode === "loading"}
|
||||
<div class="loader">
|
||||
<BrandMark {brand} size="md" />
|
||||
@@ -931,6 +960,7 @@
|
||||
onSectionChange={handleAdminSectionChange}
|
||||
onSettingsSaved={handleAdminPersistedSaved}
|
||||
onTariffsSaved={handleAdminPersistedSaved}
|
||||
onThemesSaved={handleAdminPersistedSaved}
|
||||
{brandTitle}
|
||||
{brand}
|
||||
appVersion={CFG.appVersion}
|
||||
|
||||
@@ -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 @@
|
||||
<TariffsSection {at} {fmtMoney} />
|
||||
{/if}
|
||||
|
||||
{#if active === "themes"}
|
||||
<ThemesSection {at} {currentLang} />
|
||||
{/if}
|
||||
|
||||
{#if active === "settings"}
|
||||
<SettingsSection {at} {isCompact} {onSettingsSaved} {currentLang} />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<script>
|
||||
import { Check, FileText, RefreshCw, Save } from "$components/ui/icons.js";
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
|
||||
import { localizedThemeName } from "$lib/webapp/themeStyle.js";
|
||||
|
||||
export let at;
|
||||
export let currentLang = "ru";
|
||||
|
||||
const themesStore = getContext("themesStore");
|
||||
|
||||
$: ({ themesCatalog, themesLoading, themesDir, themesSaving } = $themesStore);
|
||||
$: activeKey = themesCatalog.default_theme;
|
||||
|
||||
function themeTitle(theme) {
|
||||
return localizedThemeName(theme, currentLang) || "—";
|
||||
}
|
||||
|
||||
function themeDescription(theme) {
|
||||
const folder = `${themesDir || "data/themes"}/${theme.key}`;
|
||||
return theme.css_file ? `${folder}/${theme.css_file}` : `${folder}/theme.json`;
|
||||
}
|
||||
|
||||
function toggleAccent(event, theme) {
|
||||
event.stopPropagation();
|
||||
themesStore.togglePrimaryAccent(theme.key, event.currentTarget.checked);
|
||||
}
|
||||
|
||||
function toggleAdminTheme(event, theme) {
|
||||
event.stopPropagation();
|
||||
themesStore.toggleAdminUse(theme.key, event.currentTarget.checked);
|
||||
}
|
||||
|
||||
function isThemeOptionEvent(event) {
|
||||
return Boolean(event?.target?.closest?.(".admin-theme-card-option"));
|
||||
}
|
||||
|
||||
function selectTheme(theme, event = null) {
|
||||
if (isThemeOptionEvent(event)) return;
|
||||
if (!themesSaving) themesStore.setCurrentTheme(theme.key);
|
||||
}
|
||||
|
||||
function handleCardKeydown(event, theme) {
|
||||
if (isThemeOptionEvent(event)) return;
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
selectTheme(theme);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
themesStore.loadThemes();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if themesLoading}
|
||||
<AdminEmptyState>{at("loading", {}, "Загрузка…")}</AdminEmptyState>
|
||||
{:else}
|
||||
<article class="admin-card">
|
||||
<header class="admin-card-head">
|
||||
<div>
|
||||
<h3>{at("themes_catalog_title", {}, "Темы Web App")}</h3>
|
||||
<small
|
||||
>{at(
|
||||
"themes_catalog_sub",
|
||||
{},
|
||||
"Текущая тема выбирается карточкой; внешний вид редактируется файлами в папке темы"
|
||||
)}</small
|
||||
>
|
||||
</div>
|
||||
<div class="admin-editor-section-actions">
|
||||
<AdminButton
|
||||
size="sm"
|
||||
onclick={themesStore.loadThemes}
|
||||
disabled={themesLoading || themesSaving}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
{at("btn_refresh", {}, "Обновить")}
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onclick={themesStore.saveThemes}
|
||||
disabled={themesLoading || themesSaving}
|
||||
>
|
||||
<Save size={13} />
|
||||
{at("btn_save", {}, "Сохранить")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-card-body">
|
||||
{#if !themesCatalog.themes.length}
|
||||
<AdminEmptyState>
|
||||
{at(
|
||||
"themes_catalog_empty",
|
||||
{},
|
||||
"Каталог пуст. Добавьте папку темы в data/themes и обновите список."
|
||||
)}
|
||||
</AdminEmptyState>
|
||||
{:else}
|
||||
<div class="admin-theme-grid">
|
||||
{#each themesCatalog.themes as theme (theme.key)}
|
||||
{@const isCurrent = theme.key === activeKey}
|
||||
<div
|
||||
role="button"
|
||||
tabindex={themesSaving ? -1 : 0}
|
||||
class="admin-theme-card"
|
||||
class:is-current={isCurrent}
|
||||
class:is-disabled={theme.enabled === false}
|
||||
aria-pressed={isCurrent}
|
||||
aria-disabled={themesSaving}
|
||||
onclick={(event) => selectTheme(theme, event)}
|
||||
onkeydown={(event) => handleCardKeydown(event, theme)}
|
||||
>
|
||||
<span class="admin-theme-card-main">
|
||||
<span class="admin-theme-card-title">
|
||||
<strong>{themeTitle(theme)}</strong>
|
||||
{#if isCurrent}
|
||||
<AdminBadge variant="success">{at("status_current", {}, "Текущая")}</AdminBadge>
|
||||
{/if}
|
||||
</span>
|
||||
<small>{theme.key}</small>
|
||||
</span>
|
||||
<span class="admin-theme-card-meta">
|
||||
<FileText size={15} />
|
||||
<span>{themeDescription(theme)}</span>
|
||||
</span>
|
||||
<label class="admin-theme-card-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={theme.use_primary_accent !== false}
|
||||
disabled={themesSaving}
|
||||
onchange={(event) => toggleAccent(event, theme)}
|
||||
/>
|
||||
<span>{at("themes_use_primary_accent", {}, "Протягивать акцент")}</span>
|
||||
</label>
|
||||
<label class="admin-theme-card-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={theme.use_in_admin !== false}
|
||||
disabled={themesSaving}
|
||||
onchange={(event) => toggleAdminTheme(event, theme)}
|
||||
/>
|
||||
<span>{at("themes_use_in_admin", {}, "Использовать в админке")}</span>
|
||||
</label>
|
||||
<span class="admin-theme-card-check" aria-hidden="true">
|
||||
{#if isCurrent}<Check size={18} />{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.admin-theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-theme-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
min-height: 118px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
background: var(--admin-surface);
|
||||
color: var(--admin-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-theme-card:hover {
|
||||
border-color: var(--admin-border-strong);
|
||||
background: color-mix(in srgb, var(--admin-surface-2) 72%, var(--admin-surface));
|
||||
}
|
||||
|
||||
.admin-theme-card.is-current {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 44%, transparent);
|
||||
}
|
||||
|
||||
.admin-theme-card.is-disabled {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.admin-theme-card-main {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-theme-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-theme-card-title strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-theme-card-main small,
|
||||
.admin-theme-card-meta {
|
||||
color: var(--admin-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-theme-card-meta {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-theme-card-meta span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-theme-card-option {
|
||||
grid-column: 1 / -1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
color: var(--admin-muted);
|
||||
font-size: 12px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.admin-theme-card-option input {
|
||||
flex: 0 0 auto;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-theme-card-option span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-theme-card-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export {
|
||||
Menu,
|
||||
MessageSquare,
|
||||
MousePointerClick,
|
||||
Paintbrush,
|
||||
Plus,
|
||||
QrCode,
|
||||
Radio,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || "";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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/...`.
|
||||
|
After Width: | Height: | Size: 610 B |
|
After Width: | Height: | Size: 423 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 340 B |
|
After Width: | Height: | Size: 375 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 371 B |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 419 B |
|
After Width: | Height: | Size: 372 B |
|
After Width: | Height: | Size: 388 B |
|
After Width: | Height: | Size: 378 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 390 B |
|
After Width: | Height: | Size: 385 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 393 B |
|
After Width: | Height: | Size: 474 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 461 B |
|
After Width: | Height: | Size: 327 B |
|
After Width: | Height: | Size: 411 B |
|
After Width: | Height: | Size: 395 B |
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 393 B |
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 478 B |
|
After Width: | Height: | Size: 500 B |
|
After Width: | Height: | Size: 589 B |
|
After Width: | Height: | Size: 392 B |
|
After Width: | Height: | Size: 392 B |
|
After Width: | Height: | Size: 384 B |
|
After Width: | Height: | Size: 419 B |
|
After Width: | Height: | Size: 403 B |
|
After Width: | Height: | Size: 371 B |
|
After Width: | Height: | Size: 344 B |
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 "
|
||||
"<key>/theme.json with optional CSS/assets next to it."
|
||||
),
|
||||
)
|
||||
WEBAPP_DEFAULT_THEME: Optional[str] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Override the descriptor-marked default theme when set to an existing theme key."
|
||||
),
|
||||
)
|
||||
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
|
||||
WEBAPP_LOGO_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]:
|
||||
|
||||
@@ -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/<key>/theme.json."""
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
key: str = Field(min_length=1, max_length=64)
|
||||
names: Dict[str, str] = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
default: bool = False
|
||||
use_primary_accent: bool = True
|
||||
use_in_admin: bool = True
|
||||
css_file: Optional[str] = None
|
||||
assets_version: int = 1
|
||||
tokens: ThemeTokens = Field(default_factory=ThemeTokens)
|
||||
|
||||
|
||||
class WebappThemesConfig(BaseModel):
|
||||
"""Runtime catalog assembled from individual theme descriptor files."""
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
default_theme: str = "dark"
|
||||
themes: List[WebappTheme] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_default_and_keys(self) -> WebappThemesConfig:
|
||||
keys = [t.key for t in self.themes]
|
||||
if len(keys) != len(set(keys)):
|
||||
raise ValueError("duplicate theme keys")
|
||||
if self.themes and self.default_theme not in keys:
|
||||
raise ValueError("default_theme must match a theme key")
|
||||
return self
|
||||
|
||||
def theme_by_key(self, key: str) -> Optional[WebappTheme]:
|
||||
for theme in self.themes:
|
||||
if theme.key == key:
|
||||
return theme
|
||||
return None
|
||||
|
||||
def enabled_themes(self) -> List[WebappTheme]:
|
||||
return [theme for theme in self.themes if theme.enabled]
|
||||
|
||||
|
||||
DEFAULT_THEME_KEYS = ("dark", "light", "windows95", "ascii")
|
||||
THEME_DESCRIPTOR_FILENAME = "theme.json"
|
||||
DEFAULT_THEMES_SOURCE_DIR = Path(__file__).resolve().parents[1] / "bot" / "app" / "web" / "themes"
|
||||
|
||||
|
||||
def _safe_theme_key(value: str) -> Optional[str]:
|
||||
key = str(value or "").strip()
|
||||
if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key):
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def _theme_dir_path(theme_dir: str | Path, key: str) -> Path:
|
||||
safe_key = _safe_theme_key(key)
|
||||
if not safe_key:
|
||||
raise ValueError(f"invalid theme key: {key!r}")
|
||||
return Path(theme_dir).expanduser() / safe_key
|
||||
|
||||
|
||||
def _theme_file_path(theme_dir: str | Path, key: str) -> Path:
|
||||
return _theme_dir_path(theme_dir, key) / THEME_DESCRIPTOR_FILENAME
|
||||
|
||||
|
||||
def default_webapp_theme_css_files() -> Dict[str, str]:
|
||||
"""Read default theme CSS from repository files."""
|
||||
out: Dict[str, str] = {}
|
||||
for key in DEFAULT_THEME_KEYS:
|
||||
source_dir = DEFAULT_THEMES_SOURCE_DIR / key
|
||||
for source_path in sorted(source_dir.rglob("*.css")):
|
||||
rel_path = Path(key) / source_path.relative_to(source_dir)
|
||||
try:
|
||||
content = source_path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Default webapp theme CSS source file is missing: %s (%s)",
|
||||
source_path,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
out[rel_path.as_posix()] = content if content.endswith("\n") else f"{content}\n"
|
||||
return out
|
||||
|
||||
|
||||
def default_webapp_theme_asset_file(rel_path: str | Path) -> Optional[tuple[bytes, str]]:
|
||||
"""Read a default theme asset from the repository theme folder."""
|
||||
relative = Path(rel_path)
|
||||
if relative.is_absolute() or len(relative.parts) < 2 or ".." in relative.parts:
|
||||
return None
|
||||
source_path = (DEFAULT_THEMES_SOURCE_DIR / relative).resolve()
|
||||
try:
|
||||
source_path.relative_to(DEFAULT_THEMES_SOURCE_DIR.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
try:
|
||||
return source_path.read_bytes(), source_path.suffix.lower()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def default_webapp_theme_descriptors() -> Dict[str, Dict[str, Any]]:
|
||||
"""Read default theme descriptors from repository files."""
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
for key in DEFAULT_THEME_KEYS:
|
||||
source_path = DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME
|
||||
try:
|
||||
raw = json.loads(source_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning(
|
||||
"Default webapp theme descriptor is missing or invalid: %s (%s)",
|
||||
source_path,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
safe_key = _safe_theme_key(str(raw.get("key") or source_path.parent.name))
|
||||
if safe_key:
|
||||
raw["key"] = safe_key
|
||||
out[safe_key] = raw
|
||||
return out
|
||||
|
||||
|
||||
def _theme_from_descriptor(path: Path, raw: Any) -> Optional[WebappTheme]:
|
||||
if not isinstance(raw, dict):
|
||||
logger.warning("Ignoring theme descriptor %s: expected JSON object", path)
|
||||
return None
|
||||
data = dict(raw)
|
||||
data["key"] = data.get("key") or (
|
||||
path.parent.name if path.name == THEME_DESCRIPTOR_FILENAME else path.stem
|
||||
)
|
||||
safe_key = _safe_theme_key(str(data["key"]))
|
||||
if not safe_key:
|
||||
logger.warning("Ignoring theme descriptor %s: invalid theme key %r", path, data["key"])
|
||||
return None
|
||||
data["key"] = safe_key
|
||||
try:
|
||||
return WebappTheme.model_validate(data)
|
||||
except ValueError as exc:
|
||||
logger.warning("Ignoring theme descriptor %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
|
||||
def load_webapp_theme_file(path: str | Path) -> Optional[WebappTheme]:
|
||||
theme_path = Path(path).expanduser()
|
||||
try:
|
||||
raw = json.loads(theme_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Failed to load webapp theme descriptor from %s: %s", theme_path, exc)
|
||||
return None
|
||||
return _theme_from_descriptor(theme_path, raw)
|
||||
|
||||
|
||||
def load_webapp_theme_dir(theme_dir: str | Path) -> List[WebappTheme]:
|
||||
root = Path(theme_dir).expanduser()
|
||||
if not root.exists():
|
||||
return []
|
||||
themes_by_key: Dict[str, WebappTheme] = {}
|
||||
for path in sorted(root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}")):
|
||||
if path.parent.name.startswith("_"):
|
||||
continue
|
||||
theme = load_webapp_theme_file(path)
|
||||
if theme is None:
|
||||
continue
|
||||
if theme.key in themes_by_key:
|
||||
logger.warning("Ignoring duplicate webapp theme key %s from %s", theme.key, path)
|
||||
continue
|
||||
themes_by_key[theme.key] = theme
|
||||
return list(themes_by_key.values())
|
||||
|
||||
|
||||
def _config_with_synced_default_flags(config: WebappThemesConfig) -> WebappThemesConfig:
|
||||
data = config.model_dump(mode="json", exclude_none=True)
|
||||
default_theme = str(data.get("default_theme") or "dark")
|
||||
themes = data.get("themes", [])
|
||||
for theme in themes:
|
||||
if isinstance(theme, dict):
|
||||
theme["default"] = theme.get("key") == default_theme
|
||||
|
||||
def _sort_key(item: tuple[int, Any]) -> tuple[int, int]:
|
||||
idx, theme = item
|
||||
if not isinstance(theme, dict):
|
||||
return (len(THEME_DISPLAY_ORDER), idx)
|
||||
try:
|
||||
priority = THEME_DISPLAY_ORDER.index(str(theme.get("key") or ""))
|
||||
except ValueError:
|
||||
priority = len(THEME_DISPLAY_ORDER)
|
||||
return (priority, idx)
|
||||
|
||||
data["themes"] = [theme for _, theme in sorted(enumerate(themes), key=_sort_key)]
|
||||
return WebappThemesConfig.model_validate(data)
|
||||
|
||||
|
||||
THEME_DISPLAY_ORDER = ("dark", "light")
|
||||
|
||||
|
||||
def _theme_sort_key(theme: WebappTheme, index: int) -> tuple[int, int]:
|
||||
try:
|
||||
priority = THEME_DISPLAY_ORDER.index(theme.key)
|
||||
except ValueError:
|
||||
priority = len(THEME_DISPLAY_ORDER)
|
||||
return (priority, index)
|
||||
|
||||
|
||||
def _sorted_themes(themes: List[WebappTheme]) -> List[WebappTheme]:
|
||||
return [theme for _, theme in sorted(
|
||||
((_theme_sort_key(t, i), t) for i, t in enumerate(themes)),
|
||||
key=lambda pair: pair[0],
|
||||
)]
|
||||
|
||||
|
||||
def _themes_config_from_list(
|
||||
default_theme: Optional[str],
|
||||
themes: List[WebappTheme],
|
||||
) -> WebappThemesConfig:
|
||||
keys = {theme.key for theme in themes}
|
||||
descriptor_default = next(
|
||||
(theme.key for theme in themes if theme.default and theme.key not in DEFAULT_THEME_KEYS),
|
||||
None,
|
||||
) or next((theme.key for theme in themes if theme.default), None)
|
||||
resolved_default = default_theme or descriptor_default or "dark"
|
||||
if themes and resolved_default not in keys:
|
||||
resolved_default = "dark" if "dark" in keys else themes[0].key
|
||||
config = WebappThemesConfig(default_theme=resolved_default, themes=_sorted_themes(themes))
|
||||
return _config_with_synced_default_flags(config)
|
||||
|
||||
|
||||
def _write_webapp_theme_file(path: Path, theme: WebappTheme) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = theme.model_dump(mode="json", exclude_none=True)
|
||||
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
||||
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
|
||||
try:
|
||||
tmp_path.write_text(payload, encoding="utf-8")
|
||||
tmp_path.replace(path)
|
||||
except PermissionError:
|
||||
if tmp_path.exists():
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
path.write_text(payload, encoding="utf-8")
|
||||
|
||||
|
||||
def _copy_default_theme_assets(key: str, target_dir: Path, *, overwrite: bool = False) -> None:
|
||||
source_dir = DEFAULT_THEMES_SOURCE_DIR / key
|
||||
if not source_dir.exists():
|
||||
return
|
||||
for source_path in sorted(source_dir.rglob("*")):
|
||||
if not source_path.is_file() or source_path.name == THEME_DESCRIPTOR_FILENAME:
|
||||
continue
|
||||
rel_path = source_path.relative_to(source_dir)
|
||||
target_path = target_dir / rel_path
|
||||
if target_path.exists() and not overwrite:
|
||||
continue
|
||||
try:
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_bytes(source_path.read_bytes())
|
||||
except OSError as exc:
|
||||
logger.warning("Could not create default webapp theme asset %s: %s", target_path, exc)
|
||||
|
||||
|
||||
def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
|
||||
style_path = target_dir / "style.css"
|
||||
try:
|
||||
style = style_path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return True
|
||||
if key == "light":
|
||||
return ".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/<key>."""
|
||||
root = Path(theme_dir).expanduser()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
normalized = _config_with_synced_default_flags(config)
|
||||
keep_paths = set()
|
||||
for theme in normalized.themes:
|
||||
path = _theme_file_path(root, theme.key)
|
||||
_write_webapp_theme_file(path, theme)
|
||||
keep_paths.add(path.resolve())
|
||||
|
||||
if not delete_missing:
|
||||
return
|
||||
for path in root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}"):
|
||||
if path.parent.name.startswith("_") or path.resolve() in keep_paths:
|
||||
continue
|
||||
try:
|
||||
path.unlink()
|
||||
if not any(path.parent.iterdir()):
|
||||
path.parent.rmdir()
|
||||
except OSError as exc:
|
||||
logger.warning("Could not delete removed webapp theme descriptor %s: %s", path, exc)
|
||||
|
||||
|
||||
def ensure_webapp_core_themes(
|
||||
config: WebappThemesConfig, primary_accent: str
|
||||
) -> tuple[WebappThemesConfig, bool]:
|
||||
"""Keep dark, light and Windows 95 themes available without clobbering custom edits."""
|
||||
data = config.model_dump(mode="json", exclude_none=True)
|
||||
themes = data.setdefault("themes", [])
|
||||
by_key = {str(theme.get("key")): theme for theme in themes if isinstance(theme, dict)}
|
||||
changed = False
|
||||
|
||||
for builtin in builtin_webapp_themes_config(primary_accent).themes:
|
||||
builtin_data = builtin.model_dump(mode="json", exclude_none=True)
|
||||
existing = by_key.get(builtin.key)
|
||||
if existing is None:
|
||||
themes.append(builtin_data)
|
||||
by_key[builtin.key] = builtin_data
|
||||
changed = True
|
||||
continue
|
||||
|
||||
if existing.get("enabled") is False:
|
||||
existing["enabled"] = True
|
||||
changed = True
|
||||
|
||||
if "use_primary_accent" not in existing:
|
||||
existing["use_primary_accent"] = builtin_data.get("use_primary_accent", True)
|
||||
changed = True
|
||||
|
||||
if "use_in_admin" not in existing:
|
||||
existing["use_in_admin"] = builtin_data.get("use_in_admin", True)
|
||||
changed = True
|
||||
|
||||
if int(existing.get("assets_version") or 0) < int(builtin_data.get("assets_version") or 1):
|
||||
existing["assets_version"] = builtin_data.get("assets_version", 1)
|
||||
changed = True
|
||||
|
||||
if builtin.key in {"light", "windows95", "ascii"} and not existing.get("css_file"):
|
||||
existing["css_file"] = builtin_data.get("css_file")
|
||||
changed = True
|
||||
|
||||
tokens = existing.setdefault("tokens", {})
|
||||
builtin_tokens = builtin_data.get("tokens", {})
|
||||
for token_key in ("color_scheme", "style_preset"):
|
||||
if token_key in builtin_tokens and not tokens.get(token_key):
|
||||
tokens[token_key] = builtin_tokens[token_key]
|
||||
changed = True
|
||||
|
||||
keys = {str(theme.get("key")) for theme in themes if isinstance(theme, dict)}
|
||||
if themes and data.get("default_theme") not in keys:
|
||||
data["default_theme"] = "dark" if "dark" in keys else str(themes[0].get("key") or "dark")
|
||||
changed = True
|
||||
|
||||
normalized = _config_with_synced_default_flags(WebappThemesConfig.model_validate(data))
|
||||
if normalized.model_dump(mode="json", exclude_none=True) != data:
|
||||
changed = True
|
||||
return normalized, changed
|
||||
|
||||
|
||||
def builtin_webapp_themes_config(primary_accent: str) -> WebappThemesConfig:
|
||||
"""Default catalog backed by repository theme descriptor files."""
|
||||
accent = (primary_accent or "#00fe7a").strip() or "#00fe7a"
|
||||
themes: List[WebappTheme] = []
|
||||
descriptors = default_webapp_theme_descriptors()
|
||||
for key in DEFAULT_THEME_KEYS:
|
||||
raw = descriptors.get(key)
|
||||
if not raw:
|
||||
continue
|
||||
theme = _theme_from_descriptor(
|
||||
DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME,
|
||||
raw,
|
||||
)
|
||||
if theme is None:
|
||||
continue
|
||||
if theme.key == "dark" and not theme.tokens.accent:
|
||||
theme.tokens.accent = accent
|
||||
themes.append(theme)
|
||||
return _themes_config_from_list(None, themes)
|
||||
|
||||
|
||||
def apply_webapp_theme_env_overrides(
|
||||
config: WebappThemesConfig, env_default_theme: Optional[str]
|
||||
) -> WebappThemesConfig:
|
||||
"""If WEBAPP_DEFAULT_THEME is set and matches a theme key, override the default theme."""
|
||||
raw = (env_default_theme or "").strip()
|
||||
if not raw:
|
||||
return config
|
||||
if config.theme_by_key(raw) is None:
|
||||
logger.warning("WEBAPP_DEFAULT_THEME=%r ignored: no such theme in catalog", raw)
|
||||
return config
|
||||
data = config.model_dump(mode="json", exclude_none=True)
|
||||
data["default_theme"] = raw
|
||||
return _config_with_synced_default_flags(WebappThemesConfig.model_validate(data))
|
||||
|
||||
|
||||
def resolved_webapp_themes_catalog(
|
||||
*,
|
||||
theme_dir: str | Path,
|
||||
primary_accent: str,
|
||||
env_default_theme: Optional[str],
|
||||
) -> WebappThemesConfig:
|
||||
"""Load themes from WEBAPP_THEMES_DIR, seeding defaults when possible."""
|
||||
ensure_default_webapp_theme_descriptor_files(theme_dir)
|
||||
|
||||
themes = load_webapp_theme_dir(theme_dir)
|
||||
config = _themes_config_from_list(None, themes)
|
||||
config, changed = ensure_webapp_core_themes(config, primary_accent)
|
||||
if changed:
|
||||
try:
|
||||
write_webapp_theme_dir(theme_dir, config, delete_missing=False)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not update webapp theme descriptors in %s: %s", theme_dir, exc)
|
||||
return apply_webapp_theme_env_overrides(config, env_default_theme)
|
||||
|
||||
|
||||
def merge_primary_accent_into_theme_tokens(
|
||||
theme: WebappTheme, primary_accent: str, *, only_if_token_missing: bool = True
|
||||
) -> ThemeTokens:
|
||||
"""Fill accent from WEBAPP_PRIMARY_COLOR when theme tokens omit accent."""
|
||||
base = theme.tokens.model_copy(deep=True)
|
||||
accent = (primary_accent or "").strip()
|
||||
if not accent:
|
||||
return base
|
||||
if only_if_token_missing and base.accent:
|
||||
return base
|
||||
base.accent = accent
|
||||
return base
|
||||
|
||||
|
||||
def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]:
|
||||
tokens = (
|
||||
merge_primary_accent_into_theme_tokens(theme, primary_accent)
|
||||
if theme.use_primary_accent
|
||||
else theme.tokens
|
||||
)
|
||||
payload: Dict[str, object] = {
|
||||
"key": theme.key,
|
||||
"names": dict(theme.names),
|
||||
"enabled": bool(theme.enabled),
|
||||
"use_primary_accent": bool(theme.use_primary_accent),
|
||||
"use_in_admin": bool(theme.use_in_admin),
|
||||
"tokens": tokens.model_dump(mode="json", exclude_none=True),
|
||||
}
|
||||
if theme.css_file:
|
||||
payload["css_file"] = theme.css_file
|
||||
return payload
|
||||
|
||||
|
||||
def public_themes_catalog_payload(
|
||||
config: WebappThemesConfig, primary_accent: str, *, enabled_only: bool = False
|
||||
) -> Dict[str, object]:
|
||||
themes = [theme for theme in config.themes if not enabled_only or theme.enabled]
|
||||
return {
|
||||
"default_theme": config.default_theme,
|
||||
"themes": [public_theme_payload(theme, primary_accent) for theme in themes],
|
||||
}
|
||||
|
After Width: | Height: | Size: 119 KiB |
@@ -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",
|
||||
|
||||
@@ -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": "Периоды",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||