Compare commits

...

6 commits

Author SHA1 Message Date
daeb159ad6 Merge branch 'main' into dev
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 22s
2026-06-02 23:29:54 +02:00
bes-r
34edd95c9b v0.3.1 - UI restyle, inklapbaar menu, taalmodule
Some checks failed
Deploy server-up (prod) / deploy (push) Successful in 6s
Build and push image / build (push) Has been cancelled
2026-06-02 23:25:30 +02:00
bes-r
d866a13c20 UI: grotere/mobielvriendelijke restyle, inklapbaar menu, taalmodule add-on
Some checks failed
Deploy server-up (prod) / deploy (push) Failing after 5s
2026-06-02 23:07:27 +02:00
bes-r
f96a9407d5 fix: voorkom duplicate repo-IDs bij toevoegen via UI 2026-05-30 00:55:16 +02:00
bes-r
e5ef3b02b7 Merge branch 'dev'
Some checks failed
Deploy server-up (prod) / deploy (push) Failing after 10s
2026-05-30 00:40:06 +02:00
bes-r
5c174cecbc Merge branch 'dev'
Some checks failed
Deploy server-up (prod) / deploy (push) Has been cancelled
2026-05-27 00:31:01 +02:00
11 changed files with 1637 additions and 51 deletions

View file

@ -0,0 +1,47 @@
# Forgejo Actions — bouw + push image bij elke tag-push
#
# Activeert wanneer je `git push --tags` doet met een v*-tag.
# Bakt de tag in als SU_VERSION zodat het image z'n eigen versie kent.
#
# Vereisten in Forgejo:
# Settings → Variables → REGISTRY=git.example.com, OWNER=bes-r
# Settings → Secrets → PACKAGE_TOKEN=<token met write:package scope>
name: Build and push image
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract versie uit tag
id: tag
run: echo "v=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Login op Forgejo registry
run: |
echo "${{ secrets.PACKAGE_TOKEN }}" \
| docker login ${{ vars.REGISTRY }} \
-u ${{ vars.OWNER }} \
--password-stdin
- name: Bouw image met versie ingebakken
run: |
docker build \
--build-arg SU_VERSION=${{ steps.tag.outputs.v }} \
-t ${{ vars.REGISTRY }}/${{ vars.OWNER }}/server-up:${{ steps.tag.outputs.v }} \
-t ${{ vars.REGISTRY }}/${{ vars.OWNER }}/server-up:latest \
.
- name: Push image
run: |
docker push ${{ vars.REGISTRY }}/${{ vars.OWNER }}/server-up:${{ steps.tag.outputs.v }}
docker push ${{ vars.REGISTRY }}/${{ vars.OWNER }}/server-up:latest

View file

@ -1,3 +1,26 @@
# v0.3.1 — UI-restyle + taalmodule (add-on)
## Nieuw in v0.3.1
### 🎨 Grondige UI-restyle
Grotere, touch-vriendelijke knoppen (min. 44px), ruimere spacing, grotere
typografie en kaarten. Layout gecentreerd met max-breedte voor meer lucht op
grote schermen. Volledig mobielvriendelijk.
### 📐 Inklapbaar menu (ook op desktop)
De hamburger klapt de sidebar nu ook op desktop in/uit; voorkeur wordt onthouden
(`localStorage`), hoofdinhoud en log-paneel schuiven mee. Op mobiel een
tap-to-close overlay.
### 🌐 Taalmodule als add-on
Nieuwe sectie in Instellingen → "Taalmodule": talen toevoegen, bewerken,
verwijderen en ingebouwde talen (NL/EN) dupliceren als startpunt. Editor toont
per sleutel de Engelse referentie met zoekfilter. Backend-endpoints:
`POST/DELETE /api/i18n`, `GET /api/i18n/keys`, `GET /api/i18n/<code>/raw`.
Toegevoegde talen komen als JSON in `translations/` en zijn direct beschikbaar.
---
# v0.3.01 — Boilerplates fixes + git-driven versioning # v0.3.01 — Boilerplates fixes + git-driven versioning
## Fixes bovenop v0.3.0 ## Fixes bovenop v0.3.0

View file

