feat: update social links to use Git instead of Codeberg and enhance project descriptions
This commit is contained in:
parent
7cdf122014
commit
91a6180f44
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "portfolio-dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
|
||||||
29
index.html
29
index.html
|
|
@ -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"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
"email",
|
"email",
|
||||||
"booking",
|
"booking",
|
||||||
"linkedin",
|
"linkedin",
|
||||||
"codeberg"
|
"git"
|
||||||
],
|
],
|
||||||
"description": "The contact method to open."
|
"description": "The contact method to open."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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/',
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
|
||||||
}
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -23,5 +23,6 @@
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/test", "**/__tests__/**"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,5 +21,5 @@
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue