- 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
223 lines
6.9 KiB
TypeScript
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>
|
|
)
|
|
}
|