@ -0,0 +1,669 @@
# Forgejo Actions — deploy-pipeline opzetten voor Server Up
Volledig stappenplan om vanuit je laptop via Forgejo automatisch te deployen naar een Ubuntu/Debian server met Docker Compose. Inclusief een dev/prod-splitsing zodat je `dev`-server alleen de ontwikkelversie krijgt en prod-servers alleen stabiele releases.
## Al eerder opgezet? Lees dit eerst
Als je de pipeline al hebt ingericht en alleen de dev/prod-splitsing wilt toevoegen, hoef je niet alles opnieuw te doen. Voer alleen deze stappen uit:
1. **Runner opnieuw registreren met label `dev`** — zie [Stap 4b](#4b-registreren-op-de-app-server)
2. **`dev`-branch aanmaken** in je repo: `git checkout -b dev && git push -u origin dev`
3. **Workflow-bestanden bijwerken** — commit en push de gewijzigde `.forgejo/workflows/deploy.yml` en nieuwe `deploy-prod.yml` naar `dev`
4. **`/opt/server-up/.env`** aanmaken voor handmatige builds buiten de pipeline:
```bash
echo "SU_VERSION=dev-handmatig" > /opt/server-up/.env
```
---
## Architectuur
```
[ Laptop ]
|
| git push naar dev-branch
v
[ Forgejo VPS ] ──── dispatcht workflow ────> [ Dev-server ]
- act_runner (label: dev)
- Docker + Compose
- /opt/server-up/
[ Forgejo VPS ] ──── dispatcht workflow ────> [ Prod-server(s) ]
- act_runner (label: prod)
- Docker + Compose
- /opt/server-up/
```
Twee soorten servers:
- **Dev-server** — draait de `dev` branch. Wordt automatisch bijgewerkt bij elke push naar `dev`. Bedoeld voor testen.
- **Prod-server(s)** — draaien de `main` branch of een `v*`-tag. Worden alleen bijgewerkt als je bewust naar `main` merget of een release tagt.
De routing werkt via **runner-labels**: elke server registreert zijn runner met een eigen label (`dev` of `prod`). De workflow kiest op dat label. Zo pikt een dev-server nooit een prod-deploy op en andersom.
We draaien de runner in _host mode_: jobs draaien direct op de host, niet in een container. Dat is voor deploy-jobs verreweg het simpelst — Docker socket en bind-mount paden kloppen vanzelf.
---
## Wat je nodig hebt
- Forgejo VPS met root- of admin-toegang
- App-server (Ubuntu 22.04/24.04 of Debian 12) met root- of sudo-toegang
- DNS-naam voor je Forgejo-instance (bv. `forgejo.example.com`) met geldig TLS-certificaat
- Server Up uitgecheckt lokaal, met `Dockerfile` en `docker-compose.yml`
---
## Stap 1 — Actions globaal aanzetten in Forgejo
Forgejo heeft **geen UI-toggle** voor Actions. Je zet het aan in `app.ini`. Zolang het uit staat zie je geen **Actions**-tab in repo's.
### 1a. Snelle check: staat het al aan?
Open een willekeurige repo in Forgejo. Zie je bovenaan een **Actions**-tab? Dan is het al aan — ga door naar stap 1d.
### 1b. `app.ini` vinden
SSH naar je Forgejo VPS:
```bash
# Zoek de containernaam
docker ps --format 'table {{.Names}}\t{{.Image}}' | grep -i forgejo
# Bekijk huidige Actions-config
docker exec forgejo cat /data/gitea/conf/app.ini | grep -A2 "\[actions\]" \
|| echo "Geen [actions] blok gevonden"
# Vind het hostpad van de data-map
docker inspect forgejo --format \
'{{ range .Mounts }}{{ .Source }} -> {{ .Destination }}{{ "\n" }}{{ end }}'
```
Zoek de regel waar `Destination` `/data` is. Het bestand staat dan op `<Source>/gitea/conf/app.ini`. Typische plekken:
- `/var/lib/forgejo/gitea/conf/app.ini`
- `./forgejo/gitea/conf/app.ini` (relatief aan je `docker-compose.yml`)
- Bij een named volume: `/var/lib/docker/volumes/<naam>/_data/gitea/conf/app.ini`
### 1c. `app.ini` aanpassen en Forgejo herstarten
```bash
APP_INI=/var/lib/forgejo/gitea/conf/app.ini # pas aan naar jouw pad
sudo cp "$APP_INI" "$APP_INI.bak"
sudo nano "$APP_INI"
```
Voeg onderaan toe — of update het bestaande `[actions]`-blok:
```ini
[actions]
ENABLED = true
DEFAULT_ACTIONS_URL = https://code.forgejo.org
```
Herstart Forgejo:
```bash
docker compose restart forgejo # in de map waar je docker-compose.yml staat
# of zonder compose:
docker restart forgejo
```
Refresh een repo-pagina — de **Actions**-tab moet nu zichtbaar zijn.
### 1d. Repo-Actions inschakelen
Ga in je repo naar **Settings → Actions → General → Enable Repository Actions** (dit is standaard aan zodra Actions globaal aan staan).
---
## Stap 2 — App-server voorbereiden
SSH naar de app-server als root of met `sudo`.
### 2a. Systeem up-to-date
```bash
apt update && apt upgrade -y
apt install -y curl ca-certificates gnupg git rsync ufw
```
### 2b. Docker + Compose plugin installeren
```bash
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | \
gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
# Voor Ubuntu vervang 'debian' door 'ubuntu'
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
> /etc/apt/sources.list.d/docker.list
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
docker --version && docker compose version
```
### 2c. Deploy-user aanmaken
```bash
useradd -m -s /bin/bash deploy
usermod -aG docker deploy
mkdir -p /opt/server-up
chown deploy:deploy /opt/server-up
```
De runner draait als `deploy`. Door deze user in de `docker`-groep te zetten kan hij `docker compose` zonder sudo aanroepen.
### 2d. Firewall (optioneel maar aanbevolen)
```bash
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
```
De runner heeft alleen _uitgaande_ HTTPS naar je Forgejo nodig — geen inkomende poort.
---
## Stap 3 — Act_runner installeren
Forgejo gebruikt `act_runner` als runner-daemon.
```bash
# Controleer de nieuwste versie op:
# https://code.forgejo.org/forgejo/act_runner/releases
VERSION=6.2.2
ARCH=amd64 # of arm64 voor ARM-servers
curl -fsSL -o /usr/local/bin/act_runner \
"https://code.forgejo.org/forgejo/runner/releases/download/v${VERSION}/forgejo-runner-${VERSION}-linux-${ARCH}"
chmod +x /usr/local/bin/act_runner
act_runner --version
```
### Werkdirectory en config
```bash
mkdir -p /etc/act_runner /var/lib/act_runner
chown deploy:deploy /var/lib/act_runner
act_runner generate-config > /etc/act_runner/config.yaml
chown root:deploy /etc/act_runner/config.yaml
chmod 640 /etc/act_runner/config.yaml
```
Open `/etc/act_runner/config.yaml` en pas deze velden aan:
```yaml
runner:
file: /var/lib/act_runner/.runner
capacity: 1
timeout: 30m
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
cache:
enabled: true
dir: /var/lib/act_runner/cache
host:
workdir_parent: /var/lib/act_runner/host-work
```
---
## Stap 4 — Runner registreren
### 4a. Token ophalen uit Forgejo
Ga in Forgejo naar je repo → **Settings → Actions → Runners → Create new Runner**. Kopieer het token.
Kies het niveau dat bij je gebruik past:
- **Repo-niveau** (alleen deze repo) — veiligst
- **Organization-niveau** — handig als je meerdere repo's hebt
- **Site-niveau** — alleen als je veel repo's wilt deployen
### 4b. Registreren op de app-server
**Dev-server** — registreer met label `dev`:
```bash
sudo -u deploy bash -c '
cd /var/lib/act_runner && \
act_runner register \
--no-interactive \
--instance https://jouw-forgejo-url \
--token JOUW_TOKEN \
--name dev-server \
--labels self-hosted,dev \
--config /etc/act_runner/config.yaml
'
```
**Prod-server** — registreer met label `prod`:
```bash
sudo -u deploy bash -c '
cd /var/lib/act_runner && \
act_runner register \
--no-interactive \
--instance https://jouw-forgejo-url \
--token JOUW_TOKEN \
--name prod-server-1 \
--labels self-hosted,prod \
--config /etc/act_runner/config.yaml
'
```
> **Al geregistreerd met andere labels?** Stop de service, verwijder het `.runner`-bestand en registreer opnieuw:
> ```bash
> systemctl stop act_runner
> rm /var/lib/act_runner/.runner
> # voer daarna bovenstaand register-commando uit
> ```
Controleer in Forgejo onder **Settings → Actions → Runners** — je runner staat als **Idle**.
---
## Stap 5 — Runner als systemd-service instellen
Doe dit op elke server na de registratie:
```bash
cat > /etc/systemd/system/act_runner.service << 'EOF'
[Unit]
Description=Forgejo Actions runner
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/var/lib/act_runner
ExecStart=/usr/local/bin/act_runner daemon --config /etc/act_runner/config.yaml
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=read-only
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now act_runner
systemctl status act_runner
```
Logs volgen:
```bash
journalctl -u act_runner -f
```
---
## Stap 6 — Branches inrichten
De workflows zijn opgezet rond twee branches:
| Branch | Doel | Deploy naar |
|--------|------|-------------|
| `dev` | Ontwikkeling, testen | Dev-server |
| `main` | Stabiele releases | Prod-server(s) |
Maak de `dev`-branch aan als die er nog niet is:
```bash
git checkout -b dev
git push -u origin dev
```
Typische werkwijze:
```
feature-branch → merge naar dev → automatisch op dev-server getest
dev → stabiel → merge naar main → automatisch op prod-server(s)
main → git tag v1.2.3 → deploy met versienummer ingebakken
```
---
## Stap 7 — Workflow-bestanden
De repo bevat twee deploy-workflows in `.forgejo/workflows/`:
### `deploy.yml` — dev-server
Triggert bij elke push naar `dev`. Draait alleen op runners met label `dev`.
```yaml
name: Deploy server-up (dev)
on:
push:
branches: [dev]
workflow_dispatch:
concurrency:
group: server-up-deploy-dev
cancel-in-progress: false
jobs:
deploy:
runs-on: [self-hosted, dev]
timeout-minutes: 20
env:
DEPLOY_DIR: /opt/server-up
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Bepaal versie
id: version
run: |
set -euo pipefail
VERSION="dev-${GITHUB_SHA:0:8}"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "SU_VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "CHANNEL=dev" >> "$GITHUB_ENV"
echo "Deploying version: ${VERSION}"
- name: Sync code naar deploy-directory
run: |
set -euo pipefail
rsync -a --delete \
--exclude='.git/' \
--exclude='.forgejo/' \
./ "$DEPLOY_DIR/"
- name: Schrijf VERSION-bestand
working-directory: ${{ env.DEPLOY_DIR }}
run: echo "${VERSION}" > VERSION
- name: Build image
working-directory: ${{ env.DEPLOY_DIR }}
run: |
docker compose build --pull
docker tag "server-up:${VERSION}" server-up:latest
docker tag "server-up:${VERSION}" server-up:dev
- name: Deploy
working-directory: ${{ env.DEPLOY_DIR }}
run: |
set -euo pipefail
docker compose up -d --remove-orphans
docker compose ps
- name: Wachten op healthcheck
run: |
set -euo pipefail
for i in {1..40}; do
status=$(docker inspect --format '{{.State.Health.Status}}' server-up 2>/dev/null || echo "starting")
echo "poll $i: $status"
if [ "$status" = "healthy" ]; then
echo "server-up is healthy als versie ${VERSION}"
exit 0
fi
sleep 3
done
echo "server-up werd niet healthy in tijd — logs:"
cd "$DEPLOY_DIR" && docker compose logs --tail=200
exit 1
- name: Oude images opruimen (behoud laatste 5)
if: success()
run: |
docker images server-up --format '{{.Tag}} {{.ID}}' \
| grep -vE '^(latest|release|dev) ' \
| tail -n +6 \
| awk '{print $2}' \
| xargs -r docker rmi -f || true
docker image prune -f
```
### `deploy-prod.yml` — prod-servers
Triggert bij push naar `main` of een `v*`-tag. Draait alleen op runners met label `prod`.
```yaml
name: Deploy server-up (prod)
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
concurrency:
group: server-up-deploy-prod
cancel-in-progress: false
jobs:
deploy:
runs-on: [self-hosted, prod]
timeout-minutes: 20
env:
DEPLOY_DIR: /opt/server-up
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Bepaal versie
id: version
run: |
set -euo pipefail
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
CHANNEL="release"
else
VERSION="main-${GITHUB_SHA:0:8}"
CHANNEL="main"
fi
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
echo "SU_VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "Deploying version: ${VERSION} (${CHANNEL})"
- name: Sync code naar deploy-directory
run: |
set -euo pipefail
rsync -a --delete \
--exclude='.git/' \
--exclude='.forgejo/' \
./ "$DEPLOY_DIR/"
- name: Schrijf VERSION-bestand
working-directory: ${{ env.DEPLOY_DIR }}
run: echo "${VERSION}" > VERSION
- name: Build image
working-directory: ${{ env.DEPLOY_DIR }}
run: |
docker compose build --pull
docker tag "server-up:${VERSION}" server-up:latest
docker tag "server-up:${VERSION}" "server-up:${CHANNEL}"
- name: Deploy
working-directory: ${{ env.DEPLOY_DIR }}
run: |
set -euo pipefail
docker compose up -d --remove-orphans
docker compose ps
- name: Wachten op healthcheck
run: |
set -euo pipefail
for i in {1..40}; do
status=$(docker inspect --format '{{.State.Health.Status}}' server-up 2>/dev/null || echo "starting")
echo "poll $i: $status"
if [ "$status" = "healthy" ]; then
echo "server-up is healthy als versie ${VERSION}"
exit 0
fi
sleep 3
done
echo "server-up werd niet healthy in tijd — logs:"
cd "$DEPLOY_DIR" && docker compose logs --tail=200
exit 1
- name: Oude images opruimen (behoud laatste 5)
if: success()
run: |
docker images server-up --format '{{.Tag}} {{.ID}}' \
| grep -vE '^(latest|release|main) ' \
| tail -n +6 \
| awk '{print $2}' \
| xargs -r docker rmi -f || true
docker image prune -f
```
### Wat doet een deploy-workflow?
1. Code uitchecken in de runner-workspace
2. Versienummer bepalen (`dev-<sha>` voor dev, `v1.2.3` voor tags)
3. Code naar `/opt/server-up` synchen via `rsync --delete`
4. Docker-image bouwen met `docker compose build --pull` en `SU_VERSION` ingebakken
5. Container herstarten met `docker compose up -d`
6. Wachten tot de healthcheck groen is (max. ~2 minuten)
7. Oude images opruimen (behoudt de laatste 5)
---
## Stap 8 — Handmatig triggeren
Elke workflow heeft `workflow_dispatch`, waarmee je hem handmatig start zonder een commit:
**Forgejo UI**: Actions → selecteer de workflow → **Run workflow**
Handig als je de pipeline opnieuw wilt draaien na een serverherstel of configuratiewijziging.
---
## Stap 9 — Eerste deploy testen
Vanuit je repo op je laptop:
```bash
git checkout dev
echo "# Deploy test $(date)" >> README.md
git add README.md
git commit -m "ci: trigger first deploy"
git push origin dev
```
In Forgejo: ga naar de **Actions**-tab van je repo. Je ziet de workflow-run starten. Klik erop om de logs live te volgen.
Op de server parallel meekijken:
```bash
journalctl -u act_runner -f
# in een tweede shell:
docker logs -f server-up
```
Als alles goed gaat: groen vinkje, container draait nieuwe image.
---
## Stap 10 — Rollback
Omdat images getagd worden met de git SHA is rollback eenvoudig:
```bash
cd /opt/server-up
# Beschikbare images bekijken
docker images server-up --format '{{.Tag}}\t{{.CreatedAt}}'
# Rollback naar een eerdere SHA — pas aan
SU_VERSION=dev-abcdef12 docker compose up -d
```
Voor extra zekerheid kun je in de workflow de huidige tag wegschrijven vóór de deploy:
```bash
echo "$(docker inspect server-up --format '{{.Config.Image}}')" \
> /opt/server-up/.last-good-image
```
---
## Stap 11 — Uitbreidingen
### CI vóór deploy (tests, lint)
Splits in twee jobs zodat tests in isolatie draaien en de deploy alleen bij groen start:
```yaml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build --target build -t server-up:test .
# voeg je eigen testcommando's toe
deploy:
needs: test
runs-on: [self-hosted, dev]
steps:
# ... zoals hierboven
```
### Nieuwe prod-server toevoegen
1. Installeer `act_runner` op de nieuwe server (stap 3)
2. Maak een nieuw runner-token aan in Forgejo
3. Registreer met `--labels self-hosted,prod` (stap 4b)
4. Stel de systemd-service in (stap 5)
De `deploy-prod.yml` workflow pikt de nieuwe runner automatisch op bij de volgende push naar `main` of een tag.
### Image registry
Voor grotere setups wil je niet op de productieserver bouwen. Push de image naar de Forgejo Container Registry (ingebouwd onder **Packages**) vanuit een aparte build-job, en trek alleen op de app-server. Scheelt CPU en disk op productie.
---
## Beveiligingstips
- **Runner draait niet als root** — de `deploy`-user heeft alleen rechten op `/opt/server-up` en is lid van de `docker`-groep. Het Server Up-container zelf draait als root (`user: "0:0"` in compose), dat is een bewuste keuze voor Docker socket-toegang.
- **Repo-niveau token** is veiliger dan site-niveau — bij een lek heeft een aanvaller niet meteen toegang tot alle repo's.
- **Branch protection op `main`** — Repo → Settings → Branches → Add Rule. Vereis dat pushes via een PR gaan en de dev-pipeline groen is.
- **Secrets nooit in compose of git** — zet gevoelige waarden in Forgejo Secrets en schrijf ze in de workflow-step naar `.env`.
- **Runner-binary updaten** — periodiek bijwerken: nieuwe versie naar `/usr/local/bin/act_runner` schrijven en `systemctl restart act_runner`. Geen herregistratie nodig.
- **Forgejo zelf** — zet 2FA aan, draai achter een reverse proxy met TLS, configureer fail2ban op de SSH-poort.
---
## Problemen oplossen
| Symptoom | Oorzaak / oplossing |
|----------|---------------------|
| Workflow blijft "queued" hangen | Geen runner met het juiste label online. Check `systemctl status act_runner` en of het label (`dev`/`prod`) overeenkomt met `runs-on` in de workflow. |
| `permission denied` bij Docker | `deploy`-user niet in `docker`-groep, of service gestart vóór die toevoeging — `systemctl restart act_runner`. |
| `rsync: not found` | `apt install rsync` op de server. |
| `SU_VERSION ontbreekt` bij handmatige build | Maak `/opt/server-up/.env` aan: `echo "SU_VERSION=dev-handmatig" > /opt/server-up/.env` |
| Container draait oude code na rebuild | Deploy-directory niet bijgewerkt. Controleer: `docker exec --workdir /app server-up grep -n "trim_blocks" core/boilerplates.py`. Als dit `True` toont, trigger de pipeline handmatig of sync de code zelf: `rsync -a --delete --exclude='.git/' --exclude='.forgejo/' ./ /opt/server-up/` |
| Boilerplate geeft `yaml: line 5` fout | Zelfde oorzaak als boven — `trim_blocks=True` in de draaiende container. Fix: rebuild na correcte sync. Eerder geïnstalleerde stacks met kapotte compose-bestanden opnieuw installeren via de app-store. |
| Healthcheck-step faalt altijd | Check `docker compose logs`. Vaak ontbreekt een env-var of klopt een pad niet. |
| TLS-fout bij registreren | Zet `runner.insecure: true` in config alleen tijdelijk voor self-signed certs. Een geldig certificaat (Let's Encrypt) is de structurele oplossing. |

389
docs/synchroniseren.md Normal file
View file

@ -0,0 +1,389 @@
# Code synchroniseren via Forgejo
Dit is hoe je code van je eigen apparaat automatisch op een server krijgt via Forgejo. Je pusht naar een branch, en de server haalt de wijzigingen zelf op en herstart de applicatie. Je hoeft nooit handmatig in te loggen op de server om iets bij te werken.
Als voorbeeld gebruik ik Server Up, maar de werkwijze is voor elke applicatie hetzelfde.
---
## Hoe het werkt
Je werkt met twee branches in je repository:
| Branch | Doel | Wat er gebeurt bij een push |
|--------|------|-----------------------------|
| `dev` | Testen en ontwikkelen | De dev-server wordt automatisch bijgewerkt |
| `main` | Stabiele versie voor eindgebruikers | De prod-server wordt automatisch bijgewerkt |
Jij pusht code naar Forgejo. Forgejo start automatisch een workflow (een reeks stappen). Die workflow draait op de server zelf via een kleine achtergrondservice genaamd `act_runner`. De server haalt de code op, bouwt een nieuw Docker-image en herstart de applicatie.
---
## Stap 1 — Git installeren op je eigen apparaat
Git heb je nodig om code te beheren en naar Forgejo te pushen.
Download en installeer Git via [git-scm.com](https://git-scm.com). Tijdens de installatie kun je alle standaardinstellingen accepteren.
Controleer daarna of het werkt:
```bash
git --version
```
---
## Stap 2 — Pakketten installeren op de server
Log in op de server als root of met `sudo`. Installeer de benodigde pakketten:
```bash
apt update
apt install -y git rsync nodejs
```
Toelichting:
- **git** — de pipeline checkt de repository uit op de server
- **rsync** — kopieert de bestanden naar de juiste map op de server
- **nodejs** — vereist door `actions/checkout`, de stap die de code ophaalt
---
## Stap 3 — Docker installeren op de server
Docker is nodig om de applicatie te draaien en te bouwen.
```bash
apt install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | \
gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
> /etc/apt/sources.list.d/docker.list
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
```
Controleer of Docker werkt:
```bash
docker --version
docker compose version
```
> Draai je Ubuntu in plaats van Debian? Vervang beide keren `debian` door `ubuntu` in bovenstaande commando's.
---
## Stap 4 — Runner-gebruiker aanmaken op de server
De pipeline draait niet als root maar als een aparte gebruiker. Maak die aan en geef hem toegang tot Docker:
```bash
useradd -m -s /bin/bash git-user
usermod -aG docker git-user
```
Controleer of het gelukt is:
```bash
groups git-user
```
Je ziet `git-user docker` in de uitvoer.
---
## Stap 5 — Act_runner installeren op de server
`act_runner` is de achtergrondservice die de Forgejo-workflows uitvoert.
```bash
VERSION=6.2.2
ARCH=amd64 # gebruik arm64 voor ARM-servers
curl -fsSL -o /usr/local/bin/act_runner \
"https://code.forgejo.org/forgejo/runner/releases/download/v${VERSION}/forgejo-runner-${VERSION}-linux-${ARCH}"
chmod +x /usr/local/bin/act_runner
act_runner --version
```
Maak de benodigde mappen aan:
```bash
mkdir -p /etc/act_runner /var/lib/act_runner
chown git-user:git-user /var/lib/act_runner
act_runner generate-config > /etc/act_runner/config.yaml
chown root:git-user /etc/act_runner/config.yaml
chmod 640 /etc/act_runner/config.yaml
```
---
## Stap 6 — Runner registreren in Forgejo
### Token ophalen
Ga in Forgejo naar je repository → **Settings → Actions → Runners → Create new Runner**. Kopieer het token.
### Registreren
**Voor een dev-server:**
```bash
sudo -u git-user bash -c '
cd /var/lib/act_runner && \
act_runner register \
--no-interactive \
--instance https://jouw-forgejo-url \
--token JOUW_TOKEN \
--name dev-server \
--labels self-hosted,dev \
--config /etc/act_runner/config.yaml
'
```
**Voor een prod-server:**
```bash
sudo -u git-user bash -c '
cd /var/lib/act_runner && \
act_runner register \
--no-interactive \
--instance https://jouw-forgejo-url \
--token JOUW_TOKEN \
--name prod-server \
--labels self-hosted,prod \
--config /etc/act_runner/config.yaml
'
```
Controleer in Forgejo onder **Settings → Actions → Runners** — de runner verschijnt als **Idle**.
---
## Stap 7 — Runner instellen als achtergrondservice
Zodat de runner automatisch start bij een herstart van de server:
```bash
cat > /etc/systemd/system/act_runner.service << 'EOF'
[Unit]
Description=Forgejo Actions runner
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=git-user
Group=git-user
WorkingDirectory=/var/lib/act_runner
ExecStart=/usr/local/bin/act_runner daemon --config /etc/act_runner/config.yaml
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now act_runner
systemctl status act_runner
```
De runner is nu actief en start automatisch opnieuw na een herstart.
---
## Stap 8 — Deploy-map aanmaken op de server
De pipeline kopieert de code naar een vaste map. Maak die eenmalig aan:
```bash
mkdir -p /opt/docker/server-up
chown git-user:git-user /opt/docker/server-up
```
---
## Stap 9 — Repository klonen op je eigen apparaat
Als je de repository nog niet lokaal hebt staan:
```bash
git clone https://jouw-forgejo-url/gebruiker/server-up.git
cd server-up
```
---
## Stap 10 — Dev-branch aanmaken
Doe dit één keer. Als de branch al bestaat, sla je deze stap over.
```bash
git checkout -b dev
git push -u origin dev
```
De pipeline voor de dev-server is nu gekoppeld aan deze branch.
---
## Dagelijks gebruik: wijzigingen naar de dev-server sturen
Dit doe je elke keer als je iets hebt aangepast en wilt testen.
```bash
# Zorg dat je op de dev-branch zit
git checkout dev
# Voeg je gewijzigde bestanden toe
git add .
# Maak een commit met een korte omschrijving
git commit -m "Omschrijving van je wijziging"
# Push naar Forgejo
git push origin dev
```
De pipeline start automatisch. Binnen een paar minuten draait de dev-server de nieuwe versie.
---
## Controleren of de pipeline gelukt is
Ga in Forgejo naar je repository en klik op **Actions**. Je ziet de pipeline-run staan:
- **Groen vinkje** — gelukt, de server draait de nieuwe versie
- **Rood kruis** — er is iets misgegaan, klik erop om de logs te zien
Je kunt ook direct op de server controleren:
```bash
docker inspect --format '{{.State.Health.Status}}' server-up
```
`healthy` betekent dat alles goed draait.
---
## Stabiele versie naar de prod-server sturen
Doe dit alleen als de versie op dev goed werkt.
```bash
# Schakel over naar main
git checkout main
# Haal de laatste versie van main op
git pull origin main
# Voeg alles uit dev samen met main
git merge dev
# Push naar Forgejo
git push origin main
```
De prod-server wordt automatisch bijgewerkt.
---
## Een versienummer vastleggen (optioneel)
Wil je een officieel versienummer aan een release hangen:
```bash
git checkout main
git tag v1.2.3
git push origin v1.2.3
```
Het versienummer `v1.2.3` wordt ingebakken in het Docker-image en is zichtbaar in de applicatie.
---
## Pipeline handmatig opnieuw starten
Wil je de pipeline herhalen zonder iets te wijzigen:
1. Ga naar je repository → **Actions**
2. Klik op de workflow die je wilt starten
3. Klik rechtsboven op **Run workflow**
---
## Iets terugdraaien
Elk gebouwd image krijgt een versietag mee. Je kunt altijd terug:
```bash
# Bekijk beschikbare versies
docker images server-up --format '{{.Tag}}\t{{.CreatedAt}}'
# Start de container met een eerdere versie
cd /opt/docker/server-up
SU_VERSION=dev-abcdef12 docker compose up -d
```
Vervang `dev-abcdef12` door de versietag die je terug wilt zetten.
---
## Problemen oplossen
**De pipeline start niet**
Ga in Forgejo naar **Settings → Actions → Runners**. Als de runner offline is, log dan in op de server en voer uit:
```bash
systemctl start act_runner
systemctl status act_runner
```
**Fout: `rsync: command not found`**
```bash
apt install -y rsync
```
**Fout: `Cannot find node in PATH`**
```bash
apt install -y nodejs
```
**Fout: `permission denied` bij rsync**
De runner-gebruiker heeft geen schrijfrechten op de deploy-map:
```bash
chown -R git-user:git-user /opt/docker/server-up
```
**Fout: `usermod: command not found`**
Je bent niet ingelogd als root. Zet `sudo` voor het commando:
```bash
sudo usermod -aG docker git-user
```
**Fout: `No such image` bij docker tag**
Docker Compose bouwt het image als `latest`. De workflow moet `latest` taggen als de versie, niet andersom. Controleer de build-stap in `.forgejo/workflows/deploy.yml` — die moet er zo uitzien:
```yaml
docker compose build --pull
docker tag server-up:latest "server-up:${VERSION}"
docker tag server-up:latest "server-up:${CHANNEL}"
```
**Je staat op de verkeerde branch**
```bash
git branch # toont welke branch actief is
git checkout dev # wissel naar dev
git checkout main # wissel naar main
```
**Bestanden worden niet meegenomen**
```bash
git status # toont welke bestanden nog niet zijn toegevoegd
git add . # voeg alles toe
```

47
fix-config.sh Normal file
View file

@ -0,0 +1,47 @@
#!/bin/bash
# Repareer de APP_REPOS in de Server Up config:
# - Verwijdert dubbele entries
# - Geeft de Forgejo dev-repo een uniek id (bes-r-dev)
set -euo pipefail
docker exec server-up python3 -c "
import json
from pathlib import Path
p = Path('/data/config.json')
cfg = json.loads(p.read_text())
cfg['APP_REPOS'] = [
{
'id': 'server-up',
'name': 'server-up (GitHub)',
'url': 'https://github.com/bes-r/server-up.git',
'branch': 'main',
'subdir': 'apps',
'token': ''
},
{
'id': 'boilerplates',
'name': 'Boilerplates (ChristianLempa)',
'url': 'https://github.com/ChristianLempa/boilerplates-library.git',
'branch': 'main',
'subdir': 'compose'
},
{
'id': 'bes-r-dev',
'name': 'bes-r (dev)',
'url': 'http://10.0.20.22:3000/bes-r/server-up.git',
'branch': 'dev',
'token': '',
'subdir': 'apps'
}
]
p.write_text(json.dumps(cfg, indent=2))
print('Config bijgewerkt.')
"
echo "Container herstarten..."
cd /opt/docker/server-up && docker compose restart
echo "Klaar. Ga in Server Up naar de app-store en klik op Synchroniseren voor 'bes-r (dev)'."

129
release.ps1 Normal file
View file

@ -0,0 +1,129 @@
# release.ps1 — Server Up release-script
#
# Eén commando: stage alles → commit → tag → push (commit + tag) naar Forgejo.
# Forgejo Actions pakt de tag en bouwt automatisch het image met de juiste
# SU_VERSION (zie .forgejo/workflows/build.yml of server-up-deploy/README.md).
#
# Gebruik:
# .\release.ps1 -Version 0.3.02 -Message "Boilerplates YAML rendering fix"
#
# Optioneel:
# -DryRun Toon alles, voer niets uit
# -NoPush Wel taggen, niet pushen
# -AllowDirty Sla de "wel echt iets gewijzigd?" check over
# -ChangelogTop Plak een ## v$Version blok bovenaan CHANGELOG.md
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Version,
[Parameter(Mandatory = $true)][string]$Message,
[string]$Remote = "origin",
[switch]$DryRun,
[switch]$NoPush,
[switch]$AllowDirty,
[switch]$ChangelogTop
)
$ErrorActionPreference = "Stop"
# ── Sanity checks ─────────────────────────────────────────────────────────────
# Versie formaat (semver met optionele suffix)
if ($Version -notmatch '^\d+\.\d+(\.\d+)?([\-_.][\w]+)?$') {
throw "Versie moet 'x.y' of 'x.y.z' formaat zijn, kreeg: '$Version'"
}
# In een git repo?
$gitRoot = & git rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -ne 0) { throw "Niet in een git-repository" }
Set-Location $gitRoot
Write-Host "Repo: $gitRoot" -ForegroundColor DarkGray
# Tag bestaat al?
$existing = & git tag --list "v$Version"
if ($existing) { throw "Tag v$Version bestaat al — kies een ander versienummer of verwijder de tag eerst" }
# Iets om te committen?
$status = & git status --porcelain
if (-not $status -and -not $AllowDirty) {
Write-Host "Geen wijzigingen om te committen." -ForegroundColor Yellow
$reply = Read-Host "Alleen taggen op huidige HEAD? [y/N]"
if ($reply -notmatch '^[yY]') { exit 0 }
$tagOnly = $true
} else {
$tagOnly = $false
}
# ── Plan tonen ────────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "═══ Release plan ═══" -ForegroundColor Cyan
Write-Host " Versie : v$Version"
Write-Host " Bericht: $Message"
Write-Host " Remote : $Remote"
if ($DryRun) { Write-Host " Mode : DRY RUN — niets wordt uitgevoerd" -ForegroundColor Yellow }
if ($tagOnly) { Write-Host " Mode : alleen tag (geen nieuwe commit)" -ForegroundColor Yellow }
if ($NoPush) { Write-Host " Push : OVERGESLAGEN" -ForegroundColor Yellow }
if (-not $tagOnly) {
Write-Host ""
Write-Host "Gewijzigde bestanden:" -ForegroundColor DarkGray
& git status --short
}
# ── Optioneel: CHANGELOG bijwerken ────────────────────────────────────────────
if ($ChangelogTop -and -not $DryRun -and -not $tagOnly) {
$clog = Join-Path $gitRoot "CHANGELOG.md"
if (Test-Path $clog) {
$date = Get-Date -Format "yyyy-MM-dd"
$header = "# v$Version$date`n`n$Message`n`n---`n`n"
$original = Get-Content $clog -Raw
Set-Content $clog ($header + $original) -NoNewline
& git add CHANGELOG.md
Write-Host "✓ CHANGELOG.md bijgewerkt" -ForegroundColor Green
}
}
# ── Uitvoeren ─────────────────────────────────────────────────────────────────
function Run([string]$Description, [scriptblock]$Action) {
Write-Host "$Description" -ForegroundColor Cyan
if (-not $DryRun) { & $Action }
}
if (-not $tagOnly) {
Run "git add -A" { & git add -A }
Run "git commit" {
# Commit met header "release: vX.Y.Z" + jouw message als body
& git commit -m "release: v$Version" -m $Message
if ($LASTEXITCODE -ne 0) { throw "git commit faalde" }
}
}
Run "git tag -a v$Version" {
& git tag -a "v$Version" -m "v$Version$Message"
if ($LASTEXITCODE -ne 0) { throw "git tag faalde" }
}
if (-not $NoPush) {
Run "git push $Remote HEAD" {
& git push $Remote HEAD
if ($LASTEXITCODE -ne 0) { throw "git push (commit) faalde" }
}
Run "git push $Remote v$Version" {
& git push $Remote "v$Version"
if ($LASTEXITCODE -ne 0) { throw "git push (tag) faalde" }
}
}
# ── Klaar ─────────────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "✓ Release v$Version klaar" -ForegroundColor Green
if (-not $NoPush -and -not $DryRun) {
Write-Host " Forgejo Actions bouwt nu het image (zie repo → Actions)" -ForegroundColor DarkGray
Write-Host " Daarna op je server:" -ForegroundColor DarkGray
Write-Host " SU_VERSION=$Version in .env" -ForegroundColor DarkGray
Write-Host " docker compose pull && docker compose up -d" -ForegroundColor DarkGray
}

View file

@ -18,7 +18,7 @@ from core.modules import Module, CORE, discover
app = Flask(__name__, static_folder="static", template_folder="templates") app = Flask(__name__, static_folder="static", template_folder="templates")
VERSION = os.environ.get("SU_VERSION", "0.3.0") VERSION = os.environ.get("SU_VERSION", "0.3.1")
MODULES: list[Module] = [] MODULES: list[Module] = []
USER_MOD = APP / "modules" USER_MOD = APP / "modules"
@ -316,7 +316,13 @@ def api_repos_add():
if not url: if not url:
return jsonify(ok=False, msg="URL vereist") return jsonify(ok=False, msg="URL vereist")
repos = list(cfg.load().get("APP_REPOS", [])) repos = list(cfg.load().get("APP_REPOS", []))
rid = d.get("id", url.split("/")[-1].replace(".git", "")).strip()[:30] or str(int(time.time())) existing_ids = {r["id"] for r in repos}
base_rid = d.get("id", url.split("/")[-1].replace(".git", "")).strip()[:30] or str(int(time.time()))
rid = base_rid
counter = 1
while rid in existing_ids:
rid = f"{base_rid}-{counter}"
counter += 1
repos.append({"id": rid, "name": d.get("name", "").strip() or url.split("/")[-1], repos.append({"id": rid, "name": d.get("name", "").strip() or url.split("/")[-1],
"url": url, "branch": d.get("branch", "main"), "url": url, "branch": d.get("branch", "main"),
"token": d.get("token", ""), "subdir": d.get("subdir", "")}) "token": d.get("token", ""), "subdir": d.get("subdir", "")})
@ -877,8 +883,14 @@ def api_modrepos_add():
if not url: if not url:
return jsonify(ok=False, msg="URL vereist") return jsonify(ok=False, msg="URL vereist")
repos = list(cfg.load().get("MODULE_REPOS", [])) repos = list(cfg.load().get("MODULE_REPOS", []))
repos.append({"id": d.get("id", url.split("/")[-1].replace(".git", ""))[:30], existing_ids = {r["id"] for r in repos}
"name": d.get("name", "") or url.split("/")[-1], base_rid = d.get("id", url.split("/")[-1].replace(".git", ""))[:30]
rid = base_rid
counter = 1
while rid in existing_ids:
rid = f"{base_rid}-{counter}"
counter += 1
repos.append({"id": rid, "name": d.get("name", "") or url.split("/")[-1],
"url": url, "branch": d.get("branch", "main"), "url": url, "branch": d.get("branch", "main"),
"subdir": d.get("subdir", "")}) "subdir": d.get("subdir", "")})
cfg.patch({"MODULE_REPOS": repos}) cfg.patch({"MODULE_REPOS": repos})
@ -1000,6 +1012,41 @@ def api_i18n_lang(lang):
return jsonify(strings=i18n.strings(lang)) return jsonify(strings=i18n.strings(lang))
@app.route("/api/i18n/keys")
def api_i18n_keys():
"""Referentie: alle vertaalsleutels + Engelse waardes (voor de editor)."""
return jsonify(keys=i18n.reference_keys(), en=i18n.strings("en"))
@app.route("/api/i18n/<lang>/raw")
def api_i18n_lang_raw(lang):
"""Alleen de eigen strings van deze taal (voor bewerken)."""
return jsonify(strings=i18n.raw(lang), builtin=i18n.is_builtin(lang))
@app.route("/api/i18n", methods=["POST"])
def api_i18n_save():
"""Voeg een taal toe of werk er een bij (add-on)."""
body = request.get_json(silent=True) or {}
res = i18n.save(
code=body.get("code", ""),
name=body.get("name", ""),
flag=body.get("flag", ""),
entries=body.get("strings", {}),
)
if res.get("ok"):
audit.log("i18n", "save", "ok", ref=res.get("code"))
return jsonify(res), (200 if res.get("ok") else 400)
@app.route("/api/i18n/<lang>", methods=["DELETE"])
def api_i18n_delete(lang):
res = i18n.delete(lang)
if res.get("ok"):
audit.log("i18n", "delete", "ok", ref=lang)
return jsonify(res), (200 if res.get("ok") else 400)
# ── Wizard ──────────────────────────────────────────────────────────────────── # ── Wizard ────────────────────────────────────────────────────────────────────
@app.route("/api/wizard") @app.route("/api/wizard")

View file

@ -1,11 +1,16 @@
"""Internationalisation — built-in NL + EN.""" """Internationalisation — built-in NL + EN, plus add-on talen via UI."""
import json import json
import re
from pathlib import Path from pathlib import Path
_DIR = Path(__file__).parent.parent / "translations" _DIR = Path(__file__).parent.parent / "translations"
_cache: dict[str, dict] = {} _cache: dict[str, dict] = {}
_langs: dict[str, dict] = {} _langs: dict[str, dict] = {}
# Ingebouwde talen worden meegeleverd en kunnen niet verwijderd worden.
BUILTIN = frozenset({"nl", "en"})
_CODE_RE = re.compile(r"^[a-z]{2,3}(-[a-z0-9]{2,8})?$", re.IGNORECASE)
def load(): def load():
_cache.clear() _cache.clear()
@ -26,14 +31,82 @@ def load():
def available() -> list[dict]: def available() -> list[dict]:
if not _langs: if not _langs:
load() load()
return [{"code": k, "name": v.get("name", k), "flag": v.get("flag", "")} return [{"code": k,
"name": v.get("name", k),
"flag": v.get("flag", ""),
"builtin": k in BUILTIN,
"count": len(_cache.get(k, {}))}
for k, v in _langs.items()] for k, v in _langs.items()]
def strings(lang: str) -> dict: def strings(lang: str) -> dict:
"""Samengevoegde strings: Engelse basis + taal-overrides (voor weergave)."""
if not _cache: if not _cache:
load() load()
base = dict(_cache.get("en", {})) base = dict(_cache.get("en", {}))
if lang != "en" and lang in _cache: if lang != "en" and lang in _cache:
base.update(_cache[lang]) base.update(_cache[lang])
return base return base
def raw(lang: str) -> dict:
"""Alleen de eigen strings van deze taal (voor bewerken)."""
if not _cache:
load()
return dict(_cache.get(lang, {}))
def reference_keys() -> list[str]:
"""Alle vertaalsleutels (op basis van de Engelse bron) als referentie."""
if not _cache:
load()
return sorted(_cache.get("en", {}).keys())
def is_builtin(code: str) -> bool:
return code in BUILTIN
def _path_for(code: str) -> Path:
return _DIR / f"{code}.json"
def save(code: str, name: str, flag: str, entries: dict) -> dict:
"""Sla een (nieuwe of bestaande) taal op als add-on. Retourneert {ok, msg}."""
code = (code or "").strip().lower()
if not _CODE_RE.match(code):
return {"ok": False, "msg": "Ongeldige taalcode (bv. 'de', 'fr', 'pt-br')."}
if code in BUILTIN:
return {"ok": False, "msg": f"'{code}' is een ingebouwde taal en kan niet overschreven worden."}
if not isinstance(entries, dict):
return {"ok": False, "msg": "Strings moeten een object zijn."}
_DIR.mkdir(parents=True, exist_ok=True)
clean = {k: str(v) for k, v in entries.items()
if not str(k).startswith("_") and v not in (None, "")}
doc = {"_meta": {"code": code,
"name": (name or code).strip(),
"flag": (flag or "").strip()}}
doc.update(clean)
try:
_path_for(code).write_text(
json.dumps(doc, ensure_ascii=False, indent=2), "utf-8")
except Exception as e:
return {"ok": False, "msg": f"Schrijven mislukt: {e}"}
load()
return {"ok": True, "msg": f"Taal '{name or code}' opgeslagen.", "code": code}
def delete(code: str) -> dict:
code = (code or "").strip().lower()
if code in BUILTIN:
return {"ok": False, "msg": "Ingebouwde talen kunnen niet verwijderd worden."}
p = _path_for(code)
if not p.exists():
return {"ok": False, "msg": "Taal niet gevonden."}
try:
p.unlink()
except Exception as e:
return {"ok": False, "msg": f"Verwijderen mislukt: {e}"}
load()
return {"ok": True, "msg": "Taal verwijderd."}

View file

@ -56,54 +56,54 @@ tailwind.config = {
.log-section { color:#22d3ee; font-weight:600 } .log-section { color:#22d3ee; font-weight:600 }
</style> </style>
</head> </head>
<body class="bg-bg dark:bg-bg-dark text-tx dark:text-tx-dark text-sm h-screen overflow-hidden"> <body class="bg-bg dark:bg-bg-dark text-tx dark:text-tx-dark text-[15px] h-screen overflow-hidden">
<!-- Topbar --> <!-- Topbar -->
<header class="h-12 px-4 flex items-center gap-2 bg-s1 dark:bg-s1-dark border-b border-bd dark:border-bd-dark fixed inset-x-0 top-0 z-40"> <header class="h-16 px-3 sm:px-5 flex items-center gap-2 sm:gap-3 bg-s1 dark:bg-s1-dark border-b border-bd dark:border-bd-dark fixed inset-x-0 top-0 z-40">
<button @click="sidebarOpen = !sidebarOpen" class="w-8 h-8 grid place-items-center rounded-lg hover:bg-s2 dark:hover:bg-s2-dark"><span class="mdi mdi-menu text-lg"></span></button> <button @click="toggleSidebar()" :title="sidebarOpen ? t('hide_menu') : t('show_menu')" class="w-11 h-11 grid place-items-center rounded-xl hover:bg-s2 dark:hover:bg-s2-dark active:scale-95 transition"><span class="mdi text-2xl" :class="sidebarOpen ? 'mdi-backburger' : 'mdi-menu'"></span></button>
<span class="font-extrabold text-wh dark:text-wh-dark">Server<span class="text-ac dark:text-ac-dark">Up</span></span> <span class="font-extrabold text-lg text-wh dark:text-wh-dark">Server<span class="text-ac dark:text-ac-dark">Up</span></span>
<span class="ml-2 text-[10px] font-mono text-t2 dark:text-t2-dark" x-text="'v'+version"></span> <span class="text-[11px] font-mono text-t2 dark:text-t2-dark" x-text="'v'+version"></span>
<div class="flex-1"></div> <div class="flex-1"></div>
<div class="hidden sm:flex items-center gap-3 text-[11px] font-mono text-t2 dark:text-t2-dark"> <div class="hidden sm:flex items-center gap-4 text-xs font-mono text-t2 dark:text-t2-dark">
<span class="flex items-center gap-1"> <span class="flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full" :class="docker.running>0 ? 'bg-ok shadow-[0_0_5px_#4ade80]' : 'bg-dm dark:bg-dm-dark'"></span> <span class="w-2 h-2 rounded-full" :class="docker.running>0 ? 'bg-ok shadow-[0_0_6px_#4ade80]' : 'bg-dm dark:bg-dm-dark'"></span>
<span class="mdi mdi-play-circle-outline"></span><span x-text="docker.running ?? ''"></span> <span class="mdi mdi-play-circle-outline text-base"></span><span x-text="docker.running ?? ''"></span>
</span> </span>
<span class="flex items-center gap-1"><span class="mdi mdi-layers-outline"></span><span x-text="docker.images ?? ''"></span></span> <span class="flex items-center gap-1.5"><span class="mdi mdi-layers-outline text-base"></span><span x-text="docker.images ?? ''"></span></span>
</div> </div>
<button @click="termOpen = !termOpen" class="px-2 py-1 rounded-md border border-bd dark:border-bd-dark text-t2 dark:text-t2-dark hover:text-ac hover:border-ac flex items-center gap-1 text-xs"><span class="mdi mdi-console"></span></button> <button @click="termOpen = !termOpen" :title="t('log')" class="btn-icon"><span class="mdi mdi-console text-xl"></span></button>
<button @click="toggleTheme()" class="px-2 py-1 rounded-md hover:bg-s2 dark:hover:bg-s2-dark"><span class="mdi" :class="theme==='dark' ? 'mdi-weather-sunny' : 'mdi-weather-night'"></span></button> <button @click="toggleTheme()" :title="t('theme')" class="btn-icon"><span class="mdi text-xl" :class="theme==='dark' ? 'mdi-weather-sunny' : 'mdi-weather-night'"></span></button>
</header> </header>
<!-- Sidebar --> <!-- Sidebar -->
<aside class="fixed top-12 bottom-0 left-0 w-56 bg-s1 dark:bg-s1-dark border-r border-bd dark:border-bd-dark transition-transform z-30 overflow-y-auto" <aside class="fixed top-16 bottom-0 left-0 w-72 max-w-[85vw] bg-s1 dark:bg-s1-dark border-r border-bd dark:border-bd-dark transition-transform duration-200 ease-out z-30 overflow-y-auto"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"> :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
<nav class="p-2 space-y-0.5"> <nav class="p-3 space-y-1.5">
<template x-for="item in navItems" :key="item.id"> <template x-for="item in navItems" :key="item.id">
<a href="#" @click.prevent="goto(item.id)" <a href="#" @click.prevent="goto(item.id)"
class="flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer text-xs font-medium transition" class="flex items-center gap-3 px-4 py-3.5 rounded-xl cursor-pointer text-sm font-semibold transition"
:class="page === item.id ? 'bg-ac/10 text-ac dark:text-ac-dark border border-ac/20' : 'text-t2 dark:text-t2-dark hover:bg-s2 dark:hover:bg-s2-dark hover:text-tx dark:hover:text-tx-dark border border-transparent'"> :class="page === item.id ? 'bg-ac/10 text-ac dark:text-ac-dark border border-ac/30 shadow-sm' : 'text-t2 dark:text-t2-dark hover:bg-s2 dark:hover:bg-s2-dark hover:text-tx dark:hover:text-tx-dark border border-transparent'">
<span class="mdi text-base w-5 text-center" :class="item.icon"></span> <span class="mdi text-xl w-6 text-center" :class="item.icon"></span>
<span x-text="t(item.label)"></span> <span x-text="t(item.label)"></span>
</a> </a>
</template> </template>
<template x-if="modules.length > 0"> <template x-if="modules.length > 0">
<div class="pt-3 pb-1 px-3 text-[8px] font-bold tracking-widest uppercase text-dm dark:text-dm-dark" x-text="t('modules')">Modules</div> <div class="pt-4 pb-1 px-4 text-[10px] font-bold tracking-widest uppercase text-dm dark:text-dm-dark" x-text="t('modules')">Modules</div>
</template> </template>
<template x-for="m in modules" :key="m.id"> <template x-for="m in modules" :key="m.id">
<a href="#" @click.prevent="goto('mod:'+m.id)" <a href="#" @click.prevent="goto('mod:'+m.id)"
class="flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer text-xs font-medium transition text-t2 dark:text-t2-dark hover:bg-s2 dark:hover:bg-s2-dark"> class="flex items-center gap-3 px-4 py-3.5 rounded-xl cursor-pointer text-sm font-semibold transition text-t2 dark:text-t2-dark hover:bg-s2 dark:hover:bg-s2-dark">
<span class="text-base w-5 text-center" x-text="m.icon || '🧩'"></span> <span class="text-xl w-6 text-center" x-text="m.icon || '🧩'"></span>
<span x-text="m.name"></span> <span x-text="m.name"></span>
</a> </a>
</template> </template>
</nav> </nav>
</aside> </aside>
<div x-show="sidebarOpen" @click="sidebarOpen=false" class="md:hidden fixed inset-0 bg-black/40 z-20" x-cloak></div> <div x-show="sidebarOpen && isMobile" @click="sidebarOpen=false" class="fixed inset-0 top-16 bg-black/50 backdrop-blur-sm z-20" x-cloak></div>
<!-- Main --> <!-- Main -->
<main class="md:ml-56 pt-12 h-screen overflow-y-auto"> <main class="pt-16 h-screen overflow-y-auto transition-[margin] duration-200 ease-out" :class="(sidebarOpen && !isMobile) ? 'md:ml-72' : 'ml-0'">
<div class="p-5 pb-48"> <div class="p-4 sm:p-6 max-w-6xl mx-auto pb-48">
<!-- Dashboard --> <!-- Dashboard -->
<section x-show="page === 'dashboard'" x-cloak> <section x-show="page === 'dashboard'" x-cloak>
@ -325,13 +325,48 @@ tailwind.config = {
</div> </div>
</div> </div>
<div> <div>
<div class="text-[9px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1" x-text="t('language')">Taal</div> <div class="text-[10px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1.5" x-text="t('language')">Taal</div>
<select x-model="settings.LANGUAGE" @change="setLang(settings.LANGUAGE)" class="bg-s2 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-md px-2 py-1 text-xs"> <select x-model="settings.LANGUAGE" @change="setLang(settings.LANGUAGE)" class="form-input !w-auto min-w-[160px]">
<template x-for="l in languages" :key="l.code"><option :value="l.code" x-text="(l.flag||'')+' '+l.name"></option></template> <template x-for="l in languages" :key="l.code"><option :value="l.code" x-text="(l.flag||'')+' '+l.name"></option></template>
</select> </select>
</div> </div>
</div> </div>
<!-- Taalmodule (add-on) -->
<details class="card mb-3" id="lang-addon">
<summary class="px-4 py-3.5 cursor-pointer font-bold text-wh dark:text-wh-dark text-sm flex items-center gap-2">
<span class="mdi mdi-translate text-ac dark:text-ac-dark text-lg"></span>
<span x-text="t('language_addon')">Taalmodule</span>
<span class="badge badge-info ml-1" x-text="languages.length"></span>
<span class="ml-auto flex gap-2" @click.stop>
<button class="btn-sm" @click="openLangEditor(null)"><span class="mdi mdi-plus"></span> <span x-text="t('add_language')">Taal toevoegen</span></button>
</span>
</summary>
<div class="px-4 pb-4">
<p class="text-xs text-t2 dark:text-t2-dark mb-3" x-text="t('language_addon_hint')">Voeg extra talen toe als add-on. Ingebouwde talen kunnen niet bewerkt of verwijderd worden.</p>
<div class="space-y-2">
<template x-for="l in languages" :key="l.code">
<div class="flex items-center gap-3 p-3 rounded-xl bg-s2 dark:bg-s2-dark">
<span class="text-2xl" x-text="l.flag || '🌐'"></span>
<div class="flex-1 min-w-0">
<div class="font-semibold text-wh dark:text-wh-dark text-sm truncate"><span x-text="l.name"></span> <span class="font-mono text-[11px] text-t2 dark:text-t2-dark" x-text="'('+l.code+')'"></span></div>
<div class="text-[11px] text-dm dark:text-dm-dark"><span x-text="l.count"></span> <span x-text="t('strings_count')">strings</span><template x-if="l.builtin"><span class="ml-2 badge badge-dim" x-text="t('builtin')">ingebouwd</span></template></div>
</div>
<template x-if="!l.builtin">
<button class="btn-sm" @click="openLangEditor(l.code)"><span class="mdi mdi-pencil-outline"></span> <span x-text="t('edit')">Bewerken</span></button>
</template>
<template x-if="!l.builtin">
<button class="btn-sm btn-danger" @click="deleteLang(l.code)"><span class="mdi mdi-delete-outline"></span></button>
</template>
<template x-if="l.builtin">
<button class="btn-sm" @click="openLangEditor(l.code)"><span class="mdi mdi-content-copy"></span> <span x-text="t('duplicate')">Dupliceren</span></button>
</template>
</div>
</template>
</div>
</div>
</details>
<details class="card mb-3" open> <details class="card mb-3" open>
<summary class="px-4 py-3 cursor-pointer font-semibold text-wh dark:text-wh-dark text-xs flex items-center gap-2"><span class="mdi mdi-folder-cog-outline text-ac dark:text-ac-dark"></span> <span x-text="t('paths')">Paden</span></summary> <summary class="px-4 py-3 cursor-pointer font-semibold text-wh dark:text-wh-dark text-xs flex items-center gap-2"><span class="mdi mdi-folder-cog-outline text-ac dark:text-ac-dark"></span> <span x-text="t('paths')">Paden</span></summary>
<div class="px-4 pb-4 grid sm:grid-cols-3 gap-3"> <div class="px-4 pb-4 grid sm:grid-cols-3 gap-3">
@ -370,8 +405,8 @@ tailwind.config = {
</main> </main>
<!-- Terminal --> <!-- Terminal -->
<div class="fixed bottom-0 right-0 left-0 md:left-56 bg-bg-dark/95 border-t border-bd dark:border-bd-dark transition-transform z-30" <div class="fixed bottom-0 right-0 left-0 bg-bg-dark/95 border-t border-bd dark:border-bd-dark transition-all duration-200 z-30"
:class="termOpen ? 'translate-y-0' : 'translate-y-[180px]'"> :class="[(sidebarOpen && !isMobile) ? 'md:left-72' : 'left-0', termOpen ? 'translate-y-0' : 'translate-y-[180px]']">
<div @click="termOpen = !termOpen" class="h-9 px-3 flex items-center gap-2 bg-s1 dark:bg-s1-dark border-b border-bd dark:border-bd-dark cursor-pointer"> <div @click="termOpen = !termOpen" class="h-9 px-3 flex items-center gap-2 bg-s1 dark:bg-s1-dark border-b border-bd dark:border-bd-dark cursor-pointer">
<div class="flex gap-1.5"><span class="w-2 h-2 rounded-full bg-[#ff5f57]"></span><span class="w-2 h-2 rounded-full bg-[#febc2e]"></span><span class="w-2 h-2 rounded-full bg-[#28c840]"></span></div> <div class="flex gap-1.5"><span class="w-2 h-2 rounded-full bg-[#ff5f57]"></span><span class="w-2 h-2 rounded-full bg-[#febc2e]"></span><span class="w-2 h-2 rounded-full bg-[#28c840]"></span></div>
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark flex-1"><span class="mdi mdi-console"></span> Log</span> <span class="text-[11px] font-mono text-t2 dark:text-t2-dark flex-1"><span class="mdi mdi-console"></span> Log</span>
@ -565,6 +600,39 @@ tailwind.config = {
<button class="btn" @click="modal=null" x-text="t('close')">Sluiten</button> <button class="btn" @click="modal=null" x-text="t('close')">Sluiten</button>
</div> </div>
</div> </div>
<!-- Language editor -->
<div x-show="modal==='lang'" class="bg-s1 dark:bg-s1-dark border border-bd2 dark:border-bd2-dark rounded-2xl p-5 w-full max-w-2xl max-h-[88vh] flex flex-col">
<h3 class="text-lg font-extrabold text-wh dark:text-wh-dark mb-4 flex items-center gap-2">
<span class="mdi mdi-translate text-ac dark:text-ac-dark"></span>
<span x-text="langEdit.isNew ? t('add_language') : t('edit_language')">Taal</span>
</h3>
<div class="grid sm:grid-cols-3 gap-3 mb-3">
<div><label class="form-label" x-text="t('lang_code')">Code</label><input class="form-input" x-model="langEdit.code" :readonly="!langEdit.isNew" placeholder="de, fr, pt-br"></div>
<div><label class="form-label" x-text="t('lang_name')">Naam</label><input class="form-input" x-model="langEdit.name" placeholder="Deutsch"></div>
<div><label class="form-label" x-text="t('lang_flag')">Vlag</label><input class="form-input" x-model="langEdit.flag" placeholder="🇩🇪" maxlength="8"></div>
</div>
<div class="relative mb-3">
<span class="mdi mdi-magnify absolute left-3 top-1/2 -translate-y-1/2 text-dm dark:text-dm-dark"></span>
<input x-model="langEdit.query" :placeholder="t('search_keys')" class="form-input pl-9">
</div>
<div class="flex-1 overflow-y-auto -mx-1 px-1 space-y-2" style="max-height:48vh">
<template x-for="k in langKeysFiltered" :key="k">
<div class="grid sm:grid-cols-2 gap-2 items-center">
<div class="text-xs">
<div class="font-mono text-[11px] text-ac dark:text-ac-dark" x-text="k"></div>
<div class="text-t2 dark:text-t2-dark text-[11px] truncate" :title="langEdit.ref[k]" x-text="langEdit.ref[k]"></div>
</div>
<input class="form-input" x-model="langEdit.values[k]" :placeholder="langEdit.ref[k]">
</div>
</template>
<p x-show="langKeysFiltered.length===0" class="text-center text-dm dark:text-dm-dark text-xs py-6" x-text="t('no_results')">Geen resultaten</p>
</div>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-bd dark:border-bd-dark">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="saveLang()"><span class="mdi mdi-content-save"></span> <span x-text="t('save')">Opslaan</span></button>
</div>
</div>
</div> </div>
<!-- First-run wizard --> <!-- First-run wizard -->
@ -667,19 +735,19 @@ tailwind.config = {
<!-- Tailwind component classes --> <!-- Tailwind component classes -->
<style type="text/tailwindcss"> <style type="text/tailwindcss">
@layer components { @layer components {
.btn { @apply inline-flex items-center gap-1 px-3 py-1.5 rounded-md border border-bd dark:border-bd-dark bg-s1 dark:bg-s1-dark text-tx dark:text-tx-dark text-xs font-medium hover:border-ac hover:text-ac dark:hover:text-ac-dark transition cursor-pointer; } .btn { @apply inline-flex items-center justify-center gap-1.5 px-4 py-2.5 min-h-[44px] rounded-xl border border-bd dark:border-bd-dark bg-s1 dark:bg-s1-dark text-tx dark:text-tx-dark text-sm font-semibold hover:border-ac hover:text-ac dark:hover:text-ac-dark active:scale-[.97] transition cursor-pointer; }
.btn-primary { @apply !bg-ac !text-black !border-ac hover:!brightness-110 font-semibold; } .btn-primary { @apply !bg-ac !text-black !border-ac hover:!brightness-110 shadow-sm shadow-ac/20 font-bold; }
.btn-danger { @apply !text-er !border-er/30 hover:!bg-er/10; } .btn-danger { @apply !text-er !border-er/30 hover:!bg-er/10; }
.btn-icon { @apply w-8 h-8 grid place-items-center rounded-md border border-bd dark:border-bd-dark text-t2 dark:text-t2-dark hover:text-ac hover:border-ac transition cursor-pointer; } .btn-icon { @apply w-11 h-11 grid place-items-center rounded-xl border border-bd dark:border-bd-dark text-t2 dark:text-t2-dark hover:text-ac hover:border-ac active:scale-95 transition cursor-pointer; }
.btn-sm { @apply inline-flex items-center gap-1 px-2 py-1 rounded border border-bd dark:border-bd-dark text-tx dark:text-tx-dark text-[10px] hover:border-ac hover:text-ac dark:hover:text-ac-dark cursor-pointer transition; } .btn-sm { @apply inline-flex items-center justify-center gap-1.5 px-3 py-2 min-h-[38px] rounded-lg border border-bd dark:border-bd-dark text-tx dark:text-tx-dark text-xs font-semibold hover:border-ac hover:text-ac dark:hover:text-ac-dark active:scale-95 cursor-pointer transition; }
.card { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-xl overflow-hidden; } .card { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl overflow-hidden shadow-sm shadow-black/[0.03] dark:shadow-black/20; }
.stat-card { @apply card p-3 flex items-center gap-3; } .stat-card { @apply card p-4 flex items-center gap-4; }
.stat-icon { @apply w-9 h-9 rounded-lg grid place-items-center text-base; } .stat-icon { @apply w-12 h-12 rounded-xl grid place-items-center text-xl; }
.stat-value { @apply text-xl font-extrabold text-wh dark:text-wh-dark leading-none; } .stat-value { @apply text-2xl font-extrabold text-wh dark:text-wh-dark leading-none; }
.stat-label { @apply text-[9px] text-t2 dark:text-t2-dark mt-0.5 uppercase tracking-wider; } .stat-label { @apply text-[10px] text-t2 dark:text-t2-dark mt-1 uppercase tracking-wider font-semibold; }
.form-label { @apply block text-[9px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1; } .form-label { @apply block text-[10px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1.5; }
.form-input { @apply w-full bg-s2 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-md px-3 py-1.5 text-xs font-mono text-tx dark:text-tx-dark outline-none focus:border-ac focus:bg-s1 dark:focus:bg-s1-dark transition; } .form-input { @apply w-full bg-s2 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-lg px-3 py-2.5 min-h-[44px] text-sm font-mono text-tx dark:text-tx-dark outline-none focus:border-ac focus:ring-2 focus:ring-ac/20 focus:bg-s1 dark:focus:bg-s1-dark transition; }
.badge { @apply text-[8px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded-full; } .badge { @apply text-[9px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full; }
.badge-ok { @apply bg-ok/10 text-ok; } .badge-ok { @apply bg-ok/10 text-ok; }
.badge-warn { @apply bg-wr/10 text-wr; } .badge-warn { @apply bg-wr/10 text-wr; }
.badge-info { @apply bg-bl/10 text-bl; } .badge-info { @apply bg-bl/10 text-bl; }
@ -691,9 +759,10 @@ tailwind.config = {
<script> <script>
function app() { function app() {
return { return {
version: '0.3.0', version: '0.3.1',
page: location.hash.slice(1) || 'dashboard', page: location.hash.slice(1) || 'dashboard',
sidebarOpen: window.innerWidth >= 768, isMobile: window.innerWidth < 768,
sidebarOpen: window.innerWidth >= 768 && localStorage.getItem('sidebar') !== 'closed',
termOpen: false, termOpen: false,
theme: localStorage.getItem('theme') || 'dark', theme: localStorage.getItem('theme') || 'dark',
saved: false, saved: false,
@ -724,6 +793,7 @@ function app() {
}, },
editor: { stack:'', type:'env', title:'', content:'' }, editor: { stack:'', type:'env', title:'', content:'' },
logs: { title:'', content:'' }, logs: { title:'', content:'' },
langEdit: { isNew:true, code:'', name:'', flag:'', keys:[], ref:{}, values:{}, query:'' },
wizard: { open:false, step:0, steps: [], LIBRARY_DIR:'', DATA_DIR:'', BACKUP_DIR:'', wizard: { open:false, step:0, steps: [], LIBRARY_DIR:'', DATA_DIR:'', BACKUP_DIR:'',
git: { choice:'skip', url:'', token:'', subdir:'', port_web:3000, port_ssh:2222 } }, git: { choice:'skip', url:'', token:'', subdir:'', port_web:3000, port_ssh:2222 } },
@ -738,6 +808,10 @@ function app() {
async init() { async init() {
window.addEventListener('hashchange', () => this.page = location.hash.slice(1) || 'dashboard'); window.addEventListener('hashchange', () => this.page = location.hash.slice(1) || 'dashboard');
window.addEventListener('resize', () => {
const m = window.innerWidth < 768;
if (m !== this.isMobile) { this.isMobile = m; if (m) this.sidebarOpen = false; else this.sidebarOpen = localStorage.getItem('sidebar') !== 'closed'; }
});
await this.loadLangs(); await this.loadLangs();
await this.loadLang(localStorage.getItem('lang') || 'nl'); await this.loadLang(localStorage.getItem('lang') || 'nl');
await this.checkWizard(); await this.checkWizard();
@ -759,6 +833,57 @@ function app() {
async loadLang(code) { try { const d = await fetch('/api/i18n/'+code).then(r=>r.json()); this.T = d.strings || {}; } catch(e) {} }, async loadLang(code) { try { const d = await fetch('/api/i18n/'+code).then(r=>r.json()); this.T = d.strings || {}; } catch(e) {} },
setLang(c) { localStorage.setItem('lang', c); this.loadLang(c); }, setLang(c) { localStorage.setItem('lang', c); this.loadLang(c); },
async openLangEditor(code) {
const ref = await this.rpc('/api/i18n/keys');
const keys = ref.keys || [];
const refMap = ref.en || {};
let values = {}, meta = { code:'', name:'', flag:'' }, isNew = true;
if (code) {
const raw = await this.rpc('/api/i18n/'+code+'/raw');
const existing = this.languages.find(l => l.code === code) || {};
if (existing.builtin) {
// Dupliceren: leeg code, gebruik bestaande strings als startpunt
values = { ...(raw.strings || {}) };
meta = { code:'', name: (existing.name||'')+' (kopie)', flag: existing.flag||'' };
isNew = true;
} else {
values = { ...(raw.strings || {}) };
meta = { code, name: existing.name||'', flag: existing.flag||'' };
isNew = false;
}
}
this.langEdit = { isNew, ...meta, keys, ref: refMap, values, query:'' };
this.modal = 'lang';
},
get langKeysFiltered() {
const q = (this.langEdit.query||'').toLowerCase();
if (!q) return this.langEdit.keys;
return this.langEdit.keys.filter(k =>
k.toLowerCase().includes(q) || String(this.langEdit.ref[k]||'').toLowerCase().includes(q));
},
async saveLang() {
const e = this.langEdit;
if (!e.code.trim()) { this.toast(this.t('lang_code')+' ?', 'wr'); return; }
const strings = {};
for (const [k,v] of Object.entries(e.values)) { if (v !== undefined && v !== null && String(v).trim() !== '') strings[k] = v; }
const d = await this.rpc('/api/i18n', { code: e.code.trim().toLowerCase(), name: e.name.trim(), flag: e.flag.trim(), strings });
if (d.ok) {
this.toast(d.msg || this.t('saved'), 'ok');
this.modal = null;
await this.loadLangs();
if (d.code && d.code === (localStorage.getItem('lang') || 'nl')) await this.loadLang(d.code);
} else { this.toast(d.msg || 'Fout', 'er'); }
},
async deleteLang(code) {
if (!confirm(this.t('delete_language_confirm') || ('Taal '+code+' verwijderen?'))) return;
const d = await fetch('/api/i18n/'+code, { method:'DELETE' }).then(r=>r.json()).catch(()=>({}));
if (d.ok) {
this.toast(d.msg || this.t('deleted'), 'ok');
if (localStorage.getItem('lang') === code) this.setLang('nl');
await this.loadLangs();
} else { this.toast(d.msg || 'Fout', 'er'); }
},
toggleTheme() { this.theme = this.theme === 'dark' ? 'light' : 'dark'; localStorage.setItem('theme', this.theme); this.autoSave(); }, toggleTheme() { this.theme = this.theme === 'dark' ? 'light' : 'dark'; localStorage.setItem('theme', this.theme); this.autoSave(); },
toast(msg, kind='in') { toast(msg, kind='in') {
@ -776,7 +901,12 @@ function app() {
goto(id) { goto(id) {
this.page = id; this.page = id;
location.hash = '#' + id; location.hash = '#' + id;
if (window.innerWidth < 768) this.sidebarOpen = false; if (this.isMobile) this.sidebarOpen = false;
},
toggleSidebar() {
this.sidebarOpen = !this.sidebarOpen;
if (!this.isMobile) localStorage.setItem('sidebar', this.sidebarOpen ? 'open' : 'closed');
}, },
pollJob(jid, onDone) { pollJob(jid, onDone) {

View file

@ -88,5 +88,21 @@
"available": "Available", "available": "Available",
"synced": "Synced", "synced": "Synced",
"not_cloned": "Not cloned", "not_cloned": "Not cloned",
"auto_saved": "Auto-saved" "auto_saved": "Auto-saved",
"hide_menu": "Hide menu",
"show_menu": "Show menu",
"log": "Log",
"deleted": "Deleted",
"duplicate": "Duplicate",
"builtin": "built-in",
"strings_count": "translations",
"language_addon": "Language module",
"language_addon_hint": "Add extra languages as an add-on. Built-in languages can be duplicated as a starting point, but not edited or removed.",
"add_language": "Add language",
"edit_language": "Edit language",
"lang_code": "Language code",
"lang_name": "Name",
"lang_flag": "Flag",
"search_keys": "Search key or text…",
"delete_language_confirm": "Are you sure you want to delete this language?"
} }

View file

@ -88,5 +88,21 @@
"available": "Beschikbaar", "available": "Beschikbaar",
"synced": "Gesynchroniseerd", "synced": "Gesynchroniseerd",
"not_cloned": "Niet gecloned", "not_cloned": "Niet gecloned",
"auto_saved": "Automatisch opgeslagen" "auto_saved": "Automatisch opgeslagen",
"hide_menu": "Menu verbergen",
"show_menu": "Menu tonen",
"log": "Log",
"deleted": "Verwijderd",
"duplicate": "Dupliceren",
"builtin": "ingebouwd",
"strings_count": "vertalingen",
"language_addon": "Taalmodule",
"language_addon_hint": "Voeg extra talen toe als add-on. Ingebouwde talen kun je dupliceren als startpunt, maar niet bewerken of verwijderen.",
"add_language": "Taal toevoegen",
"edit_language": "Taal bewerken",
"lang_code": "Taalcode",
"lang_name": "Naam",
"lang_flag": "Vlag",
"search_keys": "Zoek vertaalsleutel of tekst…",
"delete_language_confirm": "Weet je zeker dat je deze taal wilt verwijderen?"
} }