feat: custom themes polishing

This commit is contained in:
3252a8
2026-05-15 11:17:27 +03:00
parent b6ee5e8790
commit af7cee5014
28 changed files with 1474 additions and 306 deletions
-3
View File
@@ -38,11 +38,8 @@ WEBAPP_ENABLED=True #
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
WEBAPP_TITLE="/minishop" # Mini App title WEBAPP_TITLE="/minishop" # Mini App title
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
WEBAPP_THEMES_DIR=data/themes # Folder with theme subfolders: <key>/theme.json and optional CSS/assets WEBAPP_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_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 WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h) WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
+1
View File
@@ -56,4 +56,5 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_put("/api/admin/tariffs", admin_tariffs_save_route) router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
router.add_get("/api/admin/themes", admin_themes_get_route) router.add_get("/api/admin/themes", admin_themes_get_route)
router.add_put("/api/admin/themes", admin_themes_save_route) router.add_put("/api/admin/themes", admin_themes_save_route)
router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route)
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route) router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
+7
View File
@@ -70,5 +70,12 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
if isinstance(cache, dict): if isinstance(cache, dict):
cache["ts"] = 0.0 cache["ts"] = 0.0
cache["data"] = {} cache["data"] = {}
if (
"WEBAPP_LOGO_URL" in updates
or "WEBAPP_LOGO_URL" in deletes
or "WEBAPP_LOGO_USE_EMOJI" in updates
or "WEBAPP_LOGO_USE_EMOJI" in deletes
):
request.app["webapp_logo_cache"] = None
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)}) return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
+169
View File
@@ -1,6 +1,14 @@
# ruff: noqa: F401,F403,F405,I001 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
import asyncio
import hashlib
import ipaddress
import re
import socket
from aiohttp import ClientSession, ClientTimeout
from config.webapp_themes_config import ( from config.webapp_themes_config import (
WebappThemesConfig, WebappThemesConfig,
ensure_webapp_core_themes, ensure_webapp_core_themes,
@@ -9,6 +17,167 @@ from config.webapp_themes_config import (
) )
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" / "uploads"
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = {
".gif": "image/gif",
".ico": "image/x-icon",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
}
def _detect_logo_extension(
body: bytes, content_type: str = "", filename: str = ""
) -> Optional[str]:
content_type = (content_type or "").split(";", 1)[0].strip().lower()
suffix = Path(filename or "").suffix.lower()
if content_type == "image/png" or body.startswith(b"\x89PNG\r\n\x1a\n"):
return ".png"
if content_type == "image/jpeg" or body.startswith(b"\xff\xd8\xff"):
return ".jpg"
if content_type == "image/gif" or body.startswith((b"GIF87a", b"GIF89a")):
return ".gif"
if content_type == "image/webp" or (
len(body) > 12 and body[:4] == b"RIFF" and body[8:12] == b"WEBP"
):
return ".webp"
if content_type in {"image/svg+xml", "image/svg"} or suffix == ".svg":
head = body[:512].lstrip().lower()
if head.startswith(b"<svg") or b"<svg" in head:
return ".svg"
if content_type == "image/x-icon" or suffix == ".ico":
if body.startswith(b"\x00\x00\x01\x00"):
return ".ico"
return suffix if suffix in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES else None
def _write_uploaded_logo(body: bytes, content_type: str = "", filename: str = "") -> str:
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
raise ValueError("logo must be a non-empty image up to 2 MiB")
ext = _detect_logo_extension(body, content_type, filename)
if ext not in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES:
raise ValueError("unsupported image type")
digest = hashlib.sha256(body).hexdigest()[:16]
safe_name = f"logo-{digest}{ext}"
WEBAPP_UPLOADED_LOGO_DIR.mkdir(parents=True, exist_ok=True)
(WEBAPP_UPLOADED_LOGO_DIR / safe_name).write_bytes(body)
return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}"
async def _read_uploaded_logo_file(request: web.Request) -> tuple[bytes, str, str]:
reader = await request.multipart()
async for part in reader:
if part.name != "file":
continue
body = bytearray()
while True:
chunk = await part.read_chunk(size=64 * 1024)
if not chunk:
break
body.extend(chunk)
if len(body) > WEBAPP_LOGO_MAX_BYTES:
raise ValueError("logo must be up to 2 MiB")
return bytes(body), part.headers.get("Content-Type", ""), part.filename or ""
raise ValueError("file field is required")
async def _hostname_resolves_to_public_address(hostname: str) -> bool:
if not hostname:
return False
try:
ip_obj = ipaddress.ip_address(hostname)
return not (
ip_obj.is_private
or ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_unspecified
or ip_obj.is_reserved
)
except ValueError:
pass
loop = asyncio.get_running_loop()
try:
resolved = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
except Exception:
return False
found_public_ip = False
for entry in resolved:
sockaddr = entry[4]
candidate = sockaddr[0] if sockaddr else ""
try:
ip_obj = ipaddress.ip_address(candidate)
except ValueError:
continue
if (
ip_obj.is_private
or ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_unspecified
or ip_obj.is_reserved
):
return False
found_public_ip = True
return found_public_ip
async def _fetch_logo_from_url(url: str) -> tuple[bytes, str, str]:
parsed = urlsplit(url)
if parsed.scheme != "https" or not parsed.hostname:
raise ValueError("only https image URLs are supported")
if not await _hostname_resolves_to_public_address(parsed.hostname):
raise ValueError("logo URL must resolve to a public address")
timeout = ClientTimeout(total=5)
async with ClientSession(timeout=timeout, headers={"User-Agent": "Mozilla/5.0"}) as session:
async with session.get(
url,
allow_redirects=False,
headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"},
) as response:
if response.status != 200:
raise ValueError(f"logo URL returned HTTP {response.status}")
content_type = (
(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
)
if content_type and not content_type.startswith("image/"):
raise ValueError("logo URL returned non-image content")
body = bytearray()
async for chunk in response.content.iter_chunked(64 * 1024):
body.extend(chunk)
if len(body) > WEBAPP_LOGO_MAX_BYTES:
raise ValueError("logo must be up to 2 MiB")
return bytes(body), content_type, Path(parsed.path).name
async def admin_appearance_logo_upload_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
content_type = (request.headers.get("Content-Type") or "").lower()
try:
if content_type.startswith("multipart/form-data"):
body, detected_content_type, filename = await _read_uploaded_logo_file(request)
else:
payload = await _read_json(request)
source_url = str(payload.get("url") or "").strip()
if not source_url:
return _error(400, "invalid_payload", "url or file is required")
body, detected_content_type, filename = await _fetch_logo_from_url(source_url)
logo_url = _write_uploaded_logo(body, detected_content_type, filename)
except ValueError as exc:
return _error(400, "invalid_logo", str(exc))
except OSError as exc:
logger.exception("Failed to save uploaded webapp logo")
return _error(500, "write_failed", str(exc))
return _ok({"logo_url": logo_url})
async def admin_themes_get_route(request: web.Request) -> web.Response: async def admin_themes_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request) _require_admin_user_id(request)
settings: Settings = request.app["settings"] settings: Settings = request.app["settings"]
+1
View File
@@ -79,6 +79,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField( SettingField(
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a" "WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
), ),
SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"),
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"), SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"), SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
SettingField( SettingField(
+27 -3
View File
@@ -87,6 +87,7 @@
...(MOCK ? MOCK.config : {}), ...(MOCK ? MOCK.config : {}),
...(injectedConfig || {}), ...(injectedConfig || {}),
}; };
const themePreviewKey = String(CFG.themePreviewKey || query.get("theme_preview") || "").trim();
const I18N = injectedI18n || {}; const I18N = injectedI18n || {};
let telegramSdkStatus = "idle"; let telegramSdkStatus = "idle";
let telegramMiniAppInitData = ""; let telegramMiniAppInitData = "";
@@ -246,7 +247,7 @@
$: brandEmojiFont = CFG.logoEmojiFont || "system"; $: brandEmojiFont = CFG.logoEmojiFont || "system";
$: brand = normalizeBrand({ $: brand = normalizeBrand({
title: brandTitle, title: brandTitle,
logoUrl: CFG.logoUrl, logoUrl: CFG.logoUseEmoji ? "" : CFG.logoUrl,
emoji: brandEmoji, emoji: brandEmoji,
emojiFont: brandEmojiFont, emojiFont: brandEmojiFont,
}); });
@@ -308,7 +309,11 @@
$: user = data?.user || {}; $: user = data?.user || {};
$: themesCatalog = data?.themes_catalog || $: themesCatalog = data?.themes_catalog ||
CFG.themesCatalog || { default_theme: "dark", themes: [] }; CFG.themesCatalog || { default_theme: "dark", themes: [] };
$: resolvedThemeKey = resolveEffectiveThemeKey(themesCatalog); $: previewThemeAllowed = Boolean(themePreviewKey && (!data?.user || user?.is_admin));
$: previewThemeEntry = previewThemeAllowed
? findThemeEntry(themesCatalog, themePreviewKey)
: null;
$: resolvedThemeKey = previewThemeEntry?.key || resolveEffectiveThemeKey(themesCatalog);
$: activeThemeEntry = findThemeEntry(themesCatalog, resolvedThemeKey); $: activeThemeEntry = findThemeEntry(themesCatalog, resolvedThemeKey);
$: darkThemeEntry = findThemeEntry(themesCatalog, "dark"); $: darkThemeEntry = findThemeEntry(themesCatalog, "dark");
$: effectiveThemeEntry = $: effectiveThemeEntry =
@@ -870,13 +875,32 @@
); );
} }
async function handleAdminPersistedSaved() { function adminPayloadHasLogoChange(options = {}) {
const keys = new Set([
...Object.keys(options.updates || {}),
...(Array.isArray(options.deletes) ? options.deletes : []),
]);
return [
"WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
].some((key) => keys.has(key));
}
async function handleAdminPersistedSaved(options = {}) {
invalidateWebappTariffOptionCaches(billingStore); invalidateWebappTariffOptionCaches(billingStore);
try { try {
await loadData(); await loadData();
} catch { } catch {
// Admin save already succeeded; a later full refresh will pick up new settings or catalog. // Admin save already succeeded; a later full refresh will pick up new settings or catalog.
} }
const shouldReloadFrontend =
options?.reloadFrontend === true ||
(!options?.deferFrontendReload && adminPayloadHasLogoChange(options));
if (shouldReloadFrontend && typeof window !== "undefined") {
window.location.reload();
}
} }
function selectTariff(tariff) { function selectTariff(tariff) {
@@ -35,7 +35,7 @@
import StatsSection from "./sections/StatsSection.svelte"; import StatsSection from "./sections/StatsSection.svelte";
import TariffEditorModal from "./sections/TariffEditorModal.svelte"; import TariffEditorModal from "./sections/TariffEditorModal.svelte";
import TariffsSection from "./sections/TariffsSection.svelte"; import TariffsSection from "./sections/TariffsSection.svelte";
import ThemesSection from "./sections/ThemesSection.svelte"; import AppearanceSection from "./sections/AppearanceSection.svelte";
import UserDetailModal from "./sections/UserDetailModal.svelte"; import UserDetailModal from "./sections/UserDetailModal.svelte";
import UsersSection from "./sections/UsersSection.svelte"; import UsersSection from "./sections/UsersSection.svelte";
import { createAdsStore } from "../lib/admin/stores/adsStore.js"; import { createAdsStore } from "../lib/admin/stores/adsStore.js";
@@ -115,7 +115,7 @@
label: at("nav_system", {}, "Система"), label: at("nav_system", {}, "Система"),
items: [ items: [
{ id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins }, { id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins },
{ id: "themes", label: at("nav_themes", {}, "Темы"), icon: Paintbrush }, { id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush },
{ id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders }, { id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders },
], ],
}, },
@@ -158,9 +158,9 @@
title: at("section_tariffs_title", {}, "Тарифы"), title: at("section_tariffs_title", {}, "Тарифы"),
subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"), subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"),
}, },
themes: { appearance: {
title: at("section_themes_title", {}, "Темы Web App"), title: at("section_appearance_title", {}, "Внешний вид"),
subtitle: at("section_themes_subtitle", {}, "Цвета, шрифты и темы оформления Mini App"), subtitle: at("section_appearance_subtitle", {}, "Логотип, темы и акцентные цвета Mini App"),
}, },
settings: { settings: {
title: at("section_settings_title", {}, "Настройки приложения"), title: at("section_settings_title", {}, "Настройки приложения"),
@@ -646,8 +646,8 @@
<TariffsSection {at} {fmtMoney} /> <TariffsSection {at} {fmtMoney} />
{/if} {/if}
{#if active === "themes"} {#if active === "appearance"}
<ThemesSection {at} {currentLang} /> <AppearanceSection {at} {currentLang} {onSettingsSaved} {brand} />
{/if} {/if}
{#if active === "settings"} {#if active === "settings"}
@@ -0,0 +1,743 @@
<script>
import { Check, ExternalLink, FileText, RefreshCw, Save } from "$components/ui/icons.js";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminSelect,
} from "$components/patterns/admin/index.js";
import { Switch } from "$components/ui/primitives.js";
import { getContext, onDestroy, onMount } from "svelte";
import BrandMark from "$lib/webapp/BrandMark.svelte";
import { localizedThemeName } from "$lib/webapp/themeStyle.js";
export let at;
export let currentLang = "ru";
export let onSettingsSaved = () => {};
export let brand = {};
const settingsStore = getContext("settingsStore");
const themesStore = getContext("themesStore");
$: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore);
$: ({ themesCatalog, themesLoading, themesDir, themesSaving } = $themesStore);
$: appearanceFields =
settingsSections.find((section) => section.id === "appearance")?.fields || [];
$: fieldMap = new Map(appearanceFields.map((field) => [field.key, field]));
$: activeKey = themesCatalog.default_theme;
$: logoUrl = valueForKey("WEBAPP_LOGO_URL");
$: useEmojiLogo = boolValue(valueForKey("WEBAPP_LOGO_USE_EMOJI"));
$: currentLogoUrl = !useEmojiLogo ? pendingLogoPreviewUrl || logoUrl || brand?.logoUrl || "" : "";
$: previewLogoUrl =
logoPreviewNonce && currentLogoUrl ? withLogoCacheBust(currentLogoUrl) : currentLogoUrl;
$: logoEmoji = valueForKey("WEBAPP_LOGO_EMOJI");
$: logoEmojiInput = useEmojiLogo ? logoEmoji : "";
$: logoEmojiPreview = logoEmoji || "🫥";
$: logoEmojiFont = valueForKey("WEBAPP_LOGO_EMOJI_FONT") || "system";
$: logoBrand = {
title: "",
logoUrl: useEmojiLogo ? "" : previewLogoUrl,
emoji: logoEmojiPreview,
emojiFont: logoEmojiFont,
};
$: emojiFontItems = (fieldMap.get("WEBAPP_LOGO_EMOJI_FONT")?.choices || []).map((item) => ({
value: item.value,
label: item.label,
}));
$: dirtyCount = Object.keys(settingsDirty || {}).filter((key) =>
appearanceFields.some((field) => field.key === key)
).length;
$: appearanceDirtyKeys = Object.keys(settingsDirty || {}).filter((key) =>
appearanceFields.some((field) => field.key === key)
);
let logoFileInput;
let logoSourceUrl = "";
let logoPreviewNonce = 0;
let logoPreviewFailed = false;
let lastPreviewLogoUrl = "";
let pendingLogoPreviewUrl = "";
let pendingObjectUrl = "";
$: if (previewLogoUrl !== lastPreviewLogoUrl) {
lastPreviewLogoUrl = previewLogoUrl;
logoPreviewFailed = false;
}
function valueFor(field) {
if (!field) return "";
if (settingsDirty[field.key]?.deleted) return "";
if (Object.prototype.hasOwnProperty.call(settingsDirty, field.key)) {
return settingsDirty[field.key].value;
}
return field.value ?? "";
}
function valueForKey(key) {
return valueFor(fieldMap.get(key));
}
function boolValue(value) {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
return Boolean(value);
}
function withLogoCacheBust(url) {
if (!url || url.startsWith("data:") || url.startsWith("blob:")) return url;
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}v=${logoPreviewNonce}`;
}
function clearPendingObjectUrl() {
if (pendingObjectUrl && typeof URL !== "undefined") {
URL.revokeObjectURL(pendingObjectUrl);
}
pendingObjectUrl = "";
}
function setPendingLogoPreview(url, objectUrl = "") {
clearPendingObjectUrl();
pendingObjectUrl = objectUrl;
pendingLogoPreviewUrl = url;
logoPreviewFailed = false;
logoPreviewNonce = Date.now();
}
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 isThemeAccentSet(theme) {
return Boolean(String(theme.tokens?.accent || "").trim());
}
function pickerHex(value) {
const raw = String(value || "").trim();
const match = raw.match(/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
if (!match) return "#000000";
let hex = match[1].toLowerCase();
if (hex.length === 3)
hex = hex
.split("")
.map((char) => char + char)
.join("");
return `#${hex}`;
}
function openThemeAccentPicker(theme) {
themesStore.setThemeAccent(theme.key, pickerHex(theme.tokens?.accent || "#00fe7a"));
}
function handleLogoFileChange(event) {
const file = event.currentTarget.files?.[0];
if (!file) return;
if (typeof URL !== "undefined") {
const objectUrl = URL.createObjectURL(file);
setPendingLogoPreview(objectUrl, objectUrl);
}
themesStore.uploadLogoFile(file).then((uploadedUrl) => {
if (!uploadedUrl) {
pendingLogoPreviewUrl = "";
clearPendingObjectUrl();
return;
}
settingsStore.markDirty("WEBAPP_LOGO_URL", uploadedUrl);
settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", false);
if (logoFileInput) logoFileInput.value = "";
});
}
function uploadLogoFromUrl() {
themesStore.uploadLogoUrl(logoSourceUrl).then((uploadedUrl) => {
if (!uploadedUrl) return;
setPendingLogoPreview(uploadedUrl);
logoSourceUrl = "";
settingsStore.markDirty("WEBAPP_LOGO_URL", uploadedUrl);
settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", false);
});
}
function setEmojiLogo(enabled) {
settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", Boolean(enabled));
if (!enabled) {
settingsStore.markDirty("WEBAPP_LOGO_EMOJI", "");
} else {
pendingLogoPreviewUrl = "";
clearPendingObjectUrl();
}
}
function setAppearanceValue(key, value) {
settingsStore.markDirty(key, value);
}
async function saveAppearance() {
const keysToSave = new Set(appearanceDirtyKeys);
if (!useEmojiLogo && logoEmoji) {
settingsStore.markDirty("WEBAPP_LOGO_EMOJI", "");
keysToSave.add("WEBAPP_LOGO_EMOJI");
}
const shouldReloadFrontend = Array.from(keysToSave).some((key) =>
[
"WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
"WEBAPP_LOGO_EMOJI",
"WEBAPP_LOGO_EMOJI_FONT",
].includes(key)
);
let settingsSaved = true;
if (keysToSave.size) {
settingsSaved = await settingsStore.saveSettings((payload) =>
onSettingsSaved({ ...payload, deferFrontendReload: true })
);
}
await themesStore.saveThemes();
if (settingsSaved && shouldReloadFrontend && typeof onSettingsSaved === "function") {
await onSettingsSaved({ reloadFrontend: true });
}
}
function toggleAdminTheme(event, theme) {
event.stopPropagation();
themesStore.toggleAdminUse(theme.key, event.currentTarget.checked);
}
function setThemeAccent(theme, value) {
themesStore.setThemeAccent(theme.key, value);
}
function selectTheme(theme, event = null) {
if (event?.target?.closest?.("button,input,label")) return;
if (!themesSaving) themesStore.setCurrentTheme(theme.key);
}
function handleThemeKeydown(event, theme) {
if (event?.target?.closest?.("button,input,label")) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
selectTheme(theme);
}
function previewTheme(event, theme) {
event.stopPropagation();
const url = `/home?theme_preview=${encodeURIComponent(theme.key)}`;
window.open(url, "_blank", "noopener");
}
onMount(() => {
themesStore.loadThemes();
settingsStore.loadSettings();
});
onDestroy(() => {
clearPendingObjectUrl();
});
</script>
{#if themesLoading || settingsLoading}
<AdminEmptyState>{at("loading", {}, "Загрузка…")}</AdminEmptyState>
{:else}
<div class="appearance-stack">
<article class="admin-card">
<header class="admin-card-head">
<div>
<h3>{at("appearance_brand_title", {}, "Логотип")}</h3>
<small
>{at(
"appearance_brand_sub",
{},
"Файл, ссылка или подтвержденный emoji-логотип"
)}</small
>
</div>
<div class="admin-editor-section-actions">
{#if dirtyCount}
<AdminBadge variant="warning">
{at("settings_dirty_count", { count: dirtyCount }, `Изменений: ${dirtyCount}`)}
</AdminBadge>
{/if}
<AdminButton
size="sm"
variant="primary"
onclick={saveAppearance}
disabled={settingsSaving || themesSaving}
>
<Save size={13} />
{settingsSaving || themesSaving
? at("btn_saving", {}, "Сохранение...")
: at("btn_save", {}, "Сохранить")}
</AdminButton>
</div>
</header>
<div class="admin-card-body appearance-logo-grid">
<div class="appearance-logo-preview">
{#if !useEmojiLogo && previewLogoUrl && !logoPreviewFailed}
<img
class="appearance-logo-image"
src={previewLogoUrl}
alt=""
loading="eager"
decoding="async"
onerror={() => {
logoPreviewFailed = true;
}}
/>
{:else if useEmojiLogo}
<BrandMark brand={logoBrand} size="lg" />
{:else}
<span class="appearance-logo-empty" aria-hidden="true"></span>
{/if}
</div>
<div class="appearance-controls">
<section class="appearance-control-card">
<input
bind:this={logoFileInput}
class="appearance-file-input"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon"
onchange={handleLogoFileChange}
/>
<AdminButton
class="appearance-control"
size="sm"
onclick={() => logoFileInput?.click()}
disabled={themesSaving}
>
<FileText size={13} />
{at("appearance_logo_upload_file", {}, "Загрузить файл")}
</AdminButton>
<div class="appearance-url-row">
<input
class="input appearance-control"
type="url"
placeholder="https://example.com/logo.png"
bind:value={logoSourceUrl}
/>
<AdminButton
class="appearance-control"
size="sm"
onclick={uploadLogoFromUrl}
disabled={themesSaving || !logoSourceUrl.trim()}
>
{at("appearance_logo_upload_url", {}, "По ссылке")}
</AdminButton>
</div>
</section>
<section class="appearance-control-card">
<label class="appearance-switch">
<Switch.Root
checked={useEmojiLogo}
onCheckedChange={setEmojiLogo}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span>{at("appearance_use_emoji_logo", {}, "Использовать emoji-логотип")}</span>
</label>
<div class="appearance-emoji-grid">
<input
class="input appearance-control"
type="text"
maxlength="8"
value={logoEmojiInput}
disabled={!useEmojiLogo}
oninput={(event) =>
setAppearanceValue("WEBAPP_LOGO_EMOJI", event.currentTarget.value)}
/>
<AdminSelect
class="appearance-control"
value={logoEmojiFont}
items={emojiFontItems}
disabled={!useEmojiLogo}
ariaLabel={at("appearance_emoji_font", {}, "Шрифт emoji")}
placeholder={at("appearance_emoji_font", {}, "Шрифт emoji")}
onValueChange={(value) => setAppearanceValue("WEBAPP_LOGO_EMOJI_FONT", value)}
/>
</div>
</section>
</div>
</div>
</article>
<article class="admin-card">
<header class="admin-card-head">
<div>
<h3>{at("appearance_themes_title", {}, "Темы")}</h3>
<small
>{at(
"appearance_themes_sub",
{},
"Глобальная тема, accent color и предпросмотр"
)}</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={saveAppearance}
disabled={settingsSaving || 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) => handleThemeKeydown(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 appearance-color-row">
<span>{at("appearance_theme_accent", {}, "Accent")}</span>
<input
class="admin-color"
class:is-empty={!isThemeAccentSet(theme)}
type="color"
value={pickerHex(theme.tokens?.accent)}
title={isThemeAccentSet(theme)
? theme.tokens?.accent
: at("appearance_theme_accent_empty", {}, "Не задан")}
onclick={() => openThemeAccentPicker(theme)}
oninput={(event) => setThemeAccent(theme, event.currentTarget.value)}
/>
<input
class="input appearance-color-text"
type="text"
placeholder={at("appearance_theme_accent_placeholder", {}, "Не задан")}
value={theme.tokens?.accent || ""}
oninput={(event) => setThemeAccent(theme, event.currentTarget.value)}
/>
</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>
<div class="appearance-theme-actions">
<AdminButton
size="sm"
variant="ghost"
onclick={(event) => previewTheme(event, theme)}
>
<ExternalLink size={13} />
{at("appearance_preview_theme", {}, "Предпросмотр")}
</AdminButton>
</div>
<span class="admin-theme-card-check" aria-hidden="true">
{#if isCurrent}<Check size={18} />{/if}
</span>
</div>
{/each}
</div>
{/if}
</div>
</article>
</div>
{/if}
<style>
.appearance-stack {
display: grid;
gap: 14px;
}
.appearance-logo-grid {
display: grid;
grid-template-columns: minmax(190px, 220px) minmax(0, 520px);
gap: 18px;
align-items: stretch;
}
.appearance-logo-preview {
display: inline-flex;
align-items: center;
justify-content: center;
grid-row: 1;
width: auto;
height: 100%;
aspect-ratio: 1 / 1;
justify-self: start;
padding: 10px;
overflow: hidden;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: color-mix(in srgb, var(--admin-surface-2) 54%, var(--admin-surface));
}
.appearance-logo-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.appearance-logo-empty {
width: 44%;
aspect-ratio: 1 / 1;
border: 1px dashed var(--admin-border-strong);
border-radius: 8px;
opacity: 0.65;
}
.appearance-logo-preview :global(.brand-mark) {
width: 100%;
height: 100%;
font-size: clamp(3rem, 8vw, 5rem);
}
.appearance-controls {
display: grid;
gap: 12px;
align-content: start;
max-width: 520px;
}
.appearance-control-card {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: color-mix(in srgb, var(--admin-surface-2) 40%, transparent);
}
.appearance-file-input {
display: none;
}
.appearance-url-row,
.appearance-emoji-grid {
display: grid;
gap: 8px;
max-width: 520px;
}
.appearance-url-row {
grid-template-columns: minmax(0, 1fr) max-content;
width: 100%;
}
.appearance-emoji-grid {
grid-template-columns: minmax(0, 360px) max-content;
}
:global(.appearance-control.input),
:global(.appearance-control.admin-btn),
:global(.appearance-control.admin-select-trigger) {
height: 36px;
min-height: 36px;
}
:global(.appearance-control.admin-btn) {
padding-inline: 12px;
border-radius: 8px;
font-size: 13px;
}
.appearance-switch {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
max-width: 520px;
color: var(--admin-text);
font-size: 13px;
}
.admin-theme-card-option input[type="checkbox"] {
accent-color: var(--accent);
}
.admin-theme-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
}
.admin-theme-card {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
min-height: 154px;
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;
max-width: 100%;
color: var(--admin-muted);
font-size: 12px;
cursor: default;
}
.appearance-color-row {
display: grid;
grid-template-columns: auto 38px minmax(0, 1fr);
width: 100%;
}
.appearance-color-text {
min-width: 0;
}
.admin-color.is-empty {
opacity: 0.42;
filter: grayscale(1);
}
.appearance-theme-actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-start;
}
.admin-theme-card-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
color: var(--accent);
}
@media (max-width: 720px) {
.appearance-logo-grid {
grid-template-columns: 1fr;
}
.appearance-logo-preview {
grid-row: auto;
height: auto;
width: min(164px, 100%);
}
.appearance-url-row,
.appearance-emoji-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -17,28 +17,32 @@
const settingsStore = getContext("settingsStore"); const settingsStore = getContext("settingsStore");
$: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore); $: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore);
$: visibleSettingsSections = settingsSections.filter((section) => section.id !== "appearance");
let settingsOpenSections = []; let settingsOpenSections = [];
let settingsOpenSubsections = {}; let settingsOpenSubsections = {};
let revealedSecrets = new Set(); let revealedSecrets = new Set();
$: settingsAllOpen = $: settingsAllOpen =
settingsSections.length > 0 && settingsOpenSections.length === settingsSections.length; visibleSettingsSections.length > 0 &&
settingsOpenSections.length === visibleSettingsSections.length;
onMount(() => { onMount(() => {
settingsStore.loadSettings().then(() => { settingsStore.loadSettings().then(() => {
if ($settingsStore.settingsSections.length) { if ($settingsStore.settingsSections.length) {
const ids = $settingsStore.settingsSections.map((s) => s.id); const ids = $settingsStore.settingsSections
.filter((s) => s.id !== "appearance")
.map((s) => s.id);
settingsOpenSections = isCompact ? ids.slice(0, 1) : ids.slice(); settingsOpenSections = isCompact ? ids.slice(0, 1) : ids.slice();
} }
}); });
}); });
function toggleAllSections() { function toggleAllSections() {
if (settingsOpenSections.length === settingsSections.length) { if (settingsOpenSections.length === visibleSettingsSections.length) {
settingsOpenSections = []; settingsOpenSections = [];
} else { } else {
settingsOpenSections = settingsSections.map((s) => s.id); settingsOpenSections = visibleSettingsSections.map((s) => s.id);
} }
} }
@@ -229,7 +233,7 @@
</div> </div>
{/snippet} {/snippet}
{#if settingsLoading || !settingsSections.length} {#if settingsLoading || !visibleSettingsSections.length}
<AdminEmptyState <AdminEmptyState
>{settingsLoading >{settingsLoading
? at("loading", {}, "Загрузка…") ? at("loading", {}, "Загрузка…")
@@ -265,7 +269,7 @@
</div> </div>
</div> </div>
<Accordion.Root type="multiple" bind:value={settingsOpenSections} class="admin-accordion"> <Accordion.Root type="multiple" bind:value={settingsOpenSections} class="admin-accordion">
{#each settingsSections as section} {#each visibleSettingsSections as section}
{@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length} {@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length}
{@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length} {@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length}
<Accordion.Item value={section.id} class="admin-accordion-item admin-card"> <Accordion.Item value={section.id} class="admin-accordion-item admin-card">
@@ -1,267 +0,0 @@
<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>
@@ -38,13 +38,30 @@ export function createSettingsStore({ api, onToast, at }) {
}); });
} }
function setFieldValue(key, value) {
state.update((s) => {
const nextDirty = { ...s.settingsDirty };
delete nextDirty[key];
return {
...s,
settingsDirty: nextDirty,
settingsSections: (s.settingsSections || []).map((section) => ({
...section,
fields: (section.fields || []).map((field) =>
field.key === key ? { ...field, value, overridden: true } : field
),
})),
};
});
}
async function saveSettings(onSettingsSaved) { async function saveSettings(onSettingsSaved) {
let dirty = {}; let dirty = {};
state.update((s) => { state.update((s) => {
dirty = s.settingsDirty; dirty = s.settingsDirty;
return s; return s;
}); });
if (!Object.keys(dirty).length) return; if (!Object.keys(dirty).length) return true;
state.update((s) => ({ ...s, settingsSaving: true })); state.update((s) => ({ ...s, settingsSaving: true }));
try { try {
@@ -63,6 +80,7 @@ export function createSettingsStore({ api, onToast, at }) {
state.update((s) => ({ ...s, settingsDirty: {} })); state.update((s) => ({ ...s, settingsDirty: {} }));
if (onSettingsSaved) await onSettingsSaved({ updates, deletes }); if (onSettingsSaved) await onSettingsSaved({ updates, deletes });
await loadSettings(); await loadSettings();
return true;
} else if (res?.errors) { } else if (res?.errors) {
const summary = Object.entries(res.errors) const summary = Object.entries(res.errors)
.map(([k, v]) => `${k}: ${v}`) .map(([k, v]) => `${k}: ${v}`)
@@ -71,6 +89,7 @@ export function createSettingsStore({ api, onToast, at }) {
} else { } else {
onToast(res?.error || "Ошибка"); onToast(res?.error || "Ошибка");
} }
return false;
} finally { } finally {
state.update((s) => ({ ...s, settingsSaving: false })); state.update((s) => ({ ...s, settingsSaving: false }));
} }
@@ -91,6 +110,7 @@ export function createSettingsStore({ api, onToast, at }) {
loadSettings, loadSettings,
markDirty, markDirty,
clearDirty, clearDirty,
setFieldValue,
resetField, resetField,
saveSettings, saveSettings,
}; };
@@ -58,6 +58,67 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
} }
} }
async function uploadLogoFile(file) {
if (!file) return null;
state.update((s) => ({ ...s, themesSaving: true }));
try {
const body = new FormData();
body.append("file", file);
const data = await api("/admin/appearance/logo", {
method: "POST",
body,
});
if (data?.ok) {
flash(
at(
"appearance_logo_uploaded_pending",
{},
"Логотип загружен. Сохраните изменения, чтобы применить его."
)
);
return data.logo_url || "";
}
flash(
data?.message ||
data?.error ||
at("appearance_logo_upload_failed", {}, "Не удалось загрузить логотип")
);
return null;
} finally {
state.update((s) => ({ ...s, themesSaving: false }));
}
}
async function uploadLogoUrl(url) {
const sourceUrl = String(url || "").trim();
if (!sourceUrl) return null;
state.update((s) => ({ ...s, themesSaving: true }));
try {
const data = await api("/admin/appearance/logo", {
method: "POST",
body: JSON.stringify({ url: sourceUrl }),
});
if (data?.ok) {
flash(
at(
"appearance_logo_uploaded_pending",
{},
"Логотип загружен. Сохраните изменения, чтобы применить его."
)
);
return data.logo_url || "";
}
flash(
data?.message ||
data?.error ||
at("appearance_logo_upload_failed", {}, "Не удалось загрузить логотип")
);
return null;
} finally {
state.update((s) => ({ ...s, themesSaving: false }));
}
}
async function setCurrentTheme(key) { async function setCurrentTheme(key) {
let changed = false; let changed = false;
state.update((s) => ({ state.update((s) => ({
@@ -102,12 +163,35 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
})); }));
} }
function setThemeAccent(key, accent) {
state.update((s) => ({
...s,
themesCatalog: {
...s.themesCatalog,
themes: (s.themesCatalog.themes || []).map((theme) =>
theme.key === key
? {
...theme,
tokens: {
...(theme.tokens || {}),
accent: String(accent || "").trim() || null,
},
}
: theme
),
},
}));
}
return { return {
subscribe: state.subscribe, subscribe: state.subscribe,
loadThemes, loadThemes,
saveThemes, saveThemes,
setCurrentTheme, setCurrentTheme,
setThemeAccent,
togglePrimaryAccent, togglePrimaryAccent,
toggleAdminUse, toggleAdminUse,
uploadLogoFile,
uploadLogoUrl,
}; };
} }
@@ -33,6 +33,7 @@
export let emojiFont = ""; export let emojiFont = "";
export let size = "sm"; export let size = "sm";
export let animate = false; export let animate = false;
export let fallbackEmoji = true;
let className = ""; let className = "";
export { className as class }; export { className as class };
@@ -206,7 +207,7 @@
clearLogoLoadTimeout(); clearLogoLoadTimeout();
}} }}
/> />
{:else if useAnimatedEmoji} {:else if fallbackEmoji && useAnimatedEmoji}
<img <img
class="brand-mark-animated-emoji loaded" class="brand-mark-animated-emoji loaded"
src={animatedEmojiStaticFallback ? animatedEmojiFallbackSrc : animatedEmojiSrc} src={animatedEmojiStaticFallback ? animatedEmojiFallbackSrc : animatedEmojiSrc}
@@ -222,7 +223,7 @@
} }
}} }}
/> />
{:else} {:else if fallbackEmoji}
<span <span
class={cn("brand-mark-emoji", getEmojiFontClass(normalizedEmojiFont))} class={cn("brand-mark-emoji", getEmojiFontClass(normalizedEmojiFont))}
style="opacity: {fontLoaded ? 1 : 0}; transition: opacity 0.2s ease;" style="opacity: {fontLoaded ? 1 : 0}; transition: opacity 0.2s ease;"
@@ -34,7 +34,7 @@ export const ADMIN_SECTIONS = new Set([
"broadcast", "broadcast",
"logs", "logs",
"tariffs", "tariffs",
"themes", "appearance",
"settings", "settings",
]); ]);
export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js"; export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js";
+67 -1
View File
@@ -219,7 +219,73 @@ export async function mockApi(path, options = {}, context = {}) {
catalog: clone(DEV_MOCK.config.themesCatalog), catalog: clone(DEV_MOCK.config.themesCatalog),
}; };
} }
if (path === "/admin/settings") return { ok: true, sections: [] }; if (path === "/admin/appearance/logo") {
return { ok: true, logo_url: "/webapp-uploaded-logo/logo-0000000000000000.png" };
}
if (path === "/admin/settings" && String(options.method || "GET").toUpperCase() === "PATCH") {
try {
const body = options?.body ? JSON.parse(String(options.body)) : {};
const updates = body.updates || {};
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_URL")) {
DEV_MOCK.config.logoUrl = updates.WEBAPP_LOGO_URL || "";
}
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_USE_EMOJI")) {
DEV_MOCK.config.logoUseEmoji = Boolean(updates.WEBAPP_LOGO_USE_EMOJI);
}
if (updates.WEBAPP_LOGO_EMOJI) DEV_MOCK.config.logoEmoji = updates.WEBAPP_LOGO_EMOJI;
if (updates.WEBAPP_LOGO_EMOJI_FONT) {
DEV_MOCK.config.logoEmojiFont = updates.WEBAPP_LOGO_EMOJI_FONT;
}
} catch (_e) {
void _e;
}
return { ok: true, applied: 1, reverted: 0 };
}
if (path === "/admin/settings")
return {
ok: true,
sections: [
{
id: "appearance",
order: 2,
fields: [
{
key: "WEBAPP_LOGO_USE_EMOJI",
type: "bool",
section: "appearance",
label: "Emoji logo",
value: Boolean(DEV_MOCK.config.logoUseEmoji),
},
{
key: "WEBAPP_LOGO_URL",
type: "url",
section: "appearance",
label: "URL логотипа",
value: DEV_MOCK.config.logoUrl || "",
},
{
key: "WEBAPP_LOGO_EMOJI",
type: "string",
section: "appearance",
label: "Emoji",
value: DEV_MOCK.config.logoEmoji || "🫥",
},
{
key: "WEBAPP_LOGO_EMOJI_FONT",
type: "string",
section: "appearance",
label: "Emoji font",
value: DEV_MOCK.config.logoEmojiFont || "system",
choices: [
{ value: "system", label: "Системный" },
{ value: "noto-color", label: "Noto Color Emoji" },
{ value: "noto-color-animated", label: "Noto Color Emoji Animated" },
],
},
],
},
],
};
if (cleanPath.startsWith("/admin/")) if (cleanPath.startsWith("/admin/"))
return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 }; return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 };
if (path === "/me") return clone(DEV_MOCK.data); if (path === "/me") return clone(DEV_MOCK.data);
@@ -27,6 +27,7 @@ export const DEV_MOCK = {
title: "/minishop", title: "/minishop",
primaryColor: "#00fe7a", primaryColor: "#00fe7a",
logoUrl: "", logoUrl: "",
logoUseEmoji: false,
logoEmoji: "🫥", logoEmoji: "🫥",
logoEmojiFont: "system", logoEmojiFont: "system",
apiBase: "/api", apiBase: "/api",
@@ -8,6 +8,8 @@ export function createApiClient({
mockApi = null, mockApi = null,
getMockContext = () => ({}), getMockContext = () => ({}),
} = {}) { } = {}) {
const isFormDataBody = (body) => typeof FormData !== "undefined" && body instanceof FormData;
async function api(path, options = {}) { async function api(path, options = {}) {
if (mockApi) return mockApi(path, options, getMockContext()); if (mockApi) return mockApi(path, options, getMockContext());
@@ -18,7 +20,9 @@ export function createApiClient({
if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) { if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
headers["X-CSRF-Token"] = csrf; headers["X-CSRF-Token"] = csrf;
} }
if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json"; if (options.body && !headers["Content-Type"] && !isFormDataBody(options.body)) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(`${apiBase}${path}`, { const response = await fetch(`${apiBase}${path}`, {
...options, ...options,
+4 -1
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import base64 import base64
import hashlib import hashlib
import html
import hmac import hmac
import io import io
import ipaddress import ipaddress
@@ -17,7 +18,7 @@ from collections import deque
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.types import LabeledPrice from aiogram.types import LabeledPrice
@@ -65,6 +66,8 @@ TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscriptio
ASSET_DIR = TEMPLATE_PATH.parent ASSET_DIR = TEMPLATE_PATH.parent
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo" WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo"
WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads"
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji" WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji"
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->" WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->" WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
+152 -3
View File
@@ -5,6 +5,7 @@ from config.webapp_themes_config import (
default_webapp_theme_asset_file, default_webapp_theme_asset_file,
default_webapp_theme_css_files, default_webapp_theme_css_files,
ensure_default_webapp_theme_descriptor_files, ensure_default_webapp_theme_descriptor_files,
public_theme_payload,
public_themes_catalog_payload, public_themes_catalog_payload,
) )
@@ -123,6 +124,9 @@ async def theme_asset_route(request: web.Request) -> web.Response:
def _resolve_webapp_logo_url(settings: Settings) -> str: def _resolve_webapp_logo_url(settings: Settings) -> str:
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return ""
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url: if not raw_logo_url:
return "" return ""
@@ -173,6 +177,8 @@ def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str:
async def webapp_logo_route(request: web.Request) -> web.Response: async def webapp_logo_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"] settings: Settings = request.app["settings"]
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
raise web.HTTPNotFound(text="webapp_logo_disabled")
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url: if not raw_logo_url:
raise web.HTTPNotFound(text="webapp_logo_not_configured") raise web.HTTPNotFound(text="webapp_logo_not_configured")
@@ -206,6 +212,41 @@ async def webapp_logo_route(request: web.Request) -> web.Response:
return response return response
async def webapp_uploaded_logo_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
filename = str(request.match_info.get("filename") or "").strip()
if not re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename):
raise web.HTTPNotFound(text="webapp_logo_not_found")
root = WEBAPP_UPLOADED_LOGO_DIR.expanduser().resolve()
path = (root / filename).resolve()
try:
path.relative_to(root)
except ValueError:
raise web.HTTPNotFound(text="webapp_logo_not_found") from None
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower())
if not content_type:
raise web.HTTPNotFound(text="webapp_logo_not_found")
try:
if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES:
raise web.HTTPNotFound(text="webapp_logo_too_large")
body = path.read_bytes()
except OSError:
raise web.HTTPNotFound(text="webapp_logo_not_found") from None
if not body:
raise web.HTTPNotFound(text="webapp_logo_not_found")
response = web.Response(body=body, content_type=content_type)
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
async def webapp_animated_emoji_route(request: web.Request) -> web.Response: async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
codepoints = str(request.match_info.get("codepoints") or "").strip().lower() codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
ext = str(request.match_info.get("ext") or "").strip().lower() ext = str(request.match_info.get("ext") or "").strip().lower()
@@ -235,6 +276,8 @@ async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
async def _warm_webapp_logo_cache(app: web.Application) -> None: async def _warm_webapp_logo_cache(app: web.Application) -> None:
settings: Settings = app["settings"] settings: Settings = app["settings"]
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url): if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url):
return return
@@ -258,6 +301,8 @@ async def _warm_webapp_logo_cache(app: web.Application) -> None:
async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None: async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None:
settings: Settings = app["settings"] settings: Settings = app["settings"]
if not getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return
if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated": if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated":
return return
@@ -547,7 +592,7 @@ async def _security_headers_middleware(request: web.Request, handler):
"frame-ancestors https://web.telegram.org https://t.me; " "frame-ancestors https://web.telegram.org https://t.me; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " # noqa: E501 "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " # noqa: E501
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; " "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; "
"img-src 'self' data: https:; " "img-src 'self' data: blob: https:; "
"connect-src 'self' https://oauth.telegram.org; " "connect-src 'self' https://oauth.telegram.org; "
"object-src 'none'; " "object-src 'none'; "
"base-uri 'self'; " "base-uri 'self'; "
@@ -741,16 +786,24 @@ async def index_route(request: web.Request) -> web.Response:
html = TEMPLATE_PATH.read_text(encoding="utf-8") html = TEMPLATE_PATH.read_text(encoding="utf-8")
cached = _get_cached_webapp_settings(request) cached = _get_cached_webapp_settings(request)
themes_catalog = settings.webapp_themes_catalog themes_catalog = settings.webapp_themes_catalog
primary_color = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a"
initial_theme = _initial_theme_for_request(request, themes_catalog)
preview_key = str(request.query.get("theme_preview") or "").strip()
preview_theme = themes_catalog.theme_by_key(preview_key) if preview_key else None
if preview_theme is None or not preview_theme.enabled:
preview_key = ""
config = { config = {
"title": settings.WEBAPP_TITLE, "title": settings.WEBAPP_TITLE,
"primaryColor": settings.WEBAPP_PRIMARY_COLOR, "primaryColor": settings.WEBAPP_PRIMARY_COLOR,
"themesCatalog": public_themes_catalog_payload( "themesCatalog": public_themes_catalog_payload(
themes_catalog, themes_catalog,
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a", primary_color,
enabled_only=True, enabled_only=True,
), ),
"themesDir": settings.WEBAPP_THEMES_DIR, "themesDir": settings.WEBAPP_THEMES_DIR,
"themePreviewKey": preview_key,
"logoUrl": cached["logo_url"], "logoUrl": cached["logo_url"],
"logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI),
"logoEmoji": settings.WEBAPP_LOGO_EMOJI, "logoEmoji": settings.WEBAPP_LOGO_EMOJI,
"logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT,
"apiBase": "/api", "apiBase": "/api",
@@ -769,6 +822,9 @@ async def index_route(request: web.Request) -> web.Response:
"appRepositoryUrl": APP_REPOSITORY_URL, "appRepositoryUrl": APP_REPOSITORY_URL,
} }
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER) html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
if initial_theme_markup:
html = html.replace("</head>", f"{initial_theme_markup}\n</head>", 1)
i18n_instance: Optional[object] = request.app.get("i18n") i18n_instance: Optional[object] = request.app.get("i18n")
i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
nonce = request.get("csp_nonce", "") nonce = request.get("csp_nonce", "")
@@ -793,7 +849,11 @@ async def index_route(request: web.Request) -> web.Response:
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>', f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
) )
brand_asset_url = cached["logo_url"] brand_asset_url = cached["logo_url"]
if not brand_asset_url and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated": if (
not brand_asset_url
and settings.WEBAPP_LOGO_USE_EMOJI
and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated"
):
brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI) brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI)
if brand_asset_url: if brand_asset_url:
html = html.replace( html = html.replace(
@@ -843,6 +903,95 @@ def _resolve_webapp_js_asset_name() -> str:
return "subscription_webapp.js" return "subscription_webapp.js"
_INITIAL_THEME_TOKEN_CSS_MAP = {
"accent": "--accent",
"bg": "--bg",
"panel": "--panel",
"panel_2": "--panel-2",
"panel_3": "--panel-3",
"border": "--border",
"border_strong": "--border-strong",
"text": "--text",
"muted": "--muted",
"dim": "--dim",
"danger": "--danger",
"blue": "--blue",
"radius": "--radius",
"font_sans": "--font-sans",
"font_logo": "--font-logo",
"font_mono": "--font-mono",
"admin_bg": "--admin-bg",
"admin_surface": "--admin-surface",
"admin_surface_2": "--admin-surface-2",
"admin_elev": "--admin-elev",
"admin_border": "--admin-border",
"admin_border_strong": "--admin-border-strong",
"admin_text": "--admin-text",
"admin_muted": "--admin-muted",
"admin_dim": "--admin-dim",
}
def _theme_css_href_for_html(theme: Any) -> str:
css_file = str(getattr(theme, "css_file", "") or "").strip()
key = str(getattr(theme, "key", "") or "").strip()
if not css_file or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key):
return ""
parts = [part for part in css_file.replace("\\", "/").split("/") if part]
if any(part in {".", ".."} for part in parts):
return ""
themed_path = "/".join([key, *parts])
encoded = "/".join(quote(part, safe="") for part in themed_path.split("/"))
return f"/webapp-theme-css/{encoded}" if encoded else ""
def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any:
preview_key = str(request.query.get("theme_preview") or "").strip()
if preview_key:
preview_theme = catalog.theme_by_key(preview_key)
if preview_theme is not None and preview_theme.enabled:
return preview_theme
theme = catalog.theme_by_key(catalog.default_theme)
if theme is not None:
return theme
return catalog.enabled_themes()[0] if catalog.enabled_themes() else None
def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: str) -> str:
if theme is None:
return ""
payload = public_theme_payload(theme, primary_color)
tokens = payload.get("tokens") if isinstance(payload, dict) else {}
tokens = tokens if isinstance(tokens, dict) else {}
declarations = []
for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items():
value = str(tokens.get(token_key) or "").strip()
if value:
declarations.append(f"{css_name}:{value}")
scheme = "light" if tokens.get("color_scheme") == "light" else "dark"
bg = str(tokens.get("bg") or "").strip()
css_rules = [f"html{{color-scheme:{scheme};}}"]
if bg:
css_rules.append(f"body{{background-color:{bg};}}")
if declarations:
css_rules.append(f".app-shell{{{';'.join(declarations)}}}")
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
style_tag = (
f'<style id="webapp-initial-theme" nonce="{nonce}">' + "".join(css_rules) + "</style>"
)
href = _theme_css_href_for_html(theme)
if not href:
return style_tag
return (
f'<link rel="stylesheet" href="{html.escape(href, quote=True)}" '
f'data-initial-theme-css="{html.escape(str(theme.key), quote=True)}">\n' + style_tag
)
def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str: def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str:
start = html.find(start_marker) start = html.find(start_marker)
if start == -1: if start == -1:
+11 -1
View File
@@ -9,12 +9,22 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/devices", index_route) app.router.add_get("/devices", index_route)
app.router.add_get("/settings", index_route) app.router.add_get("/settings", index_route)
app.router.add_get("/admin", index_route) app.router.add_get("/admin", index_route)
app.router.add_get("/admin/{section:[a-z][a-z0-9_-]*}", index_route) app.router.add_get(
(
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
"appearance|settings}"
),
index_route,
)
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route) app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route) app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route) app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
app.router.add_get("/health", health_route) app.router.add_get("/health", health_route)
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route) app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
app.router.add_get(
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
webapp_uploaded_logo_route,
)
app.router.add_get( app.router.add_get(
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}", r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
webapp_animated_emoji_route, webapp_animated_emoji_route,
+5 -3
View File
@@ -1,7 +1,7 @@
"""Branded HTML email templates that mirror the subscription Mini App look. """Branded HTML email templates that mirror the subscription Mini App look.
The web app uses a dark theme with a configurable accent colour The web app uses a dark theme with a configurable accent colour
(`WEBAPP_PRIMARY_COLOR`) and an optional logo (`WEBAPP_LOGO_URL`). The same admin-configured accent colour and logo. The same
accent + logo are reused here so emails feel like part of the product. All accent + logo are reused here so emails feel like part of the product. All
copy goes through the shared `JsonI18n` instance so translations live in copy goes through the shared `JsonI18n` instance so translations live in
``locales/<lang>.json`` next to the rest of the bot strings. ``locales/<lang>.json`` next to the rest of the bot strings.
@@ -45,8 +45,10 @@ def _safe_color(value: Optional[str]) -> str:
def _public_logo_url(settings: Settings) -> Optional[str]: def _public_logo_url(settings: Settings) -> Optional[str]:
"""Email recipients can't reach the in-app /webapp-logo proxy, so the """Email recipients can't reach the in-app /webapp-logo proxy, so only a
raw https URL from the env is used directly. Anything else is dropped.""" stored public https URL can be used directly. Anything else is dropped."""
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
return None
raw = (settings.WEBAPP_LOGO_URL or "").strip() raw = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw: if not raw:
return None return None
+28
View File
@@ -92,6 +92,7 @@ class WebAppSettings(BaseModel):
title: str title: str
primary_color: str primary_color: str
logo_url: Optional[str] logo_url: Optional[str]
logo_use_emoji: bool
logo_emoji: str logo_emoji: str
logo_emoji_font: str logo_emoji_font: str
session_ttl_seconds: int session_ttl_seconds: int
@@ -369,6 +370,7 @@ class Settings(BaseSettings):
), ),
) )
WEBAPP_LOGO_URL: Optional[str] = Field(default=None) WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
WEBAPP_LOGO_USE_EMOJI: bool = Field(default=False)
WEBAPP_LOGO_EMOJI: str = Field(default="🫥") WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
WEBAPP_LOGO_EMOJI_FONT: str = Field( WEBAPP_LOGO_EMOJI_FONT: str = Field(
default="system", default="system",
@@ -545,6 +547,7 @@ class Settings(BaseSettings):
title=self.WEBAPP_TITLE, title=self.WEBAPP_TITLE,
primary_color=self.WEBAPP_PRIMARY_COLOR, primary_color=self.WEBAPP_PRIMARY_COLOR,
logo_url=self.WEBAPP_LOGO_URL, logo_url=self.WEBAPP_LOGO_URL,
logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI,
logo_emoji=self.WEBAPP_LOGO_EMOJI, logo_emoji=self.WEBAPP_LOGO_EMOJI,
logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT, logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT,
session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS, session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS,
@@ -836,6 +839,31 @@ class Settings(BaseSettings):
theme_dir=self.WEBAPP_THEMES_DIR, theme_dir=self.WEBAPP_THEMES_DIR,
) )
@field_validator("WEBAPP_PRIMARY_COLOR", mode="before")
@classmethod
def ignore_deprecated_webapp_primary_color_env(cls, _value):
return "#00fe7a"
@field_validator("WEBAPP_LOGO_URL", mode="before")
@classmethod
def ignore_deprecated_webapp_logo_url_env(cls, _value):
return None
@field_validator("WEBAPP_LOGO_USE_EMOJI", mode="before")
@classmethod
def ignore_deprecated_webapp_logo_use_emoji_env(cls, _value):
return False
@field_validator("WEBAPP_LOGO_EMOJI", mode="before")
@classmethod
def ignore_deprecated_webapp_logo_emoji_env(cls, _value):
return "🫥"
@field_validator("WEBAPP_LOGO_EMOJI_FONT", mode="before")
@classmethod
def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value):
return "system"
@computed_field @computed_field
@property @property
def referral_bonus_inviter(self) -> Dict[int, int]: def referral_bonus_inviter(self) -> Dict[int, int]:
+24 -5
View File
@@ -8,7 +8,7 @@ import re
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Literal, Optional from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,6 +48,22 @@ class ThemeTokens(BaseModel):
admin_muted: Optional[str] = None admin_muted: Optional[str] = None
admin_dim: Optional[str] = None admin_dim: Optional[str] = None
@field_validator("accent")
@classmethod
def _normalize_accent_hex(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
raw = str(value).strip()
if not raw:
return None
match = re.fullmatch(r"#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})", raw)
if not match:
raise ValueError("accent must be a hex color (#RGB or #RRGGBB)")
hex_value = match.group(1).lower()
if len(hex_value) == 3:
hex_value = "".join(char * 2 for char in hex_value)
return f"#{hex_value}"
class WebappTheme(BaseModel): class WebappTheme(BaseModel):
"""Single theme descriptor loaded from WEBAPP_THEMES_DIR/<key>/theme.json.""" """Single theme descriptor loaded from WEBAPP_THEMES_DIR/<key>/theme.json."""
@@ -256,10 +272,13 @@ def _theme_sort_key(theme: WebappTheme, index: int) -> tuple[int, int]:
def _sorted_themes(themes: List[WebappTheme]) -> List[WebappTheme]: def _sorted_themes(themes: List[WebappTheme]) -> List[WebappTheme]:
return [theme for _, theme in sorted( return [
((_theme_sort_key(t, i), t) for i, t in enumerate(themes)), theme
key=lambda pair: pair[0], 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( def _themes_config_from_list(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 64 KiB

+19
View File
@@ -28,6 +28,25 @@ class SettingsTests(unittest.TestCase):
self.assertTrue(settings.WEBHOOK_SECRET_TOKEN) self.assertTrue(settings.WEBHOOK_SECRET_TOKEN)
self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400) self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400)
def test_deprecated_webapp_appearance_env_values_are_ignored(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
WEBAPP_PRIMARY_COLOR="#ff0000",
WEBAPP_LOGO_URL="https://cdn.example.com/logo.png",
WEBAPP_LOGO_USE_EMOJI=True,
WEBAPP_LOGO_EMOJI="🔥",
WEBAPP_LOGO_EMOJI_FONT="twemoji",
)
self.assertEqual(settings.WEBAPP_PRIMARY_COLOR, "#00fe7a")
self.assertIsNone(settings.WEBAPP_LOGO_URL)
self.assertFalse(settings.WEBAPP_LOGO_USE_EMOJI)
self.assertEqual(settings.WEBAPP_LOGO_EMOJI, "🫥")
self.assertEqual(settings.WEBAPP_LOGO_EMOJI_FONT, "system")
def test_tariffs_config_missing_uses_legacy_fallback(self): def test_tariffs_config_missing_uses_legacy_fallback(self):
settings = Settings( settings = Settings(
_env_file=None, _env_file=None,
+33 -1
View File
@@ -11,6 +11,7 @@ from unittest.mock import patch
from bot.app.web import subscription_webapp from bot.app.web import subscription_webapp
from bot.app.web.webapp import assets as webapp_assets from bot.app.web.webapp import assets as webapp_assets
from config.settings import Settings from config.settings import Settings
from config.webapp_themes_config import builtin_webapp_themes_config
class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
@@ -91,6 +92,36 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
r"^/webapp-logo\?v=[0-9a-f]{12}$", r"^/webapp-logo\?v=[0-9a-f]{12}$",
) )
def test_uploaded_webapp_logo_url_is_served_directly(self):
settings = SimpleNamespace(
WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-abcdef1234567890.png"
)
self.assertEqual(
subscription_webapp._resolve_webapp_logo_url(settings),
"/webapp-uploaded-logo/logo-abcdef1234567890.png",
)
def test_webapp_logo_is_hidden_when_emoji_logo_is_enabled(self):
settings = SimpleNamespace(
WEBAPP_LOGO_USE_EMOJI=True,
WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-abcdef1234567890.png",
)
self.assertEqual(subscription_webapp._resolve_webapp_logo_url(settings), "")
def test_initial_theme_head_markup_includes_css_and_tokens(self):
cfg = builtin_webapp_themes_config("#123456")
theme = cfg.theme_by_key("light")
request = SimpleNamespace(get=lambda key, default="": "nonce-value")
markup = subscription_webapp._initial_theme_head_markup(request, theme, "#123456")
self.assertIn("/webapp-theme-css/light/style.css", markup)
self.assertIn('nonce="nonce-value"', markup)
self.assertIn("--accent:#123456", markup)
self.assertIn("color-scheme:light", markup)
def test_animated_emoji_asset_path_uses_same_origin_route(self): def test_animated_emoji_asset_path_uses_same_origin_route(self):
self.assertEqual( self.assertEqual(
subscription_webapp._webapp_animated_emoji_asset_path("🤩"), subscription_webapp._webapp_animated_emoji_asset_path("🤩"),
@@ -364,7 +395,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
logo_url = "https://cdn.example.com/logo.png" logo_url = "https://cdn.example.com/logo.png"
logo = (b"cached-logo", "image/png") logo = (b"cached-logo", "image/png")
app = { app = {
"settings": SimpleNamespace(WEBAPP_LOGO_URL=logo_url), "settings": SimpleNamespace(WEBAPP_LOGO_URL=logo_url, WEBAPP_LOGO_USE_EMOJI=False),
"webapp_logo_cache": None, "webapp_logo_cache": None,
"webapp_logo_cache_lock": asyncio.Lock(), "webapp_logo_cache_lock": asyncio.Lock(),
} }
@@ -403,6 +434,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
app = { app = {
"settings": SimpleNamespace( "settings": SimpleNamespace(
WEBAPP_LOGO_USE_EMOJI=True,
WEBAPP_LOGO_EMOJI="🤩", WEBAPP_LOGO_EMOJI="🤩",
WEBAPP_LOGO_EMOJI_FONT="noto-color-animated", WEBAPP_LOGO_EMOJI_FONT="noto-color-animated",
), ),
+22
View File
@@ -1,8 +1,10 @@
import asyncio
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from aiohttp import web from aiohttp import web
from aiohttp.test_utils import make_mocked_request
from bot.app.web import admin_api, subscription_webapp from bot.app.web import admin_api, subscription_webapp
from bot.app.web.admin_api_impl import auth as admin_auth_routes from bot.app.web.admin_api_impl import auth as admin_auth_routes
@@ -57,6 +59,7 @@ class WebAppRouteContractTests(unittest.TestCase):
("GET", "/auth/telegram/callback"): "telegram_oauth_callback_route", ("GET", "/auth/telegram/callback"): "telegram_oauth_callback_route",
("GET", "/health"): "health_route", ("GET", "/health"): "health_route",
("GET", "/webapp-logo"): "webapp_logo_route", ("GET", "/webapp-logo"): "webapp_logo_route",
("GET", "/webapp-uploaded-logo/{filename}"): "webapp_uploaded_logo_route",
("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route", ("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route",
("GET", "/subscription_webapp.css"): "css_asset_route", ("GET", "/subscription_webapp.css"): "css_asset_route",
("GET", "/webapp-theme-css/{path}"): "theme_css_asset_route", ("GET", "/webapp-theme-css/{path}"): "theme_css_asset_route",
@@ -139,12 +142,31 @@ class WebAppRouteContractTests(unittest.TestCase):
("PUT", "/api/admin/tariffs"): "admin_tariffs_save_route", ("PUT", "/api/admin/tariffs"): "admin_tariffs_save_route",
("GET", "/api/admin/themes"): "admin_themes_get_route", ("GET", "/api/admin/themes"): "admin_themes_get_route",
("PUT", "/api/admin/themes"): "admin_themes_save_route", ("PUT", "/api/admin/themes"): "admin_themes_save_route",
("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route",
("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route", ("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route",
} }
for key, handler_name in expected.items(): for key, handler_name in expected.items():
self.assertEqual(routes.get(key), handler_name, key) self.assertEqual(routes.get(key), handler_name, key)
def test_admin_themes_page_route_is_not_registered(self):
app = web.Application()
subscription_webapp.setup_subscription_webapp_routes(app)
request = make_mocked_request("GET", "/admin/themes", app=app)
match_info = asyncio.run(app.router.resolve(request))
self.assertEqual(match_info.http_exception.status, 404)
def test_admin_appearance_page_route_is_registered(self):
app = web.Application()
subscription_webapp.setup_subscription_webapp_routes(app)
request = make_mocked_request("GET", "/admin/appearance", app=app)
match_info = asyncio.run(app.router.resolve(request))
self.assertEqual(match_info.handler.__name__, "index_route")
class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase): class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase):
def _settings(self): def _settings(self):
+29
View File
@@ -279,6 +279,35 @@ class WebappThemesConfigTests(unittest.TestCase):
self.assertTrue(win95["use_in_admin"]) self.assertTrue(win95["use_in_admin"])
self.assertNotIn("accent", win95["tokens"]) self.assertNotIn("accent", win95["tokens"])
def test_theme_accent_is_normalized_to_hex(self):
cfg = WebappThemesConfig(
default_theme="custom",
themes=[
{
"key": "custom",
"enabled": True,
"default": True,
"tokens": {"color_scheme": "dark", "accent": "0F8"},
}
],
)
self.assertEqual(cfg.theme_by_key("custom").tokens.accent, "#00ff88")
def test_theme_accent_rejects_non_hex_values(self):
with self.assertRaises(ValueError):
WebappThemesConfig(
default_theme="custom",
themes=[
{
"key": "custom",
"enabled": True,
"default": True,
"tokens": {"color_scheme": "dark", "accent": "lime"},
}
],
)
def test_public_payload_keeps_admin_usage_flag(self): def test_public_payload_keeps_admin_usage_flag(self):
cfg = WebappThemesConfig( cfg = WebappThemesConfig(
default_theme="custom", default_theme="custom",