refactor: standardize types across components, improve theme and language handling. add sticky menu and navigation section indicator

This commit is contained in:
Sascha 2026-02-02 14:36:52 +01:00
parent e8c7d3d90d
commit b389effd5e
13 changed files with 161 additions and 47 deletions

View File

@ -6,13 +6,14 @@ import Footer from '../components/layout/Footer';
import HomePage from '../pages/HomePage';
import ImprintPage from '../pages/ImprintPage';
import PrivacyPolicy from '../pages/PrivacyPolicy';
import type { Theme } from '../data/types';
function AppRouter() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [theme, setTheme] = useState<Theme>('light');
useEffect(() => {
// Check for saved theme preference or default to light mode
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null;
const savedTheme = localStorage.getItem('theme') as Theme | null;
if (savedTheme) {
setTheme(savedTheme);
}

View File

@ -1,10 +1,11 @@
import { useLanguage } from '../contexts/LanguageContext';
import type { Language } from '../data/types';
import '../scss/language-toggle.scss';
export default function LanguageToggle() {
const { language, setLanguage } = useLanguage();
const handleLanguageChange = (newLang: 'en' | 'de') => {
const handleLanguageChange = (newLang: Language) => {
if (newLang !== language) {
setLanguage(newLang);
// Announce language change to screen readers

View File

@ -1,9 +1,10 @@
import React from 'react';
import { useLanguage } from '../contexts/LanguageContext';
import type { Theme } from '../data/types';
type Props = Readonly<{
theme: 'light' | 'dark';
setTheme: React.Dispatch<React.SetStateAction<'light' | 'dark'>>;
theme: Theme;
setTheme: React.Dispatch<React.SetStateAction<Theme>>;
}>;
export default function ThemeToggle({

View File

@ -1,12 +1,14 @@
import { useEffect, useRef } from 'react';
import type { MenuItem } from '../../data/types';
type Props = {
readonly menuItems: string[];
readonly menuItems: MenuItem[];
readonly isOpen: boolean;
readonly onItemClick: (section: string) => void;
readonly onClose: () => void;
};
export default function MobileMenu({ menuItems, isOpen, onItemClick }: Props) {
export default function MobileMenu({ menuItems, isOpen, onItemClick, onClose }: Props) {
const firstButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
@ -19,17 +21,29 @@ export default function MobileMenu({ menuItems, isOpen, onItemClick }: Props) {
return (
<nav id="mobile-menu" className="navbar__mobile-menu" role="navigation" aria-label="Mobile navigation">
<div className="navbar__mobile-menu__header">
<button
onClick={onClose}
className="navbar__mobile-menu__close"
type="button"
aria-label="Close menu"
>
×
</button>
</div>
<div className="navbar__mobile-menu__items">
{menuItems.map((item, index) => (
<button
key={item}
key={item.label}
ref={index === 0 ? firstButtonRef : undefined}
onClick={() => onItemClick(item)}
onClick={() => onItemClick(item.section)}
className="navbar__mobile-menu__item"
type="button"
>
{item}
{item.label}
</button>
))}
</div>
</nav>
);
}

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import ThemeToggle from '../ThemeToggle';
import LanguageToggle from '../LanguageToggle';
@ -6,10 +6,11 @@ import MobileMenu from './MobileMenu';
import { personalConfig } from '../../config/personal';
import { useLanguage } from '../../contexts/LanguageContext';
import { scrollToSection } from '../../utils/scrollUtils';
import type { Theme } from '../../data/types';
type Props = {
theme: 'light' | 'dark';
setTheme: React.Dispatch<React.SetStateAction<'light' | 'dark'>>;
theme: Theme;
setTheme: React.Dispatch<React.SetStateAction<Theme>>;
};
export default function Navbar({
@ -17,12 +18,39 @@ export default function Navbar({
setTheme
}: Readonly<Props>) {
const [menuOpen, setMenuOpen] = useState(false);
const [activeSection, setActiveSection] = useState<string>('');
const location = useLocation();
const navigate = useNavigate();
const { texts } = useLanguage();
const menuItems = texts.navigation.menuItems;
// Track active section based on scroll position
useEffect(() => {
if (location.pathname !== '/') {
setActiveSection('');
return;
}
const handleScroll = () => {
const sections = menuItems.map(item => item.section.toLowerCase());
const scrollPosition = window.scrollY + 100; // Offset for navbar height
for (const section of sections.reverse()) {
const element = document.getElementById(section);
if (element && element.offsetTop <= scrollPosition) {
setActiveSection(section);
return;
}
}
setActiveSection('');
};
handleScroll(); // Initial check
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [location.pathname, menuItems]);
function handleNavigation(section: string): void {
const sectionId = section.toLowerCase();
scrollToSection(sectionId, location.pathname, navigate);
@ -37,11 +65,11 @@ export default function Navbar({
<div className="navbar__container">
{menuItems.map((item) => (
<button
key={item}
onClick={() => handleNavigation(item)}
className="navbar__container__button"
key={item.label}
onClick={() => handleNavigation(item.section)}
className={`navbar__container__button ${activeSection === item.section.toLowerCase() ? 'navbar__container__button--active' : ''}`}
>
{item}
{item.label}
</button>
))}
<LanguageToggle />
@ -62,6 +90,7 @@ export default function Navbar({
menuItems={menuItems}
isOpen={menuOpen}
onItemClick={handleNavigation}
onClose={() => setMenuOpen(false)}
/>
</div>
</div>

View File

@ -1,7 +1,7 @@
export const contact = {
title: 'Kontakt aufnehmen',
subtitle: 'Lassen Sie uns Ihr Projekt besprechen und Ihre Ideen zum Leben erwecken',
connectTitle: 'Lassen Sie uns verbinden',
subtitle: 'Besprechen Sie Ihr Projekt mit mir und wir erwecken Ihre Ideen zum Leben.',
connectTitle: 'Verbindung schaffen',
connectDescription:
'Ich bin immer an neuen Möglichkeiten und spannenden Projekten interessiert. Ob Sie eine Frage haben oder einfach nur Hallo sagen möchten, zögern Sie nicht, mich zu kontaktieren!',
buttonText: 'Per E-Mail kontaktieren',

View File

@ -1,11 +1,11 @@
export const navigation = {
menuItems: [
'Über mich',
'Leistungen',
'Fähigkeiten',
'Zertifikate',
'Projekte',
'Kontakt',
{ section: 'About', label: 'Über mich' },
{ section: 'Services', label: 'Leistungen' },
{ section: 'Skills', label: 'Fähigkeiten' },
{ section: 'Certifications', label: 'Zertifikate' },
{ section: 'Projects', label: 'Projekte' },
{ section: 'Contact', label: 'Kontakt' },
],
mobileMenuAriaLabel: 'Menü öffnen',
};

View File

@ -11,12 +11,13 @@ import { contact } from './contact';
import { imprint } from './imprint';
import { privacyPolicy } from './privacy-policy';
import { accessibility } from './accessibility';
import type { MenuItem } from '../../../data/types';
// TypeScript interfaces for type safety
export interface TextConfig {
// Navigation
navigation: {
menuItems: string[];
menuItems: MenuItem[];
mobileMenuAriaLabel: string;
};
@ -153,7 +154,7 @@ export interface TextConfig {
dataProtectionOfficerText: string;
rightsTitle: string;
rightsIntro: string;
rights: Array<{ title: string; text: string }>;
rights: Array<{ title: string; text: string; }>;
revocationTitle: string;
revocationText: string;
objectionTitle: string;

View File

@ -1,11 +1,11 @@
export const navigation = {
menuItems: [
'About',
'Services',
'Skills',
'Certifications',
'Projects',
'Contact',
{ section: 'About', label: 'About' },
{ section: 'Services', label: 'Services' },
{ section: 'Skills', label: 'Skills' },
{ section: 'Certifications', label: 'Certifications' },
{ section: 'Projects', label: 'Projects' },
{ section: 'Contact', label: 'Contact' },
],
mobileMenuAriaLabel: 'Menü öffnen',
};

View File

@ -1,8 +1,7 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { en, type TextConfig } from '../config/locales/en';
import { de } from '../config/locales/de';
type Language = 'en' | 'de';
import type { Language } from '../data/types';
interface LanguageContextType {
language: Language;
@ -30,7 +29,7 @@ function getSavedLanguage(): Language | null {
return null;
}
export function LanguageProvider({ children }: { children: ReactNode }) {
export function LanguageProvider({ children }: { children: ReactNode; }) {
const [language, setLanguageState] = useState<Language>(() => {
// First check localStorage, then browser language, then default to English
return getSavedLanguage() || getBrowserLanguage();

15
src/data/types.ts Normal file
View File

@ -0,0 +1,15 @@
/**
* Common types used across the application
*/
// Navigation types
export interface MenuItem {
section: string;
label: string;
}
// Language types
export type Language = 'en' | 'de';
// Theme types
export type Theme = 'light' | 'dark';

View File

@ -7,6 +7,10 @@
background: var(--color-background);
color: var(--color-text);
height: 65px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&__name {
font-weight: bold;
@ -25,12 +29,19 @@
@extend %hover-lift;
padding: 0.6rem 1.2rem;
border-radius: 8px;
transition: box-shadow 0.2s ease, transform 0.2s ease;
transition:
box-shadow 0.2s ease,
transform 0.2s ease;
&:active {
box-shadow: 0 2px 4px var(--box-shadow-active);
transform: translateY(0);
}
&--active {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
background: var(--box-shadow-hover, rgba(0, 0, 0, 0.05));
}
}
}
@ -74,23 +85,55 @@
/* Mobile Menu Dropdown */
.navbar__mobile-menu {
@include globals.flex-center();
position: absolute;
top: 65px;
right: 0;
background: var(--color-background);
border: 1px solid var(--color-primary);
border-radius: 8px;
padding: 0.5em;
gap: 0.5em;
padding: 0;
box-shadow: 0 4px 12px var(--box-shadow-hover);
z-index: 10;
min-width: 200px;
button {
&__header {
display: flex;
justify-content: flex-end;
padding: 0.5em;
border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
}
&__close {
@extend %button-reset;
padding: 0.5em 1em;
text-align: right;
font-size: 1.5rem;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s;
color: var(--color-text);
&:hover {
background: var(--box-shadow-active);
}
}
&__items {
display: flex;
flex-direction: column;
gap: 0;
padding: 0.5em;
}
&__item {
@extend %button-reset;
padding: 0.75em 1em;
text-align: left;
border-radius: 4px;
transition: background 0.2s;
width: 100%;
&:hover {
background: var(--box-shadow-active);

View File

@ -446,6 +446,16 @@
}
}
// Responsive layout for screens smaller than 1050px
@media (max-width: 1050px) {
.projects-section {
&__actions {
flex-direction: column;
gap: 0.5rem;
}
}
}
// Dark theme adjustments for help section:
@media (prefers-color-scheme: dark) {
.projects-section {