refactor: brand logo customization and emoji font selection
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
import { createAccountStore } from "./lib/webapp/stores/accountStore.js";
|
||||
import { Tooltip } from "$components/ui/primitives.js";
|
||||
|
||||
import BrandMark from "./BrandMark.svelte";
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
import PreviewBoard from "./PreviewBoard.svelte";
|
||||
import AdminPanel from "./admin/AdminPanel.svelte";
|
||||
import WebAppShell from "./webapp/WebAppShell.svelte";
|
||||
@@ -29,7 +29,12 @@
|
||||
WEBAPP_LANGUAGE_ORDER,
|
||||
} from "./lib/webapp/constants.js";
|
||||
|
||||
import { applyFavicon, readJsonScript, structuredCloneSafe } from "./lib/webapp/browser.js";
|
||||
import {
|
||||
applyFavicon,
|
||||
normalizeBrand,
|
||||
readJsonScript,
|
||||
structuredCloneSafe,
|
||||
} from "./lib/webapp/browser.js";
|
||||
import { createApiClient } from "./lib/webapp/publicApi.js";
|
||||
import { createI18n } from "./lib/webapp/i18n.js";
|
||||
import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js";
|
||||
@@ -241,6 +246,13 @@
|
||||
|
||||
$: brandTitle = CFG.title || "/minishop";
|
||||
$: brandEmoji = CFG.logoEmoji || "🫥";
|
||||
$: brandEmojiFont = CFG.logoEmojiFont || "system";
|
||||
$: brand = normalizeBrand({
|
||||
title: brandTitle,
|
||||
logoUrl: CFG.logoUrl,
|
||||
emoji: brandEmoji,
|
||||
emojiFont: brandEmojiFont,
|
||||
});
|
||||
$: accent = CFG.primaryColor || "#00fe7a";
|
||||
$: plans = data?.plans?.length ? data.plans : DEV_MOCK.data.plans;
|
||||
$: methods = data?.payment_methods?.length ? data.payment_methods : [];
|
||||
@@ -344,7 +356,7 @@
|
||||
: telegramLoginUnavailable
|
||||
? t("wa_auth_telegram_not_configured")
|
||||
: "";
|
||||
$: applyFavicon(CFG.logoUrl, brandEmoji);
|
||||
$: applyFavicon(brand);
|
||||
$: syncBodyScrollLock(
|
||||
paymentModalOpen ||
|
||||
changeModalOpen ||
|
||||
@@ -903,7 +915,7 @@
|
||||
<div class="app-shell" style={`--accent: ${accent};`}>
|
||||
{#if mode === "loading"}
|
||||
<div class="loader">
|
||||
<BrandMark class="brand-mark-lg" logoUrl={CFG.logoUrl} emoji={brandEmoji} />
|
||||
<BrandMark {brand} size="md" />
|
||||
<div>{t("wa_loading")}</div>
|
||||
</div>
|
||||
{:else if mode === "login"}
|
||||
@@ -911,7 +923,7 @@
|
||||
{screen}
|
||||
{CFG}
|
||||
{brandTitle}
|
||||
{brandEmoji}
|
||||
{brand}
|
||||
bind:email={$authStore.email}
|
||||
bind:emailCode={$authStore.emailCode}
|
||||
{pendingEmail}
|
||||
@@ -952,8 +964,7 @@
|
||||
onSettingsSaved={handleAdminPersistedSaved}
|
||||
onTariffsSaved={handleAdminPersistedSaved}
|
||||
{brandTitle}
|
||||
logoUrl={CFG.logoUrl}
|
||||
logoEmoji={brandEmoji}
|
||||
{brand}
|
||||
appVersion={CFG.appVersion}
|
||||
appRepositoryUrl={CFG.appRepositoryUrl}
|
||||
{currentLang}
|
||||
@@ -966,9 +977,8 @@
|
||||
<WebAppShell
|
||||
{screen}
|
||||
{activeTab}
|
||||
{CFG}
|
||||
{brandTitle}
|
||||
{brandEmoji}
|
||||
{brand}
|
||||
{devicesEnabled}
|
||||
{hasUnlinkedIdentity}
|
||||
{isAdmin}
|
||||
@@ -981,9 +991,8 @@
|
||||
>
|
||||
{#if screen === "home"}
|
||||
<HomeScreen
|
||||
{CFG}
|
||||
{appSettings}
|
||||
{brandEmoji}
|
||||
{brand}
|
||||
{brandTitle}
|
||||
{canChangeTariff}
|
||||
{currentTariffName}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
<script>
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
import { cn } from "./lib/utils.js";
|
||||
|
||||
const LOGO_LOAD_TIMEOUT_MS = 2600;
|
||||
|
||||
export let logoUrl = "";
|
||||
export let emoji = "🫥";
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
let loaded = false;
|
||||
let failed = false;
|
||||
let lastLogoUrl = "";
|
||||
let logoLoadTimer = null;
|
||||
let logoLoadTimerUrl = "";
|
||||
|
||||
$: normalizedLogoUrl = String(logoUrl || "").trim();
|
||||
$: normalizedEmoji = String(emoji || "🫥").trim() || "🫥";
|
||||
$: if (normalizedLogoUrl !== lastLogoUrl) {
|
||||
lastLogoUrl = normalizedLogoUrl;
|
||||
loaded = false;
|
||||
failed = false;
|
||||
}
|
||||
$: if (normalizedLogoUrl && !loaded && !failed) armLogoLoadTimeout();
|
||||
$: if (!normalizedLogoUrl || loaded || failed) clearLogoLoadTimeout();
|
||||
|
||||
onDestroy(clearLogoLoadTimeout);
|
||||
|
||||
function clearLogoLoadTimeout() {
|
||||
if (logoLoadTimer) {
|
||||
window.clearTimeout(logoLoadTimer);
|
||||
logoLoadTimer = null;
|
||||
}
|
||||
logoLoadTimerUrl = "";
|
||||
}
|
||||
|
||||
function armLogoLoadTimeout() {
|
||||
if (typeof window === "undefined") return;
|
||||
if (logoLoadTimer && logoLoadTimerUrl === normalizedLogoUrl) return;
|
||||
clearLogoLoadTimeout();
|
||||
logoLoadTimerUrl = normalizedLogoUrl;
|
||||
logoLoadTimer = window.setTimeout(() => {
|
||||
if (logoLoadTimerUrl === normalizedLogoUrl && !loaded) failed = true;
|
||||
}, LOGO_LOAD_TIMEOUT_MS);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
"brand-mark",
|
||||
normalizedLogoUrl && !failed && !loaded && "brand-mark-loading",
|
||||
normalizedLogoUrl && !failed && loaded && "brand-mark-loaded",
|
||||
className
|
||||
)}
|
||||
aria-busy={normalizedLogoUrl && !failed && !loaded ? "true" : undefined}
|
||||
>
|
||||
{#if normalizedLogoUrl && !failed}
|
||||
{#if !loaded}
|
||||
<span class="brand-mark-spinner" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<img
|
||||
class:loaded
|
||||
src={normalizedLogoUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
on:load={() => (loaded = true)}
|
||||
on:error={() => (failed = true)}
|
||||
/>
|
||||
{:else}
|
||||
<span>{normalizedEmoji}</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -24,7 +24,7 @@
|
||||
import { Select } from "$components/ui/primitives.js";
|
||||
import { AdminBadge, AdminButton } from "$components/patterns/admin/index.js";
|
||||
|
||||
import BrandMark from "../BrandMark.svelte";
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
import AdsSection from "./sections/AdsSection.svelte";
|
||||
import BroadcastSection from "./sections/BroadcastSection.svelte";
|
||||
import LogsSection from "./sections/LogsSection.svelte";
|
||||
@@ -71,9 +71,8 @@
|
||||
export let onSectionChange = () => {};
|
||||
export let onSettingsSaved = () => {};
|
||||
export let onTariffsSaved = () => {};
|
||||
export let brand = {};
|
||||
export let brandTitle = "/minishop";
|
||||
export let logoUrl = "";
|
||||
export let logoEmoji = "🫥";
|
||||
export let appVersion = "dev+local";
|
||||
export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop";
|
||||
export let currentLang = "ru";
|
||||
@@ -180,8 +179,7 @@
|
||||
|
||||
function readReduceMotion() {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
}
|
||||
|
||||
@@ -423,7 +421,7 @@
|
||||
|
||||
<aside class="admin-sidebar" aria-label={at("sidebar_navigation", {}, "Навигация админки")}>
|
||||
<div class="admin-sidebar-brand">
|
||||
<BrandMark class="admin-brand-mark" {logoUrl} emoji={logoEmoji} />
|
||||
<BrandMark class="admin-brand-mark" {brand} />
|
||||
<div>
|
||||
<strong class="admin-brand-title">{brandTitle}</strong>
|
||||
<small>{at("panel_title", {}, "Админ-панель")}</small>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script>
|
||||
import { ChevronRight, Eye, EyeOff, X } from "$components/ui/icons.js";
|
||||
import { Accordion, Switch } from "$components/ui/primitives.js";
|
||||
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
|
||||
import {
|
||||
AdminBadge,
|
||||
AdminButton,
|
||||
AdminEmptyState,
|
||||
AdminSelect,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
import { getContext, onMount } from "svelte";
|
||||
|
||||
export let at;
|
||||
@@ -162,6 +167,15 @@
|
||||
value={valueFor(field) || ""}
|
||||
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||
/>
|
||||
{:else if field.choices && field.choices.length > 0}
|
||||
<AdminSelect
|
||||
class="admin-setting-select"
|
||||
value={valueFor(field) || ""}
|
||||
items={field.choices}
|
||||
ariaLabel={fieldLabelText(field)}
|
||||
placeholder={field.placeholder || fieldLabelText(field)}
|
||||
onValueChange={(value) => settingsStore.markDirty(field.key, value)}
|
||||
/>
|
||||
{:else if field.type === "int" || field.type === "float"}
|
||||
<input
|
||||
class="input"
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
<script>
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
|
||||
import { cn } from "../utils.js";
|
||||
import { animatedEmojiAssetUrls, normalizeBrand } from "./browser.js";
|
||||
|
||||
const LOGO_LOAD_TIMEOUT_MS = 10000;
|
||||
|
||||
const EMOJI_FONT_OPTIONS = {
|
||||
"noto-color": {
|
||||
cssFamily: "Noto Color Emoji",
|
||||
stylesheet: (text) =>
|
||||
`https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap&text=${encodeURIComponent(text)}`,
|
||||
},
|
||||
"noto-emoji": {
|
||||
cssFamily: "Noto Emoji",
|
||||
stylesheet: (text) =>
|
||||
`https://fonts.googleapis.com/css2?family=Noto+Emoji:wght@700&display=swap&text=${encodeURIComponent(text)}`,
|
||||
},
|
||||
twemoji: {
|
||||
cssFamily: "Twemoji Mozilla",
|
||||
stylesheet: () => "https://cdn.jsdelivr.net/npm/twemoji-colr-font@15.0.3/twemoji.css",
|
||||
},
|
||||
openmoji: {
|
||||
cssFamily: "OpenMoji Color",
|
||||
stylesheet: () => "https://cdn.jsdelivr.net/npm/@openmoji/font@15.1.0/css/openmoji-color.css",
|
||||
},
|
||||
};
|
||||
|
||||
export let brand = {};
|
||||
export let logoUrl = "";
|
||||
export let emoji = "";
|
||||
export let emojiFont = "";
|
||||
export let size = "sm";
|
||||
export let animate = false;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
const SIZE_CLASSES = {
|
||||
sm: "",
|
||||
md: "brand-mark-lg",
|
||||
lg: "brand-mark-xl",
|
||||
xl: "brand-mark-xl",
|
||||
};
|
||||
|
||||
let loaded = false;
|
||||
let failed = false;
|
||||
let lastLogoUrl = "";
|
||||
let logoLoadTimer = null;
|
||||
let logoLoadTimerUrl = "";
|
||||
let fontLoaded = false;
|
||||
let loadedFontKey = "";
|
||||
let animatedEmojiError = false;
|
||||
let animatedEmojiStaticFallback = false;
|
||||
let lastAnimatedEmoji = "";
|
||||
|
||||
$: normalizedBrand = normalizeBrand({
|
||||
...brand,
|
||||
logoUrl: logoUrl || brand?.logoUrl,
|
||||
emoji: emoji || brand?.emoji || brand?.logoEmoji,
|
||||
emojiFont: emojiFont || brand?.emojiFont || brand?.logoEmojiFont,
|
||||
});
|
||||
$: normalizedLogoUrl = normalizedBrand.logoUrl;
|
||||
$: normalizedEmoji = normalizedBrand.emoji;
|
||||
$: normalizedEmojiFont = normalizedBrand.emojiFont;
|
||||
$: sizeClass = SIZE_CLASSES[size] || "";
|
||||
$: animatedEmojiAssets = animatedEmojiAssetUrls(normalizedEmoji);
|
||||
$: animatedEmojiSrc = animatedEmojiAssets.gif;
|
||||
$: animatedEmojiFallbackSrc = animatedEmojiAssets.webp;
|
||||
$: useAnimatedEmoji =
|
||||
!normalizedLogoUrl &&
|
||||
normalizedEmojiFont === "noto-color-animated" &&
|
||||
animatedEmojiSrc &&
|
||||
!animatedEmojiError;
|
||||
|
||||
$: if (normalizedLogoUrl !== lastLogoUrl) {
|
||||
lastLogoUrl = normalizedLogoUrl;
|
||||
loaded = false;
|
||||
failed = false;
|
||||
}
|
||||
$: if (normalizedLogoUrl && !loaded && !failed) armLogoLoadTimeout();
|
||||
$: if (!normalizedLogoUrl || loaded || failed) clearLogoLoadTimeout();
|
||||
$: if (`${normalizedEmojiFont}:${normalizedEmoji}` !== lastAnimatedEmoji) {
|
||||
lastAnimatedEmoji = `${normalizedEmojiFont}:${normalizedEmoji}`;
|
||||
animatedEmojiError = false;
|
||||
animatedEmojiStaticFallback = false;
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearLogoLoadTimeout();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
loadEmojiFont(normalizedEmojiFont, normalizedEmoji);
|
||||
});
|
||||
|
||||
$: if (normalizedEmojiFont && normalizedEmoji) {
|
||||
loadEmojiFont(normalizedEmojiFont, normalizedEmoji);
|
||||
}
|
||||
|
||||
function loadEmojiFont(font, text) {
|
||||
if (typeof document === "undefined") return;
|
||||
if (font === "system" || font === "noto-color-animated" || !font) {
|
||||
fontLoaded = true;
|
||||
loadedFontKey = "system";
|
||||
return;
|
||||
}
|
||||
|
||||
const fontOption = EMOJI_FONT_OPTIONS[font];
|
||||
if (!fontOption) {
|
||||
fontLoaded = true;
|
||||
loadedFontKey = font;
|
||||
return;
|
||||
}
|
||||
|
||||
const fontUrl = fontOption.stylesheet(text);
|
||||
const fontKey = `${font}:${text}`;
|
||||
if (loadedFontKey === fontKey) return;
|
||||
|
||||
fontLoaded = false;
|
||||
loadedFontKey = fontKey;
|
||||
|
||||
const existing = document.querySelector(`link[data-brand-emoji-font="${fontKey}"]`);
|
||||
if (existing) {
|
||||
fontLoaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = fontUrl;
|
||||
link.dataset.brandEmojiFont = fontKey;
|
||||
link.onload = () => {
|
||||
fontLoaded = true;
|
||||
if (document.fonts && fontOption.cssFamily) {
|
||||
document.fonts.load(`1em "${fontOption.cssFamily}"`, text).finally(() => {
|
||||
fontLoaded = true;
|
||||
});
|
||||
}
|
||||
};
|
||||
link.onerror = () => {
|
||||
fontLoaded = true;
|
||||
};
|
||||
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function getEmojiFontClass(font) {
|
||||
if (font === "noto-color") return "emoji-font-noto-color";
|
||||
if (font === "noto-emoji") return "emoji-font-noto-emoji";
|
||||
if (font === "twemoji") return "emoji-font-twemoji";
|
||||
if (font === "openmoji") return "emoji-font-openmoji";
|
||||
if (font === "apple") return "emoji-font-apple";
|
||||
if (font === "segoe") return "emoji-font-segoe";
|
||||
if (font === "noto-local") return "emoji-font-noto-local";
|
||||
return "";
|
||||
}
|
||||
|
||||
function clearLogoLoadTimeout() {
|
||||
if (logoLoadTimer) {
|
||||
window.clearTimeout(logoLoadTimer);
|
||||
logoLoadTimer = null;
|
||||
}
|
||||
logoLoadTimerUrl = "";
|
||||
}
|
||||
|
||||
function armLogoLoadTimeout() {
|
||||
if (typeof window === "undefined") return;
|
||||
if (logoLoadTimer && logoLoadTimerUrl === normalizedLogoUrl) return;
|
||||
clearLogoLoadTimeout();
|
||||
logoLoadTimerUrl = normalizedLogoUrl;
|
||||
logoLoadTimer = window.setTimeout(() => {
|
||||
if (logoLoadTimerUrl === normalizedLogoUrl && !loaded) failed = true;
|
||||
}, LOGO_LOAD_TIMEOUT_MS);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
"brand-mark",
|
||||
sizeClass,
|
||||
animate && "brand-mark-animate",
|
||||
normalizedLogoUrl && !failed && !loaded && "brand-mark-loading",
|
||||
normalizedLogoUrl && !failed && loaded && "brand-mark-loaded",
|
||||
className
|
||||
)}
|
||||
aria-busy={normalizedLogoUrl && !failed && !loaded ? "true" : undefined}
|
||||
>
|
||||
{#if normalizedLogoUrl && !failed}
|
||||
{#if !loaded}
|
||||
<span class="brand-mark-spinner" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<img
|
||||
class:loaded
|
||||
src={normalizedLogoUrl}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
on:load={() => {
|
||||
loaded = true;
|
||||
clearLogoLoadTimeout();
|
||||
}}
|
||||
on:error={() => {
|
||||
failed = true;
|
||||
clearLogoLoadTimeout();
|
||||
}}
|
||||
/>
|
||||
{:else if useAnimatedEmoji}
|
||||
<img
|
||||
class="brand-mark-animated-emoji loaded"
|
||||
src={animatedEmojiStaticFallback ? animatedEmojiFallbackSrc : animatedEmojiSrc}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
on:error={() => {
|
||||
if (!animatedEmojiStaticFallback && animatedEmojiFallbackSrc) {
|
||||
animatedEmojiStaticFallback = true;
|
||||
} else {
|
||||
animatedEmojiError = true;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class={cn("brand-mark-emoji", getEmojiFontClass(normalizedEmojiFont))}
|
||||
style="opacity: {fontLoaded ? 1 : 0}; transition: opacity 0.2s ease;"
|
||||
>
|
||||
{normalizedEmoji}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.brand-mark {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
overflow: visible;
|
||||
font-size: 1.625rem;
|
||||
}
|
||||
|
||||
.brand-mark img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.brand-mark img.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.brand-mark.brand-mark-lg {
|
||||
width: 4.125rem;
|
||||
height: 4.125rem;
|
||||
font-size: 2.875rem;
|
||||
}
|
||||
|
||||
.brand-mark.brand-mark-xl {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
font-size: 4.375rem;
|
||||
}
|
||||
|
||||
.brand-mark img.brand-mark-animated-emoji {
|
||||
object-fit: contain;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.brand-mark.brand-mark-animate {
|
||||
animation: brand-mark-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.brand-mark-spinner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-mark-spinner::after {
|
||||
content: "";
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid currentColor;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: brand-mark-spin 0.8s linear infinite;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes brand-mark-spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes brand-mark-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.brand-mark-emoji {
|
||||
color: inherit;
|
||||
font-size: 1em;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translateY(0.02em);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.brand-mark-xl .brand-mark-emoji {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.brand-mark-lg .brand-mark-emoji {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.emoji-font-noto-color {
|
||||
font-family: "Noto Color Emoji", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-noto-emoji {
|
||||
color: var(--accent);
|
||||
font-family: "Noto Emoji", sans-serif;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.emoji-font-twemoji {
|
||||
font-family: "Twemoji Mozilla", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-openmoji {
|
||||
font-family: "OpenMoji Color", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-apple {
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-segoe {
|
||||
font-family: "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-noto-local {
|
||||
font-family: "Noto Color Emoji", "Noto Emoji", sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -26,19 +26,57 @@ export function escapeHtml(value) {
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export function applyFavicon(logoUrl, emoji) {
|
||||
export function normalizeBrand(brand = {}) {
|
||||
return {
|
||||
title: String(brand.title || "/minishop").trim() || "/minishop",
|
||||
logoUrl: String(brand.logoUrl || "").trim(),
|
||||
emoji: String(brand.emoji || brand.logoEmoji || "🫥").trim() || "🫥",
|
||||
emojiFont: String(brand.emojiFont || brand.logoEmojiFont || "system").trim() || "system",
|
||||
};
|
||||
}
|
||||
|
||||
export function emojiToCodepoints(value) {
|
||||
return Array.from(String(value || "").trim())
|
||||
.map((char) => char.codePointAt(0)?.toString(16))
|
||||
.filter(Boolean)
|
||||
.join("_");
|
||||
}
|
||||
|
||||
export function animatedEmojiAssetUrls(emoji) {
|
||||
const codepoints = emojiToCodepoints(emoji);
|
||||
if (!codepoints) return { gif: "", webp: "" };
|
||||
return {
|
||||
gif: `/webapp-emoji/${codepoints}/512.gif`,
|
||||
webp: `/webapp-emoji/${codepoints}/512.webp`,
|
||||
};
|
||||
}
|
||||
|
||||
export function brandFaviconHref(brand = {}) {
|
||||
const normalizedBrand = normalizeBrand(brand);
|
||||
if (normalizedBrand.logoUrl) return normalizedBrand.logoUrl;
|
||||
|
||||
if (normalizedBrand.emojiFont === "noto-color-animated") {
|
||||
return animatedEmojiAssetUrls(normalizedBrand.emoji).gif;
|
||||
}
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="52">${escapeHtml(normalizedBrand.emoji)}</text></svg>`;
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
export function applyFavicon(brand = {}) {
|
||||
if (typeof document === "undefined") return;
|
||||
const favicon = document.getElementById("app-favicon");
|
||||
if (!favicon) return;
|
||||
|
||||
const normalizedLogoUrl = String(logoUrl || "").trim();
|
||||
if (normalizedLogoUrl) {
|
||||
favicon.setAttribute("href", normalizedLogoUrl);
|
||||
return;
|
||||
const href = brandFaviconHref(brand);
|
||||
favicon.setAttribute("href", href);
|
||||
if (href.startsWith("data:image/svg+xml")) {
|
||||
favicon.setAttribute("type", "image/svg+xml");
|
||||
} else if (href.endsWith(".gif")) {
|
||||
favicon.setAttribute("type", "image/gif");
|
||||
} else if (href.endsWith(".webp")) {
|
||||
favicon.setAttribute("type", "image/webp");
|
||||
} else {
|
||||
favicon.removeAttribute("type");
|
||||
}
|
||||
|
||||
const normalizedEmoji = String(emoji || "????").trim() || "????";
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="52">${escapeHtml(normalizedEmoji)}</text></svg>`;
|
||||
const encoded = encodeURIComponent(svg);
|
||||
favicon.setAttribute("href", `data:image/svg+xml,${encoded}`);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export const DEV_MOCK = {
|
||||
primaryColor: "#00fe7a",
|
||||
logoUrl: "",
|
||||
logoEmoji: "🫥",
|
||||
logoEmojiFont: "system",
|
||||
apiBase: "/api",
|
||||
supportUrl: "https://t.me/support",
|
||||
privacyPolicyUrl: "https://example.com/privacy",
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
}
|
||||
|
||||
.admin-setting-control .input,
|
||||
.admin-setting-control .admin-setting-select,
|
||||
.admin-setting-control input[type="text"],
|
||||
.admin-setting-control input[type="number"] {
|
||||
flex: 1 1 160px;
|
||||
@@ -290,4 +291,4 @@
|
||||
|
||||
.admin-message-actions .admin-btn {
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ a {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
font-size: 26px;
|
||||
@@ -2316,4 +2316,4 @@ a {
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,14 @@
|
||||
Smartphone,
|
||||
} from "$components/ui/icons.js";
|
||||
|
||||
import BrandMark from "../BrandMark.svelte";
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
|
||||
export let activeTab = "home";
|
||||
export let brand = {};
|
||||
export let brandTitle = "";
|
||||
export let devicesEnabled = false;
|
||||
export let hasUnlinkedIdentity = false;
|
||||
export let isAdmin = false;
|
||||
export let logoEmoji = "";
|
||||
export let logoUrl = "";
|
||||
export let onAdmin = () => {};
|
||||
export let onDevices = () => {};
|
||||
export let onHome = () => {};
|
||||
@@ -26,7 +25,7 @@
|
||||
|
||||
<nav class:bottom-nav-devices={devicesEnabled} class="bottom-nav" aria-label={t("wa_navigation")}>
|
||||
<div class="rail-brand" aria-hidden="true">
|
||||
<BrandMark {logoUrl} emoji={logoEmoji} />
|
||||
<BrandMark {brand} />
|
||||
<strong>{brandTitle}</strong>
|
||||
</div>
|
||||
<button class:active={activeTab === "home"} type="button" onclick={onHome}>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script>
|
||||
import BrandMark from "../BrandMark.svelte";
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
import BottomNav from "./BottomNav.svelte";
|
||||
|
||||
export let screen;
|
||||
export let activeTab;
|
||||
export let CFG;
|
||||
export let brand = {};
|
||||
export let brandTitle;
|
||||
export let brandEmoji;
|
||||
export let devicesEnabled;
|
||||
export let hasUnlinkedIdentity;
|
||||
export let isAdmin;
|
||||
@@ -22,7 +21,7 @@
|
||||
{#if screen === "invite" || screen === "devices" || screen === "settings"}
|
||||
<header class="app-header accent-title">
|
||||
<div class="brand-row">
|
||||
<BrandMark logoUrl={CFG.logoUrl} emoji={brandEmoji} />
|
||||
<BrandMark {brand} />
|
||||
<strong>{brandTitle}</strong>
|
||||
</div>
|
||||
</header>
|
||||
@@ -32,12 +31,11 @@
|
||||
|
||||
<BottomNav
|
||||
{activeTab}
|
||||
{brand}
|
||||
{brandTitle}
|
||||
{devicesEnabled}
|
||||
{hasUnlinkedIdentity}
|
||||
{isAdmin}
|
||||
logoEmoji={brandEmoji}
|
||||
logoUrl={CFG.logoUrl}
|
||||
onAdmin={openAdminPanel}
|
||||
onDevices={goDevices}
|
||||
onHome={goHome}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Tooltip } from "$components/ui/primitives.js";
|
||||
|
||||
import Button from "$components/ui/button.svelte";
|
||||
import BrandMark from "../../BrandMark.svelte";
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
import Card from "$components/ui/card.svelte";
|
||||
import Input from "$components/ui/input.svelte";
|
||||
import Spinner from "$components/ui/spinner.svelte";
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
export let screen;
|
||||
export let CFG;
|
||||
export let brand = {};
|
||||
export let brandTitle;
|
||||
export let brandEmoji;
|
||||
export let email;
|
||||
export let emailCode;
|
||||
export let pendingEmail;
|
||||
@@ -87,7 +87,7 @@
|
||||
{:else}
|
||||
<div class="auth-card-wrap">
|
||||
<div class="login-brand login-brand-auth">
|
||||
<BrandMark class="brand-mark-xl" logoUrl={CFG.logoUrl} emoji={brandEmoji} />
|
||||
<BrandMark {brand} size="xl" />
|
||||
<h1>{brandTitle}</h1>
|
||||
</div>
|
||||
<Card class="auth-card">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
RefreshCw,
|
||||
} from "$components/ui/icons.js";
|
||||
|
||||
import BrandMark from "../../BrandMark.svelte";
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
import Button from "$components/ui/button.svelte";
|
||||
import Card from "$components/ui/card.svelte";
|
||||
import { LinearProgress } from "$components/patterns/webapp/index.js";
|
||||
@@ -25,9 +25,8 @@
|
||||
activeSubscriptionTermLabel as activeSubscriptionTermLabelFn,
|
||||
} from "../../lib/webapp/traffic.js";
|
||||
|
||||
export let CFG = {};
|
||||
export let appSettings = {};
|
||||
export let brandEmoji = "";
|
||||
export let brand = {};
|
||||
export let brandTitle = "";
|
||||
export let canChangeTariff = false;
|
||||
export let premiumTrafficTopupBarClickable = false;
|
||||
@@ -83,7 +82,7 @@
|
||||
|
||||
<main class="home-layout">
|
||||
<div class="login-brand home-brand">
|
||||
<BrandMark class="brand-mark-xl" logoUrl={CFG.logoUrl} emoji={brandEmoji} />
|
||||
<BrandMark {brand} size="xl" />
|
||||
<h1>{brandTitle}</h1>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user