From de09ea039bea2fdfe3f3a9b3bc6c368a3a27f9f7 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Feb 2026 17:40:24 +0300 Subject: [PATCH 1/3] fix: hide backend URL from logo by fetching as blob Logo images were exposing the backend API URL in the DOM via . Now preloadLogo() fetches the image as a blob and serves it through URL.createObjectURL(), so only blob: URLs appear in the page source. Blob is invalidated on logo upload/delete. --- src/api/branding.ts | 78 ++++++++++++------- src/components/layout/AppShell/AppHeader.tsx | 2 +- .../layout/AppShell/DesktopSidebar.tsx | 2 +- src/hooks/useBranding.ts | 2 +- src/pages/Login.tsx | 2 +- 5 files changed, 53 insertions(+), 33 deletions(-) diff --git a/src/api/branding.ts b/src/api/branding.ts index e3e50f4..8dfc21c 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -28,16 +28,19 @@ export interface AnalyticsCounters { const BRANDING_CACHE_KEY = 'cabinet_branding'; const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded'; +// In-memory blob URL cache to avoid exposing backend URL +let _logoBlobUrl: string | null = null; + // Check if logo was already preloaded in this session export const isLogoPreloaded = (): boolean => { try { + if (_logoBlobUrl) return true; const cached = getCachedBranding(); if (!cached?.has_custom_logo || !cached?.logo_url) { return false; } - const logoUrl = `${import.meta.env.VITE_API_URL || ''}${cached.logo_url}`; const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY); - return preloaded === logoUrl; + return preloaded === cached.logo_url; } catch { return false; } @@ -65,33 +68,42 @@ export const setCachedBranding = (branding: BrandingInfo) => { } }; -// Preload logo image for instant display -export const preloadLogo = (branding: BrandingInfo): Promise => { - return new Promise((resolve) => { - if (!branding.has_custom_logo || !branding.logo_url) { - resolve(); - return; - } +// Preload logo image as blob to hide backend URL +export const preloadLogo = async (branding: BrandingInfo): Promise => { + if (!branding.has_custom_logo || !branding.logo_url) { + return; + } + // Check if already preloaded in this session + if (_logoBlobUrl) { + return; + } + + const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY); + if (preloaded === branding.logo_url && _logoBlobUrl) { + return; + } + + try { const logoUrl = `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`; + const response = await fetch(logoUrl); + if (!response.ok) return; - // Check if already preloaded in this session - const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY); - if (preloaded === logoUrl) { - resolve(); - return; + const blob = await response.blob(); + // Revoke previous blob URL if exists + if (_logoBlobUrl) { + URL.revokeObjectURL(_logoBlobUrl); } - - const img = new Image(); - img.onload = () => { - sessionStorage.setItem(LOGO_PRELOADED_KEY, logoUrl); - resolve(); - }; - img.onerror = () => resolve(); - img.src = logoUrl; - }); + _logoBlobUrl = URL.createObjectURL(blob); + sessionStorage.setItem(LOGO_PRELOADED_KEY, branding.logo_url); + } catch { + // Fetch failed, logo will use letter fallback + } }; +// Get the blob URL for the logo (safe, doesn't expose backend) +export const getLogoBlobUrl = (): string | null => _logoBlobUrl; + // Initialize logo preload from cache on page load export const initLogoPreload = () => { const cached = getCachedBranding(); @@ -122,21 +134,29 @@ export const brandingApi = { 'Content-Type': 'multipart/form-data', }, }); + // Invalidate cached blob so it gets re-fetched + if (_logoBlobUrl) { + URL.revokeObjectURL(_logoBlobUrl); + _logoBlobUrl = null; + } + sessionStorage.removeItem(LOGO_PRELOADED_KEY); return response.data; }, // Delete custom logo (admin only) deleteLogo: async (): Promise => { const response = await apiClient.delete('/cabinet/branding/logo'); + if (_logoBlobUrl) { + URL.revokeObjectURL(_logoBlobUrl); + _logoBlobUrl = null; + } + sessionStorage.removeItem(LOGO_PRELOADED_KEY); return response.data; }, - // Get logo URL (without cache busting - server handles caching via Cache-Control headers) - getLogoUrl: (branding: BrandingInfo): string | null => { - if (!branding.has_custom_logo || !branding.logo_url) { - return null; - } - return `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`; + // Get logo URL as blob (hides backend URL from DOM) + getLogoUrl: (_branding: BrandingInfo): string | null => { + return _logoBlobUrl; }, // Get animation enabled (public, no auth required) diff --git a/src/components/layout/AppShell/AppHeader.tsx b/src/components/layout/AppShell/AppHeader.tsx index 4799b62..84d6ee0 100644 --- a/src/components/layout/AppShell/AppHeader.tsx +++ b/src/components/layout/AppShell/AppHeader.tsx @@ -89,7 +89,7 @@ export function AppHeader({ queryFn: async () => { const data = await brandingApi.getBranding(); setCachedBranding(data); - preloadLogo(data); + await preloadLogo(data); return data; }, initialData: getCachedBranding() ?? undefined, diff --git a/src/components/layout/AppShell/DesktopSidebar.tsx b/src/components/layout/AppShell/DesktopSidebar.tsx index 9b71433..2f826a5 100644 --- a/src/components/layout/AppShell/DesktopSidebar.tsx +++ b/src/components/layout/AppShell/DesktopSidebar.tsx @@ -58,7 +58,7 @@ export function DesktopSidebar({ queryFn: async () => { const data = await brandingApi.getBranding(); setCachedBranding(data); - preloadLogo(data); + await preloadLogo(data); return data; }, initialData: getCachedBranding() ?? undefined, diff --git a/src/hooks/useBranding.ts b/src/hooks/useBranding.ts index b68462d..c827ea4 100644 --- a/src/hooks/useBranding.ts +++ b/src/hooks/useBranding.ts @@ -23,7 +23,7 @@ export function useBranding() { queryFn: async () => { const data = await brandingApi.getBranding(); setCachedBranding(data); - preloadLogo(data); + await preloadLogo(data); return data; }, initialData: getCachedBranding() ?? undefined, diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 6f1a58f..f35fb15 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -79,7 +79,7 @@ export default function Login() { queryFn: async () => { const data = await brandingApi.getBranding(); setCachedBranding(data); - preloadLogo(data); + await preloadLogo(data); return data; }, staleTime: 60000, From 56788b12e78ea2f45571b0a0f3a8c2e3b667355c Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Feb 2026 17:40:35 +0300 Subject: [PATCH 2/3] perf: reduce Aurora animated background GPU load by ~95% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Render WebGL canvas at 10% viewport resolution (~20K px vs 2M px) - Replace backdrop-filter: blur(80px) with filter: blur(20px) on canvas (backdrop-filter composites ALL layers underneath — 10-20x more expensive) - Disable antialiasing (useless when output is blurred) - Lower target FPS from 30 to 20 (imperceptible for ambient background) - Add CSS contain: strict to isolate layout/paint recalculations - Remove separate blur overlay div (one element instead of two) Reported: GTX 1660S showed 30-55% GPU load with cabinet open. Expected: ~1-5% GPU after this change. --- src/components/layout/AppShell/Aurora.tsx | 31 +++++++++++++---------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/components/layout/AppShell/Aurora.tsx b/src/components/layout/AppShell/Aurora.tsx index e73f726..9d88a2e 100644 --- a/src/components/layout/AppShell/Aurora.tsx +++ b/src/components/layout/AppShell/Aurora.tsx @@ -173,7 +173,7 @@ export function Aurora() { const container = containerRef.current; const renderer = new Renderer({ alpha: true, - antialias: true, + antialias: false, powerPreference: 'low-power', }); rendererRef.current = renderer; @@ -210,10 +210,15 @@ export function Aurora() { const mesh = new Mesh(gl, { geometry, program }); + // Рендерим на 10% разрешения — шейдер обрабатывает в 100 раз меньше пикселей, + // CSS масштабирует канвас обратно, а filter: blur() сглаживает результат. + // Визуально идентично оригиналу (80px blur всё равно уничтожал детали). + const RESOLUTION_SCALE = 0.1; + function resize() { if (!containerRef.current || !rendererRef.current || !programRef.current) return; - const w = containerRef.current.offsetWidth; - const h = containerRef.current.offsetHeight; + const w = Math.max(Math.ceil(containerRef.current.offsetWidth * RESOLUTION_SCALE), 1); + const h = Math.max(Math.ceil(containerRef.current.offsetHeight * RESOLUTION_SCALE), 1); rendererRef.current.setSize(w, h); programRef.current.uniforms.uResolution.value = [w, h]; } @@ -222,9 +227,9 @@ export function Aurora() { resize(); let lastTime = 0; - const targetFPS = 30; + const targetFPS = 20; const frameInterval = 1000 / targetFPS; - const speed = 0.3; // Slow and smooth + const speed = 0.3; function animate(currentTime: number) { animationFrameRef.current = requestAnimationFrame(animate); @@ -275,19 +280,19 @@ export function Aurora() { return ( <> - {/* WebGL Aurora canvas */} + {/* WebGL Aurora canvas — рендерится на ~10% разрешения, CSS растягивает обратно. + filter: blur() сглаживает артефакты масштабирования (дешевле backdrop-filter в ~10-20 раз, + т.к. блюрит только один элемент, а не все слои под ним). */}
- {/* Blur overlay */} -
From 23f56afaf7182de6e8164fdc0075d4b4b02780d8 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Feb 2026 17:46:23 +0300 Subject: [PATCH 3/3] fix: stretch low-res Aurora canvas to fill viewport OGL renderer.setSize() sets both the internal buffer and CSS dimensions, so the 10% resolution canvas was only covering 10% of the screen. Now explicitly reset canvas.style.width/height to 100% after setSize() so the small buffer is upscaled by the browser to full viewport. --- src/components/layout/AppShell/Aurora.tsx | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/components/layout/AppShell/Aurora.tsx b/src/components/layout/AppShell/Aurora.tsx index 9d88a2e..7cbd365 100644 --- a/src/components/layout/AppShell/Aurora.tsx +++ b/src/components/layout/AppShell/Aurora.tsx @@ -210,17 +210,25 @@ export function Aurora() { const mesh = new Mesh(gl, { geometry, program }); - // Рендерим на 10% разрешения — шейдер обрабатывает в 100 раз меньше пикселей, - // CSS масштабирует канвас обратно, а filter: blur() сглаживает результат. - // Визуально идентично оригиналу (80px blur всё равно уничтожал детали). + // Рендерим на 10% разрешения — шейдер обрабатывает в ~100 раз меньше пикселей. + // renderer.setSize задаёт и буфер, и CSS-размер канваса, поэтому после него + // принудительно возвращаем CSS на 100% — канвас растягивается браузером, + // а filter: blur() сглаживает артефакты масштабирования. const RESOLUTION_SCALE = 0.1; function resize() { if (!containerRef.current || !rendererRef.current || !programRef.current) return; - const w = Math.max(Math.ceil(containerRef.current.offsetWidth * RESOLUTION_SCALE), 1); - const h = Math.max(Math.ceil(containerRef.current.offsetHeight * RESOLUTION_SCALE), 1); - rendererRef.current.setSize(w, h); - programRef.current.uniforms.uResolution.value = [w, h]; + const fullW = containerRef.current.offsetWidth; + const fullH = containerRef.current.offsetHeight; + const bufW = Math.max(Math.ceil(fullW * RESOLUTION_SCALE), 1); + const bufH = Math.max(Math.ceil(fullH * RESOLUTION_SCALE), 1); + rendererRef.current.setSize(bufW, bufH); + // OGL setSize ставит canvas.style.width/height = bufW/bufH px, + // перезаписываем на 100% чтобы CSS растянул маленький буфер на весь экран + const canvas = rendererRef.current.gl.canvas as HTMLCanvasElement; + canvas.style.width = '100%'; + canvas.style.height = '100%'; + programRef.current.uniforms.uResolution.value = [bufW, bufH]; } window.addEventListener('resize', resize);