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:
rnsrk 2026-03-30 11:14:17 +02:00
parent 71a8dac389
commit f8b8f53d54
85 changed files with 7802 additions and 17 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}

View 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">
Im 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 Im 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 (19031948). 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>
)
}

View 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>
)
}

View 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}</>
}

View 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>
)
}

View 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>
)
}

View 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} />
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}