Compare commits
No commits in common. "main" and "0.0.3" have entirely different histories.
9
.gitignore
vendored
|
|
@ -1,14 +1,8 @@
|
|||
# General
|
||||
bkp/*
|
||||
.cursor
|
||||
**/.vscode/
|
||||
.env
|
||||
**/.env
|
||||
*.log
|
||||
*.out
|
||||
*.tmp
|
||||
HEALTH_CHECK_REPORT.md
|
||||
/core/mta-sts/.well-known/mta-sts.txt
|
||||
secrets/*
|
||||
!secrets/.gitkeep
|
||||
**/.git
|
||||
|
|
@ -102,5 +96,4 @@ mailcow/create_cold_standby.sh
|
|||
nextcloud/hooks/post-installation/set-configs.sh
|
||||
**/volumes/
|
||||
mailcow/data/conf/dovecot/auth/passwd-verify.lua
|
||||
mailcow/data/conf/postfix/main.cf
|
||||
passwd-verify.lua
|
||||
mailcow/data/conf/postfix/main.cf
|
||||
12
CHANGELOG
|
|
@ -1,12 +0,0 @@
|
|||
0.0.3
|
||||
- Fix the postgres-loose-it-all-volume mistake...
|
||||
+ Volume have to be `/var/lib/postgresql/data` not `/var/lib/postgresql/` or everthing will be deleted, if the container is rebuild.
|
||||
- More robust Nextcloud config.
|
||||
- Better reuse of gits for updating and maintainment.
|
||||
0.0.2
|
||||
- Make things more agnostic by
|
||||
+ use original gits
|
||||
+ have envs and overrides in parent dirs
|
||||
- better automatisation with copy_overrides-bash and create_infra.bash on root level.
|
||||
0.0.1
|
||||
- First release.
|
||||
204
README.md
|
|
@ -3,29 +3,22 @@
|
|||
This repository contains a productive stack of open-source applications for team collaboration and communication. The stack includes:
|
||||
|
||||
### Core Infrastructure
|
||||
- **Traefik**: Edge router that handles routing, TLS (Let's Encrypt) and load balancing for all services
|
||||
- **Traefik**: Edge router that handles routing and load balancing for all services
|
||||
- **PostgreSQL**: Relational database for applications requiring PostgreSQL
|
||||
- **MariaDB**: MySQL-compatible database for applications requiring MySQL
|
||||
- **Adminer**: Database management tool for easy database administration
|
||||
- **WireGuard**: VPN gateway; SSH is reachable only from within the VPN subnet
|
||||
- **MTA-STS**: Static policy host for mail transport security
|
||||
|
||||
### Code Hosting & CI
|
||||
- **Forgejo**: Self-hosted Git forge (replaces GitLab) at `git.${DOMAIN}`, SSH on port 2424
|
||||
- **Forgejo Actions Runner**: CI runner with an isolated Docker-in-Docker engine for `.forgejo/workflows`
|
||||
|
||||
### Mail
|
||||
- **Mailcow**: Complete mail server solution with SMTP, IMAP, antivirus, and webmail
|
||||
|
||||
### Collaboration Tools
|
||||
- **Nextcloud**: Self-hosted file sync and share platform (with Collabora/Talk)
|
||||
- **Nextcloud**: Self-hosted file sync and share platform with collaboration features
|
||||
- **OnlyOffice**: Online office suite for document editing and collaboration
|
||||
- **OpenProject**: Project management and team collaboration software
|
||||
- **HedgeDoc**: Collaborative markdown notes editor for team documentation
|
||||
|
||||
### Web Publishing
|
||||
- **Drupal**: Headless content management system (CMS), PHP-FPM + NGINX, Redis cache
|
||||
- **Next.js**: Decoupled frontend (`${DOMAIN}`) consuming the Drupal backend
|
||||
- **Drupal**: Flexible content management system (CMS) for building websites
|
||||
|
||||
All components are containerized using Docker for easy deployment, scaling, and management, creating a complete productivity environment for teams.
|
||||
|
||||
|
|
@ -34,28 +27,6 @@ You need [docker](https://docs.docker.com/get-started/get-docker/) and with [doc
|
|||
|
||||
At least 6 cores with 16GB RAM 100GB SSD would be sufficent.
|
||||
|
||||
## Diagnostics
|
||||
Run the unified diagnostics script from the repository root:
|
||||
```bash
|
||||
./diagnostic.sh
|
||||
```
|
||||
This combines the previous `diagnostic.sh` and `health_check.sh` checks.
|
||||
|
||||
## Mail Security
|
||||
|
||||
### Current Status
|
||||
- ✅ SPF, DKIM, DMARC configured
|
||||
- ✅ MTA-STS policy enforced (`https://mta-sts.nasarek.dev/.well-known/mta-sts.txt`)
|
||||
- ✅ TLS-RPT configured
|
||||
- ✅ TLS certificates valid on all mail ports
|
||||
- ⚠️ DNSSEC: Enable at DNS provider and ensure DS/DNSKEY are published
|
||||
- ⚠️ TLSA (DANE): Add records after DNSSEC is active (see `/var/deploy/scripts/README-TLSA.md`)
|
||||
|
||||
### TLSA Record Automation
|
||||
Automated TLSA record updates are available. See `/var/deploy/scripts/README-TLSA.md` for setup instructions.
|
||||
|
||||
The automation monitors certificate changes and updates TLSA records automatically when certificates are renewed.
|
||||
|
||||
## Install
|
||||
### Prerequisites
|
||||
1) Copy the env and docker-compose.override.yml to the service directories via the script.
|
||||
|
|
@ -64,7 +35,7 @@ Add your specific settings in `set-config.sh`, then:
|
|||
```
|
||||
./copy_overrides.bash
|
||||
```
|
||||
2) Set your env variables in `./core/.env`, `./drupal/.env`, `./forgejo/.env`, `./hedgedoc/.env`, `./onlyoffice/.env`, `./nextcloud/.env`, `./openproject/.env`.
|
||||
2) Set your env variables in `./core/.env`, `./drupal/.env`, `./gitlab/.env`, `./hedgedoc/.env`, `./onlyoffice/.env`, `./nextcloud/.env`, `./openproject/.env`.
|
||||
|
||||
3) Generate mailcow config
|
||||
```bash
|
||||
|
|
@ -86,9 +57,9 @@ cp mailcow.conf .env
|
|||
docker compose -f core/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
### Drupal + Next.js
|
||||
1) Set your env variables in `drupal/.env` and `nextjs/.env.local`.
|
||||
2) Start the stack (NGINX, Drupal PHP-FPM, Redis and the Next.js frontend).
|
||||
### Drupal
|
||||
1) Set your env variables in
|
||||
2) Start Drupal containers.
|
||||
```bash
|
||||
docker compose -f drupal/docker-compose.yml up -d
|
||||
```
|
||||
|
|
@ -109,38 +80,18 @@ $settings['cache']['bins']['config'] = 'cache.backend.chainedfast';
|
|||
|
||||
4) Visit your Domain and install Drupal site.
|
||||
|
||||
### Forgejo
|
||||
Git forge (replaces GitLab). Uses the shared Postgres DB and Traefik. SSH is
|
||||
served over the Traefik `forgejo-ssh` TCP entrypoint on port 2424.
|
||||
### Gitlab
|
||||
1) Start gitlab.
|
||||
```bash
|
||||
docker compose -f gitlab/docker-compose.yml up -d
|
||||
```
|
||||
2) Get your root password.
|
||||
```bash
|
||||
sudo docker exec -it gitlab grep 'Password:'
|
||||
/etc/gitlab/initial_root_password
|
||||
```
|
||||
|
||||
1) Start Forgejo.
|
||||
```bash
|
||||
docker compose -f forgejo/docker-compose.yml up -d
|
||||
```
|
||||
2) Create the first admin user (headless install is enabled, so skip the web installer).
|
||||
```bash
|
||||
docker exec -u 1000 forgejo forgejo admin user create \
|
||||
--admin --username <you> --email <you@domain> --random-password
|
||||
```
|
||||
3) Visit `https://git.${DOMAIN}` and log in. Add your SSH key under
|
||||
*Settings → SSH/GPG Keys*. Clone URLs use `git@git.${DOMAIN}:<owner>/<repo>.git`
|
||||
on SSH port 2424.
|
||||
|
||||
#### CI runner (Forgejo Actions)
|
||||
1) Generate a registration token and put it in `forgejo-runner/.env`:
|
||||
```bash
|
||||
docker exec -u 1000 forgejo forgejo actions generate-runner-token
|
||||
```
|
||||
2) Start the runner (registers automatically on first boot; CI jobs run in
|
||||
an isolated Docker-in-Docker engine).
|
||||
```bash
|
||||
docker compose -f forgejo-runner/docker-compose.yml up -d
|
||||
```
|
||||
Workflows live in each repo under `.forgejo/workflows/*.yml` (GitHub-Actions syntax).
|
||||
|
||||
#### Migrating from GitLab
|
||||
A helper script migrates all GitLab projects (code, issues, MRs, labels,
|
||||
milestones, releases, wiki). See `forgejo/migrate-from-gitlab.sh`.
|
||||
4) Visit you domain and log in.
|
||||
|
||||
### Hedgedoc
|
||||
1) Start containers.
|
||||
|
|
@ -157,7 +108,7 @@ docker exec hedgedoc bin/manage_users --pass ${HEDGEDOC_USER_PASSWORD} --add ${H
|
|||
### Mailcow
|
||||
1) Start containers.
|
||||
```bash
|
||||
cd mailcow && docker compose up -d
|
||||
docker compose docker-compose.yml up -d
|
||||
```
|
||||
|
||||
4) Visit DOMAIN/admin and log in with admin:admin.
|
||||
|
|
@ -182,115 +133,30 @@ docker compose -f nextcloud/docker-compose.yml up -d
|
|||
```
|
||||
3) Visit nextcloud domain and login with your .env credentials.
|
||||
|
||||
#### Nextcloud Office with Collabora
|
||||
Collabora container is included for document editing. Configure via Nextcloud admin panel:
|
||||
1) Install **Nextcloud Office** app from Apps menu.
|
||||
2) Go to **Settings** → **Administration** → **Office**.
|
||||
3) Select **"Use your own server"** and enter: `https://office.yourdomain.com`.
|
||||
4) Configure WOPI allowlist with Collabora's IP:
|
||||
```bash
|
||||
# Get Collabora IP.
|
||||
docker inspect nextcloud-collabora --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
|
||||
# Set allowlist.
|
||||
docker exec -u www-data nextcloud php occ config:app:set richdocuments wopi_allowlist --value="COLLABORA_IP"
|
||||
# Enable local servers.
|
||||
docker exec -u www-data nextcloud php occ config:system:set allow_local_remote_servers --value=true --type=boolean
|
||||
```
|
||||
See `/var/deploy/nextcloud/COLLABORA-QUICK-SETUP.md` for details.
|
||||
|
||||
### Openproject
|
||||
1) Start containers.
|
||||
```bash
|
||||
docker compose -f openproject/docker-compose.yml up -d
|
||||
docker compose -f hedgedoc/docker-compose.yml up -d
|
||||
```
|
||||
2) Visit openproject domain and login with admin:admin and set new password.
|
||||
|
||||
## SSH over VPN (WireGuard)
|
||||
|
||||
SSH access is secured behind a WireGuard VPN. Port 22 is only reachable from within the VPN subnet (`10.13.13.0/24`).
|
||||
|
||||
### Server-side setup
|
||||
|
||||
**1. Configure `core/.env`**
|
||||
|
||||
```env
|
||||
WG_SERVERURL=your.server.hostname.or.ip
|
||||
WG_PEERS=laptop,phone # or a number, e.g. "3"
|
||||
TZ=Europe/Berlin
|
||||
```
|
||||
|
||||
**2. Start the WireGuard container**
|
||||
|
||||
```bash
|
||||
docker compose -f core/docker-compose.yml up -d wireguard
|
||||
```
|
||||
|
||||
Peer configs and QR codes are generated automatically in:
|
||||
```
|
||||
core/volumes/wireguard/config/peer_<name>/
|
||||
peer_<name>.conf ← import this on the client
|
||||
peer_<name>.png ← scan this QR code on mobile
|
||||
```
|
||||
|
||||
**3. Apply firewall rules**
|
||||
|
||||
Run *after* confirming the VPN works (see client setup below):
|
||||
|
||||
```bash
|
||||
sudo bash scripts/secure-ssh-vpn.sh [--dry-run]
|
||||
```
|
||||
|
||||
This opens ports 80, 443, 25, 465, 587, 143, 993, 4190, 2424, 51820 and restricts SSH to VPN clients only. To also restrict mail client ports (IMAP, submission) to VPN:
|
||||
|
||||
```bash
|
||||
sudo bash scripts/secure-ssh-vpn.sh --mail-vpn-only
|
||||
```
|
||||
|
||||
### Local client setup
|
||||
|
||||
#### Linux
|
||||
|
||||
```bash
|
||||
sudo apt install wireguard
|
||||
# Copy peer config from the server
|
||||
scp user@your-server:/var/deploy/core/volumes/wireguard/config/peer_laptop/peer_laptop.conf \
|
||||
~/.config/wireguard/wg0.conf
|
||||
sudo wg-quick up wg0
|
||||
# Connect on boot:
|
||||
sudo systemctl enable wg-quick@wg0
|
||||
```
|
||||
|
||||
#### macOS
|
||||
|
||||
```bash
|
||||
brew install wireguard-tools
|
||||
# Or use the App Store app: "WireGuard"
|
||||
# Import peer_laptop.conf via File → Import Tunnel(s) from File
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
Download [WireGuard for Windows](https://www.wireguard.com/install/), then:
|
||||
*Add Tunnel → Import tunnel(s) from file* → select `peer_laptop.conf`.
|
||||
|
||||
#### Android / iOS
|
||||
|
||||
Scan the QR code at `core/volumes/wireguard/config/peer_<name>/peer_<name>.png` with the WireGuard app.
|
||||
|
||||
### Verify the tunnel
|
||||
|
||||
```bash
|
||||
# On the server — check connected peers
|
||||
docker exec wireguard wg show
|
||||
|
||||
# From the client — SSH should work only after connecting to VPN
|
||||
ssh user@10.13.13.1
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
- Tweak the core components and subservices for petter performance.
|
||||
- More automatisation when installing the environment.
|
||||
- Add more services
|
||||
- [ ] Matrix/Synapse + Element
|
||||
- Fix all the Nextcloud errors
|
||||
+ Your web server is not properly set up to resolve `.well-known` URLs, failed on: `/.well-known/webfinger` For more details see the documentation ↗.
|
||||
- Better reuse of gits for updating and maintainment.
|
||||
|
||||
## Changelog
|
||||
|
||||
0.0.3
|
||||
- Fix the postgres-loose-it-all-volume mistake...
|
||||
+ Volume have to be `/var/lib/postgresql/data` not `/var/lib/postgresql/` or everthing will be deleted, if the container is rebuild.
|
||||
- More robust Nextcloud config.
|
||||
0.0.2
|
||||
- Make things more agnostic by
|
||||
+ use original gits
|
||||
+ have envs and overrides in parent dirs
|
||||
- better automatisation with copy_overrides-bash and create_infra.bash on root level.
|
||||
0.0.1
|
||||
- First release.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
echo "Copying environment variables..."
|
||||
cp override/core.example.env core/.env
|
||||
cp override/drupal.example.env drupal/.env
|
||||
cp override/forgejo.example.env forgejo/.env
|
||||
cp override/gitlab.example.env gitlab/.env
|
||||
cp override/nextcloud.example.env nextcloud/.env
|
||||
cp override/onlyoffice.example.env onlyoffice/.env
|
||||
cp override/openproject.example.env openproject/.env
|
||||
|
|
|
|||
|
|
@ -1,33 +1,7 @@
|
|||
services:
|
||||
# VPN — WireGuard server. Clients must connect before SSH is reachable.
|
||||
# network_mode: host is required so wg0 is created on the host network stack,
|
||||
# making 10.13.13.1 reachable by sshd and other host services.
|
||||
wireguard:
|
||||
image: linuxserver/wireguard:latest
|
||||
container_name: wireguard
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_MODULE
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=${TZ:-Europe/Berlin}
|
||||
- SERVERURL=${WG_SERVERURL}
|
||||
- SERVERPORT=51820
|
||||
- PEERS=${WG_PEERS}
|
||||
- PEERDNS=auto
|
||||
- INTERNAL_SUBNET=10.13.13.0
|
||||
- ALLOWEDIPS=10.13.13.0/24
|
||||
- LOG_CONFS=false
|
||||
volumes:
|
||||
- ./volumes/wireguard/config:/config
|
||||
- /lib/modules:/lib/modules:ro
|
||||
restart: no
|
||||
|
||||
# Database-Stack
|
||||
adminer:
|
||||
image: adminer:5
|
||||
image: adminer
|
||||
container_name: adminer
|
||||
depends_on:
|
||||
- mariadb
|
||||
|
|
@ -47,7 +21,7 @@ services:
|
|||
restart: unless-stopped
|
||||
|
||||
mariadb:
|
||||
image: mariadb:12
|
||||
image: mariadb:11.5.2
|
||||
container_name: mariadb
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
|
||||
|
|
@ -75,7 +49,7 @@ services:
|
|||
|
||||
# Traefik
|
||||
traefik:
|
||||
image: traefik:3
|
||||
image: traefik:3.3
|
||||
container_name: traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
|
|
@ -90,10 +64,6 @@ services:
|
|||
- "traefik.http.middlewares.nextcloud-headers.headers.stsPreload=true"
|
||||
- "traefik.http.middlewares.nextcloud-headers.headers.forceSTSHeader=true"
|
||||
|
||||
# Timeout middlewares
|
||||
- "traefik.http.middlewares.timeout.headers.customrequestheaders.X-Forwarded-Timeout=120"
|
||||
- "traefik.http.middlewares.timeout.headers.customresponseheaders.X-Response-Timeout=120"
|
||||
|
||||
# routers
|
||||
- "traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN}`)"
|
||||
- "traefik.http.routers.traefik.entrypoints=web,websecure"
|
||||
|
|
@ -110,8 +80,8 @@ services:
|
|||
- --providers.docker
|
||||
# Disable exposing services without Traefik labels
|
||||
- --providers.docker.exposedbydefault=false
|
||||
# Listen on port 2424 for Forgejo SSH requests
|
||||
- --entrypoints.forgejo-ssh.address=:2424
|
||||
# Listen on port 2424 for SSH requests
|
||||
- --entrypoints.gitlab-ssh.address=:2424
|
||||
# Listen on port 80 for HTTP requests
|
||||
- --entrypoints.web.address=:80
|
||||
# Listen on port 443 for HTTPS requests
|
||||
|
|
@ -119,7 +89,7 @@ services:
|
|||
# Redirect HTTP requests to HTTPS
|
||||
- --entrypoints.web.http.redirections.entryPoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entryPoint.scheme=https
|
||||
- --entrypoints.web.http.redirections.entryPoint.permanent=true
|
||||
- --entrypoints.web.http.redirections.entrypoint.permanent=true
|
||||
# Use the specified email address for Let's Encrypt certificate requests
|
||||
- --certificatesresolvers.le.acme.email=${TRAEFIK_EMAIL}
|
||||
# Use the HTTP challenge for Let's Encrypt certificate requests
|
||||
|
|
@ -134,10 +104,6 @@ services:
|
|||
- --log.level=INFO
|
||||
# Enable the Traefik API
|
||||
- --api
|
||||
# Set global timeouts
|
||||
- --serverstransport.forwardingtimeouts.dialtimeout=120s
|
||||
- --serverstransport.forwardingtimeouts.responseheadertimeout=120s
|
||||
- --serverstransport.forwardingtimeouts.idleconntimeout=120s
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- server-certificates:/certificates
|
||||
|
|
@ -149,23 +115,6 @@ services:
|
|||
- traefik
|
||||
restart: unless-stopped
|
||||
|
||||
mta-sts:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: mta-sts
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.http.routers.mta-sts.rule=Host(`mta-sts.${DOMAIN}`)"
|
||||
- "traefik.http.routers.mta-sts.entrypoints=websecure"
|
||||
- "traefik.http.routers.mta-sts.tls=true"
|
||||
- "traefik.http.routers.mta-sts.tls.certresolver=le"
|
||||
- "traefik.http.services.mta-sts.loadbalancer.server.port=80"
|
||||
volumes:
|
||||
- ./mta-sts:/usr/share/nginx/html:ro
|
||||
networks:
|
||||
- traefik
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
server-certificates:
|
||||
name: server-certificates
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
version: STSv1
|
||||
mode: enforce
|
||||
mx: {{MAILCOW_HOSTNAME}}
|
||||
max_age: 604800
|
||||
|
|
@ -1,39 +1,9 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
source ./core/.env
|
||||
|
||||
source ./drupal/.env
|
||||
|
||||
create_mta_sts_policy() {
|
||||
local template_path="./core/mta-sts/.well-known/mta-sts.txt.template"
|
||||
local output_path="./core/mta-sts/.well-known/mta-sts.txt"
|
||||
local mailcow_conf="./mailcow/mailcow.conf"
|
||||
local mailcow_hostname=""
|
||||
|
||||
if [ ! -f "$template_path" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f "$mailcow_conf" ]; then
|
||||
mailcow_hostname=$(grep '^MAILCOW_HOSTNAME=' "$mailcow_conf" | cut -d= -f2)
|
||||
fi
|
||||
|
||||
if [ -z "$mailcow_hostname" ] && [ -n "$DOMAIN" ]; then
|
||||
mailcow_hostname="mail.${DOMAIN}"
|
||||
fi
|
||||
|
||||
if [ ! -f "$output_path" ]; then
|
||||
mkdir -p "$(dirname "$output_path")"
|
||||
sed "s/{{MAILCOW_HOSTNAME}}/${mailcow_hostname}/g" "$template_path" > "$output_path"
|
||||
echo "Created MTA-STS policy at ${output_path}"
|
||||
else
|
||||
echo "MTA-STS policy already exists at ${output_path}"
|
||||
fi
|
||||
}
|
||||
|
||||
create_mta_sts_policy
|
||||
|
||||
if [ ! -d "./drupal/drupal/root" ]; then
|
||||
echo "Creating Drupal infrastructure..."
|
||||
mkdir -p ./drupal/drupal/root
|
||||
|
|
@ -74,10 +44,3 @@ docker exec postgres psql -U $POSTGRES_USER -d postgres -c "CREATE DATABASE $OPE
|
|||
docker exec postgres psql -U $POSTGRES_USER -d $OPENPROJECT_DB_NAME -c "GRANT ALL PRIVILEGES ON DATABASE $OPENPROJECT_DB_NAME TO $OPENPROJECT_DB_USER;"
|
||||
echo "OpenProject infrastructure created"
|
||||
|
||||
echo "Creating Forgejo infrastructure..."
|
||||
source ./forgejo/.env
|
||||
docker exec postgres psql -U $POSTGRES_USER -d postgres -c "CREATE USER $FORGEJO_DB_USER WITH PASSWORD '$FORGEJO_DB_PASSWORD';"
|
||||
docker exec postgres psql -U $POSTGRES_USER -d postgres -c "CREATE DATABASE $FORGEJO_DB_NAME OWNER $FORGEJO_DB_USER;"
|
||||
docker exec postgres psql -U $POSTGRES_USER -d $FORGEJO_DB_NAME -c "GRANT ALL PRIVILEGES ON DATABASE $FORGEJO_DB_NAME TO $FORGEJO_DB_USER;"
|
||||
echo "Forgejo infrastructure created"
|
||||
|
||||
|
|
|
|||
553
diagnostic.sh
|
|
@ -1,515 +1,164 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Colors for output.
|
||||
green='\033[0;32m'
|
||||
yellow='\033[1;33m'
|
||||
red='\033[0;31m'
|
||||
blue='\033[0;34m'
|
||||
noColor='\033[0m'
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
printSection() {
|
||||
echo -e "\n${blue}--- $1 ---${noColor}"
|
||||
}
|
||||
echo -e "${YELLOW}Running diagnostic checks for Open Productive Stack...${NC}"
|
||||
|
||||
checkStatus() {
|
||||
local status="$1"
|
||||
local message="$2"
|
||||
# Function to check if a service is running
|
||||
check_service() {
|
||||
local service=$1
|
||||
|
||||
if [ "$status" = "0" ] || [ "$status" = "true" ] || [ "$status" = "OK" ] || [ "$status" = "healthy" ]; then
|
||||
echo -e "${green}✓${noColor} $message"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo -e "${red}✗${noColor} $message"
|
||||
return 1
|
||||
}
|
||||
|
||||
warnStatus() {
|
||||
local message="$1"
|
||||
echo -e "${yellow}⚠${noColor} $message"
|
||||
}
|
||||
|
||||
checkService() {
|
||||
local service="$1"
|
||||
|
||||
echo -e "${yellow}Checking if ${service} is running...${noColor}"
|
||||
echo -e "${YELLOW}Checking if $service is running...${NC}"
|
||||
if docker ps | grep -q "$service"; then
|
||||
echo -e "${green}${service} is running.${noColor}"
|
||||
echo -e "${GREEN}$service is running.${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}$service is not running.${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e "${red}${service} is not running.${noColor}"
|
||||
return 1
|
||||
}
|
||||
|
||||
checkConnectivity() {
|
||||
local service="$1"
|
||||
local port="$2"
|
||||
local host="${3:-localhost}"
|
||||
# Function to check network connectivity
|
||||
check_connectivity() {
|
||||
local service=$1
|
||||
local port=$2
|
||||
local host=${3:-localhost}
|
||||
|
||||
echo -e "${yellow}Checking connectivity to ${service} on ${host}:${port}...${noColor}"
|
||||
echo -e "${YELLOW}Checking connectivity to $service on $host:$port...${NC}"
|
||||
if nc -z -v -w5 "$host" "$port" 2>/dev/null; then
|
||||
echo -e "${green}Connection to ${service} on ${host}:${port} successful.${noColor}"
|
||||
echo -e "${GREEN}Connection to $service on $host:$port successful.${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}Cannot connect to $service on $host:$port.${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e "${red}Cannot connect to ${service} on ${host}:${port}.${noColor}"
|
||||
return 1
|
||||
}
|
||||
|
||||
loadCoreDomain() {
|
||||
local coreEnvFile="/var/deploy/core/.env"
|
||||
if [ -f "$coreEnvFile" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "$coreEnvFile"
|
||||
echo -e "${yellow}Domain configuration:${noColor} ${DOMAIN}"
|
||||
return 0
|
||||
fi
|
||||
# Function to check Traefik configuration
|
||||
check_traefik() {
|
||||
echo -e "${YELLOW}Checking Traefik configuration...${NC}"
|
||||
|
||||
echo -e "${red}Core .env file not found.${noColor}"
|
||||
DOMAIN="example.com"
|
||||
return 1
|
||||
}
|
||||
# Check if Traefik is running
|
||||
check_service "traefik" || return 1
|
||||
|
||||
loadMailcowHostname() {
|
||||
local mailcowConfFile="/var/deploy/mailcow/mailcow.conf"
|
||||
if [ -f "$mailcowConfFile" ]; then
|
||||
mailcowHostname=$(grep '^MAILCOW_HOSTNAME=' "$mailcowConfFile" | cut -d= -f2)
|
||||
return 0
|
||||
fi
|
||||
# Check Traefik ports
|
||||
check_connectivity "Traefik HTTP" 80 || echo -e "${RED}Traefik HTTP port not accessible.${NC}"
|
||||
check_connectivity "Traefik HTTPS" 443 || echo -e "${RED}Traefik HTTPS port not accessible.${NC}"
|
||||
check_connectivity "Traefik SSH" 2424 || echo -e "${RED}Traefik SSH port not accessible.${NC}"
|
||||
|
||||
mailcowHostname=""
|
||||
return 1
|
||||
}
|
||||
|
||||
checkTraefik() {
|
||||
echo -e "${yellow}Checking Traefik configuration...${noColor}"
|
||||
|
||||
checkService "traefik" || return 1
|
||||
|
||||
checkConnectivity "Traefik HTTP" 80 || echo -e "${red}Traefik HTTP port not accessible.${noColor}"
|
||||
checkConnectivity "Traefik HTTPS" 443 || echo -e "${red}Traefik HTTPS port not accessible.${noColor}"
|
||||
checkConnectivity "Traefik SSH" 2424 || echo -e "${red}Traefik SSH port not accessible.${noColor}"
|
||||
|
||||
echo -e "${yellow}Checking Traefik certificates...${noColor}"
|
||||
# Check Traefik certificates
|
||||
echo -e "${YELLOW}Checking Traefik certificates...${NC}"
|
||||
if docker exec traefik ls -la /certificates/acme.json >/dev/null 2>&1; then
|
||||
echo -e "${green}Traefik certificates found.${noColor}"
|
||||
echo -e "${GREEN}Traefik certificates found.${NC}"
|
||||
else
|
||||
echo -e "${red}Traefik certificates not found.${noColor}"
|
||||
echo -e "${RED}Traefik certificates not found.${NC}"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
checkForgejo() {
|
||||
echo -e "${yellow}Checking Forgejo configuration...${noColor}"
|
||||
# Function to check GitLab configuration
|
||||
check_gitlab() {
|
||||
echo -e "${YELLOW}Checking GitLab configuration...${NC}"
|
||||
|
||||
checkService "forgejo" || return 1
|
||||
# Check if GitLab is running
|
||||
check_service "gitlab" || return 1
|
||||
|
||||
docker exec forgejo grep -q "ROOT_URL" /data/gitea/conf/app.ini && \
|
||||
echo -e "${green}Forgejo root URL is configured.${noColor}" || \
|
||||
echo -e "${red}Forgejo root URL is not configured.${noColor}"
|
||||
# Check GitLab HTTP port
|
||||
docker exec gitlab grep -q "external_url" /etc/gitlab/gitlab.rb && \
|
||||
echo -e "${GREEN}GitLab external URL is configured.${NC}" || \
|
||||
echo -e "${RED}GitLab external URL is not configured.${NC}"
|
||||
|
||||
docker exec forgejo grep -q "SSH_PORT" /data/gitea/conf/app.ini && \
|
||||
echo -e "${green}Forgejo SSH port is configured.${noColor}" || \
|
||||
echo -e "${red}Forgejo SSH port is not configured.${noColor}"
|
||||
# Check GitLab SSH port
|
||||
docker exec gitlab grep -q "gitlab_shell_ssh_port" /etc/gitlab/gitlab.rb && \
|
||||
echo -e "${GREEN}GitLab SSH port is configured.${NC}" || \
|
||||
echo -e "${RED}GitLab SSH port is not configured.${NC}"
|
||||
|
||||
echo -e "${yellow}Checking Forgejo SSH connection...${noColor}"
|
||||
if ssh -T git@git.${DOMAIN} -p 2424 -o StrictHostKeyChecking=no -o BatchMode=yes &>/dev/null; then
|
||||
echo -e "${green}Forgejo SSH connection successful.${noColor}"
|
||||
# Check GitLab SSH connection
|
||||
echo -e "${YELLOW}Checking GitLab SSH connection...${NC}"
|
||||
if ssh -T git@gitlab.${DOMAIN} -p 2424 -o StrictHostKeyChecking=no -o BatchMode=yes &>/dev/null; then
|
||||
echo -e "${GREEN}GitLab SSH connection successful.${NC}"
|
||||
else
|
||||
echo -e "${red}Forgejo SSH connection failed. This is expected if you haven't set up SSH keys yet.${noColor}"
|
||||
echo -e "${yellow}Try: ssh -vT git@git.${DOMAIN} -p 2424${noColor}"
|
||||
echo -e "${RED}GitLab SSH connection failed. This is expected if you haven't set up SSH keys yet.${NC}"
|
||||
echo -e "${YELLOW}Try: ssh -vT git@gitlab.${DOMAIN} -p 2424${NC}"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
checkDatabases() {
|
||||
echo -e "${yellow}Checking database services...${noColor}"
|
||||
# Function to check database services
|
||||
check_databases() {
|
||||
echo -e "${YELLOW}Checking database services...${NC}"
|
||||
|
||||
checkService "mariadb" && \
|
||||
echo -e "${green}MariaDB is running.${noColor}" || \
|
||||
echo -e "${red}MariaDB is not running.${noColor}"
|
||||
# Check MariaDB
|
||||
check_service "mariadb" && \
|
||||
echo -e "${GREEN}MariaDB is running.${NC}" || \
|
||||
echo -e "${RED}MariaDB is not running.${NC}"
|
||||
|
||||
checkService "postgres" && \
|
||||
echo -e "${green}PostgreSQL is running.${noColor}" || \
|
||||
echo -e "${red}PostgreSQL is not running.${noColor}"
|
||||
# Check PostgreSQL
|
||||
check_service "postgres" && \
|
||||
echo -e "${GREEN}PostgreSQL is running.${NC}" || \
|
||||
echo -e "${RED}PostgreSQL is not running.${NC}"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
checkNextcloud() {
|
||||
echo -e "${yellow}Checking Nextcloud configuration...${noColor}"
|
||||
# Function to check all other services
|
||||
check_all_services() {
|
||||
echo -e "${YELLOW}Checking all services...${NC}"
|
||||
|
||||
checkService "nextcloud" || return 1
|
||||
checkService "nextcloud-redis" || return 1
|
||||
checkService "nextcloud-reverse-proxy" || return 1
|
||||
|
||||
echo -e "${yellow}Checking Nextcloud status...${noColor}"
|
||||
if docker exec nextcloud php /var/www/html/occ status 2>&1 | grep -q "installed: true"; then
|
||||
echo -e "${green}Nextcloud is installed and operational.${noColor}"
|
||||
docker exec nextcloud php /var/www/html/occ status 2>&1 | grep -E "version|maintenance" | sed 's/^/ /'
|
||||
else
|
||||
echo -e "${red}Nextcloud is not properly installed.${noColor}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e "${yellow}Checking Redis connectivity...${noColor}"
|
||||
if docker exec nextcloud-redis redis-cli ping 2>&1 | grep -q "PONG"; then
|
||||
echo -e "${green}Redis is responding.${noColor}"
|
||||
else
|
||||
echo -e "${red}Redis is not responding.${noColor}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e "${yellow}Checking database collation...${noColor}"
|
||||
local collationCheck
|
||||
collationCheck=$(docker exec nextcloud php /var/www/html/occ status 2>&1 | grep -i "collation")
|
||||
if [ -n "$collationCheck" ]; then
|
||||
echo -e "${yellow}Database collation version mismatch detected.${noColor}"
|
||||
echo -e "${yellow}Run: ./nextcloud-maintenance.sh collation${noColor}"
|
||||
else
|
||||
echo -e "${green}Database collation is up to date.${noColor}"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
checkAllServices() {
|
||||
echo -e "${yellow}Checking all services...${noColor}"
|
||||
|
||||
local services=("traefik" "forgejo" "mariadb" "postgres" "adminer" "nextcloud" "onlyoffice" "openproject" "hedgedoc" "drupal")
|
||||
local services=("traefik" "gitlab" "mariadb" "postgres" "adminer" "nextcloud" "onlyoffice" "openproject" "hedgedoc" "drupal")
|
||||
|
||||
for service in "${services[@]}"; do
|
||||
checkService "$service"
|
||||
check_service "$service"
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
checkMailcowServices() {
|
||||
printSection "1. Service Health Status"
|
||||
|
||||
echo -e "${yellow}Checking Docker services...${noColor}"
|
||||
local traefikStatus
|
||||
traefikStatus=$(docker ps --filter "name=traefik" --format "{{.Status}}" | grep -q "Up" && echo "OK" || echo "FAIL")
|
||||
checkStatus "$traefikStatus" "Traefik is running"
|
||||
|
||||
local mailcowServices
|
||||
local totalMailcow
|
||||
mailcowServices=$(cd /var/deploy/mailcow && docker compose ps --format json 2>/dev/null | jq -r '.State' | grep -c "running" 2>/dev/null || echo "0")
|
||||
totalMailcow=$(cd /var/deploy/mailcow && docker compose ps --format json 2>/dev/null | jq -r '.State' | wc -l 2>/dev/null || echo "0")
|
||||
if [ "$mailcowServices" = "$totalMailcow" ] && [ "$totalMailcow" -gt 0 ]; then
|
||||
checkStatus "OK" "All Mailcow services running (${mailcowServices}/${totalMailcow})"
|
||||
else
|
||||
checkStatus "FAIL" "Some Mailcow services not running (${mailcowServices}/${totalMailcow})"
|
||||
fi
|
||||
|
||||
local criticalServices=("nginx-mailcow" "postfix-mailcow" "dovecot-mailcow" "mysql-mailcow" "acme-mailcow" "watchdog-mailcow")
|
||||
for service in "${criticalServices[@]}"; do
|
||||
local serviceStatus
|
||||
serviceStatus=$(cd /var/deploy/mailcow && docker compose ps --format json 2>/dev/null | jq -r "select(.Service==\"$service\") | .State" | head -1)
|
||||
if [ "$serviceStatus" = "running" ]; then
|
||||
checkStatus "OK" "${service} is running"
|
||||
else
|
||||
checkStatus "FAIL" "${service} is not running"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
checkSslAndCerts() {
|
||||
printSection "2. SSL/TLS Configuration"
|
||||
|
||||
echo -e "${yellow}Traefik SSL Configuration:${noColor}"
|
||||
local traefikHttpChallenge
|
||||
local traefikTlsChallenge
|
||||
traefikHttpChallenge=$(docker exec traefik cat /proc/1/cmdline 2>/dev/null | tr '\0' '\n' | grep -q "httpchallenge" && echo "OK" || echo "FAIL")
|
||||
traefikTlsChallenge=$(docker exec traefik cat /proc/1/cmdline 2>/dev/null | tr '\0' '\n' | grep -q "tlschallenge" && echo "OK" || echo "FAIL")
|
||||
checkStatus "$traefikHttpChallenge" "Traefik HTTP challenge configured"
|
||||
checkStatus "$traefikTlsChallenge" "Traefik TLS-ALPN-01 challenge configured"
|
||||
|
||||
local acmeFile
|
||||
acmeFile=$(docker exec traefik test -f /certificates/acme.json && echo "OK" || echo "FAIL")
|
||||
checkStatus "$acmeFile" "Traefik acme.json exists"
|
||||
|
||||
local certCount
|
||||
certCount=$(docker exec traefik cat /certificates/acme.json 2>/dev/null | jq -r '.le.Certificates | length' 2>/dev/null || echo "0")
|
||||
if [ "$certCount" -gt 0 ]; then
|
||||
checkStatus "OK" "Traefik has ${certCount} certificate(s) stored"
|
||||
else
|
||||
checkStatus "FAIL" "Traefik has no certificates"
|
||||
fi
|
||||
|
||||
local mailcowCert
|
||||
local mailcowKey
|
||||
mailcowCert=$(cd /var/deploy/mailcow && test -f data/assets/ssl/cert.pem && echo "OK" || echo "FAIL")
|
||||
mailcowKey=$(cd /var/deploy/mailcow && test -f data/assets/ssl/key.pem && echo "OK" || echo "FAIL")
|
||||
checkStatus "$mailcowCert" "Mailcow certificate file exists"
|
||||
checkStatus "$mailcowKey" "Mailcow private key exists"
|
||||
|
||||
if [ -f /var/deploy/mailcow/data/assets/ssl/cert.pem ]; then
|
||||
local certExpiry
|
||||
local certExpiryEpoch
|
||||
local currentEpoch
|
||||
local daysUntilExpiry
|
||||
|
||||
certExpiry=$(openssl x509 -in /var/deploy/mailcow/data/assets/ssl/cert.pem -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||
certExpiryEpoch=$(date -d "$certExpiry" +%s 2>/dev/null || echo "0")
|
||||
currentEpoch=$(date +%s)
|
||||
daysUntilExpiry=$(( (certExpiryEpoch - currentEpoch) / 86400 ))
|
||||
|
||||
if [ "$daysUntilExpiry" -gt 30 ]; then
|
||||
checkStatus "OK" "Certificate valid for ${daysUntilExpiry} more days (expires: ${certExpiry})"
|
||||
elif [ "$daysUntilExpiry" -gt 0 ]; then
|
||||
warnStatus "Certificate expires in ${daysUntilExpiry} days (expires: ${certExpiry})"
|
||||
else
|
||||
checkStatus "FAIL" "Certificate expired on ${certExpiry}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$mailcowHostname" ]; then
|
||||
local httpsResponse
|
||||
local certChain
|
||||
httpsResponse=$(curl -sI "https://${mailcowHostname}" 2>&1 | head -1 | grep -q "HTTP" && echo "OK" || echo "FAIL")
|
||||
certChain=$(echo | openssl s_client -connect "${mailcowHostname}:443" -servername "${mailcowHostname}" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null | grep -q "Let's Encrypt" && echo "OK" || echo "FAIL")
|
||||
checkStatus "$httpsResponse" "HTTPS connectivity to ${mailcowHostname}"
|
||||
checkStatus "$certChain" "Certificate issued by Let's Encrypt"
|
||||
else
|
||||
warnStatus "MAILCOW_HOSTNAME not found, skipping HTTPS checks"
|
||||
fi
|
||||
}
|
||||
|
||||
checkMailcowConfig() {
|
||||
printSection "3. Mailcow Configuration"
|
||||
|
||||
if [ -f /var/deploy/mailcow/mailcow.conf ]; then
|
||||
checkStatus "OK" "mailcow.conf exists"
|
||||
|
||||
local skipLe
|
||||
local useWatchdog
|
||||
local httpRedirect
|
||||
|
||||
skipLe=$(grep "^SKIP_LETS_ENCRYPT=" /var/deploy/mailcow/mailcow.conf | cut -d= -f2)
|
||||
if [ "$skipLe" = "n" ]; then
|
||||
checkStatus "OK" "Let's Encrypt enabled in mailcow"
|
||||
else
|
||||
warnStatus "Let's Encrypt disabled in mailcow (expected with Traefik)"
|
||||
fi
|
||||
|
||||
useWatchdog=$(grep "^USE_WATCHDOG=" /var/deploy/mailcow/mailcow.conf | cut -d= -f2)
|
||||
if [ "$useWatchdog" = "y" ]; then
|
||||
checkStatus "OK" "Watchdog enabled"
|
||||
else
|
||||
warnStatus "Watchdog disabled"
|
||||
fi
|
||||
|
||||
httpRedirect=$(grep "^HTTP_REDIRECT=" /var/deploy/mailcow/mailcow.conf | cut -d= -f2)
|
||||
if [ "$httpRedirect" = "y" ]; then
|
||||
checkStatus "OK" "HTTP to HTTPS redirect enabled"
|
||||
else
|
||||
warnStatus "HTTP to HTTPS redirect disabled"
|
||||
fi
|
||||
else
|
||||
checkStatus "FAIL" "mailcow.conf not found"
|
||||
fi
|
||||
|
||||
local certdumperStatus
|
||||
certdumperStatus=$(cd /var/deploy/mailcow && docker compose ps --format json 2>/dev/null | jq -r "select(.Service==\"certdumper\") | .State" | head -1)
|
||||
if [ "$certdumperStatus" = "running" ]; then
|
||||
checkStatus "OK" "Certdumper service running (syncs Traefik certs to mailcow)"
|
||||
else
|
||||
checkStatus "FAIL" "Certdumper service not running"
|
||||
fi
|
||||
}
|
||||
|
||||
checkAcmeLogs() {
|
||||
printSection "4. SSL Challenge Status"
|
||||
|
||||
local skipLe
|
||||
skipLe=$(grep "^SKIP_LETS_ENCRYPT=" /var/deploy/mailcow/mailcow.conf | cut -d= -f2)
|
||||
if [ "$skipLe" = "y" ]; then
|
||||
checkStatus "OK" "Mailcow ACME disabled (SKIP_LETS_ENCRYPT=y)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local acmeErrors
|
||||
acmeErrors=$(cd /var/deploy/mailcow && docker compose logs acme-mailcow --tail 50 2>&1 | grep -i "HTTP validation failed" | wc -l)
|
||||
if [ "$acmeErrors" -gt 0 ]; then
|
||||
warnStatus "Found ${acmeErrors} HTTP validation failures in mailcow ACME logs"
|
||||
warnStatus "Expected if autodiscover/autoconfig subdomains are handled by Traefik"
|
||||
else
|
||||
checkStatus "OK" "No HTTP validation failures in mailcow ACME logs"
|
||||
fi
|
||||
}
|
||||
|
||||
checkMailSecurity() {
|
||||
printSection "5. Mail Security (DANE/DNSSEC/MTA-STS)"
|
||||
|
||||
if [ -z "$mailcowHostname" ]; then
|
||||
warnStatus "MAILCOW_HOSTNAME not found, skipping mail security checks"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local domainPart
|
||||
domainPart=$(echo "$mailcowHostname" | cut -d. -f2-)
|
||||
|
||||
# Check DNSSEC.
|
||||
echo -e "${yellow}DNSSEC Status:${noColor}"
|
||||
local dsRecords
|
||||
local dnskeyRecords
|
||||
dsRecords=$(dig +short DS "$domainPart" 2>/dev/null | wc -l)
|
||||
dnskeyRecords=$(dig +short DNSKEY "$domainPart" 2>/dev/null | wc -l)
|
||||
if [ "$dsRecords" -gt 0 ] && [ "$dnskeyRecords" -gt 0 ]; then
|
||||
checkStatus "OK" "DNSSEC enabled (${dsRecords} DS, ${dnskeyRecords} DNSKEY records)"
|
||||
else
|
||||
warnStatus "DNSSEC not fully active (DS: ${dsRecords}, DNSKEY: ${dnskeyRecords})"
|
||||
fi
|
||||
|
||||
# Check TLSA/DANE records.
|
||||
echo -e "${yellow}TLSA/DANE Records:${noColor}"
|
||||
local tlsa25
|
||||
local tlsa465
|
||||
local tlsa587
|
||||
tlsa25=$(dig +short TLSA "_25._tcp.${mailcowHostname}" 2>/dev/null | wc -l)
|
||||
tlsa465=$(dig +short TLSA "_465._tcp.${mailcowHostname}" 2>/dev/null | wc -l)
|
||||
tlsa587=$(dig +short TLSA "_587._tcp.${mailcowHostname}" 2>/dev/null | wc -l)
|
||||
|
||||
if [ "$tlsa25" -gt 0 ]; then
|
||||
checkStatus "OK" "TLSA record for port 25 (SMTP)"
|
||||
else
|
||||
warnStatus "TLSA record missing for port 25"
|
||||
fi
|
||||
|
||||
if [ "$tlsa465" -gt 0 ]; then
|
||||
checkStatus "OK" "TLSA record for port 465 (SMTPS)"
|
||||
else
|
||||
warnStatus "TLSA record missing for port 465"
|
||||
fi
|
||||
|
||||
if [ "$tlsa587" -gt 0 ]; then
|
||||
checkStatus "OK" "TLSA record for port 587 (Submission)"
|
||||
else
|
||||
warnStatus "TLSA record missing for port 587"
|
||||
fi
|
||||
|
||||
# Check MTA-STS.
|
||||
echo -e "${yellow}MTA-STS:${noColor}"
|
||||
local mtaStsDns
|
||||
local mtaStsPolicy
|
||||
mtaStsDns=$(dig +short TXT "_mta-sts.${domainPart}" 2>/dev/null | grep -q "STSv1" && echo "OK" || echo "FAIL")
|
||||
mtaStsPolicy=$(curl -sk "https://mta-sts.${domainPart}/.well-known/mta-sts.txt" 2>/dev/null | grep -q "version: STSv1" && echo "OK" || echo "FAIL")
|
||||
checkStatus "$mtaStsDns" "MTA-STS DNS record"
|
||||
checkStatus "$mtaStsPolicy" "MTA-STS policy file accessible"
|
||||
|
||||
# Check TLS-RPT.
|
||||
echo -e "${yellow}TLS-RPT:${noColor}"
|
||||
local tlsRpt
|
||||
tlsRpt=$(dig +short TXT "_smtp._tls.${domainPart}" 2>/dev/null | grep -q "TLSRPTv1" && echo "OK" || echo "FAIL")
|
||||
checkStatus "$tlsRpt" "TLS-RPT DNS record"
|
||||
|
||||
# Check DKIM.
|
||||
echo -e "${yellow}DKIM:${noColor}"
|
||||
local dkimRecord
|
||||
dkimRecord=$(dig +short TXT "default._domainkey.${domainPart}" 2>/dev/null | grep -q "DKIM1" && echo "OK" || echo "FAIL")
|
||||
checkStatus "$dkimRecord" "DKIM DNS record"
|
||||
|
||||
# Check SPF.
|
||||
echo -e "${yellow}SPF:${noColor}"
|
||||
local spfRecord
|
||||
spfRecord=$(dig +short TXT "$domainPart" 2>/dev/null | grep -q "spf1" && echo "OK" || echo "FAIL")
|
||||
checkStatus "$spfRecord" "SPF DNS record"
|
||||
|
||||
# Check DMARC.
|
||||
echo -e "${yellow}DMARC:${noColor}"
|
||||
local dmarcRecord
|
||||
dmarcRecord=$(dig +short TXT "_dmarc.${domainPart}" 2>/dev/null | grep -q "DMARC1" && echo "OK" || echo "FAIL")
|
||||
checkStatus "$dmarcRecord" "DMARC DNS record"
|
||||
|
||||
# Check reverse DNS (PTR).
|
||||
echo -e "${yellow}Reverse DNS (PTR):${noColor}"
|
||||
local ipv4Addr
|
||||
local ipv6Addr
|
||||
ipv4Addr=$(dig +short A "$mailcowHostname" 2>/dev/null | head -1)
|
||||
ipv6Addr=$(dig +short AAAA "$mailcowHostname" 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$ipv4Addr" ]; then
|
||||
local ptr4
|
||||
ptr4=$(dig +short -x "$ipv4Addr" 2>/dev/null)
|
||||
if echo "$ptr4" | grep -q "$mailcowHostname"; then
|
||||
checkStatus "OK" "IPv4 PTR record points to ${mailcowHostname}"
|
||||
else
|
||||
warnStatus "IPv4 PTR record: ${ptr4:-not found}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$ipv6Addr" ]; then
|
||||
local ptr6
|
||||
ptr6=$(dig +short -x "$ipv6Addr" 2>/dev/null)
|
||||
if echo "$ptr6" | grep -q "$mailcowHostname"; then
|
||||
checkStatus "OK" "IPv6 PTR record points to ${mailcowHostname}"
|
||||
else
|
||||
warnStatus "IPv6 PTR record: ${ptr6:-not found}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
printSummary() {
|
||||
printSection "Summary"
|
||||
echo -e "${green}✓${noColor} Services: Most services are running"
|
||||
echo -e "${green}✓${noColor} SSL/TLS: Certificates are valid and properly configured"
|
||||
echo -e "${green}✓${noColor} Mailcow: Configuration appears correct"
|
||||
|
||||
echo ""
|
||||
echo -e "${blue}Health check completed.${noColor}"
|
||||
}
|
||||
|
||||
echo -e "${blue}========================================${noColor}"
|
||||
echo -e "${blue} Comprehensive Diagnostics Report${noColor}"
|
||||
echo -e "${blue}========================================${noColor}"
|
||||
|
||||
echo -e "${yellow}Checking Docker and Docker Compose installation...${noColor}"
|
||||
# Check Docker and Docker Compose
|
||||
echo -e "${YELLOW}Checking Docker and Docker Compose installation...${NC}"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
echo -e "${green}Docker is installed: $(docker --version)${noColor}"
|
||||
echo -e "${GREEN}Docker is installed: $(docker --version)${NC}"
|
||||
else
|
||||
echo -e "${red}Docker is not installed.${noColor}"
|
||||
echo -e "${RED}Docker is not installed!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
echo -e "${green}Docker Compose plugin is installed: $(docker compose version)${noColor}"
|
||||
echo -e "${GREEN}Docker Compose plugin is installed: $(docker compose version)${NC}"
|
||||
else
|
||||
echo -e "${red}Docker Compose plugin is not installed.${noColor}"
|
||||
echo -e "${RED}Docker Compose plugin is not installed!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
warnStatus "jq not found, some checks will be skipped"
|
||||
# Check system resources
|
||||
echo -e "${YELLOW}Checking system resources...${NC}"
|
||||
echo -e "${YELLOW}CPU:${NC} $(grep -c processor /proc/cpuinfo) cores"
|
||||
echo -e "${YELLOW}Memory:${NC} $(free -h | grep Mem | awk '{print $2}')"
|
||||
echo -e "${YELLOW}Disk space:${NC} $(df -h / | awk 'NR==2 {print $2}')"
|
||||
|
||||
# Domain configuration from .env file
|
||||
if [ -f "./core/.env" ]; then
|
||||
source ./core/.env
|
||||
echo -e "${YELLOW}Domain configuration:${NC} ${DOMAIN}"
|
||||
else
|
||||
echo -e "${RED}Core .env file not found!${NC}"
|
||||
DOMAIN="example.com"
|
||||
fi
|
||||
|
||||
echo -e "${yellow}Checking system resources...${noColor}"
|
||||
echo -e "${yellow}CPU:${noColor} $(grep -c processor /proc/cpuinfo) cores"
|
||||
echo -e "${yellow}Memory:${noColor} $(free -h | grep Mem | awk '{print $2}')"
|
||||
echo -e "${yellow}Disk space:${noColor} $(df -h / | awk 'NR==2 {print $2}')"
|
||||
# Run specific checks
|
||||
check_traefik
|
||||
echo ""
|
||||
check_gitlab
|
||||
echo ""
|
||||
check_databases
|
||||
echo ""
|
||||
check_all_services
|
||||
|
||||
loadCoreDomain
|
||||
loadMailcowHostname
|
||||
|
||||
printSection "Core Service Diagnostics"
|
||||
checkTraefik
|
||||
echo ""
|
||||
checkForgejo
|
||||
echo ""
|
||||
checkDatabases
|
||||
echo ""
|
||||
checkNextcloud
|
||||
echo ""
|
||||
checkAllServices
|
||||
|
||||
echo ""
|
||||
checkMailcowServices
|
||||
checkSslAndCerts
|
||||
checkMailcowConfig
|
||||
checkAcmeLogs
|
||||
checkMailSecurity
|
||||
printSummary
|
||||
echo -e "${GREEN}Diagnostic checks completed.${NC}"
|
||||
echo -e "${YELLOW}For detailed logs, run: docker logs <container_name>${NC}"
|
||||
|
|
|
|||
425
drupal/README.md
|
|
@ -1,425 +0,0 @@
|
|||
# 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=<Client ID from the consumer list>
|
||||
DRUPAL_CLIENT_SECRET=<Secret from the consumer edit form>
|
||||
DRUPAL_OAUTH_SCOPE=<machine name from step 1, e.g. nextjs_frontend>
|
||||
```
|
||||
|
||||
**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/)
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
services:
|
||||
# Drupal CMS NGINX reverse proxy (cms.nasarek.dev).
|
||||
nginx:
|
||||
image: rnsrk/drupal-nginx
|
||||
image: drupal-nginx
|
||||
build:
|
||||
context: ./nginx
|
||||
dockerfile: Dockerfile
|
||||
|
|
@ -11,7 +10,7 @@ services:
|
|||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik
|
||||
- traefik.http.routers.drupal-reverse-proxy.rule=Host(`cms.${DOMAIN}`)
|
||||
- traefik.http.routers.drupal-reverse-proxy.rule=Host(`${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
|
||||
|
|
@ -23,14 +22,13 @@ services:
|
|||
- traefik
|
||||
- drupal
|
||||
|
||||
# Drupal PHP-FPM backend.
|
||||
drupal-fpm:
|
||||
image: rnsrk/drupal-php8-4-fpm-bookworm
|
||||
image: drupal-php8-4-fpm-bookworm
|
||||
build:
|
||||
context: ./drupal
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
DRUPAL_VERSION: ${DRUPAL_VERSION:-11.3.3}
|
||||
DRUPAL_VERSION: ${DRUPAL_VERSION:-11.1.6}
|
||||
labels:
|
||||
- traefik.enable=false
|
||||
container_name: drupal-fpm
|
||||
|
|
@ -42,11 +40,12 @@ 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:
|
||||
|
|
@ -58,37 +57,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
ARG DRUPAL_VERSION=11.3.3
|
||||
ARG DRUPAL_VERSION
|
||||
|
||||
FROM drupal:${DRUPAL_VERSION}-php8.4-fpm-bookworm
|
||||
|
||||
ARG NODE_ENV=production
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
vim \
|
||||
|
|
@ -15,13 +14,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; \
|
||||
docker-php-ext-enable apcu;
|
||||
pecl install apcu;
|
||||
|
||||
# Configure apcu (extension already loaded by docker-php-ext-enable).
|
||||
# Add php configs
|
||||
RUN { \
|
||||
echo 'extension=apcu.so'; \
|
||||
echo "apc.enable_cli=1"; \
|
||||
echo "apc.enable=1"; \
|
||||
echo "apc.shm_size=32M"; \
|
||||
|
|
@ -31,82 +30,3 @@ 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"
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
*.md
|
||||
.env*.local
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
25
drupal/nextjs/.gitignore
vendored
|
|
@ -1,25 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
# 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"]
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import { drupal } from "@/lib/drupal"
|
||||
import type { DrupalNode } from "@/lib/types"
|
||||
import { NodeArticle } from "@/components/node-article"
|
||||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
|
||||
|
||||
interface NodePageProps {
|
||||
params: Promise<{
|
||||
slug: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
// Render dynamically at runtime (not at build time).
|
||||
export const dynamic = "force-dynamic"
|
||||
export const revalidate = 60
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: NodePageProps): Promise<Metadata> {
|
||||
if (!drupalBaseUrl) return {}
|
||||
|
||||
const { slug } = await params
|
||||
const path = drupal.constructPathFromSegment(slug)
|
||||
|
||||
try {
|
||||
const translatedPath = await drupal.translatePath(path, { withAuth: true })
|
||||
|
||||
if (!translatedPath?.jsonapi?.resourceName || !translatedPath?.entity?.uuid) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const node = await drupal.getResource<DrupalNode>(
|
||||
translatedPath.jsonapi.resourceName,
|
||||
translatedPath.entity.uuid,
|
||||
{
|
||||
withAuth: true,
|
||||
params: {
|
||||
"fields[node--article]": "title",
|
||||
"fields[node--page]": "title",
|
||||
"fields[node--about]": "title",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
title: node?.title,
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function NodePage({ params }: NodePageProps) {
|
||||
if (!drupalBaseUrl) notFound()
|
||||
|
||||
const { slug } = await params
|
||||
const path = drupal.constructPathFromSegment(slug)
|
||||
|
||||
try {
|
||||
const translatedPath = await drupal.translatePath(path, { withAuth: true })
|
||||
|
||||
if (!translatedPath?.jsonapi?.resourceName || !translatedPath?.entity?.uuid) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const type = translatedPath.jsonapi.resourceName
|
||||
|
||||
const node = await drupal.getResource<DrupalNode>(
|
||||
type,
|
||||
translatedPath.entity.uuid,
|
||||
{
|
||||
withAuth: true,
|
||||
params: {
|
||||
include: "uid",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!node || !node.status) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <NodeArticle node={node} />
|
||||
} catch {
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { disableDraftMode } from "next-drupal/draft"
|
||||
import type { NextRequest } from "next/server"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return disableDraftMode()
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { drupal } from "@/lib/drupal"
|
||||
import { enableDraftMode } from "next-drupal/draft"
|
||||
import type { NextRequest } from "next/server"
|
||||
|
||||
export async function GET(request: NextRequest): Promise<Response | never> {
|
||||
return enableDraftMode(request, drupal)
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
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 }
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
|
|
@ -1,307 +0,0 @@
|
|||
@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;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 574 KiB |
|
|
@ -1,106 +0,0 @@
|
|||
import type { Metadata } from "next"
|
||||
import { ObfuscatedAddress } from "@/components/obfuscated-address"
|
||||
import { ObfuscatedEmail } from "@/components/obfuscated-email"
|
||||
import { drupal } from "@/lib/drupal"
|
||||
|
||||
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
|
||||
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Imprint",
|
||||
description: "Legal notice and imprint for nasarek.dev",
|
||||
}
|
||||
|
||||
const FALLBACK_TITLE = "Imprint"
|
||||
|
||||
const BODY_STYLES = "[&_h2]:mb-4 [&_h2]:mt-8 [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:text-slate-900 [&_p]:mb-4 [&_p]:text-slate-700 [&_a]:text-emerald-600 [&_a]:underline hover:[&_a]:text-emerald-500"
|
||||
|
||||
/**
|
||||
* Splits the CMS body HTML at {address} and {email} placeholders and renders
|
||||
* the obfuscated components in their place so bots cannot harvest the data.
|
||||
*/
|
||||
function ImprintBody({ html }: { html: string }) {
|
||||
const parts = html.split(/(<p>\{(?:address|email)\}<\/p>)/g)
|
||||
|
||||
return (
|
||||
<div className={BODY_STYLES}>
|
||||
{parts.map((part, i) => {
|
||||
if (part === "<p>{address}</p>") return <ObfuscatedAddress key={i} />
|
||||
if (part === "<p>{email}</p>") return <p key={i}><ObfuscatedEmail /></p>
|
||||
if (!part) return null
|
||||
return <div key={i} dangerouslySetInnerHTML={{ __html: part }} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function getImprintPageContent(): Promise<{
|
||||
title: string
|
||||
body: string | null
|
||||
}> {
|
||||
if (!drupalBaseUrl) {
|
||||
return {
|
||||
title: FALLBACK_TITLE,
|
||||
body: null,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const translatedPath = await drupal.translatePath("/imprint", {
|
||||
withAuth: true,
|
||||
next: { revalidate: 60 },
|
||||
})
|
||||
if (!translatedPath?.jsonapi?.resourceName || !translatedPath?.entity?.uuid) {
|
||||
return { title: FALLBACK_TITLE, body: null }
|
||||
}
|
||||
|
||||
const resourceType = translatedPath.jsonapi.resourceName
|
||||
const raw = await drupal.getResource(
|
||||
resourceType,
|
||||
translatedPath.entity.uuid,
|
||||
{ withAuth: true, next: { revalidate: 60 }, deserialize: false }
|
||||
)
|
||||
const rawData = (raw as { data?: Record<string, unknown> })?.data
|
||||
if (!rawData) {
|
||||
return { title: FALLBACK_TITLE, body: null }
|
||||
}
|
||||
|
||||
const title = (rawData.title as string) ?? FALLBACK_TITLE
|
||||
const bodyObj = rawData.body
|
||||
const bodyText =
|
||||
typeof bodyObj === "string"
|
||||
? bodyObj
|
||||
: (bodyObj as { processed?: string; value?: string })?.processed ??
|
||||
(bodyObj as { processed?: string; value?: string })?.value ??
|
||||
""
|
||||
|
||||
return {
|
||||
title,
|
||||
body: bodyText || null,
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
console.warn("[Imprint] CMS unreachable:", (error as Error).message)
|
||||
}
|
||||
return { title: FALLBACK_TITLE, body: null }
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ImprintPage() {
|
||||
const { title, body } = await getImprintPageContent()
|
||||
return (
|
||||
<section className="imprint animate-fade-in-on-load pb-10" aria-labelledby="imprint-heading">
|
||||
<div className="imprint-header mb-8 text-center">
|
||||
<h1 className="imprint-title mb-3 font-bold tracking-tight text-slate-900"
|
||||
style={{ fontSize: "var(--fluid-section-title)" }}>
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="imprint-content mx-auto max-w-4xl">
|
||||
{body && <ImprintBody html={body} />}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import { Source_Sans_3 } from "next/font/google"
|
||||
import {
|
||||
LayoutGrid,
|
||||
FileText,
|
||||
Database,
|
||||
Scale,
|
||||
} from "lucide-react"
|
||||
import { MainNav } from "@/components/main-nav"
|
||||
import { CookieBanner } from "@/components/cookie-banner"
|
||||
import "./globals.css"
|
||||
|
||||
const sourceSans3 = Source_Sans_3({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "nasarek.dev",
|
||||
template: "%s | nasarek.dev",
|
||||
},
|
||||
description: "Powered by Drupal and Next.js",
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`layout-body ${sourceSans3.className} flex min-h-screen flex-col bg-slate-50 text-slate-900 antialiased`}
|
||||
>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-6 focus:top-4 focus:z-100 focus:rounded-md focus:bg-slate-800 focus:px-4 focus:py-2 focus:text-white focus:outline-none focus:ring-2 focus:ring-emerald-400"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<header className="layout-header sticky top-0 z-50 border-b border-slate-700/50 bg-slate-800/95 backdrop-blur-sm">
|
||||
<div className="layout-header-inner mx-auto flex max-w-7xl items-center justify-between px-4 py-4 lg:px-6">
|
||||
<a
|
||||
href="/"
|
||||
className="layout-logo flex items-center gap-3 rounded-sm text-2xl font-bold tracking-tight text-emerald-500 outline-none transition-colors duration-200 ease-out hover:text-emerald-500 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-800 sm:text-3xl"
|
||||
aria-label="nasarek.dev home"
|
||||
>
|
||||
<Image
|
||||
src="/icon.svg"
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="size-9 shrink-0 sm:size-10"
|
||||
/>
|
||||
Nasarek Data Engineering
|
||||
</a>
|
||||
<MainNav />
|
||||
</div>
|
||||
</header>
|
||||
<main id="main-content" className="layout-main flex flex-1 flex-col overflow-x-hidden bg-white">
|
||||
<div className="layout-main-content mx-auto flex min-h-full w-full max-w-full flex-1 flex-col px-4 py-4 pb-16 lg:px-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<footer className="layout-footer mt-12 border-t border-slate-200 bg-slate-50">
|
||||
<div className="layout-footer-inner mx-auto max-w-7xl px-6 py-12">
|
||||
<div className="layout-footer-grid grid grid-cols-2 gap-8 text-center sm:text-left lg:grid-cols-4">
|
||||
<div className="layout-footer-section">
|
||||
<h3 className="layout-footer-heading mb-3 text-left text-sm font-semibold uppercase tracking-wider text-slate-500">
|
||||
Legal
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="/imprint"
|
||||
className="inline-flex items-center gap-2 text-slate-600 outline-none transition-colors hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Scale className="size-4 shrink-0" aria-hidden />
|
||||
Imprint
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="layout-footer-section">
|
||||
<h3 className="layout-footer-heading mb-3 text-left text-sm font-semibold uppercase tracking-wider text-slate-500">
|
||||
Social
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="https://github.com/rnsrk"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image src="/assets/icons/github.svg" alt="" width={16} height={16} className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200" unoptimized />
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="https://www.drupal.org/u/rnsrk"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/drupal.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
Drupal
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li className="text-left">
|
||||
<a
|
||||
rel="me"
|
||||
href="https://fedihum.org/@rnsrk"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/mastodon.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
Mastodon
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="layout-footer-section">
|
||||
<h3 className="layout-footer-heading mb-3 text-left text-sm font-semibold uppercase tracking-wider text-slate-500">
|
||||
Powered by
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="https://www.drupal.org"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors duration-200 ease-out hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/drupal.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
Drupal
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="https://nextjs.org"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors duration-200 ease-out hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/nextjs.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
Next.js
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="layout-footer-section">
|
||||
<h3 className="layout-footer-heading mb-3 text-left text-sm font-semibold uppercase tracking-wider text-slate-500">Media</h3>
|
||||
<ul className="space-y-2">
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="https://www.youtube.com/watch?v=MGOHzreEU38"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors duration-200 ease-out hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/youtube.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
YouTube
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="https://open.spotify.com/episode/40v8fdhk4WcXJYu1oIF1oe"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors duration-200 ease-out hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/spotify.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
Spotify
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-left">
|
||||
<a
|
||||
href="https://zenodo.org/search?q=metadata.creators.person_or_org.name%3A%22Nasarek%2C%20Robert%22&l=list&p=1&s=10&sort=bestmatch"
|
||||
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/zenodo.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
Zenodo
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="layout-footer-copyright mt-10 border-t border-slate-200 pt-8 text-center text-sm text-slate-500">
|
||||
© {new Date().getFullYear()} nasarek.dev
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<CookieBanner />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center text-center">
|
||||
<h1 className="mb-4 text-6xl font-bold text-gray-300">404</h1>
|
||||
<h2 className="mb-2 text-2xl font-semibold text-gray-700">
|
||||
Page Not Found
|
||||
</h2>
|
||||
<p className="mb-6 text-gray-500">
|
||||
The page you are looking for does not exist.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-lg bg-gray-900 px-6 py-3 text-sm font-medium text-white transition hover:bg-gray-800"
|
||||
>
|
||||
Go Home
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { HomeHero } from "@/components/home-hero"
|
||||
|
||||
// Force dynamic so HomeAbout fetches at request time (OAuth env vars available in container, not at build).
|
||||
export const dynamic = "force-dynamic"
|
||||
import { HomeAbout } from "@/components/home-about"
|
||||
import { HomeServices } from "@/components/home-services"
|
||||
import { HomeProjects } from "@/components/home-projects"
|
||||
import { HomeClients } from "@/components/home-clients"
|
||||
import { HomeJourneyBackground } from "@/components/home-journey-background"
|
||||
import { ScrollRevealSection } from "@/components/scroll-reveal-section"
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<HomeJourneyBackground>
|
||||
<ScrollRevealSection initialVisible>
|
||||
<HomeHero />
|
||||
</ScrollRevealSection>
|
||||
<HomeAbout />
|
||||
<div className="animate-fade-in-on-load-delayed">
|
||||
<HomeServices />
|
||||
</div>
|
||||
<ScrollRevealSection>
|
||||
<HomeClients />
|
||||
</ScrollRevealSection>
|
||||
<ScrollRevealSection>
|
||||
<HomeProjects />
|
||||
</ScrollRevealSection>
|
||||
</HomeJourneyBackground>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { FileText, Database, ArrowRight } from "lucide-react"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Resources",
|
||||
description: "Browse articles and models on nasarek.dev",
|
||||
}
|
||||
|
||||
export default function ResourcesPage() {
|
||||
return (
|
||||
<article className="mx-auto flex min-h-full flex-1 flex-col max-w-7xl">
|
||||
<header className="mb-12">
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl">
|
||||
Resources
|
||||
</h1>
|
||||
<p className="max-w-2xl text-lg text-slate-600">
|
||||
Explore my curated collection of articles and models.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-8 sm:grid-cols-2">
|
||||
<Link
|
||||
href="/resources/articles"
|
||||
className="group block rounded-xl border border-slate-200 bg-white p-8 outline-none transition-all duration-200 ease-out hover:border-emerald-500/40 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2"
|
||||
>
|
||||
<div className="mb-4 inline-flex rounded-lg bg-emerald-100 p-3 text-emerald-600">
|
||||
<FileText className="size-6" aria-hidden />
|
||||
</div>
|
||||
<h2 className="mb-3 text-xl font-semibold text-emerald-600 transition-colors duration-200 ease-out group-hover:text-emerald-500">
|
||||
Articles
|
||||
</h2>
|
||||
<p className="mb-4 text-slate-600">
|
||||
Written content covering tutorials, guides, and insights. Articles
|
||||
are published pieces with full text, images, and structured
|
||||
formatting.
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium text-emerald-600 transition-colors duration-200 ease-out group-hover:text-emerald-500">
|
||||
Browse articles
|
||||
<ArrowRight className="size-4 transition-transform duration-200 ease-out group-hover:translate-x-1" aria-hidden />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/resources/datamodelling"
|
||||
className="group block rounded-xl border border-slate-200 bg-white p-8 outline-none transition-all duration-200 ease-out hover:border-fuchsia-500/40 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2"
|
||||
>
|
||||
<div className="mb-4 inline-flex rounded-lg bg-fuchsia-100 p-3 text-fuchsia-600">
|
||||
<Database className="size-6" aria-hidden />
|
||||
</div>
|
||||
<h2 className="mb-3 text-xl font-semibold text-fuchsia-600 transition-colors duration-200 ease-out group-hover:text-fuchsia-500">
|
||||
Datamodelling
|
||||
</h2>
|
||||
<p className="mb-4 text-slate-600">
|
||||
Structured data models and schemas from my projects.
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium text-emerald-600 transition-colors duration-200 ease-out group-hover:text-emerald-500">
|
||||
Browse models
|
||||
<ArrowRight className="size-4 transition-transform duration-200 ease-out group-hover:translate-x-1" aria-hidden />
|
||||
</span>
|
||||
</Link>
|
||||
</section>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
"use client"
|
||||
|
||||
const TEXT = "I'm Robert."
|
||||
|
||||
type Direction = "up" | "down" | "r" | "e" | "t"
|
||||
|
||||
const LETTER_ANIMS: Record<number, Direction> = {
|
||||
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 (
|
||||
<h1
|
||||
className="home-hero-title mb-2 font-bold tracking-tight text-slate-900"
|
||||
style={{ fontSize: "var(--fluid-hero)" }}
|
||||
>
|
||||
{TEXT.split("").map((char, i) => {
|
||||
if (char === " ") {
|
||||
return <span key={i} className="inline-block" style={{ width: "0.25em" }} aria-hidden />
|
||||
}
|
||||
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 (
|
||||
<span
|
||||
key={i}
|
||||
className={`inline-block overflow-hidden ${animClass}`}
|
||||
style={style}
|
||||
aria-hidden
|
||||
>
|
||||
{dir === "e" ? (
|
||||
<span className="block animate-letter-e-fade-in" style={{ animationDelay: `${animDelay}ms` }}>
|
||||
e
|
||||
</span>
|
||||
) : dir === "r" ? (
|
||||
"R"
|
||||
) : dir === "t" ? (
|
||||
"t"
|
||||
) : (
|
||||
char
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
"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 (
|
||||
<div
|
||||
className="flex size-20 shrink-0 sm:size-24 [perspective:500px]"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
>
|
||||
<div
|
||||
className={`relative size-full overflow-hidden rounded-full bg-emerald-100 [transform-style:preserve-3d] ${isSpinning ? "animate-coin-spin" : ""}`}
|
||||
>
|
||||
<Image
|
||||
key={currentSrc}
|
||||
src={currentSrc}
|
||||
alt={alt}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="192px"
|
||||
quality={95}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
"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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Cookie notice"
|
||||
className="fixed bottom-0 left-0 right-0 z-50 border-t border-slate-200 bg-white/95 p-4 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] backdrop-blur-sm sm:p-6"
|
||||
>
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-slate-600">
|
||||
This site uses a single cookie to store your consent preference. No
|
||||
tracking or analytics cookies are used.{" "}
|
||||
<Link
|
||||
href="/imprint"
|
||||
className="font-medium text-emerald-600 underline-offset-2 hover:text-emerald-500 hover:underline"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAccept}
|
||||
className="shrink-0 rounded-lg px-5 py-2.5 text-sm font-medium text-white outline-none transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
style={{ backgroundColor: "var(--accent-hex)" }}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
"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
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
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 (
|
||||
<>
|
||||
<p className="mb-2 text-emerald-600 font-medium">
|
||||
<span style={{ color: "#009966" }}>Data Engineer & Developer</span>
|
||||
</p>
|
||||
<p className="text-slate-600 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
<p className="text-slate-600 pt-4">
|
||||
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.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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<DrupalAboutNode>(
|
||||
"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 (
|
||||
<section className="home-about animate-fade-in-on-load pb-10" aria-labelledby="about-heading">
|
||||
<div className="home-about-header mb-8 text-center">
|
||||
<h2
|
||||
id="about-heading"
|
||||
className="home-about-title mb-3 font-bold tracking-tight text-slate-900"
|
||||
style={{ fontSize: "var(--fluid-section-title)" }}
|
||||
>
|
||||
About me
|
||||
</h2>
|
||||
<p
|
||||
className="home-about-description mx-auto max-w-3xl text-slate-600"
|
||||
style={{ fontSize: "var(--fluid-hero-desc)" }}
|
||||
>
|
||||
Data engineer and developer with a focus on linked open data, ontology engineering, and full-stack development.
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-about-content mx-auto max-w-4xl">
|
||||
<div className="home-about-bio flex flex-col gap-6 rounded-xl border border-slate-200 bg-slate-50 p-6 sm:flex-row sm:items-start sm:gap-8 sm:p-8">
|
||||
<AvatarImage alt={title} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="mb-2 text-xl font-semibold text-slate-900">
|
||||
{title}
|
||||
</h3>
|
||||
<div
|
||||
className="pemerald pemerald-slate text-slate-600 pemerald-a:text-emerald-600 pemerald-a:underline hover:pemerald-a:text-emerald-500 [&>p]:leading-relaxed [&>p:first-child]:mb-2 [&>p:first-child]:text-emerald-600 [&>p:first-child]:font-medium [&>p:not(:first-child)]:pt-4"
|
||||
>
|
||||
<p className="mb-2 text-emerald-600 font-medium">
|
||||
<span style={{ color: "#009966" }}>Data Engineer & Developer</span>
|
||||
</p>
|
||||
<p className="text-slate-600 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
<p className="text-slate-600 pt-4">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-4">
|
||||
<MailToLink email={email} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
"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 <Building2 className="size-4 shrink-0 text-emerald-600" aria-hidden />
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={icon}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="size-4 shrink-0 rounded-sm object-contain"
|
||||
unoptimized
|
||||
onError={() => 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 = (
|
||||
<>
|
||||
<ClientIcon icon={client.icon} />
|
||||
<span className="font-medium">{client.name}</span>
|
||||
<span className="text-slate-500">({client.location})</span>
|
||||
</>
|
||||
)
|
||||
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 ? (
|
||||
<a
|
||||
key={client.name}
|
||||
href={client.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${className} outline-none transition-colors hover:border-emerald-200 hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2`}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<span key={client.name} className={className}>
|
||||
{content}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SCROLL_SPEED = 40
|
||||
|
||||
export function HomeClients() {
|
||||
const bandRef = useRef<HTMLDivElement>(null)
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const posRef = useRef(0)
|
||||
const isHoveredRef = useRef(false)
|
||||
const isDraggingRef = useRef(false)
|
||||
const dragStartXRef = useRef(0)
|
||||
const dragStartPosRef = useRef(0)
|
||||
const lastTimeRef = useRef<number | null>(null)
|
||||
const rafRef = useRef<number | null>(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 (
|
||||
<section className="home-clients py-10" aria-labelledby="clients-heading">
|
||||
<div className="home-clients-header mb-6 text-center">
|
||||
<h2
|
||||
id="clients-heading"
|
||||
className="home-clients-title mb-3 font-bold tracking-tight text-slate-900"
|
||||
style={{ fontSize: "var(--fluid-section-title)" }}
|
||||
>
|
||||
Employers & Customers
|
||||
</h2>
|
||||
<p
|
||||
className="home-clients-description mx-auto max-w-3xl text-slate-600"
|
||||
style={{ fontSize: "var(--fluid-hero-desc)" }}
|
||||
>
|
||||
Research institutions and organisations I have worked with.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
ref={bandRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
className="home-clients-band relative w-full overflow-hidden border-y border-slate-200 bg-slate-50 py-10 select-none"
|
||||
style={{ cursor: isDragging ? "grabbing" : "grab" }}
|
||||
>
|
||||
<div ref={trackRef} className="home-clients-band-track flex w-max">
|
||||
<ClientsBandContent />
|
||||
<ClientsBandContent />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import Link from "next/link"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
export function HomeCta() {
|
||||
return (
|
||||
<section className="home-cta py-10">
|
||||
<div className="home-cta-card animate-fade-in-up rounded-2xl border border-slate-200 bg-linear-to-br from-slate-50 to-emerald-50/30 p-8 text-center sm:p-10">
|
||||
<h2 className="home-cta-title mb-3 font-bold tracking-tight text-slate-900" style={{ fontSize: "var(--fluid-section-title)" }}>
|
||||
Hi There!
|
||||
</h2>
|
||||
<p className="home-cta-description mx-auto mb-6 max-w-xl text-slate-600">
|
||||
Browse articles and data models for your next project.
|
||||
</p>
|
||||
<Link
|
||||
href="/resources"
|
||||
className="home-cta-link inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-6 py-3 font-medium text-white outline-none transition-all duration-200 ease-out hover:bg-emerald-500 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
View all resources
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
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 (
|
||||
<section className="home-features py-10">
|
||||
<div className="home-features-header mb-6 text-center">
|
||||
<h2 className="home-features-title mb-3 text-3xl font-bold tracking-tight text-slate-900">
|
||||
Resources
|
||||
</h2>
|
||||
<p className="home-features-description mx-auto max-w-3xl text-slate-600" style={{ fontSize: "var(--fluid-hero-desc)" }}>
|
||||
Use one or all. Curated content for data engineers and developers.
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-features-grid grid gap-8 sm:grid-cols-2">
|
||||
{features.map((feature, index) => (
|
||||
<Link
|
||||
key={feature.href}
|
||||
href={feature.href}
|
||||
className={`home-features-card group block rounded-xl border border-slate-200 bg-white p-8 outline-none transition-all duration-200 ease-out hover:border-emerald-500/40 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2 animate-fade-in-up ${index === 1 ? "animate-delay-100" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`home-features-card-icon mb-4 inline-flex rounded-lg p-3 ${
|
||||
feature.color === "emerald"
|
||||
? "bg-emerald-100 text-emerald-600"
|
||||
: "bg-fuchsia-100 text-fuchsia-600"
|
||||
}`}
|
||||
>
|
||||
<feature.icon className="size-6" aria-hidden />
|
||||
</div>
|
||||
<h3 className="home-features-card-title mb-2 text-xl font-semibold text-slate-900">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="home-features-card-description mb-4 text-slate-600">{feature.description}</p>
|
||||
<span className="home-features-card-link inline-flex items-center gap-2 text-sm font-medium text-emerald-600 transition-colors duration-200 ease-out group-hover:text-emerald-500">
|
||||
Learn more
|
||||
<ArrowRight className="size-4 transition-transform duration-200 ease-out group-hover:translate-x-1" aria-hidden />
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { AnimatedHeroTitle } from "./animated-hero-title"
|
||||
|
||||
export function HomeHero() {
|
||||
return (
|
||||
<section className="home-hero relative pt-6">
|
||||
<div className="home-hero-content mx-auto max-w-4xl text-center">
|
||||
<AnimatedHeroTitle />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
"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<Set<string>>(new Set())
|
||||
const [visibleRightStations, setVisibleRightStations] = useState<Set<string>>(
|
||||
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<string>()
|
||||
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<string>()
|
||||
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 (
|
||||
<div className="journey-layout -mt-6 flex w-full flex-col px-4 lg:px-6">
|
||||
<div className="journey-hero w-full">{heroContent}</div>
|
||||
<div className="journey-main-row flex w-full items-stretch gap-4 lg:gap-6">
|
||||
<aside
|
||||
className="journey-sidebar relative sticky top-24 hidden min-h-full shrink-0 flex-[1] pb-8 pr-4 pt-0 lg:block"
|
||||
aria-label="Journey timeline"
|
||||
>
|
||||
<div className="journey-stations relative flex h-full flex-col">
|
||||
<div
|
||||
className="journey-sidebar-spacer journey-sidebar-spacer-top min-h-0 flex-[1]"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="journey-stations-list flex min-h-0 flex-[4] flex-col items-center justify-between">
|
||||
{STATIONS.map((station) => {
|
||||
const isVisible = visibleStations.has(station.id)
|
||||
const colors = STATION_COLORS[station.id]
|
||||
return (
|
||||
<div
|
||||
key={station.id}
|
||||
className={`journey-station journey-station-${station.id} relative z-10 flex flex-col items-center gap-2`}
|
||||
>
|
||||
<div
|
||||
className={`journey-station-icon flex size-20 shrink-0 items-center justify-center rounded-full border-2 shadow-sm transition-all duration-500 ${
|
||||
isVisible
|
||||
? `${colors.border} ${colors.bg} opacity-100 scale-100`
|
||||
: "border-slate-200 bg-white opacity-0 scale-90"
|
||||
}`}
|
||||
>
|
||||
<station.icon
|
||||
className={`size-11 transition-all duration-500 ${
|
||||
isVisible ? colors.text : "text-slate-400"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<h3
|
||||
className={`journey-station-label text-center text-m font-medium transition-all duration-500 ${
|
||||
isVisible ? `${colors.label} opacity-100` : "text-slate-500 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{station.label}
|
||||
</h3>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="journey-content main-contents-main min-w-0 flex-[8]">
|
||||
{mainContent}
|
||||
</div>
|
||||
<aside
|
||||
className="journey-sidebar journey-sidebar-right relative sticky top-24 hidden min-h-full shrink-0 flex-[1] pb-8 pl-4 pt-0 lg:block"
|
||||
aria-label="Consulting and support journey"
|
||||
>
|
||||
<div className="journey-stations relative flex h-full flex-col">
|
||||
<div
|
||||
className="journey-sidebar-spacer journey-sidebar-spacer-top min-h-0 flex-[1]"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="journey-stations-list flex min-h-0 flex-[4] flex-col items-center justify-around">
|
||||
{RIGHT_STATIONS.map((station) => {
|
||||
const isVisible = visibleRightStations.has(station.id)
|
||||
const colors = STATION_COLORS[station.id]
|
||||
return (
|
||||
<div
|
||||
key={station.id}
|
||||
className={`journey-station journey-station-${station.id} relative z-10 flex flex-col items-center gap-2`}
|
||||
>
|
||||
<div
|
||||
className={`journey-station-icon flex size-20 shrink-0 items-center justify-center rounded-full border-2 shadow-sm transition-all duration-500 ${
|
||||
isVisible
|
||||
? `${colors.border} ${colors.bg} opacity-100 scale-100`
|
||||
: "border-slate-200 bg-white opacity-0 scale-90"
|
||||
}`}
|
||||
>
|
||||
<station.icon
|
||||
className={`size-11 transition-all duration-500 ${
|
||||
isVisible ? colors.text : "text-slate-400"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<h3
|
||||
className={`journey-station-label text-center text-m font-medium transition-all duration-500 ${
|
||||
isVisible
|
||||
? `${colors.label} opacity-100`
|
||||
: "text-slate-500 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{station.label}
|
||||
</h3>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
"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) => (
|
||||
<span
|
||||
key={index}
|
||||
className="home-marquee-item mx-4 flex shrink-0 items-center gap-2 rounded-full border border-slate-200 bg-white px-6 py-3 text-sm text-slate-600 shadow-sm"
|
||||
>
|
||||
{testimonial}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeMarquee() {
|
||||
return (
|
||||
<section className="home-marquee relative left-1/2 w-screen -translate-x-1/2 overflow-hidden border-y border-slate-200 bg-slate-50 py-8">
|
||||
<div className="home-marquee-track flex w-max animate-marquee">
|
||||
<MarqueeContent />
|
||||
<MarqueeContent />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
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 (
|
||||
<section className="home-projects py-10" aria-labelledby="projects-heading">
|
||||
<div className="home-projects-header mb-6 text-center">
|
||||
<h2
|
||||
id="projects-heading"
|
||||
className="home-projects-title mb-3 font-bold tracking-tight text-slate-900"
|
||||
style={{ fontSize: "var(--fluid-section-title)" }}
|
||||
>
|
||||
Projects
|
||||
</h2>
|
||||
<p
|
||||
className="home-projects-description mx-auto max-w-3xl text-slate-600"
|
||||
style={{ fontSize: "var(--fluid-hero-desc)" }}
|
||||
>
|
||||
Data- and information-focused websites and applications.
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-projects-grid grid gap-8 sm:grid-cols-2">
|
||||
{projects.map((project) => (
|
||||
<ScrollRevealCard key={project.href}>
|
||||
<Link
|
||||
href={project.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="home-projects-card group block rounded-xl border border-slate-200 bg-white p-8 outline-none transition-all duration-200 ease-out hover:border-emerald-500/40 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2"
|
||||
>
|
||||
<h3 className="home-projects-card-title mb-2 flex items-center gap-3 text-xl font-semibold text-slate-900">
|
||||
<Image
|
||||
src={project.icon}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="size-6 shrink-0 object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="home-projects-card-description mb-4 text-slate-600">
|
||||
{project.description}
|
||||
</p>
|
||||
<span className="home-projects-card-link inline-flex items-center gap-2 text-sm font-medium text-emerald-600 transition-colors duration-200 ease-out group-hover:text-emerald-500">
|
||||
Visit project
|
||||
<ArrowUpRight className="size-4 transition-transform duration-200 ease-out group-hover:translate-x-0.5 group-hover:-translate-y-0.5" aria-hidden />
|
||||
</span>
|
||||
</Link>
|
||||
</ScrollRevealCard>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
import { Book, Brain, Calendar, Cpu, Code2, Database, Rocket, Unplug, Wrench } from "lucide-react"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { drupal } from "@/lib/drupal"
|
||||
import type { DrupalServiceNode } from "@/lib/types"
|
||||
import { ScrollRevealCard } from "@/components/scroll-reveal-card"
|
||||
|
||||
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
coordination: Calendar,
|
||||
data_processing: Cpu,
|
||||
deployment: Rocket,
|
||||
development: Code2,
|
||||
documentation: Book,
|
||||
interface_api: Unplug,
|
||||
interface_and_api: Unplug,
|
||||
maintainance: Wrench,
|
||||
maintenance: Wrench,
|
||||
modelling: Database,
|
||||
ai: Brain,
|
||||
}
|
||||
|
||||
function toIconKey(type: string): string {
|
||||
return type.toLowerCase().replace(/\s+/g, "_").replace(/-/g, "_")
|
||||
}
|
||||
|
||||
function getIcon(serviceType: string | undefined): LucideIcon {
|
||||
if (!serviceType) return Database
|
||||
const key = toIconKey(serviceType)
|
||||
return ICON_MAP[key] ?? Database
|
||||
}
|
||||
|
||||
function stripHtml(html: string | undefined): string {
|
||||
if (!html) return ""
|
||||
return html.replace(/<[^>]*>/g, "").trim()
|
||||
}
|
||||
|
||||
async function getServices(): Promise<
|
||||
{ label: string; body: string; icon: LucideIcon }[]
|
||||
> {
|
||||
if (!drupalBaseUrl) return []
|
||||
|
||||
try {
|
||||
let raw: { data?: DrupalServiceNode[] } | null = null
|
||||
try {
|
||||
raw = await drupal.getResourceCollection<{
|
||||
data: DrupalServiceNode[]
|
||||
}>("node--service", {
|
||||
params: {
|
||||
"filter[status]": "1",
|
||||
sort: "created",
|
||||
},
|
||||
deserialize: false,
|
||||
next: { revalidate: 60 },
|
||||
})
|
||||
} catch (firstError) {
|
||||
const msg = (firstError as Error).message ?? ""
|
||||
if (msg.includes("Unauthorized")) {
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
raw = await drupal.getResourceCollection<{
|
||||
data: DrupalServiceNode[]
|
||||
}>("node--service", {
|
||||
params: {
|
||||
"filter[status]": "1",
|
||||
sort: "created",
|
||||
},
|
||||
deserialize: false,
|
||||
next: { revalidate: 60 },
|
||||
})
|
||||
} else {
|
||||
throw firstError
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = raw?.data ?? []
|
||||
if (!nodes.length) return []
|
||||
|
||||
return nodes.map((node) => {
|
||||
const bodyObj = node.body
|
||||
const bodyText =
|
||||
typeof bodyObj === "string"
|
||||
? stripHtml(bodyObj)
|
||||
: stripHtml(bodyObj?.value ?? bodyObj?.processed)
|
||||
|
||||
return {
|
||||
body: bodyText,
|
||||
icon: getIcon(node.field__service__type),
|
||||
label: node.title ?? "",
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
console.warn(
|
||||
"[HomeServices] CMS unreachable:",
|
||||
(error as Error).message
|
||||
)
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function HomeServices() {
|
||||
const services = await getServices()
|
||||
|
||||
return (
|
||||
<section className="home-services py-10" aria-labelledby="services-heading">
|
||||
<div className="home-services-header mb-6 text-center">
|
||||
<h2
|
||||
id="services-heading"
|
||||
className="home-services-title mb-3 font-bold tracking-tight text-slate-900"
|
||||
style={{ fontSize: "var(--fluid-section-title)" }}
|
||||
>
|
||||
Services
|
||||
</h2>
|
||||
<p className="home-services-description mx-auto max-w-3xl text-slate-600" style={{ fontSize: "var(--fluid-hero-desc)" }}>
|
||||
Data engineering, development, deployment, and ongoing support for
|
||||
your projects.
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-services-grid mx-2 flex flex-col gap-5 sm:mx-4 lg:mx-6">
|
||||
{services.map(({ label, body, icon: Icon }) => (
|
||||
<ScrollRevealCard key={label}>
|
||||
<div
|
||||
className="home-services-card mx-auto flex max-w-[1000px] min-h-0 w-full flex-col items-center justify-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-5 py-5 transition-colors hover:border-emerald-200 hover:bg-emerald-50/50 sm:min-h-[7rem] sm:flex-row sm:gap-10 sm:px-10 sm:py-8"
|
||||
>
|
||||
<Icon
|
||||
className="home-services-card-icon size-8 shrink-0 text-emerald-600 sm:size-10 lg:size-12"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col items-center justify-center gap-1.5 text-center sm:gap-2">
|
||||
<span className="home-services-card-label text-lg font-semibold text-slate-800 sm:text-xl lg:text-2xl">
|
||||
{label}
|
||||
</span>
|
||||
{body && (
|
||||
<span className="home-services-card-body text-sm leading-snug text-slate-600 sm:text-base lg:text-lg">
|
||||
{body}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollRevealCard>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
"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(
|
||||
<span
|
||||
key={`html-${key++}`}
|
||||
style={{ display: "contents" }}
|
||||
dangerouslySetInnerHTML={{ __html: before }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (match[1] === "email") {
|
||||
parts.push(
|
||||
<ObfuscatedEmail
|
||||
key={`email-${key++}`}
|
||||
className="text-emerald-600 outline-none transition-colors hover:text-emerald-500 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
parts.push(<ObfuscatedAddress key={`addr-${key++}`} />)
|
||||
}
|
||||
lastIndex = match.index + match[0].length
|
||||
}
|
||||
|
||||
const after = html.slice(lastIndex)
|
||||
if (after) {
|
||||
parts.push(
|
||||
<span
|
||||
key={`html-${key++}`}
|
||||
style={{ display: "contents" }}
|
||||
dangerouslySetInnerHTML={{ __html: after }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
"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<string | null>(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 (
|
||||
<span className="inline-flex items-center gap-2 text-slate-500">
|
||||
<Mail className="size-4 shrink-0" aria-hidden />
|
||||
<span>Write me</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="inline-flex items-center gap-2 text-emerald-600 outline-none transition-colors duration-200 ease-out hover:text-emerald-500 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
|
||||
aria-label="Write me an email"
|
||||
>
|
||||
<Mail className="size-4 shrink-0" aria-hidden />
|
||||
<span>Write me</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
"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 (
|
||||
<div className="group relative">
|
||||
<a
|
||||
href={getHref(item.url)}
|
||||
className={`${linkClass} flex items-center gap-1.5`}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<FolderOpen className="size-4" aria-hidden />
|
||||
{item.title}
|
||||
<ChevronDown className="size-4 transition-transform duration-200 ease-out group-hover:rotate-180" aria-hidden />
|
||||
</a>
|
||||
<div className="absolute left-0 top-full pt-2 opacity-0 pointer-events-none transition-all duration-200 ease-out group-hover:opacity-100 group-hover:pointer-events-auto translate-y-[-4px] group-hover:translate-y-0">
|
||||
<div className="rounded-md border border-slate-200 bg-white py-2 shadow-md">
|
||||
{children.map((child) => (
|
||||
<a
|
||||
key={child.id}
|
||||
href={getHref(child.url)}
|
||||
className="block rounded-sm px-4 py-2 text-emerald-600 outline-none transition-colors duration-200 ease-out hover:bg-slate-50 hover:text-emerald-500 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-inset"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{child.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const href = getHref(item.url)
|
||||
const isHome = href === "/"
|
||||
return (
|
||||
<a href={href} className={linkClass} onClick={onNavigate}>
|
||||
{isHome && <Home className="size-4 shrink-0" aria-hidden />}
|
||||
{item.title}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
interface MainNavClientProps {
|
||||
menuItems: DrupalMenuItem[]
|
||||
}
|
||||
|
||||
export function MainNavClient({ menuItems }: MainNavClientProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(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 (
|
||||
<nav className="flex items-center gap-8 text-sm font-medium" aria-label="Main">
|
||||
{/* Desktop: full nav. */}
|
||||
<div className="hidden lg:flex lg:items-center lg:gap-8">
|
||||
{enabledItems.map((item) => (
|
||||
<NavLink key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile: MORE trigger and dropdown. */}
|
||||
<div className="relative lg:hidden" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className="flex items-center rounded-sm px-3 py-2 text-lg font-bold uppercase tracking-wide text-emerald-500 outline-none transition-colors duration-200 ease-out hover:text-emerald-400 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-800"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
MORE
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-2 min-w-[12rem] rounded-md border border-slate-200 bg-white py-2 shadow-lg">
|
||||
{enabledItems.map((item) => {
|
||||
const children = item.items?.filter((c) => c.enabled !== false) ?? []
|
||||
return (
|
||||
<div key={item.id} className="border-b border-slate-100 last:border-b-0">
|
||||
<a
|
||||
href={getHref(item.url)}
|
||||
className="flex items-center gap-1.5 rounded-sm px-4 py-2 text-emerald-500 hover:bg-slate-50 hover:text-emerald-400"
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{children.length > 0 ? (
|
||||
<FolderOpen className="size-4" aria-hidden />
|
||||
) : getHref(item.url) === "/" ? (
|
||||
<Home className="size-4" aria-hidden />
|
||||
) : null}
|
||||
{item.title}
|
||||
</a>
|
||||
{children.length > 0 && (
|
||||
<div className="pl-6 pb-1">
|
||||
{children.map((child) => (
|
||||
<a
|
||||
key={child.id}
|
||||
href={getHref(child.url)}
|
||||
className="block rounded-sm px-2 py-1.5 text-sm text-emerald-500 hover:bg-slate-50 hover:text-emerald-400"
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{child.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
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<DrupalMenuItem[]> {
|
||||
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 <MainNavClient menuItems={menuItems} />
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import type { DrupalNode } from "@/lib/types"
|
||||
|
||||
interface NodeArticleTeaserProps {
|
||||
node: DrupalNode
|
||||
}
|
||||
|
||||
export function NodeArticleTeaser({ node }: NodeArticleTeaserProps) {
|
||||
const href = node.path?.alias || `/node/${node.id}`
|
||||
|
||||
return (
|
||||
<article className="group rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition hover:shadow-md">
|
||||
<h3 className="mb-2 text-xl font-semibold">
|
||||
<a href={href} className="text-gray-900 group-hover:text-emerald-500">
|
||||
{node.title}
|
||||
</a>
|
||||
</h3>
|
||||
<div className="mb-3 flex items-center gap-3 text-sm text-gray-500">
|
||||
{node.uid?.display_name && (
|
||||
<span>By {node.uid.display_name}</span>
|
||||
)}
|
||||
<time dateTime={node.created}>
|
||||
{new Date(node.created).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
{node.body?.summary && (
|
||||
<p className="line-clamp-3 text-gray-600">{node.body.summary}</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import type { DrupalNode } from "@/lib/types"
|
||||
|
||||
interface NodeArticleProps {
|
||||
node: DrupalNode
|
||||
}
|
||||
|
||||
export function NodeArticle({ node }: NodeArticleProps) {
|
||||
return (
|
||||
<article className="mx-auto max-w-4xl">
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight text-emerald-600">
|
||||
{node.title}
|
||||
</h1>
|
||||
<div className="mb-8 flex items-center gap-3 text-sm text-slate-600">
|
||||
{node.uid?.display_name && (
|
||||
<span>By {node.uid.display_name}</span>
|
||||
)}
|
||||
<time dateTime={node.created}>
|
||||
{new Date(node.created).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
{(node.body?.processed ?? node.body?.value) && (
|
||||
<div
|
||||
className="pemerald pemerald-slate max-w-none pemerald-a:text-emerald-600 pemerald-a:underline hover:pemerald-a:text-emerald-500"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: node.body.processed ?? node.body?.value ?? "",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import type { DrupalNode } from "@/lib/types"
|
||||
|
||||
interface NodePageProps {
|
||||
node: DrupalNode
|
||||
}
|
||||
|
||||
export function NodePage({ node }: NodePageProps) {
|
||||
return (
|
||||
<article className="mx-auto max-w-4xl">
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight text-emerald-600">
|
||||
{node.title}
|
||||
</h1>
|
||||
{node.body?.processed && (
|
||||
<div
|
||||
className="pemerald pemerald-slate max-w-none pemerald-a:text-emerald-600 pemerald-a:underline hover:pemerald-a:text-emerald-500"
|
||||
dangerouslySetInnerHTML={{ __html: node.body.processed }}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
"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<AddressData | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setAddress({
|
||||
fullname: "Robert Nasarek",
|
||||
street: "Kleine Ulrichstraße 1",
|
||||
city: "Halle (Saale)",
|
||||
country: "Germany",
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!address) {
|
||||
return (
|
||||
<span className={className}>
|
||||
<noscript>Robert Nasarek, Kleine Ulrichstraße 1, Halle (Saale), Germany</noscript>
|
||||
<span aria-hidden>…</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p className={className}>
|
||||
{address.fullname}
|
||||
<br />
|
||||
{address.street}
|
||||
<br />
|
||||
{address.city}
|
||||
<br />
|
||||
{address.country}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
"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<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const localPart = "robert"
|
||||
const domain = "nasarek"
|
||||
const tld = "dev"
|
||||
setEmail(`${localPart}@${domain}.${tld}`)
|
||||
}, [])
|
||||
|
||||
if (!email) {
|
||||
return (
|
||||
<span className={className}>
|
||||
<noscript>robert [at] nasarek [dot] dev</noscript>
|
||||
<span aria-hidden>…</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={`mailto:${email}`} className={className}>
|
||||
{email}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react"
|
||||
|
||||
interface ScrollRevealCardProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ScrollRevealCard({ children }: ScrollRevealCardProps) {
|
||||
const ref = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`transition-all duration-700 ease-out ${
|
||||
isVisible ? "translate-y-0 opacity-100" : "translate-y-12 opacity-20"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
"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<HTMLDivElement>(null)
|
||||
const [isVisible, setIsVisible] = useState(initialVisible)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | 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 (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`transition-all duration-700 ease-out ${
|
||||
isVisible ? "translate-y-0 opacity-100" : "translate-y-12 opacity-20"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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",
|
||||
})
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
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<string, string>
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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
|
||||
1969
drupal/nextjs/package-lock.json
generated
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 980 B |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="97.97727mm" height="128.01656mm" viewBox="0 0 277.73084 362.8816"><title>Drupal</title><g id="Livello_2" data-name="Livello 2"><g id="Livello_1-2" data-name="Livello 1"><path d="M196.00843,77.29177C170.47408,51.76951,146.11187,27.43962,138.86135,0c-7.25088,27.43962-31.617,51.76951-57.14709,77.29177C43.41893,115.56293,0,158.93748,0,223.99184,0,300.57855,62.291,362.8816,138.86135,362.8816c76.58243,0,138.86524-62.29879,138.86949-138.88976,0-65.05011-43.41537-108.42891-81.72241-146.70007M59.02214,256.34977c-8.51464-.28912-39.93878-54.453,18.35806-112.124L115.95754,186.365s2.36878,2.22706-.25751,4.92082c-9.2055,9.44134-48.44171,48.78732-53.31849,62.39221-1.0066,2.80815-2.47674,2.70194-3.3594,2.67175m79.84347,71.38635a47.759,47.759,0,0,1-47.75939-47.75938c0-12.09214,4.80716-22.86793,11.90389-31.54632,8.61161-10.53036,35.8491-40.148,35.8491-40.148s26.8205,30.05237,35.78481,40.04746a46.706,46.706,0,0,1,11.981,31.64684,47.75949,47.75949,0,0,1-47.75937,47.75938m91.41133-77.44876c-1.02935,2.25121-3.36439,6.00948-6.516,6.12421-5.6177.2046-6.218-2.67388-10.37017-8.819-9.116-13.49017-88.67067-96.63406-103.5507-112.71395-13.08844-14.143-1.84309-24.11392,3.37325-29.33914,6.54441-6.55613,25.6473-25.64732,25.6473-25.64732s56.96026,54.04379,80.68775,90.97055,15.55027,68.88013,10.72856,79.42469" style="fill:#475569"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
|
@ -1,10 +0,0 @@
|
|||
<svg width="98" height="96" viewBox="0 0 98 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_730_27126)">
|
||||
<path d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 6.69539e-07 48.9043 4.309e-07C21.8203 1.92261e-07 -1.9479e-07 22.1074 -4.3343e-07 49.1914C-6.20631e-07 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z" fill="#475569"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_730_27126">
|
||||
<rect width="98" height="96" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="74" height="79" viewBox="0 0 74 79" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M73.7014 17.4323C72.5616 9.05152 65.1774 2.4469 56.424 1.1671C54.9472 0.950843 49.3518 0.163818 36.3901 0.163818H36.2933C23.3281 0.163818 20.5465 0.950843 19.0697 1.1671C10.56 2.41145 2.78877 8.34604 0.903306 16.826C-0.00357854 21.0022 -0.100361 25.6322 0.068112 29.8793C0.308275 35.9699 0.354874 42.0498 0.91406 48.1156C1.30064 52.1448 1.97502 56.1419 2.93215 60.0769C4.72441 67.3445 11.9795 73.3925 19.0876 75.86C26.6979 78.4332 34.8821 78.8603 42.724 77.0937C43.5866 76.8952 44.4398 76.6647 45.2833 76.4024C47.1867 75.8033 49.4199 75.1332 51.0616 73.9562C51.0841 73.9397 51.1026 73.9184 51.1156 73.8938C51.1286 73.8693 51.1359 73.8421 51.1368 73.8144V67.9366C51.1364 67.9107 51.1302 67.8852 51.1186 67.862C51.1069 67.8388 51.0902 67.8184 51.0695 67.8025C51.0489 67.7865 51.0249 67.7753 50.9994 67.7696C50.9738 67.764 50.9473 67.7641 50.9218 67.7699C45.8976 68.9569 40.7491 69.5519 35.5836 69.5425C26.694 69.5425 24.3031 65.3699 23.6184 63.6327C23.0681 62.1314 22.7186 60.5654 22.5789 58.9744C22.5775 58.9477 22.5825 58.921 22.5934 58.8965C22.6043 58.8721 22.621 58.8505 22.6419 58.8336C22.6629 58.8167 22.6876 58.8049 22.714 58.7992C22.7404 58.7934 22.7678 58.794 22.794 58.8007C27.7345 59.9796 32.799 60.5746 37.8813 60.5733C39.1036 60.5733 40.3223 60.5733 41.5447 60.5414C46.6562 60.3996 52.0437 60.1408 57.0728 59.1694C57.1983 59.1446 57.3237 59.1233 57.4313 59.0914C65.3638 57.5847 72.9128 52.8555 73.6799 40.8799C73.7086 40.4084 73.7803 35.9415 73.7803 35.4523C73.7839 33.7896 74.3216 23.6576 73.7014 17.4323ZM61.4925 47.3144H53.1514V27.107C53.1514 22.8528 51.3591 20.6832 47.7136 20.6832C43.7061 20.6832 41.6988 23.2499 41.6988 28.3194V39.3803H33.4078V28.3194C33.4078 23.2499 31.3969 20.6832 27.3894 20.6832C23.7654 20.6832 21.9552 22.8528 21.9516 27.107V47.3144H13.6176V26.4937C13.6176 22.2395 14.7157 18.8598 16.9118 16.3545C19.1772 13.8552 22.1488 12.5719 25.8373 12.5719C30.1064 12.5719 33.3325 14.1955 35.4832 17.4394L37.5587 20.8853L39.6377 17.4394C41.7884 14.1955 45.0145 12.5719 49.2765 12.5719C52.9614 12.5719 55.9329 13.8552 58.2055 16.3545C60.4017 18.8574 61.4997 22.2371 61.4997 26.4937L61.4925 47.3144Z" fill="#475569"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
|
@ -1,4 +0,0 @@
|
|||
<svg viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M149.508 157.52L69.142 54H54V125.97H66.1136V69.3836L139.999 164.845C143.333 162.132 146.509 159.256 149.508 156.208V157.52Z" fill="#475569"/>
|
||||
<path d="M115 54H127V126H115V54Z" fill="#475569"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 288 B |
|
Before Width: | Height: | Size: 726 B |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 395 B |
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 11.9976C3.5 7.30452 7.30452 3.5 11.9976 3.5C16.6908 3.5 20.4953 7.30452 20.4953 11.9976C20.4953 16.6908 16.6908 20.4953 11.9976 20.4953C7.30452 20.4953 3.5 16.6908 3.5 11.9976ZM11.9976 1.5C6.19995 1.5 1.5 6.19995 1.5 11.9976C1.5 17.7953 6.19995 22.4953 11.9976 22.4953C17.7953 22.4953 22.4953 17.7953 22.4953 11.9976C22.4953 6.19995 17.7953 1.5 11.9976 1.5ZM6.58886 7.81169C6.48624 7.26902 6.84298 6.74592 7.38565 6.64331C7.98535 6.54239 8.59286 6.49319 9.20055 6.47725C10.2721 6.44914 11.7394 6.52235 13.294 6.93892C14.8487 7.35549 16.1561 8.02575 17.07 8.58585C17.5854 8.90177 18.0975 9.24236 18.5579 9.63641C18.9714 9.99199 19.0204 10.6337 18.6645 11.0474C18.3047 11.4656 17.6743 11.5133 17.2556 11.1543C16.8767 10.829 16.4494 10.5513 16.0249 10.2911C15.2209 9.79833 14.0926 9.22344 12.7764 8.87077C11.4602 8.5181 10.1957 8.45183 9.25299 8.47656C8.75397 8.48965 8.24933 8.52191 7.7568 8.60857C7.21429 8.7109 6.69144 8.35421 6.58886 7.81169ZM7.57136 12.3409C7.05016 12.5151 6.48542 12.2358 6.30795 11.715C6.12983 11.1922 6.40923 10.624 6.93201 10.4459C7.43418 10.2752 7.97927 10.2036 8.50474 10.1603C9.44887 10.0825 10.7702 10.1141 12.3312 10.5324C13.8922 10.9507 15.0523 11.5839 15.831 12.1234C16.2699 12.4274 16.6857 12.7662 17.0502 13.1571C17.4139 13.5727 17.3718 14.2045 16.9562 14.5681C16.5403 14.932 15.9139 14.8769 15.5464 14.4756C15.5138 14.4403 15.2316 14.1411 14.6921 13.7674C14.0759 13.3406 13.1234 12.8152 11.8135 12.4642C10.5037 12.1133 9.41608 12.092 8.669 12.1535C8.29606 12.1843 7.93296 12.2481 7.57136 12.3409ZM6.47141 14.18C5.99179 14.4538 5.82495 15.0646 6.09878 15.5442C6.36807 16.0159 6.96329 16.1851 7.43912 15.9301C7.63939 15.8384 7.87055 15.8011 8.08687 15.7738C8.62406 15.7061 9.52907 15.7036 10.8507 16.0577C12.1723 16.4118 12.9548 16.8665 13.3862 17.1937C13.7115 17.4406 13.8457 17.6204 13.8677 17.6511C14.178 18.0878 14.738 18.272 15.2227 17.989C15.6996 17.7105 15.8605 17.0981 15.5821 16.6212C15.3193 16.2207 14.9758 15.8893 14.5949 15.6004C13.9395 15.1031 12.9176 14.541 11.3683 14.1259C9.81907 13.7107 8.65294 13.6866 7.8367 13.7895C7.36239 13.8493 6.89923 13.9646 6.47141 14.18Z" fill="#475569"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
|
@ -1,20 +0,0 @@
|
|||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="397.000000pt" height="278.000000pt" viewBox="0 0 397.000000 278.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.16, written by Peter Selinger 2001-2019
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,278.000000) scale(0.100000,-0.100000)"
|
||||
fill="#475569" stroke="none">
|
||||
<path d="M1297 2770 c-461 -16 -763 -44 -882 -81 -177 -54 -308 -207 -350
|
||||
-407 -86 -416 -86 -1369 1 -1791 43 -208 196 -370 395 -415 85 -20 305 -42
|
||||
574 -58 298 -17 1600 -17 1900 0 557 33 683 62 814 192 99 97 138 195 171 420
|
||||
32 225 44 429 44 760 0 414 -31 786 -80 950 -60 205 -219 338 -444 374 -115
|
||||
19 -282 33 -535 46 -257 14 -1313 20 -1608 10z m828 -1099 c259 -149 471 -275
|
||||
473 -279 2 -6 -585 -352 -950 -560 l-58 -33 0 591 0 592 33 -20 c17 -11 244
|
||||
-142 502 -291z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 984 B |
|
|
@ -1,3 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 51.046 51.046" fill="#475569">
|
||||
<path d="m 28.324,20.044 c -0.043,-0.106 -0.084,-0.214 -0.131,-0.32 -0.707,-1.602 -1.656,-2.997 -2.848,-4.19 -1.188,-1.187 -2.582,-2.125 -4.184,-2.805 -1.605,-0.678 -3.309,-1.02 -5.104,-1.02 -1.85,0 -3.564,0.342 -5.137,1.02 -1.467,0.628 -2.764,1.488 -3.91,2.552 V 14.84 c 0,-1.557 -1.262,-2.822 -2.82,-2.822 h -19.775 c -1.557,0 -2.82,1.265 -2.82,2.822 0,1.559 1.264,2.82 2.82,2.82 h 15.541 l -18.23,24.546 c -0.362,0.487 -0.557,1.077 -0.557,1.682 v 1.841 c 0,1.558 1.264,2.822 2.822,2.822 H 5.038 c 1.488,0 2.705,-1.153 2.812,-2.614 0.932,0.743 1.967,1.364 3.109,1.848 1.605,0.684 3.299,1.021 5.102,1.021 2.723,0 5.15,-0.726 7.287,-2.187 1.727,-1.176 3.092,-2.639 4.084,-4.389 0.832799,-1.472094 1.418284,-2.633352 1.221889,-3.729182 -0.173003,-0.965318 -0.694914,-1.946419 -2.326865,-2.378358 -0.58,0 -1.376024,0.17454 -1.833024,0.49254 -0.463,0.316 -0.793,0.744 -0.982,1.275 l -0.453,0.93 c -0.631,1.365 -1.566,2.443 -2.809,3.244 -1.238,0.803 -2.633,1.201 -4.188,1.201 -1.023,0 -2.004,-0.191 -2.955,-0.579 -0.941,-0.39 -1.758,-0.935 -2.439,-1.64 C 9.986,40.343 9.441,39.526 9.027,38.603 8.617,37.679 8.41,36.71 8.41,35.687 v -2.476 h 17.715 c 0,0 1.517774,-0.15466 2.183375,-0.770672 0.958496,-0.887085 0.864622,-2.15038 0.864622,-2.15038 0,0 -0.04354,-5.066834 -0.338376,-7.578154 C 28.729048,21.812563 28.324,20.044 28.324,20.044 Z M -11.767,42.91 2.991,23.036 C 2.913,23.623 2.87,24.22 2.87,24.827 v 10.86 c 0,1.799 0.35,3.498 1.059,5.104 0.328,0.752 0.719,1.458 1.156,2.119 -0.016,0 -0.031,-10e-4 -0.047,-10e-4 H -11.767 Z M 23.71,27.667 H 8.409 v -2.841 c 0,-1.015 0.189,-1.99 0.58,-2.912 0.391,-0.922 0.936,-1.74 1.645,-2.444 0.697,-0.703 1.516,-1.249 2.438,-1.641 0.922,-0.388 1.92,-0.581 2.99,-0.581 1.02,0 2.002,0.193 2.949,0.581 0.949,0.393 1.764,0.938 2.441,1.641 0.682,0.704 1.225,1.521 1.641,2.444 0.414,0.922 0.617,1.896 0.617,2.912 z" transform="translate(20.35 -4.735)"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 407 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 637 KiB |
|
Before Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 2 MiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 274 KiB |
|
|
@ -1,27 +0,0 @@
|
|||
{
|
||||
"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"]
|
||||
}
|
||||
|
|
@ -3,6 +3,6 @@ FROM nginx:latest
|
|||
COPY ./nginx.conf.template /etc/nginx/nginx.conf.template
|
||||
|
||||
ARG DOMAIN
|
||||
RUN envsubst '${DOMAIN}' < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf
|
||||
RUN sed 's|${DOMAIN}|'"$DOMAIN"'|g' /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf
|
||||
|
||||
ENTRYPOINT ["nginx", "-g", "daemon off;"]
|
||||
|
|
|
|||
|
|
@ -18,21 +18,11 @@ http {
|
|||
keepalive_timeout 65;
|
||||
gzip on;
|
||||
|
||||
# Increase client body size for file uploads.
|
||||
client_max_body_size 64M;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name cms.${DOMAIN};
|
||||
server_name ${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;
|
||||
}
|
||||
|
|
@ -43,10 +33,9 @@ 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|woff|woff2|ttf|eot)$ {
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
try_files $uri @rewrite;
|
||||
expires max;
|
||||
log_not_found off;
|
||||
|
|
@ -56,19 +45,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;
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
services:
|
||||
# Docker-in-Docker backend so CI jobs run in isolated containers,
|
||||
# not against the host Docker daemon.
|
||||
docker-in-docker:
|
||||
image: docker:dind
|
||||
container_name: forgejo-runner-dind
|
||||
privileged: true
|
||||
environment:
|
||||
DOCKER_TLS_CERTDIR: /certs
|
||||
volumes:
|
||||
- runner-certs:/certs/client
|
||||
- forgejo-runner-dind:/var/lib/docker
|
||||
networks:
|
||||
forgejo-runner:
|
||||
# DinD's TLS cert is issued for the SAN "docker"; alias so the
|
||||
# runner can verify the cert when connecting over TLS.
|
||||
aliases:
|
||||
- docker
|
||||
restart: unless-stopped
|
||||
|
||||
forgejo-runner:
|
||||
image: code.forgejo.org/forgejo/runner:${RUNNER_VERSION:-6}
|
||||
container_name: forgejo-runner
|
||||
environment:
|
||||
DOCKER_HOST: tcp://docker:2376
|
||||
DOCKER_CERT_PATH: /certs/client
|
||||
DOCKER_TLS_VERIFY: "1"
|
||||
FORGEJO_INSTANCE_URL: ${FORGEJO_INSTANCE_URL}
|
||||
FORGEJO_RUNNER_TOKEN: ${FORGEJO_RUNNER_TOKEN}
|
||||
FORGEJO_RUNNER_NAME: ${FORGEJO_RUNNER_NAME:-forgejo-runner}
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
set -e
|
||||
if [ ! -f /data/.runner ]; then
|
||||
echo "Registering runner with ${FORGEJO_INSTANCE_URL} ..."
|
||||
forgejo-runner register --no-interactive \
|
||||
--instance "${FORGEJO_INSTANCE_URL}" \
|
||||
--token "${FORGEJO_RUNNER_TOKEN}" \
|
||||
--name "${FORGEJO_RUNNER_NAME}" \
|
||||
--labels "docker:docker://node:20-bookworm,ubuntu-latest:docker://node:20-bookworm"
|
||||
fi
|
||||
exec forgejo-runner daemon
|
||||
working_dir: /data
|
||||
volumes:
|
||||
- forgejo-runner-data:/data
|
||||
- runner-certs:/certs/client:ro
|
||||
networks:
|
||||
- forgejo-runner
|
||||
- traefik
|
||||
depends_on:
|
||||
- docker-in-docker
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
forgejo-runner-data:
|
||||
name: forgejo-runner-data
|
||||
forgejo-runner-dind:
|
||||
name: forgejo-runner-dind
|
||||
runner-certs:
|
||||
name: forgejo-runner-certs
|
||||
|
||||
networks:
|
||||
forgejo-runner:
|
||||
name: forgejo-runner
|
||||
traefik:
|
||||
name: traefik
|
||||
external: true
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
services:
|
||||
# Forgejo git forge (git.nasarek.dev). Replaces GitLab.
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:${FORGEJO_VERSION:-11}
|
||||
container_name: forgejo
|
||||
environment:
|
||||
USER_UID: 1000
|
||||
USER_GID: 1000
|
||||
FORGEJO__database__DB_TYPE: postgres
|
||||
FORGEJO__database__HOST: postgres:5432
|
||||
FORGEJO__database__NAME: ${FORGEJO_DB_NAME}
|
||||
FORGEJO__database__USER: ${FORGEJO_DB_USER}
|
||||
FORGEJO__database__PASSWD: ${FORGEJO_DB_PASSWORD}
|
||||
FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN}
|
||||
FORGEJO__server__ROOT_URL: https://${FORGEJO_DOMAIN}/
|
||||
FORGEJO__server__SSH_DOMAIN: ${FORGEJO_DOMAIN}
|
||||
FORGEJO__server__HTTP_PORT: "3000"
|
||||
# Advertised in clone URLs (Traefik forgejo-ssh entrypoint, formerly GitLab's port).
|
||||
FORGEJO__server__SSH_PORT: "2424"
|
||||
FORGEJO__server__SSH_LISTEN_PORT: "22"
|
||||
FORGEJO__actions__ENABLED: "true"
|
||||
# Skip the web installer; auto-migrate against Postgres on boot.
|
||||
FORGEJO__security__INSTALL_LOCK: "true"
|
||||
FORGEJO__service__DISABLE_REGISTRATION: "true"
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik
|
||||
# HTTP
|
||||
- traefik.http.routers.forgejo.rule=Host(`${FORGEJO_DOMAIN}`)
|
||||
- traefik.http.routers.forgejo.entrypoints=web,websecure
|
||||
- traefik.http.routers.forgejo.middlewares=https-redirect
|
||||
- traefik.http.routers.forgejo.tls=true
|
||||
- traefik.http.routers.forgejo.tls.certresolver=le
|
||||
- traefik.http.services.forgejo.loadbalancer.server.port=3000
|
||||
# SSH over dedicated Traefik TCP entrypoint (port 2424)
|
||||
- "traefik.tcp.routers.forgejo-ssh.rule=HostSNI(`*`)"
|
||||
- "traefik.tcp.routers.forgejo-ssh.entrypoints=forgejo-ssh"
|
||||
- "traefik.tcp.services.forgejo-ssh.loadbalancer.server.port=22"
|
||||
volumes:
|
||||
- forgejo-data:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
networks:
|
||||
- traefik
|
||||
- database
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
forgejo-data:
|
||||
name: forgejo-data
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
name: traefik
|
||||
external: true
|
||||
database:
|
||||
name: database
|
||||
external: true
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# Bulk-migrate every GitLab project into Forgejo with full data
|
||||
# (code, issues, merge requests, labels, milestones, releases, wiki).
|
||||
#
|
||||
# Runs both forges in parallel — GitLab is only read, never modified.
|
||||
#
|
||||
# Usage:
|
||||
# export GITLAB_URL=https://gitlab.nasarek.dev
|
||||
# export GITLAB_TOKEN=glpat-xxxxxxxx # scopes: read_api, read_repository
|
||||
# export FORGEJO_URL=https://git.nasarek.dev
|
||||
# export FORGEJO_TOKEN=xxxxxxxx # Forgejo app token, repo + org scope
|
||||
# export FORGEJO_OWNER=root # target user/org that will own the repos
|
||||
# ./migrate-from-gitlab.sh [--dry-run]
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN=false
|
||||
[ "${1:-}" = "--dry-run" ] && DRY_RUN=true
|
||||
|
||||
: "${GITLAB_URL:?set GITLAB_URL}"
|
||||
: "${GITLAB_TOKEN:?set GITLAB_TOKEN}"
|
||||
: "${FORGEJO_URL:?set FORGEJO_URL}"
|
||||
: "${FORGEJO_TOKEN:?set FORGEJO_TOKEN}"
|
||||
: "${FORGEJO_OWNER:?set FORGEJO_OWNER (target user/org)}"
|
||||
|
||||
command -v jq >/dev/null || { echo "jq is required"; exit 1; }
|
||||
|
||||
# Resolve the Forgejo owner's numeric uid (repos/migrate needs repo_owner name + uid).
|
||||
OWNER_UID=$(curl -fsS -H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
"${FORGEJO_URL}/api/v1/users/${FORGEJO_OWNER}" | jq -r '.id')
|
||||
echo "Target owner: ${FORGEJO_OWNER} (uid=${OWNER_UID})"
|
||||
|
||||
migrate_one() {
|
||||
local clone_url="$1" name="$2" private="$3" desc="$4"
|
||||
echo "==> ${name} (private=${private})"
|
||||
if $DRY_RUN; then return 0; fi
|
||||
|
||||
local payload
|
||||
payload=$(jq -n \
|
||||
--arg addr "$clone_url" --arg token "$GITLAB_TOKEN" \
|
||||
--arg name "$name" --arg owner "$FORGEJO_OWNER" \
|
||||
--argjson uid "$OWNER_UID" --argjson private "$private" \
|
||||
--arg desc "$desc" \
|
||||
'{clone_addr:$addr, service:"gitlab", auth_token:$token,
|
||||
repo_name:$name, repo_owner:$owner, uid:$uid,
|
||||
private:$private, description:$desc,
|
||||
issues:true, pull_requests:true, labels:true,
|
||||
milestones:true, releases:true, wiki:true}')
|
||||
|
||||
local code
|
||||
code=$(curl -s -o /tmp/forgejo_migrate_resp.json -w '%{http_code}' \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" -H "Content-Type: application/json" \
|
||||
-X POST "${FORGEJO_URL}/api/v1/repos/migrate" -d "$payload")
|
||||
|
||||
if [ "$code" = "201" ]; then
|
||||
echo " OK"
|
||||
elif [ "$code" = "409" ]; then
|
||||
echo " SKIP (already exists)"
|
||||
else
|
||||
echo " FAILED (HTTP $code): $(jq -r '.message // .' /tmp/forgejo_migrate_resp.json)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Page through all GitLab projects the token can see.
|
||||
page=1
|
||||
while :; do
|
||||
resp=$(curl -fsS -H "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
|
||||
"${GITLAB_URL}/api/v4/projects?membership=true&per_page=100&page=${page}&simple=false")
|
||||
count=$(echo "$resp" | jq 'length')
|
||||
[ "$count" -eq 0 ] && break
|
||||
|
||||
while IFS=$'\t' read -r http_url repo_path visibility description; do
|
||||
private=true; [ "$visibility" = "public" ] && private=false
|
||||
# Use the bare repo slug (e.g. "dfg_3dviewer_embed"), dropping the GitLab namespace.
|
||||
migrate_one "$http_url" "$repo_path" "$private" "${description:-}"
|
||||
done < <(echo "$resp" | jq -r '.[] | [.http_url_to_repo, .path, .visibility, (.description // "")] | @tsv')
|
||||
|
||||
page=$((page + 1))
|
||||
done
|
||||
|
||||
echo "Done."
|
||||
54
gitlab/docker-compose.yml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
services:
|
||||
gitlab:
|
||||
image: gitlab/gitlab-ce:17.8.6-ce.0
|
||||
container_name: gitlab
|
||||
hostname: '${GITLAB_DOMAIN}'
|
||||
environment:
|
||||
GITLAB_OMNIBUS_CONFIG: |
|
||||
# Add any other gitlab.rb configuration here, each on its own line
|
||||
external_url 'https://${GITLAB_DOMAIN}'
|
||||
gitlab_rails['gitlab_shell_ssh_port'] = 2424
|
||||
# We need to tell GitLab to use SSH port 22 internally
|
||||
gitlab_shell['auth_file'] = "/var/opt/gitlab/.ssh/authorized_keys"
|
||||
#gitlab_shell['ssh_port'] = 22
|
||||
nginx['listen_port'] = 80
|
||||
nginx['listen_https'] = false
|
||||
nginx['proxy_set_headers'] = {
|
||||
"X-Forwarded-Proto" => "https",
|
||||
"X-Forwarded-Ssl" => "on"
|
||||
}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik
|
||||
# HTTP configuration
|
||||
- traefik.http.routers.gitlab.entrypoints=web,websecure
|
||||
- traefik.http.routers.gitlab.tls=true
|
||||
- traefik.http.routers.gitlab.tls.certresolver=le
|
||||
- traefik.http.routers.gitlab.rule=Host(`${GITLAB_DOMAIN}`)
|
||||
- traefik.http.services.gitlab.loadbalancer.server.port=80
|
||||
# TCP/SSH configuration - completely revised
|
||||
- "traefik.tcp.routers.gitlab-ssh.rule=HostSNI(`*`)"
|
||||
- "traefik.tcp.routers.gitlab-ssh.entrypoints=gitlab-ssh"
|
||||
- "traefik.tcp.services.gitlab-ssh.loadbalancer.server.port=22"
|
||||
volumes:
|
||||
- 'gitlab-config:/etc/gitlab'
|
||||
- 'gitlab-logs:/var/log/gitlab'
|
||||
- 'gitlab-data:/var/opt/gitlab'
|
||||
shm_size: '256m'
|
||||
networks:
|
||||
- traefik
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
volumes:
|
||||
gitlab-config:
|
||||
name: gitlab-config
|
||||
gitlab-logs:
|
||||
name: gitlab-logs
|
||||
gitlab-data:
|
||||
name: gitlab-data
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
name: traefik
|
||||
external: true
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
services:
|
||||
hedgedoc:
|
||||
# Make sure to use the latest release from https://hedgedoc.org/latest-release
|
||||
image: quay.io/hedgedoc/hedgedoc:1.10.3
|
||||
image: quay.io/hedgedoc/hedgedoc:1.10.2
|
||||
container_name: hedgedoc
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- CMD_DB_URL=postgres://${HEDGEDOC_DB_USER}:${HEDGEDOC_DB_PASSWORD}@${HEDGEDOC_DB_HOST}:${HEDGEDOC_DB_PORT}/${HEDGEDOC_DB_NAME}
|
||||
- CMD_DOMAIN=${HEDGEDOC_DOMAIN}
|
||||
|
|
|
|||
1
mailcow/.gitignore
vendored
|
|
@ -75,4 +75,3 @@ refresh_images.sh
|
|||
update_diffs/
|
||||
create_cold_standby.sh
|
||||
!data/conf/nginx/mailcow_auth.conf
|
||||
data/conf/postfix/postfix-tlspol
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
# Contribution Guidelines
|
||||
**_Last modified on 12th November 2025_**
|
||||
**_Last modified on 15th August 2024_**
|
||||
|
||||
First of all, thank you for wanting to provide a bugfix or a new feature for the mailcow community, it's because of your help that the project can continue to grow!
|
||||
|
||||
As we want to keep mailcow's development structured we setup these Guidelines which helps you to create your issue/pull request accordingly.
|
||||
|
||||
**PLEASE NOTE, THAT WE WILL CLOSE ISSUES/PULL REQUESTS IF THEY DON'T FULFILL OUR WRITTEN GUIDELINES WRITTEN INSIDE THIS DOCUMENT**. So please check this guidelines before you propose a Issue/Pull Request.
|
||||
**PLEASE NOTE, THAT WE MIGHT CLOSE ISSUES/PULL REQUESTS IF THEY DON'T FULLFIL OUR WRITTEN GUIDELINES WRITTEN INSIDE THIS DOCUMENT**. So please check this guidelines before you propose a Issue/Pull Request.
|
||||
|
||||
## Topics
|
||||
|
||||
|
|
@ -27,18 +27,14 @@ However, please note the following regarding pull requests:
|
|||
6. Please **ALWAYS** create the actual pull request against the staging branch and **NEVER** directly against the master branch. *If you forget to do this, our moobot will remind you to switch the branch to staging.*
|
||||
7. Wait for a merge commit: It may happen that we do not accept your pull request immediately or sometimes not at all for various reasons. Please do not be disappointed if this is the case. We always endeavor to incorporate any meaningful changes from the community into the mailcow project.
|
||||
8. If you are planning larger and therefore more complex pull requests, it would be advisable to first announce this in a separate issue and then start implementing it after the idea has been accepted in order to avoid unnecessary frustration and effort!
|
||||
9. If your PR requires a Docker image rebuild (changes to Dockerfiles or files in data/Dockerfiles/), update the image tag in docker-compose.yml. Use the base-image versioning (e.g. ghcr.io/mailcow/sogo:5.12.4 → :5.12.5 for version bumps; append a letter for patch fixes, e.g. :5.12.4a). Follow this scheme.
|
||||
|
||||
---
|
||||
|
||||
## Issue Reporting
|
||||
**_Last modified on 12th November 2025_**
|
||||
**_Last modified on 15th August 2024_**
|
||||
|
||||
If you plan to report a issue within mailcow please read and understand the following rules:
|
||||
|
||||
### Security disclosures / Security-related fixes
|
||||
- Security vulnerabilities and security fixes must always be reported confidentially first to the contact address specified in SECURITY.md before they are integrated, published, or publicly disclosed in issues/PRs. Please wait for a response from the specified contact to ensure coordinated and responsible disclosure.
|
||||
|
||||
### Issue Reporting Guidelines
|
||||
|
||||
1. **ONLY** use the issue tracker for bug reports or improvement requests and NOT for support questions. For support questions you can either contact the [mailcow community on Telegram](https://docs.mailcow.email/#community-support-and-chat) or the mailcow team directly in exchange for a [support fee](https://docs.mailcow.email/#commercial-support).
|
||||
|
|
|
|||
|
|
@ -1,230 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# _modules/scripts/core.sh
|
||||
# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
|
||||
# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
|
||||
|
||||
# ANSI color for red errors
|
||||
RED='\e[31m'
|
||||
GREEN='\e[32m'
|
||||
YELLOW='\e[33m'
|
||||
BLUE='\e[34m'
|
||||
MAGENTA='\e[35m'
|
||||
LIGHT_RED='\e[91m'
|
||||
LIGHT_GREEN='\e[92m'
|
||||
NC='\e[0m'
|
||||
|
||||
caller="${BASH_SOURCE[1]##*/}"
|
||||
|
||||
get_installed_tools(){
|
||||
for bin in openssl curl docker git awk sha1sum grep cut jq; do
|
||||
if [[ -z $(command -v ${bin}) ]]; then
|
||||
echo "Error: Cannot find command '${bin}'. Cannot proceed."
|
||||
echo "Solution: Please review system requirements and install requirements. Then, re-run the script."
|
||||
echo "See System Requirements: https://docs.mailcow.email/getstarted/install/"
|
||||
echo "Exiting..."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if grep --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox grep detected, please install gnu grep, \"apk add --no-cache --upgrade grep\"${NC}"; exit 1; fi
|
||||
# This will also cover sort
|
||||
if cp --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox cp detected, please install coreutils, \"apk add --no-cache --upgrade coreutils\"${NC}"; exit 1; fi
|
||||
if sed --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox sed detected, please install gnu sed, \"apk add --no-cache --upgrade sed\"${NC}"; exit 1; fi
|
||||
}
|
||||
|
||||
get_docker_version(){
|
||||
# Check Docker Version (need at least 24.X)
|
||||
docker_version=$(docker version --format '{{.Server.Version}}' | cut -d '.' -f 1)
|
||||
}
|
||||
|
||||
get_compose_type(){
|
||||
if docker compose > /dev/null 2>&1; then
|
||||
if docker compose version --short | grep -e "^[2-9]\." -e "^v[2-9]\." -e "^[1-9][0-9]\." -e "^v[1-9][0-9]\." > /dev/null 2>&1; then
|
||||
COMPOSE_VERSION=native
|
||||
COMPOSE_COMMAND="docker compose"
|
||||
if [[ "$caller" == "update.sh" ]]; then
|
||||
sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=native/' "$SCRIPT_DIR/mailcow.conf"
|
||||
fi
|
||||
echo -e "\e[33mFound Docker Compose Plugin (native).\e[0m"
|
||||
echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to native\e[0m"
|
||||
sleep 2
|
||||
echo -e "\e[33mNotice: You'll have to update this Compose Version via your Package Manager manually!\e[0m"
|
||||
else
|
||||
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
|
||||
echo -e "\e[31mPlease update/install it manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
elif docker-compose > /dev/null 2>&1; then
|
||||
if ! [[ $(alias docker-compose 2> /dev/null) ]] ; then
|
||||
if docker-compose version --short | grep -e "^[2-9]\." -e "^[1-9][0-9]\." > /dev/null 2>&1; then
|
||||
COMPOSE_VERSION=standalone
|
||||
COMPOSE_COMMAND="docker-compose"
|
||||
if [[ "$caller" == "update.sh" ]]; then
|
||||
sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=standalone/' "$SCRIPT_DIR/mailcow.conf"
|
||||
fi
|
||||
echo -e "\e[33mFound Docker Compose Standalone.\e[0m"
|
||||
echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to standalone\e[0m"
|
||||
sleep 2
|
||||
echo -e "\e[33mNotice: For an automatic update of docker-compose please use the update_compose.sh scripts located at the helper-scripts folder.\e[0m"
|
||||
else
|
||||
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
|
||||
echo -e "\e[31mPlease update/install manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo -e "\e[31mCannot find Docker Compose.\e[0m"
|
||||
echo -e "\e[31mPlease install it regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
detect_bad_asn() {
|
||||
echo -e "\e[33mDetecting if your IP is listed on Spamhaus Bad ASN List...\e[0m"
|
||||
response=$(curl --connect-timeout 15 --max-time 30 -s -o /dev/null -w "%{http_code}" "https://asn-check.mailcow.email")
|
||||
if [ "$response" -eq 503 ]; then
|
||||
if [ -z "$SPAMHAUS_DQS_KEY" ]; then
|
||||
echo -e "\e[33mYour server's public IP uses an AS that is blocked by Spamhaus to use their DNS public blocklists for Postfix.\e[0m"
|
||||
echo -e "\e[33mmailcow did not detected a value for the variable SPAMHAUS_DQS_KEY inside mailcow.conf!\e[0m"
|
||||
sleep 2
|
||||
echo ""
|
||||
echo -e "\e[33mTo use the Spamhaus DNS Blocklists again, you will need to create a FREE account for their Data Query Service (DQS) at: https://www.spamhaus.com/free-trial/sign-up-for-a-free-data-query-service-account\e[0m"
|
||||
echo -e "\e[33mOnce done, enter your DQS API key in mailcow.conf and mailcow will do the rest for you!\e[0m"
|
||||
echo ""
|
||||
sleep 2
|
||||
else
|
||||
echo -e "\e[33mYour server's public IP uses an AS that is blocked by Spamhaus to use their DNS public blocklists for Postfix.\e[0m"
|
||||
echo -e "\e[32mmailcow detected a Value for the variable SPAMHAUS_DQS_KEY inside mailcow.conf. Postfix will use DQS with the given API key...\e[0m"
|
||||
fi
|
||||
elif [ "$response" -eq 200 ]; then
|
||||
echo -e "\e[33mCheck completed! Your IP is \e[32mclean\e[0m"
|
||||
elif [ "$response" -eq 429 ]; then
|
||||
echo -e "\e[33mCheck completed! \e[31mYour IP seems to be rate limited on the ASN Check service... please try again later!\e[0m"
|
||||
else
|
||||
echo -e "\e[31mCheck failed! \e[0mMaybe a DNS or Network problem?\e[0m"
|
||||
fi
|
||||
}
|
||||
|
||||
check_online_status() {
|
||||
CHECK_ONLINE_DOMAINS=('https://github.com' 'https://hub.docker.com')
|
||||
for domain in "${CHECK_ONLINE_DOMAINS[@]}"; do
|
||||
if timeout 6 curl --head --silent --output /dev/null ${domain}; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
prefetch_images() {
|
||||
[[ -z ${BRANCH} ]] && { echo -e "\e[33m\nUnknown branch...\e[0m"; exit 1; }
|
||||
git fetch origin #${BRANCH}
|
||||
while read image; do
|
||||
RET_C=0
|
||||
until docker pull "${image}"; do
|
||||
RET_C=$((RET_C + 1))
|
||||
echo -e "\e[33m\nError pulling $image, retrying...\e[0m"
|
||||
[ ${RET_C} -gt 3 ] && { echo -e "\e[31m\nToo many failed retries, exiting\e[0m"; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
done < <(git show "origin/${BRANCH}:docker-compose.yml" | grep "image:" | awk '{ gsub("image:","", $3); print $2 }')
|
||||
}
|
||||
|
||||
docker_garbage() {
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )/../.." && pwd )"
|
||||
IMGS_TO_DELETE=()
|
||||
|
||||
declare -A IMAGES_INFO
|
||||
COMPOSE_IMAGES=($(grep -oP "image: \K(ghcr\.io/)?mailcow.+" "${SCRIPT_DIR}/docker-compose.yml"))
|
||||
|
||||
for existing_image in $(docker images --format "{{.ID}}:{{.Repository}}:{{.Tag}}" | grep -E '(mailcow/|ghcr\.io/mailcow/)'); do
|
||||
ID=$(echo "$existing_image" | cut -d ':' -f 1)
|
||||
REPOSITORY=$(echo "$existing_image" | cut -d ':' -f 2)
|
||||
TAG=$(echo "$existing_image" | cut -d ':' -f 3)
|
||||
|
||||
if [[ "$REPOSITORY" == "mailcow/backup" || "$REPOSITORY" == "ghcr.io/mailcow/backup" ]]; then
|
||||
if [[ "$TAG" != "<none>" ]]; then
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ " ${COMPOSE_IMAGES[@]} " =~ " ${REPOSITORY}:${TAG} " ]]; then
|
||||
continue
|
||||
else
|
||||
IMGS_TO_DELETE+=("$ID")
|
||||
IMAGES_INFO["$ID"]="$REPOSITORY:$TAG"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ! -z ${IMGS_TO_DELETE[*]} ]]; then
|
||||
echo "The following unused mailcow images were found:"
|
||||
for id in "${IMGS_TO_DELETE[@]}"; do
|
||||
echo " ${IMAGES_INFO[$id]} ($id)"
|
||||
done
|
||||
|
||||
if [ -z "$FORCE" ]; then
|
||||
read -r -p "Do you want to delete them to free up some space? [y/N] " response
|
||||
if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
docker rmi ${IMGS_TO_DELETE[*]}
|
||||
else
|
||||
echo "OK, skipped."
|
||||
fi
|
||||
else
|
||||
echo "Running in forced mode! Force removing old mailcow images..."
|
||||
docker rmi ${IMGS_TO_DELETE[*]}
|
||||
fi
|
||||
echo -e "\e[32mFurther cleanup...\e[0m"
|
||||
echo "If you want to cleanup further garbage collected by Docker, please make sure all containers are up and running before cleaning your system by executing \"docker system prune\""
|
||||
fi
|
||||
}
|
||||
|
||||
in_array() {
|
||||
local e match="$1"
|
||||
shift
|
||||
for e; do [[ "$e" == "$match" ]] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
detect_major_update() {
|
||||
if [ ${BRANCH} == "master" ]; then
|
||||
# Array with major versions
|
||||
# Add major versions here
|
||||
MAJOR_VERSIONS=(
|
||||
"2025-02"
|
||||
"2025-03"
|
||||
"2025-09"
|
||||
)
|
||||
|
||||
current_version=""
|
||||
if [[ -f "${SCRIPT_DIR}/data/web/inc/app_info.inc.php" ]]; then
|
||||
current_version=$(grep 'MAILCOW_GIT_VERSION' ${SCRIPT_DIR}/data/web/inc/app_info.inc.php | sed -E 's/.*MAILCOW_GIT_VERSION="([^"]+)".*/\1/')
|
||||
fi
|
||||
if [[ -z "$current_version" ]]; then
|
||||
return 1
|
||||
fi
|
||||
release_url="https://github.com/mailcow/mailcow-dockerized/releases/tag"
|
||||
|
||||
updates_to_apply=()
|
||||
|
||||
for version in "${MAJOR_VERSIONS[@]}"; do
|
||||
if [[ "$current_version" < "$version" ]]; then
|
||||
updates_to_apply+=("$version")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#updates_to_apply[@]} -gt 0 ]]; then
|
||||
echo -e "\e[33m\nMAJOR UPDATES to be applied:\e[0m"
|
||||
for update in "${updates_to_apply[@]}"; do
|
||||
echo "$update - $release_url/$update"
|
||||
done
|
||||
|
||||
echo -e "\nPlease read the release notes before proceeding."
|
||||
read -p "Do you want to proceed with the update? [y/n] " response
|
||||
if [[ "${response}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo "Proceeding with the update..."
|
||||
else
|
||||
echo "Update canceled. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||