Files
medassist-ng/frontend/src/components/AppHeader.tsx
T
Daniel Volz 6161c14a7b fix: logo optimization, deprecated meta tag, and clipboard copy fallback (#306)
- Replace 2 MB favicon.svg (base64-PNG-in-SVG) with optimized 43 KB app-logo.png (256x256)
- Update AppHeader and AboutModal references to use new logo
- Remove SVG favicon link from index.html (PNG/ICO favicons remain)
- Fix deprecated apple-mobile-web-app-capable → mobile-web-app-capable meta tag
- Add clipboard copy fallback for non-secure contexts (LAN IP over HTTP)

Closes #303
2026-02-25 00:04:35 +01:00

250 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AppHeader - Main application header with navigation and user menu
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { useUnsavedChanges } from "../context";
import { useTheme } from "../hooks";
import { useAuth } from "./Auth";
interface AppHeaderProps {
onOpenProfile: () => void;
onOpenAbout: () => void;
}
export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const currentPath = location.pathname;
const { user, authState, logout } = useAuth();
const { theme, themePreference, setThemePreference } = useTheme();
const { confirmNavigation } = useUnsavedChanges();
// Theme dropdown state
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
const themeMenuRef = useRef<HTMLDivElement>(null);
// Close theme dropdown when clicking outside
useEffect(() => {
if (!themeMenuOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (themeMenuRef.current && !themeMenuRef.current.contains(e.target as Node)) {
setThemeMenuOpen(false);
}
};
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, [themeMenuOpen]);
// Safe navigation that checks for unsaved changes first
const safeNavigate = async (path: string) => {
if (await confirmNavigation()) {
navigate(path);
}
};
// User dropdown state (for mobile click-based behavior)
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
// Close user dropdown when clicking outside
useEffect(() => {
if (!userDropdownOpen) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest(".user-menu")) {
setUserDropdownOpen(false);
}
};
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, [userDropdownOpen]);
// Page titles based on current route
const pageInfo = {
"/dashboard": { eyebrow: t("header.eyebrow.overview"), title: t("nav.dashboard") },
"/medications": { eyebrow: t("header.eyebrow.inventory"), title: t("nav.medications") },
"/planner": { eyebrow: t("header.eyebrow.planner"), title: t("nav.planner") },
"/settings": { eyebrow: t("header.eyebrow.settings"), title: t("nav.settings") },
"/schedule": { eyebrow: t("header.eyebrow.schedule"), title: t("dashboard.schedules.title") },
}[currentPath] || { eyebrow: t("header.eyebrow.overview"), title: t("nav.dashboard") };
return (
<header className="hero">
<div className="hero-title">
<img src="/app-logo.png" alt="MedAssist-ng" className="hero-logo" />
<div>
<p className="eyebrow">{pageInfo.eyebrow}</p>
<h1>{pageInfo.title}</h1>
</div>
</div>
<div className="header-actions">
<div className="tabs">
<button
className={currentPath === "/dashboard" || currentPath === "/" ? "pill primary" : "pill"}
onClick={() => safeNavigate("/dashboard")}
>
{t("nav.dashboard")}
</button>
<button
className={currentPath === "/medications" ? "pill primary" : "pill"}
onClick={() => safeNavigate("/medications")}
>
{t("nav.medications")}
</button>
<button
className={currentPath === "/planner" ? "pill primary" : "pill"}
onClick={() => safeNavigate("/planner")}
>
{t("nav.planner")}
</button>
</div>
{/* Settings button only shown when auth is disabled (no user dropdown available) */}
{!authState?.authEnabled && (
<button
className={`icon-btn ${currentPath === "/settings" ? "active" : ""}`}
onClick={() => safeNavigate("/settings")}
title={t("nav.settings")}
>
</button>
)}
<div className={`theme-menu ${themeMenuOpen ? "open" : ""}`} ref={themeMenuRef}>
<button className="icon-btn" onClick={() => setThemeMenuOpen(!themeMenuOpen)} title={t("theme.title")}>
{theme === "dark" ? "🌙" : "☀️"}
</button>
<div className="theme-dropdown">
<button
className={`theme-dropdown-item${themePreference === "light" ? " active" : ""}`}
onClick={() => {
setThemePreference("light");
setThemeMenuOpen(false);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
{t("theme.light")}
{themePreference === "light" && <span className="theme-check"></span>}
</button>
<button
className={`theme-dropdown-item${themePreference === "dark" ? " active" : ""}`}
onClick={() => {
setThemePreference("dark");
setThemeMenuOpen(false);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
{t("theme.dark")}
{themePreference === "dark" && <span className="theme-check"></span>}
</button>
<button
className={`theme-dropdown-item${themePreference === "system" ? " active" : ""}`}
onClick={() => {
setThemePreference("system");
setThemeMenuOpen(false);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
{t("theme.system")}
{themePreference === "system" && <span className="theme-check"></span>}
</button>
</div>
</div>
{authState?.authEnabled && user && (
<div className={`user-menu ${userDropdownOpen ? "open" : ""}`}>
<button className="user-menu-btn" onClick={() => setUserDropdownOpen(!userDropdownOpen)}>
{user.avatarUrl ? (
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="user-avatar-img" />
) : (
<span className="user-avatar">{user.username.charAt(0).toUpperCase()}</span>
)}
</button>
<div className="user-dropdown">
<div className="dropdown-header">
{user.avatarUrl ? (
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="dropdown-avatar-img" />
) : (
<div className="dropdown-avatar">{user.username.charAt(0).toUpperCase()}</div>
)}
<span className="dropdown-username">{user.username}</span>
</div>
<div className="dropdown-menu">
<button
className="dropdown-item"
onClick={() => {
onOpenProfile();
setUserDropdownOpen(false);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
{t("auth.profile", "Profile")}
</button>
<button
className="dropdown-item"
onClick={() => {
safeNavigate("/settings");
setUserDropdownOpen(false);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
{t("nav.settings", "Settings")}
</button>
<button
className="dropdown-item"
onClick={() => {
onOpenAbout();
setUserDropdownOpen(false);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
{t("about.title", "About")}
</button>
<button
className="dropdown-item danger"
onClick={() => {
logout();
setUserDropdownOpen(false);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
{t("auth.signOut", "Sign Out")}
</button>
</div>
</div>
</div>
)}
</div>
</header>
);
}