diff --git a/privat/CV/andreknie.de/.gitea/workflows/deploy.yml b/privat/CV/andreknie.de/.gitea/workflows/deploy.yml index 5a4ea8c..7da7dfb 100644 --- a/privat/CV/andreknie.de/.gitea/workflows/deploy.yml +++ b/privat/CV/andreknie.de/.gitea/workflows/deploy.yml @@ -39,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 @@ -63,12 +64,13 @@ jobs: # 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)}" + 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-${release_id}" + previous_dir="releases/previous" mkdir -p releases if [ -e "$release_dir" ]; then rm -rf -- "$release_dir"; fi mkdir -p "$release_dir" @@ -104,13 +106,6 @@ jobs: 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() + const searchText = body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 320) return { ...data, slug, @@ -86,30 +89,57 @@ function loadAllContent(includeBodies = false) { } } +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 === DETAILS_VIRTUAL_MODULE_ID) return RESOLVED_DETAILS_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_DETAILS_VIRTUAL_MODULE_ID) { - const content = loadAllContent(true) - 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 index ec28116..7be95b4 100644 --- a/privat/CV/andreknie.de/scripts/check-bundle.mjs +++ b/privat/CV/andreknie.de/scripts/check-bundle.mjs @@ -1,18 +1,27 @@ import { readdirSync, statSync } from 'node:fs' -import { join } from 'node:path' +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 detailChunk = files.find(file => /^_virtual_content-details-[^/]+\.js$/.test(file)) -const maxInitialBytes = 700 * 1024 +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 (!detailChunk) throw new Error('Kein separater Content-Detail-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-Chunk=${detailChunk}`) +console.log(`Bundle-Prüfung: ${entry}=${entryBytes} Bytes, Detail-Chunks=${detailSlugs.length}`) 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 1b5c331..fec8627 100644 --- a/privat/CV/andreknie.de/src/components/BackgroundPattern.jsx +++ b/privat/CV/andreknie.de/src/components/BackgroundPattern.jsx @@ -22,7 +22,7 @@ export default function BackgroundPattern() { 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() diff --git a/privat/CV/andreknie.de/src/components/DotCloudIcon.jsx b/privat/CV/andreknie.de/src/components/DotCloudIcon.jsx index cda1d33..2a7bf6f 100644 --- a/privat/CV/andreknie.de/src/components/DotCloudIcon.jsx +++ b/privat/CV/andreknie.de/src/components/DotCloudIcon.jsx @@ -22,6 +22,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) { const ctx = canvas.getContext('2d') const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const color = getComputedStyle(document.documentElement).getPropertyValue('--accent-color').trim() || '#4a9eff' + let active = true const dpr = window.devicePixelRatio || 1 canvas.width = size * dpr canvas.height = size * dpr @@ -29,6 +30,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) { async function init() { const points = await getShapePoints(shape, size) + if (!active) return particlesRef.current = points.map(p => ({ x: p.x, y: p.y, baseX: p.x, baseY: p.y, @@ -36,7 +38,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) { vx: 0, vy: 0, })) initializedRef.current = true - if (reducedMotion) animate() + animate() } function handleMouseMove(e) { @@ -56,7 +58,7 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) { function animate() { ctx.clearRect(0, 0, size, size) if (!initializedRef.current) { - animRef.current = requestAnimationFrame(animate) + if (!reducedMotion) animRef.current = requestAnimationFrame(animate) return } @@ -109,9 +111,9 @@ export default function DotCloudIcon({ shape = 'germany', size = 140 }) { } init() - animate() return () => { + active = false cancelAnimationFrame(animRef.current) canvas.removeEventListener('mousemove', handleMouseMove) canvas.removeEventListener('mouseleave', handleMouseLeave) diff --git a/privat/CV/andreknie.de/src/components/LogoMarquee.css b/privat/CV/andreknie.de/src/components/LogoMarquee.css index 4100568..c844bd9 100644 --- a/privat/CV/andreknie.de/src/components/LogoMarquee.css +++ b/privat/CV/andreknie.de/src/components/LogoMarquee.css @@ -26,6 +26,11 @@ -webkit-mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent); } +.marquee-track:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: -2px; +} + .marquee-inner { display: flex; gap: 60px; diff --git a/privat/CV/andreknie.de/src/components/LogoMarquee.jsx b/privat/CV/andreknie.de/src/components/LogoMarquee.jsx index 1bfaaa3..60e480c 100644 --- a/privat/CV/andreknie.de/src/components/LogoMarquee.jsx +++ b/privat/CV/andreknie.de/src/components/LogoMarquee.jsx @@ -18,7 +18,7 @@ export default function LogoMarquee({ logos = [], title = '', titleAccent = '', {title} {titleAccent && {titleAccent}} )} -
+
{allLogos.map((logo, i) => (
= logos.length}> diff --git a/privat/CV/andreknie.de/src/components/LogoMarquee.test.jsx b/privat/CV/andreknie.de/src/components/LogoMarquee.test.jsx index a011d46..44481ee 100644 --- a/privat/CV/andreknie.de/src/components/LogoMarquee.test.jsx +++ b/privat/CV/andreknie.de/src/components/LogoMarquee.test.jsx @@ -8,5 +8,6 @@ describe('LogoMarquee accessibility', () => { const { container } = render() expect(container.querySelectorAll('.marquee-item:not([aria-hidden="true"])')).toHaveLength(2) expect(container.querySelectorAll('.marquee-item[aria-hidden="true"]')).toHaveLength(4) + expect(container.querySelector('.marquee-track').tabIndex).toBe(0) }) }) diff --git a/privat/CV/andreknie.de/src/components/Navigation.jsx b/privat/CV/andreknie.de/src/components/Navigation.jsx index 99ec14c..a311fc0 100644 --- a/privat/CV/andreknie.de/src/components/Navigation.jsx +++ b/privat/CV/andreknie.de/src/components/Navigation.jsx @@ -72,7 +72,7 @@ export default function Navigation() {
-
+ +

+ Testimonial {activeIndex + 1} von {testimonials.length} +

{ @@ -12,5 +12,6 @@ describe('TestimonialSlider accessibility', () => { ] const { container } = render() expect(container.querySelectorAll('.testimonial-card:not([aria-hidden="true"])')).toHaveLength(3) + expect(screen.getByRole('status').textContent).toContain('Testimonial 1 von 3') }) }) diff --git a/privat/CV/andreknie.de/src/hooks/useContent.js b/privat/CV/andreknie.de/src/hooks/useContent.js index fe2bf60..621b7f6 100644 --- a/privat/CV/andreknie.de/src/hooks/useContent.js +++ b/privat/CV/andreknie.de/src/hooks/useContent.js @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react' import content from 'virtual:content' +import detailLoaders from 'virtual:content-detail-loaders' const detailCache = new Map() -const detailModulePromise = import('virtual:content-details') /** * Get all content of a given type, sorted by date descending. @@ -65,11 +65,20 @@ export function useContentItem(type, slug) { let active = true if (!metadata || detailCache.has(cacheKey)) return () => { active = false } - detailModulePromise.then(module => { - const detail = module.default[type]?.find(candidate => candidate.slug === slug) || null - if (detail) detailCache.set(cacheKey, detail) - if (active) setDetailState({ key: cacheKey, item: detail }) - }) + const loadDetail = detailLoaders[type]?.[slug] + const detailPromise = loadDetail + ? loadDetail() + : Promise.reject(new Error(`No detail loader for ${cacheKey}`)) + + detailPromise + .then(module => { + const detail = module.default + detailCache.set(cacheKey, detail) + if (active) setDetailState({ key: cacheKey, item: detail }) + }) + .catch(() => { + if (active) setDetailState({ key: cacheKey, item: { ...metadata, detailError: true } }) + }) return () => { active = false } }, [cacheKey, metadata, slug, type]) diff --git a/privat/CV/andreknie.de/src/pages/ArticleSingle.jsx b/privat/CV/andreknie.de/src/pages/ArticleSingle.jsx index 2383f5a..d783997 100644 --- a/privat/CV/andreknie.de/src/pages/ArticleSingle.jsx +++ b/privat/CV/andreknie.de/src/pages/ArticleSingle.jsx @@ -24,10 +24,13 @@ export default function ArticleSingle() { } if (!post.body) { + const loadFailed = post.detailError return (
-

