diff --git a/.gitignore b/.gitignore index 97d8377..a62cf45 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # General bkp/* .cursor +**/.vscode/ .env **/.env *.log diff --git a/drupal/README.md b/drupal/README.md new file mode 100644 index 0000000..a60bd08 --- /dev/null +++ b/drupal/README.md @@ -0,0 +1,425 @@ +# Headless Drupal Stack + +Drupal 11 backend for Next.js frontend with JSON:API, OAuth authentication, and Redis caching. + +## Quick Start + +### 1. Setup Shell Aliases (Optional but Recommended) +```bash +cd /var/deploy/drupal +./setup-aliases.sh +source ~/.bash-aliases +``` + +This creates convenient shortcuts in `~/.bash-aliases`: +- `ddrush` - Run Drush commands +- `dcomposer` - Run Composer commands +- `dphp` - Run PHP commands +- `dshell` - Open bash shell in container +- `dlogs` - View PHP-FPM logs (follow mode) +- `dredis` - Access Redis CLI + +### 2. Start Services +```bash +cd /var/deploy +docker compose -f drupal/docker-compose.yml up -d +``` + +### 3. Install Headless Modules +```bash +cd /var/deploy/drupal +./install-headless-modules.sh +``` + +### 4. Enable Core Modules +```bash +# With alias +ddrush en next jsonapi_extras simple_oauth consumers pathauto cors decoupled_router -y + +# Without alias +docker exec drupal-fpm bash -c "cd /opt/drupal && drush en next jsonapi_extras simple_oauth consumers pathauto cors decoupled_router -y" +``` + +### 5. Generate OAuth Keys +```bash +docker exec drupal-fpm bash -c 'cd /opt/drupal && mkdir -p oauth/keys && openssl genrsa -out oauth/keys/private.key 2048 && openssl rsa -in oauth/keys/private.key -pubout -out oauth/keys/public.key && chown www-data:www-data oauth/keys/private.key oauth/keys/public.key && chmod 640 oauth/keys/private.key oauth/keys/public.key' +``` +(PHP-FPM runs as www-data; keys must be readable by it.) + +Add to `drupal/root/web/sites/default/settings.php`: +```php +$settings['simple_oauth.keys'] = [ + 'public' => '/opt/drupal/oauth/keys/public.key', + 'private' => '/opt/drupal/oauth/keys/private.key', +]; +``` + +### 6. Configure Redis Cache +Add to `drupal/root/web/sites/default/settings.php`: +```php +// Redis Configuration. +$settings['redis.connection']['interface'] = 'PhpRedis'; +$settings['redis.connection']['host'] = 'drupal-redis'; +$settings['redis.connection']['port'] = 6379; +$settings['cache']['default'] = 'cache.backend.redis'; +$settings['cache']['bins']['bootstrap'] = 'cache.backend.chainedfast'; +$settings['cache']['bins']['discovery'] = 'cache.backend.chainedfast'; +$settings['cache']['bins']['config'] = 'cache.backend.chainedfast'; +``` + +## Stack Components + +### Containers +- **drupal-reverse-proxy**: NGINX reverse proxy (port 80) +- **drupal-fpm**: PHP 8.4 FPM with Drupal 11 +- **drupal-redis**: Redis 7 cache backend + +### Key Modules +- `drupal/next` - Next.js integration +- `drupal/jsonapi_extras` - JSON:API enhancements +- `drupal/simple_oauth` - OAuth 2.0 authentication +- `drupal/pathauto` - URL aliases +- `drupal/cors` - CORS headers +- `drupal/decoupled_router` - Route resolution + +## Common Commands + +### Shell Aliases (Recommended) +After running `./setup-aliases.sh`: + +```bash +# Composer commands +dcomposer require drupal/module_name +dcomposer update +dcomposer show + +# Drush commands +ddrush cr # Clear cache +ddrush en module_name -y # Enable module +ddrush updb -y # Database updates +ddrush cex -y # Export configuration +ddrush cim -y # Import configuration +ddrush status # Site status + +# PHP commands +dphp -v # PHP version +dphp -m # PHP modules +dphp -i # PHP info + +# Container access +dshell # Open bash shell +dlogs # View logs +dredis ping # Test Redis +``` + +### Without Aliases (Full Commands) + +#### Composer +```bash +# Install module +docker exec drupal-fpm bash -c "cd /opt/drupal && composer require drupal/module_name" + +# Update dependencies +docker exec drupal-fpm bash -c "cd /opt/drupal && composer update" + +# Show installed packages +docker exec drupal-fpm bash -c "cd /opt/drupal && composer show" +``` + +#### Drush +```bash +# Clear cache +docker exec drupal-fpm bash -c "cd /opt/drupal && drush cr" + +# Enable module +docker exec drupal-fpm bash -c "cd /opt/drupal && drush en module_name -y" + +# Database updates +docker exec drupal-fpm bash -c "cd /opt/drupal && drush updb -y" + +# Export configuration +docker exec drupal-fpm bash -c "cd /opt/drupal && drush cex -y" + +# Import configuration +docker exec drupal-fpm bash -c "cd /opt/drupal && drush cim -y" + +# Site status +docker exec drupal-fpm bash -c "cd /opt/drupal && drush status" +``` + +### Container Management +```bash +# View logs +docker logs drupal-fpm +docker logs drupal-reverse-proxy + +# Access container shell +docker exec -it drupal-fpm bash + +# Restart services +docker compose -f drupal/docker-compose.yml restart + +# Rebuild container +docker compose -f drupal/docker-compose.yml up -d --build +``` + +### Redis +```bash +# With alias +dredis ping # Test connection +dredis monitor # Monitor Redis +dredis keys '*' # Check keys +dredis flushall # Clear cache + +# Without alias +docker exec drupal-redis redis-cli ping +docker exec drupal-redis redis-cli monitor +docker exec drupal-redis redis-cli keys '*' +docker exec drupal-redis redis-cli flushall +``` + +## Configuration + +### Next.js Site Configuration +1. Navigate to `/admin/config/services/next` +2. Add Next.js site: + - Label: Production Frontend + - Base URL: `https://nasarek.dev` + - Preview URL: `https://nasarek.dev/api/preview` + - Preview Secret: Generate secure random string +3. The env page (`/admin/config/services/next/sites/{site_id}/env`) shows a template; `DRUPAL_CLIENT_ID` and `DRUPAL_CLIENT_SECRET` will say "Retrieve this from /admin/config/services/consumer" — get the actual Client ID and secret from the OAuth consumer (see OAuth Consumer section below). + +**Preview secret expired:** If you see "The provided secret has expired", increase the secret lifetime (default is 30 seconds): +- **Via UI:** `/admin/config/services/next/settings` → Preview URL generator → Simple OAuth → Secret expiration time → set to 300 (5 min) or 3600 (1 h) +- **Via Drush:** `ddrush cset next.settings preview_url_generator_configuration.secret_expiration 300 -y` + +### CORS Configuration +1. Navigate to `/admin/config/services/cors` +2. Add configuration: + - Enabled: ✓ + - Domains: `https://nasarek.dev|*` + - Methods: `GET|POST|OPTIONS` + - Headers: `Authorization|Content-Type|*` + - Credentials: ✓ + +### OAuth Consumer + +**1. Create a dynamic scope** (required; Drupal rejects token requests without a valid scope): +- Navigate to `/admin/config/people/simple_oauth/oauth2_scope/dynamic/add` +- Role: Authenticated user (or a dedicated role) +- Granularity: Role +- Grant Types: Enable **Client credentials** +- Description: e.g. Next.js Frontend +- Machine-readable name: e.g. `nextjs_frontend` +- Save + +**2. Create or edit a consumer:** +- Navigate to `/admin/config/services/consumer` (or add at `/admin/config/services/consumer/add`) +- Label: e.g. Next.js Frontend (or use existing default_consumer) +- **Client ID**: Use the **Client ID** column value from the consumer list (e.g. `lcXmAvaCtkjDhgKsLeAfUucOibVwUxKXtla8b_4CC4w`), **not** the UUID or label +- **Secret**: Not shown in the list; edit the consumer to generate or regenerate it +- Grant Types: Enable **Client credentials** +- Scopes: Select the scope created in step 1 (e.g. `nextjs_frontend`) +- Redirect URI: `https://nasarek.dev` +- Is 3rd party?: Leave **unchecked** (Next.js is your own frontend) +- Save + +**3. Add to `nextjs/.env.local`:** +``` +DRUPAL_CLIENT_ID= +DRUPAL_CLIENT_SECRET= +DRUPAL_OAUTH_SCOPE= +``` + +**4. Recreate Next.js container** (env vars are read at container start): +```bash +cd /var/deploy/drupal && docker compose -f docker-compose.yml up -d nextjs --force-recreate +``` + +**Verify OAuth:** +```bash +cd /var/deploy/drupal && docker compose -f docker-compose.yml exec nextjs node -e " +const b=process.env.NEXT_PUBLIC_DRUPAL_BASE_URL,i=process.env.DRUPAL_CLIENT_ID,s=process.env.DRUPAL_CLIENT_SECRET,scope=process.env.DRUPAL_OAUTH_SCOPE; +const body=new URLSearchParams({grant_type:'client_credentials',client_id:i,client_secret:s}); +if(scope) body.set('scope',scope); +fetch(b+'/oauth/token',{method:'POST',body}).then(r=>r.json()).then(j=>console.log(j.access_token?'OK':JSON.stringify(j))); +" +``` + +### Next.js Environment Variables +Required in `nextjs/.env.local` (loaded via docker-compose `env_file`): + +| Variable | Required | Description | +|----------|----------|-------------| +| `NEXT_PUBLIC_DRUPAL_BASE_URL` | Yes | Drupal JSON:API base URL (e.g. `https://cms.nasarek.dev`) | +| `DRUPAL_CLIENT_ID` | Yes* | OAuth consumer Client ID (from consumer list, not UUID) | +| `DRUPAL_CLIENT_SECRET` | Yes* | OAuth consumer secret from edit form | +| `DRUPAL_OAUTH_SCOPE` | Yes* | OAuth scope machine name (e.g. `nextjs_frontend`) | +| `DRUPAL_PREVIEW_SECRET` | No | For draft/preview mode webhook | +| `DRUPAL_REVALIDATE_SECRET` | No | For on-demand revalidation webhook | + +\* Without OAuth credentials, JSON:API requests run as anonymous; protected content will return "Access denied". Use `--force-recreate` after changing env vars. + +### JSON:API Configuration +1. Navigate to `/admin/config/services/jsonapi/extras` +2. Configure resource types: + - Disable unwanted resources + - Rename fields for frontend convenience + - Configure includes and sparse fieldsets + +## JSON:API Endpoints + +### Examples +```bash +# Get all articles +curl https://cms.nasarek.dev/jsonapi/node/article + +# Get specific article +curl https://cms.nasarek.dev/jsonapi/node/article/{uuid} + +# Filter by status +curl https://cms.nasarek.dev/jsonapi/node/article?filter[status]=1 + +# Include related entities +curl https://cms.nasarek.dev/jsonapi/node/article?include=field_image,uid + +# Sparse fieldsets +curl https://cms.nasarek.dev/jsonapi/node/article?fields[node--article]=title,body,created + +# Sort and paginate +curl https://cms.nasarek.dev/jsonapi/node/article?sort=-created&page[limit]=10&page[offset]=0 +``` + +### With Authentication +```bash +# Get access token (client credentials - used by next-drupal) +curl -X POST https://cms.nasarek.dev/oauth/token \ + -d "grant_type=client_credentials" \ + -d "client_id=YOUR_CLIENT_UUID" \ + -d "client_secret=YOUR_SECRET" + +# Use token +curl https://cms.nasarek.dev/jsonapi/node/article \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +## Debugging + +### Enable Xdebug +Xdebug is pre-configured and enabled: +- Mode: debug +- Client host: host.docker.internal +- Client port: 9000 +- IDE key: VSCODE +- Log: `/var/log/xdebug.log` + +### View Logs +```bash +# PHP-FPM logs +docker logs drupal-fpm + +# NGINX logs +docker logs drupal-reverse-proxy + +# Xdebug log +docker exec drupal-fpm tail -f /var/log/xdebug.log +``` + +### Check PHP Configuration +```bash +# PHP version +docker exec drupal-fpm php -v + +# PHP modules +docker exec drupal-fpm php -m + +# PHP info +docker exec drupal-fpm php -i + +# OPcache status +docker exec drupal-fpm php -r "echo json_encode(opcache_get_status(), JSON_PRETTY_PRINT);" +``` + +## Troubleshooting + +### Module not found +```bash +# Check if module is installed +dcomposer show drupal/module_name + +# Install if missing +dcomposer require drupal/module_name +``` + +### Clear all caches +```bash +# Drupal cache +ddrush cr + +# Redis cache +docker exec drupal-redis redis-cli flushall + +# OPcache (restart PHP-FPM) +docker compose -f drupal/docker-compose.yml restart drupal-fpm +``` + +### Permissions issues +```bash +# Fix file permissions +docker exec drupal-fpm chown -R www-data:www-data /opt/drupal/web/sites/default/files + +# Settings.php permissions +docker exec drupal-fpm chmod 444 /opt/drupal/web/sites/default/settings.php +``` + +## File Structure +``` +drupal/ +├── docker-compose.yml # Service definitions +├── install-headless-modules.sh # Module installation script +├── drupal/ +│ ├── Dockerfile # PHP-FPM image +│ └── root/ # Drupal codebase (mounted volume) +│ ├── composer.json +│ ├── web/ # Document root +│ │ ├── core/ +│ │ ├── modules/ +│ │ │ ├── contrib/ +│ │ │ └── custom/ +│ │ ├── themes/ +│ │ └── sites/ +│ └── oauth/ +│ └── keys/ # OAuth RSA keys +└── nginx/ + ├── Dockerfile + └── nginx.conf.template # NGINX configuration +``` + +## Development Workflow + +1. Make changes to code in `drupal/root/` +2. Clear cache: `ddrush cr` (or `docker exec drupal-fpm bash -c "cd /opt/drupal && drush cr"`) +3. Export config: `ddrush cex -y` (or `docker exec drupal-fpm bash -c "cd /opt/drupal && drush cex -y"`) +4. Commit changes to git +5. Deploy: Pull changes and run `ddrush cim -y` on production + +## Production Checklist + +- [ ] Set `opcache.validate_timestamps=0` in production +- [ ] Configure Redis cache +- [ ] Generate OAuth keys +- [ ] Configure CORS restrictively +- [ ] Set up content revalidation webhooks +- [ ] Configure rate limiting +- [ ] Enable HTTPS only +- [ ] Set secure session cookies +- [ ] Configure backup strategy +- [ ] Monitor logs and performance +- [ ] Test JSON:API endpoints with different user roles + +## Resources + +- [Next.js for Drupal](https://next-drupal.org/) +- [JSON:API Documentation](https://www.drupal.org/docs/core-modules-and-themes/core-modules/jsonapi-module) +- [Simple OAuth](https://www.drupal.org/project/simple_oauth) +- [Drupal.org](https://www.drupal.org/) diff --git a/drupal/docker-compose.override.yml.disabled b/drupal/docker-compose.override.yml.disabled new file mode 100644 index 0000000..36c9604 --- /dev/null +++ b/drupal/docker-compose.override.yml.disabled @@ -0,0 +1,27 @@ +# Development overrides: bind mounts for Next.js hot reload. +# Loaded automatically by Docker Compose. Remove or rename to disable. +services: + nextjs: + build: + context: ./nextjs + dockerfile: Dockerfile + target: development + args: + NEXT_PUBLIC_DRUPAL_BASE_URL: https://cms.${DOMAIN} + volumes: + - ./nextjs:/app + - nextjs-node-modules:/app/node_modules + - nextjs-next-cache:/app/.next + ports: + - "9229:9229" + environment: + - NODE_ENV=production + - HOSTNAME=0.0.0.0 + - WATCHPACK_POLLING=false + - NODE_OPTIONS=--inspect=0.0.0.0:9229 + +volumes: + nextjs-node-modules: + name: drupal-nextjs-node-modules + nextjs-next-cache: + name: drupal-nextjs-next-cache diff --git a/drupal/docker-compose.yml b/drupal/docker-compose.yml index 08081e7..56f9a82 100644 --- a/drupal/docker-compose.yml +++ b/drupal/docker-compose.yml @@ -1,6 +1,7 @@ services: + # Drupal CMS NGINX reverse proxy (cms.nasarek.dev). nginx: - image: drupal-nginx + image: rnsrk/drupal-nginx build: context: ./nginx dockerfile: Dockerfile @@ -10,7 +11,7 @@ services: labels: - traefik.enable=true - traefik.docker.network=traefik - - traefik.http.routers.drupal-reverse-proxy.rule=Host(`${DOMAIN}`) + - traefik.http.routers.drupal-reverse-proxy.rule=Host(`cms.${DOMAIN}`) - traefik.http.routers.drupal-reverse-proxy.entrypoints=web,websecure - traefik.http.routers.drupal-reverse-proxy.middlewares=https-redirect - traefik.http.routers.drupal-reverse-proxy.tls=true @@ -22,13 +23,14 @@ services: - traefik - drupal + # Drupal PHP-FPM backend. drupal-fpm: - image: drupal-php8-4-fpm-bookworm + image: rnsrk/drupal-php8-4-fpm-bookworm build: context: ./drupal dockerfile: Dockerfile args: - DRUPAL_VERSION: ${DRUPAL_VERSION:-11.1.6} + DRUPAL_VERSION: ${DRUPAL_VERSION:-11.3.3} labels: - traefik.enable=false container_name: drupal-fpm @@ -40,12 +42,11 @@ services: - database - drupal + # Redis cache backend. redis: image: redis:7-alpine container_name: drupal-redis command: redis-server --loglevel warning - environment: - - OVERC volumes: - redis-data:/data networks: @@ -57,6 +58,37 @@ services: retries: 5 start_period: 10s + # Next.js frontend (nasarek.dev). + nextjs: + image: rnsrk/nextjs-frontend + build: + context: ./nextjs + dockerfile: Dockerfile + args: + NEXT_PUBLIC_DRUPAL_BASE_URL: https://cms.${DOMAIN} + DRUPAL_CLIENT_ID: ${DRUPAL_CLIENT_ID} + DRUPAL_CLIENT_SECRET: ${DRUPAL_CLIENT_SECRET} + DRUPAL_OAUTH_SCOPE: ${DRUPAL_OAUTH_SCOPE:-} + + container_name: nextjs-frontend + labels: + - traefik.enable=true + - traefik.docker.network=traefik + - traefik.http.routers.nextjs-frontend.rule=Host(`${DOMAIN}`) + - traefik.http.routers.nextjs-frontend.entrypoints=web,websecure + - traefik.http.routers.nextjs-frontend.middlewares=https-redirect + - traefik.http.routers.nextjs-frontend.tls=true + - traefik.http.routers.nextjs-frontend.tls.certresolver=le + - traefik.http.services.nextjs-frontend.loadbalancer.server.port=3000 + env_file: + - ./nextjs/.env.local + networks: + - traefik + - drupal + depends_on: + - drupal-fpm + restart: unless-stopped + volumes: redis-data: name: drupal-redis-data diff --git a/drupal/drupal/Dockerfile b/drupal/drupal/Dockerfile index b11e33e..4c7a8c3 100644 --- a/drupal/drupal/Dockerfile +++ b/drupal/drupal/Dockerfile @@ -1,7 +1,8 @@ -ARG DRUPAL_VERSION +ARG DRUPAL_VERSION=11.3.3 FROM drupal:${DRUPAL_VERSION}-php8.4-fpm-bookworm +ARG NODE_ENV=production RUN apt-get update && apt-get install -y \ git \ vim \ @@ -14,13 +15,13 @@ RUN set -eux; \ docker-php-ext-install uploadprogress; \ rm -rf /usr/src/php/ext/uploadprogress; -# Install apcu +# Install apcu. RUN set -eux; \ - pecl install apcu; + pecl install apcu; \ + docker-php-ext-enable apcu; -# Add php configs +# Configure apcu (extension already loaded by docker-php-ext-enable). RUN { \ - echo 'extension=apcu.so'; \ echo "apc.enable_cli=1"; \ echo "apc.enable=1"; \ echo "apc.shm_size=32M"; \ @@ -30,3 +31,82 @@ RUN { \ RUN { \ echo 'output_buffering = on'; \ } >> /usr/local/etc/php/conf.d/zz-drupal-recommended.ini; + +# Enable Xdebug in development environment. +RUN if [ "$NODE_ENV" = "development" ]; then \ + set -eux; \ + pecl install xdebug; \ + docker-php-ext-enable xdebug; \ +fi; + +# Configure Xdebug in development environment. +# Configure xdebug (extension already loaded by docker-php-ext-enable). +RUN if [ "$NODE_ENV" = "development" ]; then \ + { \ + echo 'xdebug.mode=debug'; \ + echo 'xdebug.start_with_request=yes'; \ + echo 'xdebug.client_host=host.docker.internal'; \ + echo 'xdebug.client_port=9000'; \ + echo 'xdebug.idekey=VSCODE'; \ + echo 'xdebug.log=/var/log/xdebug.log'; \ + } >> /usr/local/etc/php/conf.d/zz-xdebug-custom.ini; \ +fi + +# Install and enable opcache. +RUN set -eux; \ + docker-php-ext-install opcache; \ + docker-php-ext-enable opcache + +# Configure opcache: dev (revalidate on) vs production (revalidate off). +RUN if [ "$NODE_ENV" = "development" ]; then \ + { \ + echo 'opcache.enable=1'; \ + echo 'opcache.enable_cli=1'; \ + echo 'opcache.memory_consumption=128'; \ + echo 'opcache.interned_strings_buffer=16'; \ + echo 'opcache.max_accelerated_files=10000'; \ + echo 'opcache.save_comments=1'; \ + echo 'opcache.validate_timestamps=1'; \ + echo 'opcache.revalidate_freq=0'; \ + echo 'opcache.fast_shutdown=1'; \ + } > /usr/local/etc/php/conf.d/zz-opcache-custom.ini; \ +else \ + { \ + echo 'opcache.enable=1'; \ + echo 'opcache.enable_cli=0'; \ + echo 'opcache.memory_consumption=256'; \ + echo 'opcache.interned_strings_buffer=32'; \ + echo 'opcache.max_accelerated_files=20000'; \ + echo 'opcache.save_comments=1'; \ + echo 'opcache.validate_timestamps=0'; \ + echo 'opcache.fast_shutdown=1'; \ + } > /usr/local/etc/php/conf.d/zz-opcache-custom.ini; \ +fi + +# Install Redis PHP extension. +RUN set -eux; \ + pecl install redis; \ + docker-php-ext-enable redis; + +# Install Node.js via NVM for frontend tooling. +ENV NVM_DIR=/root/.nvm +RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash \ + && . "$NVM_DIR/nvm.sh" \ + && nvm install --lts \ + && nvm alias default 'lts/*' \ + && nvm use default \ + && node -v \ + && npm -v + +# Production PHP settings (disable display_errors for clean JSON:API responses). +RUN { \ + echo 'display_errors = Off'; \ + echo 'log_errors = On'; \ + echo 'error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT'; \ + } >> /usr/local/etc/php/conf.d/zz-production.ini + +# Note: Drupal modules should be installed via composer in the project directory. +# The /opt/drupal directory is mounted as a volume, so composer require. +# commands here would be lost on container start. +# Install modules by running: +# docker exec drupal-fpm bash -c "cd /opt/drupal && composer require drupal/module_name" diff --git a/drupal/install-headless-modules.sh b/drupal/install-headless-modules.sh new file mode 100755 index 0000000..1cb7480 --- /dev/null +++ b/drupal/install-headless-modules.sh @@ -0,0 +1,65 @@ +#!/bin/bash +set -e + +echo "Installing Headless Drupal modules..." + +# Check if aliases are available. +if command -v dcomposer &> /dev/null; then + COMPOSER_CMD="dcomposer" + DRUSH_CMD="ddrush" +else + COMPOSER_CMD="docker exec drupal-fpm composer" + DRUSH_CMD="docker exec drupal-fpm drush" +fi + +# Core headless modules. +echo "→ Installing core headless modules..." +$COMPOSER_CMD require \ + drupal/next \ + drupal/jsonapi_extras \ + drupal/simple_oauth \ + drupal/pathauto \ + +# Additional JSON:API enhancements. +echo "→ Installing JSON:API enhancement modules..." +$COMPOSER_CMD require \ + drupal/subrequests \ + drupal/decoupled_router \ + drupal/consumers \ + drupal/jsonapi_menu_items \ + drupal/jsonapi_include \ + drupal/jsonapi_resources \ + drupal/jsonapi_menu_items + +# Content and media. +echo "→ Installing content and media modules..." +$COMPOSER_CMD require \ + drupal/metatag \ + drupal/redirect \ + drupal/field_group + +# Performance and caching. +echo "→ Installing performance modules..." +$COMPOSER_CMD require \ + drupal/redis + +# Development tools. +echo "→ Installing development tools..." +$COMPOSER_CMD require --dev \ + drupal/coder \ + drupal/devel \ + drupal/restui \ + drupal/admin_toolbar + +# "Advanced" modules. +echo "→ Installing advanced modules..." +$COMPOSER_CMD require \ + drupal/pathauto + +echo "✓ All modules installed successfully!" +echo "" +echo "Next steps:" +echo "1. Enable modules: $DRUSH_CMD en next jsonapi_extras simple_oauth pathauto -y" +echo "2. Generate OAuth keys: docker exec drupal-fpm bash -c 'cd /opt/drupal && mkdir -p oauth/keys && openssl genrsa -out oauth/keys/private.key 2048 && openssl rsa -in oauth/keys/private.key -pubout -out oauth/keys/public.key && chmod 600 oauth/keys/*.key'" +echo "3. Configure modules via Drupal admin UI" +echo "4. Export configuration: $DRUSH_CMD cex -y" diff --git a/drupal/nextjs/.dockerignore b/drupal/nextjs/.dockerignore new file mode 100644 index 0000000..69bb413 --- /dev/null +++ b/drupal/nextjs/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +.git +*.md +.env*.local +Dockerfile +.dockerignore diff --git a/drupal/nextjs/.gitignore b/drupal/nextjs/.gitignore new file mode 100644 index 0000000..3c63d97 --- /dev/null +++ b/drupal/nextjs/.gitignore @@ -0,0 +1,25 @@ +# Dependencies +node_modules +.pnp +.pnp.js + +# Build +.next +out +build +dist + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Local env +.env*.local + +# Vercel +.vercel + +# TypeScript +*.tsbuildinfo +next-env.d.ts diff --git a/drupal/nextjs/Dockerfile b/drupal/nextjs/Dockerfile new file mode 100644 index 0000000..b6a57af --- /dev/null +++ b/drupal/nextjs/Dockerfile @@ -0,0 +1,61 @@ +# Stage 0: Development (bind-mount source, run next dev). +FROM node:22-alpine AS development +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci || npm install +COPY . . +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +ENV NODE_ENV=development +# WATCHPACK_POLLING helps with bind mounts on some file systems. +ENV WATCHPACK_POLLING=true +ENTRYPOINT ["sh", "-c", "[ -d node_modules/.bin ] || npm install; exec npm run dev"] + +# Stage 1: Install dependencies. +FROM node:22-alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci || npm install + +# Stage 2: Build the application. +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Build arguments for environment variables needed at build time. +ARG NEXT_PUBLIC_DRUPAL_BASE_URL +ARG DRUPAL_CLIENT_ID +ARG DRUPAL_CLIENT_SECRET +ARG DRUPAL_OAUTH_SCOPE +ENV NEXT_PUBLIC_DRUPAL_BASE_URL=${NEXT_PUBLIC_DRUPAL_BASE_URL} +ENV DRUPAL_CLIENT_ID=${DRUPAL_CLIENT_ID} +ENV DRUPAL_CLIENT_SECRET=${DRUPAL_CLIENT_SECRET} +ENV DRUPAL_OAUTH_SCOPE=${DRUPAL_OAUTH_SCOPE} + +RUN npm run build + +# Stage 3: Production runner. +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy standalone output. +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/drupal/nextjs/app/[...slug]/page.tsx b/drupal/nextjs/app/[...slug]/page.tsx new file mode 100644 index 0000000..8b2b267 --- /dev/null +++ b/drupal/nextjs/app/[...slug]/page.tsx @@ -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 { + 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( + 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( + type, + translatedPath.entity.uuid, + { + withAuth: true, + params: { + include: "uid", + }, + } + ) + + if (!node || !node.status) { + notFound() + } + + return + } catch { + notFound() + } +} diff --git a/drupal/nextjs/app/api/disable-draft/route.ts b/drupal/nextjs/app/api/disable-draft/route.ts new file mode 100644 index 0000000..8190094 --- /dev/null +++ b/drupal/nextjs/app/api/disable-draft/route.ts @@ -0,0 +1,6 @@ +import { disableDraftMode } from "next-drupal/draft" +import type { NextRequest } from "next/server" + +export async function GET(request: NextRequest) { + return disableDraftMode() +} diff --git a/drupal/nextjs/app/api/draft/route.ts b/drupal/nextjs/app/api/draft/route.ts new file mode 100644 index 0000000..b8757e2 --- /dev/null +++ b/drupal/nextjs/app/api/draft/route.ts @@ -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 { + return enableDraftMode(request, drupal) +} diff --git a/drupal/nextjs/app/api/preview/route.ts b/drupal/nextjs/app/api/preview/route.ts new file mode 100644 index 0000000..ff4e93f --- /dev/null +++ b/drupal/nextjs/app/api/preview/route.ts @@ -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) +} diff --git a/drupal/nextjs/app/api/revalidate/route.ts b/drupal/nextjs/app/api/revalidate/route.ts new file mode 100644 index 0000000..b9357ce --- /dev/null +++ b/drupal/nextjs/app/api/revalidate/route.ts @@ -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 } diff --git a/drupal/nextjs/app/favicon.ico b/drupal/nextjs/app/favicon.ico new file mode 100644 index 0000000..8300f98 Binary files /dev/null and b/drupal/nextjs/app/favicon.ico differ diff --git a/drupal/nextjs/app/globals.css b/drupal/nextjs/app/globals.css new file mode 100644 index 0000000..078aa74 --- /dev/null +++ b/drupal/nextjs/app/globals.css @@ -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; +} diff --git a/drupal/nextjs/app/icon-robot-optimized.svg b/drupal/nextjs/app/icon-robot-optimized.svg new file mode 100644 index 0000000..ff101ca --- /dev/null +++ b/drupal/nextjs/app/icon-robot-optimized.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/drupal/nextjs/app/icon.svg b/drupal/nextjs/app/icon.svg new file mode 100644 index 0000000..665d52c --- /dev/null +++ b/drupal/nextjs/app/icon.svg @@ -0,0 +1,1517 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/drupal/nextjs/app/imprint/page.tsx b/drupal/nextjs/app/imprint/page.tsx new file mode 100644 index 0000000..404b536 --- /dev/null +++ b/drupal/nextjs/app/imprint/page.tsx @@ -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(/(

