Files
picpeak/frontend/src/components/admin/AdminHeader.tsx
T
Paul Nothaft c246fd3cc8 fix(admin-header): hide wordmark on <sm when logo also shows (#523)
Rekoo-PS's v3.59.0-beta.0 screenshot showed a different shape than
the truncate fix in e7cf834 addressed. Their company name ("Arkan
Studio") isn't unusually long, but with logo_and_text display mode
on a phone-width viewport the wordmark wrapped to two lines and the
LanguageSelector button — sitting in the right action cluster —
landed visually on top of the wrapped second line.

Truncate alone left "Arkan Studio" rendered as "Ar..." after the
logo image. Functional but ugly, and on accounts where the wordmark
reaches the right cluster the visual overlap returns. Match what
LanguageSelector does for its language name in #527: hide the
wordmark on <sm when a logo is also showing (the logo carries the
identity), keep it on sm+. text_only mode is unchanged — wordmark
shows on every width, otherwise nothing would render.

Truncate stays in place as defensive depth for the text_only path.
2026-05-31 23:07:19 +02:00

367 lines
18 KiB
TypeScript

import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2, Sun, Moon } 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 } 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<AdminHeaderProps> = ({ onMenuClick }) => {
const navigate = useNavigate();
const { user, logout } = useAdminAuth();
const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode();
const { t } = useTranslation();
const { format, formatDistanceToNow } = useLocalizedDate();
const [showUserMenu, setShowUserMenu] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const queryClient = useQueryClient();
const { data: brandingSettings } = usePublicSettings();
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = brandingSettings?.branding_logo_url?.trim();
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 <sm the wordmark hides when the logo carries the brand identity
// (logo_and_text). Same pattern LanguageSelector uses for its language
// name (#527). Without this, even with truncate, a phone-width admin
// shows things like "Ar..." after the logo image — readable but ugly,
// and on accounts whose company name lets the text reach the right
// cluster it overlaps the LanguageSelector button (#523 follow-up,
// 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.
const wordmarkVisibilityClass = showLogo ? 'hidden sm:inline' : 'inline';
const renderBrandBlock = () => (
// 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).
<div className="flex items-center gap-2 min-w-0">
{showLogo && (
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain flex-shrink-0" />
)}
{showText && (
<span className={`${wordmarkVisibilityClass} text-xl sm:text-2xl truncate`} style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
)}
</div>
);
const userMenuRef = useRef<HTMLDivElement>(null);
const notificationRef = useRef<HTMLDivElement>(null);
useOnClickOutside(userMenuRef, () => setShowUserMenu(false));
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 <header> 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.
<header className="sticky top-0 z-30 bg-white dark:bg-neutral-900 h-16 border-b border-neutral-200 dark:border-neutral-700">
<div className="px-4 sm:px-6 lg:px-8 h-full">
<div className="relative flex items-center justify-between h-full gap-3">
{/* 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. */}
<div className="flex items-center gap-3 min-w-0">
<button
onClick={onMenuClick}
className="lg:hidden text-neutral-500 hover:text-neutral-700"
>
<Menu className="w-6 h-6" />
</button>
{!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' && (
<div className="flex lg:hidden">{renderBrandBlock()}</div>
)}
{!logoInSidebar && logoPosition === 'right' && (
<div className="flex md:hidden">{renderBrandBlock()}</div>
)}
{/* 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 <p> inheriting its block-level vertical position,
which read as slightly off-centre next to the larger
logo image. */}
<div className={`hidden xl:flex items-center self-stretch ml-1 ${
logoPosition === 'left' && !logoInSidebar
? 'pl-3 border-l border-neutral-200 dark:border-neutral-700'
: ''
}`}>
<p className="text-base leading-none text-neutral-700 dark:text-neutral-300 m-0">
{format(new Date(), 'PPPP')}
</p>
</div>
</div>
{/* 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' && (
<div className="hidden lg:flex absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
{renderBrandBlock()}
</div>
)}
{/* 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. */}
<div className="flex items-center gap-3">
{!logoInSidebar && logoPosition === 'right' && (
<div className="hidden md:flex mr-1 pr-2 border-r border-neutral-200 dark:border-neutral-700">
{renderBrandBlock()}
</div>
)}
{/* Language Selector */}
<LanguageSelector />
{/* Dark Mode Toggle — hidden entirely when an admin has locked
the instance to a specific mode via Branding > Force color mode. */}
{!forcedMode && (
<button
onClick={toggleDarkMode}
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
title={isDark ? t('admin.lightMode', 'Switch to light mode') : t('admin.darkMode', 'Switch to dark mode')}
>
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
)}
{/* Notifications */}
<div className="relative" ref={notificationRef}>
<button
onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
>
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
)}
</button>
{/* Notifications dropdown */}
{showNotifications && (
<div className="absolute right-0 mt-2 w-96 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700">
<div className="px-4 py-3 border-b border-neutral-100 dark:border-neutral-700 flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{t('admin.notifications')}</h3>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={() => markAllAsReadMutation.mutate()}
className="text-xs text-accent hover:opacity-80 flex items-center gap-1"
title={t('admin.markAllRead')}
>
<CheckCircle className="w-3 h-3" />
{t('admin.markAllRead')}
</button>
)}
<button
onClick={() => clearAllMutation.mutate()}
className="text-xs text-neutral-600 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 flex items-center gap-1"
title={t('admin.clearAll')}
>
<Trash2 className="w-3 h-3" />
{t('admin.clearAll')}
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
{t('admin.noNotificationsMessage')}
</div>
) : (
notifications.map((notification) => {
const style = notificationsService.getNotificationStyle(notification.type);
return (
<div
key={notification.id}
className={`px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-700 cursor-pointer border-l-4 ${
notification.isRead ? 'border-transparent opacity-75' : 'border-accent-dark'
}`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 ${style.color}`}>
<Bell className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-neutral-900 dark:text-neutral-100">
{notificationsService.formatNotificationMessage(notification)}
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{formatDistanceToNow(notification.createdAt, { addSuffix: true })}
</p>
</div>
</div>
</div>
);
})
)}
</div>
{notifications.length > 0 && (
<div className="px-4 py-2 border-t border-neutral-100 dark:border-neutral-700 text-center">
<button
onClick={() => setShowNotifications(false)}
className="text-sm text-accent hover:opacity-80"
>
{t('admin.close')}
</button>
</div>
)}
</div>
)}
</div>
{/* User menu */}
<div className="relative" ref={userMenuRef}>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-3 p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
>
<div className="text-right hidden sm:block">
<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>
</div>
<div className="w-8 h-8 bg-accent-dark rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-white" />
</div>
</button>
{/* User dropdown */}
{showUserMenu && (
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700 py-1">
<div className="px-4 py-2 border-b border-neutral-100 dark:border-neutral-700 sm:hidden">
<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>
</div>
<button
onClick={() => {
setShowUserMenu(false);
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"
>
<Settings className="w-4 h-4" />
{t('navigation.settings')}
</button>
<button
onClick={() => {
setShowUserMenu(false);
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"
>
<Lock className="w-4 h-4" />
{t('admin.changePassword')}
</button>
<button
onClick={handleLogout}
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"
>
<LogOut className="w-4 h-4" />
{t('common.logout')}
</button>
</div>
)}
</div>
</div>
</div>
</div>
{/* Password Change Modal */}
<PasswordChangeModal
isOpen={showPasswordModal}
onClose={() => setShowPasswordModal(false)}
/>
</header>
);
};