diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx index 8b17bda7..d5f3fd23 100644 --- a/src/components/Footer.jsx +++ b/src/components/Footer.jsx @@ -92,9 +92,53 @@ const Footer = () => { return null } }, []) - const navigationQuickLinks = useMemo(() => { - const items = effectiveNavigationItems - return items + const quickLinks = useMemo(() => { + const contentLinks = Array.isArray(footerContent?.quickLinks) ? footerContent.quickLinks : [] + const normalizedContentLinks = contentLinks + .map((link) => { + if (!link) return null + if (link.target) { + const href = buildTargetHref(link.target) + return { + label: link.label || link.target?.value || 'Link', + target: link.target, + href, + } + } + if (link.href) { + const safeHref = sanitizeExternalUrl(link.href) + if (!safeHref) { + console.warn('Blocked unsafe footer quick link:', link.href) + return null + } + return { + label: link.label || link.href, + href: safeHref, + } + } + if (link.path) { + return { + label: link.label || link.path, + target: { type: 'route', value: link.path }, + href: link.path, + } + } + if (link.slug) { + const slug = link.slug.trim().replace(/^\//, '') + if (!slug) return null + return { + label: link.label || slug, + target: { type: 'route', value: `/pages/${slug}` }, + href: `/pages/${slug}`, + } + } + return null + }) + .filter(Boolean) + if (normalizedContentLinks.length > 0) { + return normalizedContentLinks + } + return effectiveNavigationItems .map((item) => { if (!item) return null if (item.target) { @@ -142,10 +186,7 @@ const Footer = () => { return null }) .filter(Boolean) - }, [buildTargetHref, effectiveNavigationItems]) - const quickLinks = useMemo(() => { - return navigationQuickLinks - }, [navigationQuickLinks]) + }, [buildTargetHref, effectiveNavigationItems, footerContent?.quickLinks]) const handleQuickLink = (event, link) => { // Early return for invalid or missing link data if (!link) return diff --git a/src/components/Header.jsx b/src/components/Header.jsx index 726e1947..aedb031a 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -82,6 +82,75 @@ const Header = () => { scrollToSection(sectionId) } } + const getNavHref = (item) => { + if (!item) return '#' + if (item.target) { + const target = item.target + const value = target.value ?? target.path ?? target.href ?? target.slug + switch (target.type) { + case 'section': { + const sectionId = resolveSectionId({ ...item, ...target }) + return sectionId ? `#${sectionId.replace(/^#/, '')}` : '#' + } + case 'route': + case 'page': + return typeof value === 'string' && value.trim() + ? value.startsWith('/') + ? value + : `/${value.trim().replace(/^\//, '')}` + : '#' + case 'external': + case 'href': { + const safeUrl = sanitizeExternalUrl(value) + return safeUrl || '#' + } + default: + return '#' + } + } + if (item.href) { + const safeUrl = sanitizeExternalUrl(item.href) + return safeUrl || '#' + } + if ((item.type === 'route' || item.type === 'page') && item.path) { + return item.path + } + if (item.type === 'section') { + const sectionId = resolveSectionId(item) + return sectionId ? `#${sectionId.replace(/^#/, '')}` : '#' + } + if (item.slug) { + return `/pages/${item.slug}` + } + return '#' + } + const isExternalHref = (href) => + typeof href === 'string' && (href.startsWith('http://') || href.startsWith('https://')) + const isSpecialProtocol = (href) => + typeof href === 'string' && (href.startsWith('mailto:') || href.startsWith('tel:')) + const handleNavClick = (event, item) => { + if (!item) return + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.button !== 0) { + return + } + event.preventDefault() + handleNavigation(item) + setMobileMenuOpen(false) + } + const handleBrandClick = (event) => { + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.button !== 0) { + return + } + event.preventDefault() + setMobileMenuOpen(false) + if (location.pathname === '/') { + if (typeof window !== 'undefined') { + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + return + } + navigate('/') + } const handleCtaClick = () => { if (ctaContent.target) { navigateContentTarget(ctaContent.target, { navigate, location }) @@ -94,25 +163,44 @@ const Header = () => { - {} {searchOpen && setSearchOpen(false)} />} ) diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx index 02d0769a..3e9bbf8c 100644 --- a/src/components/SearchBar.jsx +++ b/src/components/SearchBar.jsx @@ -10,8 +10,11 @@ const SearchBar = ({ onClose }) => { const [selectedTopic, setSelectedTopic] = useState(''); const [isLoading, setIsLoading] = useState(false); const inputRef = useRef(null); + const dialogRef = useRef(null); + const previouslyFocusedElement = useRef(null); const navigate = useNavigate(); useEffect(() => { + previouslyFocusedElement.current = document.activeElement; inputRef.current?.focus(); api.request('/search/topics', { cacheBust: false }) .then((data) => setTopics(Array.isArray(data) ? data : [])) @@ -20,6 +23,51 @@ const SearchBar = ({ onClose }) => { setTopics([]) }) }, []); + useEffect(() => { + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + if (onClose) { + onClose(); + } + return; + } + if (event.key === 'Tab' && dialogRef.current) { + const focusableSelectors = [ + 'a[href]:not([tabindex="-1"])', + 'button:not([disabled]):not([tabindex="-1"])', + 'textarea:not([disabled]):not([tabindex="-1"])', + 'input:not([disabled]):not([tabindex="-1"])', + 'select:not([disabled]):not([tabindex="-1"])', + '[tabindex]:not([tabindex="-1"])', + ].join(','); + const focusable = Array.from(dialogRef.current.querySelectorAll(focusableSelectors)).filter( + (element) => + !element.hasAttribute('disabled') && + element.getAttribute('aria-hidden') !== 'true' && + (element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0), + ); + if (!focusable.length) { + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const activeElement = document.activeElement; + if (!event.shiftKey && activeElement === last) { + event.preventDefault(); + first.focus(); + } else if (event.shiftKey && activeElement === first) { + event.preventDefault(); + last.focus(); + } + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + previouslyFocusedElement.current?.focus?.(); + }; + }, [onClose]); useEffect(() => { if (query.trim().length < 1) { setResults([]); @@ -49,16 +97,28 @@ const SearchBar = ({ onClose }) => { navigate(`/tutorials/${id}`); if (onClose) onClose(); }; - const handleKeyDown = (e) => { - if (e.key === 'Escape') { - if (onClose) onClose(); - } - }; return ( -
-
+
{ + if (event.target === event.currentTarget && onClose) { + onClose(); + } + }} + > +
{}
+

+ Tutorialsuche +

{ type="text" value={query} onChange={(e) => setQuery(e.target.value)} - onKeyDown={handleKeyDown} placeholder="Tutorial suchen..." className="w-full pl-12 pr-12 py-3 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500 dark:text-gray-100" /> @@ -74,6 +133,7 @@ const SearchBar = ({ onClose }) => { diff --git a/src/components/TutorialCard.jsx b/src/components/TutorialCard.jsx index d23d345a..fd5b94b9 100644 --- a/src/components/TutorialCard.jsx +++ b/src/components/TutorialCard.jsx @@ -10,18 +10,7 @@ const TutorialCard = ({ icon: Icon, title, description, topics, color, onSelect, scrollToSection('tutorials') } return ( -
{ - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - handleSelect() - } - }} - > +
+
) } diff --git a/src/index.css b/src/index.css index b3be18bf..48db9663 100644 --- a/src/index.css +++ b/src/index.css @@ -154,12 +154,8 @@ } /* Button utility styles for consistent CTA presentation */ - .btn-base { - @apply inline-flex items-center justify-center gap-2 rounded-xl font-semibold transition-all duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 disabled:opacity-60 disabled:cursor-not-allowed; - } - .btn-primary { - @apply btn-base px-6 py-3 text-white shadow-md bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-500 hover:to-primary-600 hover:-translate-y-0.5; + @apply inline-flex items-center justify-center gap-2 rounded-xl font-semibold transition-all duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 disabled:opacity-60 disabled:cursor-not-allowed px-6 py-3 text-white shadow-md bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-500 hover:to-primary-600 hover:-translate-y-0.5; } .btn-primary--compact { @@ -167,15 +163,15 @@ } .btn-secondary { - @apply btn-base px-6 py-3 border border-primary-600 text-primary-700 bg-white hover:bg-primary-50; + @apply inline-flex items-center justify-center gap-2 rounded-xl font-semibold transition-all duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 disabled:opacity-60 disabled:cursor-not-allowed px-6 py-3 border border-primary-600 text-primary-700 bg-white hover:bg-primary-50; } .btn-secondary-inverse { - @apply btn-base px-6 py-3 border border-white/40 text-white hover:bg-white/10; + @apply inline-flex items-center justify-center gap-2 rounded-xl font-semibold transition-all duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 disabled:opacity-60 disabled:cursor-not-allowed px-6 py-3 border border-white/40 text-white hover:bg-white/10; } .btn-ghost { - @apply btn-base px-6 py-3 text-gray-700 hover:text-primary-600 hover:bg-primary-50 dark:text-gray-300 dark:hover:text-primary-300 dark:hover:bg-gray-800/60; + @apply inline-flex items-center justify-center gap-2 rounded-xl font-semibold transition-all duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 disabled:opacity-60 disabled:cursor-not-allowed px-6 py-3 text-gray-700 hover:text-primary-600 hover:bg-primary-50 dark:text-gray-300 dark:hover:text-primary-300 dark:hover:bg-gray-800/60; } /* Floating animation class for continuous up and down movement */ .floating {