From 88bd952a601a3fccbd520abb66f50c7b72acd553 Mon Sep 17 00:00:00 2001 From: DoctoDre Date: Tue, 28 Jul 2026 23:53:18 +0200 Subject: [PATCH] feat(andreknie.de): improve accessibility and initial loading --- .../andreknie.de/.gitea/workflows/deploy.yml | 55 +++++++++++--- .../plugins/vite-plugin-content.js | 29 +++++--- .../CV/andreknie.de/scripts/check-bundle.mjs | 18 +++++ privat/CV/andreknie.de/src/App.jsx | 73 ++++++++++--------- .../src/components/BackgroundPattern.jsx | 13 +++- .../src/components/BottomDock.css | 5 ++ .../src/components/BottomDock.jsx | 4 +- .../src/components/DotCloudIcon.jsx | 17 +++-- .../src/components/LogoMarquee.css | 5 ++ .../src/components/LogoMarquee.jsx | 2 +- .../src/components/LogoMarquee.test.jsx | 12 +++ .../src/components/Navigation.css | 8 ++ .../src/components/Navigation.jsx | 31 +++++--- .../src/components/Navigation.test.jsx | 25 +++++++ .../src/components/NewsletterSignup.jsx | 10 ++- .../src/components/SpeakerCvRequest.jsx | 24 +++--- .../andreknie.de/src/components/StatsCube.css | 7 ++ .../src/components/TestimonialSlider.css | 6 ++ .../src/components/TestimonialSlider.jsx | 20 ++++- .../src/components/TestimonialSlider.test.jsx | 16 ++++ .../CV/andreknie.de/src/hooks/useContent.js | 27 ++++++- privat/CV/andreknie.de/src/index.css | 38 ++++++++++ .../andreknie.de/src/pages/ArticleSingle.jsx | 14 +++- privat/CV/andreknie.de/src/pages/Articles.jsx | 4 +- privat/CV/andreknie.de/src/pages/Contact.jsx | 26 ++++--- .../CV/andreknie.de/src/pages/Kniepunkt.jsx | 2 +- .../andreknie.de/src/pages/KniepunktIssue.jsx | 11 +++ 27 files changed, 393 insertions(+), 109 deletions(-) create mode 100644 privat/CV/andreknie.de/scripts/check-bundle.mjs create mode 100644 privat/CV/andreknie.de/src/components/LogoMarquee.test.jsx create mode 100644 privat/CV/andreknie.de/src/components/Navigation.test.jsx create mode 100644 privat/CV/andreknie.de/src/components/TestimonialSlider.test.jsx diff --git a/privat/CV/andreknie.de/.gitea/workflows/deploy.yml b/privat/CV/andreknie.de/.gitea/workflows/deploy.yml index 512b17d..5a4ea8c 100644 --- a/privat/CV/andreknie.de/.gitea/workflows/deploy.yml +++ b/privat/CV/andreknie.de/.gitea/workflows/deploy.yml @@ -18,6 +18,10 @@ jobs: run: | npm ci npm run build + node scripts/check-bundle.mjs + test -s dist/index.html + test -d dist/assets + test -n "$(find dist/assets -type f -print -quit)" - name: Copy project files to VM uses: appleboy/scp-action@v0.1.7 @@ -25,7 +29,7 @@ jobs: host: ${{ secrets.DEPLOY_SSH_HOST }} username: ${{ secrets.DEPLOY_SSH_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "Caddyfile,*,dist/**,dist/.*" + source: "Caddyfile,docker-compose.yml,Dockerfile,server/**,package.json,package-lock.json,dist/**,dist/.*" target: "/opt/orchestrator/privat/CV/andreknie.de" - name: Execute Deployment on VM @@ -55,15 +59,36 @@ jobs: HASH_SALT=${{ secrets.UMAMI_HASH_SALT }} EOF - # 2. Frontend-Build in den Caddy-Ordner verschieben - mkdir -p site - # Wenn dist existiert, kopiere den Inhalt - if [ -d "dist" ]; then - cp -r dist/* site/ || true - cp -r dist/.* site/ 2>/dev/null || true - else - echo "FEHLER: dist Ordner existiert nicht!" + # 2. Vollständiges Frontend als begrenztes Release vorbereiten. + # Die Release-ID stammt aus dem Commit und bleibt rückverfolgbar. + test -s dist/index.html + test -d dist/assets + release_id="${GITHUB_SHA:-manual-$(date -u +%Y%m%d%H%M%S)}" + case "$release_id" in + (*[!A-Za-z0-9._-]*) echo "Ungültige Release-ID"; exit 1 ;; + esac + release_dir="releases/release-${release_id}" + previous_dir="releases/previous-${release_id}" + mkdir -p releases + if [ -e "$release_dir" ]; then rm -rf -- "$release_dir"; fi + mkdir -p "$release_dir" + cp -a dist/. "$release_dir/" + test -s "$release_dir/index.html" + test -n "$(find "$release_dir/assets" -type f -print -quit)" + + rollback() { + if [ -d "$previous_dir" ]; then + rm -rf -- site + mv -- "$previous_dir" site + echo "Rollback auf das vorherige Release ausgeführt." + fi + } + trap rollback ERR + if [ -e site ]; then + rm -rf -- "$previous_dir" + mv -- site "$previous_dir" fi + mv -- "$release_dir" site # Debugging: Zeige an, ob die Dateien wirklich da sind echo "INHALT VON SITE:" @@ -77,3 +102,15 @@ jobs: docker compose exec -T caddy grep -n "Content-Security-Policy" /etc/caddy/Caddyfile docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true docker compose up -d --force-recreate caddy + trap - ERR + + # Höchstens die fünf neuesten begrenzten Release-Verzeichnisse + # behalten; site und der aktuelle vorherige Stand bleiben getrennt. + old_releases=$(find releases -mindepth 1 -maxdepth 1 -type d -name 'release-*' | sort -r | tail -n +6) + if [ -n "$old_releases" ]; then + while IFS= read -r old_release; do + [ -z "$old_release" ] || rm -rf -- "$old_release" + done <]+>/g, ' ').replace(/\s+/g, ' ').trim() return { ...data, slug, - body: renderMarkdownContent(content), + ...(includeBodies ? { body } : { searchText }), _source: `${subdir}/${filename}` } } @@ -60,15 +64,15 @@ function readMetaFile(filename) { return yaml.load(raw) } -function loadAllContent() { +function loadAllContent(includeBodies = false) { return { - posts: readContentDir('posts'), - kniepunkt: readContentDir('kniepunkt'), - podcast: readContentDir('podcast'), - talks: readContentDir('talks'), - consulting: readContentDir('consulting'), - resources: readContentDir('resources'), - events: readContentDir('events'), + posts: readContentDir('posts', includeBodies), + kniepunkt: readContentDir('kniepunkt', includeBodies), + podcast: readContentDir('podcast', includeBodies), + talks: readContentDir('talks', includeBodies), + consulting: readContentDir('consulting', includeBodies), + resources: readContentDir('resources', includeBodies), + events: readContentDir('events', includeBodies), meta: { profile: readMetaFile('profile.yaml'), stats: readMetaFile('stats.yaml'), @@ -87,12 +91,17 @@ export default function contentPlugin() { name: 'vite-plugin-content', resolveId(id) { if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID + if (id === DETAILS_VIRTUAL_MODULE_ID) return RESOLVED_DETAILS_VIRTUAL_MODULE_ID }, load(id) { if (id === RESOLVED_VIRTUAL_MODULE_ID) { const content = loadAllContent() return `export default ${JSON.stringify(content)}` } + if (id === RESOLVED_DETAILS_VIRTUAL_MODULE_ID) { + const content = loadAllContent(true) + return `export default ${JSON.stringify(content)}` + } }, handleHotUpdate({ file, server }) { if (file.includes('content')) { diff --git a/privat/CV/andreknie.de/scripts/check-bundle.mjs b/privat/CV/andreknie.de/scripts/check-bundle.mjs new file mode 100644 index 0000000..ec28116 --- /dev/null +++ b/privat/CV/andreknie.de/scripts/check-bundle.mjs @@ -0,0 +1,18 @@ +import { readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' + +const assetsDir = join(process.cwd(), 'dist', 'assets') +const files = readdirSync(assetsDir) +const entry = files.find(file => /^index-[^/]+\.js$/.test(file)) +const detailChunk = files.find(file => /^_virtual_content-details-[^/]+\.js$/.test(file)) +const maxInitialBytes = 700 * 1024 + +if (!entry) throw new Error('Kein initialer JavaScript-Entry-Chunk gefunden.') +if (!detailChunk) throw new Error('Kein separater Content-Detail-Chunk gefunden.') + +const entryBytes = statSync(join(assetsDir, entry)).size +if (entryBytes > maxInitialBytes) { + throw new Error(`Initialer JS-Chunk ist zu groß: ${entryBytes} Bytes (Limit ${maxInitialBytes}).`) +} + +console.log(`Bundle-Prüfung: ${entry}=${entryBytes} Bytes, Detail-Chunk=${detailChunk}`) diff --git a/privat/CV/andreknie.de/src/App.jsx b/privat/CV/andreknie.de/src/App.jsx index c4bc284..bb90625 100644 --- a/privat/CV/andreknie.de/src/App.jsx +++ b/privat/CV/andreknie.de/src/App.jsx @@ -1,25 +1,30 @@ -import React from 'react' +import React, { lazy, Suspense } from 'react' import { Routes, Route } from 'react-router-dom' import Navigation from './components/Navigation' import Footer from './components/Footer' import BackgroundPattern from './components/BackgroundPattern' import BottomDock from './components/BottomDock' import ScrollToTop from './components/ScrollToTop' -import Home from './pages/Home' -import Articles from './pages/Articles' -import ArticleSingle from './pages/ArticleSingle' -import Kniepunkt from './pages/Kniepunkt' -import KniepunktIssue from './pages/KniepunktIssue' -import Podcast from './pages/Podcast' -import Speaking from './pages/Speaking' -import Consulting from './pages/Consulting' -import Resources from './pages/Resources' -import Contact from './pages/Contact' -import Impressum from './pages/Impressum' -import Datenschutz from './pages/Datenschutz' -import About from './pages/About' -import NotFound from './pages/NotFound' -import Confirmation from './pages/Confirmation' + +const Home = lazy(() => import('./pages/Home')) +const Articles = lazy(() => import('./pages/Articles')) +const ArticleSingle = lazy(() => import('./pages/ArticleSingle')) +const Kniepunkt = lazy(() => import('./pages/Kniepunkt')) +const KniepunktIssue = lazy(() => import('./pages/KniepunktIssue')) +const Podcast = lazy(() => import('./pages/Podcast')) +const Speaking = lazy(() => import('./pages/Speaking')) +const Consulting = lazy(() => import('./pages/Consulting')) +const Resources = lazy(() => import('./pages/Resources')) +const Contact = lazy(() => import('./pages/Contact')) +const Impressum = lazy(() => import('./pages/Impressum')) +const Datenschutz = lazy(() => import('./pages/Datenschutz')) +const About = lazy(() => import('./pages/About')) +const NotFound = lazy(() => import('./pages/NotFound')) +const Confirmation = lazy(() => import('./pages/Confirmation')) + +function PageFallback() { + return
Inhalt wird geladen …
+} export default function App() { return ( @@ -28,23 +33,25 @@ export default function App() {
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
diff --git a/privat/CV/andreknie.de/src/components/BackgroundPattern.jsx b/privat/CV/andreknie.de/src/components/BackgroundPattern.jsx index 98ae5ad..1b5c331 100644 --- a/privat/CV/andreknie.de/src/components/BackgroundPattern.jsx +++ b/privat/CV/andreknie.de/src/components/BackgroundPattern.jsx @@ -15,6 +15,8 @@ export default function BackgroundPattern() { const canvas = canvasRef.current if (!canvas) return const ctx = canvas.getContext('2d') + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent-color').trim() || '#4a9eff' function resize() { const dpr = window.devicePixelRatio || 1 @@ -61,8 +63,6 @@ export default function BackgroundPattern() { const h = window.innerHeight ctx.clearRect(0, 0, w, h) - const style = getComputedStyle(document.documentElement) - const accentColor = style.getPropertyValue('--accent-color').trim() || '#4a9eff' const mouse = mouseRef.current const repelRadius = 80 @@ -99,14 +99,21 @@ export default function BackgroundPattern() { ctx.globalAlpha = 1 }) - animRef.current = requestAnimationFrame(animate) + if (!reducedMotion && !document.hidden) animRef.current = requestAnimationFrame(animate) + } + + function handleVisibilityChange() { + if (document.hidden) cancelAnimationFrame(animRef.current) + else if (!reducedMotion) animate() } animate() + document.addEventListener('visibilitychange', handleVisibilityChange) return () => { cancelAnimationFrame(animRef.current) window.removeEventListener('resize', resize) window.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('visibilitychange', handleVisibilityChange) } }, []) diff --git a/privat/CV/andreknie.de/src/components/BottomDock.css b/privat/CV/andreknie.de/src/components/BottomDock.css index 48a008b..a724b4e 100644 --- a/privat/CV/andreknie.de/src/components/BottomDock.css +++ b/privat/CV/andreknie.de/src/components/BottomDock.css @@ -76,6 +76,11 @@ transform: translateY(-8px) scale(1.15); } +.dock-item:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 3px; +} + .dock-item.active { color: var(--accent-color); background: rgba(var(--accent-color-rgb), 0.1); diff --git a/privat/CV/andreknie.de/src/components/BottomDock.jsx b/privat/CV/andreknie.de/src/components/BottomDock.jsx index 65cb610..d61a3bd 100644 --- a/privat/CV/andreknie.de/src/components/BottomDock.jsx +++ b/privat/CV/andreknie.de/src/components/BottomDock.jsx @@ -21,7 +21,9 @@ export default function BottomDock() {