Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 48 additions & 7 deletions src/components/Footer.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMemo } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { Terminal, Heart } from 'lucide-react'

Check warning on line 3 in src/components/Footer.jsx

View workflow job for this annotation

GitHub Actions / frontend-test

'Terminal' is defined but never used
import { useContent } from '../context/ContentContext'
import { navigateContentTarget } from '../utils/contentNavigation'
import { getIconComponent } from '../utils/iconMap'
Expand Down Expand Up @@ -71,7 +71,7 @@
const allItems = Array.isArray(navigation?.items) ? navigation.items : []
return allItems
}, [staticNavigationItems, dynamicNavigationItems, navigation?.items])
const buildTargetHref = useCallback((target) => {

Check failure on line 74 in src/components/Footer.jsx

View workflow job for this annotation

GitHub Actions / frontend-test

'useCallback' is not defined
if (!target || typeof target !== 'object') {
return null
}
Expand All @@ -92,9 +92,53 @@
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) {
Expand Down Expand Up @@ -142,10 +186,7 @@
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
Expand Down Expand Up @@ -202,7 +243,7 @@
quickLinks.map((link, index) => {
const href = link.href || '#'
const isExternal = typeof href === 'string' && (href.startsWith('http://') || href.startsWith('https://'))
const isSpecialProtocol = typeof href === 'string' && (href.startsWith('mailto:') || href.startsWith('tel:'))

Check warning on line 246 in src/components/Footer.jsx

View workflow job for this annotation

GitHub Actions / frontend-test

'isSpecialProtocol' is assigned a value but never used
return (
<li key={link.label || link.target?.value || `quick-${index}`}>
<a
Expand Down
154 changes: 123 additions & 31 deletions src/components/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
const { isAuthenticated } = useAuth()
const { getSection, navigation } = useContent()
const headerContent = getSection('header') ?? {}
const contentNavItems = Array.isArray(headerContent.navItems) ? headerContent.navItems : []

Check warning on line 24 in src/components/Header.jsx

View workflow job for this annotation

GitHub Actions / frontend-test

The 'contentNavItems' conditional could make the dependencies of useMemo Hook (at line 46) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of 'contentNavItems' in its own useMemo() Hook
const BrandIcon = getIconComponent(headerContent?.brand?.icon, 'Terminal')
const ctaContent = headerContent?.cta ?? {}
const CTAIcon = getIconComponent(ctaContent.icon, 'Lock')
Expand Down Expand Up @@ -82,6 +82,75 @@
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 })
Expand All @@ -94,25 +163,44 @@
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-20">
{}
<div className="flex items-center space-x-3">
<a
href="/"
onClick={handleBrandClick}
className="flex items-center space-x-3 rounded-xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500"
aria-label="Zur Startseite"
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-600 text-white shadow-sm">
<BrandIcon className="w-6 h-6" />
</div>
<span className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{headerContent?.brand?.name || 'Linux Tutorial'}
</span>
</div>
</a>
{}
<div className="hidden md:flex items-center space-x-8">
{computedNavItems.map((item, index) => (
<button
key={item.id || `${item.label ?? 'nav'}-${index}`}
onClick={() => handleNavigation(item)}
className="nav-link font-medium hover:text-primary-600 dark:text-gray-300 dark:hover:text-primary-400"
>
{item.label}
</button>
))}
{computedNavItems.map((item, index) => {
const href = getNavHref(item)
const external = isExternalHref(href)
const special = isSpecialProtocol(href)
const ariaCurrent = !href || href.startsWith('#') || external || special
? undefined
: location.pathname === href
? 'page'
: undefined
return (
<a
key={item.id || `${item.label ?? 'nav'}-${index}`}
href={href}
onClick={(event) => handleNavClick(event, item)}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
className="nav-link font-medium hover:text-primary-600 dark:text-gray-300 dark:hover:text-primary-400"
aria-current={ariaCurrent}
>
{item.label}
</a>
)
})}
<button
onClick={() => setSearchOpen(true)}
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
Expand All @@ -139,40 +227,44 @@
<button
className="p-2 rounded-md text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label={mobileMenuOpen ? 'Navigation schließen' : 'Navigation öffnen'}
>
{mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
{mobileMenuOpen ? (
<X className="w-5 h-5" />
) : (
<Menu className="w-5 h-5" />
)}
</button>
</div>
</div>
{}
{mobileMenuOpen && (
<div className="md:hidden pb-4 space-y-2">
{computedNavItems.map((item, index) => (
<button
key={item.id || `${item.label ?? 'nav'}-${index}`}
onClick={() => {
handleNavigation(item)
setMobileMenuOpen(false)
}}
className="block w-full text-left px-4 py-2 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-primary-600 dark:hover:text-primary-400"
>
{item.label}
</button>
))}
{computedNavItems.map((item, index) => {
const href = getNavHref(item)
const external = isExternalHref(href)
return (
<a
key={item.id || `${item.label ?? 'nav'}-${index}`}
href={href}
onClick={(event) => handleNavClick(event, item)}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
className="block w-full text-left px-4 py-2 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-primary-600 dark:hover:text-primary-400"
>
{item.label}
</a>
)
})}
<button
onClick={() => {
handleCtaClick()
setMobileMenuOpen(false)
}}
className="btn-primary w-full justify-center"
onClick={handleCtaClick}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-primary-600 to-primary-700 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:from-primary-700 hover:to-primary-800"
>
<CTAIcon className="w-4 h-4" />
<span>{isAuthenticated ? ctaContent.authLabel || 'Admin' : ctaContent.guestLabel || 'Login'}</span>
</button>
</div>
)}
</nav>
{}
{searchOpen && <SearchBar onClose={() => setSearchOpen(false)} />}
</header>
)
Expand Down
76 changes: 68 additions & 8 deletions src/components/SearchBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 : []))
Expand All @@ -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([]);
Expand Down Expand Up @@ -49,31 +97,43 @@ const SearchBar = ({ onClose }) => {
navigate(`/tutorials/${id}`);
if (onClose) onClose();
};
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
if (onClose) onClose();
}
};
return (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 z-50 flex items-start justify-center pt-20 px-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col">
<div
className="fixed inset-0 bg-black/50 dark:bg-black/70 z-50 flex items-start justify-center pt-20 px-4"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget && onClose) {
onClose();
}
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="search-dialog-title"
className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col"
>
{}
<div className="p-4 border-b dark:border-gray-700">
<h2 id="search-dialog-title" className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Tutorialsuche
</h2>
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
ref={inputRef}
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"
/>
{onClose && (
<button
onClick={onClose}
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
aria-label="Suche schließen"
>
<X className="w-5 h-5 text-gray-500" />
</button>
Expand Down
Loading
Loading