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
This commit is contained in:
parent
71a8dac389
commit
f8b8f53d54
85 changed files with 7802 additions and 17 deletions
223
drupal/nextjs/components/home-clients.tsx
Normal file
223
drupal/nextjs/components/home-clients.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue