diff --git a/privat/CV/andreknie.de/.gitea/workflows/deploy.yml b/privat/CV/andreknie.de/.gitea/workflows/deploy.yml
index 512b17d..7da7dfb 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
@@ -35,6 +39,7 @@ jobs:
username: ${{ secrets.DEPLOY_SSH_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
+ set -Eeuo pipefail
cd /opt/orchestrator/privat/CV/andreknie.de
# 1. Sichere .env aus den Gitea Secrets generieren
@@ -55,15 +60,37 @@ 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 }}"
+ test -n "$release_id"
+ 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"
+ 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 +104,8 @@ 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
+
+ # Abgebrochene temporäre Releases entfernen. Neben site bleibt
+ # höchstens ein klar benannter Rollback-Stand erhalten.
+ find releases -mindepth 1 -maxdepth 1 -type d -name 'release-*' -exec rm -rf -- {} +
diff --git a/privat/CV/andreknie.de/plugins/vite-plugin-content.js b/privat/CV/andreknie.de/plugins/vite-plugin-content.js
index 02afdfb..7dd26c9 100644
--- a/privat/CV/andreknie.de/plugins/vite-plugin-content.js
+++ b/privat/CV/andreknie.de/plugins/vite-plugin-content.js
@@ -7,8 +7,13 @@ import * as yaml from 'js-yaml'
const CONTENT_DIR = resolve(process.cwd(), 'content')
const VIRTUAL_MODULE_ID = 'virtual:content'
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID
+const DETAIL_LOADERS_MODULE_ID = 'virtual:content-detail-loaders'
+const RESOLVED_DETAIL_LOADERS_MODULE_ID = '\0' + DETAIL_LOADERS_MODULE_ID
+const DETAIL_MODULE_PREFIX = 'virtual:content-detail/'
+const RESOLVED_DETAIL_MODULE_PREFIX = '\0' + DETAIL_MODULE_PREFIX
+const DETAIL_TYPES = ['posts', 'kniepunkt']
-function readContentDir(subdir) {
+function readContentDir(subdir, includeBodies = false) {
const dir = join(CONTENT_DIR, subdir)
if (!existsSync(dir)) return []
@@ -22,10 +27,12 @@ function readContentDir(subdir) {
if (ext === '.md') {
const raw = readFileSync(filepath, 'utf-8')
const { data, content } = matter(raw)
+ const body = renderMarkdownContent(content)
+ const searchText = body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 320)
return {
...data,
slug,
- body: renderMarkdownContent(content),
+ ...(includeBodies ? { body } : { searchText }),
_source: `${subdir}/${filename}`
}
}
@@ -60,15 +67,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'),
@@ -82,25 +89,57 @@ function loadAllContent() {
}
}
+function createDetailLoadersModule() {
+ const typeEntries = DETAIL_TYPES.map(type => {
+ const loaders = readContentDir(type)
+ .map(item => `${JSON.stringify(item.slug)}: () => import(${JSON.stringify(`${DETAIL_MODULE_PREFIX}${type}/${item.slug}`)})`)
+ .join(',\n')
+ return `${JSON.stringify(type)}: {\n${loaders}\n}`
+ })
+
+ return `export default {\n${typeEntries.join(',\n')}\n}`
+}
+
+function loadDetailModule(id) {
+ const relativeId = id.slice(RESOLVED_DETAIL_MODULE_PREFIX.length)
+ const separator = relativeId.indexOf('/')
+ if (separator < 1) return null
+
+ const type = relativeId.slice(0, separator)
+ const slug = relativeId.slice(separator + 1)
+ if (!DETAIL_TYPES.includes(type)) return null
+
+ const item = readContentDir(type, true).find(candidate => candidate.slug === slug)
+ return item ? `export default ${JSON.stringify(item)}` : null
+}
+
export default function contentPlugin() {
return {
name: 'vite-plugin-content',
resolveId(id) {
if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID
+ if (id === DETAIL_LOADERS_MODULE_ID) return RESOLVED_DETAIL_LOADERS_MODULE_ID
+ if (id.startsWith(DETAIL_MODULE_PREFIX)) return `\0${id}`
},
load(id) {
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
const content = loadAllContent()
return `export default ${JSON.stringify(content)}`
}
+ if (id === RESOLVED_DETAIL_LOADERS_MODULE_ID) return createDetailLoadersModule()
+ if (id.startsWith(RESOLVED_DETAIL_MODULE_PREFIX)) return loadDetailModule(id)
},
handleHotUpdate({ file, server }) {
if (file.includes('content')) {
- const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID)
- if (mod) {
- server.moduleGraph.invalidateModule(mod)
- return [mod]
- }
+ const modules = [
+ server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID),
+ server.moduleGraph.getModuleById(RESOLVED_DETAIL_LOADERS_MODULE_ID),
+ ...Array.from(server.moduleGraph.idToModuleMap.entries())
+ .filter(([id]) => id.startsWith(RESOLVED_DETAIL_MODULE_PREFIX))
+ .map(([, module]) => module),
+ ].filter(Boolean)
+ modules.forEach(module => server.moduleGraph.invalidateModule(module))
+ return modules
}
}
}
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..7be95b4
--- /dev/null
+++ b/privat/CV/andreknie.de/scripts/check-bundle.mjs
@@ -0,0 +1,27 @@
+import { readdirSync, statSync } from 'node:fs'
+import { basename, 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 detailSlugs = ['posts', 'kniepunkt'].flatMap(type =>
+ readdirSync(join(process.cwd(), 'content', type))
+ .filter(file => file.endsWith('.md'))
+ .map(file => basename(file, '.md')),
+)
+const missingDetailChunks = detailSlugs.filter(
+ slug => !files.some(file => file.startsWith(`${slug}-`) && file.endsWith('.js')),
+)
+const maxInitialBytes = 400 * 1024
+
+if (!entry) throw new Error('Kein initialer JavaScript-Entry-Chunk gefunden.')
+if (missingDetailChunks.length > 0) {
+ throw new Error(`Fehlende Content-Detail-Chunks: ${missingDetailChunks.join(', ')}`)
+}
+
+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-Chunks=${detailSlugs.length}`)
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/AnimationLifecycle.test.jsx b/privat/CV/andreknie.de/src/components/AnimationLifecycle.test.jsx
new file mode 100644
index 0000000..78ae7f4
--- /dev/null
+++ b/privat/CV/andreknie.de/src/components/AnimationLifecycle.test.jsx
@@ -0,0 +1,65 @@
+import React from 'react'
+import { act, render } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import BackgroundPattern from './BackgroundPattern'
+import DotCloudIcon from './DotCloudIcon'
+
+const context = {
+ arc: vi.fn(),
+ beginPath: vi.fn(),
+ clearRect: vi.fn(),
+ closePath: vi.fn(),
+ drawImage: vi.fn(),
+ ellipse: vi.fn(),
+ fill: vi.fn(),
+ getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(140 * 140 * 4) })),
+ lineTo: vi.fn(),
+ moveTo: vi.fn(),
+ quadraticCurveTo: vi.fn(),
+ scale: vi.fn(),
+ setTransform: vi.fn(),
+}
+
+function setReducedMotion(matches) {
+ vi.stubGlobal('matchMedia', vi.fn(() => ({
+ matches,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ })))
+}
+
+describe('canvas animation lifecycle', () => {
+ beforeEach(() => {
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context)
+ Object.defineProperty(document, 'hidden', { configurable: true, value: false })
+ vi.stubGlobal('requestAnimationFrame', vi.fn(() => 17))
+ vi.stubGlobal('cancelAnimationFrame', vi.fn())
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+ })
+
+ it('does not schedule background animation with reduced motion', () => {
+ setReducedMotion(true)
+ render()
+ expect(requestAnimationFrame).not.toHaveBeenCalled()
+ })
+
+ it('cancels a scheduled background frame on unmount', () => {
+ setReducedMotion(false)
+ const { unmount } = render()
+ expect(requestAnimationFrame).toHaveBeenCalled()
+
+ unmount()
+ expect(cancelAnimationFrame).toHaveBeenCalledWith(17)
+ })
+
+ it('does not schedule dot animation with reduced motion', async () => {
+ setReducedMotion(true)
+ render()
+ await act(async () => Promise.resolve())
+ expect(requestAnimationFrame).not.toHaveBeenCalled()
+ })
+})
diff --git a/privat/CV/andreknie.de/src/components/BackgroundPattern.jsx b/privat/CV/andreknie.de/src/components/BackgroundPattern.jsx
index 98ae5ad..fec8627 100644
--- a/privat/CV/andreknie.de/src/components/BackgroundPattern.jsx
+++ b/privat/CV/andreknie.de/src/components/BackgroundPattern.jsx
@@ -15,12 +15,14 @@ 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
canvas.width = window.innerWidth * dpr
canvas.height = window.innerHeight * dpr
- ctx.scale(dpr, dpr)
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
canvas.style.width = window.innerWidth + 'px'
canvas.style.height = window.innerHeight + 'px'
initParticles()
@@ -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() {