\{(?:address|email)\}<\/p>)/g) + + return ( +

+ {parts.map((part, i) => { + if (part === "

{address}

") return + if (part === "

{email}

") return

+ if (!part) return null + return
+ })} +
+ ) +} + +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 })?.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 ( +
+
+

+ {title} +

+
+
+ {body && } +
+
+ ) +} diff --git a/drupal/nextjs/app/layout.tsx b/drupal/nextjs/app/layout.tsx new file mode 100644 index 0000000..78a3d7e --- /dev/null +++ b/drupal/nextjs/app/layout.tsx @@ -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 ( + + + + Skip to main content + +
+ +
+
+
+ {children} +
+
+ + + + + ) +} diff --git a/drupal/nextjs/app/not-found.tsx b/drupal/nextjs/app/not-found.tsx new file mode 100644 index 0000000..a88092b --- /dev/null +++ b/drupal/nextjs/app/not-found.tsx @@ -0,0 +1,19 @@ +export default function NotFound() { + return ( +
+

404

+

+ Page Not Found +

+

+ The page you are looking for does not exist. +

+ + Go Home + +
+ ) +} diff --git a/drupal/nextjs/app/page.tsx b/drupal/nextjs/app/page.tsx new file mode 100644 index 0000000..1743ca5 --- /dev/null +++ b/drupal/nextjs/app/page.tsx @@ -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 ( + + + + + +
+ +
+ + + + + + +
+ ) +} diff --git a/drupal/nextjs/app/resources/page.tsx b/drupal/nextjs/app/resources/page.tsx new file mode 100644 index 0000000..e0eff15 --- /dev/null +++ b/drupal/nextjs/app/resources/page.tsx @@ -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 ( +
+
+

+ Resources +

+

+ Explore my curated collection of articles and models. +

+
+ +
+ +
+ +
+

+ Articles +

+

+ Written content covering tutorials, guides, and insights. Articles + are published pieces with full text, images, and structured + formatting. +

+ + Browse articles + + + + + +
+ +
+

+ Datamodelling +

+

+ Structured data models and schemas from my projects. +

+ + Browse models + + + +
+
+ ) +} diff --git a/drupal/nextjs/components/animated-hero-title.tsx b/drupal/nextjs/components/animated-hero-title.tsx new file mode 100644 index 0000000..d993433 --- /dev/null +++ b/drupal/nextjs/components/animated-hero-title.tsx @@ -0,0 +1,78 @@ +"use client" + +const TEXT = "I'm Robert." + +type Direction = "up" | "down" | "r" | "e" | "t" + +const LETTER_ANIMS: Record = { + 0: "down", // I + 1: "up", // ' + 2: "down", // m + 3: "down", // space - treat as down for delay + 4: "r", // R (special) + 5: "down", // o + 6: "up", // b + 7: "e", // e (special: o first, then e) + 8: "down", // r + 9: "t", // t (special: appears with o, then slides right) + 10: "down", // . +} + +const DELAYS_MS = [0, 80, 240, 320, 400, 560, 680, 760, 1400, 760, 1560] + +export function AnimatedHeroTitle() { + return ( +

+ {TEXT.split("").map((char, i) => { + if (char === " ") { + return + } + const dir = LETTER_ANIMS[i] ?? "down" + const delay = DELAYS_MS[i] ?? i * 80 + + const animClass = + dir === "r" + ? "hero-letter-r animate-letter-from-up" + : dir === "e" + ? "hero-letter-e" + : dir === "t" + ? "hero-letter-t animate-letter-t-slide" + : dir === "up" + ? "animate-letter-from-up" + : "animate-letter-from-down" + const animDelay = + dir === "r" ? delay + 320 : dir === "e" ? delay + 1050 : delay + const style = + dir === "r" + ? { "--b-delay": `${delay}ms`, animationDelay: `${animDelay}ms` } as React.CSSProperties + : dir === "e" + ? { "--o-delay": `${delay}ms` } as React.CSSProperties + : { animationDelay: `${animDelay}ms` } + + return ( + + {dir === "e" ? ( + + e + + ) : dir === "r" ? ( + "R" + ) : dir === "t" ? ( + "t" + ) : ( + char + )} + + ) + })} +

+ ) +} diff --git a/drupal/nextjs/components/avatar-image.tsx b/drupal/nextjs/components/avatar-image.tsx new file mode 100644 index 0000000..ab5cd77 --- /dev/null +++ b/drupal/nextjs/components/avatar-image.tsx @@ -0,0 +1,58 @@ +"use client" + +import Image from "next/image" +import { useCallback, useState } from "react" + +const IMAGE_POOL = [ + "/assets/images/autumn.png", + "/assets/images/kuss.png", + "/assets/images/chaos.png", + "/assets/images/conference.png", + "/assets/images/explaining.png", + "/assets/images/family.png", + "/assets/images/pres_1.png", + "/assets/images/robot.png", +] as const + +function pickRandom(exclude?: string): string { + const available = exclude + ? IMAGE_POOL.filter((p) => p !== exclude) + : [...IMAGE_POOL] + return available[Math.floor(Math.random() * available.length)] +} + +export function AvatarImage({ alt }: { alt: string }) { + const [currentSrc, setCurrentSrc] = useState(() => pickRandom()) + const [isSpinning, setIsSpinning] = useState(false) + + const handleMouseEnter = useCallback(() => { + setIsSpinning(true) + setCurrentSrc((prev) => pickRandom(prev)) + }, []) + + const handleAnimationEnd = useCallback(() => { + setIsSpinning(false) + }, []) + + return ( +
+
+ {alt} +
+
+ ) +} diff --git a/drupal/nextjs/components/cookie-banner.tsx b/drupal/nextjs/components/cookie-banner.tsx new file mode 100644 index 0000000..a7b6b2d --- /dev/null +++ b/drupal/nextjs/components/cookie-banner.tsx @@ -0,0 +1,62 @@ +"use client" + +import { useState, useEffect } from "react" +import Link from "next/link" + +const CONSENT_COOKIE = "cookie-consent" +const CONSENT_MAX_AGE = 365 * 24 * 60 * 60 // 1 year in seconds + +function setConsentCookie() { + document.cookie = `${CONSENT_COOKIE}=accepted; path=/; max-age=${CONSENT_MAX_AGE}; SameSite=Lax` +} + +function hasConsent(): boolean { + if (typeof document === "undefined") return false + return document.cookie.includes(`${CONSENT_COOKIE}=accepted`) +} + +export function CookieBanner() { + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + if (!hasConsent()) { + setIsVisible(true) + } + }, []) + + const handleAccept = () => { + setConsentCookie() + setIsVisible(false) + } + + if (!isVisible) return null + + return ( +
+
+

