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 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
{menuItems.map((item, index) => (
|
<div className="navbar__mobile-menu__header">
|
||||||
<button
|
<button
|
||||||
key={item}
|
onClick={onClose}
|
||||||
ref={index === 0 ? firstButtonRef : undefined}
|
className="navbar__mobile-menu__close"
|
||||||
onClick={() => onItemClick(item)}
|
|
||||||
className="navbar__mobile-menu__item"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Close menu"
|
||||||
>
|
>
|
||||||
{item}
|
×
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
|
<div className="navbar__mobile-menu__items">
|
||||||
|
{menuItems.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
ref={index === 0 ? firstButtonRef : undefined}
|
||||||
|
onClick={() => onItemClick(item.section)}
|
||||||
|
className="navbar__mobile-menu__item"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue