Add Drupal headless stack with Next.js frontend

- Add Next.js frontend service (nextjs) with Dockerfile and source
- Update docker-compose.yml: image names, Drupal 11.3.3, nextjs service
- Add docker-compose.override.yml.disabled for dev hot-reload
- Add install-headless-modules.sh for OAuth/JSON:API module setup
- Add README.md with full setup and configuration guide
- Update nginx/Dockerfile and nginx.conf.template for cms. subdomain
- Update drupal/Dockerfile PHP-FPM build args
- Gitignore **/.vscode/ to prevent IDE workspace files from being tracked
This commit is contained in:
rnsrk 2026-03-30 11:14:17 +02:00
parent 71a8dac389
commit f8b8f53d54
85 changed files with 7802 additions and 17 deletions

View file

@ -0,0 +1,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()
}
}

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 115 KiB

1517
drupal/nextjs/app/icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 574 KiB

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

View 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">
&copy; {new Date().getFullYear()} nasarek.dev
</div>
</div>
</footer>
<CookieBanner />
</body>
</html>
)
}

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

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

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