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 HomePage from '../pages/HomePage';
import ImprintPage from '../pages/ImprintPage'; import ImprintPage from '../pages/ImprintPage';
import PrivacyPolicy from '../pages/PrivacyPolicy'; import PrivacyPolicy from '../pages/PrivacyPolicy';
import type { Theme } from '../data/types';
function AppRouter() { function AppRouter() {
const [theme, setTheme] = useState<'light' | 'dark'>('light'); const [theme, setTheme] = useState<Theme>('light');
useEffect(() => { useEffect(() => {
// Check for saved theme preference or default to light mode // 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) { if (savedTheme) {
setTheme(savedTheme); setTheme(savedTheme);
} }

View File

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

View File

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

View File

@ -1,12 +1,14 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import type { MenuItem } from '../../data/types';
type Props = { type Props = {
readonly menuItems: string[]; readonly menuItems: MenuItem[];
readonly isOpen: boolean; readonly isOpen: boolean;
readonly onItemClick: (section: string) => void; 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); const firstButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => { useEffect(() => {
@ -19,17 +21,29 @@ export default function MobileMenu({ menuItems, isOpen, onItemClick }: Props) {
return ( return (
<nav id="mobile-menu" className="navbar__mobile-menu" role="navigation" aria-label="Mobile navigation"> <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) => ( {menuItems.map((item, index) => (
<button <button
key={item} key={item.label}
ref={index === 0 ? firstButtonRef : undefined} ref={index === 0 ? firstButtonRef : undefined}
onClick={() => onItemClick(item)} onClick={() => onItemClick(item.section)}
className="navbar__mobile-menu__item" className="navbar__mobile-menu__item"
type="button" type="button"
> >
{item} {item.label}
</button> </button>
))} ))}
</div>
</nav> </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 { Link, useLocation, useNavigate } from 'react-router-dom';
import ThemeToggle from '../ThemeToggle'; import ThemeToggle from '../ThemeToggle';
import LanguageToggle from '../LanguageToggle'; import LanguageToggle from '../LanguageToggle';
@ -6,10 +6,11 @@ import MobileMenu from './MobileMenu';
import { personalConfig } from '../../config/personal'; import { personalConfig } from '../../config/personal';
import { useLanguage } from '../../contexts/LanguageContext'; import { useLanguage } from '../../contexts/LanguageContext';
import { scrollToSection } from '../../utils/scrollUtils'; import { scrollToSection } from '../../utils/scrollUtils';
import type { Theme } from '../../data/types';
type Props = { type Props = {
theme: 'light' | 'dark'; theme: Theme;
setTheme: React.Dispatch<React.SetStateAction<'light' | 'dark'>>; setTheme: React.Dispatch<React.SetStateAction<Theme>>;
}; };
export default function Navbar({ export default function Navbar({
@ -17,12 +18,39 @@ export default function Navbar({
setTheme setTheme
}: Readonly<Props>) { }: Readonly<Props>) {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [activeSection, setActiveSection] = useState<string>('');
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { texts } = useLanguage(); const { texts } = useLanguage();
const menuItems = texts.navigation.menuItems; 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 { function handleNavigation(section: string): void {
const sectionId = section.toLowerCase(); const sectionId = section.toLowerCase();
scrollToSection(sectionId, location.pathname, navigate); scrollToSection(sectionId, location.pathname, navigate);
@ -37,11 +65,11 @@ export default function Navbar({
<div className="navbar__container"> <div className="navbar__container">
{menuItems.map((item) => ( {menuItems.map((item) => (
<button <button
key={item} key={item.label}
onClick={() => handleNavigation(item)} onClick={() => handleNavigation(item.section)}
className="navbar__container__button" className={`navbar__container__button ${activeSection === item.section.toLowerCase() ? 'navbar__container__button--active' : ''}`}
> >
{item} {item.label}
</button> </button>
))} ))}
<LanguageToggle /> <LanguageToggle />
@ -62,6 +90,7 @@ export default function Navbar({
menuItems={menuItems} menuItems={menuItems}
isOpen={menuOpen} isOpen={menuOpen}
onItemClick={handleNavigation} onItemClick={handleNavigation}
onClose={() => setMenuOpen(false)}
/> />
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
export const contact = { export const contact = {
title: 'Kontakt aufnehmen', title: 'Kontakt aufnehmen',
subtitle: 'Lassen Sie uns Ihr Projekt besprechen und Ihre Ideen zum Leben erwecken', subtitle: 'Besprechen Sie Ihr Projekt mit mir und wir erwecken Ihre Ideen zum Leben.',
connectTitle: 'Lassen Sie uns verbinden', connectTitle: 'Verbindung schaffen',
connectDescription: 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!', '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', buttonText: 'Per E-Mail kontaktieren',

View File

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

View File

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

View File

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

View File

@ -1,8 +1,7 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { en, type TextConfig } from '../config/locales/en'; import { en, type TextConfig } from '../config/locales/en';
import { de } from '../config/locales/de'; import { de } from '../config/locales/de';
import type { Language } from '../data/types';
type Language = 'en' | 'de';
interface LanguageContextType { interface LanguageContextType {
language: Language; language: Language;
@ -30,7 +29,7 @@ function getSavedLanguage(): Language | null {
return null; return null;
} }
export function LanguageProvider({ children }: { children: ReactNode }) { export function LanguageProvider({ children }: { children: ReactNode; }) {
const [language, setLanguageState] = useState<Language>(() => { const [language, setLanguageState] = useState<Language>(() => {
// First check localStorage, then browser language, then default to English // First check localStorage, then browser language, then default to English
return getSavedLanguage() || getBrowserLanguage(); 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); background: var(--color-background);
color: var(--color-text); color: var(--color-text);
height: 65px; height: 65px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&__name { &__name {
font-weight: bold; font-weight: bold;
@ -25,12 +29,19 @@
@extend %hover-lift; @extend %hover-lift;
padding: 0.6rem 1.2rem; padding: 0.6rem 1.2rem;
border-radius: 8px; border-radius: 8px;
transition: box-shadow 0.2s ease, transform 0.2s ease; transition:
box-shadow 0.2s ease,
transform 0.2s ease;
&:active { &:active {
box-shadow: 0 2px 4px var(--box-shadow-active); box-shadow: 0 2px 4px var(--box-shadow-active);
transform: translateY(0); 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 */ /* Mobile Menu Dropdown */
.navbar__mobile-menu { .navbar__mobile-menu {
@include globals.flex-center();
position: absolute; position: absolute;
top: 65px;
right: 0; right: 0;
background: var(--color-background); background: var(--color-background);
border: 1px solid var(--color-primary); border: 1px solid var(--color-primary);
border-radius: 8px; border-radius: 8px;
padding: 0.5em; padding: 0;
gap: 0.5em;
box-shadow: 0 4px 12px var(--box-shadow-hover); box-shadow: 0 4px 12px var(--box-shadow-hover);
z-index: 10; 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; @extend %button-reset;
padding: 0.5em 1em; font-size: 1.5rem;
text-align: right; width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px; border-radius: 4px;
transition: background 0.2s; 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 { &:hover {
background: var(--box-shadow-active); 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: // Dark theme adjustments for help section:
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.projects-section { .projects-section {