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
78
drupal/nextjs/components/animated-hero-title.tsx
Normal file
78
drupal/nextjs/components/animated-hero-title.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"use client"
|
||||
|
||||
const TEXT = "I'm Robert."
|
||||
|
||||
type Direction = "up" | "down" | "r" | "e" | "t"
|
||||
|
||||
const LETTER_ANIMS: Record<number, Direction> = {
|
||||
0: "down", // I
|
||||
1: "up", // '
|
||||
2: "down", // m
|
||||
3: "down", // space - treat as down for delay
|
||||
4: "r", // R (special)
|
||||
5: "down", // o
|
||||
6: "up", // b
|
||||
7: "e", // e (special: o first, then e)
|
||||
8: "down", // r
|
||||
9: "t", // t (special: appears with o, then slides right)
|
||||
10: "down", // .
|
||||
}
|
||||
|
||||
const DELAYS_MS = [0, 80, 240, 320, 400, 560, 680, 760, 1400, 760, 1560]
|
||||
|
||||
export function AnimatedHeroTitle() {
|
||||
return (
|
||||
<h1
|
||||
className="home-hero-title mb-2 font-bold tracking-tight text-slate-900"
|
||||
style={{ fontSize: "var(--fluid-hero)" }}
|
||||
>
|
||||
{TEXT.split("").map((char, i) => {
|
||||
if (char === " ") {
|
||||
return <span key={i} className="inline-block" style={{ width: "0.25em" }} aria-hidden />
|
||||
}
|
||||
const dir = LETTER_ANIMS[i] ?? "down"
|
||||
const delay = DELAYS_MS[i] ?? i * 80
|
||||
|
||||
const animClass =
|
||||
dir === "r"
|
||||
? "hero-letter-r animate-letter-from-up"
|
||||
: dir === "e"
|
||||
? "hero-letter-e"
|
||||
: dir === "t"
|
||||
? "hero-letter-t animate-letter-t-slide"
|
||||
: dir === "up"
|
||||
? "animate-letter-from-up"
|
||||
: "animate-letter-from-down"
|
||||
const animDelay =
|
||||
dir === "r" ? delay + 320 : dir === "e" ? delay + 1050 : delay
|
||||
const style =
|
||||
dir === "r"
|
||||
? { "--b-delay": `${delay}ms`, animationDelay: `${animDelay}ms` } as React.CSSProperties
|
||||
: dir === "e"
|
||||
? { "--o-delay": `${delay}ms` } as React.CSSProperties
|
||||
: { animationDelay: `${animDelay}ms` }
|
||||
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`inline-block overflow-hidden ${animClass}`}
|
||||
style={style}
|
||||
aria-hidden
|
||||
>
|
||||
{dir === "e" ? (
|
||||
<span className="block animate-letter-e-fade-in" style={{ animationDelay: `${animDelay}ms` }}>
|
||||
e
|
||||
</span>
|
||||
) : dir === "r" ? (
|
||||
"R"
|
||||
) : dir === "t" ? (
|
||||
"t"
|
||||
) : (
|
||||
char
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
58
drupal/nextjs/components/avatar-image.tsx
Normal file
58
drupal/nextjs/components/avatar-image.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import { useCallback, useState } from "react"
|
||||
|
||||
const IMAGE_POOL = [
|
||||
"/assets/images/autumn.png",
|
||||
"/assets/images/kuss.png",
|
||||
"/assets/images/chaos.png",
|
||||
"/assets/images/conference.png",
|
||||
"/assets/images/explaining.png",
|
||||
"/assets/images/family.png",
|
||||
"/assets/images/pres_1.png",
|
||||
"/assets/images/robot.png",
|
||||
] as const
|
||||
|
||||
function pickRandom(exclude?: string): string {
|
||||
const available = exclude
|
||||
? IMAGE_POOL.filter((p) => p !== exclude)
|
||||
: [...IMAGE_POOL]
|
||||
return available[Math.floor(Math.random() * available.length)]
|
||||
}
|
||||
|
||||
export function AvatarImage({ alt }: { alt: string }) {
|
||||
const [currentSrc, setCurrentSrc] = useState(() => pickRandom())
|
||||
const [isSpinning, setIsSpinning] = useState(false)
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setIsSpinning(true)
|
||||
setCurrentSrc((prev) => pickRandom(prev))
|
||||
}, [])
|
||||
|
||||
const handleAnimationEnd = useCallback(() => {
|
||||
setIsSpinning(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex size-20 shrink-0 sm:size-24 [perspective:500px]"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
>
|
||||
<div
|
||||
className={`relative size-full overflow-hidden rounded-full bg-emerald-100 [transform-style:preserve-3d] ${isSpinning ? "animate-coin-spin" : ""}`}
|
||||
>
|
||||
<Image
|
||||
key={currentSrc}
|
||||
src={currentSrc}
|
||||
alt={alt}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="192px"
|
||||
quality={95}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
drupal/nextjs/components/cookie-banner.tsx
Normal file
62
drupal/nextjs/components/cookie-banner.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
|
||||
const CONSENT_COOKIE = "cookie-consent"
|
||||
const CONSENT_MAX_AGE = 365 * 24 * 60 * 60 // 1 year in seconds
|
||||
|
||||
function setConsentCookie() {
|
||||
document.cookie = `${CONSENT_COOKIE}=accepted; path=/; max-age=${CONSENT_MAX_AGE}; SameSite=Lax`
|
||||
}
|
||||
|
||||
function hasConsent(): boolean {
|
||||
if (typeof document === "undefined") return false
|
||||
return document.cookie.includes(`${CONSENT_COOKIE}=accepted`)
|
||||
}
|
||||
|
||||
export function CookieBanner() {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasConsent()) {
|
||||
setIsVisible(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAccept = () => {
|
||||
setConsentCookie()
|
||||
setIsVisible(false)
|
||||
}
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Cookie notice"
|
||||
className="fixed bottom-0 left-0 right-0 z-50 border-t border-slate-200 bg-white/95 p-4 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] backdrop-blur-sm sm:p-6"
|
||||
>
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-slate-600">
|
||||
This site uses a single cookie to store your consent preference. No
|
||||
tracking or analytics cookies are used.{" "}
|
||||
<Link
|
||||
href="/imprint"
|
||||
className="font-medium text-emerald-600 underline-offset-2 hover:text-emerald-500 hover:underline"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAccept}
|
||||
className="shrink-0 rounded-lg px-5 py-2.5 text-sm font-medium text-white outline-none transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
style={{ backgroundColor: "var(--accent-hex)" }}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
drupal/nextjs/components/debug-trigger.tsx
Normal file
16
drupal/nextjs/components/debug-trigger.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
|
||||
/**
|
||||
* Triggers the browser debugger when the component mounts.
|
||||
* Only runs in development. Remove when done debugging.
|
||||
*/
|
||||
export function DebugTrigger() {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
debugger
|
||||
}
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
127
drupal/nextjs/components/home-about.tsx
Normal file
127
drupal/nextjs/components/home-about.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { drupal } from "@/lib/drupal"
|
||||
import type { DrupalAboutNode } from "@/lib/types"
|
||||
import { AvatarImage } from "./avatar-image"
|
||||
import { MailToLink } from "./mail-to-link"
|
||||
|
||||
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
|
||||
|
||||
const FALLBACK_TITLE = "Robert Nasarek"
|
||||
function FallbackBody() {
|
||||
return (
|
||||
<>
|
||||
<p className="mb-2 text-emerald-600 font-medium">
|
||||
<span style={{ color: "#009966" }}>Data Engineer & Developer</span>
|
||||
</p>
|
||||
<p className="text-slate-600 leading-relaxed">
|
||||
I’m a freelance backend and data engineer specialising in data modelling, ETL pipelines, and data-centric application architecture. I design and implement scalable APIs and backend systems using Python and modern web frameworks like Next.js, Drupal, and Django to build robust data workflows for analytics and machine learning use cases.
|
||||
</p>
|
||||
<p className="text-slate-600 pt-4">
|
||||
My focus is on semantic and structured data systems that turn heterogeneous sources into reliable, queryable, and reusable knowledge. I deliver production-ready solutions, including containerised deployments and reproducible data pipelines, with an emphasis on correctness, performance, and maintainability.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
async function getAboutPageContent(): Promise<{
|
||||
title: string
|
||||
body: string | null
|
||||
email: string | null
|
||||
}> {
|
||||
if (!drupalBaseUrl) {
|
||||
return {
|
||||
title: FALLBACK_TITLE,
|
||||
body: null,
|
||||
email: null,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const translatedPath = await drupal.translatePath("/about/robert-nasarek", {
|
||||
withAuth: true,
|
||||
next: { revalidate: 60 },
|
||||
})
|
||||
if (
|
||||
!translatedPath?.jsonapi?.resourceName ||
|
||||
!translatedPath?.entity?.uuid ||
|
||||
translatedPath.jsonapi.resourceName !== "node--about"
|
||||
) {
|
||||
return { title: FALLBACK_TITLE, body: null, email: null }
|
||||
}
|
||||
|
||||
const node = await drupal.getResource<DrupalAboutNode>(
|
||||
"node--about",
|
||||
translatedPath.entity.uuid,
|
||||
{ withAuth: true, next: { revalidate: 60 } }
|
||||
)
|
||||
|
||||
if (!node) {
|
||||
return { title: FALLBACK_TITLE, body: null, email: null }
|
||||
}
|
||||
|
||||
const bodyObj = node.body
|
||||
const bodyText =
|
||||
typeof bodyObj === "string"
|
||||
? bodyObj
|
||||
: bodyObj?.processed ?? bodyObj?.value ?? ""
|
||||
|
||||
return {
|
||||
title: node.title ?? FALLBACK_TITLE,
|
||||
body: bodyText || null,
|
||||
email: node.field_email ?? null,
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
console.warn("[HomeAbout] CMS unreachable:", (error as Error).message)
|
||||
}
|
||||
return { title: FALLBACK_TITLE, body: null, email: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function HomeAbout() {
|
||||
const { title, body, email } = await getAboutPageContent()
|
||||
return (
|
||||
<section className="home-about animate-fade-in-on-load pb-10" aria-labelledby="about-heading">
|
||||
<div className="home-about-header mb-8 text-center">
|
||||
<h2
|
||||
id="about-heading"
|
||||
className="home-about-title mb-3 font-bold tracking-tight text-slate-900"
|
||||
style={{ fontSize: "var(--fluid-section-title)" }}
|
||||
>
|
||||
About me
|
||||
</h2>
|
||||
<p
|
||||
className="home-about-description mx-auto max-w-3xl text-slate-600"
|
||||
style={{ fontSize: "var(--fluid-hero-desc)" }}
|
||||
>
|
||||
Data engineer and developer with a focus on linked open data, ontology engineering, and full-stack development.
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-about-content mx-auto max-w-4xl">
|
||||
<div className="home-about-bio flex flex-col gap-6 rounded-xl border border-slate-200 bg-slate-50 p-6 sm:flex-row sm:items-start sm:gap-8 sm:p-8">
|
||||
<AvatarImage alt={title} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="mb-2 text-xl font-semibold text-slate-900">
|
||||
{title}
|
||||
</h3>
|
||||
<div
|
||||
className="pemerald pemerald-slate text-slate-600 pemerald-a:text-emerald-600 pemerald-a:underline hover:pemerald-a:text-emerald-500 [&>p]:leading-relaxed [&>p:first-child]:mb-2 [&>p:first-child]:text-emerald-600 [&>p:first-child]:font-medium [&>p:not(:first-child)]:pt-4"
|
||||
>
|
||||
<p className="mb-2 text-emerald-600 font-medium">
|
||||
<span style={{ color: "#009966" }}>Data Engineer & Developer</span>
|
||||
</p>
|
||||
<p className="text-slate-600 leading-relaxed">
|
||||
Hi I’m a freelance backend and data engineer specialising in data modelling, ETL pipelines, and data-centric application architecture. I design and implement scalable APIs and backend systems using Python and modern web frameworks like Next.js, Drupal, and Django to build robust data workflows for analytics and machine learning use cases.
|
||||
</p>
|
||||
<p className="text-slate-600 pt-4">
|
||||
My focus is on semantic and structured data systems that turn heterogeneous sources into reliable, queryable, and reusable knowledge. I deliver production-ready solutions, including containerised deployments and reproducible data pipelines, with an emphasis on correctness, performance, and maintainability.
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-4">
|
||||
<MailToLink email={email} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
24
drupal/nextjs/components/home-cta.tsx
Normal file
24
drupal/nextjs/components/home-cta.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import Link from "next/link"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
export function HomeCta() {
|
||||
return (
|
||||
<section className="home-cta py-10">
|
||||
<div className="home-cta-card animate-fade-in-up rounded-2xl border border-slate-200 bg-linear-to-br from-slate-50 to-emerald-50/30 p-8 text-center sm:p-10">
|
||||
<h2 className="home-cta-title mb-3 font-bold tracking-tight text-slate-900" style={{ fontSize: "var(--fluid-section-title)" }}>
|
||||
Hi There!
|
||||
</h2>
|
||||
<p className="home-cta-description mx-auto mb-6 max-w-xl text-slate-600">
|
||||
Browse articles and data models for your next project.
|
||||
</p>
|
||||
<Link
|
||||
href="/resources"
|
||||
className="home-cta-link inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-6 py-3 font-medium text-white outline-none transition-all duration-200 ease-out hover:bg-emerald-500 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
View all resources
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
63
drupal/nextjs/components/home-features.tsx
Normal file
63
drupal/nextjs/components/home-features.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import Link from "next/link"
|
||||
import { FileText, Database, ArrowRight } from "lucide-react"
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: "Articles",
|
||||
description:
|
||||
"Tutorials, guides, and insights on data engineering and software development.",
|
||||
href: "/resources/articles",
|
||||
icon: FileText,
|
||||
color: "emerald",
|
||||
},
|
||||
{
|
||||
title: "Datamodelling",
|
||||
description:
|
||||
"Structured data models and schemas from real-world projects.",
|
||||
href: "/resources/datamodelling",
|
||||
icon: Database,
|
||||
color: "fuchsia",
|
||||
},
|
||||
]
|
||||
|
||||
export function HomeFeatures() {
|
||||
return (
|
||||
<section className="home-features py-10">
|
||||
<div className="home-features-header mb-6 text-center">
|
||||
<h2 className="home-features-title mb-3 text-3xl font-bold tracking-tight text-slate-900">
|
||||
Resources
|
||||
</h2>
|
||||
<p className="home-features-description mx-auto max-w-3xl text-slate-600" style={{ fontSize: "var(--fluid-hero-desc)" }}>
|
||||
Use one or all. Curated content for data engineers and developers.
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-features-grid grid gap-8 sm:grid-cols-2">
|
||||
{features.map((feature, index) => (
|
||||
<Link
|
||||
key={feature.href}
|
||||
href={feature.href}
|
||||
className={`home-features-card group block rounded-xl border border-slate-200 bg-white p-8 outline-none transition-all duration-200 ease-out hover:border-emerald-500/40 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2 animate-fade-in-up ${index === 1 ? "animate-delay-100" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`home-features-card-icon mb-4 inline-flex rounded-lg p-3 ${
|
||||
feature.color === "emerald"
|
||||
? "bg-emerald-100 text-emerald-600"
|
||||
: "bg-fuchsia-100 text-fuchsia-600"
|
||||
}`}
|
||||
>
|
||||
<feature.icon className="size-6" aria-hidden />
|
||||
</div>
|
||||
<h3 className="home-features-card-title mb-2 text-xl font-semibold text-slate-900">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="home-features-card-description mb-4 text-slate-600">{feature.description}</p>
|
||||
<span className="home-features-card-link inline-flex items-center gap-2 text-sm font-medium text-emerald-600 transition-colors duration-200 ease-out group-hover:text-emerald-500">
|
||||
Learn more
|
||||
<ArrowRight className="size-4 transition-transform duration-200 ease-out group-hover:translate-x-1" aria-hidden />
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
11
drupal/nextjs/components/home-hero.tsx
Normal file
11
drupal/nextjs/components/home-hero.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { AnimatedHeroTitle } from "./animated-hero-title"
|
||||
|
||||
export function HomeHero() {
|
||||
return (
|
||||
<section className="home-hero relative pt-6">
|
||||
<div className="home-hero-content mx-auto max-w-4xl text-center">
|
||||
<AnimatedHeroTitle />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
196
drupal/nextjs/components/home-journey-background.tsx
Normal file
196
drupal/nextjs/components/home-journey-background.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"use client"
|
||||
|
||||
import React, { useEffect, useState } from "react"
|
||||
import {
|
||||
Lightbulb,
|
||||
PenTool,
|
||||
Code2,
|
||||
Rocket,
|
||||
Wrench,
|
||||
MessageSquare,
|
||||
LifeBuoy,
|
||||
} from "lucide-react"
|
||||
|
||||
const STATION_COLORS: Record<
|
||||
string,
|
||||
{ border: string; bg: string; text: string; label: string }
|
||||
> = {
|
||||
idea: { border: "border-amber-400", bg: "bg-amber-50", text: "text-amber-600", label: "text-amber-700" },
|
||||
concept: { border: "border-emerald-400", bg: "bg-emerald-50", text: "text-emerald-600", label: "text-emerald-700" },
|
||||
consulting: { border: "border-sky-400", bg: "bg-sky-50", text: "text-sky-600", label: "text-sky-700" },
|
||||
development: { border: "border-fuchsia-400", bg: "bg-fuchsia-100", text: "text-fuchsia-600", label: "text-fuchsia-700" },
|
||||
deployment: { border: "border-sky-400", bg: "bg-sky-50", text: "text-sky-600", label: "text-sky-700" },
|
||||
maintenance: { border: "border-slate-400", bg: "bg-slate-100", text: "text-slate-600", label: "text-slate-700" },
|
||||
support: { border: "border-rose-400", bg: "bg-rose-50", text: "text-rose-600", label: "text-rose-700" },
|
||||
}
|
||||
|
||||
const STATIONS = [
|
||||
{ id: "idea", icon: Lightbulb, label: "Idea", lineThreshold: 0 },
|
||||
{ id: "concept", icon: PenTool, label: "Concept", lineThreshold: 15 },
|
||||
{ id: "deployment", icon: Rocket, label: "Deployment", lineThreshold: 45 },
|
||||
{ id: "maintenance", icon: Wrench, label: "Maintenance", lineThreshold: 80 },
|
||||
] as const
|
||||
|
||||
const RIGHT_STATIONS = [
|
||||
{ id: "consulting", icon: MessageSquare, label: "Consulting", lineThreshold: 0 },
|
||||
{ id: "development", icon: Code2, label: "Development", lineThreshold: 25 },
|
||||
{ id: "support", icon: LifeBuoy, label: "Support", lineThreshold: 60 },
|
||||
] as const
|
||||
|
||||
export function HomeJourneyBackground({ children }: { children: React.ReactNode }) {
|
||||
const childArray = React.Children.toArray(children)
|
||||
const heroContent = childArray[0]
|
||||
const mainContent = childArray.slice(1)
|
||||
const [visibleStations, setVisibleStations] = useState<Set<string>>(new Set())
|
||||
const [visibleRightStations, setVisibleRightStations] = useState<Set<string>>(
|
||||
new Set()
|
||||
)
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const y = window.scrollY
|
||||
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight
|
||||
const scrollProgress = docHeight > 0 ? Math.min(y / docHeight, 1) : 0
|
||||
const ideaThreshold = 0.02
|
||||
const linePercent =
|
||||
scrollProgress >= ideaThreshold
|
||||
? Math.min(
|
||||
100,
|
||||
((scrollProgress - ideaThreshold) / (1 - ideaThreshold)) * 100
|
||||
)
|
||||
: 0
|
||||
|
||||
const newVisible = new Set<string>()
|
||||
if (scrollProgress >= ideaThreshold) {
|
||||
newVisible.add("idea")
|
||||
}
|
||||
STATIONS.forEach((station) => {
|
||||
if (station.id !== "idea" && linePercent >= station.lineThreshold) {
|
||||
newVisible.add(station.id)
|
||||
}
|
||||
})
|
||||
setVisibleStations(newVisible)
|
||||
|
||||
const newVisibleRight = new Set<string>()
|
||||
if (scrollProgress >= ideaThreshold) {
|
||||
newVisibleRight.add("consulting")
|
||||
}
|
||||
RIGHT_STATIONS.forEach((station) => {
|
||||
if (
|
||||
station.id !== "consulting" &&
|
||||
linePercent >= station.lineThreshold
|
||||
) {
|
||||
newVisibleRight.add(station.id)
|
||||
}
|
||||
})
|
||||
setVisibleRightStations(newVisibleRight)
|
||||
}
|
||||
|
||||
handleScroll()
|
||||
window.addEventListener("scroll", handleScroll, { passive: true })
|
||||
return () => window.removeEventListener("scroll", handleScroll)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="journey-layout -mt-6 flex w-full flex-col px-4 lg:px-6">
|
||||
<div className="journey-hero w-full">{heroContent}</div>
|
||||
<div className="journey-main-row flex w-full items-stretch gap-4 lg:gap-6">
|
||||
<aside
|
||||
className="journey-sidebar relative sticky top-24 hidden min-h-full shrink-0 flex-[1] pb-8 pr-4 pt-0 lg:block"
|
||||
aria-label="Journey timeline"
|
||||
>
|
||||
<div className="journey-stations relative flex h-full flex-col">
|
||||
<div
|
||||
className="journey-sidebar-spacer journey-sidebar-spacer-top min-h-0 flex-[1]"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="journey-stations-list flex min-h-0 flex-[4] flex-col items-center justify-between">
|
||||
{STATIONS.map((station) => {
|
||||
const isVisible = visibleStations.has(station.id)
|
||||
const colors = STATION_COLORS[station.id]
|
||||
return (
|
||||
<div
|
||||
key={station.id}
|
||||
className={`journey-station journey-station-${station.id} relative z-10 flex flex-col items-center gap-2`}
|
||||
>
|
||||
<div
|
||||
className={`journey-station-icon flex size-20 shrink-0 items-center justify-center rounded-full border-2 shadow-sm transition-all duration-500 ${
|
||||
isVisible
|
||||
? `${colors.border} ${colors.bg} opacity-100 scale-100`
|
||||
: "border-slate-200 bg-white opacity-0 scale-90"
|
||||
}`}
|
||||
>
|
||||
<station.icon
|
||||
className={`size-11 transition-all duration-500 ${
|
||||
isVisible ? colors.text : "text-slate-400"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<h3
|
||||
className={`journey-station-label text-center text-m font-medium transition-all duration-500 ${
|
||||
isVisible ? `${colors.label} opacity-100` : "text-slate-500 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{station.label}
|
||||
</h3>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="journey-content main-contents-main min-w-0 flex-[8]">
|
||||
{mainContent}
|
||||
</div>
|
||||
<aside
|
||||
className="journey-sidebar journey-sidebar-right relative sticky top-24 hidden min-h-full shrink-0 flex-[1] pb-8 pl-4 pt-0 lg:block"
|
||||
aria-label="Consulting and support journey"
|
||||
>
|
||||
<div className="journey-stations relative flex h-full flex-col">
|
||||
<div
|
||||
className="journey-sidebar-spacer journey-sidebar-spacer-top min-h-0 flex-[1]"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="journey-stations-list flex min-h-0 flex-[4] flex-col items-center justify-around">
|
||||
{RIGHT_STATIONS.map((station) => {
|
||||
const isVisible = visibleRightStations.has(station.id)
|
||||
const colors = STATION_COLORS[station.id]
|
||||
return (
|
||||
<div
|
||||
key={station.id}
|
||||
className={`journey-station journey-station-${station.id} relative z-10 flex flex-col items-center gap-2`}
|
||||
>
|
||||
<div
|
||||
className={`journey-station-icon flex size-20 shrink-0 items-center justify-center rounded-full border-2 shadow-sm transition-all duration-500 ${
|
||||
isVisible
|
||||
? `${colors.border} ${colors.bg} opacity-100 scale-100`
|
||||
: "border-slate-200 bg-white opacity-0 scale-90"
|
||||
}`}
|
||||
>
|
||||
<station.icon
|
||||
className={`size-11 transition-all duration-500 ${
|
||||
isVisible ? colors.text : "text-slate-400"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<h3
|
||||
className={`journey-station-label text-center text-m font-medium transition-all duration-500 ${
|
||||
isVisible
|
||||
? `${colors.label} opacity-100`
|
||||
: "text-slate-500 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{station.label}
|
||||
</h3>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
drupal/nextjs/components/home-marquee.tsx
Normal file
36
drupal/nextjs/components/home-marquee.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client"
|
||||
|
||||
const testimonials = [
|
||||
"Clean, fast, and well documented.",
|
||||
"Best developer experience I've had in years.",
|
||||
"Went from zero to production in minutes.",
|
||||
"The documentation is a joy to read.",
|
||||
"Scales effortlessly with my needs.",
|
||||
"Exactly what I needed for my project.",
|
||||
]
|
||||
|
||||
function MarqueeContent() {
|
||||
return (
|
||||
<>
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="home-marquee-item mx-4 flex shrink-0 items-center gap-2 rounded-full border border-slate-200 bg-white px-6 py-3 text-sm text-slate-600 shadow-sm"
|
||||
>
|
||||
{testimonial}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeMarquee() {
|
||||
return (
|
||||
<section className="home-marquee relative left-1/2 w-screen -translate-x-1/2 overflow-hidden border-y border-slate-200 bg-slate-50 py-8">
|
||||
<div className="home-marquee-track flex w-max animate-marquee">
|
||||
<MarqueeContent />
|
||||
<MarqueeContent />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
88
drupal/nextjs/components/home-projects.tsx
Normal file
88
drupal/nextjs/components/home-projects.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
import { ArrowUpRight } from "lucide-react"
|
||||
import { ScrollRevealCard } from "@/components/scroll-reveal-card"
|
||||
|
||||
const projects = [
|
||||
{
|
||||
title: "Böhler re:search",
|
||||
description:
|
||||
"Digital edition of the Munich art dealer Julius Böhler's object card system, photo folders and customer index (1903–1948). Research data on traded artworks, transactions and actors.",
|
||||
href: "https://boehler.zikg.eu/",
|
||||
icon: "/assets/icons/boehler-research.png",
|
||||
},
|
||||
{
|
||||
title: "Objektsprache und Ästhetik",
|
||||
description:
|
||||
"Shell collections at Leopoldina, Goldfuß-Museum Bonn, and Central Institute for Natural Collections MLU. Historical object references and synonym networks for conchylia.",
|
||||
href: "https://konchylien.leopoldina.org/sammlungen",
|
||||
icon: "/assets/logos/lzfw_logo.png",
|
||||
},
|
||||
{
|
||||
title: "SCS Manager",
|
||||
description:
|
||||
"Semantic Co-Working Space for academic university collections. Model, transform, analyse and publish data with JupyterLab, OpenRefine, WissKI and more.",
|
||||
href: "https://manager.scs.sammlungen.io/",
|
||||
icon: "/assets/icons/scs-manager.png",
|
||||
},
|
||||
{
|
||||
title: "WissKI",
|
||||
description:
|
||||
"Semantic data management system for GLAM institutions. Virtual research environment extending Drupal with CIDOC CRM, Pathbuilder and linked open data.",
|
||||
href: "https://wiss-ki.eu/",
|
||||
icon: "/assets/icons/wisski.svg",
|
||||
},
|
||||
]
|
||||
|
||||
export function HomeProjects() {
|
||||
return (
|
||||
<section className="home-projects py-10" aria-labelledby="projects-heading">
|
||||
<div className="home-projects-header mb-6 text-center">
|
||||
<h2
|
||||
id="projects-heading"
|
||||
className="home-projects-title mb-3 font-bold tracking-tight text-slate-900"
|
||||
style={{ fontSize: "var(--fluid-section-title)" }}
|
||||
>
|
||||
Projects
|
||||
</h2>
|
||||
<p
|
||||
className="home-projects-description mx-auto max-w-3xl text-slate-600"
|
||||
style={{ fontSize: "var(--fluid-hero-desc)" }}
|
||||
>
|
||||
Data- and information-focused websites and applications.
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-projects-grid grid gap-8 sm:grid-cols-2">
|
||||
{projects.map((project) => (
|
||||
<ScrollRevealCard key={project.href}>
|
||||
<Link
|
||||
href={project.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="home-projects-card group block rounded-xl border border-slate-200 bg-white p-8 outline-none transition-all duration-200 ease-out hover:border-emerald-500/40 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2"
|
||||
>
|
||||
<h3 className="home-projects-card-title mb-2 flex items-center gap-3 text-xl font-semibold text-slate-900">
|
||||
<Image
|
||||
src={project.icon}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="size-6 shrink-0 object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="home-projects-card-description mb-4 text-slate-600">
|
||||
{project.description}
|
||||
</p>
|
||||
<span className="home-projects-card-link inline-flex items-center gap-2 text-sm font-medium text-emerald-600 transition-colors duration-200 ease-out group-hover:text-emerald-500">
|
||||
Visit project
|
||||
<ArrowUpRight className="size-4 transition-transform duration-200 ease-out group-hover:translate-x-0.5 group-hover:-translate-y-0.5" aria-hidden />
|
||||
</span>
|
||||
</Link>
|
||||
</ScrollRevealCard>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
146
drupal/nextjs/components/home-services.tsx
Normal file
146
drupal/nextjs/components/home-services.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { Book, Brain, Calendar, Cpu, Code2, Database, Rocket, Unplug, Wrench } from "lucide-react"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { drupal } from "@/lib/drupal"
|
||||
import type { DrupalServiceNode } from "@/lib/types"
|
||||
import { ScrollRevealCard } from "@/components/scroll-reveal-card"
|
||||
|
||||
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
coordination: Calendar,
|
||||
data_processing: Cpu,
|
||||
deployment: Rocket,
|
||||
development: Code2,
|
||||
documentation: Book,
|
||||
interface_api: Unplug,
|
||||
interface_and_api: Unplug,
|
||||
maintainance: Wrench,
|
||||
maintenance: Wrench,
|
||||
modelling: Database,
|
||||
ai: Brain,
|
||||
}
|
||||
|
||||
function toIconKey(type: string): string {
|
||||
return type.toLowerCase().replace(/\s+/g, "_").replace(/-/g, "_")
|
||||
}
|
||||
|
||||
function getIcon(serviceType: string | undefined): LucideIcon {
|
||||
if (!serviceType) return Database
|
||||
const key = toIconKey(serviceType)
|
||||
return ICON_MAP[key] ?? Database
|
||||
}
|
||||
|
||||
function stripHtml(html: string | undefined): string {
|
||||
if (!html) return ""
|
||||
return html.replace(/<[^>]*>/g, "").trim()
|
||||
}
|
||||
|
||||
async function getServices(): Promise<
|
||||
{ label: string; body: string; icon: LucideIcon }[]
|
||||
> {
|
||||
if (!drupalBaseUrl) return []
|
||||
|
||||
try {
|
||||
let raw: { data?: DrupalServiceNode[] } | null = null
|
||||
try {
|
||||
raw = await drupal.getResourceCollection<{
|
||||
data: DrupalServiceNode[]
|
||||
}>("node--service", {
|
||||
params: {
|
||||
"filter[status]": "1",
|
||||
sort: "created",
|
||||
},
|
||||
deserialize: false,
|
||||
next: { revalidate: 60 },
|
||||
})
|
||||
} catch (firstError) {
|
||||
const msg = (firstError as Error).message ?? ""
|
||||
if (msg.includes("Unauthorized")) {
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
raw = await drupal.getResourceCollection<{
|
||||
data: DrupalServiceNode[]
|
||||
}>("node--service", {
|
||||
params: {
|
||||
"filter[status]": "1",
|
||||
sort: "created",
|
||||
},
|
||||
deserialize: false,
|
||||
next: { revalidate: 60 },
|
||||
})
|
||||
} else {
|
||||
throw firstError
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = raw?.data ?? []
|
||||
if (!nodes.length) return []
|
||||
|
||||
return nodes.map((node) => {
|
||||
const bodyObj = node.body
|
||||
const bodyText =
|
||||
typeof bodyObj === "string"
|
||||
? stripHtml(bodyObj)
|
||||
: stripHtml(bodyObj?.value ?? bodyObj?.processed)
|
||||
|
||||
return {
|
||||
body: bodyText,
|
||||
icon: getIcon(node.field__service__type),
|
||||
label: node.title ?? "",
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
console.warn(
|
||||
"[HomeServices] CMS unreachable:",
|
||||
(error as Error).message
|
||||
)
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function HomeServices() {
|
||||
const services = await getServices()
|
||||
|
||||
return (
|
||||
<section className="home-services py-10" aria-labelledby="services-heading">
|
||||
<div className="home-services-header mb-6 text-center">
|
||||
<h2
|
||||
id="services-heading"
|
||||
className="home-services-title mb-3 font-bold tracking-tight text-slate-900"
|
||||
style={{ fontSize: "var(--fluid-section-title)" }}
|
||||
>
|
||||
Services
|
||||
</h2>
|
||||
<p className="home-services-description mx-auto max-w-3xl text-slate-600" style={{ fontSize: "var(--fluid-hero-desc)" }}>
|
||||
Data engineering, development, deployment, and ongoing support for
|
||||
your projects.
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-services-grid mx-2 flex flex-col gap-5 sm:mx-4 lg:mx-6">
|
||||
{services.map(({ label, body, icon: Icon }) => (
|
||||
<ScrollRevealCard key={label}>
|
||||
<div
|
||||
className="home-services-card mx-auto flex max-w-[1000px] min-h-0 w-full flex-col items-center justify-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-5 py-5 transition-colors hover:border-emerald-200 hover:bg-emerald-50/50 sm:min-h-[7rem] sm:flex-row sm:gap-10 sm:px-10 sm:py-8"
|
||||
>
|
||||
<Icon
|
||||
className="home-services-card-icon size-8 shrink-0 text-emerald-600 sm:size-10 lg:size-12"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col items-center justify-center gap-1.5 text-center sm:gap-2">
|
||||
<span className="home-services-card-label text-lg font-semibold text-slate-800 sm:text-xl lg:text-2xl">
|
||||
{label}
|
||||
</span>
|
||||
{body && (
|
||||
<span className="home-services-card-body text-sm leading-snug text-slate-600 sm:text-base lg:text-lg">
|
||||
{body}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollRevealCard>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
57
drupal/nextjs/components/imprint-body.tsx
Normal file
57
drupal/nextjs/components/imprint-body.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client"
|
||||
|
||||
import { ObfuscatedEmail } from "@/components/obfuscated-email"
|
||||
import { ObfuscatedAddress } from "@/components/obfuscated-address"
|
||||
|
||||
interface ImprintBodyProps {
|
||||
html: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders imprint HTML with {email} and {address} placeholders replaced by
|
||||
* ObfuscatedEmail and ObfuscatedAddress components.
|
||||
*/
|
||||
export function ImprintBody({ html }: ImprintBodyProps) {
|
||||
const placeholderRegex = /\{(email|address)\}/g
|
||||
const parts: (string | React.ReactNode)[] = []
|
||||
let lastIndex = 0
|
||||
let match
|
||||
let key = 0
|
||||
|
||||
while ((match = placeholderRegex.exec(html)) !== null) {
|
||||
const before = html.slice(lastIndex, match.index)
|
||||
if (before) {
|
||||
parts.push(
|
||||
<span
|
||||
key={`html-${key++}`}
|
||||
style={{ display: "contents" }}
|
||||
dangerouslySetInnerHTML={{ __html: before }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (match[1] === "email") {
|
||||
parts.push(
|
||||
<ObfuscatedEmail
|
||||
key={`email-${key++}`}
|
||||
className="text-emerald-600 outline-none transition-colors hover:text-emerald-500 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
parts.push(<ObfuscatedAddress key={`addr-${key++}`} />)
|
||||
}
|
||||
lastIndex = match.index + match[0].length
|
||||
}
|
||||
|
||||
const after = html.slice(lastIndex)
|
||||
if (after) {
|
||||
parts.push(
|
||||
<span
|
||||
key={`html-${key++}`}
|
||||
style={{ display: "contents" }}
|
||||
dangerouslySetInnerHTML={{ __html: after }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{parts}</>
|
||||
}
|
||||
47
drupal/nextjs/components/mail-to-link.tsx
Normal file
47
drupal/nextjs/components/mail-to-link.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Mail } from "lucide-react"
|
||||
|
||||
interface MailToLinkProps {
|
||||
/** When provided (e.g. from Drupal field_email), use this email. Otherwise use fallback. */
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailto link with icon; email is set on client to reduce harvestability when not passed as prop.
|
||||
*/
|
||||
export function MailToLink({ email }: MailToLinkProps) {
|
||||
const [href, setHref] = useState<string | null>(email ? `mailto:${email}` : null)
|
||||
|
||||
useEffect(() => {
|
||||
if (email) {
|
||||
setHref(`mailto:${email}`)
|
||||
return
|
||||
}
|
||||
const localPart = "robert"
|
||||
const domain = "nasarek"
|
||||
const tld = "dev"
|
||||
setHref(`mailto:${localPart}@${domain}.${tld}`)
|
||||
}, [email])
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 text-slate-500">
|
||||
<Mail className="size-4 shrink-0" aria-hidden />
|
||||
<span>Write me</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="inline-flex items-center gap-2 text-emerald-600 outline-none transition-colors duration-200 ease-out hover:text-emerald-500 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
aria-label="Write me an email"
|
||||
>
|
||||
<Mail className="size-4 shrink-0" aria-hidden />
|
||||
<span>Write me</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
156
drupal/nextjs/components/main-nav-client.tsx
Normal file
156
drupal/nextjs/components/main-nav-client.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"use client"
|
||||
|
||||
import type { DrupalMenuItem } from "next-drupal"
|
||||
import { Home, FolderOpen, ChevronDown } from "lucide-react"
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
|
||||
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
|
||||
|
||||
function getHref(url: string): string {
|
||||
if (drupalBaseUrl && url.startsWith(drupalBaseUrl)) {
|
||||
return url.slice(drupalBaseUrl.length) || "/"
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
item,
|
||||
isDropdown = false,
|
||||
onNavigate,
|
||||
}: {
|
||||
item: DrupalMenuItem
|
||||
isDropdown?: boolean
|
||||
onNavigate?: () => void
|
||||
}) {
|
||||
const children = item.items?.filter((child) => child.enabled !== false) ?? []
|
||||
const hasChildren = children.length > 0
|
||||
const linkClass = isDropdown
|
||||
? "block rounded-sm px-4 py-2 text-emerald-500 outline-none transition-colors duration-200 ease-out hover:underline focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-inset"
|
||||
: "flex items-center gap-1.5 rounded-sm text-emerald-500 outline-none transition-colors duration-200 ease-out hover:text-emerald-400 hover:underline focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-800"
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<div className="group relative">
|
||||
<a
|
||||
href={getHref(item.url)}
|
||||
className={`${linkClass} flex items-center gap-1.5`}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<FolderOpen className="size-4" aria-hidden />
|
||||
{item.title}
|
||||
<ChevronDown className="size-4 transition-transform duration-200 ease-out group-hover:rotate-180" aria-hidden />
|
||||
</a>
|
||||
<div className="absolute left-0 top-full pt-2 opacity-0 pointer-events-none transition-all duration-200 ease-out group-hover:opacity-100 group-hover:pointer-events-auto translate-y-[-4px] group-hover:translate-y-0">
|
||||
<div className="rounded-md border border-slate-200 bg-white py-2 shadow-md">
|
||||
{children.map((child) => (
|
||||
<a
|
||||
key={child.id}
|
||||
href={getHref(child.url)}
|
||||
className="block rounded-sm px-4 py-2 text-emerald-600 outline-none transition-colors duration-200 ease-out hover:bg-slate-50 hover:text-emerald-500 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-inset"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{child.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const href = getHref(item.url)
|
||||
const isHome = href === "/"
|
||||
return (
|
||||
<a href={href} className={linkClass} onClick={onNavigate}>
|
||||
{isHome && <Home className="size-4 shrink-0" aria-hidden />}
|
||||
{item.title}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
interface MainNavClientProps {
|
||||
menuItems: DrupalMenuItem[]
|
||||
}
|
||||
|
||||
export function MainNavClient({ menuItems }: MainNavClientProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const enabledItems = menuItems.filter((item) => item.enabled !== false)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener("click", handleClickOutside)
|
||||
}, [isOpen])
|
||||
|
||||
const closeMenu = () => setIsOpen(false)
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-8 text-sm font-medium" aria-label="Main">
|
||||
{/* Desktop: full nav. */}
|
||||
<div className="hidden lg:flex lg:items-center lg:gap-8">
|
||||
{enabledItems.map((item) => (
|
||||
<NavLink key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile: MORE trigger and dropdown. */}
|
||||
<div className="relative lg:hidden" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className="flex items-center rounded-sm px-3 py-2 text-lg font-bold uppercase tracking-wide text-emerald-500 outline-none transition-colors duration-200 ease-out hover:text-emerald-400 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-800"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
MORE
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-2 min-w-[12rem] rounded-md border border-slate-200 bg-white py-2 shadow-lg">
|
||||
{enabledItems.map((item) => {
|
||||
const children = item.items?.filter((c) => c.enabled !== false) ?? []
|
||||
return (
|
||||
<div key={item.id} className="border-b border-slate-100 last:border-b-0">
|
||||
<a
|
||||
href={getHref(item.url)}
|
||||
className="flex items-center gap-1.5 rounded-sm px-4 py-2 text-emerald-500 hover:bg-slate-50 hover:text-emerald-400"
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{children.length > 0 ? (
|
||||
<FolderOpen className="size-4" aria-hidden />
|
||||
) : getHref(item.url) === "/" ? (
|
||||
<Home className="size-4" aria-hidden />
|
||||
) : null}
|
||||
{item.title}
|
||||
</a>
|
||||
{children.length > 0 && (
|
||||
<div className="pl-6 pb-1">
|
||||
{children.map((child) => (
|
||||
<a
|
||||
key={child.id}
|
||||
href={getHref(child.url)}
|
||||
className="block rounded-sm px-2 py-1.5 text-sm text-emerald-500 hover:bg-slate-50 hover:text-emerald-400"
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{child.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
76
drupal/nextjs/components/main-nav.tsx
Normal file
76
drupal/nextjs/components/main-nav.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import type { DrupalMenuItem } from "next-drupal"
|
||||
import { MainNavClient } from "./main-nav-client"
|
||||
|
||||
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
|
||||
|
||||
interface RawMenuItem {
|
||||
id: string
|
||||
parent: string
|
||||
title: string
|
||||
url: string
|
||||
enabled?: boolean
|
||||
weight?: string | number
|
||||
}
|
||||
|
||||
function buildMenuTree(
|
||||
items: RawMenuItem[],
|
||||
parentId: string
|
||||
): DrupalMenuItem[] {
|
||||
return items
|
||||
.filter((item) => (item.parent || "") === parentId && item.enabled !== false)
|
||||
.sort((a, b) => Number(a.weight ?? 0) - Number(b.weight ?? 0))
|
||||
.map((item) => {
|
||||
const children = buildMenuTree(items, item.id)
|
||||
return {
|
||||
...item,
|
||||
items: children.length ? children : undefined,
|
||||
} as DrupalMenuItem
|
||||
})
|
||||
}
|
||||
|
||||
const FALLBACK_MENU: DrupalMenuItem[] = [
|
||||
{
|
||||
id: "home",
|
||||
title: "Home",
|
||||
url: "/",
|
||||
enabled: true,
|
||||
items: undefined,
|
||||
} as DrupalMenuItem,
|
||||
]
|
||||
|
||||
async function getMainMenu(): Promise<DrupalMenuItem[]> {
|
||||
if (!drupalBaseUrl) return FALLBACK_MENU
|
||||
|
||||
try {
|
||||
const url = `${drupalBaseUrl.replace(/\/$/, "")}/jsonapi/menu_items/main`
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000)
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/vnd.api+json" },
|
||||
next: { revalidate: 60 },
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
if (!res.ok) {
|
||||
if (res.status !== 404) {
|
||||
console.warn(`[MainNav] Menu fetch returned ${res.status}, using fallback nav.`)
|
||||
}
|
||||
return FALLBACK_MENU
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
const items: RawMenuItem[] = json.data ?? []
|
||||
|
||||
return buildMenuTree(items, "")
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
console.warn("[MainNav] CMS unreachable, using fallback nav:", (error as Error).message)
|
||||
}
|
||||
return FALLBACK_MENU
|
||||
}
|
||||
}
|
||||
|
||||
export async function MainNav() {
|
||||
const menuItems = await getMainMenu()
|
||||
return <MainNavClient menuItems={menuItems} />
|
||||
}
|
||||
34
drupal/nextjs/components/node-article-teaser.tsx
Normal file
34
drupal/nextjs/components/node-article-teaser.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { DrupalNode } from "@/lib/types"
|
||||
|
||||
interface NodeArticleTeaserProps {
|
||||
node: DrupalNode
|
||||
}
|
||||
|
||||
export function NodeArticleTeaser({ node }: NodeArticleTeaserProps) {
|
||||
const href = node.path?.alias || `/node/${node.id}`
|
||||
|
||||
return (
|
||||
<article className="group rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition hover:shadow-md">
|
||||
<h3 className="mb-2 text-xl font-semibold">
|
||||
<a href={href} className="text-gray-900 group-hover:text-emerald-500">
|
||||
{node.title}
|
||||
</a>
|
||||
</h3>
|
||||
<div className="mb-3 flex items-center gap-3 text-sm text-gray-500">
|
||||
{node.uid?.display_name && (
|
||||
<span>By {node.uid.display_name}</span>
|
||||
)}
|
||||
<time dateTime={node.created}>
|
||||
{new Date(node.created).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
{node.body?.summary && (
|
||||
<p className="line-clamp-3 text-gray-600">{node.body.summary}</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
35
drupal/nextjs/components/node-article.tsx
Normal file
35
drupal/nextjs/components/node-article.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { DrupalNode } from "@/lib/types"
|
||||
|
||||
interface NodeArticleProps {
|
||||
node: DrupalNode
|
||||
}
|
||||
|
||||
export function NodeArticle({ node }: NodeArticleProps) {
|
||||
return (
|
||||
<article className="mx-auto max-w-4xl">
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight text-emerald-600">
|
||||
{node.title}
|
||||
</h1>
|
||||
<div className="mb-8 flex items-center gap-3 text-sm text-slate-600">
|
||||
{node.uid?.display_name && (
|
||||
<span>By {node.uid.display_name}</span>
|
||||
)}
|
||||
<time dateTime={node.created}>
|
||||
{new Date(node.created).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
{(node.body?.processed ?? node.body?.value) && (
|
||||
<div
|
||||
className="pemerald pemerald-slate max-w-none pemerald-a:text-emerald-600 pemerald-a:underline hover:pemerald-a:text-emerald-500"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: node.body.processed ?? node.body?.value ?? "",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
21
drupal/nextjs/components/node-page.tsx
Normal file
21
drupal/nextjs/components/node-page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { DrupalNode } from "@/lib/types"
|
||||
|
||||
interface NodePageProps {
|
||||
node: DrupalNode
|
||||
}
|
||||
|
||||
export function NodePage({ node }: NodePageProps) {
|
||||
return (
|
||||
<article className="mx-auto max-w-4xl">
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight text-emerald-600">
|
||||
{node.title}
|
||||
</h1>
|
||||
{node.body?.processed && (
|
||||
<div
|
||||
className="pemerald pemerald-slate max-w-none pemerald-a:text-emerald-600 pemerald-a:underline hover:pemerald-a:text-emerald-500"
|
||||
dangerouslySetInnerHTML={{ __html: node.body.processed }}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
49
drupal/nextjs/components/obfuscated-address.tsx
Normal file
49
drupal/nextjs/components/obfuscated-address.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
/**
|
||||
* Renders a mailto link only after client mount so the email is not in the
|
||||
* server-rendered HTML, reducing harvestability by bots that scan static HTML.
|
||||
* Parts are hardcoded so they live in the JS bundle, not in page HTML.
|
||||
*/
|
||||
type AddressData = {
|
||||
fullname: string
|
||||
street: string
|
||||
city: string
|
||||
country: string
|
||||
}
|
||||
|
||||
export function ObfuscatedAddress({ className }: { className?: string }) {
|
||||
const [address, setAddress] = useState<AddressData | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setAddress({
|
||||
fullname: "Robert Nasarek",
|
||||
street: "Kleine Ulrichstraße 1",
|
||||
city: "Halle (Saale)",
|
||||
country: "Germany",
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!address) {
|
||||
return (
|
||||
<span className={className}>
|
||||
<noscript>Robert Nasarek, Kleine Ulrichstraße 1, Halle (Saale), Germany</noscript>
|
||||
<span aria-hidden>…</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p className={className}>
|
||||
{address.fullname}
|
||||
<br />
|
||||
{address.street}
|
||||
<br />
|
||||
{address.city}
|
||||
<br />
|
||||
{address.country}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
34
drupal/nextjs/components/obfuscated-email.tsx
Normal file
34
drupal/nextjs/components/obfuscated-email.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
/**
|
||||
* Renders a mailto link only after client mount so the email is not in the
|
||||
* server-rendered HTML, reducing harvestability by bots that scan static HTML.
|
||||
* Parts are hardcoded so they live in the JS bundle, not in page HTML.
|
||||
*/
|
||||
export function ObfuscatedEmail({ className }: { className?: string }) {
|
||||
const [email, setEmail] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const localPart = "robert"
|
||||
const domain = "nasarek"
|
||||
const tld = "dev"
|
||||
setEmail(`${localPart}@${domain}.${tld}`)
|
||||
}, [])
|
||||
|
||||
if (!email) {
|
||||
return (
|
||||
<span className={className}>
|
||||
<noscript>robert [at] nasarek [dot] dev</noscript>
|
||||
<span aria-hidden>…</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={`mailto:${email}`} className={className}>
|
||||
{email}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
38
drupal/nextjs/components/scroll-reveal-card.tsx
Normal file
38
drupal/nextjs/components/scroll-reveal-card.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react"
|
||||
|
||||
interface ScrollRevealCardProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ScrollRevealCard({ children }: ScrollRevealCardProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) setIsVisible(true)
|
||||
},
|
||||
{ threshold: 0.35, rootMargin: "0px 0px -120px 0px" }
|
||||
)
|
||||
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`transition-all duration-700 ease-out ${
|
||||
isVisible ? "translate-y-0 opacity-100" : "translate-y-12 opacity-20"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
drupal/nextjs/components/scroll-reveal-section.tsx
Normal file
56
drupal/nextjs/components/scroll-reveal-section.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react"
|
||||
|
||||
interface ScrollRevealSectionProps {
|
||||
children: ReactNode
|
||||
/** When true, section starts visible (no opacity-20 flash). Use for above-the-fold hero. */
|
||||
initialVisible?: boolean
|
||||
/** Delay in ms before the reveal animation starts after the section enters view. */
|
||||
revealDelayMs?: number
|
||||
}
|
||||
|
||||
export function ScrollRevealSection({
|
||||
children,
|
||||
initialVisible = false,
|
||||
revealDelayMs = 0,
|
||||
}: ScrollRevealSectionProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [isVisible, setIsVisible] = useState(initialVisible)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting) return
|
||||
if (revealDelayMs <= 0) {
|
||||
setIsVisible(true)
|
||||
return
|
||||
}
|
||||
timeoutId = setTimeout(() => setIsVisible(true), revealDelayMs)
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: "0px 0px -80px 0px" }
|
||||
)
|
||||
|
||||
observer.observe(el)
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [revealDelayMs])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`transition-all duration-700 ease-out ${
|
||||
isVisible ? "translate-y-0 opacity-100" : "translate-y-12 opacity-20"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue