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() {