open-productive-stack/drupal/nextjs/components/home-clients.tsx
rnsrk f8b8f53d54 Add Drupal headless stack with Next.js frontend
- Add Next.js frontend service (nextjs) with Dockerfile and source
- Update docker-compose.yml: image names, Drupal 11.3.3, nextjs service
- Add docker-compose.override.yml.disabled for dev hot-reload
- Add install-headless-modules.sh for OAuth/JSON:API module setup
- Add README.md with full setup and configuration guide
- Update nginx/Dockerfile and nginx.conf.template for cms. subdomain
- Update drupal/Dockerfile PHP-FPM build args
- Gitignore **/.vscode/ to prevent IDE workspace files from being tracked
2026-03-30 11:14:17 +02:00

223 lines
6.9 KiB
TypeScript

"use client"
import Image from "next/image"
import { Building2 } from "lucide-react"
import { useState, useRef, useEffect } from "react"
function ClientIcon({ icon }: { icon?: string }) {
const [hasError, setHasError] = useState(false)
if (!icon || hasError) {
return <Building2 className="size-4 shrink-0 text-emerald-600" aria-hidden />
}
return (
<Image
src={icon}
alt=""
width={16}
height={16}
className="size-4 shrink-0 rounded-sm object-contain"
unoptimized
onError={() => setHasError(true)}
/>
)
}
const clients = [
{
name: "Max Planck Institute for Social Anthropology",
location: "Halle/Saale",
href: "https://www.eth.mpg.de/",
icon: "/assets/icons/eth-mpg.png",
},
{
name: "German National Academy of Sciences Leopoldina",
location: "Halle/Saale",
href: "https://www.leopoldina.org/",
icon: "/assets/icons/leopoldina.png",
},
{
name: "German National Museum",
location: "Nuremberg",
href: "https://www.gnm.de/",
icon: "/assets/icons/gnm.png",
},
{
name: "Central Institute for Art History",
location: "Munich",
href: "https://www.zikg.eu/",
icon: "/assets/icons/zikg.png",
},
{
name: "German Fairy Tale and Weser Legends Museum",
location: "Bad Oeynhausen",
href: "https://www.badoeynhausen.de/freizeit-kultur-sport/kultur/staedtische-museen/deutsches-maerchen-und-wesersagenmuseum",
icon: "/assets/icons/badoeynhausen.png",
},
{
name: "Roli-Bar",
location: "roli-bar.de",
href: "https://roli-bar.de/",
icon: "/assets/icons/roli-bar.png",
},
{
name: "Re-Cycle Halle",
location: "Halle/Saale",
href: "https://re-cycle-halle.de/",
icon: "/assets/icons/re-cycle-halle.png",
},
{
name: "bold + bündig",
location: "Leipzig",
href: "https://boldundbuendig.de/",
icon: "/assets/icons/boldundbuendig.png",
},
]
function ClientsBandContent() {
return (
<>
{clients.map((client) => {
const content = (
<>
<ClientIcon icon={client.icon} />
<span className="font-medium">{client.name}</span>
<span className="text-slate-500">({client.location})</span>
</>
)
const className =
"home-clients-band-item mx-4 flex shrink-0 items-center gap-2 rounded-xl border border-slate-200 bg-white px-6 py-4 text-base text-slate-700 shadow-sm"
return client.href ? (
<a
key={client.name}
href={client.href}
target="_blank"
rel="noopener noreferrer"
className={`${className} outline-none transition-colors hover:border-emerald-200 hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2`}
>
{content}
</a>
) : (
<span key={client.name} className={className}>
{content}
</span>
)
})}
</>
)
}
const SCROLL_SPEED = 40
export function HomeClients() {
const bandRef = useRef<HTMLDivElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
const posRef = useRef(0)
const isHoveredRef = useRef(false)
const isDraggingRef = useRef(false)
const dragStartXRef = useRef(0)
const dragStartPosRef = useRef(0)
const lastTimeRef = useRef<number | null>(null)
const rafRef = useRef<number | null>(null)
const [isDragging, setIsDragging] = useState(false)
useEffect(() => {
const track = trackRef.current
if (!track) return
const step = (time: number) => {
const halfWidth = track.scrollWidth / 2
if (halfWidth > 0 && lastTimeRef.current !== null && !isHoveredRef.current && !isDraggingRef.current) {
const dt = time - lastTimeRef.current
posRef.current += SCROLL_SPEED * dt / 1000
if (posRef.current >= halfWidth) posRef.current -= halfWidth
}
lastTimeRef.current = time
track.style.transform = `translateX(${-posRef.current}px)`
rafRef.current = requestAnimationFrame(step)
}
rafRef.current = requestAnimationFrame(step)
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
}, [])
const handleMouseEnter = () => { isHoveredRef.current = true }
const handleMouseLeave = () => {
isHoveredRef.current = false
isDraggingRef.current = false
setIsDragging(false)
}
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
isDraggingRef.current = true
dragStartXRef.current = e.clientX
dragStartPosRef.current = posRef.current
setIsDragging(true)
}
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDraggingRef.current) return
const track = trackRef.current
if (!track) return
const halfWidth = track.scrollWidth / 2
const dx = dragStartXRef.current - e.clientX
posRef.current = ((dragStartPosRef.current + dx) % halfWidth + halfWidth) % halfWidth
}
const handleMouseUp = () => {
isDraggingRef.current = false
setIsDragging(false)
}
const handleTouchStart = (e: React.TouchEvent) => {
isDraggingRef.current = true
dragStartXRef.current = e.touches[0].clientX
dragStartPosRef.current = posRef.current
}
const handleTouchMove = (e: React.TouchEvent) => {
if (!isDraggingRef.current) return
const track = trackRef.current
if (!track) return
const halfWidth = track.scrollWidth / 2
const dx = dragStartXRef.current - e.touches[0].clientX
posRef.current = ((dragStartPosRef.current + dx) % halfWidth + halfWidth) % halfWidth
}
const handleTouchEnd = () => { isDraggingRef.current = false }
return (
<section className="home-clients py-10" aria-labelledby="clients-heading">
<div className="home-clients-header mb-6 text-center">
<h2
id="clients-heading"
className="home-clients-title mb-3 font-bold tracking-tight text-slate-900"
style={{ fontSize: "var(--fluid-section-title)" }}
>
Employers & Customers
</h2>
<p
className="home-clients-description mx-auto max-w-3xl text-slate-600"
style={{ fontSize: "var(--fluid-hero-desc)" }}
>
Research institutions and organisations I have worked with.
</p>
</div>
<div
ref={bandRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
className="home-clients-band relative w-full overflow-hidden border-y border-slate-200 bg-slate-50 py-10 select-none"
style={{ cursor: isDragging ? "grabbing" : "grab" }}
>
<div ref={trackRef} className="home-clients-band-track flex w-max">
<ClientsBandContent />
<ClientsBandContent />
</div>
</div>
</section>
)
}