Add Drupal headless stack with Next.js frontend
- Add Next.js frontend service (nextjs) with Dockerfile and source - Update docker-compose.yml: image names, Drupal 11.3.3, nextjs service - Add docker-compose.override.yml.disabled for dev hot-reload - Add install-headless-modules.sh for OAuth/JSON:API module setup - Add README.md with full setup and configuration guide - Update nginx/Dockerfile and nginx.conf.template for cms. subdomain - Update drupal/Dockerfile PHP-FPM build args - Gitignore **/.vscode/ to prevent IDE workspace files from being tracked
This commit is contained in:
parent
71a8dac389
commit
f8b8f53d54
85 changed files with 7802 additions and 17 deletions
89
drupal/nextjs/app/[...slug]/page.tsx
Normal file
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
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
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
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
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
BIN
drupal/nextjs/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
307
drupal/nextjs/app/globals.css
Normal file
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
1
drupal/nextjs/app/icon-robot-optimized.svg
Normal file
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
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 |
106
drupal/nextjs/app/imprint/page.tsx
Normal file
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
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
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
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
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue