Merge pull request #604 from the-luap/fix/header-skeleton-lang-in-profile
fix(admin-header): skeleton brand block + move LanguageSelector into profile menu on <sm (#523 follow-up)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } 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 } 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';
|
||||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -10,7 +10,7 @@ import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
|
|||||||
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
|
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
|
||||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
import { PasswordChangeModal } from './PasswordChangeModal';
|
import { PasswordChangeModal } from './PasswordChangeModal';
|
||||||
import { LanguageSelector } from '../common';
|
import { LanguageSelector, SUPPORTED_LANGUAGES } from '../common';
|
||||||
import { notificationsService } from '../../services/notifications.service';
|
import { notificationsService } from '../../services/notifications.service';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { buildResourceUrl } from '../../utils/url';
|
import { buildResourceUrl } from '../../utils/url';
|
||||||
@@ -23,14 +23,16 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, logout } = useAdminAuth();
|
const { user, logout } = useAdminAuth();
|
||||||
const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode();
|
const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode();
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { format, formatDistanceToNow } = useLocalizedDate();
|
const { format, formatDistanceToNow } = useLocalizedDate();
|
||||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
|
const [showUserMenuLangSection, setShowUserMenuLangSection] = useState(false);
|
||||||
const [showNotifications, setShowNotifications] = useState(false);
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: brandingSettings } = usePublicSettings();
|
const { data: brandingSettings, isLoading: brandingLoading } = usePublicSettings();
|
||||||
|
const currentLanguage = SUPPORTED_LANGUAGES.find(lang => lang.code === i18n.language) || SUPPORTED_LANGUAGES[0];
|
||||||
|
|
||||||
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
|
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
|
||||||
const logoUrl = brandingSettings?.branding_logo_url?.trim();
|
const logoUrl = brandingSettings?.branding_logo_url?.trim();
|
||||||
@@ -59,10 +61,28 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
// 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';
|
const wordmarkVisibilityClass = showLogo ? 'hidden sm:inline' : 'inline';
|
||||||
const renderBrandBlock = () => (
|
const renderBrandBlock = () => {
|
||||||
|
// Skeleton placeholder while `usePublicSettings()` is in flight (#523
|
||||||
|
// follow-up — Rekoo-PS's "logo took some time to load" screenshot in
|
||||||
|
// 3.60.1-beta.0). The previous code used the static fallback image
|
||||||
|
// /picpeak-kamera-transparent.png as the during-loading state, and
|
||||||
|
// because the wordmark is `hidden sm:inline` whenever a logo is
|
||||||
|
// *intended* to be shown, a phone-width admin saw an empty header
|
||||||
|
// for the ~hundreds-of-ms window before the real branding payload
|
||||||
|
// arrived. The skeleton block holds the same h-8 (so no layout
|
||||||
|
// shift when the real content lands) and is wider on sm+ to hint
|
||||||
|
// at the wordmark slot.
|
||||||
|
if (brandingLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="h-8 w-8 sm:w-32 bg-neutral-200 dark:bg-neutral-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
// min-w-0 + truncate on the name span so long company names shrink
|
// min-w-0 + truncate on the name span so long company names shrink
|
||||||
// within the left cluster instead of pushing into the right-side
|
// within the left cluster instead of pushing into the right-side
|
||||||
// action buttons on narrow mobile widths (#523 regression).
|
// action buttons on narrow mobile widths (#523 regression).
|
||||||
|
return (
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{showLogo && (
|
{showLogo && (
|
||||||
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain flex-shrink-0" />
|
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain flex-shrink-0" />
|
||||||
@@ -72,11 +92,24 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// #523 follow-up: closes the user-menu lang sub-section whenever the
|
||||||
|
// outer dropdown closes, so re-opening it doesn't surprise the user
|
||||||
|
// with the language list already expanded from the previous session.
|
||||||
|
const closeUserMenu = () => {
|
||||||
|
setShowUserMenu(false);
|
||||||
|
setShowUserMenuLangSection(false);
|
||||||
|
};
|
||||||
|
const handleUserMenuLangSelect = (languageCode: string) => {
|
||||||
|
i18n.changeLanguage(languageCode);
|
||||||
|
closeUserMenu();
|
||||||
|
};
|
||||||
|
|
||||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const notificationRef = useRef<HTMLDivElement>(null);
|
const notificationRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useOnClickOutside(userMenuRef, () => setShowUserMenu(false));
|
useOnClickOutside(userMenuRef, closeUserMenu);
|
||||||
useOnClickOutside(notificationRef, () => setShowNotifications(false));
|
useOnClickOutside(notificationRef, () => setShowNotifications(false));
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@@ -200,8 +233,14 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
{renderBrandBlock()}
|
{renderBrandBlock()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Language Selector */}
|
{/* Language Selector — hidden on <sm where it's surfaced
|
||||||
|
via the user dropdown instead (#523 follow-up: phone
|
||||||
|
view header was too crowded with 4 widgets; language
|
||||||
|
is a set-once preference so it doesn't deserve permanent
|
||||||
|
header real estate on mobile per Rekoo-PS's feedback). */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Dark Mode Toggle — hidden entirely when an admin has locked
|
{/* Dark Mode Toggle — hidden entirely when an admin has locked
|
||||||
the instance to a specific mode via Branding > Force color mode. */}
|
the instance to a specific mode via Branding > Force color mode. */}
|
||||||
@@ -322,9 +361,44 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{user?.username}</p>
|
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{user?.username}</p>
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{user?.email}</p>
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Language sub-section — phone-only (#523 follow-up).
|
||||||
|
Rekoo-PS asked for language to live inside the profile
|
||||||
|
menu since it's a set-once preference; on sm+ it
|
||||||
|
stays in the header cluster where it's been. Collapsible
|
||||||
|
so the menu isn't 8 rows taller by default. */}
|
||||||
|
<div className="sm:hidden border-b border-neutral-100 dark:border-neutral-700">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUserMenuLangSection(!showUserMenuLangSection)}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
|
||||||
|
aria-expanded={showUserMenuLangSection}
|
||||||
|
>
|
||||||
|
<Globe className="w-4 h-4" />
|
||||||
|
<span className="flex-1">{t('common.language', 'Language')}</span>
|
||||||
|
<currentLanguage.Flag className="w-4 h-4" />
|
||||||
|
<ChevronDown className={`w-4 h-4 transition-transform ${showUserMenuLangSection ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{showUserMenuLangSection && (
|
||||||
|
<div className="bg-neutral-50 dark:bg-neutral-900 py-1">
|
||||||
|
{SUPPORTED_LANGUAGES.map((language) => (
|
||||||
|
<button
|
||||||
|
key={language.code}
|
||||||
|
onClick={() => handleUserMenuLangSelect(language.code)}
|
||||||
|
className={`w-full pl-11 pr-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-neutral-100 dark:hover:bg-neutral-700 ${
|
||||||
|
language.code === i18n.language
|
||||||
|
? 'text-accent bg-accent-dark/15'
|
||||||
|
: 'text-neutral-700 dark:text-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<language.Flag className="w-4 h-4" />
|
||||||
|
<span>{language.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowUserMenu(false);
|
closeUserMenu();
|
||||||
navigate('/admin/settings');
|
navigate('/admin/settings');
|
||||||
}}
|
}}
|
||||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
|
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
|
||||||
@@ -334,7 +408,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowUserMenu(false);
|
closeUserMenu();
|
||||||
setShowPasswordModal(true);
|
setShowPasswordModal(true);
|
||||||
}}
|
}}
|
||||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
|
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export { OfflineIndicator, useOnlineStatus } from './OfflineIndicator';
|
|||||||
export { SkipLink } from './SkipLink';
|
export { SkipLink } from './SkipLink';
|
||||||
export { DynamicFavicon } from './DynamicFavicon';
|
export { DynamicFavicon } from './DynamicFavicon';
|
||||||
export { RobotsMetaTags } from './RobotsMetaTags';
|
export { RobotsMetaTags } from './RobotsMetaTags';
|
||||||
export { LanguageSelector } from './LanguageSelector';
|
export { LanguageSelector, SUPPORTED_LANGUAGES } from './LanguageSelector';
|
||||||
export { AuthenticatedImage } from './AuthenticatedImage';
|
export { AuthenticatedImage } from './AuthenticatedImage';
|
||||||
export { AuthenticatedVideo } from './AuthenticatedVideo';
|
export { AuthenticatedVideo } from './AuthenticatedVideo';
|
||||||
export { ProtectedImage } from './ProtectedImage';
|
export { ProtectedImage } from './ProtectedImage';
|
||||||
|
|||||||
Reference in New Issue
Block a user