fix(admin): graceful logo-img fallback + show sidebar widgets during perm hydration (#523 follow-up 2)
Two issues Rekoo-PS hit immediately after upgrading to v3.60.3-beta.0:
1. **Broken logo URL rendered the browser's broken-image icon + alt
text.** Their `<img src={resolvedLogoUrl}>` had no `onError` handler,
so a 404 / slow logo URL produced the default broken-image rendering
— which uses the `alt` attribute (`companyName`) as text. Visually it
looked like the wordmark span had unexpectedly re-appeared on phone,
even though the actual `<span>` was correctly hidden by the existing
`wordmarkVisibilityClass` logic.
Fix:
- `useState` tracks `logoLoadError` (first failure) and
`fallbackLoadError` (second failure). On a configured-URL miss the
`<img>` swaps to the bundled `/picpeak-kamera-transparent.png`; on
a second miss the `<img>` is removed from the DOM entirely.
- `useEffect([resolvedLogoUrl])` resets both flags when the URL
changes, so a dark-mode toggle that flips `lightLogo ↔ darkLogo`
gets a fresh attempt instead of being permanently sad.
- `wordmarkVisibilityClass` now derives from `logoEffectivelyVisible`
(showLogo && !fallbackLoadError) — when both the configured URL
AND the bundled fallback have failed, the wordmark un-hides on <sm
so the phone header isn't completely empty.
2. **Sidebar VersionInfo + StorageInfo vanished during the
permission-hydration window.** The bottom block was gated on
`hasPermission('settings.view')` directly, which returns `false`
while `PermissionsContext.isLoading` is still resolving (a few
hundred ms right after a deploy when the auth context bootstraps).
Net effect: the whole "Version / Storage" block was absent on first
paint, then re-appeared once permissions hydrated — Rekoo-PS read
that flash as "backend version + storage missing".
Fix: gate on `permissionsLoading || hasPermission('settings.view')`.
Optimistic render during hydration; permitted users see the widgets
immediately (with each widget's own internal loading state), denied
users still see nothing once the permission state lands as `false`.
Side benefit: the `<img>` fallback chain also covers the broader "logo
hosted on a flaky CDN" case for self-hosters, not just the one-time
post-upgrade asset-cache hiccup. Pure resilience polish — no behaviour
change when everything works.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2, Sun, Moon, Globe, ChevronDown } from 'lucide-react';
|
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2, Sun, Moon, Globe, ChevronDown } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -51,11 +51,31 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
? (logoUrl.startsWith('http') ? logoUrl : buildResourceUrl(logoUrl))
|
? (logoUrl.startsWith('http') ? logoUrl : buildResourceUrl(logoUrl))
|
||||||
: '/picpeak-kamera-transparent.png';
|
: '/picpeak-kamera-transparent.png';
|
||||||
|
|
||||||
|
// #523 follow-up 2: graceful fallback when the configured logo URL
|
||||||
|
// 404s or stalls. Without an error handler, the <img> failure draws
|
||||||
|
// the browser's default broken-image-icon + alt text rendering — see
|
||||||
|
// Rekoo-PS's 3.60.3-beta.0 screenshot where "Arkan Studio" appeared
|
||||||
|
// as the alt text of a broken icon, not the real wordmark span. The
|
||||||
|
// chain:
|
||||||
|
// 1. configured URL fails → try the bundled picpeak fallback
|
||||||
|
// 2. bundled fallback fails → hide the image entirely, let the
|
||||||
|
// wordmark carry the brand
|
||||||
|
// Reset on URL change so a dark-mode toggle (which can flip lightLogo
|
||||||
|
// ↔ darkLogo) retries the new URL instead of being permanently sad.
|
||||||
|
const [logoLoadError, setLogoLoadError] = useState(false);
|
||||||
|
const [fallbackLoadError, setFallbackLoadError] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setLogoLoadError(false);
|
||||||
|
setFallbackLoadError(false);
|
||||||
|
}, [resolvedLogoUrl]);
|
||||||
|
const logoImgSrc = logoLoadError ? '/picpeak-kamera-transparent.png' : resolvedLogoUrl;
|
||||||
|
|
||||||
// Renders the logo + wordmark block per the current logo_display_mode.
|
// Renders the logo + wordmark block per the current logo_display_mode.
|
||||||
// Re-used in left / center / right slots below so all three positions
|
// Re-used in left / center / right slots below so all three positions
|
||||||
// produce visually identical brand chrome.
|
// produce visually identical brand chrome.
|
||||||
const showLogo = !logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text');
|
const showLogo = !logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text');
|
||||||
const showText = logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text';
|
const showText = logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text';
|
||||||
|
const logoEffectivelyVisible = showLogo && !fallbackLoadError;
|
||||||
// On <sm the wordmark hides when the logo carries the brand identity
|
// On <sm the wordmark hides when the logo carries the brand identity
|
||||||
// (logo_and_text). Same pattern LanguageSelector uses for its language
|
// (logo_and_text). Same pattern LanguageSelector uses for its language
|
||||||
// name (#527). Without this, even with truncate, a phone-width admin
|
// name (#527). Without this, even with truncate, a phone-width admin
|
||||||
@@ -64,7 +84,12 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
// cluster it overlaps the LanguageSelector button (#523 follow-up,
|
// cluster it overlaps the LanguageSelector button (#523 follow-up,
|
||||||
// Rekoo-PS's "Arkan Studio" screenshot in v3.59.0-beta.0). text_only
|
// Rekoo-PS's "Arkan Studio" screenshot in v3.59.0-beta.0). text_only
|
||||||
// mode keeps the wordmark on every width — nothing else would render.
|
// mode keeps the wordmark on every width — nothing else would render.
|
||||||
const wordmarkVisibilityClass = showLogo ? 'hidden sm:inline' : 'inline';
|
//
|
||||||
|
// #523 follow-up 2: when both the configured URL AND the bundled
|
||||||
|
// fallback have failed (fallbackLoadError → logoEffectivelyVisible
|
||||||
|
// false), unhide the wordmark on <sm too — otherwise the phone header
|
||||||
|
// shows nothing at all for the brand block.
|
||||||
|
const wordmarkVisibilityClass = logoEffectivelyVisible ? 'hidden sm:inline' : 'inline';
|
||||||
const renderBrandBlock = () => {
|
const renderBrandBlock = () => {
|
||||||
// Skeleton placeholder while `usePublicSettings()` is in flight (#523
|
// Skeleton placeholder while `usePublicSettings()` is in flight (#523
|
||||||
// follow-up — Rekoo-PS's "logo took some time to load" screenshot in
|
// follow-up — Rekoo-PS's "logo took some time to load" screenshot in
|
||||||
@@ -88,8 +113,19 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
// action buttons on narrow mobile widths (#523 regression).
|
// action buttons on narrow mobile widths (#523 regression).
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{showLogo && (
|
{logoEffectivelyVisible && (
|
||||||
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain flex-shrink-0" />
|
<img
|
||||||
|
src={logoImgSrc}
|
||||||
|
alt={companyName}
|
||||||
|
className="h-8 w-auto object-contain flex-shrink-0"
|
||||||
|
onError={() => {
|
||||||
|
// First failure: configured URL → try the bundled fallback.
|
||||||
|
// Second failure: bundled fallback → hide entirely, let
|
||||||
|
// the wordmark carry the brand (#523 follow-up 2).
|
||||||
|
if (!logoLoadError) setLogoLoadError(true);
|
||||||
|
else setFallbackLoadError(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{showText && (
|
{showText && (
|
||||||
<span className={`${wordmarkVisibilityClass} text-xl sm:text-2xl truncate`} style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
|
<span className={`${wordmarkVisibilityClass} text-xl sm:text-2xl truncate`} style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ const navigation: NavItem[] = [
|
|||||||
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => {
|
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { hasPermission } = usePermissions();
|
const { hasPermission, isLoading: permissionsLoading } = usePermissions();
|
||||||
const { flags } = useFeatureFlags();
|
const { flags } = useFeatureFlags();
|
||||||
// Branding lookup for the "logo_position = sidepanel" mode — when
|
// Branding lookup for the "logo_position = sidepanel" mode — when
|
||||||
// chosen, the logo replaces the "PicPeak Admin" text in the brand
|
// chosen, the logo replaces the "PicPeak Admin" text in the brand
|
||||||
@@ -283,8 +283,18 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, col
|
|||||||
|
|
||||||
{/* Bottom section - sticky to bottom (only for users with settings.view permission).
|
{/* Bottom section - sticky to bottom (only for users with settings.view permission).
|
||||||
Hidden on desktop when collapsed since these widgets don't fit in the icon rail;
|
Hidden on desktop when collapsed since these widgets don't fit in the icon rail;
|
||||||
mobile keeps them visible because mobile width is always w-64. */}
|
mobile keeps them visible because mobile width is always w-64.
|
||||||
{hasPermission('settings.view') && (
|
|
||||||
|
#523 follow-up 2: render OPTIMISTICALLY while permissions are
|
||||||
|
still hydrating from the auth context (Rekoo-PS's 3.60.3-beta.0
|
||||||
|
screenshot showed the whole bottom block missing on first paint
|
||||||
|
right after a deploy — `hasPermission` returns false during the
|
||||||
|
~hundreds-of-ms hydration window, the widgets vanish entirely,
|
||||||
|
then re-appear). Only HIDE the block when we definitively know
|
||||||
|
the user lacks the permission. VersionInfo + StorageInfo each
|
||||||
|
have their own loading states so admins see "—" / a spinner
|
||||||
|
instead of nothing during the actual data fetch. */}
|
||||||
|
{(permissionsLoading || hasPermission('settings.view')) && (
|
||||||
<div className={`flex-shrink-0 ${collapsed ? 'lg:hidden' : ''}`}>
|
<div className={`flex-shrink-0 ${collapsed ? 'lg:hidden' : ''}`}>
|
||||||
{/* Version Info */}
|
{/* Version Info */}
|
||||||
<VersionInfo />
|
<VersionInfo />
|
||||||
|
|||||||
Reference in New Issue
Block a user