feat(andreknie.de): loop testimonial slider

This commit is contained in:
2026-07-27 19:31:48 +02:00
parent b8c6079e3a
commit cfbd77a1a4
3 changed files with 145 additions and 7 deletions
@@ -1,6 +1,6 @@
- quote: "André hat unsere strategische Ausrichtung entscheidend vorangebracht, die Workshops konzipiert und herausragend moderiert. Die Menschen wurden abgeholt und begeistert. Wir setzen auch in der weiteren KI-Reise auf seine Expertise und Kommunikationsstärke."
name: "Kunde - Organisationsentwicklung"
role: "Öffentliche Einrichtung"
- quote: "André verbindet etwas, das ich in dieser Form nur selten erlebe: tiefes technisches Verständnis für KI mit einem echten Gespür für Menschen und Organisationen. Er kennt die Möglichkeiten moderner KI, versteht aber gleichzeitig die Besonderheiten von Hochschulen. Dadurch gelingt es ihm, komplexe Technologien verständlich zu machen und Organisationen zu befähigen, eigene tragfähige Lösungen zu entwickeln. Dabei geht er nicht den einfachsten Weg, sondern sucht gemeinsam nach der bestmöglichen Lösung. Besonders schätze ich die Zusammenarbeit im Tandem: Die Kombination aus unserer internen Perspektive und seiner externen Expertise hat unsere KI-Reise entscheidend vorangebracht."
name: "Maike Mach"
role: "Stellvertretende Leitung Organisationsentwicklung, Universität Kassel"
- quote: "André bringt uns voran, begeistert Menschen, zeigt auf was sinnvoll ist und was nicht. Durch ihn sind wir Größenordnungen schneller und sicherer. Sein Team und er befähigen uns, Dinge umzusetzen, von denen wir vorher nicht geträumt hätten."
name: "Kunde - Projektleitung Digitalisierung"
@@ -2,6 +2,33 @@
padding: var(--section-padding) 20px;
}
.testimonial-slider-controls {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-bottom: 18px;
}
.testimonial-nav-button {
width: 42px;
height: 42px;
border: 1px solid var(--glass-border);
border-radius: 50%;
background: var(--bg-elevated);
color: var(--text-primary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
}
.testimonial-nav-button:hover {
border-color: var(--accent-color);
color: var(--accent-color);
transform: translateY(-1px);
}
.testimonial-scroll-container {
display: flex;
overflow-x: auto;
@@ -9,11 +36,20 @@
scroll-behavior: smooth;
gap: 24px;
padding-bottom: 32px; /* Space for scrollbar and shadow */
cursor: grab;
user-select: none;
/* Hide scrollbar for cleaner look, or keep it subtle */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.testimonial-scroll-container.dragging {
cursor: grabbing;
scroll-snap-type: none;
scroll-behavior: auto;
}
.testimonial-scroll-container::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
@@ -1,4 +1,5 @@
import React from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import './TestimonialSlider.css'
/**
@@ -6,13 +7,114 @@ import './TestimonialSlider.css'
* Equal heights, horizontal scroll, no autoplay.
*/
export default function TestimonialSlider({ testimonials = [] }) {
const scrollerRef = useRef(null)
const dragStateRef = useRef({ isDragging: false, startX: 0, scrollLeft: 0 })
const [isDragging, setIsDragging] = useState(false)
const canLoop = testimonials.length > 1
const visibleTestimonials = canLoop ? [...testimonials, ...testimonials, ...testimonials] : testimonials
const getCardStep = () => {
const scroller = scrollerRef.current
const firstCard = scroller?.querySelector('.testimonial-card')
if (!scroller || !firstCard) return 0
const gap = parseFloat(window.getComputedStyle(scroller).columnGap || '0')
return firstCard.offsetWidth + gap
}
useEffect(() => {
const scroller = scrollerRef.current
if (!canLoop || !scroller) return
const cardStep = getCardStep()
if (!cardStep) return
scroller.scrollLeft = cardStep * testimonials.length
}, [canLoop, testimonials.length])
const scrollByCard = (direction) => {
const scroller = scrollerRef.current
const cardStep = getCardStep()
if (!scroller || !cardStep) return
scroller.scrollBy({
left: direction * cardStep,
behavior: 'smooth',
})
}
const keepLoopCentered = () => {
const scroller = scrollerRef.current
const cardStep = getCardStep()
if (!canLoop || !scroller || !cardStep) return
const segmentWidth = cardStep * testimonials.length
if (scroller.scrollLeft < segmentWidth * 0.5) {
scroller.scrollLeft += segmentWidth
} else if (scroller.scrollLeft > segmentWidth * 1.5) {
scroller.scrollLeft -= segmentWidth
}
}
const handlePointerDown = (event) => {
if (event.pointerType === 'touch') return
const scroller = scrollerRef.current
if (!scroller) return
dragStateRef.current = {
isDragging: true,
startX: event.clientX,
scrollLeft: scroller.scrollLeft,
}
setIsDragging(true)
scroller.setPointerCapture(event.pointerId)
}
const handlePointerMove = (event) => {
const scroller = scrollerRef.current
const dragState = dragStateRef.current
if (!scroller || !dragState.isDragging) return
scroller.scrollLeft = dragState.scrollLeft - (event.clientX - dragState.startX)
}
const stopDragging = (event) => {
const scroller = scrollerRef.current
dragStateRef.current.isDragging = false
setIsDragging(false)
keepLoopCentered()
if (scroller?.hasPointerCapture?.(event.pointerId)) {
scroller.releasePointerCapture(event.pointerId)
}
}
if (testimonials.length === 0) return null
return (
<section className="testimonials-section app-container">
<div className="testimonial-scroll-container">
{testimonials.map((current, i) => (
<div className="testimonial-card glass-panel" key={i}>
<div className="testimonial-slider-controls" aria-label="Testimonials wechseln">
<button type="button" className="testimonial-nav-button" onClick={() => scrollByCard(-1)} aria-label="Vorheriges Testimonial">
<ChevronLeft size={22} aria-hidden="true" />
</button>
<button type="button" className="testimonial-nav-button" onClick={() => scrollByCard(1)} aria-label="Nächstes Testimonial">
<ChevronRight size={22} aria-hidden="true" />
</button>
</div>
<div
className={`testimonial-scroll-container${isDragging ? ' dragging' : ''}`}
ref={scrollerRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={stopDragging}
onPointerCancel={stopDragging}
onScroll={keepLoopCentered}
onPointerLeave={(event) => {
if (dragStateRef.current.isDragging) stopDragging(event)
}}
>
{visibleTestimonials.map((current, i) => (
<div className="testimonial-card glass-panel" key={`${current.name}-${i}`}>
<div className="testimonial-quote">
<span className="quote-mark">"</span>
<p>{current.quote}</p>