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
7
drupal/nextjs/.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
*.md
|
||||
.env*.local
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
25
drupal/nextjs/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build
|
||||
.next
|
||||
out
|
||||
build
|
||||
dist
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Local env
|
||||
.env*.local
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
61
drupal/nextjs/Dockerfile
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Stage 0: Development (bind-mount source, run next dev).
|
||||
FROM node:22-alpine AS development
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci || npm install
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV NODE_ENV=development
|
||||
# WATCHPACK_POLLING helps with bind mounts on some file systems.
|
||||
ENV WATCHPACK_POLLING=true
|
||||
ENTRYPOINT ["sh", "-c", "[ -d node_modules/.bin ] || npm install; exec npm run dev"]
|
||||
|
||||
# Stage 1: Install dependencies.
|
||||
FROM node:22-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci || npm install
|
||||
|
||||
# Stage 2: Build the application.
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Build arguments for environment variables needed at build time.
|
||||
ARG NEXT_PUBLIC_DRUPAL_BASE_URL
|
||||
ARG DRUPAL_CLIENT_ID
|
||||
ARG DRUPAL_CLIENT_SECRET
|
||||
ARG DRUPAL_OAUTH_SCOPE
|
||||
ENV NEXT_PUBLIC_DRUPAL_BASE_URL=${NEXT_PUBLIC_DRUPAL_BASE_URL}
|
||||
ENV DRUPAL_CLIENT_ID=${DRUPAL_CLIENT_ID}
|
||||
ENV DRUPAL_CLIENT_SECRET=${DRUPAL_CLIENT_SECRET}
|
||||
ENV DRUPAL_OAUTH_SCOPE=${DRUPAL_OAUTH_SCOPE}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Production runner.
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy standalone output.
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
89
drupal/nextjs/app/[...slug]/page.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { drupal } from "@/lib/drupal"
|
||||
import type { DrupalNode } from "@/lib/types"
|
||||
import { NodeArticle } from "@/components/node-article"
|
||||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
|
||||
|
||||
interface NodePageProps {
|
||||
params: Promise<{
|
||||
slug: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
// Render dynamically at runtime (not at build time).
|
||||
export const dynamic = "force-dynamic"
|
||||
export const revalidate = 60
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: NodePageProps): Promise<Metadata> {
|
||||
if (!drupalBaseUrl) return {}
|
||||
|
||||
const { slug } = await params
|
||||
const path = drupal.constructPathFromSegment(slug)
|
||||
|
||||
try {
|
||||
const translatedPath = await drupal.translatePath(path, { withAuth: true })
|
||||
|
||||
if (!translatedPath?.jsonapi?.resourceName || !translatedPath?.entity?.uuid) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const node = await drupal.getResource<DrupalNode>(
|
||||
translatedPath.jsonapi.resourceName,
|
||||
translatedPath.entity.uuid,
|
||||
{
|
||||
withAuth: true,
|
||||
params: {
|
||||
"fields[node--article]": "title",
|
||||
"fields[node--page]": "title",
|
||||
"fields[node--about]": "title",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
title: node?.title,
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function NodePage({ params }: NodePageProps) {
|
||||
if (!drupalBaseUrl) notFound()
|
||||
|
||||
const { slug } = await params
|
||||
const path = drupal.constructPathFromSegment(slug)
|
||||
|
||||
try {
|
||||
const translatedPath = await drupal.translatePath(path, { withAuth: true })
|
||||
|
||||
if (!translatedPath?.jsonapi?.resourceName || !translatedPath?.entity?.uuid) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const type = translatedPath.jsonapi.resourceName
|
||||
|
||||
const node = await drupal.getResource<DrupalNode>(
|
||||
type,
|
||||
translatedPath.entity.uuid,
|
||||
{
|
||||
withAuth: true,
|
||||
params: {
|
||||
include: "uid",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!node || !node.status) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <NodeArticle node={node} />
|
||||
} catch {
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
6
drupal/nextjs/app/api/disable-draft/route.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { disableDraftMode } from "next-drupal/draft"
|
||||
import type { NextRequest } from "next/server"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return disableDraftMode()
|
||||
}
|
||||
7
drupal/nextjs/app/api/draft/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { drupal } from "@/lib/drupal"
|
||||
import { enableDraftMode } from "next-drupal/draft"
|
||||
import type { NextRequest } from "next/server"
|
||||
|
||||
export async function GET(request: NextRequest): Promise<Response | never> {
|
||||
return enableDraftMode(request, drupal)
|
||||
}
|
||||
9
drupal/nextjs/app/api/preview/route.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { enableDraftMode } from "next-drupal/draft"
|
||||
import { drupal } from "@/lib/drupal"
|
||||
import { NextRequest } from "next/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return enableDraftMode(request, drupal)
|
||||
}
|
||||
35
drupal/nextjs/app/api/revalidate/route.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { revalidatePath } from "next/cache"
|
||||
import { NextRequest } from "next/server"
|
||||
|
||||
async function handler(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const secret = searchParams.get("secret")
|
||||
const path = searchParams.get("path")
|
||||
|
||||
// Validate the revalidation secret.
|
||||
if (secret !== process.env.DRUPAL_REVALIDATE_SECRET) {
|
||||
return new Response("Invalid secret.", { status: 401 })
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return new Response("Missing path.", { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
revalidatePath(path)
|
||||
return new Response(
|
||||
JSON.stringify({ revalidated: true, now: Date.now() }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "Error revalidating.", error }),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
BIN
drupal/nextjs/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
307
drupal/nextjs/app/globals.css
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--accent: var(--color-emerald-600);
|
||||
--accent-hex: #e11d48;
|
||||
--fluid-hero: clamp(1.75rem, 4vw + 1rem, 3.75rem);
|
||||
--fluid-hero-desc: clamp(1rem, 1.5vw + 0.75rem, 1.25rem);
|
||||
--fluid-section-title: clamp(1.5rem, 3vw + 0.75rem, 1.875rem);
|
||||
/* Fade-in on load: About starts when hero title animation ends (~2.1s), Services after About. */
|
||||
--fade-about-delay: 2.1s;
|
||||
--fade-about-duration: 1.2s;
|
||||
--fade-services-delay: 2.5s;
|
||||
--fade-services-duration: 1.2s;
|
||||
}
|
||||
|
||||
/* Footer link icons: tint to emerald on link hover/focus (icons are img/SVG with fixed fill). */
|
||||
.group:hover .footer-icon-hover-emerald,
|
||||
.group:focus-visible .footer-icon-hover-emerald {
|
||||
filter: brightness(0) saturate(100%) invert(48%) sepia(79%) saturate(2476%) hue-rotate(130deg) brightness(95%) contrast(101%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
a {
|
||||
@apply transition-colors duration-200 ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-on-load {
|
||||
animation: fade-in var(--fade-about-duration) ease-in-out var(--fade-about-delay) both;
|
||||
}
|
||||
|
||||
.animate-fade-in-on-load-delayed {
|
||||
animation: fade-in var(--fade-services-duration) ease-in-out var(--fade-services-delay) both;
|
||||
}
|
||||
|
||||
.animate-delay-100 {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
|
||||
.animate-delay-200 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.animate-delay-300 {
|
||||
animation-delay: 300ms;
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
animation: marquee 30s linear infinite;
|
||||
}
|
||||
|
||||
.animate-marquee-slow {
|
||||
animation: marquee 60s linear infinite;
|
||||
}
|
||||
|
||||
.home-clients-band {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
black 8%,
|
||||
black 92%,
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
black 8%,
|
||||
black 92%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin 8s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-once {
|
||||
animation: spin 0.5s ease-in-out 1 forwards;
|
||||
}
|
||||
|
||||
@keyframes coin-spin {
|
||||
from {
|
||||
transform: rotateY(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotateY(720deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-coin-spin {
|
||||
animation: coin-spin 0.6s ease-in-out 1 forwards;
|
||||
}
|
||||
|
||||
/* Hero title letter animations. */
|
||||
@keyframes letter-from-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes letter-from-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes letter-b-exit {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
51% {
|
||||
opacity: 0;
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes letter-o-in-out {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes letter-e-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* t: appears with o (Robot), stays, then slides right as r appears. */
|
||||
@keyframes letter-t-slide {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%) translateX(-0.7ch);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(-0.45em) translateX(-0.7ch);
|
||||
}
|
||||
55% {
|
||||
opacity: 1;
|
||||
transform: translateY(-0.45em) translateX(-0.7ch);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(-0.45em) translateX(2px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-letter-from-down {
|
||||
animation: letter-from-down 0.5s ease-out both;
|
||||
}
|
||||
|
||||
.animate-letter-from-up {
|
||||
animation: letter-from-up 0.5s ease-out both;
|
||||
}
|
||||
|
||||
.animate-letter-b-exit {
|
||||
animation: letter-b-exit 0.9s ease-out both;
|
||||
}
|
||||
|
||||
.animate-letter-o-in-out {
|
||||
animation: letter-o-in-out 1.5s ease-out both;
|
||||
}
|
||||
|
||||
.animate-letter-e-fade-in {
|
||||
animation: letter-e-fade-in 0.5s ease-out both;
|
||||
}
|
||||
|
||||
.animate-letter-t-slide {
|
||||
animation: letter-t-slide 0.9s ease-out both;
|
||||
}
|
||||
|
||||
.hero-letter-t {
|
||||
overflow: visible;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.hero-letter-r {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero-letter-r::before {
|
||||
content: "B";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: letter-b-exit 0.6s ease-out both;
|
||||
animation-delay: var(--b-delay, 320ms);
|
||||
}
|
||||
|
||||
.hero-letter-e {
|
||||
position: relative;
|
||||
padding-right: 1px;
|
||||
padding-left: 1px;
|
||||
}
|
||||
|
||||
.hero-letter-e::before {
|
||||
content: "o";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: letter-o-in-out 1.5s ease-out both;
|
||||
animation-delay: var(--o-delay, 760ms);
|
||||
}
|
||||
|
||||
.layout-footer-section {
|
||||
margin: 0 auto;
|
||||
}
|
||||
1
drupal/nextjs/app/icon-robot-optimized.svg
Normal file
|
After Width: | Height: | Size: 115 KiB |
1517
drupal/nextjs/app/icon.svg
Normal file
|
After Width: | Height: | Size: 574 KiB |
106
drupal/nextjs/app/imprint/page.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import type { Metadata } from "next"
|
||||
import { ObfuscatedAddress } from "@/components/obfuscated-address"
|
||||
import { ObfuscatedEmail } from "@/components/obfuscated-email"
|
||||
import { drupal } from "@/lib/drupal"
|
||||
|
||||
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
|
||||
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Imprint",
|
||||
description: "Legal notice and imprint for nasarek.dev",
|
||||
}
|
||||
|
||||
const FALLBACK_TITLE = "Imprint"
|
||||
|
||||
const BODY_STYLES = "[&_h2]:mb-4 [&_h2]:mt-8 [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:text-slate-900 [&_p]:mb-4 [&_p]:text-slate-700 [&_a]:text-emerald-600 [&_a]:underline hover:[&_a]:text-emerald-500"
|
||||
|
||||
/**
|
||||
* Splits the CMS body HTML at {address} and {email} placeholders and renders
|
||||
* the obfuscated components in their place so bots cannot harvest the data.
|
||||
*/
|
||||
function ImprintBody({ html }: { html: string }) {
|
||||
const parts = html.split(/(<p>\{(?:address|email)\}<\/p>)/g)
|
||||
|
||||
return (
|
||||
<div className={BODY_STYLES}>
|
||||
{parts.map((part, i) => {
|
||||
if (part === "<p>{address}</p>") return <ObfuscatedAddress key={i} />
|
||||
if (part === "<p>{email}</p>") return <p key={i}><ObfuscatedEmail /></p>
|
||||
if (!part) return null
|
||||
return <div key={i} dangerouslySetInnerHTML={{ __html: part }} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function getImprintPageContent(): Promise<{
|
||||
title: string
|
||||
body: string | null
|
||||
}> {
|
||||
if (!drupalBaseUrl) {
|
||||
return {
|
||||
title: FALLBACK_TITLE,
|
||||
body: null,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const translatedPath = await drupal.translatePath("/imprint", {
|
||||
withAuth: true,
|
||||
next: { revalidate: 60 },
|
||||
})
|
||||
if (!translatedPath?.jsonapi?.resourceName || !translatedPath?.entity?.uuid) {
|
||||
return { title: FALLBACK_TITLE, body: null }
|
||||
}
|
||||
|
||||
const resourceType = translatedPath.jsonapi.resourceName
|
||||
const raw = await drupal.getResource(
|
||||
resourceType,
|
||||
translatedPath.entity.uuid,
|
||||
{ withAuth: true, next: { revalidate: 60 }, deserialize: false }
|
||||
)
|
||||
const rawData = (raw as { data?: Record<string, unknown> })?.data
|
||||
if (!rawData) {
|
||||
return { title: FALLBACK_TITLE, body: null }
|
||||
}
|
||||
|
||||
const title = (rawData.title as string) ?? FALLBACK_TITLE
|
||||
const bodyObj = rawData.body
|
||||
const bodyText =
|
||||
typeof bodyObj === "string"
|
||||
? bodyObj
|
||||
: (bodyObj as { processed?: string; value?: string })?.processed ??
|
||||
(bodyObj as { processed?: string; value?: string })?.value ??
|
||||
""
|
||||
|
||||
return {
|
||||
title,
|
||||
body: bodyText || null,
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
console.warn("[Imprint] CMS unreachable:", (error as Error).message)
|
||||
}
|
||||
return { title: FALLBACK_TITLE, body: null }
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ImprintPage() {
|
||||
const { title, body } = await getImprintPageContent()
|
||||
return (
|
||||
<section className="imprint animate-fade-in-on-load pb-10" aria-labelledby="imprint-heading">
|
||||
<div className="imprint-header mb-8 text-center">
|
||||
<h1 className="imprint-title mb-3 font-bold tracking-tight text-slate-900"
|
||||
style={{ fontSize: "var(--fluid-section-title)" }}>
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="imprint-content mx-auto max-w-4xl">
|
||||
{body && <ImprintBody html={body} />}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
253
drupal/nextjs/app/layout.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import { Source_Sans_3 } from "next/font/google"
|
||||
import {
|
||||
LayoutGrid,
|
||||
FileText,
|
||||
Database,
|
||||
Scale,
|
||||
} from "lucide-react"
|
||||
import { MainNav } from "@/components/main-nav"
|
||||
import { CookieBanner } from "@/components/cookie-banner"
|
||||
import "./globals.css"
|
||||
|
||||
const sourceSans3 = Source_Sans_3({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "nasarek.dev",
|
||||
template: "%s | nasarek.dev",
|
||||
},
|
||||
description: "Powered by Drupal and Next.js",
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`layout-body ${sourceSans3.className} flex min-h-screen flex-col bg-slate-50 text-slate-900 antialiased`}
|
||||
>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-6 focus:top-4 focus:z-100 focus:rounded-md focus:bg-slate-800 focus:px-4 focus:py-2 focus:text-white focus:outline-none focus:ring-2 focus:ring-emerald-400"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<header className="layout-header sticky top-0 z-50 border-b border-slate-700/50 bg-slate-800/95 backdrop-blur-sm">
|
||||
<div className="layout-header-inner mx-auto flex max-w-7xl items-center justify-between px-4 py-4 lg:px-6">
|
||||
<a
|
||||
href="/"
|
||||
className="layout-logo flex items-center gap-3 rounded-sm text-2xl font-bold tracking-tight text-emerald-500 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 focus-visible:ring-offset-slate-800 sm:text-3xl"
|
||||
aria-label="nasarek.dev home"
|
||||
>
|
||||
<Image
|
||||
src="/icon.svg"
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="size-9 shrink-0 sm:size-10"
|
||||
/>
|
||||
Nasarek Data Engineering
|
||||
</a>
|
||||
<MainNav />
|
||||
</div>
|
||||
</header>
|
||||
<main id="main-content" className="layout-main flex flex-1 flex-col overflow-x-hidden bg-white">
|
||||
<div className="layout-main-content mx-auto flex min-h-full w-full max-w-full flex-1 flex-col px-4 py-4 pb-16 lg:px-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<footer className="layout-footer mt-12 border-t border-slate-200 bg-slate-50">
|
||||
<div className="layout-footer-inner mx-auto max-w-7xl px-6 py-12">
|
||||
<div className="layout-footer-grid grid grid-cols-2 gap-8 text-center sm:text-left lg:grid-cols-4">
|
||||
<div className="layout-footer-section">
|
||||
<h3 className="layout-footer-heading mb-3 text-left text-sm font-semibold uppercase tracking-wider text-slate-500">
|
||||
Legal
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="/imprint"
|
||||
className="inline-flex items-center gap-2 text-slate-600 outline-none transition-colors hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Scale className="size-4 shrink-0" aria-hidden />
|
||||
Imprint
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="layout-footer-section">
|
||||
<h3 className="layout-footer-heading mb-3 text-left text-sm font-semibold uppercase tracking-wider text-slate-500">
|
||||
Social
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="https://github.com/rnsrk"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image src="/assets/icons/github.svg" alt="" width={16} height={16} className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200" unoptimized />
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="https://www.drupal.org/u/rnsrk"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/drupal.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
Drupal
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li className="text-left">
|
||||
<a
|
||||
rel="me"
|
||||
href="https://fedihum.org/@rnsrk"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/mastodon.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
Mastodon
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="layout-footer-section">
|
||||
<h3 className="layout-footer-heading mb-3 text-left text-sm font-semibold uppercase tracking-wider text-slate-500">
|
||||
Powered by
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="https://www.drupal.org"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors duration-200 ease-out hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/drupal.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
Drupal
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="https://nextjs.org"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors duration-200 ease-out hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/nextjs.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
Next.js
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="layout-footer-section">
|
||||
<h3 className="layout-footer-heading mb-3 text-left text-sm font-semibold uppercase tracking-wider text-slate-500">Media</h3>
|
||||
<ul className="space-y-2">
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="https://www.youtube.com/watch?v=MGOHzreEU38"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors duration-200 ease-out hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/youtube.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
YouTube
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="https://open.spotify.com/episode/40v8fdhk4WcXJYu1oIF1oe"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors duration-200 ease-out hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/spotify.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
Spotify
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="https://zenodo.org/search?q=metadata.creators.person_or_org.name%3A%22Nasarek%2C%20Robert%22&l=list&p=1&s=10&sort=bestmatch"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/zenodo.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
Zenodo
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="layout-footer-copyright mt-10 border-t border-slate-200 pt-8 text-center text-sm text-slate-500">
|
||||
© {new Date().getFullYear()} nasarek.dev
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<CookieBanner />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
19
drupal/nextjs/app/not-found.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center text-center">
|
||||
<h1 className="mb-4 text-6xl font-bold text-gray-300">404</h1>
|
||||
<h2 className="mb-2 text-2xl font-semibold text-gray-700">
|
||||
Page Not Found
|
||||
</h2>
|
||||
<p className="mb-6 text-gray-500">
|
||||
The page you are looking for does not exist.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-lg bg-gray-900 px-6 py-3 text-sm font-medium text-white transition hover:bg-gray-800"
|
||||
>
|
||||
Go Home
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
drupal/nextjs/app/page.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { HomeHero } from "@/components/home-hero"
|
||||
|
||||
// Force dynamic so HomeAbout fetches at request time (OAuth env vars available in container, not at build).
|
||||
export const dynamic = "force-dynamic"
|
||||
import { HomeAbout } from "@/components/home-about"
|
||||
import { HomeServices } from "@/components/home-services"
|
||||
import { HomeProjects } from "@/components/home-projects"
|
||||
import { HomeClients } from "@/components/home-clients"
|
||||
import { HomeJourneyBackground } from "@/components/home-journey-background"
|
||||
import { ScrollRevealSection } from "@/components/scroll-reveal-section"
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<HomeJourneyBackground>
|
||||
<ScrollRevealSection initialVisible>
|
||||
<HomeHero />
|
||||
</ScrollRevealSection>
|
||||
<HomeAbout />
|
||||
<div className="animate-fade-in-on-load-delayed">
|
||||
<HomeServices />
|
||||
</div>
|
||||
<ScrollRevealSection>
|
||||
<HomeClients />
|
||||
</ScrollRevealSection>
|
||||
<ScrollRevealSection>
|
||||
<HomeProjects />
|
||||
</ScrollRevealSection>
|
||||
</HomeJourneyBackground>
|
||||
)
|
||||
}
|
||||
65
drupal/nextjs/app/resources/page.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { FileText, Database, ArrowRight } from "lucide-react"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Resources",
|
||||
description: "Browse articles and models on nasarek.dev",
|
||||
}
|
||||
|
||||
export default function ResourcesPage() {
|
||||
return (
|
||||
<article className="mx-auto flex min-h-full flex-1 flex-col max-w-7xl">
|
||||
<header className="mb-12">
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl">
|
||||
Resources
|
||||
</h1>
|
||||
<p className="max-w-2xl text-lg text-slate-600">
|
||||
Explore my curated collection of articles and models.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-8 sm:grid-cols-2">
|
||||
<Link
|
||||
href="/resources/articles"
|
||||
className="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"
|
||||
>
|
||||
<div className="mb-4 inline-flex rounded-lg bg-emerald-100 p-3 text-emerald-600">
|
||||
<FileText className="size-6" aria-hidden />
|
||||
</div>
|
||||
<h2 className="mb-3 text-xl font-semibold text-emerald-600 transition-colors duration-200 ease-out group-hover:text-emerald-500">
|
||||
Articles
|
||||
</h2>
|
||||
<p className="mb-4 text-slate-600">
|
||||
Written content covering tutorials, guides, and insights. Articles
|
||||
are published pieces with full text, images, and structured
|
||||
formatting.
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium text-emerald-600 transition-colors duration-200 ease-out group-hover:text-emerald-500">
|
||||
Browse articles
|
||||
<ArrowRight className="size-4 transition-transform duration-200 ease-out group-hover:translate-x-1" aria-hidden />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/resources/datamodelling"
|
||||
className="group block rounded-xl border border-slate-200 bg-white p-8 outline-none transition-all duration-200 ease-out hover:border-fuchsia-500/40 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2"
|
||||
>
|
||||
<div className="mb-4 inline-flex rounded-lg bg-fuchsia-100 p-3 text-fuchsia-600">
|
||||
<Database className="size-6" aria-hidden />
|
||||
</div>
|
||||
<h2 className="mb-3 text-xl font-semibold text-fuchsia-600 transition-colors duration-200 ease-out group-hover:text-fuchsia-500">
|
||||
Datamodelling
|
||||
</h2>
|
||||
<p className="mb-4 text-slate-600">
|
||||
Structured data models and schemas from my projects.
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium text-emerald-600 transition-colors duration-200 ease-out group-hover:text-emerald-500">
|
||||
Browse models
|
||||
<ArrowRight className="size-4 transition-transform duration-200 ease-out group-hover:translate-x-1" aria-hidden />
|
||||
</span>
|
||||
</Link>
|
||||
</section>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
0
drupal/nextjs/lib/config.ts
Normal file
20
drupal/nextjs/lib/drupal.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { NextDrupal } from "next-drupal"
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL!
|
||||
|
||||
const auth =
|
||||
process.env.DRUPAL_CLIENT_ID && process.env.DRUPAL_CLIENT_SECRET
|
||||
? {
|
||||
clientId: process.env.DRUPAL_CLIENT_ID,
|
||||
clientSecret: process.env.DRUPAL_CLIENT_SECRET,
|
||||
...(process.env.DRUPAL_OAUTH_SCOPE && {
|
||||
scope: process.env.DRUPAL_OAUTH_SCOPE,
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
|
||||
export const drupal = new NextDrupal(baseUrl, {
|
||||
auth,
|
||||
withAuth: !!auth,
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
})
|
||||
71
drupal/nextjs/lib/types.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { JsonApiResource } from "next-drupal"
|
||||
|
||||
// Drupal JSON:API resource types.
|
||||
|
||||
export interface DrupalNode extends JsonApiResource {
|
||||
title: string
|
||||
status: boolean
|
||||
created: string
|
||||
changed: string
|
||||
path: {
|
||||
alias: string
|
||||
pid: number
|
||||
langcode: string
|
||||
}
|
||||
body?: {
|
||||
value: string
|
||||
format: string
|
||||
processed: string
|
||||
summary: string
|
||||
}
|
||||
field_image?: DrupalMedia
|
||||
uid?: {
|
||||
id: string
|
||||
display_name: string
|
||||
}
|
||||
metatag?: DrupalMetatag[]
|
||||
}
|
||||
|
||||
export interface DrupalMedia extends JsonApiResource {
|
||||
name: string
|
||||
field_media_image?: DrupalFile
|
||||
}
|
||||
|
||||
export interface DrupalFile extends JsonApiResource {
|
||||
uri: {
|
||||
value: string
|
||||
url: string
|
||||
}
|
||||
resourceIdObjMeta?: {
|
||||
alt: string
|
||||
title: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface DrupalMetatag {
|
||||
tag: string
|
||||
attributes: Record<string, string>
|
||||
}
|
||||
|
||||
export interface DrupalMenuLinkContent {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
parent: string
|
||||
weight: number
|
||||
expanded: boolean
|
||||
enabled: boolean
|
||||
items?: DrupalMenuLinkContent[]
|
||||
}
|
||||
|
||||
export interface DrupalServiceNode extends DrupalNode {
|
||||
/** Service type from Drupal (modelling, development, deployment, etc.). JSON:API exposes as field__service__type. */
|
||||
field__service__type?: string
|
||||
}
|
||||
|
||||
export interface DrupalAboutNode extends DrupalNode {
|
||||
/** JSON:API resource type: node--about. */
|
||||
field_email?: string
|
||||
}
|
||||
20
drupal/nextjs/next.config.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { NextConfig } from "next"
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
qualities: [75, 95],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: process.env.NEXT_IMAGE_DOMAIN || "cms.nasarek.dev",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Enable standalone output for Docker.
|
||||
output: "standalone",
|
||||
|
||||
// DevIndicators
|
||||
devIndicators: false,
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
1969
drupal/nextjs/package-lock.json
generated
Normal file
29
drupal/nextjs/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "nasarek-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev:debug": "NODE_OPTIONS='--inspect' next dev",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint",
|
||||
"docker:build": "docker build -t rnsrk/nextjs-frontend --build-arg NEXT_PUBLIC_DRUPAL_BASE_URL=https://cms.nasarek.dev .",
|
||||
"docker:up": "cd .. && docker compose up -d nextjs --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.574.0",
|
||||
"next": "^15.1",
|
||||
"next-drupal": "^2.0.0",
|
||||
"react": "^19.0",
|
||||
"react-dom": "^19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0",
|
||||
"@types/node": "^22.0",
|
||||
"@types/react": "^19.0",
|
||||
"@types/react-dom": "^19.0",
|
||||
"tailwindcss": "^4.0",
|
||||
"typescript": "^5.7"
|
||||
}
|
||||
}
|
||||
8
drupal/nextjs/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
BIN
drupal/nextjs/public/assets/icons/badoeynhausen.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
drupal/nextjs/public/assets/icons/boehler-research.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
drupal/nextjs/public/assets/icons/boldundbuendig.png
Normal file
|
After Width: | Height: | Size: 980 B |
1
drupal/nextjs/public/assets/icons/drupal.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="97.97727mm" height="128.01656mm" viewBox="0 0 277.73084 362.8816"><title>Drupal</title><g id="Livello_2" data-name="Livello 2"><g id="Livello_1-2" data-name="Livello 1"><path d="M196.00843,77.29177C170.47408,51.76951,146.11187,27.43962,138.86135,0c-7.25088,27.43962-31.617,51.76951-57.14709,77.29177C43.41893,115.56293,0,158.93748,0,223.99184,0,300.57855,62.291,362.8816,138.86135,362.8816c76.58243,0,138.86524-62.29879,138.86949-138.88976,0-65.05011-43.41537-108.42891-81.72241-146.70007M59.02214,256.34977c-8.51464-.28912-39.93878-54.453,18.35806-112.124L115.95754,186.365s2.36878,2.22706-.25751,4.92082c-9.2055,9.44134-48.44171,48.78732-53.31849,62.39221-1.0066,2.80815-2.47674,2.70194-3.3594,2.67175m79.84347,71.38635a47.759,47.759,0,0,1-47.75939-47.75938c0-12.09214,4.80716-22.86793,11.90389-31.54632,8.61161-10.53036,35.8491-40.148,35.8491-40.148s26.8205,30.05237,35.78481,40.04746a46.706,46.706,0,0,1,11.981,31.64684,47.75949,47.75949,0,0,1-47.75937,47.75938m91.41133-77.44876c-1.02935,2.25121-3.36439,6.00948-6.516,6.12421-5.6177.2046-6.218-2.67388-10.37017-8.819-9.116-13.49017-88.67067-96.63406-103.5507-112.71395-13.08844-14.143-1.84309-24.11392,3.37325-29.33914,6.54441-6.55613,25.6473-25.64732,25.6473-25.64732s56.96026,54.04379,80.68775,90.97055,15.55027,68.88013,10.72856,79.42469" style="fill:#475569"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
drupal/nextjs/public/assets/icons/eth-mpg.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
10
drupal/nextjs/public/assets/icons/github.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg width="98" height="96" viewBox="0 0 98 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_730_27126)">
|
||||
<path d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 6.69539e-07 48.9043 4.309e-07C21.8203 1.92261e-07 -1.9479e-07 22.1074 -4.3343e-07 49.1914C-6.20631e-07 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z" fill="#475569"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_730_27126">
|
||||
<rect width="98" height="96" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
drupal/nextjs/public/assets/icons/gnm.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
drupal/nextjs/public/assets/icons/leopoldina.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
3
drupal/nextjs/public/assets/icons/mastodon.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="74" height="79" viewBox="0 0 74 79" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M73.7014 17.4323C72.5616 9.05152 65.1774 2.4469 56.424 1.1671C54.9472 0.950843 49.3518 0.163818 36.3901 0.163818H36.2933C23.3281 0.163818 20.5465 0.950843 19.0697 1.1671C10.56 2.41145 2.78877 8.34604 0.903306 16.826C-0.00357854 21.0022 -0.100361 25.6322 0.068112 29.8793C0.308275 35.9699 0.354874 42.0498 0.91406 48.1156C1.30064 52.1448 1.97502 56.1419 2.93215 60.0769C4.72441 67.3445 11.9795 73.3925 19.0876 75.86C26.6979 78.4332 34.8821 78.8603 42.724 77.0937C43.5866 76.8952 44.4398 76.6647 45.2833 76.4024C47.1867 75.8033 49.4199 75.1332 51.0616 73.9562C51.0841 73.9397 51.1026 73.9184 51.1156 73.8938C51.1286 73.8693 51.1359 73.8421 51.1368 73.8144V67.9366C51.1364 67.9107 51.1302 67.8852 51.1186 67.862C51.1069 67.8388 51.0902 67.8184 51.0695 67.8025C51.0489 67.7865 51.0249 67.7753 50.9994 67.7696C50.9738 67.764 50.9473 67.7641 50.9218 67.7699C45.8976 68.9569 40.7491 69.5519 35.5836 69.5425C26.694 69.5425 24.3031 65.3699 23.6184 63.6327C23.0681 62.1314 22.7186 60.5654 22.5789 58.9744C22.5775 58.9477 22.5825 58.921 22.5934 58.8965C22.6043 58.8721 22.621 58.8505 22.6419 58.8336C22.6629 58.8167 22.6876 58.8049 22.714 58.7992C22.7404 58.7934 22.7678 58.794 22.794 58.8007C27.7345 59.9796 32.799 60.5746 37.8813 60.5733C39.1036 60.5733 40.3223 60.5733 41.5447 60.5414C46.6562 60.3996 52.0437 60.1408 57.0728 59.1694C57.1983 59.1446 57.3237 59.1233 57.4313 59.0914C65.3638 57.5847 72.9128 52.8555 73.6799 40.8799C73.7086 40.4084 73.7803 35.9415 73.7803 35.4523C73.7839 33.7896 74.3216 23.6576 73.7014 17.4323ZM61.4925 47.3144H53.1514V27.107C53.1514 22.8528 51.3591 20.6832 47.7136 20.6832C43.7061 20.6832 41.6988 23.2499 41.6988 28.3194V39.3803H33.4078V28.3194C33.4078 23.2499 31.3969 20.6832 27.3894 20.6832C23.7654 20.6832 21.9552 22.8528 21.9516 27.107V47.3144H13.6176V26.4937C13.6176 22.2395 14.7157 18.8598 16.9118 16.3545C19.1772 13.8552 22.1488 12.5719 25.8373 12.5719C30.1064 12.5719 33.3325 14.1955 35.4832 17.4394L37.5587 20.8853L39.6377 17.4394C41.7884 14.1955 45.0145 12.5719 49.2765 12.5719C52.9614 12.5719 55.9329 13.8552 58.2055 16.3545C60.4017 18.8574 61.4997 22.2371 61.4997 26.4937L61.4925 47.3144Z" fill="#475569"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
4
drupal/nextjs/public/assets/icons/nextjs.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M149.508 157.52L69.142 54H54V125.97H66.1136V69.3836L139.999 164.845C143.333 162.132 146.509 159.256 149.508 156.208V157.52Z" fill="#475569"/>
|
||||
<path d="M115 54H127V126H115V54Z" fill="#475569"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 288 B |
BIN
drupal/nextjs/public/assets/icons/objektsprache.png
Normal file
|
After Width: | Height: | Size: 726 B |
BIN
drupal/nextjs/public/assets/icons/re-cycle-halle.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
drupal/nextjs/public/assets/icons/roli-bar.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
drupal/nextjs/public/assets/icons/scs-manager.png
Normal file
|
After Width: | Height: | Size: 395 B |
4
drupal/nextjs/public/assets/icons/spotify.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 11.9976C3.5 7.30452 7.30452 3.5 11.9976 3.5C16.6908 3.5 20.4953 7.30452 20.4953 11.9976C20.4953 16.6908 16.6908 20.4953 11.9976 20.4953C7.30452 20.4953 3.5 16.6908 3.5 11.9976ZM11.9976 1.5C6.19995 1.5 1.5 6.19995 1.5 11.9976C1.5 17.7953 6.19995 22.4953 11.9976 22.4953C17.7953 22.4953 22.4953 17.7953 22.4953 11.9976C22.4953 6.19995 17.7953 1.5 11.9976 1.5ZM6.58886 7.81169C6.48624 7.26902 6.84298 6.74592 7.38565 6.64331C7.98535 6.54239 8.59286 6.49319 9.20055 6.47725C10.2721 6.44914 11.7394 6.52235 13.294 6.93892C14.8487 7.35549 16.1561 8.02575 17.07 8.58585C17.5854 8.90177 18.0975 9.24236 18.5579 9.63641C18.9714 9.99199 19.0204 10.6337 18.6645 11.0474C18.3047 11.4656 17.6743 11.5133 17.2556 11.1543C16.8767 10.829 16.4494 10.5513 16.0249 10.2911C15.2209 9.79833 14.0926 9.22344 12.7764 8.87077C11.4602 8.5181 10.1957 8.45183 9.25299 8.47656C8.75397 8.48965 8.24933 8.52191 7.7568 8.60857C7.21429 8.7109 6.69144 8.35421 6.58886 7.81169ZM7.57136 12.3409C7.05016 12.5151 6.48542 12.2358 6.30795 11.715C6.12983 11.1922 6.40923 10.624 6.93201 10.4459C7.43418 10.2752 7.97927 10.2036 8.50474 10.1603C9.44887 10.0825 10.7702 10.1141 12.3312 10.5324C13.8922 10.9507 15.0523 11.5839 15.831 12.1234C16.2699 12.4274 16.6857 12.7662 17.0502 13.1571C17.4139 13.5727 17.3718 14.2045 16.9562 14.5681C16.5403 14.932 15.9139 14.8769 15.5464 14.4756C15.5138 14.4403 15.2316 14.1411 14.6921 13.7674C14.0759 13.3406 13.1234 12.8152 11.8135 12.4642C10.5037 12.1133 9.41608 12.092 8.669 12.1535C8.29606 12.1843 7.93296 12.2481 7.57136 12.3409ZM6.47141 14.18C5.99179 14.4538 5.82495 15.0646 6.09878 15.5442C6.36807 16.0159 6.96329 16.1851 7.43912 15.9301C7.63939 15.8384 7.87055 15.8011 8.08687 15.7738C8.62406 15.7061 9.52907 15.7036 10.8507 16.0577C12.1723 16.4118 12.9548 16.8665 13.3862 17.1937C13.7115 17.4406 13.8457 17.6204 13.8677 17.6511C14.178 18.0878 14.738 18.272 15.2227 17.989C15.6996 17.7105 15.8605 17.0981 15.5821 16.6212C15.3193 16.2207 14.9758 15.8893 14.5949 15.6004C13.9395 15.1031 12.9176 14.541 11.3683 14.1259C9.81907 13.7107 8.65294 13.6866 7.8367 13.7895C7.36239 13.8493 6.89923 13.9646 6.47141 14.18Z" fill="#475569"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
687
drupal/nextjs/public/assets/icons/wisski.svg
Normal file
|
After Width: | Height: | Size: 62 KiB |
20
drupal/nextjs/public/assets/icons/youtube.svg
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="397.000000pt" height="278.000000pt" viewBox="0 0 397.000000 278.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.16, written by Peter Selinger 2001-2019
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,278.000000) scale(0.100000,-0.100000)"
|
||||
fill="#475569" stroke="none">
|
||||
<path d="M1297 2770 c-461 -16 -763 -44 -882 -81 -177 -54 -308 -207 -350
|
||||
-407 -86 -416 -86 -1369 1 -1791 43 -208 196 -370 395 -415 85 -20 305 -42
|
||||
574 -58 298 -17 1600 -17 1900 0 557 33 683 62 814 192 99 97 138 195 171 420
|
||||
32 225 44 429 44 760 0 414 -31 786 -80 950 -60 205 -219 338 -444 374 -115
|
||||
19 -282 33 -535 46 -257 14 -1313 20 -1608 10z m828 -1099 c259 -149 471 -275
|
||||
473 -279 2 -6 -585 -352 -950 -560 l-58 -33 0 591 0 592 33 -20 c17 -11 244
|
||||
-142 502 -291z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 984 B |
3
drupal/nextjs/public/assets/icons/zenodo.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 51.046 51.046" fill="#475569">
|
||||
<path d="m 28.324,20.044 c -0.043,-0.106 -0.084,-0.214 -0.131,-0.32 -0.707,-1.602 -1.656,-2.997 -2.848,-4.19 -1.188,-1.187 -2.582,-2.125 -4.184,-2.805 -1.605,-0.678 -3.309,-1.02 -5.104,-1.02 -1.85,0 -3.564,0.342 -5.137,1.02 -1.467,0.628 -2.764,1.488 -3.91,2.552 V 14.84 c 0,-1.557 -1.262,-2.822 -2.82,-2.822 h -19.775 c -1.557,0 -2.82,1.265 -2.82,2.822 0,1.559 1.264,2.82 2.82,2.82 h 15.541 l -18.23,24.546 c -0.362,0.487 -0.557,1.077 -0.557,1.682 v 1.841 c 0,1.558 1.264,2.822 2.822,2.822 H 5.038 c 1.488,0 2.705,-1.153 2.812,-2.614 0.932,0.743 1.967,1.364 3.109,1.848 1.605,0.684 3.299,1.021 5.102,1.021 2.723,0 5.15,-0.726 7.287,-2.187 1.727,-1.176 3.092,-2.639 4.084,-4.389 0.832799,-1.472094 1.418284,-2.633352 1.221889,-3.729182 -0.173003,-0.965318 -0.694914,-1.946419 -2.326865,-2.378358 -0.58,0 -1.376024,0.17454 -1.833024,0.49254 -0.463,0.316 -0.793,0.744 -0.982,1.275 l -0.453,0.93 c -0.631,1.365 -1.566,2.443 -2.809,3.244 -1.238,0.803 -2.633,1.201 -4.188,1.201 -1.023,0 -2.004,-0.191 -2.955,-0.579 -0.941,-0.39 -1.758,-0.935 -2.439,-1.64 C 9.986,40.343 9.441,39.526 9.027,38.603 8.617,37.679 8.41,36.71 8.41,35.687 v -2.476 h 17.715 c 0,0 1.517774,-0.15466 2.183375,-0.770672 0.958496,-0.887085 0.864622,-2.15038 0.864622,-2.15038 0,0 -0.04354,-5.066834 -0.338376,-7.578154 C 28.729048,21.812563 28.324,20.044 28.324,20.044 Z M -11.767,42.91 2.991,23.036 C 2.913,23.623 2.87,24.22 2.87,24.827 v 10.86 c 0,1.799 0.35,3.498 1.059,5.104 0.328,0.752 0.719,1.458 1.156,2.119 -0.016,0 -0.031,-10e-4 -0.047,-10e-4 H -11.767 Z M 23.71,27.667 H 8.409 v -2.841 c 0,-1.015 0.189,-1.99 0.58,-2.912 0.391,-0.922 0.936,-1.74 1.645,-2.444 0.697,-0.703 1.516,-1.249 2.438,-1.641 0.922,-0.388 1.92,-0.581 2.99,-0.581 1.02,0 2.002,0.193 2.949,0.581 0.949,0.393 1.764,0.938 2.441,1.641 0.682,0.704 1.225,1.521 1.641,2.444 0.414,0.922 0.617,1.896 0.617,2.912 z" transform="translate(20.35 -4.735)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
BIN
drupal/nextjs/public/assets/icons/zikg.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
drupal/nextjs/public/assets/images/autumn.png
Normal file
|
After Width: | Height: | Size: 407 KiB |
BIN
drupal/nextjs/public/assets/images/chaos.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
drupal/nextjs/public/assets/images/conference.png
Normal file
|
After Width: | Height: | Size: 637 KiB |
BIN
drupal/nextjs/public/assets/images/explaining.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
drupal/nextjs/public/assets/images/family.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
drupal/nextjs/public/assets/images/kuss.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
drupal/nextjs/public/assets/images/pres_1.png
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
drupal/nextjs/public/assets/images/robot.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
drupal/nextjs/public/assets/logos/lzfw_logo.png
Normal file
|
After Width: | Height: | Size: 274 KiB |
27
drupal/nextjs/tsconfig.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||