feat(andreknie.de): improve accessibility and initial loading #5
@@ -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 <<EOF
|
||||
$old_releases
|
||||
EOF
|
||||
fi
|
||||
# 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 -- {} +
|
||||
|
||||
@@ -7,8 +7,11 @@ 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 DETAILS_VIRTUAL_MODULE_ID = 'virtual:content-details'
|
||||
const RESOLVED_DETAILS_VIRTUAL_MODULE_ID = '\0' + DETAILS_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, includeBodies = false) {
|
||||
const dir = join(CONTENT_DIR, subdir)
|
||||
@@ -25,7 +28,7 @@ function readContentDir(subdir, includeBodies = false) {
|
||||
const raw = readFileSync(filepath, 'utf-8')
|
||||
const { data, content } = matter(raw)
|
||||
const body = renderMarkdownContent(content)
|
||||
const searchText = body.replace(/<[^>]+>/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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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(<BackgroundPattern />)
|
||||
expect(requestAnimationFrame).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels a scheduled background frame on unmount', () => {
|
||||
setReducedMotion(false)
|
||||
const { unmount } = render(<BackgroundPattern />)
|
||||
expect(requestAnimationFrame).toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledWith(17)
|
||||
})
|
||||
|
||||
it('does not schedule dot animation with reduced motion', async () => {
|
||||
setReducedMotion(true)
|
||||
render(<DotCloudIcon shape="shield" />)
|
||||
await act(async () => Promise.resolve())
|
||||
expect(requestAnimationFrame).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function LogoMarquee({ logos = [], title = '', titleAccent = '',
|
||||
{title} {titleAccent && <span className="text-gradient">{titleAccent}</span>}
|
||||
</h2>
|
||||
)}
|
||||
<div className="marquee-track">
|
||||
<div className="marquee-track" role="region" aria-label="Kunden- und Partnerlogos" tabIndex="0">
|
||||
<div className="marquee-inner">
|
||||
{allLogos.map((logo, i) => (
|
||||
<div className="marquee-item" key={i} aria-hidden={i >= logos.length}>
|
||||
|
||||
@@ -8,5 +8,6 @@ describe('LogoMarquee accessibility', () => {
|
||||
const { container } = render(<LogoMarquee logos={[{ name: 'Logo A' }, { name: 'Logo B' }]} />)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function Navigation() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="mobile-navigation" className={`nav-mobile ${mobileOpen ? 'open' : ''}`}>
|
||||
<div id="mobile-navigation" className={`nav-mobile ${mobileOpen ? 'open' : ''}`} hidden={!mobileOpen}>
|
||||
{navLinks.map(link => (
|
||||
<Link
|
||||
key={link.to}
|
||||
|
||||
@@ -8,14 +8,18 @@ describe('Navigation accessibility', () => {
|
||||
it('exposes menu state and closes it with Escape', () => {
|
||||
render(<MemoryRouter><Navigation /></MemoryRouter>)
|
||||
const toggle = screen.getByRole('button', { name: 'Menü öffnen' })
|
||||
const mobileNavigation = document.getElementById('mobile-navigation')
|
||||
|
||||
expect(mobileNavigation.hidden).toBe(true)
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(toggle.getAttribute('aria-controls')).toBe('mobile-navigation')
|
||||
expect(mobileNavigation.hidden).toBe(false)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(toggle.getAttribute('aria-label')).toBe('Menü öffnen')
|
||||
expect(mobileNavigation.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('marks detail navigation routes as current', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react'
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import './TestimonialSlider.css'
|
||||
|
||||
@@ -12,7 +12,9 @@ const MIDDLE_SET = Math.floor(LOOP_SETS / 2)
|
||||
export default function TestimonialSlider({ testimonials = [] }) {
|
||||
const scrollerRef = useRef(null)
|
||||
const dragStateRef = useRef({ isDragging: false, startX: 0, scrollLeft: 0 })
|
||||
const recenterTimerRef = useRef(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const canLoop = testimonials.length > 1
|
||||
const visibleTestimonials = canLoop
|
||||
? Array.from({ length: LOOP_SETS }, () => testimonials).flat()
|
||||
@@ -46,10 +48,12 @@ export default function TestimonialSlider({ testimonials = [] }) {
|
||||
const centerCard = (cardIndex, behavior = 'auto') => {
|
||||
const scroller = scrollerRef.current
|
||||
if (!scroller) return
|
||||
const normalizedIndex = ((cardIndex % testimonials.length) + testimonials.length) % testimonials.length
|
||||
setActiveIndex(normalizedIndex)
|
||||
|
||||
scroller.scrollTo({
|
||||
left: getCenteredScrollLeft(cardIndex),
|
||||
behavior,
|
||||
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : behavior,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,6 +71,8 @@ export default function TestimonialSlider({ testimonials = [] }) {
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [canLoop, testimonials.length])
|
||||
|
||||
useEffect(() => () => window.clearTimeout(recenterTimerRef.current), [])
|
||||
|
||||
const scrollByCard = (direction) => {
|
||||
const scroller = scrollerRef.current
|
||||
if (!scroller) return
|
||||
@@ -74,7 +80,8 @@ export default function TestimonialSlider({ testimonials = [] }) {
|
||||
const targetIndex = getClosestCardIndex() + direction
|
||||
centerCard(targetIndex, 'smooth')
|
||||
|
||||
window.setTimeout(recenterIfNeeded, 450)
|
||||
window.clearTimeout(recenterTimerRef.current)
|
||||
recenterTimerRef.current = window.setTimeout(recenterIfNeeded, 450)
|
||||
}
|
||||
|
||||
const recenterIfNeeded = () => {
|
||||
@@ -82,11 +89,12 @@ export default function TestimonialSlider({ testimonials = [] }) {
|
||||
if (!canLoop || !scroller) return
|
||||
|
||||
const currentIndex = getClosestCardIndex()
|
||||
const normalizedIndex = ((currentIndex % testimonials.length) + testimonials.length) % testimonials.length
|
||||
setActiveIndex(normalizedIndex)
|
||||
const lowBoundary = testimonials.length
|
||||
const highBoundary = testimonials.length * (LOOP_SETS - 1)
|
||||
if (currentIndex >= lowBoundary && currentIndex < highBoundary) return
|
||||
|
||||
const normalizedIndex = ((currentIndex % testimonials.length) + testimonials.length) % testimonials.length
|
||||
centerCard(testimonials.length * MIDDLE_SET + normalizedIndex)
|
||||
}
|
||||
|
||||
@@ -146,6 +154,9 @@ export default function TestimonialSlider({ testimonials = [] }) {
|
||||
<ChevronRight size={22} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
Testimonial {activeIndex + 1} von {testimonials.length}
|
||||
</p>
|
||||
<div
|
||||
className={`testimonial-scroll-container${isDragging ? ' dragging' : ''}`}
|
||||
ref={scrollerRef}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import TestimonialSlider from './TestimonialSlider'
|
||||
|
||||
describe('TestimonialSlider accessibility', () => {
|
||||
@@ -12,5 +12,6 @@ describe('TestimonialSlider accessibility', () => {
|
||||
]
|
||||
const { container } = render(<TestimonialSlider testimonials={testimonials} />)
|
||||
expect(container.querySelectorAll('.testimonial-card:not([aria-hidden="true"])')).toHaveLength(3)
|
||||
expect(screen.getByRole('status').textContent).toContain('Testimonial 1 von 3')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
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])
|
||||
|
||||
@@ -24,10 +24,13 @@ export default function ArticleSingle() {
|
||||
}
|
||||
|
||||
if (!post.body) {
|
||||
const loadFailed = post.detailError
|
||||
return (
|
||||
<main className="page-container app-container" style={{ textAlign: 'center', paddingTop: '150px' }}>
|
||||
<SEOHead title={post.title} description={post.excerpt || post.title} url={`/artikel/${post.slug}`} type="article" />
|
||||
<p role="status" aria-live="polite">Artikel wird geladen …</p>
|
||||
<p role={loadFailed ? 'alert' : 'status'} aria-live={loadFailed ? 'assertive' : 'polite'}>
|
||||
{loadFailed ? 'Der Artikel konnte nicht geladen werden.' : 'Artikel wird geladen …'}
|
||||
</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function Articles() {
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Artikel durchsuchen..."
|
||||
aria-label="Artikel durchsuchen"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function Kniepunkt() {
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Kniepunkte durchsuchen..."
|
||||
aria-label="Kniepunkte durchsuchen"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
|
||||
@@ -22,11 +22,14 @@ export default function KniepunktIssue() {
|
||||
}
|
||||
|
||||
if (!issue.body) {
|
||||
const loadFailed = issue.detailError
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<SEOHead title={issue.title} description={issue.summary || issue.title} url={`/kniepunkt/${issue.slug}`} type="article" image={issue.image} />
|
||||
<div className="app-container" style={{ textAlign: 'center' }}>
|
||||
<p role="status" aria-live="polite">Kniepunkt-Ausgabe wird geladen …</p>
|
||||
<p role={loadFailed ? 'alert' : 'status'} aria-live={loadFailed ? 'assertive' : 'polite'}>
|
||||
{loadFailed ? 'Die Kniepunkt-Ausgabe konnte nicht geladen werden.' : 'Kniepunkt-Ausgabe wird geladen …'}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ function ResourceRequestForm({ resource, onCancel }) {
|
||||
return (
|
||||
<div className="glass-panel" style={{ padding: '24px', marginTop: '24px' }}>
|
||||
{success ? (
|
||||
<p style={{ margin: 0, color: 'var(--text-primary)' }}>
|
||||
<p role="status" aria-live="polite" style={{ margin: 0, color: 'var(--text-primary)' }}>
|
||||
Danke! Wir prüfen die Anfrage und melden uns bei dir.
|
||||
</p>
|
||||
) : (
|
||||
@@ -41,7 +41,7 @@ function ResourceRequestForm({ resource, onCancel }) {
|
||||
Wir prüfen die Anfrage. Bei positiver Prüfung verschickt André ein aktuelles Profil persönlich.
|
||||
</p>
|
||||
|
||||
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}>
|
||||
<div hidden>
|
||||
<label htmlFor="resource-company-website">Firmen-Website (bitte leer lassen)</label>
|
||||
<input id="resource-company-website" name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={updateField} />
|
||||
</div>
|
||||
@@ -49,18 +49,18 @@ function ResourceRequestForm({ resource, onCancel }) {
|
||||
<input type="hidden" name="resource_id" value={resourceId} />
|
||||
|
||||
<label htmlFor="resource-name">Name</label>
|
||||
<input id="resource-name" name="name" value={form.name} onChange={updateField} style={inputStyle} autoComplete="name" aria-invalid={Boolean(errors.name)} />
|
||||
{errors.name && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.name}</p>}
|
||||
<input id="resource-name" name="name" value={form.name} onChange={updateField} style={inputStyle} autoComplete="name" aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? 'resource-name-error' : undefined} />
|
||||
{errors.name && <p id="resource-name-error" role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.name}</p>}
|
||||
|
||||
<label htmlFor="resource-email">E-Mail</label>
|
||||
<input id="resource-email" name="email" type="email" value={form.email} onChange={updateField} style={inputStyle} autoComplete="email" aria-invalid={Boolean(errors.email)} />
|
||||
{errors.email && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.email}</p>}
|
||||
<input id="resource-email" name="email" type="email" value={form.email} onChange={updateField} style={inputStyle} autoComplete="email" aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? 'resource-email-error' : undefined} />
|
||||
{errors.email && <p id="resource-email-error" role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.email}</p>}
|
||||
|
||||
<label htmlFor="resource-company">Unternehmen</label>
|
||||
<input id="resource-company" name="company" value={form.company} onChange={updateField} style={inputStyle} autoComplete="organization" aria-invalid={Boolean(errors.company)} />
|
||||
{errors.company && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.company}</p>}
|
||||
<input id="resource-company" name="company" value={form.company} onChange={updateField} style={inputStyle} autoComplete="organization" aria-invalid={Boolean(errors.company)} aria-describedby={errors.company ? 'resource-company-error' : undefined} />
|
||||
{errors.company && <p id="resource-company-error" role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.company}</p>}
|
||||
|
||||
{serverError && <p role="alert" style={{ color: '#ff4444' }}>{serverError}</p>}
|
||||
{serverError && <p role="alert" aria-live="assertive" style={{ color: '#ff4444' }}>{serverError}</p>}
|
||||
{errors.resource_id && <p role="alert" style={{ color: '#ff4444' }}>{errors.resource_id}</p>}
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '20px', flexWrap: 'wrap' }}>
|
||||
|
||||
Reference in New Issue
Block a user