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 reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import jsxA11y from 'eslint-plugin-jsx-a11y'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
@ -12,12 +13,22 @@ export default tseslint.config([
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactHooks.configs.flat['recommended-latest'],
reactRefresh.configs.vite,
jsxA11y.flatConfigs.recommended,
],
languageOptions: {
ecmaVersion: 2020,
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" />
<!-- Primary Meta Tags -->
<title>
Sascha Bach | Freelance Software Developer Accessible Web Development
</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."
/>
<!-- title, description, canonical, og:url, og:title/description, twitter:title/description
are rendered per-route by src/components/PageSeo.tsx (React 19 hoists them into <head>). -->
<meta name="author" content="Sascha Bach" />
<meta
name="keywords"
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
rel="alternate"
type="text/markdown"
@ -60,15 +54,6 @@
<!-- Open Graph / Facebook -->
<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:width" content="1200" />
<meta property="og:image:height" content="630" />
@ -82,14 +67,6 @@
<!-- Twitter -->
<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:alt"
@ -124,7 +101,7 @@
},
"sameAs": [
"https://www.linkedin.com/in/saschabach/",
"https://codeberg.org/saschab"
"https://git.sascha-bach.de/saschabach"
],
"worksFor": {
"@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:no-prerender": "tsc -b && vite build",
"lint": "eslint .",
"test": "vitest run",
"preview": "vite preview",
"deploy": "npm run build && node deploy.js",
"deploy:prod": "npm run build && node deploy.js"
@ -20,6 +21,8 @@
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.0.10",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
@ -29,14 +32,18 @@
"babel-plugin-react-compiler": "1.0.0",
"dotenv": "^17.2.2",
"eslint": "^9.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"jsdom": "^29.1.1",
"puppeteer": "^24.40.0",
"sass": "^1.90.0",
"ssh2-sftp-client": "^12.0.1",
"typescript": "~5.8.3",
"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",
"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": [
"How can I contact Sascha Bach?",
"Open the appointment booking page"

View File

@ -8,7 +8,7 @@
{
"id": "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",

View File

@ -29,7 +29,7 @@
{
"id": "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": [
"How can I contact Sascha Bach?",
"Open the appointment booking page"

View File

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

View File

@ -41,7 +41,7 @@
"properties": {
"method": {
"type": "string",
"enum": ["email", "booking", "linkedin", "codeberg"],
"enum": ["email", "booking", "linkedin", "git"],
"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:
- Open the appointment booking link
- 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

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`
- Appointment scheduling: `https://nextcloud.sascha-bach.de/index.php/apps/calendar/appointment/N58i246RSTB2`
- 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:
1. Open the appointment booking page.
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

View File

@ -16,7 +16,7 @@
- Email: freelancer@sascha-bach.de
- Appointment booking: https://nextcloud.sascha-bach.de/index.php/apps/calendar/appointment/N58i246RSTB2
- LinkedIn: https://www.linkedin.com/in/saschabach/
- Codeberg: https://codeberg.org/saschab
- Git profile: https://git.sascha-bach.de/saschabach
## 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 '../scss/App.scss';
import Navbar from '../components/layout/Navigation';
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 { 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. */
function LanguageSyncer() {
@ -26,7 +26,7 @@ function LanguageSyncer() {
return null;
}
function AppShell({ theme, setTheme }: Readonly<{ theme: Theme; setTheme: React.Dispatch<React.SetStateAction<Theme>>; }>) {
function AppShell() {
const location = useLocation();
useEffect(() => {
@ -45,25 +45,27 @@ function AppShell({ theme, setTheme }: Readonly<{ theme: Theme; setTheme: React.
<div className="app">
<a href="#main-content" className="skip-link">Skip to main content</a>
<LanguageSyncer />
<Navbar theme={theme} setTheme={setTheme} />
<Navbar />
<main id="main-content" className="app__main">
<Routes>
{/* Root redirects → German (primary market) */}
<Route path="/" element={<Navigate to="/de/" replace />} />
<Route path="/technical" element={<Navigate to="/de/technical" replace />} />
<Suspense fallback={<p role="status" className="sr-only">Loading</p>}>
<Routes>
{/* Root redirects → German (primary market) */}
<Route path="/" element={<Navigate to="/de/" replace />} />
<Route path="/technical" element={<Navigate to="/de/technical" replace />} />
{/* German routes */}
<Route path="/de/" element={<LandingPage />} />
<Route path="/de/technical" element={<HomePage />} />
{/* German routes */}
<Route path="/de/" element={<LandingPage />} />
<Route path="/de/technical" element={<HomePage />} />
{/* English routes */}
<Route path="/en/" element={<LandingPage />} />
<Route path="/en/technical" element={<HomePage />} />
{/* English routes */}
<Route path="/en/" element={<LandingPage />} />
<Route path="/en/technical" element={<HomePage />} />
{/* Shared pages (noindex, language-independent) */}
<Route path="/imprint" element={<ImprintPage />} />
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
</Routes>
{/* Shared pages (noindex, language-independent) */}
<Route path="/imprint" element={<ImprintPage />} />
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
</Routes>
</Suspense>
</main>
<Footer />
<BackToTopButton />
@ -72,23 +74,9 @@ function AppShell({ theme, setTheme }: Readonly<{ theme: Theme; setTheme: React.
}
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 (
<Router>
<AppShell theme={theme} setTheme={setTheme} />
<AppShell />
</Router>
);
}

View File

@ -1,4 +1,5 @@
import { LanguageProvider, getLanguageFromPath } from '../contexts/LanguageContext';
import { ThemeProvider } from '../contexts/ThemeContext';
import AppRouter from './AppRouter';
function PortfolioApp() {
@ -6,7 +7,9 @@ function PortfolioApp() {
return (
<LanguageProvider initialLanguage={initialLanguage}>
<AppRouter />
<ThemeProvider>
<AppRouter />
</ThemeProvider>
</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 type { Theme } from '../data/types';
import { useTheme } from '../contexts/ThemeContext';
type Props = Readonly<{
theme: Theme;
setTheme: React.Dispatch<React.SetStateAction<Theme>>;
}>;
export default function ThemeToggle({
theme,
setTheme
}: Props) {
export default function ThemeToggle() {
const { texts } = useLanguage();
const { theme, setTheme } = useTheme();
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');

View File

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

View File

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

View File

@ -84,7 +84,7 @@ export default function AboutSection(props: AboutSectionProps = {}) {
</div>
{/* Personal Photo and Bio */}
<main className="about-section__content">
<div className="about-section__content">
<div className="about-section__image-wrapper" ref={wrapperRef}>
<img
src={profileImage}
@ -117,7 +117,7 @@ export default function AboutSection(props: AboutSectionProps = {}) {
</div>
</div>
</div>
</main>
</div>
{/* Feature Cards Grid */}
<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 { personalConfig } from '../../config/personal';
import { useLanguage } from '../../contexts/LanguageContext';
@ -71,7 +70,7 @@ export default function ContactSection(props: ContactSectionProps = {}) {
</header>
{/* Contact Content Grid */}
<main id="contact-form" className="contact-section__grid">
<div id="contact-form" className="contact-section__grid">
{/* Let's Connect Card */}
<div className="contact-section__connect-card" role="region" aria-labelledby="connect-heading">
<div className="contact-section__connect-header">
@ -161,13 +160,13 @@ export default function ContactSection(props: ContactSectionProps = {}) {
<li className="contact-section__detail-item">
<Globe className="contact-section__social-icon" aria-hidden="true" />
<a
href={personalConfig.social.codeberg.url}
href={personalConfig.social.git.url}
target="_blank"
rel="noopener noreferrer"
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>
</li>
@ -185,7 +184,7 @@ export default function ContactSection(props: ContactSectionProps = {}) {
</li>
</ul>
</div>
</main>
</div>
</div>
</section>
);

View File

@ -84,7 +84,7 @@ export default function HeroSection(props: HeroSectionProps = {}) {
};
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">
{/* Skip link for keyboard navigation */}
<div className="hero-section__skip">
@ -166,6 +166,6 @@ export default function HeroSection(props: HeroSectionProps = {}) {
</aside>
)}
</div>
</main>
</section>
);
}

View File

@ -94,16 +94,22 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
</div>
{/* 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) => {
const actions = getProjectActions(project);
const isActive = activeCardIndex === cardIndex;
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}
className={`projects-section__card ${isActive ? 'projects-section__card--active' : ''}`}
ref={(el) => { cardRefs.current[cardIndex] = el; }}
tabIndex={0}
aria-describedby={`project-${project.id}-description project-${project.id}-metadata`}
aria-label={`${accessibilityTexts.screenReader.projectCard}: ${project.title}. ${cardIndex + 1} of ${projects.length}`}
onKeyDown={(e) => handleCardKeyDown(e, cardIndex)}
@ -197,7 +203,8 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
</div>
)}
</div>
</article>
</div>
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/no-noninteractive-tabindex */
);
})}
</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,',
emailAnnouncement: 'E-Mail-Client wird mit vorausgefüllter Nachricht 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',
appointmentHintText: 'Lieber einen Termin buchen?',
appointmentLinkText: 'Jetzt Termin vereinbaren',

View File

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

View File

@ -2,6 +2,7 @@ import portfolioImage from '@/assets/portfolio.PNG';
import dancaAlegriaImage from '@/assets/Danca-Alegria.png';
import lukasImage from '@/assets/lukas.png';
import a11yhubImage from '@/assets/a11yhub.png';
import placeholderImage from '@/assets/og-image.png';
export const projects = {
title: 'Ausgewählte Projekte',
@ -17,7 +18,7 @@ export const projects = {
image: portfolioImage,
technologies: ['React', 'TypeScript', 'SCSS', 'Github Copilot'],
year: '2025',
github: 'https://codeberg.org/saschab/portfolio-page',
github: 'https://git.sascha-bach.de/saschabach/portfolio-page',
live: 'https://www.sascha-bach.de',
},
{
@ -44,11 +45,38 @@ export const projects = {
id: 4,
title: 'Plattform für Bildungsangebote zu digitaler Barrierefreiheit',
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,
technologies: ['Payload', 'CSS', 'Typescript'],
technologies: ['React', 'Next.js', 'TypeScript', 'Node.js', 'PostgreSQL', 'GraphQL', 'Payload CMS'],
year: '2026',
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: 'Next.js', value: 70 },
{ skill: 'TypeScript', value: 95 },
{ skill: 'Redux / NgRx', value: 65 },
],
},
{
title: 'Styling & Design',
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: 'CSS3', value: 95 },
{ skill: 'Bootstrap', value: 80 },
@ -26,7 +28,11 @@ export const skills = {
{ skill: 'Node.js', value: 75 },
{ skill: 'Express.js', value: 70 },
{ 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: 'npm/yarn', value: 85 },
{ skill: 'Webpack/Vite', value: 75 },
{ skill: 'Figma', value: 75 },
{ skill: 'WordPress / Squarespace / Payload CMS', value: 85 },
{ skill: 'MediaWiki', value: 75 },
],
},
{
title: 'Testing & Qualität',
skills: [
{ skill: 'Unit-Testing', value: 80 },
{ skill: 'E2E-Testing', value: 70 },
{ skill: 'E2E-Testing (Cypress)', value: 70 },
{ skill: 'Code-Review', value: 85 },
{ skill: 'Performance-Optimierung', value: 75 },
],

View File

@ -23,7 +23,7 @@ export interface TextConfig {
copyrightText: string;
technicalLinkText: string;
landingLinkText: string;
codebergAriaLabel: string;
gitAriaLabel: string;
linkedinAriaLabel: string;
emailAriaLabel: string;
};
@ -185,7 +185,7 @@ export interface TextConfig {
emailBody: string;
emailAnnouncement: string;
socialAnnouncement: string;
codebergLinkText: string;
gitLinkText: string;
linkedinLinkText: string;
appointmentHintText: 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,',
emailAnnouncement: 'Opening email client with pre-filled message',
socialAnnouncement: 'Opening {platform} profile in new tab',
codebergLinkText: 'Sascha Bach on Codeberg',
gitLinkText: 'Sascha Bach on Git',
linkedinLinkText: 'Sascha Bach on LinkedIn',
appointmentHintText: 'Prefer to schedule a call?',
appointmentLinkText: 'Book an appointment',

View File

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

View File

@ -2,6 +2,7 @@ import portfolioImage from '@/assets/portfolio.PNG';
import dancaAlegriaImage from '@/assets/Danca-Alegria.png';
import lukasImage from '@/assets/lukas.png';
import a11yhubImage from '@/assets/a11yhub.png';
import placeholderImage from '@/assets/og-image.png';
export const projects = {
title: 'Featured Projects',
@ -17,7 +18,7 @@ export const projects = {
image: portfolioImage,
technologies: ['React', 'TypeScript', 'SCSS', 'Github Copilot'],
year: '2025',
github: 'https://codeberg.org/saschab/portfolio-page',
github: 'https://git.sascha-bach.de/saschabach/portfolio-page',
live: 'https://www.sascha-bach.de',
},
{
@ -44,11 +45,38 @@ export const projects = {
id: 4,
title: 'Platform for Digital Accessibility Education Offers',
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,
technologies: ['Payload', 'CSS', 'Typescript'],
technologies: ['React', 'Next.js', 'TypeScript', 'Node.js', 'PostgreSQL', 'GraphQL', 'Payload CMS'],
year: '2026',
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: 'Next.js', value: 70 },
{ skill: 'TypeScript', value: 95 },
{ skill: 'Redux / NgRx', value: 65 },
],
},
{
title: 'Styling & Design',
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: 'CSS3', value: 95 },
{ skill: 'Bootstrap', value: 80 },
@ -26,7 +28,11 @@ export const skills = {
{ skill: 'Node.js', value: 75 },
{ skill: 'Express.js', value: 70 },
{ 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: 'npm/yarn', value: 85 },
{ skill: 'Webpack/Vite', value: 75 },
{ skill: 'Figma', value: 75 },
{ skill: 'WordPress / Squarespace / Payload CMS', value: 85 },
{ skill: 'MediaWiki', value: 75 },
],
},
{
title: 'Testing & Quality',
skills: [
{ skill: 'Unit Testing', value: 80 },
{ skill: 'E2E Testing', value: 70 },
{ skill: 'E2E Testing (Cypress)', value: 70 },
{ skill: 'Code Review', value: 85 },
{ skill: 'Performance Optimization', value: 75 },
],

View File

@ -15,9 +15,9 @@ export const personalConfig = {
// Social Links
social: {
codeberg: {
url: 'https://codeberg.org/saschab',
username: 'saschab',
git: {
url: 'https://git.sascha-bach.de/saschabach',
username: 'saschabach',
},
linkedin: {
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 ProjectsSection from '../components/sections/ProjectsSection';
import ContactSection from '../components/sections/ContactSection';
import { usePageSeo } from '../hooks/usePageSeo';
import { PageSeo } from '../components/PageSeo';
import { useLanguage } from '../contexts/LanguageContext';
export default function HomePage() {
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 (
<>
<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 />
<AboutSection />
<ServicesSection />

View File

@ -1,20 +1,19 @@
import { personalConfig } from '../config/personal';
import { useLanguage } from '../contexts/LanguageContext';
import { usePageSeo } from '../hooks/usePageSeo';
import { PageSeo } from '../components/PageSeo';
export default function ImprintPage() {
const { texts: allTexts } = useLanguage();
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 (
<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__header">
<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 ReferencesSection from '../components/landing/ReferencesSection';
import ContactSection from '../components/sections/ContactSection';
import { usePageSeo } from '../hooks/usePageSeo';
import { PageSeo } from '../components/PageSeo';
import { useLanguage } from '../contexts/LanguageContext';
export default function LandingPage() {
const { language, texts } = useLanguage();
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 (
<>
<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
title={t.title}
description={t.description}

View File

@ -1,19 +1,18 @@
import { useLanguage } from '../contexts/LanguageContext';
import { usePageSeo } from '../hooks/usePageSeo';
import { PageSeo } from '../components/PageSeo';
export default function PrivacyPolicy() {
const { texts: allTexts } = useLanguage();
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 (
<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>
<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,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/test", "**/__tests__/**"]
}

View File

@ -21,5 +21,5 @@
"noFallthroughCasesInSwitch": 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/
export default defineConfig({
plugins: [react()],
plugins: [
react({
babel: {
plugins: [['babel-plugin-react-compiler', {}]],
},
}),
],
base: '/',
resolve: {
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'],
},
});