refactor: standardize types across components, improve theme and language handling. add sticky menu and navigation section indicator
This commit is contained in:
parent
e8c7d3d90d
commit
b389effd5e
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue