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 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',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
|
|||
29
index.html
29
index.html
|
|
@ -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"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
"email",
|
||||
"booking",
|
||||
"linkedin",
|
||||
"codeberg"
|
||||
"git"
|
||||
],
|
||||
"description": "The contact method to open."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["email", "booking", "linkedin", "codeberg"],
|
||||
"enum": ["email", "booking", "linkedin", "git"],
|
||||
"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:
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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/',
|
||||
|
|
|
|||
|
|
@ -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 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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/test", "**/__tests__/**"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,5 +21,5 @@
|
|||
"noFallthroughCasesInSwitch": 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/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react({
|
||||
babel: {
|
||||
plugins: [['babel-plugin-react-compiler', {}]],
|
||||
},
|
||||
}),
|
||||
],
|
||||
base: '/',
|
||||
resolve: {
|
||||
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