Merge branch 'master' of https://codeberg.org/saschab/portfolio-page
This commit is contained in:
commit
26869367d6
231
index.html
231
index.html
|
|
@ -1,12 +1,237 @@
|
||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Sascha Bach Portfolio</title>
|
|
||||||
|
<!-- 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."
|
||||||
|
/>
|
||||||
|
<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/" />
|
||||||
|
|
||||||
|
<!-- Hreflang for multi-language -->
|
||||||
|
<link rel="alternate" hreflang="de" href="https://sascha-bach.de/de/" />
|
||||||
|
<link rel="alternate" hreflang="en" href="https://sascha-bach.de/en/" />
|
||||||
|
<link
|
||||||
|
rel="alternate"
|
||||||
|
hreflang="x-default"
|
||||||
|
href="https://sascha-bach.de/de/"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 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" />
|
||||||
|
<meta
|
||||||
|
property="og:image:alt"
|
||||||
|
content="Sascha Bach – Freelance Software Developer specializing in accessible web development"
|
||||||
|
/>
|
||||||
|
<meta property="og:locale" content="de_DE" />
|
||||||
|
<meta property="og:locale:alternate" content="en_US" />
|
||||||
|
<meta property="og:site_name" content="Sascha Bach" />
|
||||||
|
|
||||||
|
<!-- 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"
|
||||||
|
content="Sascha Bach – Freelance Software Developer specializing in accessible web development"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- JSON-LD Structured Data: Person (primary entity) -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Person",
|
||||||
|
"@id": "https://sascha-bach.de/#person",
|
||||||
|
"name": "Sascha Bach",
|
||||||
|
"jobTitle": "Freelance Software Developer",
|
||||||
|
"url": "https://sascha-bach.de",
|
||||||
|
"image": "https://sascha-bach.de/og-image.png",
|
||||||
|
"description": "Sascha Bach is a freelance software developer based in Germany specializing in accessible web development (BFSG, WCAG 2.1, European Accessibility Act) using React, TypeScript, and Angular.",
|
||||||
|
"knowsLanguage": ["de", "en"],
|
||||||
|
"knowsAbout": [
|
||||||
|
"Web Accessibility",
|
||||||
|
"BFSG",
|
||||||
|
"WCAG 2.1",
|
||||||
|
"React",
|
||||||
|
"TypeScript",
|
||||||
|
"Angular",
|
||||||
|
"Frontend Development"
|
||||||
|
],
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressRegion": "Hessen",
|
||||||
|
"addressCountry": "DE"
|
||||||
|
},
|
||||||
|
"sameAs": [
|
||||||
|
"https://www.linkedin.com/in/saschabach/",
|
||||||
|
"https://codeberg.org/saschab"
|
||||||
|
],
|
||||||
|
"worksFor": {
|
||||||
|
"@id": "https://sascha-bach.de/#business"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- JSON-LD Structured Data: ProfessionalService -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "ProfessionalService",
|
||||||
|
"@id": "https://sascha-bach.de/#business",
|
||||||
|
"name": "Sascha Bach – Freelance Software Developer",
|
||||||
|
"url": "https://sascha-bach.de",
|
||||||
|
"logo": "https://sascha-bach.de/og-image.png",
|
||||||
|
"image": "https://sascha-bach.de/og-image.png",
|
||||||
|
"description": "Freelance software developer specializing in accessible web development, React, TypeScript, Angular, and BFSG/WCAG compliance.",
|
||||||
|
"founder": { "@id": "https://sascha-bach.de/#person" },
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressRegion": "Berlin",
|
||||||
|
"addressCountry": "DE"
|
||||||
|
},
|
||||||
|
"areaServed": ["DE", "AT", "CH"],
|
||||||
|
"knowsLanguage": ["de", "en"],
|
||||||
|
"hasOfferCatalog": {
|
||||||
|
"@type": "OfferCatalog",
|
||||||
|
"name": "Web Development Services",
|
||||||
|
"itemListElement": [
|
||||||
|
{
|
||||||
|
"@type": "Offer",
|
||||||
|
"itemOffered": {
|
||||||
|
"@type": "Service",
|
||||||
|
"name": "Accessible Web Development (BFSG / WCAG 2.2)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Offer",
|
||||||
|
"itemOffered": {
|
||||||
|
"@type": "Service",
|
||||||
|
"name": "Frontend Development (React, TypeScript, Angular)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Offer",
|
||||||
|
"itemOffered": {
|
||||||
|
"@type": "Service",
|
||||||
|
"name": "Responsive Web Design"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Offer",
|
||||||
|
"itemOffered": {
|
||||||
|
"@type": "Service",
|
||||||
|
"name": "Performance Optimization"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- JSON-LD Structured Data: WebSite -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
"@id": "https://sascha-bach.de/#website",
|
||||||
|
"url": "https://sascha-bach.de",
|
||||||
|
"name": "Sascha Bach",
|
||||||
|
"publisher": { "@id": "https://sascha-bach.de/#person" }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- JSON-LD Structured Data: FAQ (GEO) -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "FAQPage",
|
||||||
|
"mainEntity": [
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Was ist das BFSG und wen betrifft es?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Das Barrierefreiheitsstärkungsgesetz (BFSG) verpflichtet Unternehmen ab dem 28. Juni 2025, ihre digitalen Produkte und Websites barrierefrei zu gestalten. Es gilt für Unternehmen mit mehr als 10 Mitarbeitern oder mehr als 2 Millionen Euro Jahresumsatz."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "What is accessible web development?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Accessible web development means building websites that can be used by everyone, including people with visual, auditory, motor, or cognitive disabilities. It follows the Web Content Accessibility Guidelines (WCAG 2.1) and ensures compatibility with assistive technologies like screen readers."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Welche Technologien nutzt Sascha Bach für die Webentwicklung?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Sascha Bach entwickelt mit React, Angular, TypeScript, JavaScript und modernen CSS-Methoden. Der Fokus liegt auf barrierefreien, responsiven Webanwendungen nach WCAG 2.2 und BFSG-Standard."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "How does web accessibility improve SEO?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Accessible websites use semantic HTML, proper heading structures, descriptive alt texts, and keyboard-navigable interfaces — all of which search engines reward with better rankings. Google's crawlers are effectively assistive technologies themselves."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Wie kann ich prüfen, ob meine Website barrierefrei ist?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Eine erste Überprüfung ist mit kostenlosen Tools wie dem WAVE Web Accessibility Evaluator oder Lighthouse in den Chrome DevTools möglich. Für eine vollständige BFSG-konforme Prüfung empfiehlt sich eine manuelle Analyse durch einen Experten (wie mich ;-) )."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<noscript>
|
||||||
|
<h1>Sascha Bach – Freelance Software Developer</h1>
|
||||||
|
<p>
|
||||||
|
Freelance software developer in Germany specializing in accessible web
|
||||||
|
development. Building inclusive websites compliant with the
|
||||||
|
Accessibility Act using React, TypeScript, and modern frameworks.
|
||||||
|
</p>
|
||||||
|
<p>Contact: freelancer [at] sascha-bach.de</p>
|
||||||
|
<p>Visit with JavaScript enabled for the full experience.</p>
|
||||||
|
</noscript>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,7 +5,8 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build && node prerender.js",
|
||||||
|
"build:no-prerender": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"deploy": "npm run build && node deploy.js",
|
"deploy": "npm run build && node deploy.js",
|
||||||
|
|
@ -15,7 +16,6 @@
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-icons": "^5.5.0",
|
|
||||||
"react-router-dom": "^7.8.2"
|
"react-router-dom": "^7.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -25,12 +25,14 @@
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"agentation": "^3.0.2",
|
||||||
"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-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",
|
||||||
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
/**
|
||||||
|
* Post-build prerendering script.
|
||||||
|
* Starts a local server from dist/, visits each route with Puppeteer,
|
||||||
|
* and saves the fully-rendered HTML so crawlers see real content.
|
||||||
|
*
|
||||||
|
* Usage: node prerender.js (run after `vite build`)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createServer } from 'node:http'
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
||||||
|
import { join, dirname } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import puppeteer from 'puppeteer'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const DIST = join(__dirname, 'dist')
|
||||||
|
const PORT = 4173
|
||||||
|
|
||||||
|
const ROUTES = [
|
||||||
|
'/de/',
|
||||||
|
'/de/technical',
|
||||||
|
'/en/',
|
||||||
|
'/en/technical',
|
||||||
|
'/imprint',
|
||||||
|
'/privacy-policy',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Minimal static file server that falls back to index.html (SPA). */
|
||||||
|
function startServer() {
|
||||||
|
const mimeTypes = {
|
||||||
|
'.html': 'text/html',
|
||||||
|
'.js': 'application/javascript',
|
||||||
|
'.css': 'text/css',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
let filePath = join(DIST, req.url === '/' ? '/index.html' : req.url)
|
||||||
|
|
||||||
|
if (!existsSync(filePath) || !filePath.includes('.')) {
|
||||||
|
filePath = join(DIST, 'index.html')
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = '.' + filePath.split('.').pop()
|
||||||
|
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(filePath)
|
||||||
|
res.writeHead(200, { 'Content-Type': contentType })
|
||||||
|
res.end(content)
|
||||||
|
} catch {
|
||||||
|
res.writeHead(404)
|
||||||
|
res.end('Not found')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(` Server running on http://localhost:${PORT}`)
|
||||||
|
resolve(server)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prerender() {
|
||||||
|
console.log('\n🔍 Prerendering routes...\n')
|
||||||
|
|
||||||
|
const server = await startServer()
|
||||||
|
const browser = await puppeteer.launch({ headless: true })
|
||||||
|
|
||||||
|
for (const route of ROUTES) {
|
||||||
|
const page = await browser.newPage()
|
||||||
|
const url = `http://localhost:${PORT}${route}`
|
||||||
|
|
||||||
|
console.log(` Rendering ${route} ...`)
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle0', timeout: 15000 })
|
||||||
|
|
||||||
|
// Wait a bit for React to finish any async rendering
|
||||||
|
await new Promise((r) => setTimeout(r, 1500))
|
||||||
|
|
||||||
|
const html = await page.content()
|
||||||
|
await page.close()
|
||||||
|
|
||||||
|
// Write to dist/<route>/index.html
|
||||||
|
// Normalise the route: strip trailing slash for directory creation (except root, which is now redirected)
|
||||||
|
const routeDir =
|
||||||
|
route.endsWith('/') && route !== '/' ? route.slice(0, -1) : route
|
||||||
|
const outDir = routeDir === '/' ? DIST : join(DIST, routeDir)
|
||||||
|
if (!existsSync(outDir)) {
|
||||||
|
mkdirSync(outDir, { recursive: true })
|
||||||
|
}
|
||||||
|
const outFile = join(outDir, 'index.html')
|
||||||
|
writeFileSync(outFile, html, 'utf-8')
|
||||||
|
console.log(` ✓ Saved ${outFile.replace(DIST, 'dist')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
server.close()
|
||||||
|
console.log('\n✅ Prerendering complete!\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prerender()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Prerendering failed:', err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
Options -MultiViews
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteRule ^ index.html [QSA,L]
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
|
|
@ -0,0 +1,6 @@
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /imprint
|
||||||
|
Disallow: /privacy-policy
|
||||||
|
|
||||||
|
Sitemap: https://sascha-bach.de/sitemap.xml
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||||
|
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||||
|
<!-- German landing -->
|
||||||
|
<url>
|
||||||
|
<loc>https://sascha-bach.de/de/</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://sascha-bach.de/de/" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://sascha-bach.de/en/" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://sascha-bach.de/de/" />
|
||||||
|
<lastmod>2025-06-28</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<!-- English landing -->
|
||||||
|
<url>
|
||||||
|
<loc>https://sascha-bach.de/en/</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://sascha-bach.de/de/" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://sascha-bach.de/en/" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://sascha-bach.de/de/" />
|
||||||
|
<lastmod>2025-06-28</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<!-- German technical portfolio -->
|
||||||
|
<url>
|
||||||
|
<loc>https://sascha-bach.de/de/technical</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://sascha-bach.de/de/technical" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://sascha-bach.de/en/technical" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://sascha-bach.de/de/technical" />
|
||||||
|
<lastmod>2025-06-28</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<!-- English technical portfolio -->
|
||||||
|
<url>
|
||||||
|
<loc>https://sascha-bach.de/en/technical</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://sascha-bach.de/de/technical" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://sascha-bach.de/en/technical" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://sascha-bach.de/de/technical" />
|
||||||
|
<lastmod>2025-06-28</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
|
|
@ -1,68 +1,94 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route } 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 HomePage from '../pages/HomePage';
|
||||||
|
import LandingPage from '../pages/LandingPage';
|
||||||
import ImprintPage from '../pages/ImprintPage';
|
import ImprintPage from '../pages/ImprintPage';
|
||||||
import PrivacyPolicy from '../pages/PrivacyPolicy';
|
import PrivacyPolicy from '../pages/PrivacyPolicy';
|
||||||
|
import BackToTopButton from '../components/BackToTopButton';
|
||||||
|
import { getLanguageFromPath, useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import type { Theme } from '../data/types';
|
||||||
|
|
||||||
function AppRouter() {
|
/** Syncs the LanguageContext whenever the URL prefix (/de/ or /en/) changes. */
|
||||||
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
function LanguageSyncer() {
|
||||||
|
const { setLanguage, language } = useLanguage();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check for saved theme preference or default to light mode
|
const urlLang = getLanguageFromPath(location.pathname);
|
||||||
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null;
|
if (urlLang && urlLang !== language) {
|
||||||
|
setLanguage(urlLang);
|
||||||
|
}
|
||||||
|
}, [location.pathname, language, setLanguage]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppShell({ theme, setTheme }: Readonly<{ theme: Theme; setTheme: React.Dispatch<React.SetStateAction<Theme>>; }>) {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hash = location.hash.substring(1);
|
||||||
|
if (hash) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const element = document.getElementById(hash);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [location.hash]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<a href="#main-content" className="skip-link">Skip to main content</a>
|
||||||
|
<LanguageSyncer />
|
||||||
|
<Navbar theme={theme} setTheme={setTheme} />
|
||||||
|
<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 />} />
|
||||||
|
|
||||||
|
{/* 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 />} />
|
||||||
|
|
||||||
|
{/* Shared pages (noindex, language-independent) */}
|
||||||
|
<Route path="/imprint" element={<ImprintPage />} />
|
||||||
|
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
<BackToTopButton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRouter() {
|
||||||
|
const [theme, setTheme] = useState<Theme>('light');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedTheme = localStorage.getItem('theme') as Theme | null;
|
||||||
if (savedTheme) {
|
if (savedTheme) {
|
||||||
setTheme(savedTheme);
|
setTheme(savedTheme);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Apply theme to document and save preference
|
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('theme', theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Handle hash-based navigation for sections
|
|
||||||
const handleHashChange = () => {
|
|
||||||
const hash = globalThis.location.hash.substring(1);
|
|
||||||
if (hash) {
|
|
||||||
setTimeout(() => {
|
|
||||||
const element = document.getElementById(hash);
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check hash on initial load
|
|
||||||
handleHashChange();
|
|
||||||
|
|
||||||
// Listen for hash changes
|
|
||||||
globalThis.addEventListener('hashchange', handleHashChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
globalThis.removeEventListener('hashchange', handleHashChange);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<div className="app">
|
<AppShell theme={theme} setTheme={setTheme} />
|
||||||
<a href="#main-content" className="skip-link">Skip to main content</a>
|
|
||||||
<Navbar theme={theme} setTheme={setTheme} />
|
|
||||||
<main id="main-content" className="app__main">
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<HomePage />} />
|
|
||||||
<Route path="/imprint" element={<ImprintPage />} />
|
|
||||||
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { LanguageProvider } from '../contexts/LanguageContext';
|
import { LanguageProvider, getLanguageFromPath } from '../contexts/LanguageContext';
|
||||||
import AppRouter from './AppRouter';
|
import AppRouter from './AppRouter';
|
||||||
|
|
||||||
function PortfolioApp() {
|
function PortfolioApp() {
|
||||||
|
const initialLanguage = getLanguageFromPath(globalThis.location.pathname) ?? 'de';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LanguageProvider>
|
<LanguageProvider initialLanguage={initialLanguage}>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 60" width="640" height="120">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Comfortaa:wght@600&display=swap');
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect x="12" y="10" width="20" height="36" rx="2" fill="none" stroke="#E6F1FB" stroke-width="2.5"/>
|
||||||
|
<rect x="21" y="10" width="11" height="36" rx="1.5" fill="#E6F1FB"/>
|
||||||
|
<circle cx="19.5" cy="28" r="2" fill="#EF9F27"/>
|
||||||
|
|
||||||
|
<text x="42" y="29" font-family="Comfortaa, Georgia, serif" font-size="24" font-weight="600" fill="#E6F1FB">Sascha Bach</text>
|
||||||
|
<text x="43" y="46" font-family="Comfortaa, Arial, sans-serif" font-size="8.5" font-weight="600" fill="#FAC775" letter-spacing="1.8">BARRIEREFREIE WEBENTWICKLUNG</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 763 B |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 70 KiB |
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { ArrowUp } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function BackToTopButton() {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const toggleVisibility = () => {
|
||||||
|
// Show button when page is scrolled down 300px
|
||||||
|
if (window.scrollY > 300) {
|
||||||
|
setIsVisible(true);
|
||||||
|
} else {
|
||||||
|
setIsVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', toggleVisibility);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', toggleVisibility);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollToTop = () => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={scrollToTop}
|
||||||
|
className={`back-to-top ${isVisible ? 'back-to-top--visible' : ''}`}
|
||||||
|
aria-label="Back to top"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ArrowUp className="back-to-top__icon" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,27 @@
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import type { Language } from '../data/types';
|
||||||
import '../scss/language-toggle.scss';
|
import '../scss/language-toggle.scss';
|
||||||
|
import { useScreenReaderAnnouncements } from '../hooks/useScreenReaderAnnouncements';
|
||||||
|
|
||||||
|
/** Swap /de/ ↔ /en/ prefix (or /de/technical ↔ /en/technical) in the pathname. */
|
||||||
|
function getEquivalentPath(pathname: string, targetLang: Language): string {
|
||||||
|
if (pathname.startsWith('/de')) return pathname.replace(/^\/de/, `/${targetLang}`);
|
||||||
|
if (pathname.startsWith('/en')) return pathname.replace(/^\/en/, `/${targetLang}`);
|
||||||
|
return `/${targetLang}/`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function LanguageToggle() {
|
export default function LanguageToggle() {
|
||||||
const { language, setLanguage } = useLanguage();
|
const { language } = useLanguage();
|
||||||
|
const { announce } = useScreenReaderAnnouncements();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const handleLanguageChange = (newLang: 'en' | 'de') => {
|
const handleLanguageChange = (newLang: Language) => {
|
||||||
if (newLang !== language) {
|
if (newLang !== language) {
|
||||||
setLanguage(newLang);
|
const targetPath = getEquivalentPath(location.pathname, newLang);
|
||||||
// Announce language change to screen readers
|
navigate(targetPath);
|
||||||
const announcement = document.createElement('div');
|
announce(`Language changed to ${newLang === 'en' ? 'English' : 'Deutsch'}`);
|
||||||
announcement.setAttribute('role', 'status');
|
|
||||||
announcement.setAttribute('aria-live', 'polite');
|
|
||||||
announcement.setAttribute('class', 'sr-only');
|
|
||||||
announcement.textContent = `Language changed to ${newLang === 'en' ? 'English' : 'Deutsch'}`;
|
|
||||||
document.body.appendChild(announcement);
|
|
||||||
setTimeout(() => announcement.remove(), 1000);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import type { Theme } from '../data/types';
|
||||||
|
|
||||||
type Props = Readonly<{
|
type Props = Readonly<{
|
||||||
theme: 'light' | 'dark';
|
theme: Theme;
|
||||||
setTheme: React.Dispatch<React.SetStateAction<'light' | 'dark'>>;
|
setTheme: React.Dispatch<React.SetStateAction<Theme>>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export default function ThemeToggle({
|
export default function ThemeToggle({
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { useScrollReveal } from '../../hooks/useScrollReveal';
|
||||||
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
|
||||||
|
export default function ExperienceSection() {
|
||||||
|
const { texts } = useLanguage();
|
||||||
|
const t = texts.landingExperience;
|
||||||
|
const sectionRef = useScrollReveal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="experience" className="experience-section" ref={sectionRef}>
|
||||||
|
<div className="experience-section__container">
|
||||||
|
<h2 className="experience-section__title reveal-item">
|
||||||
|
{t.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="experience-section__body">
|
||||||
|
{t.paragraphs.map((paragraph, index) => (
|
||||||
|
<p
|
||||||
|
key={paragraph.substring(0, 40)}
|
||||||
|
className={`experience-section__paragraph reveal-item${index === t.paragraphs.length - 1 ? ' experience-section__paragraph--outro' : ''}`}
|
||||||
|
style={{ '--reveal-delay': `${index * 100}ms` } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
import AboutSection from '../sections/AboutSection';
|
||||||
|
|
||||||
|
export default function LandingAboutMeSection() {
|
||||||
|
const { texts } = useLanguage();
|
||||||
|
const t = texts.landingAboutMe;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AboutSection
|
||||||
|
title={t.title}
|
||||||
|
subtitle={t.subtitle}
|
||||||
|
greeting={t.greeting}
|
||||||
|
name={t.name}
|
||||||
|
bio={t.bio}
|
||||||
|
featureCards={[]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useScrollReveal } from '../../hooks/useScrollReveal';
|
||||||
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
|
||||||
|
export default function ProcessesSection() {
|
||||||
|
const { texts } = useLanguage();
|
||||||
|
const t = texts.landingProcesses;
|
||||||
|
const sectionRef = useScrollReveal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="processes" className="processes-section" ref={sectionRef}>
|
||||||
|
<div className="processes-section__container">
|
||||||
|
<h2 className="processes-section__title reveal-item">{t.title}</h2>
|
||||||
|
<p className="processes-section__intro reveal-item">{t.intro}</p>
|
||||||
|
<ul className="processes-section__list" aria-label={t.title}>
|
||||||
|
{t.items.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={item.text.substring(0, 40)}
|
||||||
|
className="processes-section__item reveal-item"
|
||||||
|
style={{ '--reveal-delay': `${index * 80}ms` } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<p className="processes-section__text">{item.text}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="processes-section__outro reveal-item">{t.outro}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { useScrollReveal } from '../../hooks/useScrollReveal';
|
||||||
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
|
||||||
|
export default function ReasonsSection() {
|
||||||
|
const { texts } = useLanguage();
|
||||||
|
const t = texts.landingReasons;
|
||||||
|
const sectionRef = useScrollReveal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="reasons" className="reasons-section" ref={sectionRef}>
|
||||||
|
<div className="reasons-section__container">
|
||||||
|
<h2 className="reasons-section__title reveal-item">
|
||||||
|
{t.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<ul className="reasons-section__list" aria-label="Reasons">
|
||||||
|
{t.reasons.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={item.stat}
|
||||||
|
className="reasons-section__item reveal-item"
|
||||||
|
style={{ '--reveal-delay': `${index * 100}ms` } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<span className="reasons-section__stat" aria-hidden="true">
|
||||||
|
{item.stat}
|
||||||
|
</span>
|
||||||
|
<p className="reasons-section__text">{item.text}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p className="reasons-section__outro reveal-item">
|
||||||
|
{t.outro}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { useScrollReveal } from '../../hooks/useScrollReveal';
|
||||||
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
|
||||||
|
export default function ReferencesSection() {
|
||||||
|
const { texts } = useLanguage();
|
||||||
|
const t = texts.landingReferences;
|
||||||
|
const sectionRef = useScrollReveal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="references" className="references-section" ref={sectionRef}>
|
||||||
|
<div className="references-section__container">
|
||||||
|
<h2 className="references-section__title reveal-item">{t.title}</h2>
|
||||||
|
<ul className="references-section__list" aria-label={t.title}>
|
||||||
|
{t.items.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
className="references-section__item reveal-item"
|
||||||
|
style={{ '--reveal-delay': `${index * 100}ms` } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<p className="references-section__quote">{item.referenceText}</p>
|
||||||
|
<div className="references-section__author">
|
||||||
|
<p className="references-section__name">{item.name}</p>
|
||||||
|
<p className="references-section__position">{item.position}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { useScrollReveal } from '../../hooks/useScrollReveal';
|
||||||
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
|
||||||
|
export default function WinningsSection() {
|
||||||
|
const { texts } = useLanguage();
|
||||||
|
const t = texts.landingWinnings;
|
||||||
|
const sectionRef = useScrollReveal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="winnings" className="winnings-section" ref={sectionRef}>
|
||||||
|
<div className="winnings-section__container">
|
||||||
|
<h2 className="winnings-section__title reveal-item">
|
||||||
|
{t.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="winnings-section__intro reveal-item">
|
||||||
|
{t.intro}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="winnings-section__list" aria-label={t.title}>
|
||||||
|
{t.items.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={item.text.substring(0, 40)}
|
||||||
|
className="winnings-section__item reveal-item"
|
||||||
|
style={{ '--reveal-delay': `${index * 80}ms` } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<p className="winnings-section__text">{item.text}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p className="winnings-section__outro reveal-item">
|
||||||
|
{t.outro}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { Github, Linkedin, Mail } from 'lucide-react';
|
import { Globe, Linkedin, Mail } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { personalConfig, createEmailLink } from '../../config/personal';
|
import { personalConfig, createEmailLink } from '../../config/personal';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const { texts } = useLanguage();
|
const { texts } = useLanguage();
|
||||||
|
const location = useLocation();
|
||||||
|
const isTechnicalPage = location.pathname === '/technical';
|
||||||
// Email obfuscation function using config
|
// Email obfuscation function using config
|
||||||
const handleEmailClick = (e: React.MouseEvent) => {
|
const handleEmailClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -21,6 +23,13 @@ export default function Footer() {
|
||||||
<Link to="/imprint" className="footer__link">
|
<Link to="/imprint" className="footer__link">
|
||||||
{texts.footer.imprintText}
|
{texts.footer.imprintText}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={isTechnicalPage ? '/' : '/technical'}
|
||||||
|
className="footer__link"
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
|
>
|
||||||
|
{isTechnicalPage ? texts.footer.landingLinkText : texts.footer.technicalLinkText}
|
||||||
|
</Link>
|
||||||
<div className="footer__copyright">
|
<div className="footer__copyright">
|
||||||
© {new Date().getFullYear()} {personalConfig.name}. {texts.footer.copyrightText}
|
© {new Date().getFullYear()} {personalConfig.name}. {texts.footer.copyrightText}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -29,10 +38,10 @@ 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.github.url, '_blank')}
|
onClick={() => window.open(personalConfig.social.codeberg.url, '_blank')}
|
||||||
aria-label={texts.footer.githubAriaLabel}
|
aria-label={texts.footer.codebergAriaLabel}
|
||||||
>
|
>
|
||||||
<Github className="footer__social-icon" />
|
<Globe className="footer__social-icon" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import type { MenuItem } from '../../data/types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
readonly menuItems: string[];
|
readonly menuItems: MenuItem[];
|
||||||
readonly isOpen: boolean;
|
readonly isOpen: boolean;
|
||||||
readonly onItemClick: (section: string) => void;
|
readonly onItemClick: (section: string) => void;
|
||||||
|
readonly onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MobileMenu({ menuItems, isOpen, onItemClick }: Props) {
|
export default function MobileMenu({ menuItems, isOpen, onItemClick, onClose }: Props) {
|
||||||
const firstButtonRef = useRef<HTMLButtonElement>(null);
|
const firstButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -19,17 +21,29 @@ export default function MobileMenu({ menuItems, isOpen, onItemClick }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav id="mobile-menu" className="navbar__mobile-menu" role="navigation" aria-label="Mobile navigation">
|
<nav id="mobile-menu" className="navbar__mobile-menu" role="navigation" aria-label="Mobile navigation">
|
||||||
{menuItems.map((item, index) => (
|
<div className="navbar__mobile-menu__header">
|
||||||
<button
|
<button
|
||||||
key={item}
|
onClick={onClose}
|
||||||
ref={index === 0 ? firstButtonRef : undefined}
|
className="navbar__mobile-menu__close"
|
||||||
onClick={() => onItemClick(item)}
|
|
||||||
className="navbar__mobile-menu__item"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Close menu"
|
||||||
>
|
>
|
||||||
{item}
|
×
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
|
<div className="navbar__mobile-menu__items">
|
||||||
|
{menuItems.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
ref={index === 0 ? firstButtonRef : undefined}
|
||||||
|
onClick={() => onItemClick(item.section)}
|
||||||
|
className="navbar__mobile-menu__item"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import ThemeToggle from '../ThemeToggle';
|
import ThemeToggle from '../ThemeToggle';
|
||||||
import LanguageToggle from '../LanguageToggle';
|
import LanguageToggle from '../LanguageToggle';
|
||||||
import MobileMenu from './MobileMenu';
|
import MobileMenu from './MobileMenu';
|
||||||
import { personalConfig } from '../../config/personal';
|
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 = {
|
type Props = {
|
||||||
theme: 'light' | 'dark';
|
theme: Theme;
|
||||||
setTheme: React.Dispatch<React.SetStateAction<'light' | 'dark'>>;
|
setTheme: React.Dispatch<React.SetStateAction<Theme>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Navbar({
|
export default function Navbar({
|
||||||
|
|
@ -17,11 +18,43 @@ export default function Navbar({
|
||||||
setTheme
|
setTheme
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [activeSection, setActiveSection] = useState<string>('');
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { texts } = useLanguage();
|
const { texts } = useLanguage();
|
||||||
|
|
||||||
const menuItems = texts.navigation.menuItems;
|
const isLandingPage = location.pathname === '/de/' || location.pathname === '/en/' || location.pathname === '/';
|
||||||
|
const technicalPath = location.pathname.startsWith('/en') ? '/en/technical' : '/de/technical';
|
||||||
|
const menuItems = isLandingPage
|
||||||
|
? texts.navigation.landingMenuItems
|
||||||
|
: texts.navigation.menuItems;
|
||||||
|
|
||||||
|
// Track active section based on scroll position
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname !== technicalPath && !isLandingPage) {
|
||||||
|
setActiveSection('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const sections = menuItems.map(item => item.section.toLowerCase());
|
||||||
|
const scrollPosition = window.scrollY + 100; // Offset for navbar height
|
||||||
|
|
||||||
|
const reversedSections = [...sections].reverse();
|
||||||
|
for (const section of reversedSections) {
|
||||||
|
const element = document.getElementById(section);
|
||||||
|
if (element && element.offsetTop <= scrollPosition) {
|
||||||
|
setActiveSection(section);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setActiveSection('');
|
||||||
|
};
|
||||||
|
|
||||||
|
handleScroll(); // Initial check
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [location.pathname, menuItems]);
|
||||||
|
|
||||||
function handleNavigation(section: string): void {
|
function handleNavigation(section: string): void {
|
||||||
const sectionId = section.toLowerCase();
|
const sectionId = section.toLowerCase();
|
||||||
|
|
@ -32,16 +65,25 @@ export default function Navbar({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="navbar">
|
<div className={`navbar${isLandingPage ? ' navbar--landing' : ''}`}>
|
||||||
<Link to="/" className="navbar__name">{personalConfig.name}</Link>
|
<Link
|
||||||
|
to={isLandingPage ? location.pathname : technicalPath}
|
||||||
|
className="navbar__name"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={logoUrl} alt={texts.navigation.logoAlt} className="navbar__logo" />
|
||||||
|
</Link>
|
||||||
<div className="navbar__container">
|
<div className="navbar__container">
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item}
|
key={item.label}
|
||||||
onClick={() => handleNavigation(item)}
|
onClick={() => handleNavigation(item.section)}
|
||||||
className="navbar__container__button"
|
className={`navbar__container__button ${activeSection === item.section.toLowerCase() ? 'navbar__container__button--active' : ''}`}
|
||||||
>
|
>
|
||||||
{item}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
|
|
@ -62,6 +104,7 @@ export default function Navbar({
|
||||||
menuItems={menuItems}
|
menuItems={menuItems}
|
||||||
isOpen={menuOpen}
|
isOpen={menuOpen}
|
||||||
onItemClick={handleNavigation}
|
onItemClick={handleNavigation}
|
||||||
|
onClose={() => setMenuOpen(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
|
import { useRef, useState, useEffect } from 'react';
|
||||||
import { Code, Palette, ShieldCheck, Eye } from 'lucide-react';
|
import { Code, Palette, ShieldCheck, Eye } from 'lucide-react';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
import FeatureCardsGrid from '../FeatureCardsGrid';
|
import FeatureCardsGrid from '../FeatureCardsGrid';
|
||||||
import type { Service } from '../../data/Service';
|
import type { Service } from '../../data/Service';
|
||||||
import saschaImage from '../../assets/sascha.png';
|
import saschaImage from '../../assets/sascha.png';
|
||||||
|
import saschaPixel from '../../assets/sascha_pixel.svg';
|
||||||
|
|
||||||
|
// Define feature card icon and color mappings as constants
|
||||||
|
const FEATURE_CARD_ICONS = [Code, Palette, ShieldCheck, Eye] as const;
|
||||||
|
const FEATURE_CARD_COLORS = ['primary', 'secondary', 'tertiary', 'quaternary'] as const;
|
||||||
|
|
||||||
interface AboutSectionProps {
|
interface AboutSectionProps {
|
||||||
name?: string;
|
name?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
greeting?: string;
|
||||||
bio?: string[];
|
bio?: string[];
|
||||||
profileImage?: string;
|
profileImage?: string;
|
||||||
featureCards?: Service[];
|
featureCards?: Service[];
|
||||||
|
|
@ -23,16 +30,37 @@ export default function AboutSection(props: AboutSectionProps = {}) {
|
||||||
name = texts.name,
|
name = texts.name,
|
||||||
title = texts.title,
|
title = texts.title,
|
||||||
subtitle = texts.subtitle,
|
subtitle = texts.subtitle,
|
||||||
|
greeting,
|
||||||
bio = texts.bio,
|
bio = texts.bio,
|
||||||
profileImage = saschaImage,
|
profileImage = saschaImage,
|
||||||
featureCards = texts.featureCards.map((card, index) => ({
|
featureCards = texts.featureCards.map((card, index) => ({
|
||||||
icon: [Code, Palette, ShieldCheck, Eye][index] || Code,
|
icon: FEATURE_CARD_ICONS[index] || Code,
|
||||||
title: card.title,
|
title: card.title,
|
||||||
description: card.description,
|
description: card.description,
|
||||||
colorClass: (["primary", "secondary", "tertiary", "quaternary"][index] || "primary") as "primary" | "secondary" | "tertiary" | "quaternary"
|
colorClass: (FEATURE_CARD_COLORS[index])
|
||||||
}))
|
}))
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [fading, setFading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = wrapperRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const timer = setTimeout(() => setFading(true), 2000);
|
||||||
|
observer.disconnect();
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.4 }
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="about" className="about-section" aria-labelledby="about-title">
|
<section id="about" className="about-section" aria-labelledby="about-title">
|
||||||
<div className="about-section__container">
|
<div className="about-section__container">
|
||||||
|
|
@ -41,9 +69,11 @@ export default function AboutSection(props: AboutSectionProps = {}) {
|
||||||
<h2 id="about-title" className="about-section__title">
|
<h2 id="about-title" className="about-section__title">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="about-section__subtitle">
|
{subtitle && (
|
||||||
{subtitle}
|
<p className="about-section__subtitle">
|
||||||
</p>
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Skip link for keyboard navigation */}
|
{/* Skip link for keyboard navigation */}
|
||||||
|
|
@ -54,8 +84,8 @@ export default function AboutSection(props: AboutSectionProps = {}) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Personal Photo and Bio */}
|
{/* Personal Photo and Bio */}
|
||||||
<main className="about-section__content" role="main">
|
<main className="about-section__content">
|
||||||
<div className="about-section__image-wrapper">
|
<div className="about-section__image-wrapper" ref={wrapperRef}>
|
||||||
<img
|
<img
|
||||||
src={profileImage}
|
src={profileImage}
|
||||||
alt={`Professional headshot of ${name}, Web Developer and Software Engineer`}
|
alt={`Professional headshot of ${name}, Web Developer and Software Engineer`}
|
||||||
|
|
@ -63,16 +93,26 @@ export default function AboutSection(props: AboutSectionProps = {}) {
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="about-section__image-overlay" aria-hidden="true"></div>
|
<div className="about-section__image-overlay" aria-hidden="true"></div>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`about-section__pixel-bg${fading ? ' about-section__pixel-bg--fade' : ''}`}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src={saschaPixel}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`about-section__pixel-overlay${fading ? ' about-section__pixel-overlay--fade' : ''}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="about-section__bio-section">
|
<div className="about-section__bio-section">
|
||||||
<div className="about-section__bio-content">
|
<div className="about-section__bio-content">
|
||||||
<h3 className="about-section__greeting">
|
<h3 className="about-section__greeting">
|
||||||
Hi, I'm {name}!
|
{greeting ?? `Hi, I'm ${name}!`}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="about-section__bio-text">
|
<div className="about-section__bio-text">
|
||||||
{bio.map((paragraph) => (
|
{bio.map((paragraph) => (
|
||||||
<p key={paragraph.substring(0, 50)}>{paragraph}</p>
|
<p key={paragraph}>{paragraph}</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { Mail, ExternalLink, Send } from 'lucide-react';
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||||
import { personalConfig, getObfuscatedEmail } from '../../config/personal';
|
import { Mail, Send, Globe, Linkedin, CalendarClock } from 'lucide-react';
|
||||||
|
import { personalConfig } from '../../config/personal';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
import { useScreenReaderAnnouncements } from '../../hooks/useScreenReaderAnnouncements';
|
||||||
|
|
||||||
interface ContactSectionProps {
|
interface ContactSectionProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
@ -21,6 +23,7 @@ interface ContactSectionProps {
|
||||||
export default function ContactSection(props: ContactSectionProps = {}) {
|
export default function ContactSection(props: ContactSectionProps = {}) {
|
||||||
const { texts: allTexts } = useLanguage();
|
const { texts: allTexts } = useLanguage();
|
||||||
const texts = allTexts.contact;
|
const texts = allTexts.contact;
|
||||||
|
const { announce } = useScreenReaderAnnouncements();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
title = texts.title,
|
title = texts.title,
|
||||||
|
|
@ -44,18 +47,9 @@ export default function ContactSection(props: ContactSectionProps = {}) {
|
||||||
const subject = encodeURIComponent(texts.emailSubject);
|
const subject = encodeURIComponent(texts.emailSubject);
|
||||||
const body = encodeURIComponent(texts.emailBody);
|
const body = encodeURIComponent(texts.emailBody);
|
||||||
|
|
||||||
// Announce action to screen readers
|
announce(texts.emailAnnouncement);
|
||||||
const announcement = document.createElement('div');
|
|
||||||
announcement.setAttribute('aria-live', 'polite');
|
|
||||||
announcement.setAttribute('class', 'sr-only');
|
|
||||||
announcement.textContent = texts.emailAnnouncement;
|
|
||||||
document.body.appendChild(announcement);
|
|
||||||
|
|
||||||
globalThis.location.href = `mailto:${email}?subject=${subject}&body=${body}`;
|
globalThis.location.href = `mailto:${email}?subject=${subject}&body=${body}`;
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
announcement.remove();
|
|
||||||
}, 1000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEmailAddress = () => {
|
const getEmailAddress = () => {
|
||||||
|
|
@ -63,21 +57,6 @@ export default function ContactSection(props: ContactSectionProps = {}) {
|
||||||
return parts[0] + '@' + parts[1] + '.' + parts[2];
|
return parts[0] + '@' + parts[1] + '.' + parts[2];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Enhanced social link handler with announcements
|
|
||||||
const handleSocialClick = (platform: string, url: string) => {
|
|
||||||
const announcement = document.createElement('div');
|
|
||||||
announcement.setAttribute('aria-live', 'polite');
|
|
||||||
announcement.setAttribute('class', 'sr-only');
|
|
||||||
announcement.textContent = texts.socialAnnouncement.replace('{platform}', platform);
|
|
||||||
document.body.appendChild(announcement);
|
|
||||||
|
|
||||||
globalThis.open(url, '_blank', 'noopener,noreferrer');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
announcement.remove();
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="contact" className="contact-section" aria-labelledby="contact-title">
|
<section id="contact" className="contact-section" aria-labelledby="contact-title">
|
||||||
<div className="contact-section__container">
|
<div className="contact-section__container">
|
||||||
|
|
@ -91,15 +70,8 @@ export default function ContactSection(props: ContactSectionProps = {}) {
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Skip link for keyboard navigation */}
|
|
||||||
<div className="contact-section__skip">
|
|
||||||
<a href="#contact-form" className="sr-only sr-only-focusable">
|
|
||||||
Skip to contact form
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Content Grid */}
|
{/* Contact Content Grid */}
|
||||||
<main className="contact-section__grid">
|
<main 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">
|
||||||
|
|
@ -117,6 +89,20 @@ export default function ContactSection(props: ContactSectionProps = {}) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-section__appointment-hint">
|
||||||
|
<CalendarClock className="contact-section__appointment-icon" aria-hidden="true" />
|
||||||
|
<span className="contact-section__appointment-text">{texts.appointmentHintText}</span>
|
||||||
|
<a
|
||||||
|
href={personalConfig.appointmentUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="contact-section__appointment-link"
|
||||||
|
aria-label={`${texts.appointmentLinkText} (opens in new tab)`}
|
||||||
|
>
|
||||||
|
{texts.appointmentLinkText}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleEmailClick}
|
onClick={handleEmailClick}
|
||||||
className="contact-section__email-button"
|
className="contact-section__email-button"
|
||||||
|
|
@ -173,45 +159,29 @@ export default function ContactSection(props: ContactSectionProps = {}) {
|
||||||
<ul className="contact-section__social-list">
|
<ul className="contact-section__social-list">
|
||||||
|
|
||||||
<li className="contact-section__detail-item">
|
<li className="contact-section__detail-item">
|
||||||
<button
|
<Globe className="contact-section__social-icon" aria-hidden="true" />
|
||||||
className="contact-section__social-button contact-section__social-button--email"
|
<a
|
||||||
onClick={handleEmailClick}
|
href={personalConfig.social.codeberg.url}
|
||||||
aria-label={`Send email to ${getObfuscatedEmail()}`}
|
target="_blank"
|
||||||
type="button"
|
rel="noopener noreferrer"
|
||||||
|
className="contact-section__detail-link"
|
||||||
|
aria-label={`${texts.codebergLinkText} (opens in new tab)`}
|
||||||
>
|
>
|
||||||
<Mail className="contact-section__social-icon" aria-hidden="true" />
|
{texts.codebergLinkText}
|
||||||
</button>
|
</a>
|
||||||
<span className="contact-section__detail-text" aria-label="Email address">
|
|
||||||
{getObfuscatedEmail()}
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li className="contact-section__detail-item">
|
<li className="contact-section__detail-item">
|
||||||
<button
|
<Linkedin className="contact-section__social-icon" aria-hidden="true" />
|
||||||
className="contact-section__social-button contact-section__social-button--github"
|
<a
|
||||||
onClick={() => handleSocialClick('GitHub', personalConfig.social.github.url)}
|
href={personalConfig.social.linkedin.url}
|
||||||
aria-label={`Visit GitHub profile: ${personalConfig.social.github.username} (opens in new tab)`}
|
target="_blank"
|
||||||
type="button"
|
rel="noopener noreferrer"
|
||||||
|
className="contact-section__detail-link"
|
||||||
|
aria-label={`${texts.linkedinLinkText} (opens in new tab)`}
|
||||||
>
|
>
|
||||||
<ExternalLink className="contact-section__social-icon" aria-hidden="true" />
|
{texts.linkedinLinkText}
|
||||||
</button>
|
</a>
|
||||||
<span className="contact-section__detail-text" aria-label="GitHub username">
|
|
||||||
{personalConfig.social.github.username}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li className="contact-section__detail-item">
|
|
||||||
<button
|
|
||||||
className="contact-section__social-button contact-section__social-button--linkedin"
|
|
||||||
onClick={() => handleSocialClick('LinkedIn', personalConfig.social.linkedin.url)}
|
|
||||||
aria-label={`Visit LinkedIn profile: ${personalConfig.social.linkedin.username} (opens in new tab)`}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<ExternalLink className="contact-section__social-icon" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<span className="contact-section__detail-text" aria-label="LinkedIn username">
|
|
||||||
{personalConfig.social.linkedin.username}
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { Download } from 'lucide-react';
|
import { Download } from 'lucide-react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import type { StatItem } from '../../data/StatItem';
|
import type { StatItem } from '../../data/StatItem';
|
||||||
import { scrollToProjects } from '../../utils/scrollUtils';
|
import { scrollToProjects, scrollToSection } from '../../utils/scrollUtils';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
import { useScreenReaderAnnouncements } from '../../hooks/useScreenReaderAnnouncements';
|
||||||
import resumePDF from '../../assets/CV_Sascha_Bach.pdf';
|
import resumePDF from '../../assets/CV_Sascha_Bach.pdf';
|
||||||
|
|
||||||
interface HeroSectionProps {
|
interface HeroSectionProps {
|
||||||
|
|
@ -12,6 +14,10 @@ interface HeroSectionProps {
|
||||||
secondaryButtonText?: string;
|
secondaryButtonText?: string;
|
||||||
tertiaryButtonText?: string;
|
tertiaryButtonText?: string;
|
||||||
statItems?: StatItem[];
|
statItems?: StatItem[];
|
||||||
|
hideStats?: boolean;
|
||||||
|
showPrimaryButton?: boolean;
|
||||||
|
showSecondaryButton?: boolean;
|
||||||
|
showTertiaryButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HeroSection(props: HeroSectionProps = {}) {
|
export default function HeroSection(props: HeroSectionProps = {}) {
|
||||||
|
|
@ -27,75 +33,58 @@ export default function HeroSection(props: HeroSectionProps = {}) {
|
||||||
secondaryButtonText = texts.secondaryButtonText,
|
secondaryButtonText = texts.secondaryButtonText,
|
||||||
tertiaryButtonText = texts.tertiaryButtonText,
|
tertiaryButtonText = texts.tertiaryButtonText,
|
||||||
statItems = texts.statItems,
|
statItems = texts.statItems,
|
||||||
|
hideStats = false,
|
||||||
|
showPrimaryButton = true,
|
||||||
|
showSecondaryButton = true,
|
||||||
|
showTertiaryButton = true,
|
||||||
} = props;
|
} = props;
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { announce } = useScreenReaderAnnouncements();
|
||||||
|
|
||||||
// Enhanced announcement function for screen readers
|
const [loaded, setLoaded] = useState(false);
|
||||||
const announceToScreenReader = (message: string) => {
|
useEffect(() => {
|
||||||
const announcement = document.createElement('div');
|
if (document.readyState === 'complete') {
|
||||||
announcement.setAttribute('aria-live', 'polite');
|
setLoaded(true);
|
||||||
announcement.setAttribute('aria-atomic', 'true');
|
} else {
|
||||||
announcement.setAttribute('class', 'sr-only');
|
const onLoad = () => setLoaded(true);
|
||||||
announcement.textContent = message;
|
window.addEventListener('load', onLoad);
|
||||||
|
return () => window.removeEventListener('load', onLoad);
|
||||||
document.body.appendChild(announcement);
|
}
|
||||||
setTimeout(() => {
|
}, []);
|
||||||
announcement.remove();
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewWork = () => {
|
const handleViewWork = () => {
|
||||||
announceToScreenReader('Navigating to projects section');
|
announce('Navigating to projects section');
|
||||||
scrollToProjects(location.pathname, navigate);
|
scrollToProjects(location.pathname, navigate);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadResume = () => {
|
const handleDownloadResume = () => {
|
||||||
try {
|
try {
|
||||||
announceToScreenReader('Starting resume download');
|
announce('Starting resume download');
|
||||||
|
|
||||||
// Create a temporary anchor element
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = resumePDF;
|
link.href = resumePDF;
|
||||||
link.download = 'CV_Sascha_Bach.pdf'; // Specify the filename for download
|
link.download = 'CV_Sascha_Bach.pdf';
|
||||||
link.target = '_blank'; // Optional: open in new tab as fallback
|
link.target = '_blank';
|
||||||
|
|
||||||
// Temporarily add to DOM and trigger click
|
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
|
|
||||||
// Clean up
|
|
||||||
link.remove();
|
link.remove();
|
||||||
|
|
||||||
console.log('✅ Resume download initiated');
|
announce('Resume download started successfully');
|
||||||
announceToScreenReader('Resume download started successfully');
|
} catch {
|
||||||
} catch (error) {
|
announce('Resume download failed, opening in new tab');
|
||||||
console.error('❌ Error downloading resume:', error);
|
window.open(resumePDF, '_blank');
|
||||||
announceToScreenReader('Resume download failed, opening in new tab');
|
|
||||||
|
|
||||||
// Fallback: open in new tab if download fails
|
|
||||||
try {
|
|
||||||
window.open(resumePDF, '_blank');
|
|
||||||
} catch (fallbackError) {
|
|
||||||
console.error('❌ Fallback method also failed:', fallbackError);
|
|
||||||
announceToScreenReader('Unable to access resume file');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGetInTouch = () => {
|
const handleGetInTouch = () => {
|
||||||
announceToScreenReader('Navigating to contact section');
|
announce('Navigating to contact section');
|
||||||
const contactSection = document.querySelector('.contact-section');
|
scrollToSection('contact', location.pathname, navigate);
|
||||||
if (contactSection) {
|
|
||||||
contactSection.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'start'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="hero-section" aria-labelledby="hero-title">
|
<main 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">
|
||||||
|
|
@ -112,32 +101,38 @@ export default function HeroSection(props: HeroSectionProps = {}) {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav className="hero-section__buttons" role="navigation" aria-label="Main actions">
|
<nav className="hero-section__buttons" role="navigation" aria-label="Main actions">
|
||||||
<button
|
{showPrimaryButton && (
|
||||||
className="hero-section__button hero-section__button--primary"
|
<button
|
||||||
onClick={handleViewWork}
|
className="hero-section__button hero-section__button--primary"
|
||||||
aria-label="View my work and projects"
|
onClick={handleViewWork}
|
||||||
type="button"
|
aria-label="View my work and projects"
|
||||||
>
|
type="button"
|
||||||
{primaryButtonText}
|
>
|
||||||
</button>
|
{primaryButtonText}
|
||||||
<button
|
</button>
|
||||||
className="hero-section__button hero-section__button--secondary"
|
)}
|
||||||
onClick={handleDownloadResume}
|
{showSecondaryButton && (
|
||||||
aria-label="Download my resume as PDF file"
|
<button
|
||||||
type="button"
|
className="hero-section__button hero-section__button--secondary"
|
||||||
aria-describedby="resume-description"
|
onClick={handleDownloadResume}
|
||||||
>
|
aria-label="Download my resume as PDF file"
|
||||||
<Download className="hero-section__button-icon" aria-hidden="true" />
|
type="button"
|
||||||
{secondaryButtonText}
|
aria-describedby="resume-description"
|
||||||
</button>
|
>
|
||||||
<button
|
<Download className="hero-section__button-icon" aria-hidden="true" />
|
||||||
className="hero-section__button hero-section__button--tertiary"
|
{secondaryButtonText}
|
||||||
onClick={handleGetInTouch}
|
</button>
|
||||||
aria-label="Navigate to contact section to get in touch"
|
)}
|
||||||
type="button"
|
{showTertiaryButton && (
|
||||||
>
|
<button
|
||||||
{tertiaryButtonText}
|
className="hero-section__button hero-section__button--tertiary"
|
||||||
</button>
|
onClick={handleGetInTouch}
|
||||||
|
aria-label="Navigate to contact section to get in touch"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tertiaryButtonText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Hidden descriptions for screen readers */}
|
{/* Hidden descriptions for screen readers */}
|
||||||
|
|
@ -145,29 +140,31 @@ export default function HeroSection(props: HeroSectionProps = {}) {
|
||||||
{accessibilityTexts.screenReader.downloadResumeDescription}
|
{accessibilityTexts.screenReader.downloadResumeDescription}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="hero-section__stats" aria-labelledby="stats-heading">
|
{!hideStats && (
|
||||||
<h2 id="stats-heading" className="sr-only">{accessibilityTexts.screenReader.professionalStatistics}</h2>
|
<aside className="hero-section__stats" aria-labelledby="stats-heading">
|
||||||
{statItems.map((item, index) => (
|
<h2 id="stats-heading" className="sr-only">{accessibilityTexts.screenReader.professionalStatistics}</h2>
|
||||||
<div
|
{statItems.map((item, index) => (
|
||||||
key={item.label}
|
<div
|
||||||
className={`hero-section__stat-item hero-section__stat-item--${index + 1}`}
|
key={item.label}
|
||||||
aria-labelledby={`stat-${index}-label`}
|
className={`hero-section__stat-item hero-section__stat-item--${index + 1}`}
|
||||||
>
|
aria-labelledby={`stat-${index}-label`}
|
||||||
<span
|
|
||||||
className="hero-section__stat-value"
|
|
||||||
aria-describedby={`stat-${index}-label`}
|
|
||||||
>
|
>
|
||||||
{item.value}
|
<span
|
||||||
</span>
|
className="hero-section__stat-value"
|
||||||
<span
|
aria-describedby={`stat-${index}-label`}
|
||||||
id={`stat-${index}-label`}
|
>
|
||||||
className="hero-section__stat-label"
|
{item.value}
|
||||||
>
|
</span>
|
||||||
{item.label}
|
<span
|
||||||
</span>
|
id={`stat-${index}-label`}
|
||||||
</div>
|
className="hero-section__stat-label"
|
||||||
))}
|
>
|
||||||
</aside>
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { GitBranch, ExternalLink } from 'lucide-react';
|
import { GitBranch, ExternalLink } from 'lucide-react';
|
||||||
import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
|
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
import { projectsData } from '../../config/projects-config';
|
import { projectsData } from '../../config/projects-config';
|
||||||
import type { Project } from '../../data/Project';
|
import type { Project } from '../../data/Project';
|
||||||
|
import { useScreenReaderAnnouncements } from '../../hooks/useScreenReaderAnnouncements';
|
||||||
|
import { useProjectKeyboardNavigation } from '../../hooks/useProjectKeyboardNavigation';
|
||||||
|
|
||||||
|
|
||||||
interface ProjectsSectionProps {
|
interface ProjectsSectionProps {
|
||||||
|
|
@ -15,10 +16,6 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||||
const { texts: allTexts } = useLanguage();
|
const { texts: allTexts } = useLanguage();
|
||||||
const texts = allTexts.projects;
|
const texts = allTexts.projects;
|
||||||
const accessibilityTexts = allTexts.accessibility;
|
const accessibilityTexts = allTexts.accessibility;
|
||||||
const [activeCardIndex, setActiveCardIndex] = useState<number | null>(null);
|
|
||||||
const [focusedActionIndex, setFocusedActionIndex] = useState<number>(0);
|
|
||||||
const cardRefs = useRef<(HTMLElement | null)[]>([]);
|
|
||||||
const actionRefs = useRef<(HTMLAnchorElement | null)[]>([]);
|
|
||||||
|
|
||||||
// Use centralized texts as defaults, allow props to override
|
// Use centralized texts as defaults, allow props to override
|
||||||
const {
|
const {
|
||||||
|
|
@ -31,129 +28,21 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||||
})
|
})
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
// Get available actions for a project
|
// Custom hooks for accessibility and keyboard navigation
|
||||||
const getProjectActions = (project: Project) => {
|
const { announce } = useScreenReaderAnnouncements();
|
||||||
const actions = [];
|
const {
|
||||||
if (project.github) actions.push({ type: 'github', url: project.github });
|
activeCardIndex,
|
||||||
if (project.live) actions.push({ type: 'live', url: project.live });
|
cardRefs,
|
||||||
return actions;
|
actionRefs,
|
||||||
};
|
handleCardSelect,
|
||||||
|
handleCardKeyDown,
|
||||||
// Handle card selection
|
handleActionKeyDown,
|
||||||
const handleCardSelect = (index: number) => {
|
getProjectActions,
|
||||||
setActiveCardIndex(index);
|
} = useProjectKeyboardNavigation({
|
||||||
setFocusedActionIndex(0);
|
projects,
|
||||||
|
announce,
|
||||||
// Announce to screen readers
|
useTabToNavigateText: accessibilityTexts.navigation.useTabToNavigate,
|
||||||
const project = projects[index];
|
});
|
||||||
const actions = getProjectActions(project);
|
|
||||||
const announcement = `${project.title} card selected. ${actions.length} actions available. ${accessibilityTexts.navigation.useTabToNavigate}, Escape to close.`;
|
|
||||||
announceToScreenReader(announcement);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle keyboard navigation within cards
|
|
||||||
const handleCardKeyDown = (event: KeyboardEvent<HTMLElement>, cardIndex: number) => {
|
|
||||||
const project = projects[cardIndex];
|
|
||||||
const actions = getProjectActions(project);
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case 'Enter':
|
|
||||||
case ' ':
|
|
||||||
event.preventDefault();
|
|
||||||
handleCardSelect(cardIndex);
|
|
||||||
break;
|
|
||||||
case 'Escape':
|
|
||||||
if (activeCardIndex === cardIndex) {
|
|
||||||
event.preventDefault();
|
|
||||||
setActiveCardIndex(null);
|
|
||||||
setFocusedActionIndex(0);
|
|
||||||
cardRefs.current[cardIndex]?.focus();
|
|
||||||
announceToScreenReader(`${project.title} card closed.`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'Tab':
|
|
||||||
if (activeCardIndex === cardIndex && actions.length > 0) {
|
|
||||||
event.preventDefault();
|
|
||||||
const nextActionIndex = event.shiftKey
|
|
||||||
? (focusedActionIndex - 1 + actions.length) % actions.length
|
|
||||||
: (focusedActionIndex + 1) % actions.length;
|
|
||||||
setFocusedActionIndex(nextActionIndex);
|
|
||||||
|
|
||||||
// Focus the action button
|
|
||||||
const actionButton = actionRefs.current[cardIndex * 2 + nextActionIndex];
|
|
||||||
actionButton?.focus();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'ArrowDown':
|
|
||||||
case 'ArrowUp':
|
|
||||||
if (activeCardIndex !== null) {
|
|
||||||
event.preventDefault();
|
|
||||||
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
|
||||||
const nextCardIndex = (cardIndex + direction + projects.length) % projects.length;
|
|
||||||
|
|
||||||
// Close current card and move to next
|
|
||||||
setActiveCardIndex(null);
|
|
||||||
setFocusedActionIndex(0);
|
|
||||||
cardRefs.current[nextCardIndex]?.focus();
|
|
||||||
announceToScreenReader(`Moved to ${projects[nextCardIndex].title} card.`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle action button keyboard navigation
|
|
||||||
const handleActionKeyDown = (event: KeyboardEvent<HTMLAnchorElement>, cardIndex: number, actionIndex: number) => {
|
|
||||||
const actions = getProjectActions(projects[cardIndex]);
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case 'Escape':
|
|
||||||
event.preventDefault();
|
|
||||||
setActiveCardIndex(null);
|
|
||||||
setFocusedActionIndex(0);
|
|
||||||
cardRefs.current[cardIndex]?.focus();
|
|
||||||
announceToScreenReader(`${projects[cardIndex].title} card closed.`);
|
|
||||||
break;
|
|
||||||
case 'Tab':
|
|
||||||
if (event.shiftKey && actionIndex === 0) {
|
|
||||||
// Tab back to card
|
|
||||||
event.preventDefault();
|
|
||||||
cardRefs.current[cardIndex]?.focus();
|
|
||||||
} else if (!event.shiftKey && actionIndex === actions.length - 1) {
|
|
||||||
// Tab forward to next card
|
|
||||||
event.preventDefault();
|
|
||||||
const nextCardIndex = (cardIndex + 1) % projects.length;
|
|
||||||
setActiveCardIndex(null);
|
|
||||||
setFocusedActionIndex(0);
|
|
||||||
cardRefs.current[nextCardIndex]?.focus();
|
|
||||||
announceToScreenReader(`Moved to ${projects[nextCardIndex].title} card.`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Announce to screen readers
|
|
||||||
const announceToScreenReader = (message: string) => {
|
|
||||||
const announcement = document.createElement('div');
|
|
||||||
announcement.setAttribute('aria-live', 'polite');
|
|
||||||
announcement.setAttribute('aria-atomic', 'true');
|
|
||||||
announcement.setAttribute('class', 'sr-only');
|
|
||||||
announcement.textContent = message;
|
|
||||||
|
|
||||||
document.body.appendChild(announcement);
|
|
||||||
setTimeout(() => {
|
|
||||||
announcement.remove();
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Focus management effect
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeCardIndex !== null) {
|
|
||||||
const firstActionRef = actionRefs.current[activeCardIndex * 2];
|
|
||||||
if (firstActionRef) {
|
|
||||||
firstActionRef.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [activeCardIndex]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="projects" className="projects-section">
|
<section id="projects" className="projects-section">
|
||||||
|
|
@ -214,7 +103,7 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||||
<div className="projects-section__image-container">
|
<div className="projects-section__image-container">
|
||||||
<img
|
<img
|
||||||
src={project.image || "/api/placeholder/400/200"}
|
src={project.image || "/api/placeholder/400/200"}
|
||||||
alt={`Screenshot showing the user interface of ${project.title} project${project.description ? ': ' + project.description.substring(0, 50) + '...' : ''}`}
|
alt={`${project.title} project screenshot`}
|
||||||
className="projects-section__image"
|
className="projects-section__image"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -224,12 +113,12 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||||
{/* Conditionally visible action buttons */}
|
{/* Conditionally visible action buttons */}
|
||||||
<fieldset
|
<fieldset
|
||||||
className={`projects-section__actions ${isActive ? 'projects-section__actions--visible' : ''}`}
|
className={`projects-section__actions ${isActive ? 'projects-section__actions--visible' : ''}`}
|
||||||
aria-label={`Actions for ${project.title}`}
|
|
||||||
aria-hidden={!isActive}
|
aria-hidden={!isActive}
|
||||||
>
|
>
|
||||||
|
<legend className="sr-only">{`Actions for ${project.title}`}</legend>
|
||||||
{project.github && (
|
{project.github && (
|
||||||
<a
|
<a
|
||||||
ref={(el) => { actionRefs.current[cardIndex * 2] = el }}
|
ref={(el) => { actionRefs.current[cardIndex * 2] = el; }}
|
||||||
href={project.github}
|
href={project.github}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
|
@ -244,7 +133,7 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||||
)}
|
)}
|
||||||
{project.live && (
|
{project.live && (
|
||||||
<a
|
<a
|
||||||
ref={(el) => { actionRefs.current[cardIndex * 2 + 1] = el }}
|
ref={(el) => { actionRefs.current[cardIndex * 2 + 1] = el; }}
|
||||||
href={project.live}
|
href={project.live}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
|
@ -272,7 +161,7 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Add project metadata for screen readers */}
|
{/* Add project metadata for screen readers */}
|
||||||
<div className="projects-section__metadata sr-only">
|
<div id={`project-${project.id}-metadata`} className="projects-section__metadata sr-only">
|
||||||
Project {cardIndex + 1} of {projects.length}.
|
Project {cardIndex + 1} of {projects.length}.
|
||||||
{actions.length > 0 ? `${actions.length} actions available.` : 'No actions available.'}
|
{actions.length > 0 ? `${actions.length} actions available.` : 'No actions available.'}
|
||||||
{project.year && `Created in ${project.year}.`}
|
{project.year && `Created in ${project.year}.`}
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,8 @@ export default function SkillsSection(props: SkillsSectionProps = {}) {
|
||||||
<fieldset
|
<fieldset
|
||||||
key={category.title}
|
key={category.title}
|
||||||
className={`skills-section__category skills-section__category--${colorClass}`}
|
className={`skills-section__category skills-section__category--${colorClass}`}
|
||||||
aria-labelledby={`category-${categoryIndex}-title`}
|
|
||||||
>
|
>
|
||||||
|
<legend className="sr-only">{category.title}</legend>
|
||||||
<header className="skills-section__category-header">
|
<header className="skills-section__category-header">
|
||||||
<h3
|
<h3
|
||||||
id={`category-${categoryIndex}-title`}
|
id={`category-${categoryIndex}-title`}
|
||||||
|
|
@ -140,7 +140,7 @@ export default function SkillsSection(props: SkillsSectionProps = {}) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
// Note: API configuration no longer needed since we're using direct mailto links
|
|
||||||
// This file is kept for reference but can be removed
|
|
||||||
|
|
||||||
export const API_CONFIG = {
|
|
||||||
BASE_URL: '', // No longer used
|
|
||||||
ENDPOINTS: {
|
|
||||||
CONTACT: '/api/contact', // No longer used
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export individual items for backward compatibility
|
|
||||||
export const ENDPOINTS = API_CONFIG.ENDPOINTS;
|
|
||||||
|
|
||||||
// Helper function to get full API URL
|
|
||||||
export const getApiUrl = (endpoint: string): string => {
|
|
||||||
return `${API_CONFIG.BASE_URL}${endpoint}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Default export
|
|
||||||
export default API_CONFIG;
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { certifications } from './locales/en/certifications';
|
import { certifications } from './locales/en/technical/certifications';
|
||||||
import type { Certification } from '../data/Certification';
|
import type { Certification } from '../data/Certification';
|
||||||
|
|
||||||
export const certificationsData: Certification[] = certifications.certificationItems
|
export const certificationsData: Certification[] = certifications.certificationItems
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export const contact = {
|
export const contact = {
|
||||||
title: 'Kontakt aufnehmen',
|
title: 'Kontakt aufnehmen',
|
||||||
subtitle: 'Lassen Sie uns Ihr Projekt besprechen und Ihre Ideen zum Leben erwecken',
|
subtitle: 'Besprechen Sie Ihr Projekt mit mir und wir erwecken Ihre Ideen zum Leben.',
|
||||||
connectTitle: 'Lassen Sie uns verbinden',
|
connectTitle: 'Verbindung schaffen',
|
||||||
connectDescription:
|
connectDescription:
|
||||||
'Ich bin immer an neuen Möglichkeiten und spannenden Projekten interessiert. Ob Sie eine Frage haben oder einfach nur Hallo sagen möchten, zögern Sie nicht, mich zu kontaktieren!',
|
'Ich bin immer an neuen Möglichkeiten und spannenden Projekten interessiert. Ob Sie eine Frage haben oder einfach nur Hallo sagen möchten, zögern Sie nicht, mich zu kontaktieren!',
|
||||||
buttonText: 'Per E-Mail kontaktieren',
|
buttonText: 'Per E-Mail kontaktieren',
|
||||||
|
|
@ -18,4 +18,8 @@ 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',
|
||||||
|
linkedinLinkText: 'Sascha Bach auf LinkedIn',
|
||||||
|
appointmentHintText: 'Lieber einen Termin buchen?',
|
||||||
|
appointmentLinkText: 'Jetzt Termin vereinbaren',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
export const footer = {
|
export const footer = {
|
||||||
imprintText: 'Impressum und Datenschutz',
|
imprintText: 'Impressum und Datenschutz',
|
||||||
copyrightText: 'Alle Rechte vorbehalten.',
|
copyrightText: 'Alle Rechte vorbehalten.',
|
||||||
githubAriaLabel: 'GitHub-Profil',
|
technicalLinkText: 'Technisches Portfolio',
|
||||||
|
landingLinkText: 'Landing Page',
|
||||||
|
codebergAriaLabel: 'Codeberg-Profil',
|
||||||
linkedinAriaLabel: 'LinkedIn-Profil',
|
linkedinAriaLabel: 'LinkedIn-Profil',
|
||||||
emailAriaLabel: 'E-Mail senden',
|
emailAriaLabel: 'E-Mail senden',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,15 @@ export const imprint = {
|
||||||
title: 'Impressum und Datenschutz',
|
title: 'Impressum und Datenschutz',
|
||||||
subtitle: 'Rechtliche Informationen',
|
subtitle: 'Rechtliche Informationen',
|
||||||
companyInfoTitle: 'Angaben gemäß § 5 DDG',
|
companyInfoTitle: 'Angaben gemäß § 5 DDG',
|
||||||
|
umsatzsteuerID: 'Umsatzsteuer-ID: DE316934637',
|
||||||
|
vatID: 'VAT ID: DE316934637',
|
||||||
|
businessRegistrationTitle: 'Gewerbeanmeldung',
|
||||||
|
businessRegistration: {
|
||||||
|
title: 'Gewerbeerlaubnis nach § 34c GewO',
|
||||||
|
dateIssued: '18.03.2026',
|
||||||
|
issuedBy: 'Bezirksamt Tempelhof-Schöneberg von Berlin',
|
||||||
|
authority: 'Ordnungsamt',
|
||||||
|
},
|
||||||
contactTitle: 'Kontakt',
|
contactTitle: 'Kontakt',
|
||||||
responsibilityTitle: 'Vertretungsberechtigt',
|
responsibilityTitle: 'Vertretungsberechtigt',
|
||||||
disclaimerTitle: 'Haftungsausschluss',
|
disclaimerTitle: 'Haftungsausschluss',
|
||||||
|
|
@ -116,10 +125,17 @@ export const imprint = {
|
||||||
name: 'Bitpalast GmbH',
|
name: 'Bitpalast GmbH',
|
||||||
address: 'Postfach 19 15 64, D-14005 Berlin, Deutschland',
|
address: 'Postfach 19 15 64, D-14005 Berlin, Deutschland',
|
||||||
website: 'https://preiswerter-webserver-de.bitpalast.net/',
|
website: 'https://preiswerter-webserver-de.bitpalast.net/',
|
||||||
|
privacyPolicyLabel: 'Datenschutzerklärung:',
|
||||||
},
|
},
|
||||||
contactTitle: 'Kontaktaufnahme',
|
contactTitle: 'Kontaktaufnahme',
|
||||||
contactText:
|
contactText:
|
||||||
'Soweit Sie uns über E-Mail, Soziale Medien, Telefon, Fax, Post, unser Kontaktformular oder sonstwie ansprechen und uns hierbei personenbezogene Daten wie Ihren Namen, Ihre Telefonnummer oder Ihre E-Mail-Adresse zur Verfügung stellen oder weitere Angaben zur Ihrer Person oder Ihrem Anliegen machen, verarbeiten wir diese Daten zur Beantwortung Ihrer Anfrage im Rahmen des zwischen uns bestehenden vorvertraglichen oder vertraglichen Beziehungen.',
|
'Soweit Sie uns über E-Mail, Soziale Medien, Telefon, Fax, Post, unser Kontaktformular oder sonstwie ansprechen und uns hierbei personenbezogene Daten wie Ihren Namen, Ihre Telefonnummer oder Ihre E-Mail-Adresse zur Verfügung stellen oder weitere Angaben zur Ihrer Person oder Ihrem Anliegen machen, verarbeiten wir diese Daten zur Beantwortung Ihrer Anfrage im Rahmen des zwischen uns bestehenden vorvertraglichen oder vertraglichen Beziehungen.',
|
||||||
|
contactDataLabels: {
|
||||||
|
affectedData: 'Betroffene Daten:',
|
||||||
|
affectedPersons: 'Betroffene Personen:',
|
||||||
|
processingPurpose: 'Verarbeitungszweck:',
|
||||||
|
legalBasis: 'Rechtsgrundlage:',
|
||||||
|
},
|
||||||
contactDataCategories: {
|
contactDataCategories: {
|
||||||
affectedData: [
|
affectedData: [
|
||||||
'Bestandsdaten (bspw. Namen, Adressen)',
|
'Bestandsdaten (bspw. Namen, Adressen)',
|
||||||
|
|
@ -134,12 +150,36 @@ export const imprint = {
|
||||||
legalBasis:
|
legalBasis:
|
||||||
'Vertragserfüllung und vorvertragliche Anfragen, Art. 6 Abs. 1 lit. b DSGVO, berechtigtes Interesse, Art. 6 Abs. 1 lit. f DSGVO',
|
'Vertragserfüllung und vorvertragliche Anfragen, Art. 6 Abs. 1 lit. b DSGVO, berechtigtes Interesse, Art. 6 Abs. 1 lit. f DSGVO',
|
||||||
},
|
},
|
||||||
|
onlineAppointmentTitle: 'Online-Terminbuchung (Nextcloud)',
|
||||||
|
onlineAppointmentIntro:
|
||||||
|
'Auf unserer Website bieten wir die Möglichkeit, über einen externen Link Termine für Beratungsgespräche oder Meetings zu buchen. Dafür nutzen wir den Terminplaner-Dienst der Open-Source-Software Nextcloud, die auf unseren Servern bei unserem Webhosting-Provider betrieben wird.',
|
||||||
|
onlineAppointmentProvider:
|
||||||
|
'Anbieter der Infrastruktur: Die Server, auf denen die Nextcloud-Instanz läuft, werden bereitgestellt von der Bitpalast GmbH, Postfach 19 15 64, D-14005 Berlin, Deutschland. Verantwortlich für die Datenverarbeitung im Rahmen der Terminbuchung bleibt jedoch allein Sascha Bach (siehe Abschnitt „Wer bei uns für die Datenverarbeitung verantwortlich ist").',
|
||||||
|
onlineAppointmentDataTitle:
|
||||||
|
'Erhobene Daten: Bei der Buchung eines Termins werden folgende personenbezogene Daten von Ihnen eingegeben und verarbeitet:',
|
||||||
|
onlineAppointmentData: [
|
||||||
|
'Name (Vor- und Nachname)',
|
||||||
|
'E-Mail-Adresse',
|
||||||
|
'Ggf. Telefonnummer (sofern im Buchungsformular als optionales Feld hinterlegt)',
|
||||||
|
'Ggf. Kommentare oder Anmerkungen zum Termininhalt',
|
||||||
|
'Der gewählte Terminzeitraum',
|
||||||
|
],
|
||||||
|
onlineAppointmentPurpose:
|
||||||
|
'Zweck der Verarbeitung: Die Daten werden ausschließlich zum Zweck der Terminvereinbarung, der automatischen Terminbestätigung per E-Mail und der Erinnerung an den vereinbarten Termin verarbeitet. Ohne Bereitstellung dieser Daten ist die Nutzung der Buchungsfunktion nicht möglich.',
|
||||||
|
onlineAppointmentLegalBasis:
|
||||||
|
'Rechtsgrundlage: Die Verarbeitung der Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO (Erfüllung vorvertraglicher Maßnahmen). Die Buchung eines Termins stellt eine konkrete Anfrage zur Anbahnung oder Durchführung eines Vertragsverhältnisses dar.',
|
||||||
|
onlineAppointmentRetention:
|
||||||
|
'Speicherdauer: Die Daten werden nach Durchführung des Termins und Ablauf etwaiger gesetzlicher Aufbewahrungsfristen (z. B. aus dem Steuerrecht: 6 Jahre für Geschäftskorrespondenz, 10 Jahre für Buchungsbelege) gelöscht. Sofern kein Vertragsverhältnis zustande kommt, werden die Daten spätestens nach Ablauf von 3 Monaten gelöscht, es sei denn, Sie haben in eine längere Speicherung eingewilligt.',
|
||||||
|
onlineAppointmentThirdParty:
|
||||||
|
'Weitergabe: Eine Weitergabe der Daten an Dritte erfolgt nicht, außer dies ist zur Erfüllung des Termins gesetzlich erforderlich oder Sie haben hierzu ausdrücklich eingewilligt. Die Daten verbleiben auf Servern innerhalb der Europäischen Union (Deutschland).',
|
||||||
|
onlineAppointmentServerLocation:
|
||||||
|
'Serverstandort: Die Daten werden auf Servern in Deutschland gespeichert.',
|
||||||
securityTitle: 'Sicherheitsmaßnahmen',
|
securityTitle: 'Sicherheitsmaßnahmen',
|
||||||
securityText:
|
securityText:
|
||||||
'Wir treffen im Übrigen technische und organisatorische Sicherheitsmaßnahmen nach dem Stand der Technik, um die Vorschriften der Datenschutzgesetze einzuhalten und Ihre Daten gegen zufällige oder vorsätzliche Manipulationen, teilweisen oder vollständigen Verlust, Zerstörung oder gegen den unbefugten Zugriff Dritter zu schützen.',
|
'Wir treffen im Übrigen technische und organisatorische Sicherheitsmaßnahmen nach dem Stand der Technik, um die Vorschriften der Datenschutzgesetze einzuhalten und Ihre Daten gegen zufällige oder vorsätzliche Manipulationen, teilweisen oder vollständigen Verlust, Zerstörung oder gegen den unbefugten Zugriff Dritter zu schützen.',
|
||||||
changesTitle: 'Aktualität und Änderung dieser Datenschutzerklärung',
|
changesTitle: 'Aktualität und Änderung dieser Datenschutzerklärung',
|
||||||
changesText:
|
changesText:
|
||||||
'Diese Datenschutzerklärung ist aktuell gültig und hat den Stand September 2025. Aufgrund geänderter gesetzlicher bzw. behördlicher Vorgaben kann es notwendig werden, diese Datenschutzerklärung anzupassen.',
|
'Diese Datenschutzerklärung ist aktuell gültig und hat den Stand Mai 2026. Aufgrund geänderter gesetzlicher bzw. behördlicher Vorgaben kann es notwendig werden, diese Datenschutzerklärung anzupassen.',
|
||||||
disclaimer:
|
disclaimer:
|
||||||
'Diese Datenschutzerklärung wurde mit Hilfe des Datenschutz-Generators von SOS Recht erstellt. SOS Recht ist ein Angebot der Mueller.legal Rechtsanwälte Partnerschaft mit Sitz in Berlin.',
|
'Diese Datenschutzerklärung wurde mit Hilfe des Datenschutz-Generators von SOS Recht erstellt. SOS Recht ist ein Angebot der Mueller.legal Rechtsanwälte Partnerschaft mit Sitz in Berlin.',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
import { navigation } from './navigation';
|
import { navigation } from './navigation';
|
||||||
import { themeToggle } from '../../theme-toggle';
|
import { themeToggle } from '../../theme-toggle';
|
||||||
import { footer } from './footer';
|
import { footer } from './footer';
|
||||||
import { hero } from './hero';
|
import { hero } from './technical/hero';
|
||||||
import { about } from './about';
|
import { about } from './technical/about';
|
||||||
import { services } from './services';
|
import { landingAboutMe } from './landing/landing-aboutme';
|
||||||
import { skills } from './skills';
|
import { landingHero } from './landing/landing-hero';
|
||||||
|
import { landingReasons } from './landing/landing-reasons';
|
||||||
|
import { landingExperience } from './landing/landing-experience';
|
||||||
|
import { landingWinnings } from './landing/landing-winnings';
|
||||||
|
import { landingProcesses } from './landing/landing-processes';
|
||||||
|
import { landingReferences } from './landing/landing-references';
|
||||||
|
import { services } from './technical/services';
|
||||||
|
import { skills } from './technical/skills';
|
||||||
import { projects } from './projects';
|
import { projects } from './projects';
|
||||||
import { certifications } from './certifications';
|
import { certifications } from './technical/certifications';
|
||||||
import { contact } from './contact';
|
import { contact } from './contact';
|
||||||
import { imprint } from './imprint';
|
import { imprint } from './imprint';
|
||||||
import { privacyPolicy } from './privacy-policy';
|
import { privacyPolicy } from './privacy-policy';
|
||||||
|
|
@ -18,6 +25,13 @@ export const de = {
|
||||||
footer,
|
footer,
|
||||||
hero,
|
hero,
|
||||||
about,
|
about,
|
||||||
|
landingAboutMe,
|
||||||
|
landingHero,
|
||||||
|
landingReasons,
|
||||||
|
landingExperience,
|
||||||
|
landingWinnings,
|
||||||
|
landingProcesses,
|
||||||
|
landingReferences,
|
||||||
services,
|
services,
|
||||||
skills,
|
skills,
|
||||||
projects,
|
projects,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
export const landingAboutMe = {
|
||||||
|
title: 'Über mich',
|
||||||
|
subtitle: '',
|
||||||
|
greeting: 'Hi, ich bin Sascha.',
|
||||||
|
name: 'Sascha',
|
||||||
|
bio: [
|
||||||
|
`Seit ich zum ersten Mal mit dem Thema Barrierefreiheit in Berührung gekommen bin, habe ich
|
||||||
|
für mich einen Mehrwert in der Webentwicklung entdeckt, der mich seitdem nicht mehr loslässt.
|
||||||
|
Barrierefreiheit ist für mich nicht nur ein technisches Thema, sondern eine Angelegenheit, die mit meinen persönlichen Werten
|
||||||
|
von Inklusion und Gleichberechtigung übereinstimmt.
|
||||||
|
Ich helfe Webagenturen und Unternehmen bei der Entwicklung und Umsetzung von Apps und Webseiten mit und ohne Barrierefreiheit.`,
|
||||||
|
`In meiner Freizeit tanze ich Brasilianischen Zouk und West Coast Swing. Ich beschäftige mich mit alternativen
|
||||||
|
Beziehungsformen und bin ein großer Fan von Science-Fiction-Filmen. Außerdem interessiere ich mich für Persönlichkeitsentwicklung und
|
||||||
|
Ethik in der Technologie. Ich bin immer offen für neue Kontakte und spannende Projekte, also zögert nicht, mich zu kontaktieren!`,
|
||||||
|
],
|
||||||
|
featureCards: [] as Array<{ title: string; description: string; }>,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export const landingExperience = {
|
||||||
|
title: 'Meine Erfahrung',
|
||||||
|
paragraphs: [
|
||||||
|
'Ich habe Unternehmen wie deins geholfen – von kleinen Läden bis hin zu Dienstleistern – ihre Webseiten barrierefrei zu machen.',
|
||||||
|
'Bei ORWO habe ich einen Fotobuch-Designer entwickelt, der für tausende Kunden funktionierte, auch für Menschen mit Sehbeeinträchtigungen. Ich habe ihn nicht nur gut aussehen lassen, ich habe ihn für alle zum Laufen gebracht.',
|
||||||
|
'Diese Sorgfalt bringe ich jetzt zu deiner Webseite, egal ob Bäckerei, Therapeut, Handwerker oder Online-Shop.',
|
||||||
|
'Barrierefreiheit bedeutet Respekt und mehr Reichweite für dich und deine Produkte.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export const landingHero = {
|
||||||
|
title: 'Barrierefreiheit: 20\u00A0% mehr Reichweite, 100\u00A0% mehr Zugang.',
|
||||||
|
description: 'Das BFSG gilt seit Juni 2025. Ich mache deine Website barrierefrei und rechtssicher – bevor Haftungsrisiken entstehen.',
|
||||||
|
tertiaryButtonText: 'Kontakt aufnehmen',
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export const landingProcesses = {
|
||||||
|
title: 'Wie ich arbeite',
|
||||||
|
intro: 'Ich bin dein Partner.',
|
||||||
|
items: [
|
||||||
|
{ text: 'Ich arbeite meist remote - weniger Meetings, weniger Pendelzeit, weniger Aufwand.' },
|
||||||
|
{ text: 'Wir vereinbaren einen Zeitplan - ich liefere.' },
|
||||||
|
{ text: 'Ich nutze meine eigenen Tools - kein Setup auf deiner Seite nötig.' },
|
||||||
|
{ text: 'Ich bin flexibel - egal ob du eine schnelle Lösung oder ein komplettes Redesign brauchst.' },
|
||||||
|
],
|
||||||
|
outro: 'Du bekommst meine volle Aufmerksamkeit.',
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
export const landingReasons = {
|
||||||
|
title: 'Warum es wichtig ist:',
|
||||||
|
|
||||||
|
reasons: [
|
||||||
|
{
|
||||||
|
stat: '20\u00A0%',
|
||||||
|
text: 'aller Menschen in Deutschland haben eine Beeinträchtigung. Das sind 16 Millionen potenzielle Kunden.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: '80\u00A0%',
|
||||||
|
text: 'dieser Menschen verlassen Webseiten, die nicht für sie funktionieren.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: 'BFSG',
|
||||||
|
text: 'Seit Juni 2025 sind viele Unternehmen gesetzlich verpflichtet. Wer nicht handelt, riskiert Abmahnungen und Haftung.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: 'SEO',
|
||||||
|
text: 'Google belohnt barrierefreie Webseiten mit besseren Rankings, mehr Traffic und mehr Umsatz.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: 'Vertrauen',
|
||||||
|
text: 'Kunden vertrauen barrierefreien Webseiten mehr. Sie fühlen sich respektiert, nicht ignoriert.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outro:
|
||||||
|
'Du tust nicht nur das Richtige. Du machst den Unterschied.',
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
export const landingReferences = {
|
||||||
|
title: 'Was Kunden und Kollegen sagen',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
referenceText:
|
||||||
|
'Das Audit zur digitalen Barrierefreiheit war äußerst aufschlussreich und praxisnah. Die identifizierten Schwachstellen wurden klar und verständlich erklärt, sodass konkrete Verbesserungsmaßnahmen abgeleitet werden konnten. Besonders positiv ist der ganzheitliche Blick auf Nutzerfreundlichkeit und Inklusion. Die Zusammenarbeit ist sehr empfehlenswert für alle, die ihre Website nachhaltig barrierefrei gestalten möchten.',
|
||||||
|
name: 'Florian Adragna',
|
||||||
|
position: 'Freiberufler, Florian Lucas Adragna',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
referenceText:
|
||||||
|
'Ich habe die Zusammenarbeit mit dir immer sehr geschätzt. Bei der Fehlersuche und -behebung warst du besonders schnell und zuverlässig. Auch in neue Themen hast du dich problemlos und zügig eingearbeitet. Mit dir zu arbeiten war fachlich wie menschlich jederzeit angenehm – ich empfehle dich sehr gerne weiter.',
|
||||||
|
name: 'Kollege',
|
||||||
|
position: 'Teamlead, ORWO Net GmbH',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
export const landingWinnings = {
|
||||||
|
title: 'Was du bekommst',
|
||||||
|
intro: 'Wenn du mit mir arbeitest, bekommst du:',
|
||||||
|
items: [
|
||||||
|
{ text: 'Eine Webseite, die für alle funktioniert, egal wie sie surfen.' },
|
||||||
|
{ text: 'Mehr Besucher, denn Google liebt barrierefreie Seiten.' },
|
||||||
|
{ text: 'Mehr Umsatz, denn Menschen kaufen, wenn sie sich einbezogen fühlen.' },
|
||||||
|
{ text: 'Mehr Vertrauen, denn deine Kunden fühlen sich respektiert.' },
|
||||||
|
{ text: 'Rechtssicherheit, denn ich stelle sicher, dass deine Webseite die BFSG-Anforderungen erfüllt.' },
|
||||||
|
{ text: 'Entspannung, denn ich kümmere mich um alles, vom Testen bis zur Konformitätsprüfung.' },
|
||||||
|
{ text: 'Kein Tech-Stress, denn ich erkläre alles in einfacher Sprache, ohne Fachjargon.' },
|
||||||
|
],
|
||||||
|
outro: 'Ich stelle sicher, dass ich dein Problem verstehe und meine Lösung passt.',
|
||||||
|
};
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
export const navigation = {
|
export const navigation = {
|
||||||
menuItems: [
|
menuItems: [
|
||||||
'Über mich',
|
{ section: 'About', label: 'Über mich' },
|
||||||
'Leistungen',
|
{ section: 'Services', label: 'Leistungen' },
|
||||||
'Fähigkeiten',
|
{ section: 'Skills', label: 'Fähigkeiten' },
|
||||||
'Zertifikate',
|
{ section: 'Certifications', label: 'Zertifikate' },
|
||||||
'Projekte',
|
{ section: 'Projects', label: 'Projekte' },
|
||||||
'Kontakt',
|
{ section: 'Contact', label: 'Kontakt' },
|
||||||
|
],
|
||||||
|
landingMenuItems: [
|
||||||
|
{ section: 'About', label: 'Über mich' },
|
||||||
|
{ section: 'Winnings', label: 'Vorteile' },
|
||||||
|
{ section: 'Experience', label: 'Erfahrung' },
|
||||||
|
{ section: 'Processes', label: 'Wie ich arbeite' },
|
||||||
|
{ section: 'Projects', label: 'Projekte' },
|
||||||
|
{ section: 'References', label: 'Referenzen' },
|
||||||
|
{ section: 'Contact', label: 'Kontakt' },
|
||||||
],
|
],
|
||||||
mobileMenuAriaLabel: 'Menü öffnen',
|
mobileMenuAriaLabel: 'Menü öffnen',
|
||||||
|
logoAlt: 'Sascha Bach – Barrierefreie Webentwicklung',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export const about = {
|
||||||
bio: [
|
bio: [
|
||||||
'Ich habe Informatik an der TU Darmstadt studiert und 2016 meinen Bachelor mit einer Arbeit über Motion Sickness in Virtual Reality abgeschlossen. Seitdem arbeite ich als Software- und Webentwickler mit Schwerpunkt auf Angular und React.',
|
'Ich habe Informatik an der TU Darmstadt studiert und 2016 meinen Bachelor mit einer Arbeit über Motion Sickness in Virtual Reality abgeschlossen. Seitdem arbeite ich als Software- und Webentwickler mit Schwerpunkt auf Angular und React.',
|
||||||
'In meiner Arbeit habe ich gesehen, dass jedes Unternehmen anders ist, aber viele stehen vor der gleichen Herausforderung: ihre digitale Präsenz zugänglich, modern und zukunftssicher zu halten. Vielen Unternehmen ist bereits bewusst, dass Barrierefreiheit gesetzlich vorgeschrieben ist, aber in der Praxis kommt es vor allem darauf an, dass eine Website für alle Nutzer einfach und angenehm funktioniert.',
|
'In meiner Arbeit habe ich gesehen, dass jedes Unternehmen anders ist, aber viele stehen vor der gleichen Herausforderung: ihre digitale Präsenz zugänglich, modern und zukunftssicher zu halten. Vielen Unternehmen ist bereits bewusst, dass Barrierefreiheit gesetzlich vorgeschrieben ist, aber in der Praxis kommt es vor allem darauf an, dass eine Website für alle Nutzer einfach und angenehm funktioniert.',
|
||||||
'Meine Spezialisierung liegt in Web Accessibility (WCAG 2.1, BFSG, European Accessibility Act). Ich helfe Unternehmen dabei, ihre Websites und Anwendungen zu aktualisieren, sodass sie konform und gleichzeitig für jeden einfach zu bedienen sind. Was meine Kunden schätzen, ist, dass ich technisches Fachwissen in Angular, React und TypeScript mit einem menschenzentrierten Ansatz verbinde. Denn Barrierefreiheit geht nicht nur um Code, sondern um Menschen.',
|
'Meine Spezialisierung liegt in Web Accessibility (WCAG 2.2, BFSG, European Accessibility Act). Ich helfe Unternehmen dabei, ihre Websites und Anwendungen zu aktualisieren, sodass sie konform und gleichzeitig für jeden einfach zu bedienen sind. Was meine Kunden schätzen, ist, dass ich technisches Fachwissen in Angular, React und TypeScript mit einem menschenzentrierten Ansatz verbinde. Denn Barrierefreiheit geht nicht nur um Code, sondern um Menschen.',
|
||||||
'Wenn Sie sich fragen, ob Ihre Website bereits den Standards für Barrierefreiheit entspricht, oder wenn Sie das Gefühl haben, dass es Zeit ist, Ihre Online-Präsenz inklusiver und rechtssicher zu gestalten, würde ich Sie gerne unterstützen. Oft können bereits wenige klare Schritte einen großen Unterschied machen und Ihrer Website helfen, mehr Menschen zu erreichen und sich von der Konkurrenz abzuheben.',
|
'Wenn Sie sich fragen, ob Ihre Website bereits den Standards für Barrierefreiheit entspricht, oder wenn Sie das Gefühl haben, dass es Zeit ist, Ihre Online-Präsenz inklusiver und rechtssicher zu gestalten, würde ich Sie gerne unterstützen. Oft können bereits wenige klare Schritte einen großen Unterschied machen und Ihrer Website helfen, mehr Menschen zu erreichen und sich von der Konkurrenz abzuheben.',
|
||||||
'Lassen Sie uns verbinden und schauen, wie wir Ihre Website barrierefrei, modern und zukunftssicher machen können.',
|
'Lassen Sie uns verbinden und schauen, wie wir Ihre Website barrierefrei, modern und zukunftssicher machen können.',
|
||||||
],
|
],
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { certificationItems } from '../../certifications-data';
|
import { certificationItems } from '../../../certifications-data';
|
||||||
|
|
||||||
export const certifications = {
|
export const certifications = {
|
||||||
title: 'Zertifizierungen & Erfolge',
|
title: 'Zertifizierungen & Erfolge',
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
export const hero = {
|
export const hero = {
|
||||||
title: 'Jeder verdient ein reibungsloses Online-Erlebnis',
|
title: 'Barrierefreie Webentwicklung für alle',
|
||||||
description:
|
description:
|
||||||
'Ich helfe Ihnen, barrierefreie Websites zu erstellen, die jeden Besucher einbeziehen, im Einklang mit dem Barrierefreiheitsstärkungsgesetz.',
|
'Ich helfe Ihnen, barrierefreie Websites zu erstellen, die jeden Besucher einbeziehen, im Einklang mit dem Barrierefreiheitsstärkungsgesetz.',
|
||||||
primaryButtonText: 'Projekte ansehen',
|
primaryButtonText: 'Projekte ansehen',
|
||||||
|
|
@ -0,0 +1,359 @@
|
||||||
|
import type { MenuItem } from '../../../data/types';
|
||||||
|
|
||||||
|
export interface TextConfig {
|
||||||
|
// Navigation
|
||||||
|
navigation: {
|
||||||
|
menuItems: MenuItem[];
|
||||||
|
landingMenuItems: MenuItem[];
|
||||||
|
mobileMenuAriaLabel: string;
|
||||||
|
logoAlt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Theme Toggle
|
||||||
|
themeToggle: {
|
||||||
|
lightIcon: string;
|
||||||
|
darkIcon: string;
|
||||||
|
lightModeText: string;
|
||||||
|
darkModeText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
footer: {
|
||||||
|
imprintText: string;
|
||||||
|
copyrightText: string;
|
||||||
|
technicalLinkText: string;
|
||||||
|
landingLinkText: string;
|
||||||
|
codebergAriaLabel: string;
|
||||||
|
linkedinAriaLabel: string;
|
||||||
|
emailAriaLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hero Section
|
||||||
|
hero: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryButtonText: string;
|
||||||
|
secondaryButtonText: string;
|
||||||
|
tertiaryButtonText: string;
|
||||||
|
statItems: Array<{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// About Section
|
||||||
|
about: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
name: string;
|
||||||
|
bio: string[];
|
||||||
|
featureCards: Array<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Landing Hero Section
|
||||||
|
landingHero: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
tertiaryButtonText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Landing About Me Section
|
||||||
|
landingAboutMe: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
greeting: string;
|
||||||
|
name: string;
|
||||||
|
bio: string[];
|
||||||
|
featureCards: Array<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Landing Reasons Section
|
||||||
|
landingReasons: {
|
||||||
|
title: string;
|
||||||
|
reasons: Array<{
|
||||||
|
stat: string;
|
||||||
|
text: string;
|
||||||
|
}>;
|
||||||
|
outro: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Landing Experience Section
|
||||||
|
landingExperience: {
|
||||||
|
title: string;
|
||||||
|
paragraphs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Landing Winnings Section
|
||||||
|
landingWinnings: {
|
||||||
|
title: string;
|
||||||
|
intro: string;
|
||||||
|
items: Array<{ text: string; }>;
|
||||||
|
outro: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Landing Processes Section
|
||||||
|
landingProcesses: {
|
||||||
|
title: string;
|
||||||
|
intro: string;
|
||||||
|
items: Array<{ text: string; }>;
|
||||||
|
outro: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Landing References Section
|
||||||
|
landingReferences: {
|
||||||
|
title: string;
|
||||||
|
items: Array<{
|
||||||
|
referenceText: string;
|
||||||
|
name: string;
|
||||||
|
position: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Services Section
|
||||||
|
services: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
serviceItems: Array<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skills Section
|
||||||
|
skills: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
skillCategories: Array<{
|
||||||
|
title: string;
|
||||||
|
skills: Array<{
|
||||||
|
skill: string;
|
||||||
|
value: number;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Projects Section
|
||||||
|
projects: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
codeButtonText: string;
|
||||||
|
liveButtonText: string;
|
||||||
|
projectItems: Array<{
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Certifications Section
|
||||||
|
certifications: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
certificationItems: Array<{
|
||||||
|
name: string;
|
||||||
|
issuer: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Contact Section
|
||||||
|
contact: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
connectTitle: string;
|
||||||
|
connectDescription: string;
|
||||||
|
buttonText: string;
|
||||||
|
socialTitle: string;
|
||||||
|
availabilityText: string;
|
||||||
|
responseTimeLabel: string;
|
||||||
|
responseTimeValue: string;
|
||||||
|
preferredContactLabel: string;
|
||||||
|
preferredContactValue: string;
|
||||||
|
locationLabel: string;
|
||||||
|
locationValue: string;
|
||||||
|
emailSubject: string;
|
||||||
|
emailBody: string;
|
||||||
|
emailAnnouncement: string;
|
||||||
|
socialAnnouncement: string;
|
||||||
|
codebergLinkText: string;
|
||||||
|
linkedinLinkText: string;
|
||||||
|
appointmentHintText: string;
|
||||||
|
appointmentLinkText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Imprint Page
|
||||||
|
imprint: {
|
||||||
|
umsatzsteuerID: string;
|
||||||
|
vatID: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
companyInfoTitle: string;
|
||||||
|
businessRegistrationTitle: string;
|
||||||
|
businessRegistration: {
|
||||||
|
title: string;
|
||||||
|
dateIssued: string;
|
||||||
|
issuedBy: string;
|
||||||
|
authority: string;
|
||||||
|
};
|
||||||
|
contactTitle: string;
|
||||||
|
responsibilityTitle: string;
|
||||||
|
disclaimerTitle: string;
|
||||||
|
contentLiabilityTitle: string;
|
||||||
|
contentLiabilityText: string[];
|
||||||
|
copyrightTitle: string;
|
||||||
|
copyrightText: string[];
|
||||||
|
privacyTitle: string;
|
||||||
|
privacyText: string;
|
||||||
|
detailedPrivacyPolicy: {
|
||||||
|
title: string;
|
||||||
|
introduction: string;
|
||||||
|
responsibleTitle: string;
|
||||||
|
responsibleText: string;
|
||||||
|
responsibleContact: string;
|
||||||
|
dataProtectionOfficerTitle: string;
|
||||||
|
dataProtectionOfficerText: string;
|
||||||
|
rightsTitle: string;
|
||||||
|
rightsIntro: string;
|
||||||
|
rights: Array<{ title: string; text: string; }>;
|
||||||
|
revocationTitle: string;
|
||||||
|
revocationText: string;
|
||||||
|
objectionTitle: string;
|
||||||
|
objectionText: string;
|
||||||
|
objectionHighlight: string;
|
||||||
|
objectionContact: string;
|
||||||
|
dataDeletionTitle: string;
|
||||||
|
dataDeletionIntro: string;
|
||||||
|
dataDeletionReasons: string[];
|
||||||
|
retentionText: string;
|
||||||
|
webhostingTitle: string;
|
||||||
|
webhostingText: string;
|
||||||
|
webhostingDataTypes: string[];
|
||||||
|
webhostingPurpose: string;
|
||||||
|
webhostingDataCategories: {
|
||||||
|
affectedData: string;
|
||||||
|
affectedDataList: string[];
|
||||||
|
affectedPersons: string;
|
||||||
|
processingPurpose: string;
|
||||||
|
legalBasis: string;
|
||||||
|
provider: string;
|
||||||
|
};
|
||||||
|
hostingProvider: {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
website: string;
|
||||||
|
privacyPolicyLabel: string;
|
||||||
|
};
|
||||||
|
contactTitle: string;
|
||||||
|
contactText: string;
|
||||||
|
contactDataLabels: {
|
||||||
|
affectedData: string;
|
||||||
|
affectedPersons: string;
|
||||||
|
processingPurpose: string;
|
||||||
|
legalBasis: string;
|
||||||
|
};
|
||||||
|
contactDataCategories: {
|
||||||
|
affectedData: string[];
|
||||||
|
affectedPersons: string;
|
||||||
|
processingPurpose: string;
|
||||||
|
legalBasis: string;
|
||||||
|
};
|
||||||
|
onlineAppointmentTitle: string;
|
||||||
|
onlineAppointmentIntro: string;
|
||||||
|
onlineAppointmentProvider: string;
|
||||||
|
onlineAppointmentDataTitle: string;
|
||||||
|
onlineAppointmentData: string[];
|
||||||
|
onlineAppointmentPurpose: string;
|
||||||
|
onlineAppointmentLegalBasis: string;
|
||||||
|
onlineAppointmentRetention: string;
|
||||||
|
onlineAppointmentThirdParty: string;
|
||||||
|
onlineAppointmentServerLocation: string;
|
||||||
|
securityTitle: string;
|
||||||
|
securityText: string;
|
||||||
|
changesTitle: string;
|
||||||
|
changesText: string;
|
||||||
|
disclaimer: string;
|
||||||
|
};
|
||||||
|
address: {
|
||||||
|
street: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
contact: {
|
||||||
|
phone: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Privacy Policy Page
|
||||||
|
privacyPolicy: {
|
||||||
|
title: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
introTitle: string;
|
||||||
|
introText: string;
|
||||||
|
dataCollectionTitle: string;
|
||||||
|
dataCollectionText: string;
|
||||||
|
dataCollectionList: string[];
|
||||||
|
webhostingTitle: string;
|
||||||
|
webhostingText: string;
|
||||||
|
webhostingProvider: string;
|
||||||
|
webhostingProviderName: string;
|
||||||
|
webhostingProviderAddress: string;
|
||||||
|
webhostingProviderWebsite: string;
|
||||||
|
rightsTitle: string;
|
||||||
|
rightsText: string;
|
||||||
|
rightsList: string[];
|
||||||
|
contactTitle: string;
|
||||||
|
contactText: string;
|
||||||
|
contactEmail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Accessibility and Navigation
|
||||||
|
accessibility: {
|
||||||
|
skipLinks: {
|
||||||
|
skipToHero: string;
|
||||||
|
skipToAbout: string;
|
||||||
|
skipToServices: string;
|
||||||
|
skipToSkills: string;
|
||||||
|
skipToProjects: string;
|
||||||
|
skipToCertifications: string;
|
||||||
|
skipToContact: string;
|
||||||
|
};
|
||||||
|
navigation: {
|
||||||
|
keyboardHelp: string;
|
||||||
|
keyboardHelpExpanded: string[];
|
||||||
|
useTabToNavigate: string;
|
||||||
|
useTabToNavigateCards: string;
|
||||||
|
useTabToNavigateOptions: string;
|
||||||
|
closeKeyboardHelp: string;
|
||||||
|
};
|
||||||
|
screenReader: {
|
||||||
|
professionalStatistics: string;
|
||||||
|
projectCard: string;
|
||||||
|
viewSourceCode: string;
|
||||||
|
viewLiveDemo: string;
|
||||||
|
downloadResume: string;
|
||||||
|
downloadResumeDescription: string;
|
||||||
|
keyboardNavigationHelp: string;
|
||||||
|
themeSwitchButton: string;
|
||||||
|
switchToTheme: string;
|
||||||
|
certificationCard: string;
|
||||||
|
serviceCard: string;
|
||||||
|
skillBadge: string;
|
||||||
|
};
|
||||||
|
ariaLabels: {
|
||||||
|
mainNavigation: string;
|
||||||
|
socialLinks: string;
|
||||||
|
themeToggle: string;
|
||||||
|
mobileMenuToggle: string;
|
||||||
|
contactForm: string;
|
||||||
|
projectsGrid: string;
|
||||||
|
skillsGrid: string;
|
||||||
|
certificationsGrid: string;
|
||||||
|
servicesGrid: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -18,4 +18,8 @@ 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',
|
||||||
|
linkedinLinkText: 'Sascha Bach on LinkedIn',
|
||||||
|
appointmentHintText: 'Prefer to schedule a call?',
|
||||||
|
appointmentLinkText: 'Book an appointment',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
export const footer = {
|
export const footer = {
|
||||||
imprintText: 'Impressum und Datenschutz',
|
imprintText: 'Impressum und Datenschutz',
|
||||||
copyrightText: 'All rights reserved.',
|
copyrightText: 'All rights reserved.',
|
||||||
githubAriaLabel: 'GitHub Profile',
|
technicalLinkText: 'Technical Portfolio',
|
||||||
|
landingLinkText: 'Landing Page',
|
||||||
|
codebergAriaLabel: 'Codeberg Profile',
|
||||||
linkedinAriaLabel: 'LinkedIn Profile',
|
linkedinAriaLabel: 'LinkedIn Profile',
|
||||||
emailAriaLabel: 'Send Email',
|
emailAriaLabel: 'Send Email',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,152 +1,192 @@
|
||||||
export const imprint = {
|
export const imprint = {
|
||||||
title: 'Impressum und Datenschutz',
|
title: 'Imprint and Privacy Policy',
|
||||||
subtitle: 'Legal Information',
|
subtitle: 'Legal Information',
|
||||||
companyInfoTitle: 'Angaben gemäß § 5 DDG',
|
companyInfoTitle: 'Information pursuant to § 5 DDG',
|
||||||
contactTitle: 'Kontakt',
|
umsatzsteuerID: 'Umsatzsteuer-ID: DE316934637',
|
||||||
responsibilityTitle: 'Vertretungsberechtigt',
|
vatID: 'VAT ID: DE316934637',
|
||||||
disclaimerTitle: 'Haftungsausschluss',
|
businessRegistrationTitle: 'Business Registration',
|
||||||
contentLiabilityTitle: 'Haftung für Inhalte',
|
businessRegistration: {
|
||||||
|
title: 'Business License pursuant to § 34c GewO',
|
||||||
|
dateIssued: '18.03.2026',
|
||||||
|
issuedBy: 'District Office Tempelhof-Schöneberg of Berlin',
|
||||||
|
authority: 'Regulatory Authority',
|
||||||
|
},
|
||||||
|
contactTitle: 'Contact',
|
||||||
|
responsibilityTitle: 'Authorized Representative',
|
||||||
|
disclaimerTitle: 'Disclaimer',
|
||||||
|
contentLiabilityTitle: 'Liability for Content',
|
||||||
contentLiabilityText: [
|
contentLiabilityText: [
|
||||||
'Als Diensteanbieter sind wir gemäß § 7 Abs.1 DDG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht unter der Verpflichtung, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.',
|
'As a service provider, we are responsible for our own content on these pages in accordance with general laws pursuant to § 7 para. 1 DDG. According to §§ 8 to 10 TMG, however, we as a service provider are not obligated to monitor transmitted or stored third-party information or to investigate circumstances that indicate illegal activity.',
|
||||||
'Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.',
|
'Obligations to remove or block the use of information under general laws remain unaffected. However, liability in this regard only arises from the moment of knowledge of a specific legal violation. Upon becoming aware of such infringements, we will remove this content immediately.',
|
||||||
],
|
],
|
||||||
copyrightTitle: 'Urheberrecht',
|
copyrightTitle: 'Copyright',
|
||||||
copyrightText: [
|
copyrightText: [
|
||||||
'Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers.',
|
'The content and works created by the website operators on these pages are subject to German copyright law. The reproduction, editing, distribution, and any kind of exploitation outside the limits of copyright law require the written consent of the respective author or creator.',
|
||||||
'Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet. Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen.',
|
'Downloads and copies of this website are only permitted for private and non-commercial use. Where the content on this website was not created by the operator, the copyrights of third parties are respected. Third-party content is identified as such. Should you nevertheless notice a copyright infringement, please notify us accordingly. Upon becoming aware of infringements, we will remove such content immediately.',
|
||||||
],
|
],
|
||||||
privacyTitle: 'Datenschutz',
|
privacyTitle: 'Privacy',
|
||||||
privacyText:
|
privacyText:
|
||||||
'Die Nutzung unserer Webseite ist in der Regel ohne Angabe personenbezogener Daten möglich. Soweit auf unseren Seiten personenbezogene Daten (beispielsweise Name, Anschrift oder E-Mail-Adressen) erhoben werden, erfolgt dies, soweit möglich, stets auf freiwilliger Basis. Diese Daten werden ohne Ihre ausdrückliche Zustimmung nicht an Dritte weitergegeben.',
|
'The use of our website is generally possible without providing personal data. To the extent that personal data (such as name, address, or email addresses) is collected on our website, this is done, wherever possible, on a voluntary basis. This data will not be passed on to third parties without your explicit consent.',
|
||||||
detailedPrivacyPolicy: {
|
detailedPrivacyPolicy: {
|
||||||
title: 'Datenschutzerklärung',
|
title: 'Privacy Policy',
|
||||||
introduction:
|
introduction:
|
||||||
'Mit dieser Datenschutzerklärung möchten wir Sie über Art, Umfang und Zweck der Verarbeitung von personenbezogenen Daten (im Folgenden auch nur als "Daten" bezeichnet) aufklären. Personenbezogene Daten sind alle Daten, die einen persönlichen Bezug zu Ihnen aufweisen, z. B. Name, Adresse, E-Mail-Adresse oder Ihr Nutzerverhalten. Die Datenschutzerklärung gilt für alle von uns vorgenommene Daten-Verarbeitungsvorgänge sowohl im Rahmen unserer Kerntätigkeit als auch für die von uns vorgehaltenen Online-Medien.',
|
'With this privacy policy, we would like to inform you about the nature, scope, and purpose of the processing of personal data (hereinafter simply referred to as "data"). Personal data is all data that has a personal reference to you, such as name, address, email address, or your user behavior. This privacy policy applies to all data processing operations carried out by us, both within the scope of our core activities and for the online media we maintain.',
|
||||||
responsibleTitle:
|
responsibleTitle:
|
||||||
'Wer bei uns für die Datenverarbeitung verantwortlich ist',
|
'Who is responsible for data processing at our company',
|
||||||
responsibleText: 'Verantwortlich für die Datenverarbeitung ist:',
|
responsibleText: 'Responsible for data processing is:',
|
||||||
responsibleContact:
|
responsibleContact:
|
||||||
'Sascha Bach\nAlt-Tempelhof 15\n12099 Berlin\nDE\n01799364867\ninfo@sascha-bach.de',
|
'Sascha Bach\nAlt-Tempelhof 15\n12099 Berlin\nDE\n01799364867\ninfo@sascha-bach.de',
|
||||||
dataProtectionOfficerTitle:
|
dataProtectionOfficerTitle:
|
||||||
'Kontaktdaten unseres Datenschutzbeauftragten',
|
'Contact details of our data protection officer',
|
||||||
dataProtectionOfficerText:
|
dataProtectionOfficerText:
|
||||||
'Unseren Datenschutzbeauftragten können Sie per E-Mail unter info [at] sascha-bach.de oder unter unserer Postadresse mit dem Zusatz „an den Datenschutzbeauftragten" erreichen.',
|
'You can contact our data protection officer by email at info [at] sascha-bach.de or at our postal address with the addition "to the Data Protection Officer".',
|
||||||
rightsTitle: 'Ihre Rechte nach der DSGVO',
|
rightsTitle: 'Your rights under the GDPR',
|
||||||
rightsIntro:
|
rightsIntro:
|
||||||
'Nach der DSGVO stehen Ihnen die nachfolgend aufgeführten Rechte zu, die Sie jederzeit bei dem in Ziffer 1. dieser Datenschutzerklärung genannten Verantwortlichen geltend machen können:',
|
'Under the GDPR, you have the following rights, which you may assert at any time against the controller named in section 1 of this privacy policy:',
|
||||||
rights: [
|
rights: [
|
||||||
{
|
{
|
||||||
title: 'Recht auf Auskunft',
|
title: 'Right to information',
|
||||||
text: 'Sie haben das Recht, von uns Auskunft darüber zu verlangen, ob und welche Daten wir von Ihnen verarbeiten.',
|
text: 'You have the right to request information from us about whether and what data we process about you.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Recht auf Berichtigung',
|
title: 'Right to rectification',
|
||||||
text: 'Sie haben das Recht, die Berichtigung unrichtiger oder Vervollständigung unvollständiger Daten zu verlangen.',
|
text: 'You have the right to request the correction of inaccurate data or the completion of incomplete data.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Recht auf Löschung',
|
title: 'Right to erasure',
|
||||||
text: 'Sie haben das Recht, die Löschung Ihrer Daten zu verlangen.',
|
text: 'You have the right to request the deletion of your data.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Recht auf Einschränkung',
|
title: 'Right to restriction',
|
||||||
text: 'Sie haben in bestimmten Fällen das Recht zu verlangen, dass wir Ihre Daten nur noch eingeschränkt bearbeiten.',
|
text: 'In certain cases, you have the right to request that we only process your data in a restricted manner.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Recht auf Datenübertragbarkeit',
|
title: 'Right to data portability',
|
||||||
text: 'Sie haben das Recht zu verlangen, dass wir Ihnen oder einem anderen Verantwortlichen Ihre Daten in einem strukturierten, gängigen und maschinenlesebaren Format übermitteln.',
|
text: 'You have the right to request that we transmit your data to you or to another controller in a structured, commonly used, and machine-readable format.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Beschwerderecht',
|
title: 'Right to lodge a complaint',
|
||||||
text: 'Sie haben das Recht, sich bei einer Aufsichtsbehörde zu beschweren. Zuständig ist die Aufsichtsbehörde Ihres üblichen Aufenthaltsortes, Ihres Arbeitsplatzes oder unseres Firmensitzes.',
|
text: 'You have the right to lodge a complaint with a supervisory authority. The supervisory authority of your usual place of residence, your place of work, or our company\'s registered office is responsible.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
revocationTitle: 'Widerrufsrecht',
|
revocationTitle: 'Right of withdrawal',
|
||||||
revocationText:
|
revocationText:
|
||||||
'Sie haben das Recht, die von Ihnen erteilte Einwilligung zur Datenverarbeitung jederzeit zu widerrufen.',
|
'You have the right to withdraw the consent you have given for data processing at any time.',
|
||||||
objectionTitle: 'Widerspruchsrecht',
|
objectionTitle: 'Right to object',
|
||||||
objectionText:
|
objectionText:
|
||||||
'Sie haben das Recht, jederzeit gegen die Verarbeitung Ihrer Daten, die wir auf unser berechtigtes Interesse nach Art. 6 Abs. 1 lit. f DSGVO stützen, Widerspruch einzulegen. Sofern Sie von Ihrem Widerspruchsrecht Gebrauch machen, bitten wir Sie um die Darlegung der Gründe. Wir werden Ihre personenbezogenen Daten dann nicht mehr verarbeiten, es sei denn, wir können Ihnen gegenüber nachweisen, dass zwingende schutzwürdige Gründe an der Datenverarbeitung Ihre Interessen und Rechte überwiegen.',
|
'You have the right to object at any time to the processing of your data that we base on our legitimate interest pursuant to Art. 6(1)(f) GDPR. If you exercise your right to object, please provide your reasons. We will then no longer process your personal data unless we can demonstrate compelling legitimate grounds for the processing that override your interests and rights.',
|
||||||
objectionHighlight:
|
objectionHighlight:
|
||||||
'Unabhängig vom vorstehend Gesagten, haben Sie das jederzeitige Recht, der Verarbeitung Ihrer personenbezogenen Daten für Zwecke der Werbung und Datenanalyse zu widersprechen.',
|
'Regardless of the foregoing, you have the right at any time to object to the processing of your personal data for the purposes of advertising and data analysis.',
|
||||||
objectionContact:
|
objectionContact:
|
||||||
'Ihren Widerspruch richten Sie bitte an die oben angegebene Kontaktadresse des Verantwortlichen.',
|
'Please direct your objection to the contact address of the controller provided above.',
|
||||||
dataDeletionTitle: 'Wann löschen wir Ihre Daten?',
|
dataDeletionTitle: 'When do we delete your data?',
|
||||||
dataDeletionIntro:
|
dataDeletionIntro:
|
||||||
'Wir löschen Ihre Daten dann, wenn wir diese nicht mehr brauchen oder Sie uns dies vorgeben. Das bedeutet, dass - sofern sich aus den einzelnen Datenschutzhinweisen dieser Datenschutzerklärung nichts anderes ergibt - wir Ihre Daten löschen,',
|
'We delete your data when we no longer need it or when you instruct us to do so. This means that – unless otherwise stated in the individual privacy notices in this privacy policy – we will delete your data,',
|
||||||
dataDeletionReasons: [
|
dataDeletionReasons: [
|
||||||
'wenn der Zweck der Datenverarbeitung weggefallen ist und damit die jeweilige in den einzelnen Datenschutzhinweisen genannte Rechtsgrundlage nicht mehr besteht, also bspw.',
|
'when the purpose of data processing has ceased to exist and thus the respective legal basis mentioned in the individual privacy notices no longer applies, e.g.',
|
||||||
'nach Beendigung der zwischen uns bestehenden vertraglichen oder mitgliedschaftlichen Beziehungen (Art. 6 Abs. 1 lit. a DSGVO) oder',
|
'after termination of the contractual or membership relationships existing between us (Art. 6(1)(a) GDPR), or',
|
||||||
'nach Wegfall unseres berechtigten Interesses an der weiteren Verarbeitung oder Speicherung Ihrer Daten (Art. 6 Abs. 1 lit. f DSGVO),',
|
'after the cessation of our legitimate interest in further processing or storage of your data (Art. 6(1)(f) GDPR),',
|
||||||
'wenn Sie von Ihrem Widerrufsrecht Gebrauch machen und keine anderweitige gesetzliche Rechtsgrundlage für die Verarbeitung im Sinne von Art. 6 Abs. 1 lit. b-f DSGVO eingreift,',
|
'when you exercise your right of withdrawal and no other statutory legal basis for the processing pursuant to Art. 6(1)(b)–(f) GDPR applies,',
|
||||||
'wenn Sie vom Ihrem Widerspruchsrecht Gebrauch machen und der Löschung keine zwingenden schutzwürdigen Gründe entgegenstehen.',
|
'when you exercise your right to object and no compelling legitimate grounds for deletion can be opposed.',
|
||||||
],
|
],
|
||||||
retentionText:
|
retentionText:
|
||||||
'Sofern wir (bestimmte Teile) Ihre(r) Daten jedoch noch für andere Zwecke vorhalten müssen, weil dies etwa steuerliche Aufbewahrungsfristen (in der Regel 6 Jahre für Geschäftskorrespondenz bzw. 10 Jahre für Buchungsbelege) oder die Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen aus vertraglichen Beziehungen (bis zu vier Jahren) erforderlich machen oder die Daten zum Schutz der Rechte einer anderen natürlichen oder juristischen Person gebraucht werden, löschen wir (den Teil) Ihre(r) Daten erst nach Ablauf dieser Fristen. Bis zum Ablauf dieser Fristen beschränken wir die Verarbeitung dieser Daten jedoch auf diese Zwecke (Erfüllung der Aufbewahrungspflichten).',
|
'However, if we (certain parts of) your data still need to be retained for other purposes, such as statutory retention periods (generally 6 years for business correspondence or 10 years for accounting records), the assertion, exercise, or defense of legal claims arising from contractual relationships (up to four years), or if the data is needed to protect the rights of another natural or legal person, we will only delete (the respective part of) your data after the expiry of these periods. Until these periods expire, we will restrict the processing of this data to these purposes (fulfillment of retention obligations).',
|
||||||
webhostingTitle: 'Webhosting',
|
webhostingTitle: 'Web Hosting',
|
||||||
webhostingText:
|
webhostingText:
|
||||||
'Wir bedienen uns zum Vorhalten unserer Internetseiten eines Anbieters, auf dessen Server unsere Internetseiten gespeichert und für den Abruf im Internet verfügbar gemacht werden (Hosting). Hierbei können von dem Anbieter all diejenigen über den von Ihnen genutzten Browser übertragenen Daten verarbeitet werden, die bei der Nutzung unserer Internetseiten anfallen. Hierzu gehören insbesondere Ihre IP-Adresse, die der Anbieter benötigt, um unser Online-Angebot an den von Ihnen genutzten Browser ausliefern zu können sowie sämtliche von Ihnen über unsere Internetseite getätigten Eingaben. Daneben kann der von uns genutzte Anbieter',
|
'We use a provider to host our websites, on whose servers our websites are stored and made available for retrieval on the internet (hosting). In doing so, the provider may process all data transmitted by you via the browser during use of our websites. This includes in particular your IP address, which the provider needs to deliver our online offering to the browser you are using, as well as all inputs made by you on our website. In addition, the provider we use may collect',
|
||||||
webhostingDataTypes: [
|
webhostingDataTypes: [
|
||||||
'das Datum und die Uhrzeit des Zugriffs auf unsere Internetseite',
|
'the date and time of access to our website',
|
||||||
'Zeitzonendifferenz zur Greenwich Mean Time (GMT)',
|
'time zone difference from Greenwich Mean Time (GMT)',
|
||||||
'Zugriffsstatus (HTTP-Status)',
|
'access status (HTTP status)',
|
||||||
'die übertragene Datenmenge',
|
'the amount of data transferred',
|
||||||
'der Internet-Service-Provider des zugreifenden Systems',
|
'the internet service provider of the accessing system',
|
||||||
'der von Ihnen verwendete Browsertyp und dessen Version',
|
'the browser type and version you are using',
|
||||||
'das von Ihnen verwendete Betriebssystem',
|
'the operating system you are using',
|
||||||
'die Internetseite, von welcher Sie gegebenenfalls auf unsere Internetseite gelangt sind',
|
'the website from which you may have reached our website',
|
||||||
'die Seiten bzw. Unterseiten, welche Sie auf unserer Internetseite besuchen.',
|
'the pages or sub-pages you visit on our website.',
|
||||||
],
|
],
|
||||||
webhostingPurpose:
|
webhostingPurpose:
|
||||||
'erheben. Die vorgenannten Daten werden als Logfiles auf den Servern unseres Anbieters gespeichert. Dies ist erforderlich, um die Stabilität und Sicherheit des Betriebs unserer Internetseite zu gewährleisten.',
|
'The aforementioned data is stored as log files on our provider\'s servers. This is necessary to ensure the stability and security of our website\'s operation.',
|
||||||
webhostingDataCategories: {
|
webhostingDataCategories: {
|
||||||
affectedData: 'Betroffene Daten:',
|
affectedData: 'Data affected:',
|
||||||
affectedDataList: [
|
affectedDataList: [
|
||||||
'Inhaltsdaten (bspw. Posts, Fotos, Videos)',
|
'Content data (e.g. posts, photos, videos)',
|
||||||
'Nutzungsdaten (bspw. Zugriffszeiten, angeklickte Webseiten)',
|
'Usage data (e.g. access times, visited websites)',
|
||||||
'Kommunikationsdaten (bspw. Informationen über das genutzte Gerät, IP-Adresse)',
|
'Communication data (e.g. information about the device used, IP address)',
|
||||||
],
|
],
|
||||||
affectedPersons: 'Betroffene Personen: Nutzer unserer Internetpräsenz',
|
affectedPersons: 'Affected persons: Users of our online presence',
|
||||||
processingPurpose:
|
processingPurpose:
|
||||||
'Verarbeitungszweck: Ausspielen unserer Internetseiten, Gewährleistung des Betriebs unserer Internetseiten',
|
'Processing purpose: Provision of our website, ensuring the operation of our website',
|
||||||
legalBasis:
|
legalBasis:
|
||||||
'Rechtsgrundlage: Berechtigtes Interesse, Art. 6 Abs. 1 lit. f DSGVO',
|
'Legal basis: Legitimate interest, Art. 6(1)(f) GDPR',
|
||||||
provider: 'Von uns beauftragte(r) Webhoster:',
|
provider: 'Web host commissioned by us:',
|
||||||
},
|
},
|
||||||
hostingProvider: {
|
hostingProvider: {
|
||||||
name: 'Bitpalast GmbH',
|
name: 'Bitpalast GmbH',
|
||||||
address: 'Postfach 19 15 64, D-14005 Berlin, Deutschland',
|
address: 'Postfach 19 15 64, D-14005 Berlin, Germany',
|
||||||
website: 'https://preiswerter-webserver-de.bitpalast.net/',
|
website: 'https://preiswerter-webserver-de.bitpalast.net/',
|
||||||
|
privacyPolicyLabel: 'Privacy Policy:',
|
||||||
},
|
},
|
||||||
contactTitle: 'Kontaktaufnahme',
|
contactTitle: 'Contact',
|
||||||
contactText:
|
contactText:
|
||||||
'Soweit Sie uns über E-Mail, Soziale Medien, Telefon, Fax, Post, unser Kontaktformular oder sonstwie ansprechen und uns hierbei personenbezogene Daten wie Ihren Namen, Ihre Telefonnummer oder Ihre E-Mail-Adresse zur Verfügung stellen oder weitere Angaben zur Ihrer Person oder Ihrem Anliegen machen, verarbeiten wir diese Daten zur Beantwortung Ihrer Anfrage im Rahmen des zwischen uns bestehenden vorvertraglichen oder vertraglichen Beziehungen.',
|
'To the extent that you contact us via email, social media, telephone, fax, post, our contact form, or otherwise, and in doing so provide us with personal data such as your name, phone number, or email address, or make further statements about yourself or your request, we will process this data to respond to your inquiry within the scope of the pre-contractual or contractual relationship existing between us.',
|
||||||
|
contactDataLabels: {
|
||||||
|
affectedData: 'Data affected:',
|
||||||
|
affectedPersons: 'Persons affected:',
|
||||||
|
processingPurpose: 'Processing purpose:',
|
||||||
|
legalBasis: 'Legal basis:',
|
||||||
|
},
|
||||||
contactDataCategories: {
|
contactDataCategories: {
|
||||||
affectedData: [
|
affectedData: [
|
||||||
'Bestandsdaten (bspw. Namen, Adressen)',
|
'Master data (e.g. names, addresses)',
|
||||||
'Kontakdaten (bspw. E-Mail-Adresse, Telefonnummer, Postanschrift)',
|
'Contact data (e.g. email address, phone number, postal address)',
|
||||||
'Inhaltsdaten (Texte, Fotos, Videos)',
|
'Content data (texts, photos, videos)',
|
||||||
'Vertragsdaten (bspw. Vertragsgegenstand, Vertragsdauer)',
|
'Contract data (e.g. subject matter of contract, duration of contract)',
|
||||||
],
|
],
|
||||||
affectedPersons:
|
affectedPersons:
|
||||||
'Interessenten, Kunden, Geschäfts- und Vertragspartner',
|
'Prospective customers, clients, business and contractual partners',
|
||||||
processingPurpose:
|
processingPurpose:
|
||||||
'Kommunikation sowie Beantwortung von Kontaktanfragen, Büro und Organisationsverfahren',
|
'Communication and responding to contact requests, office and organizational procedures',
|
||||||
legalBasis:
|
legalBasis:
|
||||||
'Vertragserfüllung und vorvertragliche Anfragen, Art. 6 Abs. 1 lit. b DSGVO, berechtigtes Interesse, Art. 6 Abs. 1 lit. f DSGVO',
|
'Contract fulfillment and pre-contractual inquiries, Art. 6(1)(b) GDPR, legitimate interest, Art. 6(1)(f) GDPR',
|
||||||
},
|
},
|
||||||
securityTitle: 'Sicherheitsmaßnahmen',
|
onlineAppointmentTitle: 'Online Appointment Booking (Nextcloud)',
|
||||||
|
onlineAppointmentIntro:
|
||||||
|
'On our website, we offer the option to book appointments for consultations or meetings via an external link. For this purpose, we use the appointment scheduler service of the open-source software Nextcloud, which is operated on our servers at our web hosting provider.',
|
||||||
|
onlineAppointmentProvider:
|
||||||
|
'Infrastructure provider: The servers on which the Nextcloud instance runs are provided by Bitpalast GmbH, Postfach 19 15 64, D-14005 Berlin, Germany. However, the sole responsibility for data processing in connection with appointment booking remains with Sascha Bach (see section "Who is responsible for data processing at our company").',
|
||||||
|
onlineAppointmentDataTitle:
|
||||||
|
'Data collected: When booking an appointment, the following personal data is entered and processed by you:',
|
||||||
|
onlineAppointmentData: [
|
||||||
|
'Name (first and last name)',
|
||||||
|
'Email address',
|
||||||
|
'Phone number if applicable (if included as an optional field in the booking form)',
|
||||||
|
'Comments or notes about the appointment content if applicable',
|
||||||
|
'The selected appointment time slot',
|
||||||
|
],
|
||||||
|
onlineAppointmentPurpose:
|
||||||
|
'Purpose of processing: The data is processed exclusively for the purpose of scheduling the appointment, sending automatic appointment confirmations by email, and reminding you of the scheduled appointment. Without providing this data, it is not possible to use the booking function.',
|
||||||
|
onlineAppointmentLegalBasis:
|
||||||
|
'Legal basis: The processing of data is based on Art. 6(1)(b) GDPR (performance of pre-contractual measures). Booking an appointment constitutes a specific request to initiate or carry out a contractual relationship.',
|
||||||
|
onlineAppointmentRetention:
|
||||||
|
'Retention period: Data will be deleted after the appointment has taken place and any applicable statutory retention periods have expired (e.g., under tax law: 6 years for business correspondence, 10 years for accounting records). If no contractual relationship is established, the data will be deleted no later than 3 months after the appointment, unless you have consented to longer storage.',
|
||||||
|
onlineAppointmentThirdParty:
|
||||||
|
'Disclosure: Data will not be disclosed to third parties unless this is legally required for the fulfillment of the appointment or you have explicitly consented to such disclosure. The data remains on servers within the European Union (Germany).',
|
||||||
|
onlineAppointmentServerLocation:
|
||||||
|
'Server location: The data is stored on servers in Germany.',
|
||||||
|
securityTitle: 'Security measures',
|
||||||
securityText:
|
securityText:
|
||||||
'Wir treffen im Übrigen technische und organisatorische Sicherheitsmaßnahmen nach dem Stand der Technik, um die Vorschriften der Datenschutzgesetze einzuhalten und Ihre Daten gegen zufällige oder vorsätzliche Manipulationen, teilweisen oder vollständigen Verlust, Zerstörung oder gegen den unbefugten Zugriff Dritter zu schützen.',
|
'We also take technical and organizational security measures in accordance with the state of the art to comply with data protection laws and to protect your data against accidental or intentional manipulation, partial or complete loss, destruction, or unauthorized access by third parties.',
|
||||||
changesTitle: 'Aktualität und Änderung dieser Datenschutzerklärung',
|
changesTitle: 'Currency and amendments to this privacy policy',
|
||||||
changesText:
|
changesText:
|
||||||
'Diese Datenschutzerklärung ist aktuell gültig und hat den Stand September 2025. Aufgrund geänderter gesetzlicher bzw. behördlicher Vorgaben kann es notwendig werden, diese Datenschutzerklärung anzupassen.',
|
'This privacy policy is currently valid and was last updated in May 2026. Due to changes in legislation or official requirements, it may become necessary to amend this privacy policy.',
|
||||||
disclaimer:
|
disclaimer:
|
||||||
'Diese Datenschutzerklärung wurde mit Hilfe des Datenschutz-Generators von SOS Recht erstellt. SOS Recht ist ein Angebot der Mueller.legal Rechtsanwälte Partnerschaft mit Sitz in Berlin.',
|
'This privacy policy was created with the help of the data protection generator by SOS Recht. SOS Recht is an offering of Mueller.legal Attorneys at Law, based in Berlin.',
|
||||||
},
|
},
|
||||||
address: {
|
address: {
|
||||||
street: 'Alt-Tempelhof 15',
|
street: 'Alt-Tempelhof 15',
|
||||||
city: '12099 Berlin',
|
city: '12099 Berlin',
|
||||||
country: 'Deutschland',
|
country: 'Germany',
|
||||||
},
|
},
|
||||||
contact: {
|
contact: {
|
||||||
phone: '+49 (0) 179 9364867',
|
phone: '+49 (0) 179 9364867',
|
||||||
|
|
|
||||||
|
|
@ -1,279 +1,26 @@
|
||||||
import { navigation } from './navigation';
|
import { navigation } from './navigation';
|
||||||
import { themeToggle } from '../../theme-toggle';
|
import { themeToggle } from '../../theme-toggle';
|
||||||
import { footer } from './footer';
|
import { footer } from './footer';
|
||||||
import { hero } from './hero';
|
import { hero } from './technical/hero';
|
||||||
import { about } from './about';
|
import { about } from './technical/about';
|
||||||
import { services } from './services';
|
import { landingAboutMe } from './landing/landing-aboutme';
|
||||||
import { skills } from './skills';
|
import { landingHero } from './landing/landing-hero';
|
||||||
|
import { landingReasons } from './landing/landing-reasons';
|
||||||
|
import { landingExperience } from './landing/landing-experience';
|
||||||
|
import { landingWinnings } from './landing/landing-winnings';
|
||||||
|
import { landingProcesses } from './landing/landing-processes';
|
||||||
|
import { landingReferences } from './landing/landing-references';
|
||||||
|
import { services } from './technical/services';
|
||||||
|
import { skills } from './technical/skills';
|
||||||
import { projects } from './projects';
|
import { projects } from './projects';
|
||||||
import { certifications } from './certifications';
|
import { certifications } from './technical/certifications';
|
||||||
import { contact } from './contact';
|
import { contact } from './contact';
|
||||||
import { imprint } from './imprint';
|
import { imprint } from './imprint';
|
||||||
import { privacyPolicy } from './privacy-policy';
|
import { privacyPolicy } from './privacy-policy';
|
||||||
import { accessibility } from './accessibility';
|
import { accessibility } from './accessibility';
|
||||||
|
import type { TextConfig } from './TextConfig';
|
||||||
|
|
||||||
// TypeScript interfaces for type safety
|
export type { TextConfig } from './TextConfig';
|
||||||
export interface TextConfig {
|
|
||||||
// Navigation
|
|
||||||
navigation: {
|
|
||||||
menuItems: string[];
|
|
||||||
mobileMenuAriaLabel: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Theme Toggle
|
|
||||||
themeToggle: {
|
|
||||||
lightIcon: string;
|
|
||||||
darkIcon: string;
|
|
||||||
lightModeText: string;
|
|
||||||
darkModeText: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
footer: {
|
|
||||||
imprintText: string;
|
|
||||||
copyrightText: string;
|
|
||||||
githubAriaLabel: string;
|
|
||||||
linkedinAriaLabel: string;
|
|
||||||
emailAriaLabel: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hero Section
|
|
||||||
hero: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
primaryButtonText: string;
|
|
||||||
secondaryButtonText: string;
|
|
||||||
tertiaryButtonText: string;
|
|
||||||
statItems: Array<{
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// About Section
|
|
||||||
about: {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
name: string;
|
|
||||||
bio: string[];
|
|
||||||
featureCards: Array<{
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Services Section
|
|
||||||
services: {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
serviceItems: Array<{
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Skills Section
|
|
||||||
skills: {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
skillCategories: Array<{
|
|
||||||
title: string;
|
|
||||||
skills: Array<{
|
|
||||||
skill: string;
|
|
||||||
value: number;
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Projects Section
|
|
||||||
projects: {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
codeButtonText: string;
|
|
||||||
liveButtonText: string;
|
|
||||||
projectItems: Array<{
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Certifications Section
|
|
||||||
certifications: {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
certificationItems: Array<{
|
|
||||||
name: string;
|
|
||||||
issuer: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Contact Section
|
|
||||||
contact: {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
connectTitle: string;
|
|
||||||
connectDescription: string;
|
|
||||||
buttonText: string;
|
|
||||||
socialTitle: string;
|
|
||||||
availabilityText: string;
|
|
||||||
responseTimeLabel: string;
|
|
||||||
responseTimeValue: string;
|
|
||||||
preferredContactLabel: string;
|
|
||||||
preferredContactValue: string;
|
|
||||||
locationLabel: string;
|
|
||||||
locationValue: string;
|
|
||||||
emailSubject: string;
|
|
||||||
emailBody: string;
|
|
||||||
emailAnnouncement: string;
|
|
||||||
socialAnnouncement: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Imprint Page
|
|
||||||
imprint: {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
companyInfoTitle: string;
|
|
||||||
contactTitle: string;
|
|
||||||
responsibilityTitle: string;
|
|
||||||
disclaimerTitle: string;
|
|
||||||
contentLiabilityTitle: string;
|
|
||||||
contentLiabilityText: string[];
|
|
||||||
copyrightTitle: string;
|
|
||||||
copyrightText: string[];
|
|
||||||
privacyTitle: string;
|
|
||||||
privacyText: string;
|
|
||||||
detailedPrivacyPolicy: {
|
|
||||||
title: string;
|
|
||||||
introduction: string;
|
|
||||||
responsibleTitle: string;
|
|
||||||
responsibleText: string;
|
|
||||||
responsibleContact: string;
|
|
||||||
dataProtectionOfficerTitle: string;
|
|
||||||
dataProtectionOfficerText: string;
|
|
||||||
rightsTitle: string;
|
|
||||||
rightsIntro: string;
|
|
||||||
rights: Array<{ title: string; text: string }>;
|
|
||||||
revocationTitle: string;
|
|
||||||
revocationText: string;
|
|
||||||
objectionTitle: string;
|
|
||||||
objectionText: string;
|
|
||||||
objectionHighlight: string;
|
|
||||||
objectionContact: string;
|
|
||||||
dataDeletionTitle: string;
|
|
||||||
dataDeletionIntro: string;
|
|
||||||
dataDeletionReasons: string[];
|
|
||||||
retentionText: string;
|
|
||||||
webhostingTitle: string;
|
|
||||||
webhostingText: string;
|
|
||||||
webhostingDataTypes: string[];
|
|
||||||
webhostingPurpose: string;
|
|
||||||
webhostingDataCategories: {
|
|
||||||
affectedData: string;
|
|
||||||
affectedDataList: string[];
|
|
||||||
affectedPersons: string;
|
|
||||||
processingPurpose: string;
|
|
||||||
legalBasis: string;
|
|
||||||
provider: string;
|
|
||||||
};
|
|
||||||
hostingProvider: {
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
website: string;
|
|
||||||
};
|
|
||||||
contactTitle: string;
|
|
||||||
contactText: string;
|
|
||||||
contactDataCategories: {
|
|
||||||
affectedData: string[];
|
|
||||||
affectedPersons: string;
|
|
||||||
processingPurpose: string;
|
|
||||||
legalBasis: string;
|
|
||||||
};
|
|
||||||
securityTitle: string;
|
|
||||||
securityText: string;
|
|
||||||
changesTitle: string;
|
|
||||||
changesText: string;
|
|
||||||
disclaimer: string;
|
|
||||||
};
|
|
||||||
address: {
|
|
||||||
street: string;
|
|
||||||
city: string;
|
|
||||||
country: string;
|
|
||||||
};
|
|
||||||
contact: {
|
|
||||||
phone: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Privacy Policy Page
|
|
||||||
privacyPolicy: {
|
|
||||||
title: string;
|
|
||||||
lastUpdated: string;
|
|
||||||
introTitle: string;
|
|
||||||
introText: string;
|
|
||||||
dataCollectionTitle: string;
|
|
||||||
dataCollectionText: string;
|
|
||||||
dataCollectionList: string[];
|
|
||||||
webhostingTitle: string;
|
|
||||||
webhostingText: string;
|
|
||||||
webhostingProvider: string;
|
|
||||||
webhostingProviderName: string;
|
|
||||||
webhostingProviderAddress: string;
|
|
||||||
webhostingProviderWebsite: string;
|
|
||||||
rightsTitle: string;
|
|
||||||
rightsText: string;
|
|
||||||
rightsList: string[];
|
|
||||||
contactTitle: string;
|
|
||||||
contactText: string;
|
|
||||||
contactEmail: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Accessibility and Navigation
|
|
||||||
accessibility: {
|
|
||||||
skipLinks: {
|
|
||||||
skipToHero: string;
|
|
||||||
skipToAbout: string;
|
|
||||||
skipToServices: string;
|
|
||||||
skipToSkills: string;
|
|
||||||
skipToProjects: string;
|
|
||||||
skipToCertifications: string;
|
|
||||||
skipToContact: string;
|
|
||||||
};
|
|
||||||
navigation: {
|
|
||||||
keyboardHelp: string;
|
|
||||||
keyboardHelpExpanded: string[];
|
|
||||||
useTabToNavigate: string;
|
|
||||||
useTabToNavigateCards: string;
|
|
||||||
useTabToNavigateOptions: string;
|
|
||||||
closeKeyboardHelp: string;
|
|
||||||
};
|
|
||||||
screenReader: {
|
|
||||||
professionalStatistics: string;
|
|
||||||
projectCard: string;
|
|
||||||
viewSourceCode: string;
|
|
||||||
viewLiveDemo: string;
|
|
||||||
downloadResume: string;
|
|
||||||
downloadResumeDescription: string;
|
|
||||||
keyboardNavigationHelp: string;
|
|
||||||
themeSwitchButton: string;
|
|
||||||
switchToTheme: string;
|
|
||||||
certificationCard: string;
|
|
||||||
serviceCard: string;
|
|
||||||
skillBadge: string;
|
|
||||||
};
|
|
||||||
ariaLabels: {
|
|
||||||
mainNavigation: string;
|
|
||||||
socialLinks: string;
|
|
||||||
themeToggle: string;
|
|
||||||
mobileMenuToggle: string;
|
|
||||||
contactForm: string;
|
|
||||||
projectsGrid: string;
|
|
||||||
skillsGrid: string;
|
|
||||||
certificationsGrid: string;
|
|
||||||
servicesGrid: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// English text configuration
|
// English text configuration
|
||||||
export const en: TextConfig = {
|
export const en: TextConfig = {
|
||||||
|
|
@ -282,6 +29,13 @@ export const en: TextConfig = {
|
||||||
footer,
|
footer,
|
||||||
hero,
|
hero,
|
||||||
about,
|
about,
|
||||||
|
landingAboutMe,
|
||||||
|
landingHero,
|
||||||
|
landingReasons,
|
||||||
|
landingExperience,
|
||||||
|
landingWinnings,
|
||||||
|
landingProcesses,
|
||||||
|
landingReferences,
|
||||||
services,
|
services,
|
||||||
skills,
|
skills,
|
||||||
projects,
|
projects,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
export const landingAboutMe = {
|
||||||
|
title: 'More Clients Through Accessibility',
|
||||||
|
subtitle: '',
|
||||||
|
greeting: 'Hi, I\'m Sascha.',
|
||||||
|
name: 'Sascha',
|
||||||
|
bio: [
|
||||||
|
'I help businesses build websites that can be used by everyone. A website that works for all reaches all.',
|
||||||
|
],
|
||||||
|
featureCards: [] as Array<{ title: string; description: string; }>,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export const landingExperience = {
|
||||||
|
title: 'My Experience',
|
||||||
|
paragraphs: [
|
||||||
|
"I've helped businesses like yours — from small shops to service providers — make their websites accessible.",
|
||||||
|
"At ORWO, I built a photo book designer that worked for thousands of customers, even those with visual impairments.",
|
||||||
|
'Now, I bring that same care to your website, whether you are a bakery, a therapist, a craftsman, or a local shop.',
|
||||||
|
'Accessibility means respect and more reach for you and your products.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export const landingHero = {
|
||||||
|
title: 'Accessible websites that reach more customers and ensure legal compliance',
|
||||||
|
description: 'I help you make your website accessible to everyone – compliant with accessibility laws.',
|
||||||
|
tertiaryButtonText: 'Get in Touch',
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export const landingProcesses = {
|
||||||
|
title: 'How I Work',
|
||||||
|
intro: "I'm your partner.",
|
||||||
|
items: [
|
||||||
|
{ text: 'I work mostly remotely - less meetings, less commute, less overhead.' },
|
||||||
|
{ text: 'We agree on a schedule - I deliver.' },
|
||||||
|
{ text: 'I use my own tools - no setup required on your side.' },
|
||||||
|
{ text: "I'm flexible - whether you need a quick fix or a full redesign." },
|
||||||
|
],
|
||||||
|
outro: 'You get my full focus.',
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
export const landingReasons = {
|
||||||
|
title: 'Why it is important',
|
||||||
|
reasons: [
|
||||||
|
{
|
||||||
|
stat: '15 %',
|
||||||
|
text: 'of all people in Germany have a disability. That is 12 million potential customers.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: '80 %',
|
||||||
|
text: 'of those people leave websites that do not work for them.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: 'SEO',
|
||||||
|
text: 'Google rewards accessible websites with better rankings, more traffic, and more sales.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stat: 'Trust',
|
||||||
|
text: 'Customers trust accessible websites more. They feel respected, not ignored.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outro:
|
||||||
|
'You are not just doing the right thing. You are making a difference.',
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
export const landingReferences = {
|
||||||
|
title: 'What Clients and Colleagues Say',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
referenceText:
|
||||||
|
'The digital accessibility audit was extremely insightful and practical. The identified weaknesses were explained clearly and in an understandable way, making it possible to derive concrete improvements. Particularly noteworthy is the holistic view of usability and inclusion. Working together is highly recommended for anyone who wants to make their website sustainably accessible.',
|
||||||
|
name: 'Florian Adragna',
|
||||||
|
position: 'Freelancer, Florian Lucas Adragna',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
referenceText:
|
||||||
|
'I always greatly appreciated working with you. You were particularly fast and reliable when it came to finding and fixing bugs. You also picked up new topics quickly and without any issues. Working with you was pleasant both professionally and personally — I am very happy to recommend you.',
|
||||||
|
name: 'Colleague',
|
||||||
|
position: 'Team Lead, ORWO Net GmbH',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
export const landingWinnings = {
|
||||||
|
title: 'What You Get',
|
||||||
|
intro: 'When you work with me, you get:',
|
||||||
|
items: [
|
||||||
|
{ text: 'A website that works for everyone, no matter how they browse.' },
|
||||||
|
{ text: 'More visitors, because Google loves accessible sites.' },
|
||||||
|
{ text: 'More sales, because people buy when they feel included.' },
|
||||||
|
{ text: 'More trust, because your customers feel respected.' },
|
||||||
|
{ text: 'Peace of mind, because I handle everything from testing to legal compliance.' },
|
||||||
|
{ text: 'No tech stress, because I explain things in plain language with no jargon.' },
|
||||||
|
],
|
||||||
|
outro: 'I make sure I understand your issue and my solution fits.',
|
||||||
|
};
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
export const navigation = {
|
export const navigation = {
|
||||||
menuItems: [
|
menuItems: [
|
||||||
'About',
|
{ section: 'About', label: 'About' },
|
||||||
'Services',
|
{ section: 'Services', label: 'Services' },
|
||||||
'Skills',
|
{ section: 'Skills', label: 'Skills' },
|
||||||
'Certifications',
|
{ section: 'Certifications', label: 'Certifications' },
|
||||||
'Projects',
|
{ section: 'Projects', label: 'Projects' },
|
||||||
'Contact',
|
{ section: 'Contact', label: 'Contact' },
|
||||||
],
|
],
|
||||||
mobileMenuAriaLabel: 'Menü öffnen',
|
landingMenuItems: [
|
||||||
|
{ section: 'About', label: 'About' },
|
||||||
|
{ section: 'Winnings', label: 'Benefits' },
|
||||||
|
{ section: 'Experience', label: 'Experience' },
|
||||||
|
{ section: 'Processes', label: 'How I Work' },
|
||||||
|
{ section: 'Projects', label: 'Projects' },
|
||||||
|
{ section: 'References', label: 'References' },
|
||||||
|
{ section: 'Contact', label: 'Contact' },
|
||||||
|
],
|
||||||
|
mobileMenuAriaLabel: 'Open menu',
|
||||||
|
logoAlt: 'Sascha Bach – Accessible Web Development',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export const projects = {
|
||||||
image: icaraceImage,
|
image: icaraceImage,
|
||||||
technologies: ['Angular 4', 'Typescript', 'HTML', 'CSS'],
|
technologies: ['Angular 4', 'Typescript', 'HTML', 'CSS'],
|
||||||
year: '2018',
|
year: '2018',
|
||||||
github: 'https://github.com/LuciusShadow/ErgoVR',
|
live: 'https://live.icarace.com/home',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { certificationItems } from '../../certifications-data';
|
import { certificationItems } from '../../../certifications-data';
|
||||||
|
|
||||||
export const certifications = {
|
export const certifications = {
|
||||||
title: 'Certifications & Achievements',
|
title: 'Certifications & Achievements',
|
||||||
|
|
@ -10,11 +10,14 @@ export const personalConfig = {
|
||||||
full: 'freelancer [at] sascha-bach.de',
|
full: 'freelancer [at] sascha-bach.de',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Booking
|
||||||
|
appointmentUrl: 'https://nextcloud.sascha-bach.de/index.php/apps/calendar/appointment/N58i246RSTB2',
|
||||||
|
|
||||||
// Social Links
|
// Social Links
|
||||||
social: {
|
social: {
|
||||||
github: {
|
codeberg: {
|
||||||
url: 'https://github.com/LuciusShadow',
|
url: 'https://codeberg.org/saschab',
|
||||||
username: 'LuciusShadow',
|
username: 'saschab',
|
||||||
},
|
},
|
||||||
linkedin: {
|
linkedin: {
|
||||||
url: 'https://www.linkedin.com/in/saschabach/',
|
url: 'https://www.linkedin.com/in/saschabach/',
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||||
import { en, type TextConfig } from '../config/locales/en';
|
import { en, type TextConfig } from '../config/locales/en';
|
||||||
import { de } from '../config/locales/de';
|
import { de } from '../config/locales/de';
|
||||||
|
import type { Language } from '../data/types';
|
||||||
type Language = 'en' | 'de';
|
|
||||||
|
|
||||||
interface LanguageContextType {
|
interface LanguageContextType {
|
||||||
language: Language;
|
language: Language;
|
||||||
|
|
@ -12,46 +11,29 @@ interface LanguageContextType {
|
||||||
|
|
||||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||||
|
|
||||||
const LANGUAGE_KEY = 'preferred-language';
|
/** Derive the language from the URL pathname prefix (/en/… or /de/…). */
|
||||||
|
export function getLanguageFromPath(pathname: string): Language | null {
|
||||||
function getBrowserLanguage(): Language {
|
if (pathname.startsWith('/en')) return 'en';
|
||||||
const browserLang = navigator.language.toLowerCase();
|
if (pathname.startsWith('/de')) return 'de';
|
||||||
if (browserLang.startsWith('de')) {
|
|
||||||
return 'de';
|
|
||||||
}
|
|
||||||
return 'en'; // Default to English for all other languages
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSavedLanguage(): Language | null {
|
|
||||||
const saved = localStorage.getItem(LANGUAGE_KEY);
|
|
||||||
if (saved === 'en' || saved === 'de') {
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
export function LanguageProvider({ initialLanguage = 'de', children }: Readonly<{ initialLanguage?: Language; children: ReactNode; }>) {
|
||||||
const [language, setLanguageState] = useState<Language>(() => {
|
const [language, setLanguage] = useState<Language>(initialLanguage);
|
||||||
// First check localStorage, then browser language, then default to English
|
|
||||||
return getSavedLanguage() || getBrowserLanguage();
|
|
||||||
});
|
|
||||||
|
|
||||||
const texts = language === 'de' ? de : en;
|
const texts = language === 'de' ? de : en;
|
||||||
|
|
||||||
const setLanguage = (lang: Language) => {
|
const handleSetLanguage = (lang: Language) => {
|
||||||
setLanguageState(lang);
|
setLanguage(lang);
|
||||||
localStorage.setItem(LANGUAGE_KEY, lang);
|
|
||||||
// Update HTML lang attribute for accessibility and SEO
|
|
||||||
document.documentElement.lang = lang;
|
document.documentElement.lang = lang;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Set initial lang attribute
|
|
||||||
document.documentElement.lang = language;
|
document.documentElement.lang = language;
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LanguageContext.Provider value={{ language, setLanguage, texts }}>
|
<LanguageContext.Provider value={{ language, setLanguage: handleSetLanguage, texts }}>
|
||||||
{children}
|
{children}
|
||||||
</LanguageContext.Provider>
|
</LanguageContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Common types used across the application
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Navigation types
|
||||||
|
export interface MenuItem {
|
||||||
|
section: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Language types
|
||||||
|
export type Language = 'en' | 'de';
|
||||||
|
|
||||||
|
// Theme types
|
||||||
|
export type Theme = 'light' | 'dark';
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { useState, useRef, useEffect, type KeyboardEvent, type RefObject } from 'react';
|
||||||
|
import type { Project } from '../data/Project';
|
||||||
|
|
||||||
|
interface ProjectAction {
|
||||||
|
type: 'github' | 'live';
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseProjectKeyboardNavigationProps {
|
||||||
|
projects: Project[];
|
||||||
|
announce: (message: string) => void;
|
||||||
|
useTabToNavigateText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseProjectKeyboardNavigationReturn {
|
||||||
|
activeCardIndex: number | null;
|
||||||
|
focusedActionIndex: number;
|
||||||
|
cardRefs: RefObject<(HTMLElement | null)[]>;
|
||||||
|
actionRefs: RefObject<(HTMLAnchorElement | null)[]>;
|
||||||
|
handleCardSelect: (index: number) => void;
|
||||||
|
handleCardKeyDown: (event: KeyboardEvent<HTMLElement>, cardIndex: number) => void;
|
||||||
|
handleActionKeyDown: (event: KeyboardEvent<HTMLAnchorElement>, cardIndex: number, actionIndex: number) => void;
|
||||||
|
getProjectActions: (project: Project) => ProjectAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing keyboard navigation in project cards
|
||||||
|
* Handles card selection, focus management, and keyboard shortcuts
|
||||||
|
*/
|
||||||
|
export function useProjectKeyboardNavigation({
|
||||||
|
projects,
|
||||||
|
announce,
|
||||||
|
useTabToNavigateText,
|
||||||
|
}: UseProjectKeyboardNavigationProps): UseProjectKeyboardNavigationReturn {
|
||||||
|
const [activeCardIndex, setActiveCardIndex] = useState<number | null>(null);
|
||||||
|
const [focusedActionIndex, setFocusedActionIndex] = useState<number>(0);
|
||||||
|
const cardRefs = useRef<(HTMLElement | null)[]>([]);
|
||||||
|
const actionRefs = useRef<(HTMLAnchorElement | null)[]>([]);
|
||||||
|
|
||||||
|
// Get available actions for a project
|
||||||
|
const getProjectActions = (project: Project): ProjectAction[] => {
|
||||||
|
const actions: ProjectAction[] = [];
|
||||||
|
if (project.github) actions.push({ type: 'github', url: project.github });
|
||||||
|
if (project.live) actions.push({ type: 'live', url: project.live });
|
||||||
|
return actions;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle card selection
|
||||||
|
const handleCardSelect = (index: number) => {
|
||||||
|
setActiveCardIndex(index);
|
||||||
|
setFocusedActionIndex(0);
|
||||||
|
|
||||||
|
const project = projects[index];
|
||||||
|
const actions = getProjectActions(project);
|
||||||
|
const announcement = `${project.title} card selected. ${actions.length} actions available. ${useTabToNavigateText}, Escape to close.`;
|
||||||
|
announce(announcement);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle keyboard navigation within cards
|
||||||
|
const handleCardKeyDown = (event: KeyboardEvent<HTMLElement>, cardIndex: number) => {
|
||||||
|
const project = projects[cardIndex];
|
||||||
|
const actions = getProjectActions(project);
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
event.preventDefault();
|
||||||
|
handleCardSelect(cardIndex);
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
if (activeCardIndex === cardIndex) {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveCardIndex(null);
|
||||||
|
setFocusedActionIndex(0);
|
||||||
|
cardRefs.current[cardIndex]?.focus();
|
||||||
|
announce(`${project.title} card closed.`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
if (activeCardIndex === cardIndex && actions.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
const nextActionIndex = event.shiftKey
|
||||||
|
? (focusedActionIndex - 1 + actions.length) % actions.length
|
||||||
|
: (focusedActionIndex + 1) % actions.length;
|
||||||
|
setFocusedActionIndex(nextActionIndex);
|
||||||
|
|
||||||
|
// Focus the action button
|
||||||
|
const actionButton = actionRefs.current[cardIndex * 2 + nextActionIndex];
|
||||||
|
actionButton?.focus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 'ArrowUp':
|
||||||
|
if (activeCardIndex !== null) {
|
||||||
|
event.preventDefault();
|
||||||
|
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
||||||
|
const nextCardIndex = (cardIndex + direction + projects.length) % projects.length;
|
||||||
|
|
||||||
|
// Close current card and move to next
|
||||||
|
setActiveCardIndex(null);
|
||||||
|
setFocusedActionIndex(0);
|
||||||
|
cardRefs.current[nextCardIndex]?.focus();
|
||||||
|
announce(`Moved to ${projects[nextCardIndex].title} card.`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle action button keyboard navigation
|
||||||
|
const handleActionKeyDown = (event: KeyboardEvent<HTMLAnchorElement>, cardIndex: number, actionIndex: number) => {
|
||||||
|
const actions = getProjectActions(projects[cardIndex]);
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Escape':
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveCardIndex(null);
|
||||||
|
setFocusedActionIndex(0);
|
||||||
|
cardRefs.current[cardIndex]?.focus();
|
||||||
|
announce(`${projects[cardIndex].title} card closed.`);
|
||||||
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
if (event.shiftKey && actionIndex === 0) {
|
||||||
|
// Tab back to card
|
||||||
|
event.preventDefault();
|
||||||
|
cardRefs.current[cardIndex]?.focus();
|
||||||
|
} else if (!event.shiftKey && actionIndex === actions.length - 1) {
|
||||||
|
// Tab forward to next card
|
||||||
|
event.preventDefault();
|
||||||
|
const nextCardIndex = (cardIndex + 1) % projects.length;
|
||||||
|
setActiveCardIndex(null);
|
||||||
|
setFocusedActionIndex(0);
|
||||||
|
cardRefs.current[nextCardIndex]?.focus();
|
||||||
|
announce(`Moved to ${projects[nextCardIndex].title} card.`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Focus management effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeCardIndex !== null) {
|
||||||
|
const firstActionRef = actionRefs.current[activeCardIndex * 2];
|
||||||
|
if (firstActionRef) {
|
||||||
|
firstActionRef.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeCardIndex]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCardIndex,
|
||||||
|
focusedActionIndex,
|
||||||
|
cardRefs,
|
||||||
|
actionRefs,
|
||||||
|
handleCardSelect,
|
||||||
|
handleCardKeyDown,
|
||||||
|
handleActionKeyDown,
|
||||||
|
getProjectActions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing screen reader announcements
|
||||||
|
* Creates temporary live region elements to announce messages to assistive technologies
|
||||||
|
*/
|
||||||
|
export function useScreenReaderAnnouncements() {
|
||||||
|
const announce = useCallback((message: string) => {
|
||||||
|
const announcement = document.createElement('div');
|
||||||
|
announcement.setAttribute('aria-live', 'polite');
|
||||||
|
announcement.setAttribute('aria-atomic', 'true');
|
||||||
|
announcement.setAttribute('class', 'sr-only');
|
||||||
|
announcement.textContent = message;
|
||||||
|
|
||||||
|
document.body.appendChild(announcement);
|
||||||
|
setTimeout(() => {
|
||||||
|
announcement.remove();
|
||||||
|
}, 1000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { announce };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes all `.reveal-item` descendants of the returned ref and adds
|
||||||
|
* `.is-visible` when they enter the viewport, triggering the CSS transition
|
||||||
|
* defined in globals.scss.
|
||||||
|
*
|
||||||
|
* The observer starts immediately so that elements already in the viewport
|
||||||
|
* become visible right away — including after language-change remounts.
|
||||||
|
*/
|
||||||
|
export function useScrollReveal() {
|
||||||
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = ref.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('is-visible');
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.12 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = container.querySelectorAll('.reveal-item');
|
||||||
|
items.forEach((el) => observer.observe(el));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,21 @@ 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 { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
export default function HomePage() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeroSection />
|
<HeroSection />
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,17 @@
|
||||||
import { useEffect } from 'react';
|
|
||||||
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';
|
||||||
|
|
||||||
export default function ImprintPage() {
|
export default function ImprintPage() {
|
||||||
const { texts: allTexts } = useLanguage();
|
const { texts: allTexts } = useLanguage();
|
||||||
const texts = allTexts.imprint;
|
const texts = allTexts.imprint;
|
||||||
|
|
||||||
useEffect(() => {
|
usePageSeo({
|
||||||
// Prevent indexing of this page
|
title: 'Imprint | Sascha Bach',
|
||||||
const metaRobots = document.createElement('meta');
|
description: 'Legal information and imprint for Sascha Bach, freelance software developer.',
|
||||||
metaRobots.name = 'robots';
|
canonical: '/imprint',
|
||||||
metaRobots.content = 'noindex, nofollow';
|
noIndex: true,
|
||||||
document.head.appendChild(metaRobots);
|
});
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
return () => {
|
|
||||||
const existingMeta = document.querySelector('meta[name="robots"]');
|
|
||||||
if (existingMeta) {
|
|
||||||
existingMeta.remove();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="imprint-page">
|
<section className="imprint-page">
|
||||||
|
|
@ -39,6 +30,17 @@ export default function ImprintPage() {
|
||||||
<p>{texts.address.street}</p>
|
<p>{texts.address.street}</p>
|
||||||
<p>{texts.address.city}</p>
|
<p>{texts.address.city}</p>
|
||||||
<p>{texts.address.country}</p>
|
<p>{texts.address.country}</p>
|
||||||
|
<p>{texts.umsatzsteuerID || texts.vatID}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="imprint-page__section">
|
||||||
|
<h2 className="imprint-page__section-title">{texts.businessRegistrationTitle}</h2>
|
||||||
|
<div className="imprint-page__info">
|
||||||
|
<p><strong>{texts.businessRegistration.title}</strong></p>
|
||||||
|
<p><strong>Datum der Erteilung:</strong> {texts.businessRegistration.dateIssued}</p>
|
||||||
|
<p><strong>Erteilt von:</strong> {texts.businessRegistration.issuedBy}</p>
|
||||||
|
<p>{texts.businessRegistration.authority}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -179,7 +181,7 @@ export default function ImprintPage() {
|
||||||
<p><strong>{texts.detailedPrivacyPolicy.hostingProvider.name}</strong></p>
|
<p><strong>{texts.detailedPrivacyPolicy.hostingProvider.name}</strong></p>
|
||||||
<p>{texts.detailedPrivacyPolicy.hostingProvider.address}</p>
|
<p>{texts.detailedPrivacyPolicy.hostingProvider.address}</p>
|
||||||
<p>
|
<p>
|
||||||
Datenschutzerklärung: {' '}
|
{texts.detailedPrivacyPolicy.hostingProvider.privacyPolicyLabel}{' '}
|
||||||
<a
|
<a
|
||||||
href={texts.detailedPrivacyPolicy.hostingProvider.website}
|
href={texts.detailedPrivacyPolicy.hostingProvider.website}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -198,18 +200,36 @@ export default function ImprintPage() {
|
||||||
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.contactText}</p>
|
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.contactText}</p>
|
||||||
|
|
||||||
<div className="imprint-page__data-categories">
|
<div className="imprint-page__data-categories">
|
||||||
<p className="imprint-page__category-title"><strong>Betroffene Daten:</strong></p>
|
<p className="imprint-page__category-title"><strong>{texts.detailedPrivacyPolicy.contactDataLabels.affectedData}</strong></p>
|
||||||
<ul className="imprint-page__category-list">
|
<ul className="imprint-page__category-list">
|
||||||
{texts.detailedPrivacyPolicy.contactDataCategories.affectedData.map((item: string) => (
|
{texts.detailedPrivacyPolicy.contactDataCategories.affectedData.map((item: string) => (
|
||||||
<li key={item}>{item}</li>
|
<li key={item}>{item}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<p><strong>Betroffene Personen: </strong>{texts.detailedPrivacyPolicy.contactDataCategories.affectedPersons}</p>
|
<p><strong>{texts.detailedPrivacyPolicy.contactDataLabels.affectedPersons} </strong>{texts.detailedPrivacyPolicy.contactDataCategories.affectedPersons}</p>
|
||||||
<p><strong>Verarbeitungszweck: </strong>{texts.detailedPrivacyPolicy.contactDataCategories.processingPurpose}</p>
|
<p><strong>{texts.detailedPrivacyPolicy.contactDataLabels.processingPurpose} </strong>{texts.detailedPrivacyPolicy.contactDataCategories.processingPurpose}</p>
|
||||||
<p><strong>Rechtsgrundlage: </strong>{texts.detailedPrivacyPolicy.contactDataCategories.legalBasis}</p>
|
<p><strong>{texts.detailedPrivacyPolicy.contactDataLabels.legalBasis} </strong>{texts.detailedPrivacyPolicy.contactDataCategories.legalBasis}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Online Appointment Booking (Nextcloud) */}
|
||||||
|
<div className="imprint-page__privacy-section">
|
||||||
|
<h3 className="imprint-page__privacy-subtitle">{texts.detailedPrivacyPolicy.onlineAppointmentTitle}</h3>
|
||||||
|
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentIntro}</p>
|
||||||
|
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentProvider}</p>
|
||||||
|
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentDataTitle}</p>
|
||||||
|
<ul className="imprint-page__category-list">
|
||||||
|
{texts.detailedPrivacyPolicy.onlineAppointmentData.map((item: string) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentPurpose}</p>
|
||||||
|
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentLegalBasis}</p>
|
||||||
|
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentRetention}</p>
|
||||||
|
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentThirdParty}</p>
|
||||||
|
<p className="imprint-page__privacy-text">{texts.detailedPrivacyPolicy.onlineAppointmentServerLocation}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Security Measures */}
|
{/* Security Measures */}
|
||||||
<div className="imprint-page__privacy-section">
|
<div className="imprint-page__privacy-section">
|
||||||
<h3 className="imprint-page__privacy-subtitle">{texts.detailedPrivacyPolicy.securityTitle}</h3>
|
<h3 className="imprint-page__privacy-subtitle">{texts.detailedPrivacyPolicy.securityTitle}</h3>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import HeroSection from '../components/sections/HeroSection';
|
||||||
|
import LandingAboutMeSection from '../components/landing/LandingAboutMeSection';
|
||||||
|
import ReasonsSection from '../components/landing/ReasonsSection';
|
||||||
|
import ExperienceSection from '../components/landing/ExperienceSection';
|
||||||
|
import WinningsSection from '../components/landing/WinningsSection';
|
||||||
|
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 { 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 (
|
||||||
|
<>
|
||||||
|
<HeroSection
|
||||||
|
title={t.title}
|
||||||
|
description={t.description}
|
||||||
|
tertiaryButtonText={t.tertiaryButtonText}
|
||||||
|
showPrimaryButton={false}
|
||||||
|
showSecondaryButton={false}
|
||||||
|
hideStats={true}
|
||||||
|
/>
|
||||||
|
<LandingAboutMeSection />
|
||||||
|
<ReasonsSection />
|
||||||
|
<WinningsSection />
|
||||||
|
<ExperienceSection />
|
||||||
|
<ProcessesSection />
|
||||||
|
<ProjectsSection />
|
||||||
|
<ReferencesSection />
|
||||||
|
<ContactSection />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,25 +1,16 @@
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { usePageSeo } from '../hooks/usePageSeo';
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
export default function PrivacyPolicy() {
|
||||||
const { texts: allTexts } = useLanguage();
|
const { texts: allTexts } = useLanguage();
|
||||||
const texts = allTexts.privacyPolicy;
|
const texts = allTexts.privacyPolicy;
|
||||||
|
|
||||||
useEffect(() => {
|
usePageSeo({
|
||||||
// Prevent indexing of this page
|
title: 'Privacy Policy | Sascha Bach',
|
||||||
const metaRobots = document.createElement('meta');
|
description: 'Privacy policy for the website of Sascha Bach, freelance software developer.',
|
||||||
metaRobots.name = 'robots';
|
canonical: '/privacy-policy',
|
||||||
metaRobots.content = 'noindex, nofollow';
|
noIndex: true,
|
||||||
document.head.appendChild(metaRobots);
|
});
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
return () => {
|
|
||||||
const existingMeta = document.querySelector('meta[name="robots"]');
|
|
||||||
if (existingMeta) {
|
|
||||||
existingMeta.remove();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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' }}>
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@
|
||||||
// All sections imported through index
|
// All sections imported through index
|
||||||
@use 'sections';
|
@use 'sections';
|
||||||
|
|
||||||
// Import Geist font from Google Fonts or local files
|
// Component styles
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
@use 'components/back-to-top';
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Comfortaa:wght@600&family=Quicksand:wght@400;500&display=swap');
|
||||||
|
|
||||||
// CSS Reset - Add this
|
// CSS Reset - Add this
|
||||||
* {
|
* {
|
||||||
|
|
@ -19,27 +21,342 @@
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard Theme (Light)
|
// Standard Theme (Light) + design system tokens
|
||||||
:root {
|
:root {
|
||||||
@include t.theme-vars(t.$light-theme);
|
@include t.theme-vars(t.$light-theme);
|
||||||
|
|
||||||
|
// Spacing scale (multiples of 4px)
|
||||||
|
--space-1: 0.25rem;
|
||||||
|
--space-2: 0.5rem;
|
||||||
|
--space-3: 0.75rem;
|
||||||
|
--space-4: 1rem;
|
||||||
|
--space-5: 1.25rem;
|
||||||
|
--space-6: 1.5rem;
|
||||||
|
--space-8: 2rem;
|
||||||
|
--space-10: 2.5rem;
|
||||||
|
--space-12: 3rem;
|
||||||
|
--space-16: 4rem;
|
||||||
|
--space-20: 5rem;
|
||||||
|
--space-24: 6rem;
|
||||||
|
|
||||||
|
// Typography scale
|
||||||
|
--font-size-xs: 0.75rem;
|
||||||
|
--font-size-sm: 0.875rem;
|
||||||
|
--font-size-base: 1rem;
|
||||||
|
--font-size-lg: 1.125rem;
|
||||||
|
--font-size-xl: 1.25rem;
|
||||||
|
--font-size-2xl: 1.5rem;
|
||||||
|
--font-size-3xl: 1.875rem;
|
||||||
|
--font-size-4xl: 2.25rem;
|
||||||
|
|
||||||
|
// Line heights
|
||||||
|
--leading-tight: 1.2;
|
||||||
|
--leading-snug: 1.375;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
--leading-relaxed: 1.625;
|
||||||
|
--leading-loose: 1.75;
|
||||||
|
|
||||||
|
// Letter spacing
|
||||||
|
--tracking-tight: -0.025em;
|
||||||
|
--tracking-normal: 0em;
|
||||||
|
--tracking-wide: 0.05em;
|
||||||
|
--tracking-wider: 0.1em;
|
||||||
|
|
||||||
|
// Border radius
|
||||||
|
--radius-sm: 0.375rem;
|
||||||
|
--radius-md: 0.5rem;
|
||||||
|
--radius-lg: 0.75rem;
|
||||||
|
--radius-xl: 1rem;
|
||||||
|
|
||||||
|
// Shadows (light) – brand-tinted
|
||||||
|
--shadow-xs: 0 1px 2px rgba(4, 44, 83, 0.05);
|
||||||
|
--shadow-sm: 0 2px 8px rgba(4, 44, 83, 0.08);
|
||||||
|
--shadow-md: 0 4px 16px rgba(4, 44, 83, 0.08);
|
||||||
|
--shadow-hover: 0 8px 24px rgba(4, 44, 83, 0.12);
|
||||||
|
|
||||||
|
// Transitions
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition-base: 200ms ease;
|
||||||
|
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
// Brand accent & text-on-dark
|
||||||
|
--color-accent-text: #412402;
|
||||||
|
--color-accent-deco: #ef9f27;
|
||||||
|
--color-text-on-dark: #e6f1fb;
|
||||||
|
--font-headline: 'Comfortaa', sans-serif;
|
||||||
|
--font-body: 'Quicksand', sans-serif;
|
||||||
|
--contact-button-bg: #042c53;
|
||||||
|
--contact-button-text: #e6f1fb;
|
||||||
|
--contact-button-hover-bg: #0c447c;
|
||||||
|
|
||||||
|
// Landing section backgrounds (light) – flat brand
|
||||||
|
--bg-reasons: #ffffff;
|
||||||
|
--bg-winnings: #f1efe8;
|
||||||
|
--bg-experience: #ffffff;
|
||||||
|
--bg-processes: #f1efe8;
|
||||||
|
--bg-references: #ffffff;
|
||||||
|
|
||||||
|
// Reused section backgrounds (light)
|
||||||
|
--about-background: #ffffff;
|
||||||
|
--projects-background: #ffffff;
|
||||||
|
--contact-background: #f1efe8;
|
||||||
|
|
||||||
|
// Tech section backgrounds (light)
|
||||||
|
--hero-background: #f1efe8;
|
||||||
|
--services-background: #f1efe8;
|
||||||
|
--skills-background: #ffffff;
|
||||||
|
--certifications-background: #f1efe8;
|
||||||
|
|
||||||
|
// Card tokens (light)
|
||||||
|
--card-glass-bg: #ffffff;
|
||||||
|
--card-glass-border: rgba(4, 44, 83, 0.12);
|
||||||
|
|
||||||
|
// Functional shadows
|
||||||
|
--shadow-glow:
|
||||||
|
0 0 0 1px rgba(4, 44, 83, 0.1), 0 8px 24px rgba(4, 44, 83, 0.08);
|
||||||
|
--shadow-glow-hover:
|
||||||
|
0 0 0 1px rgba(4, 44, 83, 0.2), 0 16px 48px rgba(4, 44, 83, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dark Theme aktiv, wenn `data-theme="dark"` am html oder body
|
// Dark theme + shadow overrides
|
||||||
[data-theme='dark'] {
|
[data-theme='dark'] {
|
||||||
@include t.theme-vars(t.$dark-theme);
|
@include t.theme-vars(t.$dark-theme);
|
||||||
|
|
||||||
|
// Core colors
|
||||||
|
--color-primary: #e6f1fb;
|
||||||
|
--color-secondary: #b5d4f4;
|
||||||
|
--color-accent-text: #fac775;
|
||||||
|
--color-accent-deco: #ef9f27;
|
||||||
|
--color-background: #021829;
|
||||||
|
--color-surface: #042c53;
|
||||||
|
--color-text: #e6f1fb;
|
||||||
|
--color-text-muted: #b5d4f4;
|
||||||
|
--color-text-on-dark: #e6f1fb;
|
||||||
|
--color-border: rgba(230, 241, 251, 0.12);
|
||||||
|
--color-focus-ring: #ef9f27;
|
||||||
|
|
||||||
|
// Section backgrounds
|
||||||
|
--hero-background: #021829;
|
||||||
|
--about-background: #042c53;
|
||||||
|
--services-background: #021829;
|
||||||
|
--skills-background: #042c53;
|
||||||
|
--certifications-background: #021829;
|
||||||
|
--projects-background: #042c53;
|
||||||
|
--contact-background: #021829;
|
||||||
|
--bg-reasons: #042c53;
|
||||||
|
--bg-winnings: #021829;
|
||||||
|
--bg-experience: #042c53;
|
||||||
|
--bg-processes: #021829;
|
||||||
|
--bg-references: #042c53;
|
||||||
|
|
||||||
|
// Cards + shadows
|
||||||
|
--card-glass-bg: rgba(12, 68, 124, 0.5);
|
||||||
|
--card-glass-border: rgba(230, 241, 251, 0.1);
|
||||||
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-hover: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-glow:
|
||||||
|
0 0 0 1px rgba(239, 159, 39, 0.15), 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||||
|
--shadow-glow-hover:
|
||||||
|
0 0 0 1px rgba(239, 159, 39, 0.3), 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
--contact-button-bg: #ef9f27;
|
||||||
|
--contact-button-text: #042c53;
|
||||||
|
--contact-button-hover-bg: #fac775;
|
||||||
|
|
||||||
|
// Form inputs
|
||||||
|
--contact-input-bg: #042c53;
|
||||||
|
--contact-input-border: rgba(230, 241, 251, 0.2);
|
||||||
|
--contact-input-focus-ring: rgba(239, 159, 39, 0.3);
|
||||||
|
|
||||||
|
// Stat cards
|
||||||
|
--stat-primary-bg: rgba(12, 68, 124, 0.4);
|
||||||
|
--stat-primary-value: #e6f1fb;
|
||||||
|
--stat-primary-label: #b5d4f4;
|
||||||
|
--stat-secondary-bg: rgba(12, 68, 124, 0.4);
|
||||||
|
--stat-secondary-value: #e6f1fb;
|
||||||
|
--stat-secondary-label: #b5d4f4;
|
||||||
|
--stat-tertiary-bg: rgba(12, 68, 124, 0.4);
|
||||||
|
--stat-tertiary-value: #e6f1fb;
|
||||||
|
--stat-tertiary-label: #b5d4f4;
|
||||||
|
--stat-quaternary-bg: rgba(12, 68, 124, 0.4);
|
||||||
|
--stat-quaternary-value: #e6f1fb;
|
||||||
|
--stat-quaternary-label: #b5d4f4;
|
||||||
|
|
||||||
|
// Service cards
|
||||||
|
--service-card-primary-bg: rgba(12, 68, 124, 0.5);
|
||||||
|
--service-card-secondary-bg: rgba(4, 44, 83, 0.8);
|
||||||
|
--service-card-tertiary-bg: rgba(12, 68, 124, 0.5);
|
||||||
|
--service-card-quaternary-bg: rgba(4, 44, 83, 0.8);
|
||||||
|
--service-card-quinary-bg: rgba(12, 68, 124, 0.5);
|
||||||
|
--service-card-senary-bg: rgba(4, 44, 83, 0.8);
|
||||||
|
--service-icon-primary: #ef9f27;
|
||||||
|
--service-icon-secondary: #ef9f27;
|
||||||
|
--service-icon-tertiary: #ef9f27;
|
||||||
|
--service-icon-quaternary: #ef9f27;
|
||||||
|
--service-icon-quinary: #ef9f27;
|
||||||
|
--service-icon-senary: #ef9f27;
|
||||||
|
--service-title-primary: #e6f1fb;
|
||||||
|
--service-title-secondary: #e6f1fb;
|
||||||
|
--service-title-tertiary: #e6f1fb;
|
||||||
|
--service-title-quaternary: #e6f1fb;
|
||||||
|
--service-title-quinary: #e6f1fb;
|
||||||
|
--service-title-senary: #e6f1fb;
|
||||||
|
--service-description-primary: #b5d4f4;
|
||||||
|
--service-description-secondary: #b5d4f4;
|
||||||
|
--service-description-tertiary: #b5d4f4;
|
||||||
|
--service-description-quaternary: #b5d4f4;
|
||||||
|
--service-description-quinary: #b5d4f4;
|
||||||
|
--service-description-senary: #b5d4f4;
|
||||||
|
|
||||||
|
// Skills
|
||||||
|
--skills-category-bg: rgba(12, 68, 124, 0.4);
|
||||||
|
--skills-category-border: rgba(230, 241, 251, 0.1);
|
||||||
|
--skills-skill-name-color: #e6f1fb;
|
||||||
|
--skills-skill-level-color: #b5d4f4;
|
||||||
|
--skills-skill-percentage-color: #b5d4f4;
|
||||||
|
--skills-progress-bg: rgba(230, 241, 251, 0.1);
|
||||||
|
--skills-category-bg-primary: rgba(12, 68, 124, 0.5);
|
||||||
|
--skills-category-bg-secondary: rgba(12, 68, 124, 0.5);
|
||||||
|
--skills-category-bg-tertiary: rgba(12, 68, 124, 0.5);
|
||||||
|
--skills-category-bg-quaternary: rgba(12, 68, 124, 0.5);
|
||||||
|
--skills-category-bg-quinary: rgba(12, 68, 124, 0.5);
|
||||||
|
--skills-category-bg-senary: rgba(12, 68, 124, 0.5);
|
||||||
|
--skills-progress-primary: #ef9f27;
|
||||||
|
--skills-progress-secondary: #ef9f27;
|
||||||
|
--skills-progress-tertiary: #ef9f27;
|
||||||
|
--skills-progress-quaternary: #ef9f27;
|
||||||
|
--skills-progress-quinary: #ef9f27;
|
||||||
|
--skills-progress-senary: #ef9f27;
|
||||||
|
|
||||||
|
// Certifications
|
||||||
|
--certifications-card-background: #042c53;
|
||||||
|
--certifications-badge-background: rgba(239, 159, 39, 0.15);
|
||||||
|
--certifications-badge-border: rgba(239, 159, 39, 0.3);
|
||||||
|
--color-certifications-badge-text: #fac775;
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
--projects-card-background: #042c53;
|
||||||
|
--projects-card-border: rgba(239, 159, 39, 0.15);
|
||||||
|
--projects-button-primary-bg: #ef9f27;
|
||||||
|
--projects-button-primary-text: #042c53;
|
||||||
|
--projects-button-primary-border: rgba(239, 159, 39, 0.3);
|
||||||
|
--projects-button-primary-hover-bg: #fac775;
|
||||||
|
--projects-button-secondary-bg: #0c447c;
|
||||||
|
--projects-button-secondary-text: #e6f1fb;
|
||||||
|
--projects-button-secondary-border: rgba(230, 241, 251, 0.15);
|
||||||
|
--projects-button-secondary-hover-bg: #185fa5;
|
||||||
|
--projects-tech-badge-bg: rgba(12, 68, 124, 0.6);
|
||||||
|
--projects-tech-badge-text: #e6f1fb;
|
||||||
|
--projects-tech-badge-border: rgba(239, 159, 39, 0.2);
|
||||||
|
|
||||||
|
// Contact
|
||||||
|
--contact-social-bg: #042c53;
|
||||||
|
--contact-social-text: #e6f1fb;
|
||||||
|
--contact-social-border: rgba(239, 159, 39, 0.3);
|
||||||
|
--contact-social-hover-bg: #0c447c;
|
||||||
|
--contact-social-hover-border: #ef9f27;
|
||||||
|
--contact-status-success-bg: rgba(4, 44, 83, 0.5);
|
||||||
|
--contact-status-success-border: #ef9f27;
|
||||||
|
--contact-status-success-text: #fac775;
|
||||||
|
|
||||||
|
// Page backgrounds
|
||||||
|
--bg-primary: #021829;
|
||||||
|
--bg-secondary: #042c53;
|
||||||
|
--border-color: rgba(230, 241, 251, 0.12);
|
||||||
|
|
||||||
|
// Skill badges
|
||||||
|
--skill-badge-primary-bg: rgba(12, 68, 124, 0.5);
|
||||||
|
--skill-badge-primary-text: #e6f1fb;
|
||||||
|
--skill-badge-secondary-bg: rgba(12, 68, 124, 0.5);
|
||||||
|
--skill-badge-secondary-text: #e6f1fb;
|
||||||
|
--skill-badge-tertiary-bg: rgba(12, 68, 124, 0.5);
|
||||||
|
--skill-badge-tertiary-text: #e6f1fb;
|
||||||
|
--skill-badge-quaternary-bg: rgba(65, 36, 2, 0.4);
|
||||||
|
--skill-badge-quaternary-text: #fac775;
|
||||||
|
|
||||||
|
// Feature cards
|
||||||
|
--feature-card-primary-bg: rgba(12, 68, 124, 0.4);
|
||||||
|
--feature-card-secondary-bg: rgba(4, 44, 83, 0.6);
|
||||||
|
--feature-card-tertiary-bg: rgba(12, 68, 124, 0.4);
|
||||||
|
--feature-icon-primary: #ef9f27;
|
||||||
|
--feature-icon-secondary: #ef9f27;
|
||||||
|
--feature-icon-tertiary: #ef9f27;
|
||||||
|
--feature-title-primary: #e6f1fb;
|
||||||
|
--feature-title-secondary: #e6f1fb;
|
||||||
|
--feature-title-tertiary: #e6f1fb;
|
||||||
|
--feature-description-primary: #b5d4f4;
|
||||||
|
--feature-description-secondary: #b5d4f4;
|
||||||
|
--feature-description-tertiary: #b5d4f4;
|
||||||
|
|
||||||
|
// Remove gradients
|
||||||
|
--gradient-primary: #ef9f27;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode: override hardcoded SASS-compiled colors on section headings
|
||||||
|
[data-theme='dark'] {
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
color: #e6f1fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section__greeting {
|
||||||
|
color: #e6f1fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-section__category-title {
|
||||||
|
color: #e6f1fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Border-left accent colors
|
||||||
|
.reasons-section__item,
|
||||||
|
.references-section__item,
|
||||||
|
.experience-section__paragraph--outro,
|
||||||
|
.reasons-section__outro {
|
||||||
|
border-left-color: #ef9f27;
|
||||||
|
}
|
||||||
|
|
||||||
|
.winnings-section__item,
|
||||||
|
.processes-section__item {
|
||||||
|
border-left-color: #b5d4f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat numbers in reasons section
|
||||||
|
.reasons-section__stat {
|
||||||
|
color: #ef9f27;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navbar deepens in dark mode
|
||||||
|
.navbar {
|
||||||
|
background: #021829;
|
||||||
|
border-bottom: 1px solid rgba(230, 241, 251, 0.08);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
font-family: 'Quicksand', sans-serif;
|
||||||
'Roboto', sans-serif;
|
font-weight: 400;
|
||||||
|
font-size: 1rem;
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
margin: 0; // Explicitly remove body margin
|
margin: 0;
|
||||||
padding: 0; // Explicitly remove body padding
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
font-family: 'Comfortaa', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
min-height: 100vh;
|
|
||||||
padding: 3rem 1rem;
|
padding: 3rem 1rem;
|
||||||
margin: 0; // Remove any section margins
|
margin: 0; // Remove any section margins
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
@use '../variables' as *;
|
||||||
|
@use '../globals';
|
||||||
|
|
||||||
|
.back-to-top {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-dark); // #E6F1FB – white on dark navy
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(100px);
|
||||||
|
z-index: 999;
|
||||||
|
|
||||||
|
&--visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 2px solid var(--color-focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.back-to-top {
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,21 +5,25 @@
|
||||||
|
|
||||||
// Global CSS custom properties for accessibility
|
// Global CSS custom properties for accessibility
|
||||||
:root {
|
:root {
|
||||||
--color-primary: #2563eb;
|
--color-primary: #042c53;
|
||||||
--color-secondary: #60a5fa;
|
--color-secondary: #0c447c;
|
||||||
--color-focus-ring: #2563eb;
|
--color-accent-text: #412402;
|
||||||
--color-text-aaa-light: #111827;
|
--color-accent-deco: #ef9f27;
|
||||||
--color-text-aaa-dark: #ffffff;
|
--color-text-on-dark: #e6f1fb;
|
||||||
--box-shadow-hover: rgba(0, 0, 0, 0.15);
|
--color-focus-ring: #042c53;
|
||||||
|
--color-text-aaa-light: #1a1a1a;
|
||||||
|
--color-text-aaa-dark: #e6f1fb;
|
||||||
|
--box-shadow-hover: rgba(4, 44, 83, 0.15);
|
||||||
|
--font-headline: 'Comfortaa', sans-serif;
|
||||||
|
--font-body: 'Quicksand', sans-serif;
|
||||||
|
--contact-button-bg: #042c53;
|
||||||
|
--contact-button-text: #e6f1fb;
|
||||||
|
--contact-button-hover-bg: #0c447c;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
// Dark mode is controlled via [data-theme='dark'] attribute on <html>.
|
||||||
:root {
|
// OS-level prefers-color-scheme is intentionally NOT used here to avoid
|
||||||
--color-primary: #60a5fa;
|
// --color-primary flipping to #E6F1FB, which breaks the always-dark navbar.
|
||||||
--color-secondary: #93c5fd;
|
|
||||||
--color-focus-ring: #60a5fa;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Screen Reader Only Classes - Central Definition
|
// Screen Reader Only Classes - Central Definition
|
||||||
.sr-only {
|
.sr-only {
|
||||||
|
|
@ -59,18 +63,19 @@
|
||||||
top: -40px;
|
top: -40px;
|
||||||
left: 6px;
|
left: 6px;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: var(--color-text-aaa-dark);
|
color: #000000;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
transition: top 0.3s;
|
transition: top 0.3s;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
top: 6px;
|
top: 6px;
|
||||||
outline: 2px solid var(--color-focus-ring);
|
outline: 2px solid var(--color-focus-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
|
color: #000000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,3 +207,21 @@ textarea,
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Scroll reveal animation ───────────────────────────────────────────────────
|
||||||
|
// Add class `reveal-item` to any element you want to animate in on scroll.
|
||||||
|
// The hook useScrollReveal() adds `is-visible` when the element enters view.
|
||||||
|
// Use `--reveal-delay` CSS custom property for staggered children.
|
||||||
|
.reveal-item {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(28px);
|
||||||
|
transition:
|
||||||
|
opacity 0.55s ease,
|
||||||
|
transform 0.55s ease;
|
||||||
|
transition-delay: var(--reveal-delay, 0ms);
|
||||||
|
|
||||||
|
&.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--color-text-muted);
|
border: 1px solid rgba(230, 241, 251, 0.35); // light on always-dark navbar
|
||||||
border-radius: $border-radius-sm;
|
border-radius: $border-radius-sm;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
|
|
||||||
|
|
@ -16,32 +16,32 @@
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text);
|
color: var(--color-text-on-dark); // #E6F1FB – 10:1 on #042C53 navbar
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
@extend %hover-lift;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--color-text);
|
background: rgba(230, 241, 251, 0.1);
|
||||||
|
color: var(--color-text-on-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: 2px solid var(--color-primary);
|
outline: 2px solid var(--color-accent-deco); // ochre – always visible on dark
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
box-shadow: 0 2px 4px var(--box-shadow-active);
|
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
background: var(--color-primary);
|
background: var(--color-text-on-dark); // #E6F1FB bg → 10:1 contrast
|
||||||
color: var(--color-background);
|
color: #042c53; // night-blue text on light bg → AAA
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-primary);
|
background: var(--color-text-on-dark);
|
||||||
color: var(--color-background);
|
color: #042c53;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,17 @@
|
||||||
@use '../variables';
|
@use '../variables';
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
background-color: var(--color-background-muted);
|
background-color: var(--color-surface);
|
||||||
margin-top: auto; // Push footer to bottom if using flexbox layout
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin-top: auto;
|
||||||
|
|
||||||
&__container {
|
&__container {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 1rem;
|
padding: 2rem 1rem 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__separator {
|
&__separator {
|
||||||
height: 1px;
|
display: none; // border-top on .footer replaces this
|
||||||
background-color: var(--color-border);
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__content {
|
&__content {
|
||||||
|
|
@ -38,7 +37,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&__link {
|
&__link {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
|
@ -46,21 +45,22 @@
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--color-text);
|
color: var(--color-primary);
|
||||||
background-color: var(--color-background-hover);
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: 2px solid var(--color-primary);
|
outline: 2px solid var(--color-focus-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__copyright {
|
&__copyright {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__social {
|
&__social {
|
||||||
|
|
@ -75,20 +75,22 @@
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-background-hover);
|
background-color: var(--color-primary);
|
||||||
color: var(--color-text);
|
color: var(--color-text-on-dark);
|
||||||
|
border-color: var(--color-primary);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: 2px solid var(--color-primary);
|
outline: 2px solid var(--color-focus-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,33 +4,74 @@
|
||||||
@include globals.flex-center();
|
@include globals.flex-center();
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
background: var(--color-background);
|
background: #042c53; // always night-blue – independent of --color-primary cascade
|
||||||
color: var(--color-text);
|
color: var(--color-text-on-dark);
|
||||||
height: 65px;
|
height: 65px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 2px 8px rgba(4, 44, 83, 0.2);
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text-on-dark);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__logo {
|
||||||
|
height: 65px;
|
||||||
|
max-height: calc(65px - 0.5rem); // stay within navbar height minus padding
|
||||||
|
width: auto;
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__container {
|
&__container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
// Fluid gap: 0.25rem at 1050px → 0.5rem at 1300px
|
||||||
|
gap: clamp(0.25rem, -0.8rem + 1.6vw, 0.5rem);
|
||||||
|
|
||||||
&__button {
|
&__button {
|
||||||
@extend %button-reset;
|
@extend %button-reset;
|
||||||
@extend %hover-lift;
|
@extend %hover-lift;
|
||||||
padding: 0.6rem 1.2rem;
|
// Fluid padding: 0.5rem 0.5rem at 1050px → 0.6rem 1.2rem at 1300px
|
||||||
|
padding: clamp(0.5rem, 0.08rem + 0.64vw, 0.6rem)
|
||||||
|
clamp(0.5rem, -2.44rem + 4.48vw, 1.2rem);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
color: var(--color-text-on-dark);
|
||||||
|
white-space: nowrap;
|
||||||
|
// Fluid font-size: 0.85rem at 1050px → 1rem at 1300px
|
||||||
|
font-size: clamp(0.85rem, 0.22rem + 0.96vw, 1rem);
|
||||||
|
transition:
|
||||||
|
color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-accent-deco);
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
box-shadow: 0 2px 4px var(--box-shadow-active);
|
box-shadow: none;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
color: var(
|
||||||
|
--color-text-on-dark
|
||||||
|
); // #E6F1FB – 10:1 contrast, same as default
|
||||||
|
box-shadow: none;
|
||||||
|
background: rgba(230, 241, 251, 0.12);
|
||||||
|
border-bottom: 2px solid var(--color-accent-deco); // ochre underline as accent indicator
|
||||||
|
border-radius: 8px 8px 4px 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,14 +90,14 @@
|
||||||
display: block;
|
display: block;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: var(--color-text);
|
background-color: var(--color-text-on-dark); // light on dark navy bg
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
margin: 1px 0;
|
margin: 1px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover &__item {
|
&:hover &__item {
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-accent-deco); // ochre accent on hover
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
|
|
@ -74,23 +115,56 @@
|
||||||
|
|
||||||
/* Mobile Menu Dropdown */
|
/* Mobile Menu Dropdown */
|
||||||
.navbar__mobile-menu {
|
.navbar__mobile-menu {
|
||||||
@include globals.flex-center();
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
top: 65px;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
border: 1px solid var(--color-primary);
|
border: 1px solid var(--color-primary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.5em;
|
padding: 0;
|
||||||
gap: 0.5em;
|
|
||||||
box-shadow: 0 4px 12px var(--box-shadow-hover);
|
box-shadow: 0 4px 12px var(--box-shadow-hover);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
min-width: 200px;
|
||||||
|
|
||||||
button {
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0.5em;
|
||||||
|
border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close {
|
||||||
@extend %button-reset;
|
@extend %button-reset;
|
||||||
padding: 0.5em 1em;
|
font-size: 1.5rem;
|
||||||
text-align: right;
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--box-shadow-active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
@extend %button-reset;
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--box-shadow-active);
|
background: var(--box-shadow-active);
|
||||||
|
|
@ -105,8 +179,22 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Landing page has 7 menu items — switch to burger at same breakpoint as regular nav
|
||||||
|
@media (max-width: 1049px) {
|
||||||
|
.navbar--landing .navbar__container__button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@include globals.desktop-only {
|
@include globals.desktop-only {
|
||||||
.navbar__burger-button {
|
.navbar__burger-button {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep burger visible for landing page up to 1049px
|
||||||
|
@media (min-width: 769px) and (max-width: 1049px) {
|
||||||
|
.navbar--landing .navbar__burger-button {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
border-radius: $border-radius-sm;
|
border-radius: $border-radius-sm;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: var(--box-shadow-sm);
|
box-shadow: var(--box-shadow-sm);
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
// Card hover effect mixin
|
// Card hover effect mixin
|
||||||
@mixin card-hover {
|
@mixin card-hover {
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: var(--box-shadow-lg);
|
box-shadow: var(--box-shadow-lg);
|
||||||
|
|
@ -47,14 +47,14 @@
|
||||||
// Aspect ratio mixin
|
// Aspect ratio mixin
|
||||||
@mixin aspect-ratio($ratio: 1) {
|
@mixin aspect-ratio($ratio: 1) {
|
||||||
aspect-ratio: $ratio;
|
aspect-ratio: $ratio;
|
||||||
|
|
||||||
@supports not (aspect-ratio: 1) {
|
@supports not (aspect-ratio: 1) {
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
float: left;
|
float: left;
|
||||||
padding-top: calc(100% / #{$ratio});
|
padding-top: calc(100% / #{$ratio});
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
display: table;
|
display: table;
|
||||||
|
|
@ -65,13 +65,13 @@
|
||||||
|
|
||||||
// Responsive breakpoint mixins
|
// Responsive breakpoint mixins
|
||||||
@mixin mobile-only {
|
@mixin mobile-only {
|
||||||
@media (max-width: $mobile-breakpoint) {
|
@media (max-width: $mobile-breakpoint) {
|
||||||
@content;
|
@content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin desktop-only {
|
@mixin desktop-only {
|
||||||
@media (min-width: $desktop-breakpoint) {
|
@media (min-width: $desktop-breakpoint) {
|
||||||
@content;
|
@content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -96,7 +96,7 @@
|
||||||
font-size: 1.875rem;
|
font-size: 1.875rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: var(--color-text);
|
color: $color-heading;
|
||||||
|
|
||||||
@include desktop-only {
|
@include desktop-only {
|
||||||
font-size: 2.25rem;
|
font-size: 2.25rem;
|
||||||
|
|
@ -108,4 +108,4 @@
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
max-width: 48rem;
|
max-width: 48rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
// Section imports - organized and easy to manage
|
// Section imports - organized and easy to manage
|
||||||
@forward 'hero-section';
|
@forward 'hero-section';
|
||||||
@forward 'about-section';
|
@forward 'about-section';
|
||||||
|
@forward 'reasons-section';
|
||||||
|
@forward 'experience-section';
|
||||||
|
@forward 'winnings-section';
|
||||||
|
@forward 'processes-section';
|
||||||
|
@forward 'references-section';
|
||||||
@forward 'services-section';
|
@forward 'services-section';
|
||||||
@forward 'skills-section';
|
@forward 'skills-section';
|
||||||
@forward 'certifications-section';
|
@forward 'certifications-section';
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,23 @@
|
||||||
|
|
||||||
.about-section {
|
.about-section {
|
||||||
background: var(--about-background);
|
background: var(--about-background);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
var(--color-primary) 30%,
|
||||||
|
var(--color-secondary) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
&__container {
|
&__container {
|
||||||
@include section-container;
|
@include section-container;
|
||||||
|
|
@ -15,6 +32,8 @@
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
@include section-title;
|
@include section-title;
|
||||||
|
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||||
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__subtitle {
|
&__subtitle {
|
||||||
|
|
@ -55,6 +74,50 @@
|
||||||
background: var(--gradient-image-overlay);
|
background: var(--gradient-image-overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__pixel-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 0 -35px;
|
||||||
|
background-color: #dbeafe;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 1.2s ease-in-out;
|
||||||
|
will-change: opacity;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
[data-theme='dark'] & {
|
||||||
|
background-color: #1e3a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--fade {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pixel-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 0 -35px;
|
||||||
|
width: calc(100% + 35px);
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: calc(30%) 50%;
|
||||||
|
border: 4px solid var(--color-background);
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 1.2s ease-in-out;
|
||||||
|
will-change: opacity;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&--fade {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__bio-section {
|
&__bio-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -77,7 +140,7 @@
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: var(--about-greeting-color);
|
color: $color-heading;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__bio-text {
|
&__bio-text {
|
||||||
|
|
@ -113,8 +176,8 @@
|
||||||
transition: $transition-base;
|
transition: $transition-base;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-2px) scale(1.05);
|
||||||
box-shadow: 0 4px 12px var(--box-shadow-hover);
|
box-shadow: var(--shadow-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--primary {
|
&--primary {
|
||||||
|
|
@ -161,14 +224,14 @@
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: $card-border-radius-sm;
|
border-radius: $card-border-radius-sm;
|
||||||
box-shadow: $shadow-card-lg;
|
box-shadow: $shadow-card-lg;
|
||||||
transition: $transition-base;
|
transition: var(--transition-slow);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
box-shadow: var(--shadow-glow-hover);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-4px) scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced focus state for accessibility
|
// Enhanced focus state for accessibility
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,23 @@
|
||||||
|
|
||||||
.certifications-section {
|
.certifications-section {
|
||||||
background: var(--certifications-background);
|
background: var(--certifications-background);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
var(--color-primary) 30%,
|
||||||
|
var(--color-secondary) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
&__container {
|
&__container {
|
||||||
@include section-container;
|
@include section-container;
|
||||||
|
|
@ -15,6 +32,8 @@
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
@include section-title;
|
@include section-title;
|
||||||
|
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||||
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__subtitle {
|
&__subtitle {
|
||||||
|
|
@ -41,8 +60,10 @@
|
||||||
border-radius: $card-border-radius-md;
|
border-radius: $card-border-radius-md;
|
||||||
box-shadow: var(--certifications-card-shadow);
|
box-shadow: var(--certifications-card-shadow);
|
||||||
background: var(--certifications-card-background);
|
background: var(--certifications-card-background);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
padding: 1.5rem 1rem;
|
padding: 1.5rem 1rem;
|
||||||
transition: $transition-base;
|
transition: var(--transition-slow);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -63,8 +84,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-0.25rem);
|
transform: translateY(-4px) scale(1.02);
|
||||||
box-shadow: var(--certifications-card-shadow-hover);
|
box-shadow: var(--shadow-glow-hover);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
@ -102,7 +123,7 @@
|
||||||
&__card-title {
|
&__card-title {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: $color-heading;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
@ -145,11 +166,4 @@
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reduce card height on larger screens
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
&__card {
|
|
||||||
height: 80%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,23 @@
|
||||||
|
|
||||||
.contact-section {
|
.contact-section {
|
||||||
background: var(--contact-background);
|
background: var(--contact-background);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
var(--color-primary) 30%,
|
||||||
|
var(--color-secondary) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
&__container {
|
&__container {
|
||||||
@include section-container;
|
@include section-container;
|
||||||
|
|
@ -15,6 +32,8 @@
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
@include section-title;
|
@include section-title;
|
||||||
|
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||||
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__subtitle {
|
&__subtitle {
|
||||||
|
|
@ -66,8 +85,10 @@
|
||||||
&__email-icon {
|
&__email-icon {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
color: var(--primary-color);
|
color: var(
|
||||||
background: rgba(147, 51, 234, 0.1);
|
--color-primary
|
||||||
|
); // #042C53 light / #E6F1FB dark – high contrast in both
|
||||||
|
background: rgba(4, 44, 83, 0.08);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
@ -76,7 +97,7 @@
|
||||||
&__email-title {
|
&__email-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text);
|
color: $color-heading;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,7 +105,51 @@
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__appointment-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__appointment-icon {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__appointment-text {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__appointment-link {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: transparent;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
transition: text-decoration-color var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 2px solid var(--color-focus-ring, #2563eb);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Social Card - positioned below connect card
|
// Social Card - positioned below connect card
|
||||||
|
|
@ -162,7 +227,11 @@
|
||||||
&__status-text {
|
&__status-text {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #22c55e;
|
color: #065f46; // dark emerald – 7.4:1 on white; light-mode accessible
|
||||||
|
|
||||||
|
[data-theme='dark'] & {
|
||||||
|
color: #34d399; // light mint – high contrast on dark bg
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__quick-facts {
|
&__quick-facts {
|
||||||
|
|
@ -236,6 +305,26 @@
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__detail-link {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: transparent;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
transition: text-decoration-color var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 2px solid var(--color-focus-ring, #2563eb);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__social {
|
&__social {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
@ -268,17 +357,10 @@
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Brand-specific icon colors with better contrast
|
// Icons inherit color from parent button's var(--contact-social-text)
|
||||||
&--email .contact-section__social-icon {
|
// which is #042C53 (light) / #E6F1FB (dark) – both high contrast
|
||||||
color: var(--social-icon-email, #2563eb); // Use theme variable
|
.contact-section__social-icon {
|
||||||
}
|
color: currentColor;
|
||||||
|
|
||||||
&--github .contact-section__social-icon {
|
|
||||||
color: var(--social-icon-github, #1f2937); // Use theme variable
|
|
||||||
}
|
|
||||||
|
|
||||||
&--linkedin .contact-section__social-icon {
|
|
||||||
color: var(--social-icon-linkedin, #0d67b5); // Use theme variable
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,12 +556,14 @@
|
||||||
border: 1px solid var(--form-border);
|
border: 1px solid var(--form-border);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
@ -493,7 +577,7 @@
|
||||||
&__email-card-icon {
|
&__email-card-icon {
|
||||||
width: 3rem;
|
width: 3rem;
|
||||||
height: 3rem;
|
height: 3rem;
|
||||||
color: var(--primary-color);
|
color: var(--color-primary); // #042C53 light / #E6F1FB dark – high contrast
|
||||||
margin: 0 auto 1rem;
|
margin: 0 auto 1rem;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
@ -514,37 +598,19 @@
|
||||||
margin: var(--card-padding-sm) 0;
|
margin: var(--card-padding-sm) 0;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--contact-button-text);
|
color: var(--contact-button-text);
|
||||||
box-shadow: 0 4px 14px 0 rgba(37, 99, 235, 0.25),
|
box-shadow: none;
|
||||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
display: none;
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: -100%;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent,
|
|
||||||
rgba(255, 255, 255, 0.2),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
transition: left 0.5s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--contact-button-hover-bg);
|
background: var(--contact-button-hover-bg);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 8px 25px 0 rgba(37, 99, 235, 0.35),
|
box-shadow: none;
|
||||||
0 4px 6px -2px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
left: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
|
|
@ -574,7 +640,7 @@
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
color: var(--primary-color);
|
color: var(--color-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -666,6 +732,99 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dark mode explicit overrides ─────────────────────────────────────────────
|
||||||
|
// CSS vars can silently fall back to light values in certain cascade scenarios.
|
||||||
|
// These scoped rules guarantee correct dark appearance regardless.
|
||||||
|
[data-theme='dark'] {
|
||||||
|
.contact-section {
|
||||||
|
// Card / form backgrounds
|
||||||
|
&__connect-card,
|
||||||
|
&__social-card,
|
||||||
|
&__info-card,
|
||||||
|
&__email-card,
|
||||||
|
&__form-card {
|
||||||
|
background: #042c53;
|
||||||
|
border-color: rgba(230, 241, 251, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email button – ochre fill, navy text
|
||||||
|
&__email-button {
|
||||||
|
background: #ef9f27;
|
||||||
|
color: #042c53;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fac775;
|
||||||
|
color: #042c53;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form submit button
|
||||||
|
&__form-button {
|
||||||
|
background: #ef9f27;
|
||||||
|
color: #042c53;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #fac775;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Social list icons (Globe, Linkedin) and links
|
||||||
|
&__social-icon {
|
||||||
|
color: #e6f1fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__detail-item {
|
||||||
|
color: #e6f1fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__detail-link {
|
||||||
|
color: #e6f1fb;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration-color: #e6f1fb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appointment hint block
|
||||||
|
&__appointment-hint {
|
||||||
|
background: rgba(12, 68, 124, 0.5);
|
||||||
|
border-color: rgba(230, 241, 251, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__appointment-icon,
|
||||||
|
&__appointment-link,
|
||||||
|
&__appointment-text {
|
||||||
|
color: #e6f1fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email icon in header
|
||||||
|
&__email-icon {
|
||||||
|
color: #ef9f27;
|
||||||
|
background: rgba(239, 159, 39, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titles within cards (compiled from $color-heading)
|
||||||
|
&__email-title,
|
||||||
|
&__connect-title,
|
||||||
|
&__social-title,
|
||||||
|
&__form-title {
|
||||||
|
color: #e6f1fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inputs
|
||||||
|
&__form-input,
|
||||||
|
&__form-textarea {
|
||||||
|
background: #021829;
|
||||||
|
color: #e6f1fb;
|
||||||
|
border-color: rgba(230, 241, 251, 0.2);
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #b5d4f4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// High contrast mode support
|
// High contrast mode support
|
||||||
@media (prefers-contrast: high) {
|
@media (prefers-contrast: high) {
|
||||||
.contact-section__social-button {
|
.contact-section__social-button {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
@use '../variables' as *;
|
||||||
|
|
||||||
|
.experience-section {
|
||||||
|
padding-block: var(--space-20);
|
||||||
|
padding-inline: var(--space-4);
|
||||||
|
background: var(--bg-experience);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
var(--color-primary) 30%,
|
||||||
|
var(--color-secondary) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding-block: var(--space-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Title ─────────────────────────────────────────────────────
|
||||||
|
&__title {
|
||||||
|
font-size: clamp(2rem, 5vw, var(--font-size-4xl));
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
color: $color-heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Body ──────────────────────────────────────────────────────
|
||||||
|
&__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__paragraph {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: var(--leading-loose);
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
// ── Outro (last paragraph) ───────────────────────────────
|
||||||
|
&--outro {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
background: var(--card-glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
padding: var(--space-5) var(--space-6);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
border: 1px solid var(--card-glass-border);
|
||||||
|
border-left: 4px solid var(--color-secondary);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,40 +17,112 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes hero-fade-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 20px, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hero-bubble-before {
|
||||||
|
0% { transform: scale(0.6) translate3d(0, 16px, 0); opacity: 0; }
|
||||||
|
40% { transform: scale(1.1) translate3d(0, -5px, 0); opacity: 1; }
|
||||||
|
65% { transform: scale(0.96) translate3d(0, 3px, 0); }
|
||||||
|
82% { transform: scale(1.03) translate3d(0, -1px, 0); }
|
||||||
|
100% { transform: scale(1) translate3d(0, 0, 0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hero-bubble-after {
|
||||||
|
0% { transform: scale(0.5) translate3d(0, -12px, 0); opacity: 0; }
|
||||||
|
45% { transform: scale(1.14) translate3d(0, 4px, 0); opacity: 1; }
|
||||||
|
68% { transform: scale(0.94) translate3d(0, -2px, 0); }
|
||||||
|
84% { transform: scale(1.04) translate3d(0, 1px, 0); }
|
||||||
|
100% { transform: scale(1) translate3d(0, 0, 0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
.hero-section {
|
.hero-section {
|
||||||
@include flex-center;
|
@include flex-center;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--hero-background);
|
background: var(--hero-background);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
// Mid-Century geometric circle decoration
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -80px;
|
||||||
|
right: -80px;
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(12, 68, 124, 0.15);
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 40px;
|
||||||
|
left: -60px;
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(239, 159, 39, 0.08);
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] & {
|
||||||
|
&::before {
|
||||||
|
border-color: rgba(230, 241, 251, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: rgba(239, 159, 39, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__container {
|
&__container {
|
||||||
max-width: 800px;
|
max-width: 900px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@include flex-center;
|
@include flex-center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-xl;
|
gap: $spacing-xl;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-size: 3rem;
|
font-size: clamp(2.5rem, 7vw, 5rem);
|
||||||
font-weight: bold;
|
font-weight: 900;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@include gradient-text(var(--gradient-text));
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: $color-heading;
|
||||||
|
will-change: transform, opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__description {
|
&__description {
|
||||||
font-size: 1.2rem;
|
font-size: 1.25rem;
|
||||||
line-height: 1.6;
|
line-height: 1.65;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
will-change: transform, opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__buttons {
|
&__buttons {
|
||||||
@include flex-center;
|
@include flex-center;
|
||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
will-change: transform, opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__button {
|
&__button {
|
||||||
|
|
@ -58,27 +130,31 @@
|
||||||
|
|
||||||
&--primary,
|
&--primary,
|
||||||
&--tertiary {
|
&--tertiary {
|
||||||
background: var(--gradient-primary);
|
background: var(--color-primary);
|
||||||
color: var(--color-background);
|
color: var(--color-text-on-dark);
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
box-shadow: none;
|
||||||
|
filter: none;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: var(--box-shadow-sm);
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--secondary {
|
&--secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text);
|
color: var(--color-primary);
|
||||||
border: 2px solid $color-primary;
|
border: 2px solid var(--color-primary);
|
||||||
padding: calc(#{$spacing-sm} - 2px) $spacing-xl;
|
padding: calc(#{$spacing-sm} - 2px) $spacing-xl;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $color-primary;
|
background: var(--color-primary);
|
||||||
color: var(--color-background);
|
color: var(--color-text-on-dark);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: var(--box-shadow-sm);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +180,7 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
border-radius: $border-radius-md;
|
border-radius: $border-radius-md;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
|
||||||
&--1 {
|
&--1 {
|
||||||
background: var(--stat-primary-bg);
|
background: var(--stat-primary-bg);
|
||||||
|
|
@ -187,3 +264,37 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Animations fire only after the page has fully loaded (--loaded class added via JS)
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.hero-section--loaded {
|
||||||
|
&::before {
|
||||||
|
animation: hero-bubble-before 4s cubic-bezier(0.34, 1.2, 0.64, 1) both 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
animation: hero-bubble-after 4s cubic-bezier(0.34, 1.2, 0.64, 1) both 0.7s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section__title {
|
||||||
|
animation: hero-fade-up 0.6s ease-out both 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section__description {
|
||||||
|
animation: hero-fade-up 0.6s ease-out both 0.35s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section__buttons {
|
||||||
|
animation: hero-fade-up 0.6s ease-out both 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section__stat-item {
|
||||||
|
animation: hero-fade-up 0.7s ease-out both;
|
||||||
|
|
||||||
|
&--1 { animation-delay: 0.7s; }
|
||||||
|
&--2 { animation-delay: 0.85s; }
|
||||||
|
&--3 { animation-delay: 1s; }
|
||||||
|
&--4 { animation-delay: 1.15s; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
&__title {
|
&__title {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--color-text);
|
color: globals.$color-heading;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
@include globals.desktop-only {
|
@include globals.desktop-only {
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
&__section-title {
|
&__section-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: globals.$color-heading;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
border-bottom: 2px solid var(--color-primary);
|
border-bottom: 2px solid var(--color-primary);
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
&__subsection-title {
|
&__subsection-title {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: globals.$color-heading;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,7 +120,7 @@
|
||||||
&__privacy-subtitle {
|
&__privacy-subtitle {
|
||||||
font-size: 1.375rem;
|
font-size: 1.375rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: globals.$color-heading;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
@ -137,7 +137,6 @@
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
text-align: justify;
|
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
@ -150,7 +149,7 @@
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border-left: 4px solid var(--color-primary);
|
border-left: 4px solid var(--color-secondary);
|
||||||
border-radius: 0 0.5rem 0.5rem 0;
|
border-radius: 0 0.5rem 0.5rem 0;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
@use '../variables' as *;
|
||||||
|
|
||||||
|
.processes-section {
|
||||||
|
padding-block: var(--space-20);
|
||||||
|
padding-inline: var(--space-4);
|
||||||
|
background: var(--bg-processes);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
var(--color-secondary) 30%,
|
||||||
|
var(--color-primary) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding-block: var(--space-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Title ─────────────────────────────────────────────────────
|
||||||
|
&__title {
|
||||||
|
font-size: clamp(2rem, 5vw, var(--font-size-4xl));
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
color: $color-heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Intro line ────────────────────────────────────────────────
|
||||||
|
&__intro {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
margin-top: calc(var(--space-4) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List ──────────────────────────────────────────────────────
|
||||||
|
&__list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
background: var(--card-glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--card-glass-border);
|
||||||
|
border-left: 3px solid var(--color-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition:
|
||||||
|
box-shadow var(--transition-slow),
|
||||||
|
transform var(--transition-slow);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow-glow-hover);
|
||||||
|
transform: translateY(-3px) scale(1.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: var(--leading-loose);
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Outro ─────────────────────────────────────────────────────
|
||||||
|
&__outro {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
background: var(--card-glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
padding: var(--space-5) var(--space-6);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
border: 1px solid var(--card-glass-border);
|
||||||
|
border-left: 4px solid var(--color-secondary);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,23 @@
|
||||||
|
|
||||||
.projects-section {
|
.projects-section {
|
||||||
background: var(--projects-background);
|
background: var(--projects-background);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
var(--color-secondary) 30%,
|
||||||
|
var(--color-primary) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
&__container {
|
&__container {
|
||||||
@include section-container;
|
@include section-container;
|
||||||
|
|
@ -15,6 +32,8 @@
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
@include section-title;
|
@include section-title;
|
||||||
|
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||||
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__subtitle {
|
&__subtitle {
|
||||||
|
|
@ -221,7 +240,7 @@
|
||||||
&__card-title {
|
&__card-title {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: $color-heading;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
@ -446,6 +465,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Responsive layout for screens smaller than 1050px
|
||||||
|
@media (max-width: 1050px) {
|
||||||
|
.projects-section {
|
||||||
|
&__actions {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dark theme adjustments for help section:
|
// Dark theme adjustments for help section:
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.projects-section {
|
.projects-section {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
@use '../variables' as *;
|
||||||
|
|
||||||
|
.reasons-section {
|
||||||
|
padding-block: var(--space-20);
|
||||||
|
padding-inline: var(--space-4);
|
||||||
|
background: var(--bg-reasons);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
var(--color-primary) 30%,
|
||||||
|
var(--color-secondary) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding-block: var(--space-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Title ─────────────────────────────────────────────────────
|
||||||
|
&__title {
|
||||||
|
font-size: clamp(2rem, 5vw, var(--font-size-4xl));
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
color: $color-heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Intro quote ───────────────────────────────────────────────
|
||||||
|
&__intro {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
margin-top: calc(var(--space-4) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transition line ───────────────────────────────────────────
|
||||||
|
&__transition {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
margin-top: calc(var(--space-4) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List ──────────────────────────────────────────────────────
|
||||||
|
&__list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 5.5rem 1fr;
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--space-4);
|
||||||
|
background: var(--card-glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--card-glass-border);
|
||||||
|
border-left: 3px solid var(--color-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition:
|
||||||
|
box-shadow var(--transition-slow),
|
||||||
|
transform var(--transition-slow);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow-glow-hover);
|
||||||
|
transform: translateY(-3px) scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 800;
|
||||||
|
color: $color-heading; // compiled literal – immune to CSS var cascade issues
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
|
||||||
|
[data-theme='dark'] & {
|
||||||
|
color: var(
|
||||||
|
--color-text-on-dark
|
||||||
|
); // #E6F1FB – always defined in globals, never overridden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: var(--leading-loose);
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Outro ─────────────────────────────────────────────────────
|
||||||
|
&__outro {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
background: var(--card-glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
padding: var(--space-5) var(--space-6);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
border: 1px solid var(--card-glass-border);
|
||||||
|
border-left: 4px solid var(--color-secondary);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
@use '../variables' as *;
|
||||||
|
|
||||||
|
.references-section {
|
||||||
|
padding-block: var(--space-20);
|
||||||
|
padding-inline: var(--space-4);
|
||||||
|
background: var(--bg-references);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
var(--color-primary) 30%,
|
||||||
|
var(--color-secondary) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding-block: var(--space-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Title ─────────────────────────────────────────────────────
|
||||||
|
&__title {
|
||||||
|
font-size: clamp(2rem, 5vw, var(--font-size-4xl));
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
color: $color-heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List ──────────────────────────────────────────────────────
|
||||||
|
&__list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
background: var(--card-glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--card-glass-border);
|
||||||
|
border-left: 3px solid var(--color-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-6);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
transition:
|
||||||
|
box-shadow var(--transition-slow),
|
||||||
|
transform var(--transition-slow);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow-glow-hover);
|
||||||
|
transform: translateY(-3px) scale(1.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quote text ────────────────────────────────────────────────
|
||||||
|
&__quote {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: var(--leading-loose);
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
font-style: italic;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '\201C';
|
||||||
|
font-size: 2.2em;
|
||||||
|
line-height: 0;
|
||||||
|
vertical-align: -0.45em;
|
||||||
|
margin-right: 0.15em;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-style: normal;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '\201D';
|
||||||
|
font-size: 2.2em;
|
||||||
|
line-height: 0;
|
||||||
|
vertical-align: -0.45em;
|
||||||
|
margin-left: 0.15em;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-style: normal;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Author attribution ────────────────────────────────────────
|
||||||
|
&__author {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__position {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,23 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: $section-padding-y 0;
|
padding: $section-padding-y 0;
|
||||||
background: var(--services-background);
|
background: var(--services-background);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
var(--color-primary) 30%,
|
||||||
|
var(--color-secondary) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Only apply min-height on larger screens where we have more space
|
// Only apply min-height on larger screens where we have more space
|
||||||
@media (min-width: 1024px) and (min-height: 800px) {
|
@media (min-width: 1024px) and (min-height: 800px) {
|
||||||
|
|
@ -30,10 +47,8 @@
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
@include section-title;
|
@include section-title;
|
||||||
|
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||||
@include mobile-only {
|
font-weight: 800;
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__subtitle {
|
&__subtitle {
|
||||||
|
|
@ -84,16 +99,17 @@
|
||||||
&__card {
|
&__card {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: $card-border-radius-md;
|
border-radius: $card-border-radius-md;
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
box-shadow: var(--shadow-sm);
|
||||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
transition: var(--transition-slow);
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 250px; // Ensures consistent card heights
|
min-height: 250px;
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid var(--card-glass-border);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
animation: cardFadeIn 0.6s ease-out;
|
animation: cardFadeIn 0.6s ease-out;
|
||||||
|
|
||||||
// Add subtle backdrop blur for depth
|
// Add subtle backdrop blur for depth
|
||||||
|
|
@ -111,9 +127,7 @@
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-8px) scale(1.02);
|
transform: translateY(-8px) scale(1.02);
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
box-shadow: var(--shadow-glow-hover);
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.3);
|
|
||||||
filter: brightness(1.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
|
|
@ -133,17 +147,16 @@
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced hover and focus interaction
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus {
|
&:focus {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px) scale(1.015);
|
||||||
box-shadow: $shadow-card-hover;
|
box-shadow: var(--shadow-glow-hover);
|
||||||
|
|
||||||
// When both focused and hovered
|
// When both focused and hovered
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: 3px solid var(--color-focus-ring, #2563eb);
|
outline: 3px solid var(--color-focus-ring, #2563eb);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
transform: translateY(-6px); // Slightly more lift when focused
|
transform: translateY(-6px) scale(1.015);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,7 +242,8 @@
|
||||||
|
|
||||||
// Dark theme enhancements
|
// Dark theme enhancements
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3),
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.3),
|
||||||
0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,28 +343,13 @@
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--primary {
|
&--primary,
|
||||||
color: var(--service-title-primary);
|
&--secondary,
|
||||||
}
|
&--tertiary,
|
||||||
|
&--quaternary,
|
||||||
&--secondary {
|
&--quinary,
|
||||||
color: var(--service-title-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--tertiary {
|
|
||||||
color: var(--service-title-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--quaternary {
|
|
||||||
color: var(--service-title-quaternary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--quinary {
|
|
||||||
color: var(--service-title-quinary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--senary {
|
&--senary {
|
||||||
color: var(--service-title-senary);
|
color: $color-heading;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -443,7 +442,8 @@
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.services-section__card {
|
.services-section__card {
|
||||||
// Enhance card visibility with better shadows in dark mode
|
// Enhance card visibility with better shadows in dark mode
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3),
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.3),
|
||||||
0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||||
animation: cardFadeIn 0.6s ease-out;
|
animation: cardFadeIn 0.6s ease-out;
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue