- 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
146 lines
4.6 KiB
TypeScript
146 lines
4.6 KiB
TypeScript
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>
|
|
)
|
|
}
|