feat(andreknie.de): loop testimonial slider
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user