summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIwanIDev <iwan@iwani.dev>2026-03-19 20:16:23 +0000
committerIwanIDev <iwan@iwani.dev>2026-03-19 20:16:23 +0000
commita706dcf6a9b91ef2c3d1e1d28449b9b8e0e8187d (patch)
treeca3ea838179472713e1e2d089813f0f39ac72adb
parent572a393440b39a838b99227ba2222a210a495fac (diff)
Add support for headless Drupal integration with environment variables and Docker setup
- Create .env.example for environment variable configuration - Update Dockerfile to accept Drupal-related build arguments - Enhance docker_build.yml to pass environment variables during Docker build - Add drupalClient and env configuration for API interaction - Implement local development instructions and Docker deployment steps in README - Add drupal and mariadb services to docker-stack.yml for complete setup - Update package.json and bun.lock to include axios and drupal-jsonapi-params dependencies - Add TypeScript definitions for new environment variables
-rw-r--r--.env.example3
-rw-r--r--.github/workflows/docker_build.yml4
-rw-r--r--.gitignore3
-rw-r--r--Dockerfile11
-rw-r--r--README.md125
-rw-r--r--bun.lock64
-rw-r--r--docker-stack.yml50
-rw-r--r--package.json2
-rw-r--r--src/config/env.ts23
-rw-r--r--src/lib/drupalClient.ts36
-rw-r--r--src/vite-env.d.ts11
11 files changed, 270 insertions, 62 deletions
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..df591d0
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,3 @@
+VITE_DRUPAL_BASE_URL=https://your-drupal-domain.tld
+VITE_DRUPAL_API_PREFIX=/jsonapi
+VITE_DRUPAL_AUTH_TOKEN=
diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml
index 608592c..76299c9 100644
--- a/.github/workflows/docker_build.yml
+++ b/.github/workflows/docker_build.yml
@@ -57,6 +57,10 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/arm64
provenance: false
+ build-args: |
+ VITE_DRUPAL_BASE_URL=${{ vars.VITE_DRUPAL_BASE_URL }}
+ VITE_DRUPAL_API_PREFIX=${{ vars.VITE_DRUPAL_API_PREFIX }}
+ VITE_DRUPAL_AUTH_TOKEN=${{ secrets.VITE_DRUPAL_AUTH_TOKEN }}
cache-from: type=gha
cache-to: type=gha,mode=max
diff --git a/.gitignore b/.gitignore
index a547bf3..b50664c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,9 @@ node_modules
dist
dist-ssr
*.local
+.env
+.env.*
+!.env.example
# Editor directories and files
.vscode/*
diff --git a/Dockerfile b/Dockerfile
index 8cd785f..c3bd0d5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,13 +3,22 @@ FROM --platform=$BUILDPLATFORM oven/bun:latest AS build
WORKDIR /app
+ARG VITE_DRUPAL_BASE_URL
+ARG VITE_DRUPAL_API_PREFIX=/jsonapi
+ARG VITE_DRUPAL_AUTH_TOKEN
+
+ENV VITE_DRUPAL_BASE_URL=${VITE_DRUPAL_BASE_URL}
+ENV VITE_DRUPAL_API_PREFIX=${VITE_DRUPAL_API_PREFIX}
+ENV VITE_DRUPAL_AUTH_TOKEN=${VITE_DRUPAL_AUTH_TOKEN}
+
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
-# Production stage - targets the deployment platform
+# Production stage
+
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
diff --git a/README.md b/README.md
index 86b2b11..5e16435 100644
--- a/README.md
+++ b/README.md
@@ -1,75 +1,78 @@
-# React + TypeScript + Vite
+# Vite Portfolio (Headless Drupal Frontend)
-This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+This project is configured to consume a headless Drupal backend (JSON:API) and deploy as a Docker image served by Nginx.
-Currently, two official plugins are available:
+## Added Drupal libraries
-- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
-- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+- `axios` for HTTP requests
+- `drupal-jsonapi-params` for building Drupal JSON:API query parameters
-## React Compiler
+## Environment variables
-The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
+Create a local env file from the template:
-Note: This will impact Vite dev & build performances.
+```bash
+cp .env.example .env.local
+```
+
+Available variables:
+
+- `VITE_DRUPAL_BASE_URL` (required) – base Drupal URL, for example `https://cms.example.com`
+- `VITE_DRUPAL_API_PREFIX` (optional) – defaults to `/jsonapi`
+- `VITE_DRUPAL_AUTH_TOKEN` (optional) – bearer token used by the HTTP client
+
+## Client utilities
+
+- Typed env config: `src/config/env.ts`
+- Reusable Drupal client: `src/lib/drupalClient.ts`
-## Expanding the ESLint configuration
+Example usage:
-If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
+```ts
+import { createDrupalParams, fetchDrupalResource } from './lib/drupalClient'
-```js
-export default defineConfig([
- globalIgnores(['dist']),
- {
- files: ['**/*.{ts,tsx}'],
- extends: [
- // Other configs...
+type NodeCollection = {
+ data: Array<{ id: string; attributes: Record<string, unknown> }>
+}
- // Remove tseslint.configs.recommended and replace with this
- tseslint.configs.recommendedTypeChecked,
- // Alternatively, use this for stricter rules
- tseslint.configs.strictTypeChecked,
- // Optionally, add this for stylistic rules
- tseslint.configs.stylisticTypeChecked,
+const params = createDrupalParams().addPageLimit(5).addFields('node--article', ['title'])
- // Other configs...
- ],
- languageOptions: {
- parserOptions: {
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
- tsconfigRootDir: import.meta.dirname,
- },
- // other options...
- },
- },
-])
+const articles = await fetchDrupalResource<NodeCollection>('/node/article', { params })
```
-You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
-
-```js
-// eslint.config.js
-import reactX from 'eslint-plugin-react-x'
-import reactDom from 'eslint-plugin-react-dom'
-
-export default defineConfig([
- globalIgnores(['dist']),
- {
- files: ['**/*.{ts,tsx}'],
- extends: [
- // Other configs...
- // Enable lint rules for React
- reactX.configs['recommended-typescript'],
- // Enable lint rules for React DOM
- reactDom.configs.recommended,
- ],
- languageOptions: {
- parserOptions: {
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
- tsconfigRootDir: import.meta.dirname,
- },
- // other options...
- },
- },
-])
+## Local development
+
+```bash
+bun install
+bun run dev
```
+
+## Docker build and run
+
+Pass Drupal variables at build time (Vite injects `VITE_*` during build):
+
+```bash
+docker build \
+ --build-arg VITE_DRUPAL_BASE_URL=https://cms.example.com \
+ --build-arg VITE_DRUPAL_API_PREFIX=/jsonapi \
+ --build-arg VITE_DRUPAL_AUTH_TOKEN=your-token \
+ -t vite-portfolio:latest .
+
+docker run --rm -p 8080:80 vite-portfolio:latest
+```
+
+## CI/CD deployment (GitHub Actions + Docker Swarm)
+
+The workflow in `.github/workflows/docker_build.yml` now forwards Drupal settings to Docker build args.
+
+Set these in your GitHub repository before deploying:
+
+- Repository variable: `VITE_DRUPAL_BASE_URL`
+- Repository variable: `VITE_DRUPAL_API_PREFIX` (optional)
+- Repository secret: `VITE_DRUPAL_AUTH_TOKEN` (optional)
+
+Existing deploy flow:
+
+1. Build and push image to GHCR on `main`
+2. SSH to the remote host
+3. `docker stack deploy -c docker-stack.yml vite-portfolio`
diff --git a/bun.lock b/bun.lock
index 87358b3..d04ae24 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,6 +6,8 @@
"name": "vite-portfolio-site",
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
+ "axios": "^1.13.6",
+ "drupal-jsonapi-params": "^3.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwindcss": "^4.2.1",
@@ -286,6 +288,10 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
+ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
+
+ "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
+
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
@@ -296,6 +302,10 @@
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
+ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
+
+ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
+
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="],
@@ -306,6 +316,8 @@
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
+
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -318,12 +330,26 @@
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
+ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
+
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+ "drupal-jsonapi-params": ["drupal-jsonapi-params@3.0.1", "", { "dependencies": { "qs": "^6.14.2" } }, "sha512-bK7MCgFEfoMxLCVrOfn/bibjU/R04h3MNR2KsEjLdyS9yQoHNO01zzP/v0yFFI7OpBv9sxUfxMx9OJfcl9IJAQ=="],
+
+ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
+
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
+ "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
+
+ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+
+ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
+
+ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
+
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -366,18 +392,36 @@
"flatted": ["flatted@3.3.4", "", {}, "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA=="],
+ "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
+
+ "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
+
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
+ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
+
+ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
+
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
+ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
+
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
+ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
+
+ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
+
+ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
@@ -446,6 +490,12 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+
+ "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+
+ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -456,6 +506,8 @@
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
+ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
+
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -476,8 +528,12 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+ "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
+
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+ "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
+
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
@@ -496,6 +552,14 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+ "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
+
+ "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
+
+ "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
+
+ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
+
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
diff --git a/docker-stack.yml b/docker-stack.yml
index 730e730..6512e2e 100644
--- a/docker-stack.yml
+++ b/docker-stack.yml
@@ -24,6 +24,56 @@ services:
networks:
- portfolio-network
+ drupal:
+ image: drupal:10-apache
+ ports:
+ - target: 80
+ published: 8281
+ protocol: tcp
+ mode: host
+ environment:
+ DRUPAL_DB_HOST: mariadb
+ DRUPAL_DB_PORT: 3306
+ DRUPAL_DB_NAME: ${DRUPAL_DB_NAME:-drupal}
+ DRUPAL_DB_USER: ${DRUPAL_DB_USER:-drupal}
+ DRUPAL_DB_PASSWORD: ${DRUPAL_DB_PASSWORD:-drupal}
+ volumes:
+ - drupal-sites:/var/www/html/sites
+ - drupal-modules:/var/www/html/modules
+ - drupal-themes:/var/www/html/themes
+ deploy:
+ replicas: 1
+ restart_policy:
+ condition: on-failure
+ labels:
+ - "app=drupal"
+ networks:
+ - portfolio-network
+
+ mariadb:
+ image: mariadb:11
+ environment:
+ MARIADB_DATABASE: ${DRUPAL_DB_NAME:-drupal}
+ MARIADB_USER: ${DRUPAL_DB_USER:-drupal}
+ MARIADB_PASSWORD: ${DRUPAL_DB_PASSWORD:-drupal}
+ MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-root}
+ volumes:
+ - mariadb-data:/var/lib/mysql
+ deploy:
+ replicas: 1
+ restart_policy:
+ condition: on-failure
+ labels:
+ - "app=drupal-db"
+ networks:
+ - portfolio-network
+
+volumes:
+ drupal-sites:
+ drupal-modules:
+ drupal-themes:
+ mariadb-data:
+
networks:
portfolio-network:
driver: overlay
diff --git a/package.json b/package.json
index 4f66904..7eaa64a 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,8 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
+ "axios": "^1.13.6",
+ "drupal-jsonapi-params": "^3.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwindcss": "^4.2.1"
diff --git a/src/config/env.ts b/src/config/env.ts
new file mode 100644
index 0000000..c530b3a
--- /dev/null
+++ b/src/config/env.ts
@@ -0,0 +1,23 @@
+const trimTrailingSlashes = (value: string): string => value.replace(/\/+$/, '')
+
+const normalizePathPrefix = (value: string): string => {
+ if (!value) {
+ return '/jsonapi'
+ }
+
+ return value.startsWith('/') ? value : `/${value}`
+}
+
+export const drupalEnv = {
+ baseUrl: trimTrailingSlashes(import.meta.env.VITE_DRUPAL_BASE_URL ?? ''),
+ apiPrefix: normalizePathPrefix(import.meta.env.VITE_DRUPAL_API_PREFIX ?? '/jsonapi'),
+ authToken: import.meta.env.VITE_DRUPAL_AUTH_TOKEN ?? '',
+}
+
+export const getDrupalApiBaseUrl = (): string => {
+ if (!drupalEnv.baseUrl) {
+ throw new Error('Missing VITE_DRUPAL_BASE_URL. Set it in your environment before using the Drupal client.')
+ }
+
+ return `${drupalEnv.baseUrl}${drupalEnv.apiPrefix}`
+}
diff --git a/src/lib/drupalClient.ts b/src/lib/drupalClient.ts
new file mode 100644
index 0000000..7a30729
--- /dev/null
+++ b/src/lib/drupalClient.ts
@@ -0,0 +1,36 @@
+import axios from 'axios'
+import type { AxiosRequestConfig } from 'axios'
+import { DrupalJsonApiParams } from 'drupal-jsonapi-params'
+import { drupalEnv, getDrupalApiBaseUrl } from '../config/env'
+
+export const drupalClient = axios.create({
+ baseURL: getDrupalApiBaseUrl(),
+ headers: {
+ Accept: 'application/vnd.api+json',
+ 'Content-Type': 'application/vnd.api+json',
+ },
+})
+
+drupalClient.interceptors.request.use((config) => {
+ if (drupalEnv.authToken) {
+ config.headers.Authorization = `Bearer ${drupalEnv.authToken}`
+ }
+
+ return config
+})
+
+export const createDrupalParams = (): DrupalJsonApiParams => new DrupalJsonApiParams()
+
+export const fetchDrupalResource = async <T>(
+ resourcePath: string,
+ options?: {
+ params?: DrupalJsonApiParams
+ config?: AxiosRequestConfig
+ },
+): Promise<T> => {
+ const queryString = options?.params?.getQueryString()
+ const url = queryString ? `${resourcePath}?${queryString}` : resourcePath
+ const { data } = await drupalClient.get<T>(url, options?.config)
+
+ return data
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..55d593d
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1,11 @@
+/// <reference types="vite/client" />
+
+interface ImportMetaEnv {
+ readonly VITE_DRUPAL_BASE_URL?: string
+ readonly VITE_DRUPAL_API_PREFIX?: string
+ readonly VITE_DRUPAL_AUTH_TOKEN?: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}