feat: custom themes polishing
This commit is contained in:
@@ -38,11 +38,8 @@ WEBAPP_ENABLED=True #
|
||||
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
|
||||
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
|
||||
WEBAPP_TITLE="/minishop" # Mini App title
|
||||
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
|
||||
WEBAPP_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
|
||||
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
|
||||
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
|
||||
|
||||
@@ -56,4 +56,5 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
|
||||
router.add_get("/api/admin/themes", admin_themes_get_route)
|
||||
router.add_put("/api/admin/themes", admin_themes_save_route)
|
||||
router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route)
|
||||
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
|
||||
|
||||
@@ -70,5 +70,12 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
if (
|
||||
"WEBAPP_LOGO_URL" in updates
|
||||
or "WEBAPP_LOGO_URL" in deletes
|
||||
or "WEBAPP_LOGO_USE_EMOJI" in updates
|
||||
or "WEBAPP_LOGO_USE_EMOJI" in deletes
|
||||
):
|
||||
request.app["webapp_logo_cache"] = None
|
||||
|
||||
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
|
||||
from config.webapp_themes_config import (
|
||||
WebappThemesConfig,
|
||||
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:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
|
||||
@@ -79,6 +79,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
SettingField(
|
||||
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
|
||||
),
|
||||
SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"),
|
||||
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
|
||||
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
|
||||
SettingField(
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
...(MOCK ? MOCK.config : {}),
|
||||
...(injectedConfig || {}),
|
||||
};
|
||||
const themePreviewKey = String(CFG.themePreviewKey || query.get("theme_preview") || "").trim();
|
||||
const I18N = injectedI18n || {};
|
||||
let telegramSdkStatus = "idle";
|
||||
let telegramMiniAppInitData = "";
|
||||
@@ -246,7 +247,7 @@
|
||||
$: brandEmojiFont = CFG.logoEmojiFont || "system";
|
||||
$: brand = normalizeBrand({
|
||||
title: brandTitle,
|
||||
logoUrl: CFG.logoUrl,
|
||||
logoUrl: CFG.logoUseEmoji ? "" : CFG.logoUrl,
|
||||
emoji: brandEmoji,
|
||||
emojiFont: brandEmojiFont,
|
||||
});
|
||||
@@ -308,7 +309,11 @@
|
||||
$: user = data?.user || {};
|
||||
$: themesCatalog = data?.themes_catalog ||
|
||||
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);
|
||||
$: darkThemeEntry = findThemeEntry(themesCatalog, "dark");
|
||||
$: 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);
|
||||
try {
|
||||
await loadData();
|
||||
} catch {
|
||||
// Admin save already succeeded; a later full refresh will pick up new settings or catalog.
|
||||
}
|
||||
const shouldReloadFrontend =
|
||||
options?.reloadFrontend === true ||
|
||||
(!options?.deferFrontendReload && adminPayloadHasLogoChange(options));
|
||||
if (shouldReloadFrontend && typeof window !== "undefined") {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function selectTariff(tariff) {
|
||||
|
||||
@@ -35,7 +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 AppearanceSection from "./sections/AppearanceSection.svelte";
|
||||
import UserDetailModal from "./sections/UserDetailModal.svelte";
|
||||
import UsersSection from "./sections/UsersSection.svelte";
|
||||
import { createAdsStore } from "../lib/admin/stores/adsStore.js";
|
||||
@@ -115,7 +115,7 @@
|
||||
label: at("nav_system", {}, "Система"),
|
||||
items: [
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
@@ -158,9 +158,9 @@
|
||||
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"),
|
||||
appearance: {
|
||||
title: at("section_appearance_title", {}, "Внешний вид"),
|
||||
subtitle: at("section_appearance_subtitle", {}, "Логотип, темы и акцентные цвета Mini App"),
|
||||
},
|
||||
settings: {
|
||||
title: at("section_settings_title", {}, "Настройки приложения"),
|
||||
@@ -646,8 +646,8 @@
|
||||
<TariffsSection {at} {fmtMoney} />
|
||||
{/if}
|
||||
|
||||
{#if active === "themes"}
|
||||
<ThemesSection {at} {currentLang} />
|
||||
{#if active === "appearance"}
|
||||
<AppearanceSection {at} {currentLang} {onSettingsSaved} {brand} />
|
||||
{/if}
|
||||
|
||||
{#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");
|
||||
|
||||
$: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore);
|
||||
$: visibleSettingsSections = settingsSections.filter((section) => section.id !== "appearance");
|
||||
|
||||
let settingsOpenSections = [];
|
||||
let settingsOpenSubsections = {};
|
||||
let revealedSecrets = new Set();
|
||||
|
||||
$: settingsAllOpen =
|
||||
settingsSections.length > 0 && settingsOpenSections.length === settingsSections.length;
|
||||
visibleSettingsSections.length > 0 &&
|
||||
settingsOpenSections.length === visibleSettingsSections.length;
|
||||
|
||||
onMount(() => {
|
||||
settingsStore.loadSettings().then(() => {
|
||||
if ($settingsStore.settingsSections.length) {
|
||||
const ids = $settingsStore.settingsSections.map((s) => s.id);
|
||||
const ids = $settingsStore.settingsSections
|
||||
.filter((s) => s.id !== "appearance")
|
||||
.map((s) => s.id);
|
||||
settingsOpenSections = isCompact ? ids.slice(0, 1) : ids.slice();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function toggleAllSections() {
|
||||
if (settingsOpenSections.length === settingsSections.length) {
|
||||
if (settingsOpenSections.length === visibleSettingsSections.length) {
|
||||
settingsOpenSections = [];
|
||||
} else {
|
||||
settingsOpenSections = settingsSections.map((s) => s.id);
|
||||
settingsOpenSections = visibleSettingsSections.map((s) => s.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +233,7 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if settingsLoading || !settingsSections.length}
|
||||
{#if settingsLoading || !visibleSettingsSections.length}
|
||||
<AdminEmptyState
|
||||
>{settingsLoading
|
||||
? at("loading", {}, "Загрузка…")
|
||||
@@ -265,7 +269,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<Accordion.Root type="multiple" bind:value={settingsOpenSections} class="admin-accordion">
|
||||
{#each settingsSections as section}
|
||||
{#each visibleSettingsSections as section}
|
||||
{@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length}
|
||||
{@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length}
|
||||
<Accordion.Item value={section.id} class="admin-accordion-item admin-card">
|
||||
|
||||
@@ -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) {
|
||||
let dirty = {};
|
||||
state.update((s) => {
|
||||
dirty = s.settingsDirty;
|
||||
return s;
|
||||
});
|
||||
if (!Object.keys(dirty).length) return;
|
||||
if (!Object.keys(dirty).length) return true;
|
||||
|
||||
state.update((s) => ({ ...s, settingsSaving: true }));
|
||||
try {
|
||||
@@ -63,6 +80,7 @@ export function createSettingsStore({ api, onToast, at }) {
|
||||
state.update((s) => ({ ...s, settingsDirty: {} }));
|
||||
if (onSettingsSaved) await onSettingsSaved({ updates, deletes });
|
||||
await loadSettings();
|
||||
return true;
|
||||
} else if (res?.errors) {
|
||||
const summary = Object.entries(res.errors)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
@@ -71,6 +89,7 @@ export function createSettingsStore({ api, onToast, at }) {
|
||||
} else {
|
||||
onToast(res?.error || "Ошибка");
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, settingsSaving: false }));
|
||||
}
|
||||
@@ -91,6 +110,7 @@ export function createSettingsStore({ api, onToast, at }) {
|
||||
loadSettings,
|
||||
markDirty,
|
||||
clearDirty,
|
||||
setFieldValue,
|
||||
resetField,
|
||||
saveSettings,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
let changed = false;
|
||||
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 {
|
||||
subscribe: state.subscribe,
|
||||
loadThemes,
|
||||
saveThemes,
|
||||
setCurrentTheme,
|
||||
setThemeAccent,
|
||||
togglePrimaryAccent,
|
||||
toggleAdminUse,
|
||||
uploadLogoFile,
|
||||
uploadLogoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
export let emojiFont = "";
|
||||
export let size = "sm";
|
||||
export let animate = false;
|
||||
export let fallbackEmoji = true;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
@@ -206,7 +207,7 @@
|
||||
clearLogoLoadTimeout();
|
||||
}}
|
||||
/>
|
||||
{:else if useAnimatedEmoji}
|
||||
{:else if fallbackEmoji && useAnimatedEmoji}
|
||||
<img
|
||||
class="brand-mark-animated-emoji loaded"
|
||||
src={animatedEmojiStaticFallback ? animatedEmojiFallbackSrc : animatedEmojiSrc}
|
||||
@@ -222,7 +223,7 @@
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
{:else if fallbackEmoji}
|
||||
<span
|
||||
class={cn("brand-mark-emoji", getEmojiFontClass(normalizedEmojiFont))}
|
||||
style="opacity: {fontLoaded ? 1 : 0}; transition: opacity 0.2s ease;"
|
||||
|
||||
@@ -34,7 +34,7 @@ export const ADMIN_SECTIONS = new Set([
|
||||
"broadcast",
|
||||
"logs",
|
||||
"tariffs",
|
||||
"themes",
|
||||
"appearance",
|
||||
"settings",
|
||||
]);
|
||||
export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js";
|
||||
|
||||
@@ -219,7 +219,73 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
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/"))
|
||||
return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 };
|
||||
if (path === "/me") return clone(DEV_MOCK.data);
|
||||
|
||||
@@ -27,6 +27,7 @@ export const DEV_MOCK = {
|
||||
title: "/minishop",
|
||||
primaryColor: "#00fe7a",
|
||||
logoUrl: "",
|
||||
logoUseEmoji: false,
|
||||
logoEmoji: "🫥",
|
||||
logoEmojiFont: "system",
|
||||
apiBase: "/api",
|
||||
|
||||
@@ -8,6 +8,8 @@ export function createApiClient({
|
||||
mockApi = null,
|
||||
getMockContext = () => ({}),
|
||||
} = {}) {
|
||||
const isFormDataBody = (body) => typeof FormData !== "undefined" && body instanceof FormData;
|
||||
|
||||
async function api(path, options = {}) {
|
||||
if (mockApi) return mockApi(path, options, getMockContext());
|
||||
|
||||
@@ -18,7 +20,9 @@ export function createApiClient({
|
||||
if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
|
||||
headers["X-CSRF-Token"] = csrf;
|
||||
}
|
||||
if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
|
||||
if (options.body && !headers["Content-Type"] && !isFormDataBody(options.body)) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
...options,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import html
|
||||
import hmac
|
||||
import io
|
||||
import ipaddress
|
||||
@@ -17,7 +18,7 @@ from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.types import LabeledPrice
|
||||
@@ -65,6 +66,8 @@ TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscriptio
|
||||
ASSET_DIR = TEMPLATE_PATH.parent
|
||||
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
|
||||
WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo"
|
||||
WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads"
|
||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||
WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji"
|
||||
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||
|
||||
@@ -5,6 +5,7 @@ from config.webapp_themes_config import (
|
||||
default_webapp_theme_asset_file,
|
||||
default_webapp_theme_css_files,
|
||||
ensure_default_webapp_theme_descriptor_files,
|
||||
public_theme_payload,
|
||||
public_themes_catalog_payload,
|
||||
)
|
||||
|
||||
@@ -123,6 +124,9 @@ async def theme_asset_route(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
def _resolve_webapp_logo_url(settings: Settings) -> str:
|
||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
return ""
|
||||
|
||||
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||
if not raw_logo_url:
|
||||
return ""
|
||||
@@ -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:
|
||||
settings: Settings = request.app["settings"]
|
||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
raise web.HTTPNotFound(text="webapp_logo_disabled")
|
||||
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||
if not raw_logo_url:
|
||||
raise web.HTTPNotFound(text="webapp_logo_not_configured")
|
||||
@@ -206,6 +212,41 @@ async def webapp_logo_route(request: web.Request) -> web.Response:
|
||||
return response
|
||||
|
||||
|
||||
async def webapp_uploaded_logo_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.WEBAPP_ENABLED:
|
||||
raise web.HTTPNotFound(text="webapp_disabled")
|
||||
|
||||
filename = str(request.match_info.get("filename") or "").strip()
|
||||
if not re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename):
|
||||
raise web.HTTPNotFound(text="webapp_logo_not_found")
|
||||
|
||||
root = WEBAPP_UPLOADED_LOGO_DIR.expanduser().resolve()
|
||||
path = (root / filename).resolve()
|
||||
try:
|
||||
path.relative_to(root)
|
||||
except ValueError:
|
||||
raise web.HTTPNotFound(text="webapp_logo_not_found") from None
|
||||
|
||||
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower())
|
||||
if not content_type:
|
||||
raise web.HTTPNotFound(text="webapp_logo_not_found")
|
||||
|
||||
try:
|
||||
if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES:
|
||||
raise web.HTTPNotFound(text="webapp_logo_too_large")
|
||||
body = path.read_bytes()
|
||||
except OSError:
|
||||
raise web.HTTPNotFound(text="webapp_logo_not_found") from None
|
||||
|
||||
if not body:
|
||||
raise web.HTTPNotFound(text="webapp_logo_not_found")
|
||||
|
||||
response = web.Response(body=body, content_type=content_type)
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
|
||||
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
|
||||
ext = str(request.match_info.get("ext") or "").strip().lower()
|
||||
@@ -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:
|
||||
settings: Settings = app["settings"]
|
||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
return
|
||||
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||
if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url):
|
||||
return
|
||||
@@ -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:
|
||||
settings: Settings = app["settings"]
|
||||
if not getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
return
|
||||
if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated":
|
||||
return
|
||||
|
||||
@@ -547,7 +592,7 @@ async def _security_headers_middleware(request: web.Request, handler):
|
||||
"frame-ancestors https://web.telegram.org https://t.me; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " # noqa: E501
|
||||
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; "
|
||||
"img-src 'self' data: https:; "
|
||||
"img-src 'self' data: blob: https:; "
|
||||
"connect-src 'self' https://oauth.telegram.org; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
@@ -741,16 +786,24 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
html = TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
cached = _get_cached_webapp_settings(request)
|
||||
themes_catalog = settings.webapp_themes_catalog
|
||||
primary_color = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a"
|
||||
initial_theme = _initial_theme_for_request(request, themes_catalog)
|
||||
preview_key = str(request.query.get("theme_preview") or "").strip()
|
||||
preview_theme = themes_catalog.theme_by_key(preview_key) if preview_key else None
|
||||
if preview_theme is None or not preview_theme.enabled:
|
||||
preview_key = ""
|
||||
config = {
|
||||
"title": settings.WEBAPP_TITLE,
|
||||
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
|
||||
"themesCatalog": public_themes_catalog_payload(
|
||||
themes_catalog,
|
||||
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
|
||||
primary_color,
|
||||
enabled_only=True,
|
||||
),
|
||||
"themesDir": settings.WEBAPP_THEMES_DIR,
|
||||
"themePreviewKey": preview_key,
|
||||
"logoUrl": cached["logo_url"],
|
||||
"logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI),
|
||||
"logoEmoji": settings.WEBAPP_LOGO_EMOJI,
|
||||
"logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT,
|
||||
"apiBase": "/api",
|
||||
@@ -769,6 +822,9 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
"appRepositoryUrl": APP_REPOSITORY_URL,
|
||||
}
|
||||
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
|
||||
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
|
||||
if initial_theme_markup:
|
||||
html = html.replace("</head>", f"{initial_theme_markup}\n</head>", 1)
|
||||
i18n_instance: Optional[object] = request.app.get("i18n")
|
||||
i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
|
||||
nonce = request.get("csp_nonce", "")
|
||||
@@ -793,7 +849,11 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
|
||||
)
|
||||
brand_asset_url = cached["logo_url"]
|
||||
if not brand_asset_url and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated":
|
||||
if (
|
||||
not brand_asset_url
|
||||
and settings.WEBAPP_LOGO_USE_EMOJI
|
||||
and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated"
|
||||
):
|
||||
brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI)
|
||||
if brand_asset_url:
|
||||
html = html.replace(
|
||||
@@ -843,6 +903,95 @@ def _resolve_webapp_js_asset_name() -> str:
|
||||
return "subscription_webapp.js"
|
||||
|
||||
|
||||
_INITIAL_THEME_TOKEN_CSS_MAP = {
|
||||
"accent": "--accent",
|
||||
"bg": "--bg",
|
||||
"panel": "--panel",
|
||||
"panel_2": "--panel-2",
|
||||
"panel_3": "--panel-3",
|
||||
"border": "--border",
|
||||
"border_strong": "--border-strong",
|
||||
"text": "--text",
|
||||
"muted": "--muted",
|
||||
"dim": "--dim",
|
||||
"danger": "--danger",
|
||||
"blue": "--blue",
|
||||
"radius": "--radius",
|
||||
"font_sans": "--font-sans",
|
||||
"font_logo": "--font-logo",
|
||||
"font_mono": "--font-mono",
|
||||
"admin_bg": "--admin-bg",
|
||||
"admin_surface": "--admin-surface",
|
||||
"admin_surface_2": "--admin-surface-2",
|
||||
"admin_elev": "--admin-elev",
|
||||
"admin_border": "--admin-border",
|
||||
"admin_border_strong": "--admin-border-strong",
|
||||
"admin_text": "--admin-text",
|
||||
"admin_muted": "--admin-muted",
|
||||
"admin_dim": "--admin-dim",
|
||||
}
|
||||
|
||||
|
||||
def _theme_css_href_for_html(theme: Any) -> str:
|
||||
css_file = str(getattr(theme, "css_file", "") or "").strip()
|
||||
key = str(getattr(theme, "key", "") or "").strip()
|
||||
if not css_file or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key):
|
||||
return ""
|
||||
parts = [part for part in css_file.replace("\\", "/").split("/") if part]
|
||||
if any(part in {".", ".."} for part in parts):
|
||||
return ""
|
||||
themed_path = "/".join([key, *parts])
|
||||
encoded = "/".join(quote(part, safe="") for part in themed_path.split("/"))
|
||||
return f"/webapp-theme-css/{encoded}" if encoded else ""
|
||||
|
||||
|
||||
def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any:
|
||||
preview_key = str(request.query.get("theme_preview") or "").strip()
|
||||
if preview_key:
|
||||
preview_theme = catalog.theme_by_key(preview_key)
|
||||
if preview_theme is not None and preview_theme.enabled:
|
||||
return preview_theme
|
||||
|
||||
theme = catalog.theme_by_key(catalog.default_theme)
|
||||
if theme is not None:
|
||||
return theme
|
||||
return catalog.enabled_themes()[0] if catalog.enabled_themes() else None
|
||||
|
||||
|
||||
def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: str) -> str:
|
||||
if theme is None:
|
||||
return ""
|
||||
|
||||
payload = public_theme_payload(theme, primary_color)
|
||||
tokens = payload.get("tokens") if isinstance(payload, dict) else {}
|
||||
tokens = tokens if isinstance(tokens, dict) else {}
|
||||
declarations = []
|
||||
for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items():
|
||||
value = str(tokens.get(token_key) or "").strip()
|
||||
if value:
|
||||
declarations.append(f"{css_name}:{value}")
|
||||
|
||||
scheme = "light" if tokens.get("color_scheme") == "light" else "dark"
|
||||
bg = str(tokens.get("bg") or "").strip()
|
||||
css_rules = [f"html{{color-scheme:{scheme};}}"]
|
||||
if bg:
|
||||
css_rules.append(f"body{{background-color:{bg};}}")
|
||||
if declarations:
|
||||
css_rules.append(f".app-shell{{{';'.join(declarations)}}}")
|
||||
|
||||
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
|
||||
style_tag = (
|
||||
f'<style id="webapp-initial-theme" nonce="{nonce}">' + "".join(css_rules) + "</style>"
|
||||
)
|
||||
href = _theme_css_href_for_html(theme)
|
||||
if not href:
|
||||
return style_tag
|
||||
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:
|
||||
start = html.find(start_marker)
|
||||
if start == -1:
|
||||
|
||||
@@ -9,12 +9,22 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/devices", index_route)
|
||||
app.router.add_get("/settings", index_route)
|
||||
app.router.add_get("/admin", index_route)
|
||||
app.router.add_get("/admin/{section:[a-z][a-z0-9_-]*}", index_route)
|
||||
app.router.add_get(
|
||||
(
|
||||
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
||||
"appearance|settings}"
|
||||
),
|
||||
index_route,
|
||||
)
|
||||
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
|
||||
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
|
||||
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
|
||||
app.router.add_get("/health", health_route)
|
||||
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
|
||||
app.router.add_get(
|
||||
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||
webapp_uploaded_logo_route,
|
||||
)
|
||||
app.router.add_get(
|
||||
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
|
||||
webapp_animated_emoji_route,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Branded HTML email templates that mirror the subscription Mini App look.
|
||||
|
||||
The web app uses a dark theme with a configurable accent colour
|
||||
(`WEBAPP_PRIMARY_COLOR`) and an optional logo (`WEBAPP_LOGO_URL`). The same
|
||||
admin-configured accent colour and logo. The same
|
||||
accent + logo are reused here so emails feel like part of the product. All
|
||||
copy goes through the shared `JsonI18n` instance so translations live in
|
||||
``locales/<lang>.json`` next to the rest of the bot strings.
|
||||
@@ -45,8 +45,10 @@ def _safe_color(value: Optional[str]) -> str:
|
||||
|
||||
|
||||
def _public_logo_url(settings: Settings) -> Optional[str]:
|
||||
"""Email recipients can't reach the in-app /webapp-logo proxy, so the
|
||||
raw https URL from the env is used directly. Anything else is dropped."""
|
||||
"""Email recipients can't reach the in-app /webapp-logo proxy, so only a
|
||||
stored public https URL can be used directly. Anything else is dropped."""
|
||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
return None
|
||||
raw = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
@@ -92,6 +92,7 @@ class WebAppSettings(BaseModel):
|
||||
title: str
|
||||
primary_color: str
|
||||
logo_url: Optional[str]
|
||||
logo_use_emoji: bool
|
||||
logo_emoji: str
|
||||
logo_emoji_font: str
|
||||
session_ttl_seconds: int
|
||||
@@ -369,6 +370,7 @@ class Settings(BaseSettings):
|
||||
),
|
||||
)
|
||||
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
|
||||
WEBAPP_LOGO_USE_EMOJI: bool = Field(default=False)
|
||||
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
|
||||
WEBAPP_LOGO_EMOJI_FONT: str = Field(
|
||||
default="system",
|
||||
@@ -545,6 +547,7 @@ class Settings(BaseSettings):
|
||||
title=self.WEBAPP_TITLE,
|
||||
primary_color=self.WEBAPP_PRIMARY_COLOR,
|
||||
logo_url=self.WEBAPP_LOGO_URL,
|
||||
logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI,
|
||||
logo_emoji=self.WEBAPP_LOGO_EMOJI,
|
||||
logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT,
|
||||
session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS,
|
||||
@@ -836,6 +839,31 @@ class Settings(BaseSettings):
|
||||
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
|
||||
@property
|
||||
def referral_bonus_inviter(self) -> Dict[int, int]:
|
||||
|
||||
@@ -8,7 +8,7 @@ import re
|
||||
from pathlib import Path
|
||||
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__)
|
||||
|
||||
@@ -48,6 +48,22 @@ class ThemeTokens(BaseModel):
|
||||
admin_muted: Optional[str] = None
|
||||
admin_dim: Optional[str] = None
|
||||
|
||||
@field_validator("accent")
|
||||
@classmethod
|
||||
def _normalize_accent_hex(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
raw = str(value).strip()
|
||||
if not raw:
|
||||
return None
|
||||
match = re.fullmatch(r"#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})", raw)
|
||||
if not match:
|
||||
raise ValueError("accent must be a hex color (#RGB or #RRGGBB)")
|
||||
hex_value = match.group(1).lower()
|
||||
if len(hex_value) == 3:
|
||||
hex_value = "".join(char * 2 for char in hex_value)
|
||||
return f"#{hex_value}"
|
||||
|
||||
|
||||
class WebappTheme(BaseModel):
|
||||
"""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]:
|
||||
return [theme for _, theme in sorted(
|
||||
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(
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 64 KiB |
@@ -28,6 +28,25 @@ class SettingsTests(unittest.TestCase):
|
||||
self.assertTrue(settings.WEBHOOK_SECRET_TOKEN)
|
||||
self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400)
|
||||
|
||||
def test_deprecated_webapp_appearance_env_values_are_ignored(self):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
BOT_TOKEN="token",
|
||||
POSTGRES_USER="app_user",
|
||||
POSTGRES_PASSWORD="app_password",
|
||||
WEBAPP_PRIMARY_COLOR="#ff0000",
|
||||
WEBAPP_LOGO_URL="https://cdn.example.com/logo.png",
|
||||
WEBAPP_LOGO_USE_EMOJI=True,
|
||||
WEBAPP_LOGO_EMOJI="🔥",
|
||||
WEBAPP_LOGO_EMOJI_FONT="twemoji",
|
||||
)
|
||||
|
||||
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):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
|
||||
@@ -11,6 +11,7 @@ from unittest.mock import patch
|
||||
from bot.app.web import subscription_webapp
|
||||
from bot.app.web.webapp import assets as webapp_assets
|
||||
from config.settings import Settings
|
||||
from config.webapp_themes_config import builtin_webapp_themes_config
|
||||
|
||||
|
||||
class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
||||
@@ -91,6 +92,36 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
||||
r"^/webapp-logo\?v=[0-9a-f]{12}$",
|
||||
)
|
||||
|
||||
def test_uploaded_webapp_logo_url_is_served_directly(self):
|
||||
settings = SimpleNamespace(
|
||||
WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-abcdef1234567890.png"
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
subscription_webapp._resolve_webapp_logo_url(settings),
|
||||
"/webapp-uploaded-logo/logo-abcdef1234567890.png",
|
||||
)
|
||||
|
||||
def test_webapp_logo_is_hidden_when_emoji_logo_is_enabled(self):
|
||||
settings = SimpleNamespace(
|
||||
WEBAPP_LOGO_USE_EMOJI=True,
|
||||
WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-abcdef1234567890.png",
|
||||
)
|
||||
|
||||
self.assertEqual(subscription_webapp._resolve_webapp_logo_url(settings), "")
|
||||
|
||||
def test_initial_theme_head_markup_includes_css_and_tokens(self):
|
||||
cfg = builtin_webapp_themes_config("#123456")
|
||||
theme = cfg.theme_by_key("light")
|
||||
request = SimpleNamespace(get=lambda key, default="": "nonce-value")
|
||||
|
||||
markup = subscription_webapp._initial_theme_head_markup(request, theme, "#123456")
|
||||
|
||||
self.assertIn("/webapp-theme-css/light/style.css", markup)
|
||||
self.assertIn('nonce="nonce-value"', markup)
|
||||
self.assertIn("--accent:#123456", markup)
|
||||
self.assertIn("color-scheme:light", markup)
|
||||
|
||||
def test_animated_emoji_asset_path_uses_same_origin_route(self):
|
||||
self.assertEqual(
|
||||
subscription_webapp._webapp_animated_emoji_asset_path("🤩"),
|
||||
@@ -364,7 +395,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
||||
logo_url = "https://cdn.example.com/logo.png"
|
||||
logo = (b"cached-logo", "image/png")
|
||||
app = {
|
||||
"settings": SimpleNamespace(WEBAPP_LOGO_URL=logo_url),
|
||||
"settings": SimpleNamespace(WEBAPP_LOGO_URL=logo_url, WEBAPP_LOGO_USE_EMOJI=False),
|
||||
"webapp_logo_cache": None,
|
||||
"webapp_logo_cache_lock": asyncio.Lock(),
|
||||
}
|
||||
@@ -403,6 +434,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
app = {
|
||||
"settings": SimpleNamespace(
|
||||
WEBAPP_LOGO_USE_EMOJI=True,
|
||||
WEBAPP_LOGO_EMOJI="🤩",
|
||||
WEBAPP_LOGO_EMOJI_FONT="noto-color-animated",
|
||||
),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
from bot.app.web import admin_api, subscription_webapp
|
||||
from bot.app.web.admin_api_impl import auth as admin_auth_routes
|
||||
@@ -57,6 +59,7 @@ class WebAppRouteContractTests(unittest.TestCase):
|
||||
("GET", "/auth/telegram/callback"): "telegram_oauth_callback_route",
|
||||
("GET", "/health"): "health_route",
|
||||
("GET", "/webapp-logo"): "webapp_logo_route",
|
||||
("GET", "/webapp-uploaded-logo/{filename}"): "webapp_uploaded_logo_route",
|
||||
("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route",
|
||||
("GET", "/subscription_webapp.css"): "css_asset_route",
|
||||
("GET", "/webapp-theme-css/{path}"): "theme_css_asset_route",
|
||||
@@ -139,12 +142,31 @@ class WebAppRouteContractTests(unittest.TestCase):
|
||||
("PUT", "/api/admin/tariffs"): "admin_tariffs_save_route",
|
||||
("GET", "/api/admin/themes"): "admin_themes_get_route",
|
||||
("PUT", "/api/admin/themes"): "admin_themes_save_route",
|
||||
("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route",
|
||||
("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route",
|
||||
}
|
||||
|
||||
for key, handler_name in expected.items():
|
||||
self.assertEqual(routes.get(key), handler_name, key)
|
||||
|
||||
def test_admin_themes_page_route_is_not_registered(self):
|
||||
app = web.Application()
|
||||
subscription_webapp.setup_subscription_webapp_routes(app)
|
||||
|
||||
request = make_mocked_request("GET", "/admin/themes", app=app)
|
||||
match_info = asyncio.run(app.router.resolve(request))
|
||||
|
||||
self.assertEqual(match_info.http_exception.status, 404)
|
||||
|
||||
def test_admin_appearance_page_route_is_registered(self):
|
||||
app = web.Application()
|
||||
subscription_webapp.setup_subscription_webapp_routes(app)
|
||||
|
||||
request = make_mocked_request("GET", "/admin/appearance", app=app)
|
||||
match_info = asyncio.run(app.router.resolve(request))
|
||||
|
||||
self.assertEqual(match_info.handler.__name__, "index_route")
|
||||
|
||||
|
||||
class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase):
|
||||
def _settings(self):
|
||||
|
||||
@@ -279,6 +279,35 @@ class WebappThemesConfigTests(unittest.TestCase):
|
||||
self.assertTrue(win95["use_in_admin"])
|
||||
self.assertNotIn("accent", win95["tokens"])
|
||||
|
||||
def test_theme_accent_is_normalized_to_hex(self):
|
||||
cfg = WebappThemesConfig(
|
||||
default_theme="custom",
|
||||
themes=[
|
||||
{
|
||||
"key": "custom",
|
||||
"enabled": True,
|
||||
"default": True,
|
||||
"tokens": {"color_scheme": "dark", "accent": "0F8"},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(cfg.theme_by_key("custom").tokens.accent, "#00ff88")
|
||||
|
||||
def test_theme_accent_rejects_non_hex_values(self):
|
||||
with self.assertRaises(ValueError):
|
||||
WebappThemesConfig(
|
||||
default_theme="custom",
|
||||
themes=[
|
||||
{
|
||||
"key": "custom",
|
||||
"enabled": True,
|
||||
"default": True,
|
||||
"tokens": {"color_scheme": "dark", "accent": "lime"},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def test_public_payload_keeps_admin_usage_flag(self):
|
||||
cfg = WebappThemesConfig(
|
||||
default_theme="custom",
|
||||
|
||||
Reference in New Issue
Block a user