Artikel wird geladen …

+

+ {loadFailed ? 'Der Artikel konnte nicht geladen werden.' : 'Artikel wird geladen …'} +

) } diff --git a/privat/CV/andreknie.de/src/pages/Articles.jsx b/privat/CV/andreknie.de/src/pages/Articles.jsx index ea1bfb8..b327fb9 100644 --- a/privat/CV/andreknie.de/src/pages/Articles.jsx +++ b/privat/CV/andreknie.de/src/pages/Articles.jsx @@ -45,6 +45,7 @@ export default function Articles() { setSearchQuery(e.target.value)} style={{ diff --git a/privat/CV/andreknie.de/src/pages/Kniepunkt.jsx b/privat/CV/andreknie.de/src/pages/Kniepunkt.jsx index 9c01e64..0d560a4 100644 --- a/privat/CV/andreknie.de/src/pages/Kniepunkt.jsx +++ b/privat/CV/andreknie.de/src/pages/Kniepunkt.jsx @@ -36,6 +36,7 @@ export default function Kniepunkt() { setSearchQuery(e.target.value)} style={{ diff --git a/privat/CV/andreknie.de/src/pages/KniepunktIssue.jsx b/privat/CV/andreknie.de/src/pages/KniepunktIssue.jsx index 4823eb1..84804e8 100644 --- a/privat/CV/andreknie.de/src/pages/KniepunktIssue.jsx +++ b/privat/CV/andreknie.de/src/pages/KniepunktIssue.jsx @@ -22,11 +22,14 @@ export default function KniepunktIssue() { } if (!issue.body) { + const loadFailed = issue.detailError return (
-

Kniepunkt-Ausgabe wird geladen …

+

+ {loadFailed ? 'Die Kniepunkt-Ausgabe konnte nicht geladen werden.' : 'Kniepunkt-Ausgabe wird geladen …'} +

) diff --git a/privat/CV/andreknie.de/src/pages/Resources.jsx b/privat/CV/andreknie.de/src/pages/Resources.jsx index 21390e6..25a2eeb 100644 --- a/privat/CV/andreknie.de/src/pages/Resources.jsx +++ b/privat/CV/andreknie.de/src/pages/Resources.jsx @@ -31,7 +31,7 @@ function ResourceRequestForm({ resource, onCancel }) { return (
{success ? ( -

+

Danke! Wir prüfen die Anfrage und melden uns bei dir.

) : ( @@ -41,7 +41,7 @@ function ResourceRequestForm({ resource, onCancel }) { Wir prüfen die Anfrage. Bei positiver Prüfung verschickt André ein aktuelles Profil persönlich.

-