import React, { useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2, Sun, Moon, Globe, ChevronDown } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useAdminAuth } from '../../contexts'; import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext'; import { useOnClickOutside } from '../../hooks/useOnClickOutside'; import { usePublicSettings } from '../../hooks/usePublicSettings'; import { PasswordChangeModal } from './PasswordChangeModal'; import { LanguageSelector, SUPPORTED_LANGUAGES } from '../common'; import { notificationsService } from '../../services/notifications.service'; import { toast } from 'react-toastify'; import { buildResourceUrl } from '../../utils/url'; interface AdminHeaderProps { onMenuClick: () => void; } export const AdminHeader: React.FC = ({ onMenuClick }) => { const navigate = useNavigate(); const { user, logout } = useAdminAuth(); const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode(); const { t, i18n } = useTranslation(); const { format, formatDistanceToNow } = useLocalizedDate(); const [showUserMenu, setShowUserMenu] = useState(false); const [showUserMenuLangSection, setShowUserMenuLangSection] = useState(false); const [showNotifications, setShowNotifications] = useState(false); const [showPasswordModal, setShowPasswordModal] = useState(false); const queryClient = useQueryClient(); 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'; // Dark-mode logo variant. Symmetric fallback: if only one logo is set, // use it for both modes (dark → dark||light, light → light||dark). const lightLogo = brandingSettings?.branding_logo_url?.trim(); const darkLogo = brandingSettings?.branding_logo_url_dark?.trim(); const logoUrl = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo); const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text'; // Logo placement honours the same Branding > Logo Position setting // the gallery does. 'sidepanel' moves the logo into the AdminSidebar // brand row — suppress it here so it doesn't double up. left / // center / right reposition the logo block within this header bar. const logoPosition = brandingSettings?.branding_logo_position || 'left'; const logoInSidebar = logoPosition === 'sidepanel'; const resolvedLogoUrl = logoUrl ? (logoUrl.startsWith('http') ? logoUrl : buildResourceUrl(logoUrl)) : '/picpeak-kamera-transparent.png'; // Renders the logo + wordmark block per the current logo_display_mode. // Re-used in left / center / right slots below so all three positions // produce visually identical brand chrome. const showLogo = !logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text'); const showText = logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text'; // On { // 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 (
); } // min-w-0 + truncate on the name span so long company names shrink // within the left cluster instead of pushing into the right-side // action buttons on narrow mobile widths (#523 regression). return (
{showLogo && ( {companyName} )} {showText && ( {companyName} )}
); }; // #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(null); const notificationRef = useRef(null); useOnClickOutside(userMenuRef, closeUserMenu); useOnClickOutside(notificationRef, () => setShowNotifications(false)); const handleLogout = () => { logout(); navigate('/admin/login'); }; // Fetch notifications const { data: notificationsData } = useQuery({ queryKey: ['notifications', showNotifications], queryFn: () => notificationsService.getNotifications(showNotifications, 20), refetchInterval: 60000, // Refetch every minute }); // Mark all as read mutation const markAllAsReadMutation = useMutation({ mutationFn: notificationsService.markAllAsRead, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['notifications'] }); toast.success(t('admin.notificationToasts.markedAllRead')); }, }); // Clear notifications mutation const clearAllMutation = useMutation({ mutationFn: notificationsService.clearAllNotifications, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['notifications'] }); toast.success(t('admin.notificationToasts.clearedAll', { count: data.deletedCount })); }, }); const notifications = notificationsData?.notifications || []; const unreadCount = notificationsData?.unreadCount || 0; return ( // border-b is on the OUTER
so it spans the full header // width — putting it on the inner row instead left a 32px gap on // the left (the px-4/sm:px-6/lg:px-8 padding) before the divider // started, visible as a missing segment between the sidebar's // brand-row bottom border and the header's bottom border. // // The outer header is explicitly h-16 with the default border-box, // so the 1px border is painted INSIDE the 64px height (y=63..64) // — same model as the sidebar's brand row (also h-16 border-b // border-box). Both bottom borders meet at the exact same // y-coordinate.
{/* Left side - Menu button, optional left-positioned logo, Date. Logo block appears here when logo_position = 'left' (the default). For 'center' it's absolutely positioned across the whole header; for 'right' it sits in the right-side cluster just before the action widgets. */}
{!logoInSidebar && logoPosition === 'left' && renderBrandBlock()} {/* Narrow-viewport fallback for center / right positions. On screens where the centered (lg+) or right-anchored (md+) brand block is hidden, render it on the left so the admin chrome doesn't go logo-less on phones. */} {!logoInSidebar && logoPosition === 'center' && (
{renderBrandBlock()}
)} {!logoInSidebar && logoPosition === 'right' && (
{renderBrandBlock()}
)} {/* Date display - hidden on smaller screens. The vertical divider + left padding only render when the logo sits on the left of the date (logo_position = 'left'). For 'center' / 'right' / 'sidepanel' the left cluster has only the mobile menu (which is hidden on xl+), so a divider would be floating on its own with nothing to separate. */} {/* Explicit `flex items-center` (not just block) + the self-stretch on the border-l variant so the divider covers the full header row, AND the text baseline sits exactly on the same y-axis as the brand-block / sidebar logo to its left. The previous `hidden xl:block` left the

inheriting its block-level vertical position, which read as slightly off-centre next to the larger logo image. */}

{format(new Date(), 'PPPP')}

{/* Centered logo. Absolutely positioned so the existing left/right clusters keep their natural sizing; hidden on sub-lg widths to avoid colliding with the right-side action cluster on narrow screens. pointer-events-none on the wrapper passes hover/click through (the logo itself has no interactive children today). */} {!logoInSidebar && logoPosition === 'center' && (
{renderBrandBlock()}
)} {/* Right side actions (preceded by the brand block when logo_position = 'right'). The right-anchored logo sits before the language / dark-mode / notifications / user cluster so the widgets stay where admins expect them. */}
{!logoInSidebar && logoPosition === 'right' && (
{renderBrandBlock()}
)} {/* Language Selector — hidden on
{/* Dark Mode Toggle — hidden entirely when an admin has locked the instance to a specific mode via Branding > Force color mode. */} {!forcedMode && ( )} {/* Notifications */}
{/* Notifications dropdown */} {showNotifications && (

{t('admin.notifications')}

{unreadCount > 0 && ( )}
{notifications.length === 0 ? (
{t('admin.noNotificationsMessage')}
) : ( notifications.map((notification) => { const style = notificationsService.getNotificationStyle(notification.type); return (

{notificationsService.formatNotificationMessage(notification)}

{formatDistanceToNow(notification.createdAt, { addSuffix: true })}

); }) )}
{notifications.length > 0 && (
)}
)}
{/* User menu */}
{/* User dropdown */} {showUserMenu && (

{user?.username}

{user?.email}

{/* 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. */}
{showUserMenuLangSection && (
{SUPPORTED_LANGUAGES.map((language) => ( ))}
)}
)}
{/* Password Change Modal */} setShowPasswordModal(false)} /> ); };