zweisprachigkeit implementiert

This commit is contained in:
Sascha Bach 2025-11-26 12:21:04 +01:00
parent c587bec2c7
commit d99c0e2429
19 changed files with 215 additions and 39 deletions

View File

@ -1,7 +1,12 @@
import { LanguageProvider } from '../contexts/LanguageContext';
import AppRouter from './AppRouter'; import AppRouter from './AppRouter';
function PortfolioApp() { function PortfolioApp() {
return <AppRouter />; return (
<LanguageProvider>
<AppRouter />
</LanguageProvider>
);
} }
export default PortfolioApp; export default PortfolioApp;

View File

@ -0,0 +1,43 @@
import { useLanguage } from '../contexts/LanguageContext';
import '../scss/language-toggle.scss';
export default function LanguageToggle() {
const { language, setLanguage } = useLanguage();
const handleLanguageChange = (newLang: 'en' | 'de') => {
if (newLang !== language) {
setLanguage(newLang);
// Announce language change to screen readers
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', 'polite');
announcement.setAttribute('class', 'sr-only');
announcement.textContent = `Language changed to ${newLang === 'en' ? 'English' : 'Deutsch'}`;
document.body.appendChild(announcement);
setTimeout(() => announcement.remove(), 1000);
}
};
return (
<div className="language-toggle" role="group" aria-label="Language selection">
<button
className={`language-toggle__button ${language === 'en' ? 'language-toggle__button--active' : ''}`}
onClick={() => handleLanguageChange('en')}
aria-label="Switch to English"
aria-pressed={language === 'en'}
type="button"
>
EN
</button>
<button
className={`language-toggle__button ${language === 'de' ? 'language-toggle__button--active' : ''}`}
onClick={() => handleLanguageChange('de')}
aria-label="Zu Deutsch wechseln"
aria-pressed={language === 'de'}
type="button"
>
DE
</button>
</div>
);
}

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { getTexts } from '../config/texts'; import { useLanguage } from '../contexts/LanguageContext';
type Props = Readonly<{ type Props = Readonly<{
theme: 'light' | 'dark'; theme: 'light' | 'dark';
@ -10,7 +10,7 @@ export default function ThemeToggle({
theme, theme,
setTheme setTheme
}: Props) { }: Props) {
const texts = getTexts(); const { texts } = useLanguage();
const toggleTheme = () => { const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light'); setTheme(theme === 'light' ? 'dark' : 'light');

View File

@ -1,10 +1,10 @@
import { Github, Linkedin, Mail } from 'lucide-react'; import { Github, Linkedin, Mail } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { personalConfig, createEmailLink } from '../../config/personal'; import { personalConfig, createEmailLink } from '../../config/personal';
import { getTexts } from '../../config/texts'; import { useLanguage } from '../../contexts/LanguageContext';
export default function Footer() { export default function Footer() {
const texts = getTexts(); const { texts } = useLanguage();
// Email obfuscation function using config // Email obfuscation function using config
const handleEmailClick = (e: React.MouseEvent) => { const handleEmailClick = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();

View File

@ -1,9 +1,10 @@
import { useState } from 'react'; import { useState } 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 MobileMenu from './MobileMenu'; import MobileMenu from './MobileMenu';
import { personalConfig } from '../../config/personal'; import { personalConfig } from '../../config/personal';
import { getTexts } from '../../config/texts'; import { useLanguage } from '../../contexts/LanguageContext';
import { scrollToSection } from '../../utils/scrollUtils'; import { scrollToSection } from '../../utils/scrollUtils';
type Props = { type Props = {
@ -18,7 +19,7 @@ export default function Navbar({
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const texts = getTexts(); const { texts } = useLanguage();
const menuItems = texts.navigation.menuItems; const menuItems = texts.navigation.menuItems;
@ -43,6 +44,7 @@ export default function Navbar({
{item} {item}
</button> </button>
))} ))}
<LanguageToggle />
<ThemeToggle theme={theme} setTheme={setTheme} /> <ThemeToggle theme={theme} setTheme={setTheme} />
{/* Burger Button für Mobile */} {/* Burger Button für Mobile */}
<button <button

View File

@ -1,5 +1,5 @@
import { Code, Palette, ShieldCheck, Eye } from 'lucide-react'; import { Code, Palette, ShieldCheck, Eye } from 'lucide-react';
import { getTexts } from '../../config/texts'; import { useLanguage } from '../../contexts/LanguageContext';
import type { Service } from '../../data/Service'; import type { Service } from '../../data/Service';
import saschaImage from '../../assets/sascha.png'; import saschaImage from '../../assets/sascha.png';
@ -13,8 +13,9 @@ interface AboutSectionProps {
} }
export default function AboutSection(props: AboutSectionProps = {}) { export default function AboutSection(props: AboutSectionProps = {}) {
const texts = getTexts().about; const { texts: allTexts } = useLanguage();
const accessibilityTexts = getTexts().accessibility; const texts = allTexts.about;
const accessibilityTexts = allTexts.accessibility;
// Use centralized texts as defaults, allow props to override // Use centralized texts as defaults, allow props to override
const { const {

View File

@ -1,5 +1,5 @@
import { Award } from 'lucide-react'; import { Award } from 'lucide-react';
import { getTexts } from '../../config/texts'; import { useLanguage } from '../../contexts/LanguageContext';
import type { Certification } from '../../data/Certification'; import type { Certification } from '../../data/Certification';
interface CertificationsSectionProps { interface CertificationsSectionProps {
@ -9,8 +9,9 @@ interface CertificationsSectionProps {
} }
export default function CertificationsSection(props: CertificationsSectionProps = {}) { export default function CertificationsSection(props: CertificationsSectionProps = {}) {
const texts = getTexts().certifications; const { texts: allTexts } = useLanguage();
const accessibilityTexts = getTexts().accessibility; const texts = allTexts.certifications;
const accessibilityTexts = allTexts.accessibility;
// Use centralized texts as defaults, allow props to override // Use centralized texts as defaults, allow props to override
const { const {

View File

@ -1,6 +1,6 @@
import { Mail, ExternalLink, Send } from 'lucide-react'; import { Mail, ExternalLink, Send } from 'lucide-react';
import { personalConfig, getObfuscatedEmail } from '../../config/personal'; import { personalConfig, getObfuscatedEmail } from '../../config/personal';
import { getTexts } from '../../config/texts'; import { useLanguage } from '../../contexts/LanguageContext';
interface ContactSectionProps { interface ContactSectionProps {
title?: string; title?: string;
@ -19,7 +19,8 @@ interface ContactSectionProps {
} }
export default function ContactSection(props: ContactSectionProps = {}) { export default function ContactSection(props: ContactSectionProps = {}) {
const texts = getTexts().contact; const { texts: allTexts } = useLanguage();
const texts = allTexts.contact;
const { const {
title = texts.title, title = texts.title,

View File

@ -2,7 +2,7 @@ import { Download } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import type { StatItem } from '../../data/StatItem'; import type { StatItem } from '../../data/StatItem';
import { scrollToProjects } from '../../utils/scrollUtils'; import { scrollToProjects } from '../../utils/scrollUtils';
import { getTexts } from '../../config/texts'; import { useLanguage } from '../../contexts/LanguageContext';
import resumePDF from '../../assets/CV_Sascha_Bach.pdf'; import resumePDF from '../../assets/CV_Sascha_Bach.pdf';
interface HeroSectionProps { interface HeroSectionProps {
@ -15,8 +15,9 @@ interface HeroSectionProps {
} }
export default function HeroSection(props: HeroSectionProps = {}) { export default function HeroSection(props: HeroSectionProps = {}) {
const texts = getTexts().hero; const { texts: allTexts } = useLanguage();
const accessibilityTexts = getTexts().accessibility; const texts = allTexts.hero;
const accessibilityTexts = allTexts.accessibility;
// Use centralized texts as defaults, allow props to override // Use centralized texts as defaults, allow props to override
const { const {

View File

@ -1,6 +1,6 @@
import { GitBranch, ExternalLink } from 'lucide-react'; import { GitBranch, ExternalLink } from 'lucide-react';
import { useState, useRef, useEffect, type KeyboardEvent } from 'react'; import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
import { getTexts } from '../../config/texts'; import { useLanguage } from '../../contexts/LanguageContext';
import { projectsData } from '../../config/projects-config'; import { projectsData } from '../../config/projects-config';
import type { Project } from '../../data/Project'; import type { Project } from '../../data/Project';
@ -12,8 +12,9 @@ interface ProjectsSectionProps {
} }
export default function ProjectsSection(props: ProjectsSectionProps = {}) { export default function ProjectsSection(props: ProjectsSectionProps = {}) {
const texts = getTexts().projects; const { texts: allTexts } = useLanguage();
const accessibilityTexts = getTexts().accessibility; const texts = allTexts.projects;
const accessibilityTexts = allTexts.accessibility;
const [activeCardIndex, setActiveCardIndex] = useState<number | null>(null); const [activeCardIndex, setActiveCardIndex] = useState<number | null>(null);
const [focusedActionIndex, setFocusedActionIndex] = useState<number>(0); const [focusedActionIndex, setFocusedActionIndex] = useState<number>(0);
const cardRefs = useRef<(HTMLElement | null)[]>([]); const cardRefs = useRef<(HTMLElement | null)[]>([]);

View File

@ -1,5 +1,5 @@
import { Eye, Lightbulb, Palette, Laptop, Rocket, ShieldCheck } from 'lucide-react'; import { Eye, Lightbulb, Palette, Laptop, Rocket, ShieldCheck } from 'lucide-react';
import { getTexts } from '../../config/texts'; import { useLanguage } from '../../contexts/LanguageContext';
import type { Service } from '../../data/Service'; import type { Service } from '../../data/Service';
interface ServicesSectionProps { interface ServicesSectionProps {
@ -9,8 +9,9 @@ interface ServicesSectionProps {
} }
export default function ServicesSection(props: ServicesSectionProps = {}) { export default function ServicesSection(props: ServicesSectionProps = {}) {
const texts = getTexts().services; const { texts: allTexts } = useLanguage();
const accessibilityTexts = getTexts().accessibility; const texts = allTexts.services;
const accessibilityTexts = allTexts.accessibility;
// Use centralized texts as defaults, allow props to override // Use centralized texts as defaults, allow props to override
const { const {

View File

@ -1,4 +1,4 @@
import { getTexts } from '../../config/texts'; import { useLanguage } from '../../contexts/LanguageContext';
import type { Skill } from '../../data/Skill'; import type { Skill } from '../../data/Skill';
interface SkillCategory { interface SkillCategory {
@ -27,8 +27,9 @@ const getColorByPosition = (index: number): string => {
}; };
export default function SkillsSection(props: SkillsSectionProps = {}) { export default function SkillsSection(props: SkillsSectionProps = {}) {
const texts = getTexts().skills; const { texts: allTexts } = useLanguage();
const accessibilityTexts = getTexts().accessibility; const texts = allTexts.skills;
const accessibilityTexts = allTexts.accessibility;
// Use centralized texts as defaults, allow props to override // Use centralized texts as defaults, allow props to override
const { const {

View File

@ -209,23 +209,23 @@ export interface TextConfig {
privacyPolicy: { privacyPolicy: {
title: string; title: string;
lastUpdated: string; lastUpdated: string;
introTitle: string;
introText: string;
dataCollectionTitle: string; dataCollectionTitle: string;
dataCollectionText: string; dataCollectionText: string;
dataCollectionList: string[]; dataCollectionList: string[];
purposeTitle: string; webhostingTitle: string;
purposeText: string; webhostingText: string;
providerTitle: string; webhostingProvider: string;
providerText: string; webhostingProviderName: string;
retentionTitle: string; webhostingProviderAddress: string;
retentionText: string; webhostingProviderWebsite: string;
rightsTitle: string; rightsTitle: string;
rightsText: string; rightsText: string;
rightsList: string[]; rightsList: string[];
contactTitle: string; contactTitle: string;
contactText: string; contactText: string;
contactEmail: string; contactEmail: string;
legalBasisTitle: string;
legalBasisText: string;
}; };
// Accessibility and Navigation // Accessibility and Navigation

View File

@ -2,7 +2,6 @@ import portfolioImage from '../assets/portfolio.PNG';
import ergoVRImage from '../assets/ErgoVR.PNG'; import ergoVRImage from '../assets/ErgoVR.PNG';
import icaraceImage from '../assets/icarace.PNG'; import icaraceImage from '../assets/icarace.PNG';
import dancaAlegriaImage from '../assets/Danca-Alegria.png'; import dancaAlegriaImage from '../assets/Danca-Alegria.png';
import { personalConfig } from './personal';
import { projects } from './locales/en/projects'; import { projects } from './locales/en/projects';
import type { Project } from '../data/Project'; import type { Project } from '../data/Project';

View File

@ -0,0 +1,66 @@
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';
interface LanguageContextType {
language: Language;
setLanguage: (lang: Language) => void;
texts: TextConfig;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
const LANGUAGE_KEY = 'preferred-language';
function getBrowserLanguage(): Language {
const browserLang = navigator.language.toLowerCase();
if (browserLang.startsWith('de')) {
return 'de';
}
return 'en'; // Default to English for all other languages
}
function getSavedLanguage(): Language | null {
const saved = localStorage.getItem(LANGUAGE_KEY);
if (saved === 'en' || saved === 'de') {
return saved;
}
return null;
}
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>(() => {
// First check localStorage, then browser language, then default to English
return getSavedLanguage() || getBrowserLanguage();
});
const texts = language === 'de' ? de : en;
const setLanguage = (lang: Language) => {
setLanguageState(lang);
localStorage.setItem(LANGUAGE_KEY, lang);
// Update HTML lang attribute for accessibility and SEO
document.documentElement.lang = lang;
};
useEffect(() => {
// Set initial lang attribute
document.documentElement.lang = language;
}, [language]);
return (
<LanguageContext.Provider value={{ language, setLanguage, texts }}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguage() {
const context = useContext(LanguageContext);
if (context === undefined) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
}

View File

@ -3,7 +3,8 @@ import { personalConfig } from '../config/personal';
import { getTexts } from '../config/texts'; import { getTexts } from '../config/texts';
export default function ImprintPage() { export default function ImprintPage() {
const texts = getTexts().imprint; const { texts: allTexts } = useLanguage();
const texts = allTexts.imprint;
useEffect(() => { useEffect(() => {
// Prevent indexing of this page // Prevent indexing of this page

View File

@ -1,8 +1,9 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { getTexts } from '../config/texts'; import { useLanguage } from '../contexts/LanguageContext';
export default function PrivacyPolicy() { export default function PrivacyPolicy() {
const texts = getTexts().privacyPolicy; const { texts: allTexts } = useLanguage();
const texts = allTexts.privacyPolicy;
useEffect(() => { useEffect(() => {
// Prevent indexing of this page // Prevent indexing of this page

View File

@ -0,0 +1,48 @@
@use './variables' as *;
@use './globals';
.language-toggle {
display: flex;
gap: 0.25rem;
background: transparent;
border: 1px solid var(--color-text-muted);
border-radius: $border-radius-sm;
padding: 0.25rem;
&__button {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
border: none;
border-radius: 0.25rem;
background: transparent;
color: var(--color-text);
cursor: pointer;
transition: all 0.2s ease;
@extend %hover-lift;
&:hover {
color: var(--color-text);
}
&:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
&:active {
box-shadow: 0 2px 4px var(--box-shadow-active);
transform: translateY(0);
}
&--active {
background: var(--color-primary);
color: var(--color-background);
&:hover {
background: var(--color-primary);
color: var(--color-background);
}
}
}
}

View File

@ -16,6 +16,10 @@
} }
&__container { &__container {
display: flex;
align-items: center;
gap: 0.5rem;
&__button { &__button {
@extend %button-reset; @extend %button-reset;
@extend %hover-lift; @extend %hover-lift;