+ This site uses a single cookie to store your consent preference. No + tracking or analytics cookies are used.{" "} + + Learn more + +

+ +
+
+ ) +} diff --git a/drupal/nextjs/components/debug-trigger.tsx b/drupal/nextjs/components/debug-trigger.tsx new file mode 100644 index 0000000..095a782 --- /dev/null +++ b/drupal/nextjs/components/debug-trigger.tsx @@ -0,0 +1,16 @@ +"use client" + +import { useEffect } from "react" + +/** + * Triggers the browser debugger when the component mounts. + * Only runs in development. Remove when done debugging. + */ +export function DebugTrigger() { + useEffect(() => { + if (process.env.NODE_ENV === "development") { + debugger + } + }, []) + return null +} diff --git a/drupal/nextjs/components/home-about.tsx b/drupal/nextjs/components/home-about.tsx new file mode 100644 index 0000000..c60ac7c --- /dev/null +++ b/drupal/nextjs/components/home-about.tsx @@ -0,0 +1,127 @@ +import { drupal } from "@/lib/drupal" +import type { DrupalAboutNode } from "@/lib/types" +import { AvatarImage } from "./avatar-image" +import { MailToLink } from "./mail-to-link" + +const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? "" + +const FALLBACK_TITLE = "Robert Nasarek" +function FallbackBody() { + return ( + <> +

+ Data Engineer & Developer +

+

+ I’m a freelance backend and data engineer specialising in data modelling, ETL pipelines, and data-centric application architecture. I design and implement scalable APIs and backend systems using Python and modern web frameworks like Next.js, Drupal, and Django to build robust data workflows for analytics and machine learning use cases. +

+

+ My focus is on semantic and structured data systems that turn heterogeneous sources into reliable, queryable, and reusable knowledge. I deliver production-ready solutions, including containerised deployments and reproducible data pipelines, with an emphasis on correctness, performance, and maintainability. +

+ + ) +} + +async function getAboutPageContent(): Promise<{ + title: string + body: string | null + email: string | null +}> { + if (!drupalBaseUrl) { + return { + title: FALLBACK_TITLE, + body: null, + email: null, + } + } + + try { + const translatedPath = await drupal.translatePath("/about/robert-nasarek", { + withAuth: true, + next: { revalidate: 60 }, + }) + if ( + !translatedPath?.jsonapi?.resourceName || + !translatedPath?.entity?.uuid || + translatedPath.jsonapi.resourceName !== "node--about" + ) { + return { title: FALLBACK_TITLE, body: null, email: null } + } + + const node = await drupal.getResource( + "node--about", + translatedPath.entity.uuid, + { withAuth: true, next: { revalidate: 60 } } + ) + + if (!node) { + return { title: FALLBACK_TITLE, body: null, email: null } + } + + const bodyObj = node.body + const bodyText = + typeof bodyObj === "string" + ? bodyObj + : bodyObj?.processed ?? bodyObj?.value ?? "" + + return { + title: node.title ?? FALLBACK_TITLE, + body: bodyText || null, + email: node.field_email ?? null, + } + } catch (error) { + if ((error as Error).name !== "AbortError") { + console.warn("[HomeAbout] CMS unreachable:", (error as Error).message) + } + return { title: FALLBACK_TITLE, body: null, email: null } + } +} + +export async function HomeAbout() { + const { title, body, email } = await getAboutPageContent() + return ( +
+
+

+ About me +

+

+ Data engineer and developer with a focus on linked open data, ontology engineering, and full-stack development. +

+
+
+
+ +
+

+ {title} +

+
+

+ Data Engineer & Developer +

+

+ Hi I’m a freelance backend and data engineer specialising in data modelling, ETL pipelines, and data-centric application architecture. I design and implement scalable APIs and backend systems using Python and modern web frameworks like Next.js, Drupal, and Django to build robust data workflows for analytics and machine learning use cases. +

+

+ My focus is on semantic and structured data systems that turn heterogeneous sources into reliable, queryable, and reusable knowledge. I deliver production-ready solutions, including containerised deployments and reproducible data pipelines, with an emphasis on correctness, performance, and maintainability. +

+
+

+ +

+
+
+
+
+ ) +} diff --git a/drupal/nextjs/components/home-clients.tsx b/drupal/nextjs/components/home-clients.tsx new file mode 100644 index 0000000..680f776 --- /dev/null +++ b/drupal/nextjs/components/home-clients.tsx @@ -0,0 +1,223 @@ +"use client" + +import Image from "next/image" +import { Building2 } from "lucide-react" +import { useState, useRef, useEffect } from "react" + +function ClientIcon({ icon }: { icon?: string }) { + const [hasError, setHasError] = useState(false) + + if (!icon || hasError) { + return + } + + return ( + setHasError(true)} + /> + ) +} + +const clients = [ + { + name: "Max Planck Institute for Social Anthropology", + location: "Halle/Saale", + href: "https://www.eth.mpg.de/", + icon: "/assets/icons/eth-mpg.png", + }, + { + name: "German National Academy of Sciences Leopoldina", + location: "Halle/Saale", + href: "https://www.leopoldina.org/", + icon: "/assets/icons/leopoldina.png", + }, + { + name: "German National Museum", + location: "Nuremberg", + href: "https://www.gnm.de/", + icon: "/assets/icons/gnm.png", + }, + { + name: "Central Institute for Art History", + location: "Munich", + href: "https://www.zikg.eu/", + icon: "/assets/icons/zikg.png", + }, + { + name: "German Fairy Tale and Weser Legends Museum", + location: "Bad Oeynhausen", + href: "https://www.badoeynhausen.de/freizeit-kultur-sport/kultur/staedtische-museen/deutsches-maerchen-und-wesersagenmuseum", + icon: "/assets/icons/badoeynhausen.png", + }, + { + name: "Roli-Bar", + location: "roli-bar.de", + href: "https://roli-bar.de/", + icon: "/assets/icons/roli-bar.png", + }, + { + name: "Re-Cycle Halle", + location: "Halle/Saale", + href: "https://re-cycle-halle.de/", + icon: "/assets/icons/re-cycle-halle.png", + }, + { + name: "bold + bündig", + location: "Leipzig", + href: "https://boldundbuendig.de/", + icon: "/assets/icons/boldundbuendig.png", + }, +] + +function ClientsBandContent() { + return ( + <> + {clients.map((client) => { + const content = ( + <> + + {client.name} + ({client.location}) + + ) + const className = + "home-clients-band-item mx-4 flex shrink-0 items-center gap-2 rounded-xl border border-slate-200 bg-white px-6 py-4 text-base text-slate-700 shadow-sm" + return client.href ? ( + + {content} + + ) : ( + + {content} + + ) + })} + + ) +} + +const SCROLL_SPEED = 40 + +export function HomeClients() { + const bandRef = useRef(null) + const trackRef = useRef(null) + const posRef = useRef(0) + const isHoveredRef = useRef(false) + const isDraggingRef = useRef(false) + const dragStartXRef = useRef(0) + const dragStartPosRef = useRef(0) + const lastTimeRef = useRef(null) + const rafRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + + useEffect(() => { + const track = trackRef.current + if (!track) return + + const step = (time: number) => { + const halfWidth = track.scrollWidth / 2 + if (halfWidth > 0 && lastTimeRef.current !== null && !isHoveredRef.current && !isDraggingRef.current) { + const dt = time - lastTimeRef.current + posRef.current += SCROLL_SPEED * dt / 1000 + if (posRef.current >= halfWidth) posRef.current -= halfWidth + } + lastTimeRef.current = time + track.style.transform = `translateX(${-posRef.current}px)` + rafRef.current = requestAnimationFrame(step) + } + + rafRef.current = requestAnimationFrame(step) + return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) } + }, []) + + const handleMouseEnter = () => { isHoveredRef.current = true } + const handleMouseLeave = () => { + isHoveredRef.current = false + isDraggingRef.current = false + setIsDragging(false) + } + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault() + isDraggingRef.current = true + dragStartXRef.current = e.clientX + dragStartPosRef.current = posRef.current + setIsDragging(true) + } + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDraggingRef.current) return + const track = trackRef.current + if (!track) return + const halfWidth = track.scrollWidth / 2 + const dx = dragStartXRef.current - e.clientX + posRef.current = ((dragStartPosRef.current + dx) % halfWidth + halfWidth) % halfWidth + } + const handleMouseUp = () => { + isDraggingRef.current = false + setIsDragging(false) + } + + const handleTouchStart = (e: React.TouchEvent) => { + isDraggingRef.current = true + dragStartXRef.current = e.touches[0].clientX + dragStartPosRef.current = posRef.current + } + const handleTouchMove = (e: React.TouchEvent) => { + if (!isDraggingRef.current) return + const track = trackRef.current + if (!track) return + const halfWidth = track.scrollWidth / 2 + const dx = dragStartXRef.current - e.touches[0].clientX + posRef.current = ((dragStartPosRef.current + dx) % halfWidth + halfWidth) % halfWidth + } + const handleTouchEnd = () => { isDraggingRef.current = false } + + return ( +
+
+

+ Employers & Customers +

+

+ Research institutions and organisations I have worked with. +

+
+
+
+ + +
+
+
+ ) +} diff --git a/drupal/nextjs/components/home-cta.tsx b/drupal/nextjs/components/home-cta.tsx new file mode 100644 index 0000000..68a82fe --- /dev/null +++ b/drupal/nextjs/components/home-cta.tsx @@ -0,0 +1,24 @@ +import Link from "next/link" +import { ArrowRight } from "lucide-react" + +export function HomeCta() { + return ( +
+
+

+ Hi There! +

+

+ Browse articles and data models for your next project. +

+ + View all resources + + +
+
+ ) +} diff --git a/drupal/nextjs/components/home-features.tsx b/drupal/nextjs/components/home-features.tsx new file mode 100644 index 0000000..b782326 --- /dev/null +++ b/drupal/nextjs/components/home-features.tsx @@ -0,0 +1,63 @@ +import Link from "next/link" +import { FileText, Database, ArrowRight } from "lucide-react" + +const features = [ + { + title: "Articles", + description: + "Tutorials, guides, and insights on data engineering and software development.", + href: "/resources/articles", + icon: FileText, + color: "emerald", + }, + { + title: "Datamodelling", + description: + "Structured data models and schemas from real-world projects.", + href: "/resources/datamodelling", + icon: Database, + color: "fuchsia", + }, +] + +export function HomeFeatures() { + return ( +
+
+

+ Resources +

+

+ Use one or all. Curated content for data engineers and developers. +

+
+
+ {features.map((feature, index) => ( + +
+ +
+

+ {feature.title} +

+

{feature.description}

+ + Learn more + + + + ))} +
+
+ ) +} diff --git a/drupal/nextjs/components/home-hero.tsx b/drupal/nextjs/components/home-hero.tsx new file mode 100644 index 0000000..f013806 --- /dev/null +++ b/drupal/nextjs/components/home-hero.tsx @@ -0,0 +1,11 @@ +import { AnimatedHeroTitle } from "./animated-hero-title" + +export function HomeHero() { + return ( +
+
+ +
+
+ ) +} diff --git a/drupal/nextjs/components/home-journey-background.tsx b/drupal/nextjs/components/home-journey-background.tsx new file mode 100644 index 0000000..dfbb3ed --- /dev/null +++ b/drupal/nextjs/components/home-journey-background.tsx @@ -0,0 +1,196 @@ +"use client" + +import React, { useEffect, useState } from "react" +import { + Lightbulb, + PenTool, + Code2, + Rocket, + Wrench, + MessageSquare, + LifeBuoy, +} from "lucide-react" + +const STATION_COLORS: Record< + string, + { border: string; bg: string; text: string; label: string } +> = { + idea: { border: "border-amber-400", bg: "bg-amber-50", text: "text-amber-600", label: "text-amber-700" }, + concept: { border: "border-emerald-400", bg: "bg-emerald-50", text: "text-emerald-600", label: "text-emerald-700" }, + consulting: { border: "border-sky-400", bg: "bg-sky-50", text: "text-sky-600", label: "text-sky-700" }, + development: { border: "border-fuchsia-400", bg: "bg-fuchsia-100", text: "text-fuchsia-600", label: "text-fuchsia-700" }, + deployment: { border: "border-sky-400", bg: "bg-sky-50", text: "text-sky-600", label: "text-sky-700" }, + maintenance: { border: "border-slate-400", bg: "bg-slate-100", text: "text-slate-600", label: "text-slate-700" }, + support: { border: "border-rose-400", bg: "bg-rose-50", text: "text-rose-600", label: "text-rose-700" }, +} + +const STATIONS = [ + { id: "idea", icon: Lightbulb, label: "Idea", lineThreshold: 0 }, + { id: "concept", icon: PenTool, label: "Concept", lineThreshold: 15 }, + { id: "deployment", icon: Rocket, label: "Deployment", lineThreshold: 45 }, + { id: "maintenance", icon: Wrench, label: "Maintenance", lineThreshold: 80 }, +] as const + +const RIGHT_STATIONS = [ + { id: "consulting", icon: MessageSquare, label: "Consulting", lineThreshold: 0 }, + { id: "development", icon: Code2, label: "Development", lineThreshold: 25 }, + { id: "support", icon: LifeBuoy, label: "Support", lineThreshold: 60 }, +] as const + +export function HomeJourneyBackground({ children }: { children: React.ReactNode }) { + const childArray = React.Children.toArray(children) + const heroContent = childArray[0] + const mainContent = childArray.slice(1) + const [visibleStations, setVisibleStations] = useState>(new Set()) + const [visibleRightStations, setVisibleRightStations] = useState>( + new Set() + ) + useEffect(() => { + const handleScroll = () => { + const y = window.scrollY + + const docHeight = document.documentElement.scrollHeight - window.innerHeight + const scrollProgress = docHeight > 0 ? Math.min(y / docHeight, 1) : 0 + const ideaThreshold = 0.02 + const linePercent = + scrollProgress >= ideaThreshold + ? Math.min( + 100, + ((scrollProgress - ideaThreshold) / (1 - ideaThreshold)) * 100 + ) + : 0 + + const newVisible = new Set() + if (scrollProgress >= ideaThreshold) { + newVisible.add("idea") + } + STATIONS.forEach((station) => { + if (station.id !== "idea" && linePercent >= station.lineThreshold) { + newVisible.add(station.id) + } + }) + setVisibleStations(newVisible) + + const newVisibleRight = new Set() + if (scrollProgress >= ideaThreshold) { + newVisibleRight.add("consulting") + } + RIGHT_STATIONS.forEach((station) => { + if ( + station.id !== "consulting" && + linePercent >= station.lineThreshold + ) { + newVisibleRight.add(station.id) + } + }) + setVisibleRightStations(newVisibleRight) + } + + handleScroll() + window.addEventListener("scroll", handleScroll, { passive: true }) + return () => window.removeEventListener("scroll", handleScroll) + }, []) + + return ( +
+
{heroContent}
+
+ +
+ {mainContent} +
+ +
+
+ ) +} diff --git a/drupal/nextjs/components/home-marquee.tsx b/drupal/nextjs/components/home-marquee.tsx new file mode 100644 index 0000000..c0f1c7c --- /dev/null +++ b/drupal/nextjs/components/home-marquee.tsx @@ -0,0 +1,36 @@ +"use client" + +const testimonials = [ + "Clean, fast, and well documented.", + "Best developer experience I've had in years.", + "Went from zero to production in minutes.", + "The documentation is a joy to read.", + "Scales effortlessly with my needs.", + "Exactly what I needed for my project.", +] + +function MarqueeContent() { + return ( + <> + {testimonials.map((testimonial, index) => ( + + {testimonial} + + ))} + + ) +} + +export function HomeMarquee() { + return ( +
+
+ + +
+
+ ) +} diff --git a/drupal/nextjs/components/home-projects.tsx b/drupal/nextjs/components/home-projects.tsx new file mode 100644 index 0000000..3c8fe77 --- /dev/null +++ b/drupal/nextjs/components/home-projects.tsx @@ -0,0 +1,88 @@ +import Link from "next/link" +import Image from "next/image" +import { ArrowUpRight } from "lucide-react" +import { ScrollRevealCard } from "@/components/scroll-reveal-card" + +const projects = [ + { + title: "Böhler re:search", + description: + "Digital edition of the Munich art dealer Julius Böhler's object card system, photo folders and customer index (1903–1948). Research data on traded artworks, transactions and actors.", + href: "https://boehler.zikg.eu/", + icon: "/assets/icons/boehler-research.png", + }, + { + title: "Objektsprache und Ästhetik", + description: + "Shell collections at Leopoldina, Goldfuß-Museum Bonn, and Central Institute for Natural Collections MLU. Historical object references and synonym networks for conchylia.", + href: "https://konchylien.leopoldina.org/sammlungen", + icon: "/assets/logos/lzfw_logo.png", + }, + { + title: "SCS Manager", + description: + "Semantic Co-Working Space for academic university collections. Model, transform, analyse and publish data with JupyterLab, OpenRefine, WissKI and more.", + href: "https://manager.scs.sammlungen.io/", + icon: "/assets/icons/scs-manager.png", + }, + { + title: "WissKI", + description: + "Semantic data management system for GLAM institutions. Virtual research environment extending Drupal with CIDOC CRM, Pathbuilder and linked open data.", + href: "https://wiss-ki.eu/", + icon: "/assets/icons/wisski.svg", + }, +] + +export function HomeProjects() { + return ( +
+
+

+ Projects +

+

+ Data- and information-focused websites and applications. +

+
+
+ {projects.map((project) => ( + + +

+ + {project.title} +

+

+ {project.description} +

+ + Visit project + + + +
+ ))} +
+
+ ) +} diff --git a/drupal/nextjs/components/home-services.tsx b/drupal/nextjs/components/home-services.tsx new file mode 100644 index 0000000..5b103d2 --- /dev/null +++ b/drupal/nextjs/components/home-services.tsx @@ -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 = { + 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 ( +
+
+

+ Services +

+

+ Data engineering, development, deployment, and ongoing support for + your projects. +

+
+
+ {services.map(({ label, body, icon: Icon }) => ( + +
+ +
+ + {label} + + {body && ( + + {body} + + )} +
+
+
+ ))} +
+
+ ) +} diff --git a/drupal/nextjs/components/imprint-body.tsx b/drupal/nextjs/components/imprint-body.tsx new file mode 100644 index 0000000..5cabff9 --- /dev/null +++ b/drupal/nextjs/components/imprint-body.tsx @@ -0,0 +1,57 @@ +"use client" + +import { ObfuscatedEmail } from "@/components/obfuscated-email" +import { ObfuscatedAddress } from "@/components/obfuscated-address" + +interface ImprintBodyProps { + html: string +} + +/** + * Renders imprint HTML with {email} and {address} placeholders replaced by + * ObfuscatedEmail and ObfuscatedAddress components. + */ +export function ImprintBody({ html }: ImprintBodyProps) { + const placeholderRegex = /\{(email|address)\}/g + const parts: (string | React.ReactNode)[] = [] + let lastIndex = 0 + let match + let key = 0 + + while ((match = placeholderRegex.exec(html)) !== null) { + const before = html.slice(lastIndex, match.index) + if (before) { + parts.push( + + ) + } + if (match[1] === "email") { + parts.push( + + ) + } else { + parts.push() + } + lastIndex = match.index + match[0].length + } + + const after = html.slice(lastIndex) + if (after) { + parts.push( + + ) + } + + return <>{parts} +} diff --git a/drupal/nextjs/components/mail-to-link.tsx b/drupal/nextjs/components/mail-to-link.tsx new file mode 100644 index 0000000..7972acc --- /dev/null +++ b/drupal/nextjs/components/mail-to-link.tsx @@ -0,0 +1,47 @@ +"use client" + +import { useEffect, useState } from "react" +import { Mail } from "lucide-react" + +interface MailToLinkProps { + /** When provided (e.g. from Drupal field_email), use this email. Otherwise use fallback. */ + email?: string | null +} + +/** + * Mailto link with icon; email is set on client to reduce harvestability when not passed as prop. + */ +export function MailToLink({ email }: MailToLinkProps) { + const [href, setHref] = useState(email ? `mailto:${email}` : null) + + useEffect(() => { + if (email) { + setHref(`mailto:${email}`) + return + } + const localPart = "robert" + const domain = "nasarek" + const tld = "dev" + setHref(`mailto:${localPart}@${domain}.${tld}`) + }, [email]) + + if (!href) { + return ( + + + Write me + + ) + } + + return ( + + + Write me + + ) +} diff --git a/drupal/nextjs/components/main-nav-client.tsx b/drupal/nextjs/components/main-nav-client.tsx new file mode 100644 index 0000000..99833b9 --- /dev/null +++ b/drupal/nextjs/components/main-nav-client.tsx @@ -0,0 +1,156 @@ +"use client" + +import type { DrupalMenuItem } from "next-drupal" +import { Home, FolderOpen, ChevronDown } from "lucide-react" +import { useState, useRef, useEffect } from "react" + +const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? "" + +function getHref(url: string): string { + if (drupalBaseUrl && url.startsWith(drupalBaseUrl)) { + return url.slice(drupalBaseUrl.length) || "/" + } + return url +} + +function NavLink({ + item, + isDropdown = false, + onNavigate, +}: { + item: DrupalMenuItem + isDropdown?: boolean + onNavigate?: () => void +}) { + const children = item.items?.filter((child) => child.enabled !== false) ?? [] + const hasChildren = children.length > 0 + const linkClass = isDropdown + ? "block rounded-sm px-4 py-2 text-emerald-500 outline-none transition-colors duration-200 ease-out hover:underline focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-inset" + : "flex items-center gap-1.5 rounded-sm text-emerald-500 outline-none transition-colors duration-200 ease-out hover:text-emerald-400 hover:underline focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-800" + + if (hasChildren) { + return ( +
+ + + {item.title} + + +
+
+ {children.map((child) => ( + + {child.title} + + ))} +
+
+
+ ) + } + + const href = getHref(item.url) + const isHome = href === "/" + return ( + + {isHome && } + {item.title} + + ) +} + +interface MainNavClientProps { + menuItems: DrupalMenuItem[] +} + +export function MainNavClient({ menuItems }: MainNavClientProps) { + const [isOpen, setIsOpen] = useState(false) + const menuRef = useRef(null) + + const enabledItems = menuItems.filter((item) => item.enabled !== false) + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + if (isOpen) { + document.addEventListener("click", handleClickOutside) + } + return () => document.removeEventListener("click", handleClickOutside) + }, [isOpen]) + + const closeMenu = () => setIsOpen(false) + + return ( + + ) +} diff --git a/drupal/nextjs/components/main-nav.tsx b/drupal/nextjs/components/main-nav.tsx new file mode 100644 index 0000000..5100a61 --- /dev/null +++ b/drupal/nextjs/components/main-nav.tsx @@ -0,0 +1,76 @@ +import type { DrupalMenuItem } from "next-drupal" +import { MainNavClient } from "./main-nav-client" + +const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? "" + +interface RawMenuItem { + id: string + parent: string + title: string + url: string + enabled?: boolean + weight?: string | number +} + +function buildMenuTree( + items: RawMenuItem[], + parentId: string +): DrupalMenuItem[] { + return items + .filter((item) => (item.parent || "") === parentId && item.enabled !== false) + .sort((a, b) => Number(a.weight ?? 0) - Number(b.weight ?? 0)) + .map((item) => { + const children = buildMenuTree(items, item.id) + return { + ...item, + items: children.length ? children : undefined, + } as DrupalMenuItem + }) +} + +const FALLBACK_MENU: DrupalMenuItem[] = [ + { + id: "home", + title: "Home", + url: "/", + enabled: true, + items: undefined, + } as DrupalMenuItem, +] + +async function getMainMenu(): Promise { + if (!drupalBaseUrl) return FALLBACK_MENU + + try { + const url = `${drupalBaseUrl.replace(/\/$/, "")}/jsonapi/menu_items/main` + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 5000) + const res = await fetch(url, { + headers: { Accept: "application/vnd.api+json" }, + next: { revalidate: 60 }, + signal: controller.signal, + }) + clearTimeout(timeoutId) + if (!res.ok) { + if (res.status !== 404) { + console.warn(`[MainNav] Menu fetch returned ${res.status}, using fallback nav.`) + } + return FALLBACK_MENU + } + + const json = await res.json() + const items: RawMenuItem[] = json.data ?? [] + + return buildMenuTree(items, "") + } catch (error) { + if ((error as Error).name !== "AbortError") { + console.warn("[MainNav] CMS unreachable, using fallback nav:", (error as Error).message) + } + return FALLBACK_MENU + } +} + +export async function MainNav() { + const menuItems = await getMainMenu() + return +} diff --git a/drupal/nextjs/components/node-article-teaser.tsx b/drupal/nextjs/components/node-article-teaser.tsx new file mode 100644 index 0000000..e5628e6 --- /dev/null +++ b/drupal/nextjs/components/node-article-teaser.tsx @@ -0,0 +1,34 @@ +import type { DrupalNode } from "@/lib/types" + +interface NodeArticleTeaserProps { + node: DrupalNode +} + +export function NodeArticleTeaser({ node }: NodeArticleTeaserProps) { + const href = node.path?.alias || `/node/${node.id}` + + return ( +
+

+ + {node.title} + +

+
+ {node.uid?.display_name && ( + By {node.uid.display_name} + )} + +
+ {node.body?.summary && ( +

{node.body.summary}

+ )} +
+ ) +} diff --git a/drupal/nextjs/components/node-article.tsx b/drupal/nextjs/components/node-article.tsx new file mode 100644 index 0000000..a33566a --- /dev/null +++ b/drupal/nextjs/components/node-article.tsx @@ -0,0 +1,35 @@ +import type { DrupalNode } from "@/lib/types" + +interface NodeArticleProps { + node: DrupalNode +} + +export function NodeArticle({ node }: NodeArticleProps) { + return ( +
+

+ {node.title} +

+
+ {node.uid?.display_name && ( + By {node.uid.display_name} + )} + +
+ {(node.body?.processed ?? node.body?.value) && ( +
+ )} +
+ ) +} diff --git a/drupal/nextjs/components/node-page.tsx b/drupal/nextjs/components/node-page.tsx new file mode 100644 index 0000000..ea30624 --- /dev/null +++ b/drupal/nextjs/components/node-page.tsx @@ -0,0 +1,21 @@ +import type { DrupalNode } from "@/lib/types" + +interface NodePageProps { + node: DrupalNode +} + +export function NodePage({ node }: NodePageProps) { + return ( +
+

+ {node.title} +

+ {node.body?.processed && ( +
+ )} +
+ ) +} diff --git a/drupal/nextjs/components/obfuscated-address.tsx b/drupal/nextjs/components/obfuscated-address.tsx new file mode 100644 index 0000000..dd88f60 --- /dev/null +++ b/drupal/nextjs/components/obfuscated-address.tsx @@ -0,0 +1,49 @@ +"use client" + +import { useEffect, useState } from "react" + +/** + * Renders a mailto link only after client mount so the email is not in the + * server-rendered HTML, reducing harvestability by bots that scan static HTML. + * Parts are hardcoded so they live in the JS bundle, not in page HTML. + */ +type AddressData = { + fullname: string + street: string + city: string + country: string +} + +export function ObfuscatedAddress({ className }: { className?: string }) { + const [address, setAddress] = useState(null) + + useEffect(() => { + setAddress({ + fullname: "Robert Nasarek", + street: "Kleine Ulrichstraße 1", + city: "Halle (Saale)", + country: "Germany", + }) + }, []) + + if (!address) { + return ( + + + + + ) + } + + return ( +

+ {address.fullname} +
+ {address.street} +
+ {address.city} +
+ {address.country} +

+ ) +} diff --git a/drupal/nextjs/components/obfuscated-email.tsx b/drupal/nextjs/components/obfuscated-email.tsx new file mode 100644 index 0000000..e2d8978 --- /dev/null +++ b/drupal/nextjs/components/obfuscated-email.tsx @@ -0,0 +1,34 @@ +"use client" + +import { useEffect, useState } from "react" + +/** + * Renders a mailto link only after client mount so the email is not in the + * server-rendered HTML, reducing harvestability by bots that scan static HTML. + * Parts are hardcoded so they live in the JS bundle, not in page HTML. + */ +export function ObfuscatedEmail({ className }: { className?: string }) { + const [email, setEmail] = useState(null) + + useEffect(() => { + const localPart = "robert" + const domain = "nasarek" + const tld = "dev" + setEmail(`${localPart}@${domain}.${tld}`) + }, []) + + if (!email) { + return ( + + + + + ) + } + + return ( + + {email} + + ) +} diff --git a/drupal/nextjs/components/scroll-reveal-card.tsx b/drupal/nextjs/components/scroll-reveal-card.tsx new file mode 100644 index 0000000..6ca44bc --- /dev/null +++ b/drupal/nextjs/components/scroll-reveal-card.tsx @@ -0,0 +1,38 @@ +"use client" + +import { useEffect, useRef, useState, type ReactNode } from "react" + +interface ScrollRevealCardProps { + children: ReactNode +} + +export function ScrollRevealCard({ children }: ScrollRevealCardProps) { + const ref = useRef(null) + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + const el = ref.current + if (!el) return + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) setIsVisible(true) + }, + { threshold: 0.35, rootMargin: "0px 0px -120px 0px" } + ) + + observer.observe(el) + return () => observer.disconnect() + }, []) + + return ( +
+ {children} +
+ ) +} diff --git a/drupal/nextjs/components/scroll-reveal-section.tsx b/drupal/nextjs/components/scroll-reveal-section.tsx new file mode 100644 index 0000000..fd0af6c --- /dev/null +++ b/drupal/nextjs/components/scroll-reveal-section.tsx @@ -0,0 +1,56 @@ +"use client" + +import { useEffect, useRef, useState, type ReactNode } from "react" + +interface ScrollRevealSectionProps { + children: ReactNode + /** When true, section starts visible (no opacity-20 flash). Use for above-the-fold hero. */ + initialVisible?: boolean + /** Delay in ms before the reveal animation starts after the section enters view. */ + revealDelayMs?: number +} + +export function ScrollRevealSection({ + children, + initialVisible = false, + revealDelayMs = 0, +}: ScrollRevealSectionProps) { + const ref = useRef(null) + const [isVisible, setIsVisible] = useState(initialVisible) + + useEffect(() => { + const el = ref.current + if (!el) return + + let timeoutId: ReturnType | null = null + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) return + if (revealDelayMs <= 0) { + setIsVisible(true) + return + } + timeoutId = setTimeout(() => setIsVisible(true), revealDelayMs) + }, + { threshold: 0.1, rootMargin: "0px 0px -80px 0px" } + ) + + observer.observe(el) + return () => { + if (timeoutId) clearTimeout(timeoutId) + observer.disconnect() + } + }, [revealDelayMs]) + + return ( +
+ {children} +
+ ) +} diff --git a/drupal/nextjs/lib/config.ts b/drupal/nextjs/lib/config.ts new file mode 100644 index 0000000..e69de29 diff --git a/drupal/nextjs/lib/drupal.ts b/drupal/nextjs/lib/drupal.ts new file mode 100644 index 0000000..df0bb70 --- /dev/null +++ b/drupal/nextjs/lib/drupal.ts @@ -0,0 +1,20 @@ +import { NextDrupal } from "next-drupal" + +const baseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL! + +const auth = + process.env.DRUPAL_CLIENT_ID && process.env.DRUPAL_CLIENT_SECRET + ? { + clientId: process.env.DRUPAL_CLIENT_ID, + clientSecret: process.env.DRUPAL_CLIENT_SECRET, + ...(process.env.DRUPAL_OAUTH_SCOPE && { + scope: process.env.DRUPAL_OAUTH_SCOPE, + }), + } + : undefined + +export const drupal = new NextDrupal(baseUrl, { + auth, + withAuth: !!auth, + debug: process.env.NODE_ENV === "development", +}) diff --git a/drupal/nextjs/lib/types.ts b/drupal/nextjs/lib/types.ts new file mode 100644 index 0000000..a232d45 --- /dev/null +++ b/drupal/nextjs/lib/types.ts @@ -0,0 +1,71 @@ +import type { JsonApiResource } from "next-drupal" + +// Drupal JSON:API resource types. + +export interface DrupalNode extends JsonApiResource { + title: string + status: boolean + created: string + changed: string + path: { + alias: string + pid: number + langcode: string + } + body?: { + value: string + format: string + processed: string + summary: string + } + field_image?: DrupalMedia + uid?: { + id: string + display_name: string + } + metatag?: DrupalMetatag[] +} + +export interface DrupalMedia extends JsonApiResource { + name: string + field_media_image?: DrupalFile +} + +export interface DrupalFile extends JsonApiResource { + uri: { + value: string + url: string + } + resourceIdObjMeta?: { + alt: string + title: string + width: number + height: number + } +} + +export interface DrupalMetatag { + tag: string + attributes: Record +} + +export interface DrupalMenuLinkContent { + id: string + title: string + url: string + parent: string + weight: number + expanded: boolean + enabled: boolean + items?: DrupalMenuLinkContent[] +} + +export interface DrupalServiceNode extends DrupalNode { + /** Service type from Drupal (modelling, development, deployment, etc.). JSON:API exposes as field__service__type. */ + field__service__type?: string +} + +export interface DrupalAboutNode extends DrupalNode { + /** JSON:API resource type: node--about. */ + field_email?: string +} diff --git a/drupal/nextjs/next.config.ts b/drupal/nextjs/next.config.ts new file mode 100644 index 0000000..dd1ca4c --- /dev/null +++ b/drupal/nextjs/next.config.ts @@ -0,0 +1,20 @@ +import type { NextConfig } from "next" + +const nextConfig: NextConfig = { + images: { + qualities: [75, 95], + remotePatterns: [ + { + protocol: "https", + hostname: process.env.NEXT_IMAGE_DOMAIN || "cms.nasarek.dev", + }, + ], + }, + // Enable standalone output for Docker. + output: "standalone", + + // DevIndicators + devIndicators: false, +} + +export default nextConfig diff --git a/drupal/nextjs/package-lock.json b/drupal/nextjs/package-lock.json new file mode 100644 index 0000000..d88ee31 --- /dev/null +++ b/drupal/nextjs/package-lock.json @@ -0,0 +1,1969 @@ +{ + "name": "nasarek-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nasarek-frontend", + "version": "1.0.0", + "dependencies": { + "lucide-react": "^0.574.0", + "next": "^15.1", + "next-drupal": "^2.0.0", + "react": "^19.0", + "react-dom": "^19.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0", + "@types/node": "^22.0", + "@types/react": "^19.0", + "@types/react-dom": "^19.0", + "tailwindcss": "^4.0", + "typescript": "^5.7" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz", + "integrity": "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz", + "integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz", + "integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz", + "integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz", + "integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz", + "integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz", + "integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz", + "integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz", + "integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jsona": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/jsona/-/jsona-1.12.1.tgz", + "integrity": "sha512-44WL4ZdsKx//mCDPUFQtbK7mnVdHXcVzbBy7Pzy0LAgXyfpN5+q8Hum7cLUX4wTnRsClHb4eId1hePZYchwczg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.1" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "0.574.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.574.0.tgz", + "integrity": "sha512-dJ8xb5juiZVIbdSn3HTyHsjjIwUwZ4FNwV0RtYDScOyySOeie1oXZTymST6YPJ4Qwt3Po8g4quhYl4OxtACiuQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz", + "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.12", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.12", + "@next/swc-darwin-x64": "15.5.12", + "@next/swc-linux-arm64-gnu": "15.5.12", + "@next/swc-linux-arm64-musl": "15.5.12", + "@next/swc-linux-x64-gnu": "15.5.12", + "@next/swc-linux-x64-musl": "15.5.12", + "@next/swc-win32-arm64-msvc": "15.5.12", + "@next/swc-win32-x64-msvc": "15.5.12", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-drupal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/next-drupal/-/next-drupal-2.0.0.tgz", + "integrity": "sha512-cgkfdSe2iepaiJkG4586SyCrUoXNsCfj9r1CcChidDZN7iDPLSKIIq8i63Yf0lPzLz+amwVW9lhWwrErOy8OIA==", + "license": "MIT", + "dependencies": { + "jsona": "^1.12.1", + "next": "^14.2.21 || ^15.1.2", + "node-cache": "^5.1.2", + "qs": "^6.13.1", + "react": "^18.2 || ^19.0", + "react-dom": "^18.2 || ^19.0" + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/drupal/nextjs/package.json b/drupal/nextjs/package.json new file mode 100644 index 0000000..1110f70 --- /dev/null +++ b/drupal/nextjs/package.json @@ -0,0 +1,29 @@ +{ + "name": "nasarek-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "dev:debug": "NODE_OPTIONS='--inspect' next dev", + "build": "next build", + "start": "next start -p 3000", + "lint": "next lint", + "docker:build": "docker build -t rnsrk/nextjs-frontend --build-arg NEXT_PUBLIC_DRUPAL_BASE_URL=https://cms.nasarek.dev .", + "docker:up": "cd .. && docker compose up -d nextjs --build" + }, + "dependencies": { + "lucide-react": "^0.574.0", + "next": "^15.1", + "next-drupal": "^2.0.0", + "react": "^19.0", + "react-dom": "^19.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0", + "@types/node": "^22.0", + "@types/react": "^19.0", + "@types/react-dom": "^19.0", + "tailwindcss": "^4.0", + "typescript": "^5.7" + } +} diff --git a/drupal/nextjs/postcss.config.mjs b/drupal/nextjs/postcss.config.mjs new file mode 100644 index 0000000..f6c75ff --- /dev/null +++ b/drupal/nextjs/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +} + +export default config diff --git a/drupal/nextjs/public/assets/icons/badoeynhausen.png b/drupal/nextjs/public/assets/icons/badoeynhausen.png new file mode 100644 index 0000000..2ec8cd3 Binary files /dev/null and b/drupal/nextjs/public/assets/icons/badoeynhausen.png differ diff --git a/drupal/nextjs/public/assets/icons/boehler-research.png b/drupal/nextjs/public/assets/icons/boehler-research.png new file mode 100644 index 0000000..ac17085 Binary files /dev/null and b/drupal/nextjs/public/assets/icons/boehler-research.png differ diff --git a/drupal/nextjs/public/assets/icons/boldundbuendig.png b/drupal/nextjs/public/assets/icons/boldundbuendig.png new file mode 100644 index 0000000..57c8306 Binary files /dev/null and b/drupal/nextjs/public/assets/icons/boldundbuendig.png differ diff --git a/drupal/nextjs/public/assets/icons/drupal.svg b/drupal/nextjs/public/assets/icons/drupal.svg new file mode 100644 index 0000000..e932dbb --- /dev/null +++ b/drupal/nextjs/public/assets/icons/drupal.svg @@ -0,0 +1 @@ +Drupal diff --git a/drupal/nextjs/public/assets/icons/eth-mpg.png b/drupal/nextjs/public/assets/icons/eth-mpg.png new file mode 100644 index 0000000..3c6f7ff Binary files /dev/null and b/drupal/nextjs/public/assets/icons/eth-mpg.png differ diff --git a/drupal/nextjs/public/assets/icons/github.svg b/drupal/nextjs/public/assets/icons/github.svg new file mode 100644 index 0000000..62c6e57 --- /dev/null +++ b/drupal/nextjs/public/assets/icons/github.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/drupal/nextjs/public/assets/icons/gnm.png b/drupal/nextjs/public/assets/icons/gnm.png new file mode 100644 index 0000000..709da7c Binary files /dev/null and b/drupal/nextjs/public/assets/icons/gnm.png differ diff --git a/drupal/nextjs/public/assets/icons/leopoldina.png b/drupal/nextjs/public/assets/icons/leopoldina.png new file mode 100644 index 0000000..d88d1de Binary files /dev/null and b/drupal/nextjs/public/assets/icons/leopoldina.png differ diff --git a/drupal/nextjs/public/assets/icons/mastodon.svg b/drupal/nextjs/public/assets/icons/mastodon.svg new file mode 100644 index 0000000..750bba2 --- /dev/null +++ b/drupal/nextjs/public/assets/icons/mastodon.svg @@ -0,0 +1,3 @@ + + + diff --git a/drupal/nextjs/public/assets/icons/nextjs.svg b/drupal/nextjs/public/assets/icons/nextjs.svg new file mode 100644 index 0000000..58a452e --- /dev/null +++ b/drupal/nextjs/public/assets/icons/nextjs.svg @@ -0,0 +1,4 @@ + + + + diff --git a/drupal/nextjs/public/assets/icons/objektsprache.png b/drupal/nextjs/public/assets/icons/objektsprache.png new file mode 100644 index 0000000..1e660b7 Binary files /dev/null and b/drupal/nextjs/public/assets/icons/objektsprache.png differ diff --git a/drupal/nextjs/public/assets/icons/re-cycle-halle.png b/drupal/nextjs/public/assets/icons/re-cycle-halle.png new file mode 100644 index 0000000..d1a4609 Binary files /dev/null and b/drupal/nextjs/public/assets/icons/re-cycle-halle.png differ diff --git a/drupal/nextjs/public/assets/icons/roli-bar.png b/drupal/nextjs/public/assets/icons/roli-bar.png new file mode 100644 index 0000000..0a692a9 Binary files /dev/null and b/drupal/nextjs/public/assets/icons/roli-bar.png differ diff --git a/drupal/nextjs/public/assets/icons/scs-manager.png b/drupal/nextjs/public/assets/icons/scs-manager.png new file mode 100644 index 0000000..1eef8c1 Binary files /dev/null and b/drupal/nextjs/public/assets/icons/scs-manager.png differ diff --git a/drupal/nextjs/public/assets/icons/spotify.svg b/drupal/nextjs/public/assets/icons/spotify.svg new file mode 100644 index 0000000..9ca691a --- /dev/null +++ b/drupal/nextjs/public/assets/icons/spotify.svg @@ -0,0 +1,4 @@ + + + + diff --git a/drupal/nextjs/public/assets/icons/wisski.svg b/drupal/nextjs/public/assets/icons/wisski.svg new file mode 100644 index 0000000..3f952fc --- /dev/null +++ b/drupal/nextjs/public/assets/icons/wisski.svg @@ -0,0 +1,687 @@ + + diff --git a/drupal/nextjs/public/assets/icons/youtube.svg b/drupal/nextjs/public/assets/icons/youtube.svg new file mode 100644 index 0000000..f4fcd50 --- /dev/null +++ b/drupal/nextjs/public/assets/icons/youtube.svg @@ -0,0 +1,20 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + diff --git a/drupal/nextjs/public/assets/icons/zenodo.svg b/drupal/nextjs/public/assets/icons/zenodo.svg new file mode 100644 index 0000000..fc8cc76 --- /dev/null +++ b/drupal/nextjs/public/assets/icons/zenodo.svg @@ -0,0 +1,3 @@ + + + diff --git a/drupal/nextjs/public/assets/icons/zikg.png b/drupal/nextjs/public/assets/icons/zikg.png new file mode 100644 index 0000000..cfc97e9 Binary files /dev/null and b/drupal/nextjs/public/assets/icons/zikg.png differ diff --git a/drupal/nextjs/public/assets/images/autumn.png b/drupal/nextjs/public/assets/images/autumn.png new file mode 100644 index 0000000..79eaa46 Binary files /dev/null and b/drupal/nextjs/public/assets/images/autumn.png differ diff --git a/drupal/nextjs/public/assets/images/chaos.png b/drupal/nextjs/public/assets/images/chaos.png new file mode 100644 index 0000000..0481889 Binary files /dev/null and b/drupal/nextjs/public/assets/images/chaos.png differ diff --git a/drupal/nextjs/public/assets/images/conference.png b/drupal/nextjs/public/assets/images/conference.png new file mode 100644 index 0000000..f51bcd2 Binary files /dev/null and b/drupal/nextjs/public/assets/images/conference.png differ diff --git a/drupal/nextjs/public/assets/images/explaining.png b/drupal/nextjs/public/assets/images/explaining.png new file mode 100644 index 0000000..66737df Binary files /dev/null and b/drupal/nextjs/public/assets/images/explaining.png differ diff --git a/drupal/nextjs/public/assets/images/family.png b/drupal/nextjs/public/assets/images/family.png new file mode 100644 index 0000000..56fd980 Binary files /dev/null and b/drupal/nextjs/public/assets/images/family.png differ diff --git a/drupal/nextjs/public/assets/images/kuss.png b/drupal/nextjs/public/assets/images/kuss.png new file mode 100644 index 0000000..b758911 Binary files /dev/null and b/drupal/nextjs/public/assets/images/kuss.png differ diff --git a/drupal/nextjs/public/assets/images/pres_1.png b/drupal/nextjs/public/assets/images/pres_1.png new file mode 100644 index 0000000..824fc67 Binary files /dev/null and b/drupal/nextjs/public/assets/images/pres_1.png differ diff --git a/drupal/nextjs/public/assets/images/robot.png b/drupal/nextjs/public/assets/images/robot.png new file mode 100644 index 0000000..f07c203 Binary files /dev/null and b/drupal/nextjs/public/assets/images/robot.png differ diff --git a/drupal/nextjs/public/assets/logos/lzfw_logo.png b/drupal/nextjs/public/assets/logos/lzfw_logo.png new file mode 100644 index 0000000..56b2bd0 Binary files /dev/null and b/drupal/nextjs/public/assets/logos/lzfw_logo.png differ diff --git a/drupal/nextjs/tsconfig.json b/drupal/nextjs/tsconfig.json new file mode 100644 index 0000000..d8b9323 --- /dev/null +++ b/drupal/nextjs/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/drupal/nginx/Dockerfile b/drupal/nginx/Dockerfile index 0ffadfd..efbcffd 100644 --- a/drupal/nginx/Dockerfile +++ b/drupal/nginx/Dockerfile @@ -3,6 +3,6 @@ FROM nginx:latest COPY ./nginx.conf.template /etc/nginx/nginx.conf.template ARG DOMAIN -RUN sed 's|${DOMAIN}|'"$DOMAIN"'|g' /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf +RUN envsubst '${DOMAIN}' < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf ENTRYPOINT ["nginx", "-g", "daemon off;"] diff --git a/drupal/nginx/nginx.conf.template b/drupal/nginx/nginx.conf.template index 5aee951..7d56f22 100644 --- a/drupal/nginx/nginx.conf.template +++ b/drupal/nginx/nginx.conf.template @@ -18,11 +18,21 @@ http { keepalive_timeout 65; gzip on; + # Increase client body size for file uploads. + client_max_body_size 64M; + server { listen 80; - server_name ${DOMAIN}; + server_name cms.${DOMAIN}; root /var/www/html; + # JSON:API endpoint caching headers. + location /jsonapi { + try_files $uri /index.php$is_args$args; + add_header Cache-Control "public, max-age=60"; + add_header X-Content-Type-Options nosniff; + } + location / { try_files $uri /index.php$is_args$args; } @@ -33,9 +43,10 @@ http { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param DOCUMENT_ROOT $document_root; + fastcgi_read_timeout 120; } - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { try_files $uri @rewrite; expires max; log_not_found off; @@ -45,19 +56,19 @@ http { rewrite ^ /index.php; } - # Don't allow direct access to PHP files in the vendor directory + # Don't allow direct access to PHP files in the vendor directory. location ~ /vendor/.*\.php$ { deny all; return 404; } - # Protect files and directories from prying eyes + # Protect files and directories from prying eyes. location ~* \.(engine|inc|install|make|module|profile|po|sh|.*sql|theme|twig|tpl(\.php)?|xtmpl|yml)(~|\.sw[op]|\.bak|\.orig|\.save)?$|^(\.(?!well-known).*|Entries.*|Repository|Root|Tag|Template|composer\.(json|lock)|web\.config)$|^#.*#$|\.php(~|\.sw[op]|\.bak|\.orig|\.save)$ { deny all; return 404; } - # Protect .git directory + # Protect .git directory. location ~ /\.git { deny all; return 404;