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
+27 -3
View File
@@ -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";
+67 -1
View File
@@ -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,