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
146
drupal/nextjs/components/home-services.tsx
Normal file
146
drupal/nextjs/components/home-services.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { Book, Brain, Calendar, Cpu, Code2, Database, Rocket, Unplug, Wrench } from "lucide-react"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { drupal } from "@/lib/drupal"
|
||||
import type { DrupalServiceNode } from "@/lib/types"
|
||||
import { ScrollRevealCard } from "@/components/scroll-reveal-card"
|
||||
|
||||
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
coordination: Calendar,
|
||||
data_processing: Cpu,
|
||||
deployment: Rocket,
|
||||
development: Code2,
|
||||
documentation: Book,
|
||||
interface_api: Unplug,
|
||||
interface_and_api: Unplug,
|
||||
maintainance: Wrench,
|
||||
maintenance: Wrench,
|
||||
modelling: Database,
|
||||
ai: Brain,
|
||||
}
|
||||
|
||||
function toIconKey(type: string): string {
|
||||
return type.toLowerCase().replace(/\s+/g, "_").replace(/-/g, "_")
|
||||
}
|
||||
|
||||
function getIcon(serviceType: string | undefined): LucideIcon {
|
||||
if (!serviceType) return Database
|
||||
const key = toIconKey(serviceType)
|
||||
return ICON_MAP[key] ?? Database
|
||||
}
|
||||
|
||||
function stripHtml(html: string | undefined): string {
|
||||
if (!html) return ""
|
||||
return html.replace(/<[^>]*>/g, "").trim()
|
||||
}
|
||||
|
||||
async function getServices(): Promise<
|
||||
{ label: string; body: string; icon: LucideIcon }[]
|
||||
> {
|
||||
if (!drupalBaseUrl) return []
|
||||
|
||||
try {
|
||||
let raw: { data?: DrupalServiceNode[] } | null = null
|
||||
try {
|
||||
raw = await drupal.getResourceCollection<{
|
||||
data: DrupalServiceNode[]
|
||||
}>("node--service", {
|
||||
params: {
|
||||
"filter[status]": "1",
|
||||
sort: "created",
|
||||
},
|
||||
deserialize: false,
|
||||
next: { revalidate: 60 },
|
||||
})
|
||||
} catch (firstError) {
|
||||
const msg = (firstError as Error).message ?? ""
|
||||
if (msg.includes("Unauthorized")) {
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
raw = await drupal.getResourceCollection<{
|
||||
data: DrupalServiceNode[]
|
||||
}>("node--service", {
|
||||
params: {
|
||||
"filter[status]": "1",
|
||||
sort: "created",
|
||||
},
|
||||
deserialize: false,
|
||||
next: { revalidate: 60 },
|
||||
})
|
||||
} else {
|
||||
throw firstError
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = raw?.data ?? []
|
||||
if (!nodes.length) return []
|
||||
|
||||
return nodes.map((node) => {
|
||||
const bodyObj = node.body
|
||||
const bodyText =
|
||||
typeof bodyObj === "string"
|
||||
? stripHtml(bodyObj)
|
||||
: stripHtml(bodyObj?.value ?? bodyObj?.processed)
|
||||
|
||||
return {
|
||||
body: bodyText,
|
||||
icon: getIcon(node.field__service__type),
|
||||
label: node.title ?? "",
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
console.warn(
|
||||
"[HomeServices] CMS unreachable:",
|
||||
(error as Error).message
|
||||
)
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function HomeServices() {
|
||||
const services = await getServices()
|
||||
|
||||
return (
|
||||
<section className="home-services py-10" aria-labelledby="services-heading">
|
||||
<div className="home-services-header mb-6 text-center">
|
||||
<h2
|
||||
id="services-heading"
|
||||
className="home-services-title mb-3 font-bold tracking-tight text-slate-900"
|
||||
style={{ fontSize: "var(--fluid-section-title)" }}
|
||||
>
|
||||
Services
|
||||
</h2>
|
||||
<p className="home-services-description mx-auto max-w-3xl text-slate-600" style={{ fontSize: "var(--fluid-hero-desc)" }}>
|
||||
Data engineering, development, deployment, and ongoing support for
|
||||
your projects.
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-services-grid mx-2 flex flex-col gap-5 sm:mx-4 lg:mx-6">
|
||||
{services.map(({ label, body, icon: Icon }) => (
|
||||
<ScrollRevealCard key={label}>
|
||||
<div
|
||||
className="home-services-card mx-auto flex max-w-[1000px] min-h-0 w-full flex-col items-center justify-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-5 py-5 transition-colors hover:border-emerald-200 hover:bg-emerald-50/50 sm:min-h-[7rem] sm:flex-row sm:gap-10 sm:px-10 sm:py-8"
|
||||
>
|
||||
<Icon
|
||||
className="home-services-card-icon size-8 shrink-0 text-emerald-600 sm:size-10 lg:size-12"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col items-center justify-center gap-1.5 text-center sm:gap-2">
|
||||
<span className="home-services-card-label text-lg font-semibold text-slate-800 sm:text-xl lg:text-2xl">
|
||||
{label}
|
||||
</span>
|
||||
{body && (
|
||||
<span className="home-services-card-body text-sm leading-snug text-slate-600 sm:text-base lg:text-lg">
|
||||
{body}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollRevealCard>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue