feat: update social links to use Git instead of Codeberg and enhance project descriptions

This commit is contained in:
Sascha 2026-07-27 13:23:54 +02:00
parent 7cdf122014
commit 91a6180f44
46 changed files with 3347 additions and 299 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "portfolio-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}

View File

@ -2,6 +2,7 @@ import js from '@eslint/js'
import globals from 'globals' import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh' import reactRefresh from 'eslint-plugin-react-refresh'
import jsxA11y from 'eslint-plugin-jsx-a11y'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config' import { globalIgnores } from 'eslint/config'
@ -12,12 +13,22 @@ export default tseslint.config([
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
reactHooks.configs['recommended-latest'], reactHooks.configs.flat['recommended-latest'],
reactRefresh.configs.vite, reactRefresh.configs.vite,
jsxA11y.flatConfigs.recommended,
], ],
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
}, },
{
// Context files intentionally co-locate the Provider component with its
// consumer hook (and small helpers); splitting them for Fast Refresh
// purity isn't worth the extra indirection at this app's size.
files: ['src/contexts/**/*.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
]) ])

View File

@ -5,19 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Primary Meta Tags --> <!-- Primary Meta Tags -->
<title> <!-- title, description, canonical, og:url, og:title/description, twitter:title/description
Sascha Bach | Freelance Software Developer Accessible Web Development are rendered per-route by src/components/PageSeo.tsx (React 19 hoists them into <head>). -->
</title>
<meta
name="description"
content="Freelance software developer in Germany specializing in accessible web development. I build inclusive websites compliant with the Accessibility Act using React, TypeScript, and modern frameworks."
/>
<meta name="author" content="Sascha Bach" /> <meta name="author" content="Sascha Bach" />
<meta <meta
name="keywords" name="keywords"
content="freelance software developer, accessible web development, barrierefreie Webentwicklung, React developer, TypeScript, web accessibility, BFSG, Germany" content="freelance software developer, accessible web development, barrierefreie Webentwicklung, React developer, TypeScript, web accessibility, BFSG, Germany"
/> />
<link rel="canonical" href="https://sascha-bach.de/de/" />
<link <link
rel="alternate" rel="alternate"
type="text/markdown" type="text/markdown"
@ -60,15 +54,6 @@
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content="https://sascha-bach.de/de/" />
<meta
property="og:title"
content="Sascha Bach | Freelance Software Developer"
/>
<meta
property="og:description"
content="Freelance software developer in Germany specializing in accessible web development. Building inclusive websites compliant with the Accessibility Act."
/>
<meta property="og:image" content="https://sascha-bach.de/og-image.png" /> <meta property="og:image" content="https://sascha-bach.de/og-image.png" />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
@ -82,14 +67,6 @@
<!-- Twitter --> <!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta
name="twitter:title"
content="Sascha Bach | Freelance Software Developer | Accessibility WCAG Barrierefreiheitsstärkungsgesetz"
/>
<meta
name="twitter:description"
content="Freelance software developer in Germany specializing in accessible web development. Building inclusive websites compliant with the Accessibility Act and WCAG."
/>
<meta name="twitter:image" content="https://sascha-bach.de/og-image.png" /> <meta name="twitter:image" content="https://sascha-bach.de/og-image.png" />
<meta <meta
name="twitter:image:alt" name="twitter:image:alt"
@ -124,7 +101,7 @@
}, },
"sameAs": [ "sameAs": [
"https://www.linkedin.com/in/saschabach/", "https://www.linkedin.com/in/saschabach/",
"https://codeberg.org/saschab" "https://git.sascha-bach.de/saschabach"
], ],
"worksFor": { "worksFor": {
"@id": "https://sascha-bach.de/#business" "@id": "https://sascha-bach.de/#business"

2977
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@
"build": "tsc -b && vite build && node prerender.js", "build": "tsc -b && vite build && node prerender.js",
"build:no-prerender": "tsc -b && vite build", "build:no-prerender": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest run",
"preview": "vite preview", "preview": "vite preview",
"deploy": "npm run build && node deploy.js", "deploy": "npm run build && node deploy.js",
"deploy:prod": "npm run build && node deploy.js" "deploy:prod": "npm run build && node deploy.js"
@ -20,6 +21,8 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.32.0", "@eslint/js": "^9.32.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/react": "^19.1.9", "@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
@ -29,14 +32,18 @@
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"eslint": "^9.32.0", "eslint": "^9.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.0.0", "eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0", "globals": "^16.3.0",
"jsdom": "^29.1.1",
"puppeteer": "^24.40.0", "puppeteer": "^24.40.0",
"sass": "^1.90.0", "sass": "^1.90.0",
"ssh2-sftp-client": "^12.0.1", "ssh2-sftp-client": "^12.0.1",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.39.0", "typescript-eslint": "^8.39.0",
"vite": "^7.1.0" "vite": "^7.1.0",
"vitest": "^4.1.10",
"vitest-axe": "^0.1.0"
} }
} }

View File

@ -29,7 +29,7 @@
{ {
"id": "find_contact_options", "id": "find_contact_options",
"name": "Find contact options", "name": "Find contact options",
"description": "Locate the preferred direct contact methods, including email, appointment booking, LinkedIn, and Codeberg.", "description": "Locate the preferred direct contact methods, including email, appointment booking, LinkedIn, and Git.",
"examples": [ "examples": [
"How can I contact Sascha Bach?", "How can I contact Sascha Bach?",
"Open the appointment booking page" "Open the appointment booking page"

View File

@ -8,7 +8,7 @@
{ {
"id": "find_contact_options", "id": "find_contact_options",
"name": "Find contact options", "name": "Find contact options",
"description": "Locate direct contact methods including email, booking, LinkedIn, and Codeberg." "description": "Locate direct contact methods including email, booking, LinkedIn, and Git."
}, },
{ {
"id": "navigate_localized_content", "id": "navigate_localized_content",

View File

@ -29,7 +29,7 @@
{ {
"id": "find_contact_options", "id": "find_contact_options",
"name": "Find contact options", "name": "Find contact options",
"description": "Locate the preferred direct contact methods, including email, appointment booking, LinkedIn, and Codeberg.", "description": "Locate the preferred direct contact methods, including email, appointment booking, LinkedIn, and Git.",
"examples": [ "examples": [
"How can I contact Sascha Bach?", "How can I contact Sascha Bach?",
"Open the appointment booking page" "Open the appointment booking page"

View File

@ -55,7 +55,7 @@
"email", "email",
"booking", "booking",
"linkedin", "linkedin",
"codeberg" "git"
], ],
"description": "The contact method to open." "description": "The contact method to open."
} }

View File

@ -41,7 +41,7 @@
"properties": { "properties": {
"method": { "method": {
"type": "string", "type": "string",
"enum": ["email", "booking", "linkedin", "codeberg"], "enum": ["email", "booking", "linkedin", "git"],
"description": "The contact method to open." "description": "The contact method to open."
} }
}, },

View File

@ -25,7 +25,7 @@ This site is the public portfolio of Sascha Bach, a freelance software developer
- For conversion or follow-up, prefer one of these actions: - For conversion or follow-up, prefer one of these actions:
- Open the appointment booking link - Open the appointment booking link
- Compose an email to `freelancer@sascha-bach.de` - Compose an email to `freelancer@sascha-bach.de`
- Open LinkedIn or Codeberg profile links for verification - Open LinkedIn or Git profile links for verification
## Main Topics ## Main Topics

View File

@ -28,13 +28,13 @@ Sascha Bach is a freelance software developer based in Germany. The portfolio em
- Email contact: `freelancer@sascha-bach.de` - Email contact: `freelancer@sascha-bach.de`
- Appointment scheduling: `https://nextcloud.sascha-bach.de/index.php/apps/calendar/appointment/N58i246RSTB2` - Appointment scheduling: `https://nextcloud.sascha-bach.de/index.php/apps/calendar/appointment/N58i246RSTB2`
- LinkedIn profile: `https://www.linkedin.com/in/saschabach/` - LinkedIn profile: `https://www.linkedin.com/in/saschabach/`
- Codeberg profile: `https://codeberg.org/saschab` - Git profile: `https://git.sascha-bach.de/saschabach`
Preferred conversion paths: Preferred conversion paths:
1. Open the appointment booking page. 1. Open the appointment booking page.
2. Compose an email with a project or accessibility inquiry. 2. Compose an email with a project or accessibility inquiry.
3. Review LinkedIn or Codeberg for external verification. 3. Review LinkedIn or Git for external verification.
## Agent instructions ## Agent instructions

View File

@ -16,7 +16,7 @@
- Email: freelancer@sascha-bach.de - Email: freelancer@sascha-bach.de
- Appointment booking: https://nextcloud.sascha-bach.de/index.php/apps/calendar/appointment/N58i246RSTB2 - Appointment booking: https://nextcloud.sascha-bach.de/index.php/apps/calendar/appointment/N58i246RSTB2
- LinkedIn: https://www.linkedin.com/in/saschabach/ - LinkedIn: https://www.linkedin.com/in/saschabach/
- Codeberg: https://codeberg.org/saschab - Git profile: https://git.sascha-bach.de/saschabach
## Guidance for agents ## Guidance for agents

View File

@ -1,15 +1,15 @@
import { useState, useEffect } from 'react'; import { lazy, Suspense, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import '../scss/App.scss'; import '../scss/App.scss';
import Navbar from '../components/layout/Navigation'; import Navbar from '../components/layout/Navigation';
import Footer from '../components/layout/Footer'; import Footer from '../components/layout/Footer';
import HomePage from '../pages/HomePage';
import LandingPage from '../pages/LandingPage';
import ImprintPage from '../pages/ImprintPage';
import PrivacyPolicy from '../pages/PrivacyPolicy';
import BackToTopButton from '../components/BackToTopButton'; import BackToTopButton from '../components/BackToTopButton';
import { getLanguageFromPath, useLanguage } from '../contexts/LanguageContext'; import { getLanguageFromPath, useLanguage } from '../contexts/LanguageContext';
import type { Theme } from '../data/types';
const HomePage = lazy(() => import('../pages/HomePage'));
const LandingPage = lazy(() => import('../pages/LandingPage'));
const ImprintPage = lazy(() => import('../pages/ImprintPage'));
const PrivacyPolicy = lazy(() => import('../pages/PrivacyPolicy'));
/** Syncs the LanguageContext whenever the URL prefix (/de/ or /en/) changes. */ /** Syncs the LanguageContext whenever the URL prefix (/de/ or /en/) changes. */
function LanguageSyncer() { function LanguageSyncer() {
@ -26,7 +26,7 @@ function LanguageSyncer() {
return null; return null;
} }
function AppShell({ theme, setTheme }: Readonly<{ theme: Theme; setTheme: React.Dispatch<React.SetStateAction<Theme>>; }>) { function AppShell() {
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
@ -45,8 +45,9 @@ function AppShell({ theme, setTheme }: Readonly<{ theme: Theme; setTheme: React.
<div className="app"> <div className="app">
<a href="#main-content" className="skip-link">Skip to main content</a> <a href="#main-content" className="skip-link">Skip to main content</a>
<LanguageSyncer /> <LanguageSyncer />
<Navbar theme={theme} setTheme={setTheme} /> <Navbar />
<main id="main-content" className="app__main"> <main id="main-content" className="app__main">
<Suspense fallback={<p role="status" className="sr-only">Loading</p>}>
<Routes> <Routes>
{/* Root redirects → German (primary market) */} {/* Root redirects → German (primary market) */}
<Route path="/" element={<Navigate to="/de/" replace />} /> <Route path="/" element={<Navigate to="/de/" replace />} />
@ -64,6 +65,7 @@ function AppShell({ theme, setTheme }: Readonly<{ theme: Theme; setTheme: React.
<Route path="/imprint" element={<ImprintPage />} /> <Route path="/imprint" element={<ImprintPage />} />
<Route path="/privacy-policy" element={<PrivacyPolicy />} /> <Route path="/privacy-policy" element={<PrivacyPolicy />} />
</Routes> </Routes>
</Suspense>
</main> </main>
<Footer /> <Footer />
<BackToTopButton /> <BackToTopButton />
@ -72,23 +74,9 @@ function AppShell({ theme, setTheme }: Readonly<{ theme: Theme; setTheme: React.
} }
function AppRouter() { function AppRouter() {
const [theme, setTheme] = useState<Theme>('light');
useEffect(() => {
const savedTheme = localStorage.getItem('theme') as Theme | null;
if (savedTheme) {
setTheme(savedTheme);
}
}, []);
useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem('theme', theme);
}, [theme]);
return ( return (
<Router> <Router>
<AppShell theme={theme} setTheme={setTheme} /> <AppShell />
</Router> </Router>
); );
} }

View File

@ -1,4 +1,5 @@
import { LanguageProvider, getLanguageFromPath } from '../contexts/LanguageContext'; import { LanguageProvider, getLanguageFromPath } from '../contexts/LanguageContext';
import { ThemeProvider } from '../contexts/ThemeContext';
import AppRouter from './AppRouter'; import AppRouter from './AppRouter';
function PortfolioApp() { function PortfolioApp() {
@ -6,7 +7,9 @@ function PortfolioApp() {
return ( return (
<LanguageProvider initialLanguage={initialLanguage}> <LanguageProvider initialLanguage={initialLanguage}>
<ThemeProvider>
<AppRouter /> <AppRouter />
</ThemeProvider>
</LanguageProvider> </LanguageProvider>
); );
} }

View File

@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { axe } from 'vitest-axe';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
import { LanguageProvider } from '../../contexts/LanguageContext';
import { ThemeProvider } from '../../contexts/ThemeContext';
import LandingPage from '../../pages/LandingPage';
import HomePage from '../../pages/HomePage';
function renderPage(children: ReactNode) {
return render(
<MemoryRouter>
<LanguageProvider initialLanguage="de">
<ThemeProvider>{children}</ThemeProvider>
</LanguageProvider>
</MemoryRouter>
);
}
describe('accessibility smoke test', () => {
it('LandingPage has no axe violations', async () => {
const { container } = renderPage(<LandingPage />);
expect(await axe(container)).toHaveNoViolations();
});
it('HomePage has no axe violations', async () => {
const { container } = renderPage(<HomePage />);
expect(await axe(container)).toHaveNoViolations();
});
});

View File

@ -0,0 +1,34 @@
interface PageSeoProps {
title: string;
description: string;
canonical?: string;
noIndex?: boolean;
image?: string;
}
const BASE_URL = 'https://sascha-bach.de';
/**
* Renders per-route metadata as plain tags. React 19 hoists title/meta/link
* elements into <head> regardless of where they're rendered in the tree, so
* this can be dropped into any page component without a portal or effect.
*/
export function PageSeo({ title, description, canonical, noIndex, image }: PageSeoProps) {
const imageUrl = image ? (image.startsWith('http') ? image : `${BASE_URL}${image}`) : undefined;
return (
<>
<title>{title}</title>
<meta name="description" content={description} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
{imageUrl && <meta property="og:image" content={imageUrl} />}
{imageUrl && <meta name="twitter:image" content={imageUrl} />}
{canonical && <meta property="og:url" content={`${BASE_URL}${canonical}`} />}
{canonical && <link rel="canonical" href={`${BASE_URL}${canonical}`} />}
{noIndex && <meta name="robots" content="noindex, nofollow" />}
</>
);
}

View File

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

View File

@ -38,8 +38,8 @@ export default function Footer() {
<div className="footer__social"> <div className="footer__social">
<button <button
className="footer__social-button" className="footer__social-button"
onClick={() => window.open(personalConfig.social.codeberg.url, '_blank')} onClick={() => window.open(personalConfig.social.git.url, '_blank')}
aria-label={texts.footer.codebergAriaLabel} aria-label={texts.footer.gitAriaLabel}
> >
<Globe className="footer__social-icon" /> <Globe className="footer__social-icon" />
</button> </button>

View File

@ -6,17 +6,8 @@ import MobileMenu from './MobileMenu';
import logoUrl from '../../assets/logo.svg'; import logoUrl from '../../assets/logo.svg';
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 = { export default function Navbar() {
theme: Theme;
setTheme: React.Dispatch<React.SetStateAction<Theme>>;
};
export default function Navbar({
theme,
setTheme
}: Readonly<Props>) {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [activeSection, setActiveSection] = useState<string>(''); const [activeSection, setActiveSection] = useState<string>('');
const location = useLocation(); const location = useLocation();
@ -29,10 +20,12 @@ export default function Navbar({
? texts.navigation.landingMenuItems ? texts.navigation.landingMenuItems
: texts.navigation.menuItems; : texts.navigation.menuItems;
const isSectionTrackingPage = location.pathname === technicalPath || isLandingPage;
const displayedActiveSection = isSectionTrackingPage ? activeSection : '';
// Track active section based on scroll position // Track active section based on scroll position
useEffect(() => { useEffect(() => {
if (location.pathname !== technicalPath && !isLandingPage) { if (!isSectionTrackingPage) {
setActiveSection('');
return; return;
} }
@ -54,7 +47,7 @@ export default function Navbar({
handleScroll(); // Initial check handleScroll(); // Initial check
window.addEventListener('scroll', handleScroll); window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll);
}, [location.pathname, menuItems]); }, [isSectionTrackingPage, menuItems]);
function handleNavigation(section: string): void { function handleNavigation(section: string): void {
const sectionId = section.toLowerCase(); const sectionId = section.toLowerCase();
@ -81,13 +74,13 @@ export default function Navbar({
<button <button
key={item.label} key={item.label}
onClick={() => handleNavigation(item.section)} onClick={() => handleNavigation(item.section)}
className={`navbar__container__button ${activeSection === item.section.toLowerCase() ? 'navbar__container__button--active' : ''}`} className={`navbar__container__button ${displayedActiveSection === item.section.toLowerCase() ? 'navbar__container__button--active' : ''}`}
> >
{item.label} {item.label}
</button> </button>
))} ))}
<LanguageToggle /> <LanguageToggle />
<ThemeToggle theme={theme} setTheme={setTheme} /> <ThemeToggle />
{/* Burger Button für Mobile */} {/* Burger Button für Mobile */}
<button <button
type="button" type="button"

View File

@ -84,7 +84,7 @@ export default function AboutSection(props: AboutSectionProps = {}) {
</div> </div>
{/* Personal Photo and Bio */} {/* Personal Photo and Bio */}
<main className="about-section__content"> <div className="about-section__content">
<div className="about-section__image-wrapper" ref={wrapperRef}> <div className="about-section__image-wrapper" ref={wrapperRef}>
<img <img
src={profileImage} src={profileImage}
@ -117,7 +117,7 @@ export default function AboutSection(props: AboutSectionProps = {}) {
</div> </div>
</div> </div>
</div> </div>
</main> </div>
{/* Feature Cards Grid */} {/* Feature Cards Grid */}
<FeatureCardsGrid featureCards={featureCards} /> <FeatureCardsGrid featureCards={featureCards} />

View File

@ -1,4 +1,3 @@
// eslint-disable-next-line @typescript-eslint/no-deprecated
import { Mail, Send, Globe, Linkedin, CalendarClock } from 'lucide-react'; import { Mail, Send, Globe, Linkedin, CalendarClock } from 'lucide-react';
import { personalConfig } from '../../config/personal'; import { personalConfig } from '../../config/personal';
import { useLanguage } from '../../contexts/LanguageContext'; import { useLanguage } from '../../contexts/LanguageContext';
@ -71,7 +70,7 @@ export default function ContactSection(props: ContactSectionProps = {}) {
</header> </header>
{/* Contact Content Grid */} {/* Contact Content Grid */}
<main id="contact-form" className="contact-section__grid"> <div id="contact-form" className="contact-section__grid">
{/* Let's Connect Card */} {/* Let's Connect Card */}
<div className="contact-section__connect-card" role="region" aria-labelledby="connect-heading"> <div className="contact-section__connect-card" role="region" aria-labelledby="connect-heading">
<div className="contact-section__connect-header"> <div className="contact-section__connect-header">
@ -161,13 +160,13 @@ export default function ContactSection(props: ContactSectionProps = {}) {
<li className="contact-section__detail-item"> <li className="contact-section__detail-item">
<Globe className="contact-section__social-icon" aria-hidden="true" /> <Globe className="contact-section__social-icon" aria-hidden="true" />
<a <a
href={personalConfig.social.codeberg.url} href={personalConfig.social.git.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="contact-section__detail-link" className="contact-section__detail-link"
aria-label={`${texts.codebergLinkText} (opens in new tab)`} aria-label={`${texts.gitLinkText} (opens in new tab)`}
> >
{texts.codebergLinkText} {texts.gitLinkText}
</a> </a>
</li> </li>
@ -185,7 +184,7 @@ export default function ContactSection(props: ContactSectionProps = {}) {
</li> </li>
</ul> </ul>
</div> </div>
</main> </div>
</div> </div>
</section> </section>
); );

View File

@ -84,7 +84,7 @@ export default function HeroSection(props: HeroSectionProps = {}) {
}; };
return ( return (
<main className={`hero-section${loaded ? ' hero-section--loaded' : ''}`} aria-labelledby="hero-title"> <section className={`hero-section${loaded ? ' hero-section--loaded' : ''}`} aria-labelledby="hero-title">
<div className="hero-section__container"> <div className="hero-section__container">
{/* Skip link for keyboard navigation */} {/* Skip link for keyboard navigation */}
<div className="hero-section__skip"> <div className="hero-section__skip">
@ -166,6 +166,6 @@ export default function HeroSection(props: HeroSectionProps = {}) {
</aside> </aside>
)} )}
</div> </div>
</main> </section>
); );
} }

View File

@ -94,16 +94,22 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
</div> </div>
{/* Projects Grid */} {/* Projects Grid */}
<div className="projects-section__grid" role="grid" aria-label="Projects grid"> <div className="projects-section__grid" aria-label="Projects grid">
{projects.map((project, cardIndex) => { {projects.map((project, cardIndex) => {
const actions = getProjectActions(project); const actions = getProjectActions(project);
const isActive = activeCardIndex === cardIndex; const isActive = activeCardIndex === cardIndex;
return ( return (
<article // The card is a focusable disclosure trigger that reveals its own
// interactive action links, so it can't also carry a widget role
// like "option"/"gridcell" without failing axe's nested-interactive
// check. It keeps tabIndex/key handling without an ARIA role.
/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/no-noninteractive-tabindex */
<div
key={project.id} key={project.id}
className={`projects-section__card ${isActive ? 'projects-section__card--active' : ''}`} className={`projects-section__card ${isActive ? 'projects-section__card--active' : ''}`}
ref={(el) => { cardRefs.current[cardIndex] = el; }} ref={(el) => { cardRefs.current[cardIndex] = el; }}
tabIndex={0}
aria-describedby={`project-${project.id}-description project-${project.id}-metadata`} aria-describedby={`project-${project.id}-description project-${project.id}-metadata`}
aria-label={`${accessibilityTexts.screenReader.projectCard}: ${project.title}. ${cardIndex + 1} of ${projects.length}`} aria-label={`${accessibilityTexts.screenReader.projectCard}: ${project.title}. ${cardIndex + 1} of ${projects.length}`}
onKeyDown={(e) => handleCardKeyDown(e, cardIndex)} onKeyDown={(e) => handleCardKeyDown(e, cardIndex)}
@ -197,7 +203,8 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
</div> </div>
)} )}
</div> </div>
</article> </div>
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/no-noninteractive-tabindex */
); );
})} })}
</div> </div>

View File

@ -18,7 +18,7 @@ export const contact = {
'Hallo Sascha,\n\nIch habe Ihr Portfolio besucht und würde gerne ein potenzielles Projekt oder eine Zusammenarbeit besprechen.\n\nMit freundlichen Grüßen,', 'Hallo Sascha,\n\nIch habe Ihr Portfolio besucht und würde gerne ein potenzielles Projekt oder eine Zusammenarbeit besprechen.\n\nMit freundlichen Grüßen,',
emailAnnouncement: 'E-Mail-Client wird mit vorausgefüllter Nachricht geöffnet', emailAnnouncement: 'E-Mail-Client wird mit vorausgefüllter Nachricht geöffnet',
socialAnnouncement: '{platform}-Profil wird in neuem Tab geöffnet', socialAnnouncement: '{platform}-Profil wird in neuem Tab geöffnet',
codebergLinkText: 'Sascha Bach auf Codeberg', gitLinkText: 'Sascha Bach auf Git',
linkedinLinkText: 'Sascha Bach auf LinkedIn', linkedinLinkText: 'Sascha Bach auf LinkedIn',
appointmentHintText: 'Lieber einen Termin buchen?', appointmentHintText: 'Lieber einen Termin buchen?',
appointmentLinkText: 'Jetzt Termin vereinbaren', appointmentLinkText: 'Jetzt Termin vereinbaren',

View File

@ -3,7 +3,7 @@ export const footer = {
copyrightText: 'Alle Rechte vorbehalten.', copyrightText: 'Alle Rechte vorbehalten.',
technicalLinkText: 'Technisches Portfolio', technicalLinkText: 'Technisches Portfolio',
landingLinkText: 'Landing Page', landingLinkText: 'Landing Page',
codebergAriaLabel: 'Codeberg-Profil', gitAriaLabel: 'Git-Profil',
linkedinAriaLabel: 'LinkedIn-Profil', linkedinAriaLabel: 'LinkedIn-Profil',
emailAriaLabel: 'E-Mail senden', emailAriaLabel: 'E-Mail senden',
}; };

View File

@ -2,6 +2,7 @@ import portfolioImage from '@/assets/portfolio.PNG';
import dancaAlegriaImage from '@/assets/Danca-Alegria.png'; import dancaAlegriaImage from '@/assets/Danca-Alegria.png';
import lukasImage from '@/assets/lukas.png'; import lukasImage from '@/assets/lukas.png';
import a11yhubImage from '@/assets/a11yhub.png'; import a11yhubImage from '@/assets/a11yhub.png';
import placeholderImage from '@/assets/og-image.png';
export const projects = { export const projects = {
title: 'Ausgewählte Projekte', title: 'Ausgewählte Projekte',
@ -17,7 +18,7 @@ export const projects = {
image: portfolioImage, image: portfolioImage,
technologies: ['React', 'TypeScript', 'SCSS', 'Github Copilot'], technologies: ['React', 'TypeScript', 'SCSS', 'Github Copilot'],
year: '2025', year: '2025',
github: 'https://codeberg.org/saschab/portfolio-page', github: 'https://git.sascha-bach.de/saschabach/portfolio-page',
live: 'https://www.sascha-bach.de', live: 'https://www.sascha-bach.de',
}, },
{ {
@ -44,11 +45,38 @@ export const projects = {
id: 4, id: 4,
title: 'Plattform für Bildungsangebote zu digitaler Barrierefreiheit', title: 'Plattform für Bildungsangebote zu digitaler Barrierefreiheit',
description: description:
'Viele Events, Webinar und Co. haben keinen zentralen Ort, wo sie aufgerufen werden. Dieses Projekt in Zusammenarbeit mit Benjamin Christi soll helfen dieses Problem zu lösen.', 'Ein Kooperationsprojekt mit Benjamin Christi, das Events, Webinare und Weiterbildungsangebote zu digitaler Barrierefreiheit an einem zentralen, WCAG-2.2-konformen Ort bündelt Frontend mit BEM-Struktur in React/Next.js, Backend mit Node.js/PostgreSQL/GraphQL, Content über Payload CMS.',
image: a11yhubImage, image: a11yhubImage,
technologies: ['Payload', 'CSS', 'Typescript'], technologies: ['React', 'Next.js', 'TypeScript', 'Node.js', 'PostgreSQL', 'GraphQL', 'Payload CMS'],
year: '2026', year: '2026',
live: 'https://www.a11y-hub.de', live: 'https://www.a11y-hub.de',
},
{
id: 5,
title: 'Barrierefreiheits-Audit für einen Unternehmenskunden',
description:
'Vollständige BITV 2.0 / EN 301 549 Prüfung (115 Kriterien) nach WCAG 2.2 Level AA über 21 Seiten, mit priorisiertem Befundbericht (Critical/High/Medium/Low), konkreten Lösungsvorschlägen und Re-Audit-Fahrplan.',
image: placeholderImage,
technologies: ['WCAG 2.2', 'BITV 2.0', 'EN 301 549', 'axe DevTools', 'WAVE'],
year: '2026',
},
{
id: 6,
title: 'Individuelle MediaWiki-Plattform (GxPlex)',
description:
'Aufbau einer maßgeschneiderten MediaWiki-Instanz inkl. Installation, MySQL-Datenbank, SSL und automatischer Backups; Einrichtung rollenbasierter Rechte (Admin, Mod, Verifiziert, User), FlaggedRevisions-Workflow für die redaktionelle Prüfung sowie Kommentar-/Bewertungs-Extensions.',
image: placeholderImage,
technologies: ['MediaWiki', 'MySQL', 'PHP', 'SSL'],
year: '2026',
},
{
id: 7,
title: 'Fotobuch-Designer Web-App',
description:
'Komplexe Angular-Anwendung für individuelle Fotoprodukte bei ORWO Net GmbH; Umsetzung WCAG-AA-konformer, wiederverwendbarer UI-Komponenten in SCSS/BEM-Architektur, mit XSLT-Templates im agilen Scrum-Team.',
image: placeholderImage,
technologies: ['Angular', 'TypeScript', 'SCSS', 'XSLT', 'WCAG AA'],
year: '2025',
} }
], ],
}; };

View File

@ -9,12 +9,14 @@ export const skills = {
{ skill: 'React', value: 70 }, { skill: 'React', value: 70 },
{ skill: 'Next.js', value: 70 }, { skill: 'Next.js', value: 70 },
{ skill: 'TypeScript', value: 95 }, { skill: 'TypeScript', value: 95 },
{ skill: 'Redux / NgRx', value: 65 },
], ],
}, },
{ {
title: 'Styling & Design', title: 'Styling & Design',
skills: [ skills: [
{ skill: 'Barrierefreiheit (a11y) Standards', value: 90 }, { skill: 'WCAG 2.1/2.2 & BITV 2.0', value: 90 },
{ skill: 'ARIA, axe DevTools & WAVE', value: 85 },
{ skill: 'SCSS/Sass', value: 95 }, { skill: 'SCSS/Sass', value: 95 },
{ skill: 'CSS3', value: 95 }, { skill: 'CSS3', value: 95 },
{ skill: 'Bootstrap', value: 80 }, { skill: 'Bootstrap', value: 80 },
@ -26,7 +28,11 @@ export const skills = {
{ skill: 'Node.js', value: 75 }, { skill: 'Node.js', value: 75 },
{ skill: 'Express.js', value: 70 }, { skill: 'Express.js', value: 70 },
{ skill: 'REST APIs', value: 85 }, { skill: 'REST APIs', value: 85 },
{ skill: 'Datenbank-Design', value: 70 }, { skill: 'PostgreSQL / MySQL', value: 75 },
{ skill: 'GraphQL', value: 70 },
{ skill: 'PHP', value: 70 },
{ skill: 'MongoDB', value: 55 },
{ skill: 'Java (Grundkenntnisse)', value: 35 },
], ],
}, },
{ {
@ -36,13 +42,16 @@ export const skills = {
{ skill: 'VS Code', value: 95 }, { skill: 'VS Code', value: 95 },
{ skill: 'npm/yarn', value: 85 }, { skill: 'npm/yarn', value: 85 },
{ skill: 'Webpack/Vite', value: 75 }, { skill: 'Webpack/Vite', value: 75 },
{ skill: 'Figma', value: 75 },
{ skill: 'WordPress / Squarespace / Payload CMS', value: 85 },
{ skill: 'MediaWiki', value: 75 },
], ],
}, },
{ {
title: 'Testing & Qualität', title: 'Testing & Qualität',
skills: [ skills: [
{ skill: 'Unit-Testing', value: 80 }, { skill: 'Unit-Testing', value: 80 },
{ skill: 'E2E-Testing', value: 70 }, { skill: 'E2E-Testing (Cypress)', value: 70 },
{ skill: 'Code-Review', value: 85 }, { skill: 'Code-Review', value: 85 },
{ skill: 'Performance-Optimierung', value: 75 }, { skill: 'Performance-Optimierung', value: 75 },
], ],

View File

@ -23,7 +23,7 @@ export interface TextConfig {
copyrightText: string; copyrightText: string;
technicalLinkText: string; technicalLinkText: string;
landingLinkText: string; landingLinkText: string;
codebergAriaLabel: string; gitAriaLabel: string;
linkedinAriaLabel: string; linkedinAriaLabel: string;
emailAriaLabel: string; emailAriaLabel: string;
}; };
@ -185,7 +185,7 @@ export interface TextConfig {
emailBody: string; emailBody: string;
emailAnnouncement: string; emailAnnouncement: string;
socialAnnouncement: string; socialAnnouncement: string;
codebergLinkText: string; gitLinkText: string;
linkedinLinkText: string; linkedinLinkText: string;
appointmentHintText: string; appointmentHintText: string;
appointmentLinkText: string; appointmentLinkText: string;

View File

@ -18,7 +18,7 @@ export const contact = {
'Hello Sascha,\n\nI visited your portfolio and would like to discuss a potential project or collaboration.\n\nBest regards,', 'Hello Sascha,\n\nI visited your portfolio and would like to discuss a potential project or collaboration.\n\nBest regards,',
emailAnnouncement: 'Opening email client with pre-filled message', emailAnnouncement: 'Opening email client with pre-filled message',
socialAnnouncement: 'Opening {platform} profile in new tab', socialAnnouncement: 'Opening {platform} profile in new tab',
codebergLinkText: 'Sascha Bach on Codeberg', gitLinkText: 'Sascha Bach on Git',
linkedinLinkText: 'Sascha Bach on LinkedIn', linkedinLinkText: 'Sascha Bach on LinkedIn',
appointmentHintText: 'Prefer to schedule a call?', appointmentHintText: 'Prefer to schedule a call?',
appointmentLinkText: 'Book an appointment', appointmentLinkText: 'Book an appointment',

View File

@ -3,7 +3,7 @@ export const footer = {
copyrightText: 'All rights reserved.', copyrightText: 'All rights reserved.',
technicalLinkText: 'Technical Portfolio', technicalLinkText: 'Technical Portfolio',
landingLinkText: 'Landing Page', landingLinkText: 'Landing Page',
codebergAriaLabel: 'Codeberg Profile', gitAriaLabel: 'Git Profile',
linkedinAriaLabel: 'LinkedIn Profile', linkedinAriaLabel: 'LinkedIn Profile',
emailAriaLabel: 'Send Email', emailAriaLabel: 'Send Email',
}; };

View File

@ -2,6 +2,7 @@ import portfolioImage from '@/assets/portfolio.PNG';
import dancaAlegriaImage from '@/assets/Danca-Alegria.png'; import dancaAlegriaImage from '@/assets/Danca-Alegria.png';
import lukasImage from '@/assets/lukas.png'; import lukasImage from '@/assets/lukas.png';
import a11yhubImage from '@/assets/a11yhub.png'; import a11yhubImage from '@/assets/a11yhub.png';
import placeholderImage from '@/assets/og-image.png';
export const projects = { export const projects = {
title: 'Featured Projects', title: 'Featured Projects',
@ -17,7 +18,7 @@ export const projects = {
image: portfolioImage, image: portfolioImage,
technologies: ['React', 'TypeScript', 'SCSS', 'Github Copilot'], technologies: ['React', 'TypeScript', 'SCSS', 'Github Copilot'],
year: '2025', year: '2025',
github: 'https://codeberg.org/saschab/portfolio-page', github: 'https://git.sascha-bach.de/saschabach/portfolio-page',
live: 'https://www.sascha-bach.de', live: 'https://www.sascha-bach.de',
}, },
{ {
@ -44,11 +45,38 @@ export const projects = {
id: 4, id: 4,
title: 'Platform for Digital Accessibility Education Offers', title: 'Platform for Digital Accessibility Education Offers',
description: description:
'A collaboration project with Benjamin Christi to provide one central place for events, webinars, and training offers about digital accessibility.', 'A collaboration project with Benjamin Christi providing one central, WCAG 2.2-compliant place for events, webinars, and training offers about digital accessibility, built with a BEM-structured React/Next.js frontend, a Node.js/PostgreSQL/GraphQL backend, and Payload CMS.',
image: a11yhubImage, image: a11yhubImage,
technologies: ['Payload', 'CSS', 'Typescript'], technologies: ['React', 'Next.js', 'TypeScript', 'Node.js', 'PostgreSQL', 'GraphQL', 'Payload CMS'],
year: '2026', year: '2026',
live: 'https://www.a11y-hub.de', live: 'https://www.a11y-hub.de',
},
{
id: 5,
title: 'Accessibility Audit for a Corporate Client',
description:
'Full BITV 2.0 / EN 301 549 conformance audit (115 criteria) against WCAG 2.2 Level AA across 21 pages, delivering a prioritized findings report (Critical/High/Medium/Low) with concrete remediation guidance and a re-audit roadmap.',
image: placeholderImage,
technologies: ['WCAG 2.2', 'BITV 2.0', 'EN 301 549', 'axe DevTools', 'WAVE'],
year: '2026',
},
{
id: 6,
title: 'Custom MediaWiki Platform (GxPlex)',
description:
'Set up a tailored MediaWiki instance including installation, MySQL database, SSL, and automated backups; configured role-based permissions (Admin, Mod, Verified, User), a FlaggedRevisions editorial review workflow, and comment/rating extensions.',
image: placeholderImage,
technologies: ['MediaWiki', 'MySQL', 'PHP', 'SSL'],
year: '2026',
},
{
id: 7,
title: 'Photobook Designer Web App',
description:
'Complex Angular application for personalized photo products at ORWO Net GmbH; built WCAG AA-conformant, reusable UI components within a SCSS/BEM architecture, with XSLT-driven templates in an agile Scrum team.',
image: placeholderImage,
technologies: ['Angular', 'TypeScript', 'SCSS', 'XSLT', 'WCAG AA'],
year: '2025',
} }
], ],
}; };

View File

@ -9,12 +9,14 @@ export const skills = {
{ skill: 'React', value: 70 }, { skill: 'React', value: 70 },
{ skill: 'Next.js', value: 70 }, { skill: 'Next.js', value: 70 },
{ skill: 'TypeScript', value: 95 }, { skill: 'TypeScript', value: 95 },
{ skill: 'Redux / NgRx', value: 65 },
], ],
}, },
{ {
title: 'Styling & Design', title: 'Styling & Design',
skills: [ skills: [
{ skill: 'Accessibility (a11y) Standards', value: 90 }, { skill: 'WCAG 2.1/2.2 & BITV 2.0', value: 90 },
{ skill: 'ARIA, axe DevTools & WAVE', value: 85 },
{ skill: 'SCSS/Sass', value: 95 }, { skill: 'SCSS/Sass', value: 95 },
{ skill: 'CSS3', value: 95 }, { skill: 'CSS3', value: 95 },
{ skill: 'Bootstrap', value: 80 }, { skill: 'Bootstrap', value: 80 },
@ -26,7 +28,11 @@ export const skills = {
{ skill: 'Node.js', value: 75 }, { skill: 'Node.js', value: 75 },
{ skill: 'Express.js', value: 70 }, { skill: 'Express.js', value: 70 },
{ skill: 'REST APIs', value: 85 }, { skill: 'REST APIs', value: 85 },
{ skill: 'Database Design', value: 70 }, { skill: 'PostgreSQL / MySQL', value: 75 },
{ skill: 'GraphQL', value: 70 },
{ skill: 'PHP', value: 70 },
{ skill: 'MongoDB', value: 55 },
{ skill: 'Java (Basics)', value: 35 },
], ],
}, },
{ {
@ -36,13 +42,16 @@ export const skills = {
{ skill: 'VS Code', value: 95 }, { skill: 'VS Code', value: 95 },
{ skill: 'npm/yarn', value: 85 }, { skill: 'npm/yarn', value: 85 },
{ skill: 'Webpack/Vite', value: 75 }, { skill: 'Webpack/Vite', value: 75 },
{ skill: 'Figma', value: 75 },
{ skill: 'WordPress / Squarespace / Payload CMS', value: 85 },
{ skill: 'MediaWiki', value: 75 },
], ],
}, },
{ {
title: 'Testing & Quality', title: 'Testing & Quality',
skills: [ skills: [
{ skill: 'Unit Testing', value: 80 }, { skill: 'Unit Testing', value: 80 },
{ skill: 'E2E Testing', value: 70 }, { skill: 'E2E Testing (Cypress)', value: 70 },
{ skill: 'Code Review', value: 85 }, { skill: 'Code Review', value: 85 },
{ skill: 'Performance Optimization', value: 75 }, { skill: 'Performance Optimization', value: 75 },
], ],

View File

@ -15,9 +15,9 @@ export const personalConfig = {
// Social Links // Social Links
social: { social: {
codeberg: { git: {
url: 'https://codeberg.org/saschab', url: 'https://git.sascha-bach.de/saschabach',
username: 'saschab', username: 'saschabach',
}, },
linkedin: { linkedin: {
url: 'https://www.linkedin.com/in/saschabach/', url: 'https://www.linkedin.com/in/saschabach/',

View File

@ -1,44 +0,0 @@
/**
* Main text configuration file
*
* This file re-exports the localized text configurations and provides
* backward compatibility with the existing API.
*
* For multi-language support, add new locale imports and update the
* getTexts() function to select the appropriate language based on
* user preference or browser settings.
*/
import { type TextConfig, en } from './locales/en';
// Re-export the TextConfig type for backward compatibility
export type { TextConfig };
// Re-export English texts as default for backward compatibility
export const defaultTexts: TextConfig = en;
/**
* Get the current text configuration based on locale
*
* @param _locale - Optional locale string (e.g., 'en', 'de') - Reserved for future use
* @returns TextConfig object with all localized strings
*
* Future implementation:
* - Accept locale parameter
* - Check localStorage/sessionStorage for user preference
* - Fall back to browser language
* - Return corresponding locale configuration
*/
export const getTexts = (_locale?: string): TextConfig => {
// For now, return English texts
// Future: implement language switching logic here
// Example:
// const userLocale = _locale || localStorage.getItem('locale') || navigator.language.split('-')[0];
// switch (userLocale) {
// case 'de': return de;
// case 'en':
// default: return en;
// }
return en;
};

View File

@ -0,0 +1,37 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import type { Theme } from '../data/types';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
function getInitialTheme(): Theme {
const saved = window.localStorage.getItem('theme');
return saved === 'dark' ? 'dark' : 'light';
}
export function ThemeProvider({ children }: Readonly<{ children: ReactNode }>) {
const [theme, setTheme] = useState<Theme>(getInitialTheme);
useEffect(() => {
document.documentElement.dataset.theme = theme;
window.localStorage.setItem('theme', theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View File

@ -1,63 +0,0 @@
import { useEffect } from 'react';
interface SeoProps {
title: string;
description: string;
canonical?: string;
noIndex?: boolean;
image?: string;
}
const BASE_URL = 'https://sascha-bach.de';
export function usePageSeo({ title, description, canonical, noIndex, image }: SeoProps) {
useEffect(() => {
document.title = title;
const setMeta = (name: string, content: string, isProperty = false) => {
const attr = isProperty ? 'property' : 'name';
let el = document.querySelector<HTMLMetaElement>(`meta[${attr}="${name}"]`);
if (!el) {
el = document.createElement('meta');
el.setAttribute(attr, name);
document.head.appendChild(el);
}
el.content = content;
};
setMeta('description', description);
setMeta('og:title', title, true);
setMeta('og:description', description, true);
setMeta('twitter:title', title);
setMeta('twitter:description', description);
if (image) {
const imageUrl = image.startsWith('http') ? image : `${BASE_URL}${image}`;
setMeta('og:image', imageUrl, true);
setMeta('twitter:image', imageUrl);
}
if (canonical) {
setMeta('og:url', `${BASE_URL}${canonical}`, true);
let link = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!link) {
link = document.createElement('link');
link.rel = 'canonical';
document.head.appendChild(link);
}
link.href = `${BASE_URL}${canonical}`;
}
if (noIndex) {
setMeta('robots', 'noindex, nofollow');
}
return () => {
if (noIndex) {
const robotsMeta = document.querySelector('meta[name="robots"]');
robotsMeta?.remove();
}
};
}, [title, description, canonical, noIndex]);
}

View File

@ -5,23 +5,23 @@ import SkillsSection from '../components/sections/SkillsSection';
import CertificationsSection from '../components/sections/CertificationsSection'; import CertificationsSection from '../components/sections/CertificationsSection';
import ProjectsSection from '../components/sections/ProjectsSection'; import ProjectsSection from '../components/sections/ProjectsSection';
import ContactSection from '../components/sections/ContactSection'; import ContactSection from '../components/sections/ContactSection';
import { usePageSeo } from '../hooks/usePageSeo'; import { PageSeo } from '../components/PageSeo';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
export default function HomePage() { export default function HomePage() {
const { language } = useLanguage(); const { language } = useLanguage();
usePageSeo({
title: language === 'de'
? 'Sascha Bach | Technisches Portfolio Skills, Services & Projekte'
: 'Sascha Bach | Technical Portfolio Skills, Services & Projects',
description: language === 'de'
? 'Das technische Portfolio von Sascha Bach: Services, Fähigkeiten, Zertifizierungen und Projekte in React, TypeScript und barrierefreier Webentwicklung.'
: 'Explore the technical portfolio of Sascha Bach: services, skills, certifications, and projects in React, TypeScript, and accessible web development.',
canonical: `/${language}/technical`,
});
return ( return (
<> <>
<PageSeo
title={language === 'de'
? 'Sascha Bach | Technisches Portfolio Skills, Services & Projekte'
: 'Sascha Bach | Technical Portfolio Skills, Services & Projects'}
description={language === 'de'
? 'Das technische Portfolio von Sascha Bach: Services, Fähigkeiten, Zertifizierungen und Projekte in React, TypeScript und barrierefreier Webentwicklung.'
: 'Explore the technical portfolio of Sascha Bach: services, skills, certifications, and projects in React, TypeScript, and accessible web development.'}
canonical={`/${language}/technical`}
/>
<HeroSection /> <HeroSection />
<AboutSection /> <AboutSection />
<ServicesSection /> <ServicesSection />

View File

@ -1,20 +1,19 @@
import { personalConfig } from '../config/personal'; import { personalConfig } from '../config/personal';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { usePageSeo } from '../hooks/usePageSeo'; import { PageSeo } from '../components/PageSeo';
export default function ImprintPage() { export default function ImprintPage() {
const { texts: allTexts } = useLanguage(); const { texts: allTexts } = useLanguage();
const texts = allTexts.imprint; const texts = allTexts.imprint;
usePageSeo({
title: 'Imprint | Sascha Bach',
description: 'Legal information and imprint for Sascha Bach, freelance software developer.',
canonical: '/imprint',
noIndex: true,
});
return ( return (
<section className="imprint-page"> <section className="imprint-page">
<PageSeo
title="Imprint | Sascha Bach"
description="Legal information and imprint for Sascha Bach, freelance software developer."
canonical="/imprint"
noIndex
/>
<div className="imprint-page__container"> <div className="imprint-page__container">
<div className="imprint-page__header"> <div className="imprint-page__header">
<h1 className="imprint-page__title">{texts.title}</h1> <h1 className="imprint-page__title">{texts.title}</h1>

View File

@ -7,24 +7,24 @@ import ProcessesSection from '../components/landing/ProcessesSection';
import ProjectsSection from '../components/sections/ProjectsSection'; import ProjectsSection from '../components/sections/ProjectsSection';
import ReferencesSection from '../components/landing/ReferencesSection'; import ReferencesSection from '../components/landing/ReferencesSection';
import ContactSection from '../components/sections/ContactSection'; import ContactSection from '../components/sections/ContactSection';
import { usePageSeo } from '../hooks/usePageSeo'; import { PageSeo } from '../components/PageSeo';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
export default function LandingPage() { export default function LandingPage() {
const { language, texts } = useLanguage(); const { language, texts } = useLanguage();
const t = texts.landingHero; const t = texts.landingHero;
usePageSeo({
title: language === 'de'
? 'Sascha Bach | Freelance Softwareentwickler Barrierefreie Webentwicklung'
: 'Sascha Bach | Freelance Software Developer Accessible Web Development',
description: language === 'de'
? 'Freelance Softwareentwickler in Deutschland spezialisiert auf barrierefreie Webentwicklung. Ich baue inklusive Websites konform zum BFSG mit React, TypeScript und modernen Frameworks.'
: 'Freelance software developer in Germany specializing in accessible web development. Building inclusive websites compliant with the Accessibility Act.',
canonical: `/${language}/`,
});
return ( return (
<> <>
<PageSeo
title={language === 'de'
? 'Sascha Bach | Freelance Softwareentwickler Barrierefreie Webentwicklung'
: 'Sascha Bach | Freelance Software Developer Accessible Web Development'}
description={language === 'de'
? 'Freelance Softwareentwickler in Deutschland spezialisiert auf barrierefreie Webentwicklung. Ich baue inklusive Websites konform zum BFSG mit React, TypeScript und modernen Frameworks.'
: 'Freelance software developer in Germany specializing in accessible web development. Building inclusive websites compliant with the Accessibility Act.'}
canonical={`/${language}/`}
/>
<HeroSection <HeroSection
title={t.title} title={t.title}
description={t.description} description={t.description}

View File

@ -1,19 +1,18 @@
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { usePageSeo } from '../hooks/usePageSeo'; import { PageSeo } from '../components/PageSeo';
export default function PrivacyPolicy() { export default function PrivacyPolicy() {
const { texts: allTexts } = useLanguage(); const { texts: allTexts } = useLanguage();
const texts = allTexts.privacyPolicy; const texts = allTexts.privacyPolicy;
usePageSeo({
title: 'Privacy Policy | Sascha Bach',
description: 'Privacy policy for the website of Sascha Bach, freelance software developer.',
canonical: '/privacy-policy',
noIndex: true,
});
return ( return (
<div className="privacy-policy" style={{ maxWidth: '800px', margin: '0 auto', padding: '2rem' }}> <div className="privacy-policy" style={{ maxWidth: '800px', margin: '0 auto', padding: '2rem' }}>
<PageSeo
title="Privacy Policy | Sascha Bach"
description="Privacy policy for the website of Sascha Bach, freelance software developer."
canonical="/privacy-policy"
noIndex
/>
<h1>{texts.title}</h1> <h1>{texts.title}</h1>
<p><strong>{texts.lastUpdated}</strong></p> <p><strong>{texts.lastUpdated}</strong></p>

21
src/test/setup.ts Normal file
View File

@ -0,0 +1,21 @@
import 'vitest-axe/extend-expect';
import * as matchers from 'vitest-axe/matchers';
import { expect } from 'vitest';
expect.extend(matchers);
// jsdom has no layout engine, so IntersectionObserver (used by useScrollReveal)
// doesn't exist. A no-op stub is enough since tests don't assert on scroll reveal.
class IntersectionObserverStub implements IntersectionObserver {
readonly root = null;
readonly rootMargin = '';
readonly thresholds: ReadonlyArray<number> = [];
observe() {}
unobserve() {}
disconnect() {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
}
globalThis.IntersectionObserver = IntersectionObserverStub as unknown as typeof IntersectionObserver;

View File

@ -23,5 +23,6 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/test", "**/__tests__/**"]
} }

View File

@ -21,5 +21,5 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts", "vitest.config.ts"]
} }

View File

@ -8,7 +8,13 @@ const __dirname = dirname(__filename);
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [
react({
babel: {
plugins: [['babel-plugin-react-compiler', {}]],
},
}),
],
base: '/', base: '/',
resolve: { resolve: {
alias: { alias: {

20
vitest.config.ts Normal file
View File

@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
});