Voeg 28 herkenbare kleurplaten en creatieve niveaus toe (v0.4.66-beta) #4

Open
bes-r wants to merge 207 commits from bes-r/kleurplaten-v0-4-66 into main AGit
212 changed files with 29803 additions and 4877 deletions

View file

@ -13,3 +13,8 @@ POSTGRES_PASSWORD=change-me-locally
# Connectiestring die de app gebruikt.
# Hostnaam 'db' verwijst naar de postgres-service binnen het compose-netwerk.
DATABASE_URL=postgres://teach:change-me-locally@db:5432/teach
# Eerste overkoepelende beheerder (wordt eenmalig aangemaakt als er nog geen bestaat).
# Laat SUPER_PASS leeg om een willekeurig wachtwoord in de serverlog te krijgen.
SUPER_USER=beheerder
SUPER_PASS=

View file

@ -11,7 +11,7 @@ concurrency:
jobs:
build-and-deploy:
runs-on: ubuntu-latest # pas aan naar het label van jouw Forgejo-runner
runs-on: dev # jouw dev-runner (test-server)
env:
# 10.0.20.22:3000 - host van je Forgejo (= container registry host)
REGISTRY: ${{ vars.REGISTRY }}
@ -19,13 +19,23 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Dependencies en securitytests
run: |
docker run --rm \
-v "$PWD:/app" \
-v /app/node_modules \
-w /app node:22-alpine \
sh -lc 'npm ci && npm test && npm audit --omit=dev --audit-level=moderate'
- name: Image-tags bepalen
id: meta
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
VERSION="$(tr -d '[:space:]' < VERSION)"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "sha_tag=${IMAGE}:dev-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
echo "dev_tag=${IMAGE}:dev" >> "$GITHUB_OUTPUT"
echo "app_version=${VERSION}" >> "$GITHUB_OUTPUT"
# Platte docker build/push i.p.v. buildx: betrouwbaarder tegen een
# HTTP-registry. De runner-host moet 10.0.20.22:3000 als
@ -34,24 +44,25 @@ jobs:
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker build \
--build-arg APP_VERSION="dev-${GITHUB_SHA::7}" \
--build-arg APP_VERSION="${{ steps.meta.outputs.app_version }}" \
--build-arg GIT_SHA="${GITHUB_SHA::7}" \
-t "${{ steps.meta.outputs.sha_tag }}" \
-t "${{ steps.meta.outputs.dev_tag }}" .
docker push "${{ steps.meta.outputs.sha_tag }}"
docker push "${{ steps.meta.outputs.dev_tag }}"
# Deploy via gewone ssh (geen externe action nodig).
- name: Deploy naar test-VM
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.TEST_HOST }}
username: ${{ secrets.TEST_USER }}
key: ${{ secrets.TEST_SSH_KEY }}
script: |
set -e
cd ${{ secrets.TEST_DEPLOY_PATH }}
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ vars.REGISTRY }} -u "${{ secrets.REGISTRY_USER }}" --password-stdin
# Zet de te draaien image-tag in de env van de VM
sed -i "s|^IMAGE=.*|IMAGE=${{ steps.meta.outputs.dev_tag }}|" .env
docker compose -f compose.deploy.yaml pull
docker compose -f compose.deploy.yaml up -d
docker image prune -f
run: |
echo "${{ secrets.TEST_SSH_KEY }}" > deploy_key
chmod 600 deploy_key
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=15 \
-i deploy_key deploy/compose.deploy.yaml \
${{ secrets.TEST_USER }}@${{ secrets.TEST_HOST }}:${{ secrets.TEST_DEPLOY_PATH }}/compose.deploy.yaml
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=15 \
-i deploy_key deploy/nginx.conf \
${{ secrets.TEST_USER }}@${{ secrets.TEST_HOST }}:${{ secrets.TEST_DEPLOY_PATH }}/nginx.conf.new
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=15 \
-i deploy_key ${{ secrets.TEST_USER }}@${{ secrets.TEST_HOST }} \
"set -e && cd ${{ secrets.TEST_DEPLOY_PATH }} && mv -f nginx.conf.new nginx.conf && echo '${{ secrets.REGISTRY_TOKEN }}' | docker login ${{ vars.REGISTRY }} -u '${{ secrets.REGISTRY_USER }}' --password-stdin && sed -i 's|^IMAGE=.*|IMAGE=${{ steps.meta.outputs.dev_tag }}|' .env && docker compose -f compose.deploy.yaml pull app && docker compose -f compose.deploy.yaml up -d --force-recreate app web && docker image prune -f"
rm -f deploy_key

View file

@ -12,13 +12,21 @@ concurrency:
jobs:
build-and-deploy:
runs-on: ubuntu-latest # pas aan naar het label van jouw Forgejo-runner
runs-on: prod # jouw prod-runner (productieserver)
env:
REGISTRY: ${{ vars.REGISTRY }}
IMAGE_NAME: ${{ github.repository }} # bes-r/teach
steps:
- uses: actions/checkout@v4
- name: Dependencies en securitytests
run: |
docker run --rm \
-v "$PWD:/app" \
-v /app/node_modules \
-w /app node:22-alpine \
sh -lc 'npm ci && npm test && npm audit --omit=dev --audit-level=moderate'
- name: Image-tags bepalen
id: meta
run: |
@ -35,22 +43,24 @@ jobs:
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker build \
--build-arg APP_VERSION="${{ steps.meta.outputs.version }}" \
--build-arg GIT_SHA="${GITHUB_SHA::7}" \
-t "${{ steps.meta.outputs.version_tag }}" \
-t "${{ steps.meta.outputs.latest_tag }}" .
docker push "${{ steps.meta.outputs.version_tag }}"
docker push "${{ steps.meta.outputs.latest_tag }}"
# Deploy via gewone ssh (geen externe action nodig).
- name: Deploy naar productie-VM
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
set -e
cd ${{ secrets.PROD_DEPLOY_PATH }}
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ vars.REGISTRY }} -u "${{ secrets.REGISTRY_USER }}" --password-stdin
sed -i "s|^IMAGE=.*|IMAGE=${{ steps.meta.outputs.version_tag }}|" .env
docker compose -f compose.deploy.yaml pull
docker compose -f compose.deploy.yaml up -d
docker image prune -f
run: |
echo "${{ secrets.PROD_SSH_KEY }}" > deploy_key
chmod 600 deploy_key
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=15 \
-i deploy_key deploy/compose.deploy.yaml \
${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }}:${{ secrets.PROD_DEPLOY_PATH }}/compose.deploy.yaml
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=15 \
-i deploy_key deploy/nginx.conf \
${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }}:${{ secrets.PROD_DEPLOY_PATH }}/nginx.conf.new
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=15 \
-i deploy_key ${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }} \
"set -e && cd ${{ secrets.PROD_DEPLOY_PATH }} && mv -f nginx.conf.new nginx.conf && echo '${{ secrets.REGISTRY_TOKEN }}' | docker login ${{ vars.REGISTRY }} -u '${{ secrets.REGISTRY_USER }}' --password-stdin && sed -i 's|^IMAGE=.*|IMAGE=${{ steps.meta.outputs.version_tag }}|' .env && docker compose -f compose.deploy.yaml pull app && docker compose -f compose.deploy.yaml up -d --force-recreate app web && docker image prune -f"
rm -f deploy_key

1
.gitignore vendored
View file

@ -8,6 +8,7 @@ npm-debug.log*
# Postgres data volume bij lokaal draaien
pgdata/
storage/
# OS / editor
.DS_Store

11
AGENTS.md Normal file
View file

@ -0,0 +1,11 @@
# Projectafspraken
- Werk nooit rechtstreeks op main
- Push naar de dev branch
- Werk met versienummering (v0.0.00)
- In de dev-branch altijd beta weergeven bij de versienummering
- Beschrijf gewijzigde functionaliteiten puntsgewijs in de pull request.
- Gebruik geen sudo
- Commit geen wachtwoorden, tokens, `.env`-bestanden of privésleutels.
- Voer relevante tests uit voor iedere commit.
- Push wijzigingen naar Forgejo, maar merge niet zelfstandig.
- Bij opdracht "Release versie" maak een versie releaseversie

11
CLAUDE.md Normal file
View file

@ -0,0 +1,11 @@
# Projectafspraken
- Werk nooit rechtstreeks op main
- Push naar de dev branch
- Werk met versienummering (v0.0.00)
- In de dev-branch altijd beta weergeven bij de versienummering
- Beschrijf gewijzigde functionaliteiten puntsgewijs in de pull request.
- Gebruik geen sudo
- Commit geen wachtwoorden, tokens, `.env`-bestanden of privésleutels.
- Voer relevante tests uit voor iedere commit.
- Push wijzigingen naar Forgejo, maar merge niet zelfstandig.
- Bij opdracht "Release versie" maak een versie releaseversie

View file

@ -13,16 +13,19 @@ ENV NODE_ENV=production
WORKDIR /app
# Draai als non-root gebruiker
RUN addgroup -S app && adduser -S app -G app
RUN addgroup -S app && adduser -S app -G app && mkdir -p /app/storage/images && chown -R app:app /app/storage
COPY --chown=app:app --from=deps /app/node_modules ./node_modules
COPY --chown=app:app package.json ./
COPY --chown=app:app src ./src
COPY --chown=app:app public ./public
COPY --chown=app:app db ./db
# Versie wordt tijdens de build meegegeven door CI
# Versie + commit-hash worden tijdens de build meegegeven door CI
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
ARG GIT_SHA=
ENV GIT_SHA=$GIT_SHA
USER app
EXPOSE 3000

View file

@ -35,8 +35,9 @@ App op http://localhost:3000, healthcheck op `/healthz`, DB-check op `/readyz`.
serveert de HTML én biedt de `/api`, en praat met de DB.
- **Forgejo container registry**: CI bouwt de image één keer en pusht die; beide
VM's pullen exact dezelfde geteste image. Geen build op de productie-VM.
- **Nginx blijft ervoor** als reverse proxy / TLS op elke VM; de app luistert
alleen op `127.0.0.1:<APP_PORT>`.
- **Pangolin/Traefik verzorgt de publieke HTTPS-ingang**. De meegeleverde nginx
vormt de interne proxylaag en luistert standaard op poort `8081`, zodat de
Pangolin/Traefik-route deze via het VM-/containernetwerk kan bereiken. De Fastify-app vertrouwt precies twee proxy-hops.
## Eenmalige setup
@ -107,28 +108,29 @@ POSTGRES_USER=teach
POSTGRES_PASSWORD=<sterk-wachtwoord>
DATABASE_URL=postgres://teach:<sterk-wachtwoord>@db:5432/teach
APP_PORT=3000
WEB_BIND_IP=0.0.0.0
WEB_PORT=8081
TRUST_PROXY_HOPS=2
SUPER_USER=beheerder
SUPER_PASS=<uniek-sterk-eerste-wachtwoord>
EOF
```
CI werkt bij elke deploy de `IMAGE=`-regel bij, pullt en herstart.
### 4. Nginx reverse proxy (per VM)
```nginx
server {
listen 443 ssl;
server_name digibord.familiebesselink.nl; # test-VM eigen subdomein
### 4. Pangolin/Traefik publiceren
# ssl_certificate ... (bestaande config)
Publiceer `http://<interne-vm-ip>:8081` via Pangolin/Traefik en laat daar TLS
beëindigen. De interne nginx behoudt `X-Forwarded-Proto: https`, waarna de app
Secure/HttpOnly/SameSite-cookies, HSTS en overige securityheaders gebruikt.
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
- Beperk poort 8081 met de hostfirewall tot het Pangolin/Traefik- of tunnelnetwerk.
- Pas `TRUST_PROXY_HOPS` alleen aan wanneer de proxyketen werkelijk verandert.
- Bij een aparte Traefik-container kan een gedeeld intern Docker-netwerk nodig
zijn; zet `WEB_BIND_IP` niet ruimer dan noodzakelijk.
- `SUPER_PASS` is alleen nodig bij een lege database, wordt nooit gelogd en
moet via de beveiligde deploy-omgeving worden aangeleverd.
- De overgang naar v0.3.03-beta trekt bestaande browsersessies bewust in.
## Workflow / branching

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.4.66-beta

View file

@ -16,8 +16,14 @@ services:
PORT: 3000
DATABASE_URL: ${DATABASE_URL}
APP_VERSION: dev
SUPER_USER: ${SUPER_USER:-beheerder}
SUPER_PASS: ${SUPER_PASS:-}
TRUST_PROXY_HOPS: 0
COOKIE_SECURE: "false"
ports:
- "3000:3000"
volumes:
- image_data:/app/storage/images
restart: unless-stopped
db:
@ -39,3 +45,4 @@ services:
volumes:
pgdata:
image_data:

52
db/002_schools_auth.sql Normal file
View file

@ -0,0 +1,52 @@
-- v0.2.00: scholen, klassen, rollen en sessies.
-- Rollen: super (overkoepelend beheerder), admin (schoolbeheerder),
-- teacher (groepsleiding), pupil (leerling).
CREATE TABLE IF NOT EXISTS schools (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS classes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
school_id BIGINT NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE users
ADD COLUMN IF NOT EXISTS school_id BIGINT REFERENCES schools(id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS display_name TEXT,
ADD COLUMN IF NOT EXISTS password_hash TEXT,
-- alleen voor leerlingen: wachtwoord dat groepsleiding/beheer mag inzien
ADD COLUMN IF NOT EXISTS password_plain TEXT,
-- eenmalige koppelcode waarmee nieuwe gebruikers hun account activeren
ADD COLUMN IF NOT EXISTS link_code TEXT UNIQUE,
ADD COLUMN IF NOT EXISTS class_id BIGINT REFERENCES classes(id) ON DELETE SET NULL,
-- alle app-gegevens van de gebruiker (borden, thema's, eigen woorden)
ADD COLUMN IF NOT EXISTS data JSONB NOT NULL DEFAULT '{}'::jsonb;
-- gebruikersnaam uniek binnen een school; supers (school_id IS NULL) landelijk uniek
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_username_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_school_username
ON users (school_id, lower(username)) WHERE school_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_super_username
ON users (lower(username)) WHERE school_id IS NULL;
-- welke groepsleiding hoort bij welke klas
CREATE TABLE IF NOT EXISTS class_teachers (
class_id BIGINT NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (class_id, user_id)
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_users_school ON users(school_id);
CREATE INDEX IF NOT EXISTS idx_users_class ON users(class_id);

View file

@ -0,0 +1,5 @@
-- v0.3.00: bijhouden of een account al gebruikt is, zodat leerling-
-- wachtwoorden na de eerste keer inloggen niet langer inzichtelijk zijn.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;

9
db/004_user_roles.sql Normal file
View file

@ -0,0 +1,9 @@
-- v0.2.21: extra functies naast de hoofdrol (users.role) - alleen voor stafaccounts.
-- Leerlingen krijgen nooit een extra rol; dat wordt in de applicatielaag afgedwongen
-- (net als de bestaande regel dat alleen super rollen mag wijzigen).
CREATE TABLE IF NOT EXISTS user_roles (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL,
PRIMARY KEY (user_id, role)
);
CREATE INDEX IF NOT EXISTS idx_user_roles_user ON user_roles(user_id);

16
db/005_assignments.sql Normal file
View file

@ -0,0 +1,16 @@
-- Toewijzingen: een leerkracht koppelt één van haar eigen borden (via het
-- stabiele board-id uit users.data) aan een klas of een individuele leerling.
-- Puur additief, leeg bij aanmaak. Precies één van class_id/pupil_id gezet:
-- een klas-toewijzing (standaard voor iedereen in de klas) of een leerling-
-- toewijzing (uitzondering, voor een leerling op een ander niveau).
CREATE TABLE IF NOT EXISTS assignments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
class_id BIGINT REFERENCES classes(id) ON DELETE CASCADE,
pupil_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
board_id TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (num_nonnulls(class_id, pupil_id) = 1)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_assignments_class ON assignments(class_id) WHERE class_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_assignments_pupil ON assignments(pupil_id) WHERE pupil_id IS NOT NULL;

View file

@ -0,0 +1,62 @@
-- v0.3.03: tenant-isolatie en security-invarianten ook in PostgreSQL afdwingen.
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
-- Leerlingwachtwoorden worden voortaan alleen eenmalig in de API-respons getoond.
ALTER TABLE users DROP COLUMN IF EXISTS password_plain;
-- Ruim eventuele historische tenant-kruisingen veilig op vóór de triggers actief worden.
UPDATE users u SET class_id = NULL
WHERE class_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM classes c WHERE c.id = u.class_id AND c.school_id = u.school_id
);
DELETE FROM class_teachers ct
WHERE NOT EXISTS (
SELECT 1 FROM classes c
JOIN users u ON u.id = ct.user_id AND u.school_id = c.school_id
WHERE c.id = ct.class_id
AND (u.role = 'teacher' OR EXISTS (
SELECT 1 FROM user_roles ur WHERE ur.user_id = u.id AND ur.role = 'teacher'
))
);
CREATE OR REPLACE FUNCTION teach_validate_user_class_school()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.class_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM classes c WHERE c.id = NEW.class_id AND c.school_id = NEW.school_id
) THEN
RAISE EXCEPTION 'leerling en klas moeten bij dezelfde school horen';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_users_class_school ON users;
CREATE TRIGGER trg_users_class_school
BEFORE INSERT OR UPDATE OF class_id, school_id ON users
FOR EACH ROW EXECUTE FUNCTION teach_validate_user_class_school();
CREATE OR REPLACE FUNCTION teach_validate_class_teacher()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM classes c
JOIN users u ON u.id = NEW.user_id AND u.school_id = c.school_id
WHERE c.id = NEW.class_id
AND (u.role = 'teacher' OR EXISTS (
SELECT 1 FROM user_roles ur WHERE ur.user_id = u.id AND ur.role = 'teacher'
))
) THEN
RAISE EXCEPTION 'groepsleiding en klas moeten bij dezelfde school horen';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_class_teachers_tenant ON class_teachers;
CREATE TRIGGER trg_class_teachers_tenant
BEFORE INSERT OR UPDATE ON class_teachers
FOR EACH ROW EXECUTE FUNCTION teach_validate_class_teacher();

19
db/007_progress.sql Normal file
View file

@ -0,0 +1,19 @@
-- Voortgang: leerlingen loggen elk afgerond "moment" in een widget (ronde
-- sommen, voltooid woord, geknapte ballon) zodat leerkrachten een tijdlijn
-- per leerling kunnen bekijken. teacher_id/board_id komen server-side uit de
-- bestaande assignments-tabel, nooit van de client, om te voorkomen dat een
-- leerling voortgang onder een andere leerkracht kan wegschrijven.
CREATE TABLE IF NOT EXISTS progress_events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pupil_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
board_id TEXT NOT NULL,
widget_id TEXT NOT NULL,
widget_type TEXT NOT NULL,
attempts INT NOT NULL DEFAULT 0,
correct INT NOT NULL DEFAULT 0,
stars INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_progress_events_pupil ON progress_events(pupil_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_progress_events_teacher ON progress_events(teacher_id, created_at DESC);

7
db/008_widget_share.sql Normal file
View file

@ -0,0 +1,7 @@
-- Widget-delen: een toewijzing kan naast een heel bord (widget_id NULL,
-- bestaand gedrag) ook één enkele widget van dat bord delen, met een modus:
-- 'kijken' (leerling ziet de widget read-only, snellere poll) of 'werken'
-- (leerling werkt erin; voortgang loopt via progress_events). widget_id
-- verwijst naar het stabiele wid in de borddata van de leerkracht.
ALTER TABLE assignments ADD COLUMN IF NOT EXISTS widget_id TEXT;
ALTER TABLE assignments ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'werken';

16
db/009_save_system.sql Normal file
View file

@ -0,0 +1,16 @@
-- Opslaansysteem: optimistische concurrency + versiegeschiedenis.
-- data_rev telt elke wijziging van users.data; een client stuurt de rev mee
-- die hij geladen heeft en de update slaagt alleen als die nog actueel is
-- (anders 409: een ander tabblad/digibord schreef eerder). user_data_versions
-- bewaart periodieke snapshots van de vorige data zodat een leerkracht een
-- eerdere versie kan terugzetten.
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_rev BIGINT NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_updated_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS user_data_versions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
data JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_user_data_versions ON user_data_versions(user_id, created_at DESC);

17
db/010_shared_library.sql Normal file
View file

@ -0,0 +1,17 @@
-- Gedeelde bibliotheek: borden en ankerdiagrammen die de systeemmanager
-- (school_id NULL = systeembreed, voor iedereen zichtbaar) of stafleden van
-- een school (school_id gezet = alleen binnen die school zichtbaar) hebben
-- klaargezet. Andere gebruikers kopiëren een item naar hun eigen collectie;
-- er wordt niet live in gedeelde items gewerkt. folder is een pad met
-- "/"-scheiding, dezelfde conventie als de mappenverkenner.
CREATE TABLE IF NOT EXISTS shared_library (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
school_id BIGINT REFERENCES schools(id) ON DELETE CASCADE,
owner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
folder TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
data JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_shared_library ON shared_library(kind, school_id);

View file

@ -0,0 +1,6 @@
-- Alleen-lezen-vlag voor gedeelde items: een vergrendeld school-item kan ook
-- door de eigenaar niet meer gewijzigd worden, alleen nog door de
-- schoolbeheerder of de systeemmanager (die de vlag ook zetten/weghalen).
-- Systeembrede items zijn impliciet al alleen-lezen voor iedereen behalve de
-- systeemmanager (beheerregel), de vlag is daar niet voor nodig.
ALTER TABLE shared_library ADD COLUMN IF NOT EXISTS readonly BOOLEAN NOT NULL DEFAULT false;

130
db/012_image_catalog.sql Normal file
View file

@ -0,0 +1,130 @@
-- v0.3.55-beta: doorzoekbare afbeeldingscatalogus met meervoudige thema's,
-- map-paden, eigenaarschap en instelbare opslagquota.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS image_quota_bytes BIGINT
CHECK (image_quota_bytes IS NULL OR image_quota_bytes >= -1);
ALTER TABLE schools
ADD COLUMN IF NOT EXISTS image_quota_bytes BIGINT
CHECK (image_quota_bytes IS NULL OR image_quota_bytes >= -1);
CREATE TABLE IF NOT EXISTS image_themes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
key TEXT UNIQUE,
scope TEXT NOT NULL CHECK (scope IN ('global','school','personal')),
school_id BIGINT REFERENCES schools(id) ON DELETE CASCADE,
owner_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
folder TEXT NOT NULL DEFAULT '',
name_nl TEXT NOT NULL,
name_en TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (
(scope = 'global' AND school_id IS NULL) OR
(scope = 'school' AND school_id IS NOT NULL AND owner_id IS NOT NULL) OR
(scope = 'personal' AND owner_id IS NOT NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_image_themes_global_name
ON image_themes (lower(name_nl)) WHERE scope = 'global';
CREATE UNIQUE INDEX IF NOT EXISTS idx_image_themes_school_name
ON image_themes (school_id, lower(name_nl)) WHERE scope = 'school';
CREATE UNIQUE INDEX IF NOT EXISTS idx_image_themes_personal_name
ON image_themes (owner_id, lower(name_nl)) WHERE scope = 'personal';
CREATE TABLE IF NOT EXISTS image_folders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
scope TEXT NOT NULL CHECK (scope IN ('global','school','personal')),
school_id BIGINT REFERENCES schools(id) ON DELETE CASCADE,
owner_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
path TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (
(scope = 'global' AND school_id IS NULL) OR
(scope = 'school' AND school_id IS NOT NULL AND owner_id IS NOT NULL) OR
(scope = 'personal' AND owner_id IS NOT NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_image_folders_global_path
ON image_folders (path) WHERE scope = 'global';
CREATE UNIQUE INDEX IF NOT EXISTS idx_image_folders_school_path
ON image_folders (school_id, path) WHERE scope = 'school';
CREATE UNIQUE INDEX IF NOT EXISTS idx_image_folders_personal_path
ON image_folders (owner_id, path) WHERE scope = 'personal';
CREATE TABLE IF NOT EXISTS image_assets (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL CHECK (scope IN ('global','school','personal')),
school_id BIGINT REFERENCES schools(id) ON DELETE CASCADE,
owner_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
folder TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
original_name TEXT,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
storage_key TEXT UNIQUE,
public_path TEXT UNIQUE,
tags TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK ((storage_key IS NULL) <> (public_path IS NULL)),
CHECK (
(scope = 'global' AND school_id IS NULL) OR
(scope = 'school' AND school_id IS NOT NULL AND owner_id IS NOT NULL) OR
(scope = 'personal' AND owner_id IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS idx_image_assets_owner ON image_assets(owner_id);
CREATE INDEX IF NOT EXISTS idx_image_assets_school ON image_assets(school_id);
CREATE INDEX IF NOT EXISTS idx_image_assets_folder ON image_assets(scope, folder);
CREATE TABLE IF NOT EXISTS image_asset_themes (
asset_id BIGINT NOT NULL REFERENCES image_assets(id) ON DELETE CASCADE,
theme_id BIGINT NOT NULL REFERENCES image_themes(id) ON DELETE CASCADE,
PRIMARY KEY (asset_id, theme_id)
);
CREATE INDEX IF NOT EXISTS idx_image_asset_themes_theme ON image_asset_themes(theme_id);
INSERT INTO image_themes (key, scope, name_nl, name_en)
VALUES
('builtin-money', 'global', 'Geld', 'Money'),
('builtin-coins', 'global', 'Munten', 'Coins'),
('builtin-banknotes', 'global', 'Briefgeld', 'Banknotes')
ON CONFLICT (key) DO NOTHING;
INSERT INTO image_folders (scope, path)
VALUES ('global','Rekenen'), ('global','Rekenen/Geld'),
('global','Rekenen/Geld/Munten'), ('global','Rekenen/Geld/Briefgeld')
ON CONFLICT DO NOTHING;
INSERT INTO image_assets (key, scope, folder, name, mime_type, public_path, tags)
VALUES
('builtin-money-coin-1', 'global', 'Rekenen/Geld/Munten', '1 cent', 'image/png', '/img/money/coin-1.png', ARRAY['geld','money','munt','coin','cent']),
('builtin-money-coin-2', 'global', 'Rekenen/Geld/Munten', '2 cent', 'image/png', '/img/money/coin-2.png', ARRAY['geld','money','munt','coin','cent']),
('builtin-money-coin-5', 'global', 'Rekenen/Geld/Munten', '5 cent', 'image/png', '/img/money/coin-5.png', ARRAY['geld','money','munt','coin','cent']),
('builtin-money-coin-10', 'global', 'Rekenen/Geld/Munten', '10 cent', 'image/png', '/img/money/coin-10.png', ARRAY['geld','money','munt','coin','cent']),
('builtin-money-coin-20', 'global', 'Rekenen/Geld/Munten', '20 cent', 'image/png', '/img/money/coin-20.png', ARRAY['geld','money','munt','coin','cent']),
('builtin-money-coin-50', 'global', 'Rekenen/Geld/Munten', '50 cent', 'image/png', '/img/money/coin-50.png', ARRAY['geld','money','munt','coin','cent']),
('builtin-money-coin-100', 'global', 'Rekenen/Geld/Munten', '1 euro', 'image/png', '/img/money/coin-100.png', ARRAY['geld','money','munt','coin','euro']),
('builtin-money-coin-200', 'global', 'Rekenen/Geld/Munten', '2 euro', 'image/png', '/img/money/coin-200.png', ARRAY['geld','money','munt','coin','euro']),
('builtin-money-note-500', 'global', 'Rekenen/Geld/Briefgeld', '5 euro', 'image/jpeg', '/img/money/note-500.jpg', ARRAY['geld','money','briefgeld','banknote','euro']),
('builtin-money-note-1000', 'global', 'Rekenen/Geld/Briefgeld', '10 euro', 'image/jpeg', '/img/money/note-1000.jpg', ARRAY['geld','money','briefgeld','banknote','euro']),
('builtin-money-note-2000', 'global', 'Rekenen/Geld/Briefgeld', '20 euro', 'image/jpeg', '/img/money/note-2000.jpg', ARRAY['geld','money','briefgeld','banknote','euro']),
('builtin-money-note-5000', 'global', 'Rekenen/Geld/Briefgeld', '50 euro', 'image/jpeg', '/img/money/note-5000.jpg', ARRAY['geld','money','briefgeld','banknote','euro'])
ON CONFLICT (key) DO NOTHING;
INSERT INTO image_asset_themes (asset_id, theme_id)
SELECT a.id, t.id
FROM image_assets a
JOIN image_themes t ON t.key = 'builtin-money'
WHERE a.key LIKE 'builtin-money-%'
ON CONFLICT DO NOTHING;
INSERT INTO image_asset_themes (asset_id, theme_id)
SELECT a.id, t.id
FROM image_assets a
JOIN image_themes t ON t.key = CASE
WHEN a.key LIKE 'builtin-money-coin-%' THEN 'builtin-coins'
ELSE 'builtin-banknotes'
END
WHERE a.key LIKE 'builtin-money-%'
ON CONFLICT DO NOTHING;

30
db/013_parent_portal.sql Normal file
View file

@ -0,0 +1,30 @@
-- v0.3.57-beta: ouderportaal.
-- Ouders (role 'parent', school_id NULL) koppelen aan kind-accounts (pupils):
-- * parent_children: de koppeling zelf (een kind kan meerdere ouders hebben).
-- * users.parent_code: eenmalige code waarmee staf een schoolleerling aan een
-- ouder koppelt (code vervalt na gebruik).
-- * school_link_requests: aanmeldverzoek van een thuiskind (pupil zonder
-- school) bij een school; de school handelt het verzoek af (adopteren,
-- koppelen aan een bestaande leerling of afwijzen).
CREATE TABLE IF NOT EXISTS parent_children (
parent_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
pupil_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (parent_id, pupil_id)
);
CREATE INDEX IF NOT EXISTS idx_parent_children_pupil ON parent_children(pupil_id);
ALTER TABLE users ADD COLUMN IF NOT EXISTS parent_code TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_parent_code
ON users (parent_code) WHERE parent_code IS NOT NULL;
CREATE TABLE IF NOT EXISTS school_link_requests (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
school_id BIGINT NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
parent_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
pupil_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (pupil_id, school_id)
);
CREATE INDEX IF NOT EXISTS idx_school_link_requests_school ON school_link_requests(school_id);

11
db/014_cms.sql Normal file
View file

@ -0,0 +1,11 @@
-- v0.3.83-beta: CMS voor de systeemmanager.
-- Kleine sleutel/waarde-opslag voor site-inhoud die zonder release aanpasbaar
-- moet zijn: welkomstwoord (inlogscherm), update-meldingen (banner) en
-- aanvullende widget-uitleg (galerij). De waarde is JSONB zodat elke sleutel
-- zijn eigen vorm heeft (bv. { nl, en } of { actief, tekst per taal, id }).
CREATE TABLE IF NOT EXISTS site_content (
key TEXT PRIMARY KEY,
value JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by BIGINT REFERENCES users(id) ON DELETE SET NULL
);

View file

@ -0,0 +1,33 @@
-- v0.3.95-beta: een gebruiker moet altijd te verwijderen zijn.
--
-- Alle foreign keys die naar users(id) verwijzen horen ON DELETE CASCADE te
-- zijn (of bewust SET NULL, zoals site_content.updated_by). Databases die zijn
-- ontstaan vóór een tabel z'n cascade kreeg houden echter de oude constraint:
-- `CREATE TABLE IF NOT EXISTS` werkt een bestaande tabel nooit bij. Op zo'n
-- database geeft het verwijderen van een gebruiker een foreign-key-fout (500
-- "serverfout"). Deze migratie zoekt elke FK naar users die NIET cascade en
-- NIET set-null is, en zet die alsnog op ON DELETE CASCADE - de bewuste
-- SET NULL-constraints blijven ongemoeid.
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT con.conname AS name,
con.conrelid::regclass::text AS tbl,
att.attname AS col
FROM pg_constraint con
JOIN pg_attribute att
ON att.attrelid = con.conrelid AND att.attnum = con.conkey[1]
WHERE con.contype = 'f'
AND con.confrelid = 'users'::regclass
AND array_length(con.conkey, 1) = 1 -- alle user-FK's zijn 1 kolom
AND con.confdeltype NOT IN ('c', 'n') -- niet cascade, niet set null
LOOP
EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', r.tbl, r.name);
EXECUTE format(
'ALTER TABLE %s ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES users(id) ON DELETE CASCADE',
r.tbl, r.name, r.col);
RAISE NOTICE 'FK % op %(%): ON DELETE CASCADE hersteld', r.name, r.tbl, r.col;
END LOOP;
END $$;

View file

@ -0,0 +1,28 @@
-- v0.4.1-beta: een klasnaam mag niet dubbel voorkomen binnen dezelfde school.
-- Zonder deze constraint kon "Nieuwe klas" herhaaldelijk dezelfde naam
-- aanmaken (bv. na een eerdere poging die door een andere bug leek te
-- mislukken, maar stiekem toch al aanmaakte) - de app-laag controleert dit nu
-- ook (POST /admin/classes), maar de database-constraint is het echte vangnet.
--
-- Bestaande dubbele klasnamen eerst uniek maken: anders faalt de CREATE
-- UNIQUE INDEX hieronder op een database die deze bug al heeft laten
-- ontstaan (en dan start de app helemaal niet meer op). Er wordt niets
-- verwijderd, alleen de latere dubbele naam hernoemd - de klas, z'n
-- leerlingen en koppelingen blijven ongemoeid.
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT id, name,
ROW_NUMBER() OVER (PARTITION BY school_id, lower(name) ORDER BY id) AS rn
FROM classes
LOOP
IF r.rn > 1 THEN
UPDATE classes SET name = r.name || ' (' || r.rn || ')' WHERE id = r.id;
END IF;
END LOOP;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS idx_classes_school_name
ON classes (school_id, lower(name));

View file

@ -0,0 +1,44 @@
-- v0.4.07-beta: schoolbeheerder (en systeemmanager) konden niet aan een klas
-- gekoppeld worden, met een verwarrende "groepsleiding en klas moeten bij
-- dezelfde school horen" - ook al staat dit in de applicatielaag (POST
-- /admin/classes/:id/teachers, src/api.js) al langer voor elke stafrol open.
--
-- Oorzaak: de trigger teach_validate_class_teacher() (uit 006) is destijds
-- geschreven toen alleen groepsleiding aan een klas gekoppeld kon worden, en
-- is nooit meegegroeid toen schoolbeheerder/systeemmanager daar later bij
-- kwamen - de trigger stond dus in de weg van iets wat de app allang toestond.
CREATE OR REPLACE FUNCTION teach_validate_class_teacher()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM classes c
JOIN users u ON u.id = NEW.user_id
WHERE c.id = NEW.class_id
AND (
-- systeemmanager is school-onafhankelijk, mag overal aan gekoppeld worden
u.role = 'super'
OR (
u.school_id = c.school_id
AND (
u.role IN ('teacher', 'admin')
OR EXISTS (
SELECT 1 FROM user_roles ur
WHERE ur.user_id = u.id AND ur.role IN ('teacher', 'admin')
)
)
)
)
) THEN
RAISE EXCEPTION 'alleen staf uit dezelfde school (of een systeemmanager) kan aan een klas gekoppeld worden';
END IF;
RETURN NEW;
END;
$$;
-- trigger zelf blijft ongewijzigd (verwijst naar de herdefinieerde functie),
-- alleen opnieuw aangemaakt zodat deze migratie op zichzelf leesbaar is
DROP TRIGGER IF EXISTS trg_class_teachers_tenant ON class_teachers;
CREATE TRIGGER trg_class_teachers_tenant
BEFORE INSERT OR UPDATE ON class_teachers
FOR EACH ROW EXECUTE FUNCTION teach_validate_class_teacher();

6
db/018_user_avatar.sql Normal file
View file

@ -0,0 +1,6 @@
-- v0.4.23: eigen personage (avatar) per gebruiker - soort, kleuren, haar en
-- accessoires als één JSON-object. NULL = nog geen personage gekozen, de
-- client toont dan een neutrale standaardweergave.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS avatar_data JSONB;

24
db/019_pupil_levels.sql Normal file
View file

@ -0,0 +1,24 @@
-- Klassenmanagement: per vakgebied (taal/rekenen/world) een niveaugroep
-- (groep 1-8, gebaseerd op de leerjaren van de basisschool, waaraan de
-- leerdoelen gekoppeld zijn) en een niveau (1-3, fijnmazige verfijning
-- BINNEN die groep - niet aan 3 geklemd in het datamodel zelf, widgets
-- klemmen voorlopig zelf nog wel). Zelfde vorm als assignments: precies
-- één van class_id/pupil_id gezet (klas-standaard, of een individuele
-- afwijking - in het speciaal onderwijs vaak vanaf het begin de norm),
-- nu met vakgebied als extra dimensie, dus één rij per (klas|leerling,
-- vakgebied) i.p.v. één rij per klas|leerling.
CREATE TABLE IF NOT EXISTS pupil_levels (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
class_id BIGINT REFERENCES classes(id) ON DELETE CASCADE,
pupil_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
subject TEXT NOT NULL,
year_group INT NOT NULL,
level INT NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (num_nonnulls(class_id, pupil_id) = 1),
CHECK (subject IN ('taal','rekenen','world')),
CHECK (year_group BETWEEN 1 AND 8),
CHECK (level >= 1)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_pupil_levels_class ON pupil_levels(class_id, subject) WHERE class_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_pupil_levels_pupil ON pupil_levels(pupil_id, subject) WHERE pupil_id IS NOT NULL;

View file

@ -0,0 +1,7 @@
-- Voortgang: optioneel detail per moment (welke som/opdracht, gegeven
-- antwoord, juist antwoord) - tot nu toe werd alleen een totaaltelling per
-- afgerond blok bewaard (bv. math.js: één rij per 10 sommen), waardoor het
-- onmogelijk was te zien WELKE opdracht fout ging. Nullable en puur
-- additief: bestaande rijen en widgets die geen detail meegeven blijven
-- gewoon werken met detail = null.
ALTER TABLE progress_events ADD COLUMN IF NOT EXISTS detail JSONB;

24
db/021_learning_paths.sql Normal file
View file

@ -0,0 +1,24 @@
ALTER TABLE assignments
ADD COLUMN IF NOT EXISTS title TEXT,
ADD COLUMN IF NOT EXISTS sequence JSONB NOT NULL DEFAULT '[]'::jsonb;
CREATE TABLE IF NOT EXISTS assignment_step_progress (
assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
pupil_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
step_id TEXT NOT NULL,
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (assignment_id, pupil_id, step_id)
);
CREATE INDEX IF NOT EXISTS idx_assignment_step_progress_pupil
ON assignment_step_progress (pupil_id, assignment_id);
ALTER TABLE users
ADD COLUMN IF NOT EXISTS reward_points INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS user_avatar_rewards (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reward_key TEXT NOT NULL,
unlocked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, reward_key)
);

View file

@ -0,0 +1,25 @@
-- Realtime leerlingactiviteit: een sessie start pas bij echte interactie met
-- een toegewezen widget. Heartbeats houden de sessie live; afgesloten en
-- weggevallen sessies blijven kort terug te zien in het klasdashboard.
CREATE TABLE IF NOT EXISTS live_learning_sessions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pupil_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
class_id BIGINT NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
assignment_id BIGINT REFERENCES assignments(id) ON DELETE SET NULL,
board_id TEXT NOT NULL,
widget_id TEXT NOT NULL,
widget_type TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'werken' CHECK (mode IN ('werken', 'kijken')),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
progress_count INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 0,
correct INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_live_learning_sessions_class_recent
ON live_learning_sessions (class_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_live_learning_sessions_pupil_open
ON live_learning_sessions (pupil_id, ended_at, last_seen_at DESC);

View file

@ -0,0 +1,25 @@
-- Bidirectionele live klasinteractie. Sessies onthouden het laatste echte
-- voortgangsmoment en een eventuele pauze. Leerkrachtacties worden duurzaam
-- opgeslagen, zodat een korte netwerkonderbreking geen hint of aanmoediging
-- laat verdwijnen.
ALTER TABLE live_learning_sessions
ADD COLUMN IF NOT EXISTS last_progress_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS teacher_pupil_actions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
class_id BIGINT NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
pupil_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('hint', 'encourage', 'pause', 'resume', 'easier')),
message TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
acknowledged_at TIMESTAMPTZ,
CHECK (message IS NULL OR char_length(message) <= 160)
);
CREATE INDEX IF NOT EXISTS idx_teacher_pupil_actions_pending
ON teacher_pupil_actions (pupil_id, acknowledged_at, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_teacher_pupil_actions_class
ON teacher_pupil_actions (class_id, created_at DESC);

View file

@ -0,0 +1,7 @@
-- Kleurplaten gebruiken dezelfde klas-standaard-met-leerling-override als de
-- andere vakgebieden. Bestaande niveaukeuzes blijven ongewijzigd; alleen de
-- toegestane waarde 'creatief' wordt aan de constraint toegevoegd.
ALTER TABLE pupil_levels DROP CONSTRAINT IF EXISTS pupil_levels_subject_check;
ALTER TABLE pupil_levels
ADD CONSTRAINT pupil_levels_subject_check
CHECK (subject IN ('taal','rekenen','world','creatief'));

View file

@ -19,10 +19,29 @@ services:
environment:
PORT: 3000
DATABASE_URL: ${DATABASE_URL}
APP_VERSION: ${IMAGE}
# APP_VERSION komt uit de image zelf (build-arg); hier niet overschrijven.
SUPER_USER: ${SUPER_USER:-beheerder}
SUPER_PASS: ${SUPER_PASS:-}
# Pangolin/Traefik -> nginx -> app: twee vertrouwde proxy-hops.
TRUST_PROXY_HOPS: ${TRUST_PROXY_HOPS:-2}
COOKIE_SECURE: "true"
ports:
# Alleen op localhost van de VM; nginx zit ervoor als reverse proxy
- "127.0.0.1:${APP_PORT:-3000}:3000"
volumes:
- image_data:/app/storage/images
restart: unless-stopped
web:
image: nginx:alpine
depends_on:
- app
ports:
# Bereikbaar voor Pangolin/Traefik via het VM-/containernetwerk.
# Beperk poort 8081 met de hostfirewall tot de proxy/tunnel.
- "${WEB_BIND_IP:-0.0.0.0}:${WEB_PORT:-8081}:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped
db:
@ -43,3 +62,4 @@ services:
volumes:
pgdata:
image_data:

57
deploy/nginx.conf Normal file
View file

@ -0,0 +1,57 @@
# Behoud het publieke schema van Pangolin/Traefik; val lokaal terug op nginx.
map $http_x_forwarded_proto $teach_forwarded_proto {
default $http_x_forwarded_proto;
"" $scheme;
}
server {
listen 80;
server_name _;
server_tokens off;
# Wat groter zodat grote borden/afbeeldingen niet geweigerd worden
client_max_body_size 55m;
# Gzip-vangnet: de app comprimeert zelf al (brotli/gzip via @fastify/compress)
# en nginx comprimeert nooit dubbel (responses mét Content-Encoding blijven
# ongemoeid) - dit vangt alleen wat de app mist. Let op: zonder
# gzip_proxied comprimeert nginx geproxyde responses helemaal niet.
gzip on;
gzip_proxied any;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/css application/javascript application/json image/svg+xml text/plain;
location /api/my/live-actions/ {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $teach_forwarded_proto;
}
location /api/progress/live/ {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $teach_forwarded_proto;
}
location / {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $teach_forwarded_proto;
}
}

View file

@ -0,0 +1,318 @@
# Curriculumkader kerndoelen primair onderwijs 2026
Laatst inhoudelijk gecontroleerd: 19 juli 2026
Broneditie: SLO, *Kerndoelen primair onderwijs*, versie juli 2026
Status van dit document: implementatiegericht werkdocument voor Teach
## Doel en afbakening
Dit document ordent de nieuwe kerndoelen voor het primair onderwijs per leergebied. De formuleringen hieronder zijn compacte samenvattingen voor productontwikkeling; de officiële publicatie blijft leidend voor interpretatie, ontwerp en verantwoording.
De juli-publicatie bevat veertig kerndoelen in negen leergebieden. SLO duidt ze nog aan als definitieve conceptkerndoelen: het ministerie van OCW legt ze vast in wet- en regelgeving. Volgens de actuele SLO-informatie verloopt de invoering gefaseerd en moeten alle scholen uiterlijk in augustus 2031 op de nieuwe kerndoelen zijn overgegaan.
Dit kader omvat de landelijke po-doelen. De aparte doelen voor Friese taal en cultuur en Nederlandse Gebarentaal vallen buiten deze inventarisatie en vragen later een eigen uitbreiding wanneer Teach die doelgroepen ondersteunt.
## TULE, inhoudslijnen en de nieuwe leerlijnen
TULE beschrijft een mogelijke uitwerking van de kerndoelen uit 2006 over groep 1 tot en met 8. Die uitwerking is voorbeeldmatig en geen methode, verplicht programma of bewijs dat een leerling een doel beheerst. De bijbehorende inhoudslijnen ordenen aanbod in drie fasen: jonge kind, fase 2 en fase 3.
De oude TULE-structuur kent zeven leergebieden: Nederlands, Engels, Friese taal, rekenen/wiskunde, oriëntatie op jezelf en de wereld, kunstzinnige oriëntatie en bewegingsonderwijs. De nieuwe landelijke set is anders geordend en voegt onder meer burgerschap en digitale geletterdheid als zelfstandige leergebieden toe. Ook wordt het oude brede gebied oriëntatie op jezelf en de wereld verdeeld over mens en maatschappij en mens en natuur.
SLO ontwikkelt nieuwe leerlijnen bij de kerndoelen van 2026. Nederlands en rekenen/wiskunde zijn in maart 2026 geconsulteerd. Voor de overige zeven leergebieden is de consultatie volgens de huidige planning voorzien voor april 2027. Daarom geldt voor Teach:
- gebruik de doelen uit 2026 als actuele inhoudelijke hoofdstructuur;
- gebruik TULE alleen als historische inspiratie voor opbouw en werkvormen;
- presenteer een zelfgemaakte niveau-indeling nooit als officiële SLO-leerlijn;
- voeg de nieuwe SLO-leerlijnen later als afzonderlijke, geversioneerde laag toe.
## Lees- en modelleringsregels
Een officieel kerndoel bestaat uit een kernzin, een of meer doelzinnen en een nadere uitwerking. De actor is de school of de leerling. Daarnaast onderscheidt SLO aanbod, beheersing, ervaring en combinaties daarvan. Dat verschil is essentieel voor widgets:
- een schooldoel mag niet als individuele leerlingprestatie worden gescoord;
- een beheersingsdoel kan bewijs opleveren via handelen, een product of een antwoord;
- een ervaringsdoel vraagt deelname, reflectie of expressie en geen simpele goed/fout-score;
- een widget die één onderdeel oefent, dekt niet automatisch het volledige kerndoel;
- activiteit, leerdoel en verzameld bewijs moeten expliciet op elkaar aansluiten.
De kolom **Teach nu** gebruikt drie waarden:
- **Deel**: een huidige widget oefent aantoonbaar een deel van het doel;
- **Steun**: een huidige widget kan de les ondersteunen, maar bevat geen volwaardige doelgerichte activiteit;
- **Open**: er is nog geen specifieke widgetdekking.
Geen van deze labels is een formele SLO-validering.
## Overzicht
| Leergebied | Kerndoelen | Domeinen |
| --- | ---: | --- |
| Nederlands | 19 | overkoepelend; communicatie; taal; literatuur |
| Rekenen en wiskunde | 1018 | wiskundige concepten; denk-werkwijzen; wiskunde en de wereld |
| Burgerschap | 1921 | democratische oefenplaats; democratische rechtsstaat; betrokkenheid |
| Digitale geletterdheid | 2224 | kennis en vaardigheden; ontwerpen en maken; gedigitaliseerde wereld |
| Mens en maatschappij | 2528 | vraagstukken en perspectieven; ruimte; tijd; samenleving |
| Mens en natuur | 2932 | natuurwetenschappen en technologie; natuur- en scheikunde/techniek; organismen en gezondheid; systeem aarde |
| Moderne vreemde talen: Engels | 3334 | communicatie; taal- en cultuurbewustzijn |
| Kunst en cultuur | 3537 | artistiek creatief vermogen; maken; meemaken |
| Bewegen en sport | 3840 | leren bewegen; samen bewegen; betrokken bewegen |
## 1. Nederlands
Bedoeling: leerlingen ontwikkelen taalcompetentie in samenhangende, betekenisvolle situaties. Begrijpen, produceren, gesprekken voeren, kennis van het taalsysteem en ervaringen met literatuur horen bij elkaar. Taalontwikkeling vindt bovendien in alle leergebieden plaats.
| ID | Domein | Samenvatting en onderdelen | Teach nu |
| --- | --- | --- | --- |
| 1 | Overkoepelend | De school bouwt taalcompetentie op via een rijke taal- en leesomgeving (1A) en taalontwikkeling in alle leergebieden (1B). | **Steun** — alle taalwidgets, anchor, media |
| 2 | Communicatie | Leerlingen begrijpen zakelijke en literaire teksten (2A), vergelijken en evalueren teksten voor dieper begrip (2B) en onderzoeken bronbetrouwbaarheid (2C). | **Steun** — anchor, mind, media |
| 3 | Communicatie | Leerlingen spreken en schrijven passend bij doel, publiek en context (3A), gebruiken taal creatief (3B) en schrijven om kennis en gedachten te ordenen (3C). | **Deel** — sentence, write, notes, mind |
| 4 | Communicatie | Leerlingen voeren doelgerichte gesprekken (4A) en gebruiken gesprekken om samen te denken en leren (4B). | **Steun** — anchor, mind, poll |
| 5 | Communicatie | Leerlingen kijken terug op taalactiviteiten, aanpak en ontwikkeling en leren bewust keuzes maken (5A). | **Steun** — taalwidgets geven feedback, maar vragen nog geen inhoudelijke reflectie |
| 6 | Taal | Leerlingen onderzoeken relaties tussen vorm en betekenis (6A) en passen spelling, formulering en interpunctie functioneel toe (6B). | **Deel** — letters, hak, sentence, wordtypes |
| 7 | Taal | Leerlingen onderzoeken hun talige identiteit en repertoire (7A) en variatie en verandering in taal (7B). | **Steun** — tweetalige woordcatalogus en taalkeuze |
| 8 | Literatuur | Leerlingen bouwen een eigen leesvoorkeur op (8A) en ervaren welke persoonlijke en maatschappelijke waarde literatuur kan hebben (8B). | **Open** — media kan alleen materiaal tonen |
| 9 | Literatuur | Leerlingen doorgronden verhalende teksten (9A) en herkennen en bespreken kenmerken van literaire genres (9B). | **Open** |
Ontwerpaccenten:
- breid taalwidgets uit van losse woorden en zinnen naar rijke teksten, bronnen en gesprekken;
- laat bron, doel, publiek en context als instelbare metadata terugkomen;
- voeg bij taalproducten een reflectiestap toe zonder creativiteit alleen met goed/fout te beoordelen;
- bouw een tekst- en literatuurwidget voor doelen 2, 8 en 9.
## 2. Rekenen en wiskunde
Bedoeling: leerlingen leren wiskundige concepten begrijpen en gebruiken, passende denk-werkwijzen inzetten en wiskunde herkennen en toepassen in de werkelijkheid en andere leergebieden.
| ID | Domein | Samenvatting en onderdelen | Teach nu |
| --- | --- | --- | --- |
| 10 | Wiskundige concepten | Redeneren en rekenen met gehele en decimale getallen (10A), breuken (10B) en verhoudingen, procenten en schaal (10C). | **Deel** — madd, msub, mmul, mdiv, mpct, numberline, fractions, placevalue |
| 11 | Wiskundige concepten | Meten, redeneren en rekenen met grootheden, eenheden en passende meetinstrumenten (11A). | **Deel** — clock, money; andere grootheden zijn open |
| 12 | Wiskundige concepten | Data verzamelen of ordenen, representeren, interpreteren en conclusies kritisch beoordelen (12A). | **Steun** — poll; nog geen leerlinggerichte data-analyse |
| 13 | Wiskundige concepten | Patronen en verbanden herkennen, beschrijven, voortzetten en met passende representaties onderzoeken (13A). | **Open** |
| 14 | Wiskundige concepten | Redeneren over figuren en plaatsbepaling en meetkundige transformaties uitvoeren (14A). | **Open** — write met raster is alleen gereedschap |
| 15 | Wiskundige denk-werkwijzen | Problemen oplossen (15A), situaties modelleren (15B) en algoritmes gebruiken en beschrijven (15C). | **Steun** — rekenwidgets oefenen uitkomsten, maar leggen aanpak en model nog nauwelijks vast |
| 16 | Wiskundige denk-werkwijzen | Wiskundetaal en representaties gebruiken en omzetten (16A), en wiskundige instrumenten beredeneerd kiezen en inzetten (16B). | **Deel** — numberline, fractions, clock, placevalue, money |
| 17 | Wiskunde en de wereld | Een onderzoekende houding ontwikkelen: precies, kritisch, volhardend en met vertrouwen wiskundig handelen (17A). | **Steun** — feedback en niveaus ondersteunen oefenen, maar meten de attitude niet |
| 18 | Wiskunde en de wereld | Wiskunde herkennen en toepassen in echte situaties (18A) en in andere leergebieden (18B). | **Deel** — money, clock, topography |
Ontwerpaccenten:
- voeg modules toe voor meten, data, patronen en meetkunde;
- registreer naast het antwoord ook gekozen representatie, hulpmiddel en eventueel strategie;
- gebruik realistische contexten zonder taalbelasting onnodig te verhogen;
- behandel een vlotte rekenscore niet als bewijs voor probleemoplossen of modelleren.
## 3. Burgerschap
Bedoeling: de school is een veilige democratische oefenplaats. Leerlingen leren de basis van de democratische rechtsstaat en diversiteit kennen en doen ervaring op met gezamenlijke besluitvorming en maatschappelijke betrokkenheid.
| ID | Domein | Samenvatting en onderdelen | Teach nu |
| --- | --- | --- | --- |
| 19 | Democratische oefenplaats | De school stimuleert sociale en maatschappelijke competenties en respectvolle, veilige interactie, offline en online (19A). | **Steun** — poll, mood, feelings, compliments, names, light |
| 20 | Samenleven in een democratische rechtsstaat | Leerlingen onderzoeken democratische basiswaarden en rechtsstatelijke beginselen (20A) en diversiteit in de samenleving (20B). | **Steun** — sociaal-emotionele widgets; inhoud over rechtsstaat ontbreekt |
| 21 | Democratische en maatschappelijke betrokkenheid | Leerlingen ervaren democratische deelname en invloed (21A) en maatschappelijke inzet en samenwerking (21B). | **Steun** — poll, mind, anchor; geen project- of actieworkflow |
Ontwerpaccenten:
- ontwerp een klasraad- of besluitvormingswidget met rollen, argumenten, stemvormen en reflectie;
- voorkom dat meerderheid van stemmen als enige vorm van democratie wordt gepresenteerd;
- leg bij doel 19 bewijs op school- of lesniveau vast, niet als leerlingranglijst;
- maak ruimte voor meerdere perspectieven, belangen, basiswaarden en minderheidsstemmen.
## 4. Digitale geletterdheid
Bedoeling: leerlingen leren digitale systemen en media functioneel en kritisch gebruiken, digitale producten ontwerpen en deelnemen aan de gedigitaliseerde wereld met aandacht voor veiligheid, privacy, zichzelf, anderen en de samenleving.
| ID | Domein | Samenvatting en onderdelen | Teach nu |
| --- | --- | --- | --- |
| 22 | Praktische kennis en vaardigheden | Digitale systemen functioneel inzetten (22A), doelgericht informatie zoeken en beoordelen (22B), data gebruiken (22C) en AI herkennen, gebruiken en kritisch onderzoeken (22D). | **Steun** — gebruik van Teach, media, mind, write; geen expliciete leerlijnactiviteit |
| 23 | Ontwerpen en maken | Digitale producten iteratief creëren (23A) en programmeerbare oplossingen ontwerpen, testen en verbeteren (23B). | **Steun** — bord, tekenen, write, mind, media; programmeren ontbreekt |
| 24 | De gedigitaliseerde wereld | Veiligheid en privacy afwegen (24A), invloed op jezelf en anderen onderzoeken (24B) en wisselwerking met samenleving en wereld beoordelen (24C). | **Open** |
Ontwerpaccenten:
- maak een digitale werkplaats met bestandstaken, zoeken, bronbeoordeling, data en AI-geletterdheid;
- voeg een blok- of stappenprogrammeermodule toe waarin ontwerpen, testen en verbeteren zichtbaar zijn;
- gebruik gesimuleerde persoonsgegevens en veilige scenarios; verzamel geen gevoelige leerlingdata om privacy te onderwijzen;
- scheid productvaardigheid van kritisch begrip van digitale technologie.
## 5. Mens en maatschappij
Bedoeling: leerlingen onderzoeken de menselijke leefwereld vanuit geografische, historische, economische en sociale invalshoeken. Ze leren vraagstukken, bronnen, perspectieven, waarden, belangen, ruimte, tijd en samenleving met elkaar verbinden.
| ID | Domein | Samenvatting en onderdelen | Teach nu |
| --- | --- | --- | --- |
| 25 | Vraagstukken en perspectieven | Maatschappelijke verschijnselen analyseren met meerdere denkwijzen (25A), vraagstukken vanuit bronnen en perspectieven onderzoeken (25B) en de schoolomgeving geografisch, historisch, economisch en sociaal verkennen (25C). | **Steun** — mind, anchor, poll, media |
| 26 | Mens en ruimte | Een wereldbeeld opbouwen met geografische bronnen, topografie en persoonlijke betekenis (26A), en wederzijdse invloed van menselijk handelen en leefomgeving onderzoeken (26B). | **Deel** — topography; sociale geografie en bronanalyse zijn nog beperkt |
| 27 | Mens en tijd | Historische ontwikkelingen en samenlevingen onderzoeken (27A), chronologie en tijdsindeling gebruiken (27B) en doorwerking van het verleden herkennen (27C). | **Open** |
| 28 | Mens en samenleving | Economische keuzes en gevolgen onderzoeken (28A), invloed en macht herkennen (28B), diversiteit begrijpen (28C), veilig en respectvol deelnemen aan de sociale omgeving (28D) en verantwoord deelnemen aan verkeer (28E). | **Steun** — money, sociaal-emotionele widgets en topography; doelgerichte inhoud ontbreekt grotendeels |
Ontwerpaccenten:
- behoud topography als oefenwidget, maar voeg kaarten als bron en verklarende vragen toe;
- bouw een tijdlijn- en bronnenwidget voor geschiedenis;
- gebruik één herbruikbare onderzoeksschil voor vraagstuk, bron, perspectief, waarde/belang, conclusie en presentatie;
- behandel verkeer, economie en sociale ontwikkeling als inhoudelijke leeractiviteiten, niet alleen als thema of illustratie.
## 6. Mens en natuur
Bedoeling: leerlingen onderzoeken natuurwetenschappelijke verschijnselen, technologie, organismen, gezondheid en systeem aarde. Waarnemen, vragen stellen, onderzoeken, ontwerpen, verklaren en waarderen worden in samenhang aangeboden.
| ID | Domein | Samenvatting en onderdelen | Teach nu |
| --- | --- | --- | --- |
| 29 | Natuurwetenschappen en technologie | Vraagstukken in de leefomgeving onderzoeken (29A), natuurwetenschappelijke en technologische denkwijzen gebruiken (29B), onderzoek- en ontwerpwerkwijzen uitvoeren (29C) en begrijpen hoe kennis en techniek tot stand komen (29D). | **Deel** — trees, birds, plants ondersteunen waarnemen en classificeren; onderzoek en ontwerpen zijn open |
| 30 | Natuurkundige en scheikundige verschijnselen en technische systemen | Werking en ontwerp van technische systemen onderzoeken (30A), stoffen en materiaaleigenschappen verkennen (30B) en verschijnselen rond licht, geluid, energie en krachten onderzoeken (30C). | **Open** |
| 31 | Organismen en gezondheid | Bouw, functies, levensprocessen en ordening van organismen onderzoeken (31A) en keuzes rond lichamelijke en mentale gezondheid begrijpen (31B). | **Deel** — trees, birds, plants; breathe, mood en feelings zijn alleen ondersteunend bij welzijn |
| 32 | Systeem aarde | Opbouw en verandering van de aarde onderzoeken (32A), weer, klimaat en waterkringloop begrijpen (32B) en relaties, biodiversiteit en menselijke invloed in ecosystemen verklaren (32C). | **Deel** — soortwidgets en topography; systemen, klimaat en voedselrelaties ontbreken |
Ontwerpaccenten:
- voeg aan de soortwidgets taxonomie, kenmerkenvergelijking, voedselrelaties en leefgebieden toe;
- bouw een onderzoekslab voor voorspellen, waarnemen/meten, resultaten vastleggen en verklaren;
- voeg veilige simulaties toe voor krachten, energie, stoffen, weer, water en ecosystemen;
- onderscheid feitkennis, onderzoeksvaardigheid en waardering van natuur in activiteiten en voortgang.
## 7. Moderne vreemde talen: Engels
Bedoeling: leerlingen ontwikkelen plezier en vertrouwen in eenvoudige communicatie in het Engels en worden zich bewust van hun taalleerproces, meertaligheid en culturele contexten.
| ID | Domein | Samenvatting en onderdelen | Teach nu |
| --- | --- | --- | --- |
| 33 | Communicatie | Eenvoudige Engelse teksten bekijken, beluisteren en lezen (33A), mondeling communiceren (33B) en eenvoudige schriftelijke communicatie gebruiken (33C). | **Deel** — tweetalige woordcatalogus, letters, flits, hak, ball, spraakondersteuning; gesprekken en rijke teksten zijn beperkt |
| 34 | Taal- en cultuurbewustzijn | Reflecteren op taalleren en meertaligheid (34A) en overeenkomsten, verschillen en passende communicatie in culturele contexten onderzoeken (34B). | **Steun** — taalwissel en tweetalige inhoud; interculturele activiteit ontbreekt |
Ontwerpaccenten:
- voeg luisterfragmenten, korte dialogen en functionele spreek- en schrijftaken toe;
- gebruik vertrouwde contexten en geleidelijke taalsteun;
- maak ruimte voor thuistalen en vergelijking zonder talen of accenten te rangschikken;
- voeg culturele context toe als onderzoek en ontmoeting, niet als verzameling stereotypen.
## 8. Kunst en cultuur
Bedoeling: leerlingen ontwikkelen artistiek creatief vermogen, maken eigen kunstzinnige uitingen en beleven en duiden kunst en cultuur in verschillende contexten.
| ID | Domein | Samenvatting en onderdelen | Teach nu |
| --- | --- | --- | --- |
| 35 | Artistiek creatief vermogen | In een iteratief maakproces creatieve strategieën gebruiken (35A), verbeelding en artistieke mogelijkheden ontwikkelen (35B) en betekenis geven aan eigen en andermans werk (35C). | **Steun** — tekenen, write, mind, media; geen begeleid artistiek proces |
| 36 | Maken en betekenis geven | Ideeën artistiek verbeelden en creëren (36A) en technieken, vaardigheden, materialen en vaktaal uit verschillende disciplines inzetten (36B). | **Steun** — bordtekenen, write, media; muziek, drama, dans en gerichte technieken ontbreken |
| 37 | Meemaken en betekenis geven | Kunst en cultuur actief beleven (37A) en verbinden aan maker, tijd, plaats, functie en andere context (37B). | **Open** — media kan bronnen tonen maar begeleidt beleving en duiding niet |
Ontwerpaccenten:
- ontwerp een creatief atelier met inspiratie, experiment, versie, presentatie en reflectie;
- ondersteun beeld, muziek, beweging, drama en combinaties daarvan;
- beoordeel proces, keuzes en betekenis; voorkom één norm voor wat een goed kunstwerk is;
- koppel bronnen aan maker, context, rechten en herkomst.
## 9. Bewegen en sport
Bedoeling: leerlingen ontwikkelen beweegvaardigheid, leren veilig en verantwoordelijk met anderen bewegen en onderzoeken welke beweegvormen, contexten en motieven bij hen passen.
| ID | Domein | Samenvatting en onderdelen | Teach nu |
| --- | --- | --- | --- |
| 38 | Leren bewegen | Deelnemen aan uiteenlopende beweegthemas (38A), beweegvaardigheid verbeteren (38B) en activiteiten passend maken bij eigen mogelijkheden (38C). | **Open** — timer, dice en names kunnen alleen organiseren |
| 39 | Samen bewegen | Verantwoordelijkheid nemen voor veilig en sociaal samen bewegen (39A) en activiteiten helpen opbouwen, begeleiden en regelen (39B). | **Open** — algemene klaswidgets bieden hooguit ondersteuning |
| 40 | Betrokken bewegen | Verschillende beweegcontexten, doelen en relaties met welbevinden verkennen (40A) en eigen voorkeuren, kwaliteiten en ontwikkeling onderzoeken (40B). | **Open** |
Ontwerpaccenten:
- maak een beweegactiviteitenwidget met veilige instructie, differentiatie en organisatievormen;
- registreer deelname en reflectie terughoudend; vermijd openbare ranglijsten en medische conclusies;
- laat leerlingen rollen kiezen en regels of materiaal passend aanpassen;
- ontwerp voor gebruik naast het bord: grote bediening, weinig leestijd en duidelijke fasen.
## Dekkingsbeeld en prioriteiten
Teach is op dit moment het sterkst in onderdelen van Nederlands, rekenen/wiskunde, topografie en organismenkennis. De grootste inhoudelijke hiaten zijn literatuur en bronbegrip, data/patronen/meetkunde, geschiedenis, natuurkundig en technologisch onderzoek, digitale geletterdheid, kunstbeleving en bewegen en sport.
Aanbevolen volgorde:
1. Voeg eerst curriculummetadata en doelkoppelingen toe zonder bestaand widgetgedrag te wijzigen.
2. Verdiep bestaande taal-, reken- en wereldwidgets waar zij al een duidelijk deeldoel raken.
3. Bouw herbruikbare schillen voor tekst/bronnen, onderzoeken en creëren.
4. Voeg ontbrekende inhoudswidgets toe voor geschiedenis, burgerschap, digitale geletterdheid, kunst en bewegen.
5. Verwerk officiële nieuwe SLO-leerlijnen pas na publicatie als een apart geversioneerd gegevenspakket.
## Technisch gegevensmodel voor widgets
Galerijcategorieën (taal, rekenen, world, class, tools en media) blijven bedoeld voor vindbaarheid. Curriculumkoppelingen worden een aparte many-to-many-laag, omdat bijvoorbeeld een poll tegelijk rekenen/data, taal/gesprek en burgerschap kan ondersteunen.
Aanbevolen doelrecord:
{
id: "slo-po-2026-10a",
framework: "slo-po-2026",
edition: "2026-07",
legalStatus: "definitief-concept",
learningArea: "rekenen-en-wiskunde",
domain: "wiskundige-concepten",
goal: 10,
component: "a",
actor: "leerling",
goalType: "beheersing",
summary: "Redeneren en rekenen met gehele en decimale getallen.",
source: {
publicationPage: 33,
url: "https://www.slo.nl/publish/pages/23186/kerndoelen-primair-onderwijs-slo-juli-2026.pdf"
}
}
Het veld goalType en de officiële detailinhoud moeten bij invoer per doelzin tegen de bron worden gecontroleerd; neem ze niet automatisch over uit het werkwoord van de samenvatting.
Aanbevolen widgetkoppeling:
{
widgetId: "fractions",
goalId: "slo-po-2026-10b",
relation: "exercise",
coverage: "partial",
activityModes: ["explore"],
evidence: ["constructed-representation"],
teacherNote: "Oefent breukrepresentaties; dekt niet alle relaties en berekeningen uit 10B."
}
Gebruik voor relation minimaal:
- exercise: de leerling handelt gericht met inhoud uit het doel;
- support: de widget ondersteunt instructie of gesprek;
- evidence: de activiteit kan passend bewijs van leren opleveren;
- context: het doel levert alleen een betekenisvolle context.
Voor voortgang zijn naast goalId ook activiteit, niveau, poging, resultaatsoort en bewijssoort nodig. Een numerieke score past niet bij elk doel. Bewaar bij open producten liever een productverwijzing, observatie of reflectie. Schooldoelen krijgen een aanbodregistratie op klas- of schoolniveau.
De huidige voortgangstaxonomie gebruikt hoofdzakelijk taal, rekenen en world. Breid die niet ad hoc uit met widgetcategorieën. Introduceer stabiele codes voor alle negen leergebieden en behoud aliassen voor bestaande gegevens:
nederlands
rekenen-en-wiskunde
burgerschap
digitale-geletterdheid
mens-en-maatschappij
mens-en-natuur
engels
kunst-en-cultuur
bewegen-en-sport
## Acceptatiecriteria voor curriculumkoppelingen
Een koppeling tussen widget en doel is pas gereed wanneer:
- doel-ID, broneditie, domein en actor zijn vastgelegd;
- duidelijk is welk onderdeel van het doel wordt geoefend of ondersteund;
- activiteit en feedback passen bij aanbod, beheersing of ervaring;
- dekkingsclaim expliciet partial of aantoonbaar vollediger is;
- niveau-opbouw als Teach-keuze of als officiële leerlijn is gelabeld;
- toegankelijkheid, inclusiviteit, privacy en mobiele bediening zijn beoordeeld;
- tests controleren dat doel-IDs geldig en bronversies bekend zijn;
- voortgang geen bredere beheersing suggereert dan het verzamelde bewijs toelaat.
## Bronnen
- SLO, [Kerndoelen primair onderwijs — versie juli 2026](https://www.slo.nl/publish/pages/23186/kerndoelen-primair-onderwijs-slo-juli-2026.pdf). Primaire bron voor de veertig doelen.
- SLO, [Kerndoelen primair onderwijs — publicatiepagina](https://www.slo.nl/publicaties/@25212/kerndoelen-primair-onderwijs/). Status en actuele editie.
- SLO, [Kerndoelen primair onderwijs — sectorinformatie](https://www.slo.nl/sectoren/po/kerndoelen/). Invoering, huidige doelen en relatie met TULE.
- SLO, [TULE inhouden en activiteiten](https://www.slo.nl/thema/meer/tule/). Historische uitwerking van de doelen uit 2006.
- SLO, [Inhoudslijnen primair onderwijs](https://www.slo.nl/sectoren/po/inhoudslijnen-po/). Fasering van de bestaande inhoudslijnen.
- SLO, [Ontwikkeling leerlijnen](https://www.slo.nl/thema/meer/kerndoelen-onderwijs/ontwikkeling-leerlijnen/). Status en planning van leerlijnen bij de nieuwe doelen.
- SLO, [Wijzigingen kerndoelenbundels](https://www.slo.nl/thema/meer/actualisatie-kerndoelen-examenprogramma/actualisatie-kerndoelen/wijzigingen-bundels-kerndoelen/). Correcties in de editie van juli 2026.

659
package-lock.json generated
View file

@ -8,7 +8,11 @@
"name": "teach",
"version": "0.1.0",
"dependencies": {
"@fastify/static": "^8.0.1",
"@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.1.1",
"@fastify/helmet": "^13.1.0",
"@fastify/rate-limit": "^11.1.0",
"@fastify/static": "^10.1.2",
"fastify": "^5.1.0",
"pg": "^8.13.1"
},
@ -53,6 +57,81 @@
"fast-uri": "^3.0.0"
}
},
"node_modules/@fastify/compress": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/@fastify/compress/-/compress-8.3.1.tgz",
"integrity": "sha512-BUpItLr6MUX9e9ukg5Y6xekyA/7pBFG8QWtFCrUDm9ctoBc3R2/nA16yOaOWtVoccpXGjdDEYA/MxAb5+8cxag==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"fastify-plugin": "^5.0.0",
"mime-db": "^1.52.0",
"minipass": "^7.0.4",
"peek-stream": "^1.1.3",
"pump": "^3.0.0",
"pumpify": "^2.0.1",
"readable-stream": "^4.5.2"
}
},
"node_modules/@fastify/compress/node_modules/fastify-plugin": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/cookie": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.1.tgz",
"integrity": "sha512-sJ0NXzGVYjUB4OynPZRsIcQ1mKSP4rW45xLCN0aelRq5Vl37xVVbz5kJ6Y0a9m2T0mCUjYCuvlUA9QlTafrZWw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"cookie": "^2.0.0",
"fastify-plugin": "^6.0.0"
}
},
"node_modules/@fastify/cookie/node_modules/cookie": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
"integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
"license": "MIT",
"engines": {
"node": ">=22"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@fastify/error": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
@ -104,6 +183,26 @@
],
"license": "MIT"
},
"node_modules/@fastify/helmet": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-13.1.0.tgz",
"integrity": "sha512-SvVOU0IrzYJW1BvSkfq9G1WUdW3dnaRUvg6m0BtgGMBmML62No0VmSu087jecH58SFbicbREgZTPJ89mAguupA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"fastify-plugin": "^6.0.0",
"helmet": "^8.0.0"
}
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
@ -143,6 +242,27 @@
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@fastify/rate-limit": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.1.0.tgz",
"integrity": "sha512-BeJ9tizLvmTXGD7deYU5G04OtHhwk5uHxbpEPVp09gKvUBIXmau/4Bshxhu9ci54MvVWfGjCEx4RzvsTntojwA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"fastify-plugin": "^6.0.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/@fastify/send": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
@ -167,9 +287,9 @@
}
},
"node_modules/@fastify/static": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.3.0.tgz",
"integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==",
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.2.tgz",
"integrity": "sha512-G/g18cG9tLutT/OVyN1AIsHIl9L1UwmJ+S3dkyhVpplIx0nEMicd7RGQ+uJLyhKKF4a3tTcQydccn3Mop1fX+Q==",
"funding": [
{
"type": "github",
@ -183,20 +303,12 @@
"license": "MIT",
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/error": "^4.0.0",
"@fastify/send": "^4.0.0",
"content-disposition": "^0.5.4",
"fastify-plugin": "^5.0.0",
"content-disposition": "^2.0.1",
"fastify-plugin": "^6.0.0",
"fastq": "^1.17.1",
"glob": "^11.0.0"
}
},
"node_modules/@isaacs/cliui": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
"glob": "^13.0.0"
}
},
"node_modules/@lukeed/ms": {
@ -214,6 +326,18 @@
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@ -291,28 +415,79 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/content-disposition": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cookie": {
@ -328,19 +503,11 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/depd": {
"version": "2.0.0",
@ -360,12 +527,81 @@
"node": ">=6"
}
},
"node_modules/duplexify": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
"integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.0.0",
"inherits": "^2.0.1",
"readable-stream": "^2.0.0",
"stream-shift": "^1.0.0"
}
},
"node_modules/duplexify/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/duplexify/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/duplexify/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
@ -412,9 +648,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"funding": [
{
"type": "github",
@ -461,9 +697,9 @@
}
},
"node_modules/fastify-plugin": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
"funding": [
{
"type": "github",
@ -486,9 +722,9 @@
}
},
"node_modules/find-my-way": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz",
"integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==",
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz",
"integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@ -499,44 +735,33 @@
"node": ">=20"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": ">=14"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "BlueOak-1.0.0",
"dependencies": {
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"node_modules/helmet": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
"license": "MIT",
"engines": {
"node": "20 || >=22"
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/http-errors": {
@ -559,6 +784,26 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -574,26 +819,11 @@
"node": ">= 10"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^9.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/json-schema-ref-resolver": {
"version": "3.0.0",
@ -658,9 +888,9 @@
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@ -678,6 +908,15 @@
"node": ">=10.0.0"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@ -711,19 +950,13 @@
"node": ">=14.0.0"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/path-scurry": {
@ -742,6 +975,17 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/peek-stream": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/peek-stream/-/peek-stream-1.1.3.tgz",
"integrity": "sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==",
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"duplexify": "^3.5.0",
"through2": "^2.0.3"
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
@ -907,6 +1151,21 @@
"node": ">=0.10.0"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
@ -923,12 +1182,75 @@
],
"license": "MIT"
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/pumpify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pumpify/-/pumpify-2.0.1.tgz",
"integrity": "sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw==",
"license": "MIT",
"dependencies": {
"duplexify": "^4.1.1",
"inherits": "^2.0.3",
"pump": "^3.0.0"
}
},
"node_modules/pumpify/node_modules/duplexify": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.4.1",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1",
"stream-shift": "^1.0.2"
}
},
"node_modules/pumpify/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
@ -1063,39 +1385,6 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@ -1123,6 +1412,21 @@
"node": ">= 0.8"
}
},
"node_modules/stream-shift": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
@ -1141,6 +1445,46 @@
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
"license": "MIT"
},
"node_modules/through2": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
"integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
"license": "MIT",
"dependencies": {
"readable-stream": "~2.3.6",
"xtend": "~4.0.1"
}
},
"node_modules/through2/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/through2/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/through2/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/toad-cache": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz",
@ -1159,20 +1503,17 @@
"node": ">=0.6"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",

View file

@ -9,10 +9,15 @@
},
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
"dev": "node --watch src/server.js",
"test": "node --test"
},
"dependencies": {
"@fastify/static": "^8.0.1",
"@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.1.1",
"@fastify/helmet": "^13.1.0",
"@fastify/rate-limit": "^11.1.0",
"@fastify/static": "^10.1.2",
"fastify": "^5.1.0",
"pg": "^8.13.1"
}

48
public/css/coloring.css Normal file
View file

@ -0,0 +1,48 @@
/* Thematische kleurplaten */
.coloring-app{height:100%;min-height:0;display:flex;flex-direction:column;background:var(--surface);color:var(--ink)}
.coloring-choices,.coloring-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--orange) 7%,var(--surface))}
.coloring-choices label{display:grid;grid-template-columns:auto minmax(120px,1fr);align-items:center;gap:6px;color:var(--muted);font-size:11px;font-weight:850}
.coloring-choices select{height:34px;max-width:230px;border:1px solid var(--line);border-radius:9px;padding:0 28px 0 9px;background:var(--surface);color:var(--ink);font:inherit;font-weight:750}
.coloring-fit{margin-left:auto;padding:5px 9px;border-radius:999px;background:var(--surface);color:var(--muted);font-size:11px;font-weight:800;white-space:nowrap}
.coloring-tools{gap:6px;background:var(--surface)}
.coloring-tool-buttons,.coloring-palette,.coloring-sizes{display:flex;align-items:center;gap:4px;padding:3px;border-radius:11px;background:var(--surface-2)}
.coloring-tools button{width:36px;height:36px;display:grid;place-items:center;border:0;border-radius:9px;background:transparent;color:var(--ink);font:inherit;font-size:18px;cursor:pointer}
.coloring-tools button:is(:hover,:focus-visible),.coloring-tools button.on{background:var(--surface);box-shadow:0 1px 5px rgba(30,50,90,.18)}
.coloring-tool-buttons button.on{outline:2px solid var(--orange);outline-offset:-2px}
.coloring-color i{width:22px;height:22px;display:block;border:1px solid rgba(38,52,74,.22);border-radius:50%;box-shadow:inset 0 0 0 1px rgba(255,255,255,.18)}
.coloring-color.on{outline:2.5px solid var(--ink);outline-offset:-3px}
.coloring-size i{display:block;border-radius:50%;background:var(--ink)}
.coloring-size.on{outline:2px solid var(--orange);outline-offset:-2px}
.coloring-custom{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:10px;font-weight:800}
.coloring-custom input{width:36px;height:36px;padding:3px;border:1px solid var(--line);border-radius:9px;background:var(--surface);cursor:pointer}
.coloring-tools .coloring-reset{margin-left:auto;background:var(--red-soft);color:var(--red)}
.coloring-tools .coloring-done{width:auto;min-width:112px;padding:0 12px;color:#fff;font-size:13px}
.coloring-stage{flex:1;min-height:0;overflow:auto;display:grid;place-items:center;padding:12px;background:linear-gradient(135deg,var(--surface-2),color-mix(in srgb,var(--purple) 5%,var(--surface)))}
.coloring-paper{position:relative;width:min(100%,900px);aspect-ratio:4/3;flex:none;overflow:hidden;border:1px solid var(--line);border-radius:16px;background:#fff;box-shadow:0 10px 28px rgba(30,50,90,.14);isolation:isolate}
.coloring-art,.coloring-paper canvas{position:absolute;inset:0;width:100%;height:100%}
.coloring-art svg{width:100%;height:100%;display:block;background:#fff}
.coloring-art [hidden]{display:none}
.coloring-svg-lines .coloring-detail{stroke-width:4.5;pointer-events:none}
.coloring-svg-lines .coloring-recognition{pointer-events:none}
.coloring-paper canvas{z-index:2;touch-action:none;cursor:crosshair;mix-blend-mode:multiply}
.coloring-app[data-tool="bucket"] .coloring-art{cursor:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Ctext y='24' font-size='23'%3E%F0%9F%AA%A3%3C/text%3E%3C/svg%3E") 6 24,pointer}
.coloring-app[data-tool="bucket"] .coloring-art [data-fillable]{pointer-events:all}
.coloring-hint{min-height:30px;padding:7px 11px;color:var(--muted);font-size:11px;font-weight:700;text-align:center;background:var(--surface)}
.widget[data-widget="coloring"]{--widget-accent:var(--orange)}
@media(max-width:620px){
.coloring-choices,.coloring-tools{padding:6px;gap:4px}
.coloring-choices label{grid-template-columns:1fr;gap:2px;flex:1;min-width:125px}
.coloring-choices select{width:100%;max-width:none;height:32px}
.coloring-fit{width:100%;margin:0;text-align:center}
.coloring-tools button{width:32px;height:32px;font-size:16px}
.coloring-color i{width:18px;height:18px}
.coloring-custom span{display:none}.coloring-custom input{width:32px;height:32px}
.coloring-tools .coloring-reset{margin-left:0}
.coloring-tools .coloring-done{min-width:90px;font-size:11px}
.coloring-stage{place-items:start center;padding:6px}
.coloring-paper{min-width:360px;border-radius:10px}
.coloring-hint{font-size:10px}
}
@media(pointer:coarse){.coloring-tools button,.coloring-custom input{min-width:40px;min-height:40px}.coloring-choices select{min-height:40px}}
@media(prefers-reduced-motion:reduce){.coloring-app *{scroll-behavior:auto!important}}

View file

@ -0,0 +1,44 @@
.image-catalog-modal{width:min(1080px,96vw);max-height:94vh}
/* titelbalk met ✕ blijft altijd in beeld, ook na scrollen in een lange grid */
.image-catalog-modal .gallery-head{position:sticky;top:-24px;z-index:4;background:var(--surface);padding:4px 0 8px;margin-top:-4px}
.ic-tools{display:grid;grid-template-columns:minmax(180px,1.5fr) repeat(3,minmax(130px,.8fr)) auto;gap:8px;position:sticky;top:12px;z-index:3;background:var(--surface);padding:8px 0 10px}
.ic-tools input,.ic-tools select,.ic-add select,.ic-add input,.ic-editor input,.ic-editor select{min-width:0;width:100%;border:1px solid var(--line);border-radius:10px;padding:9px 10px;background:var(--surface);color:var(--ink);font:inherit}
.ic-word-themes{display:flex;align-items:center;gap:7px;overflow-x:auto;padding:9px 10px;margin:0 0 10px;border:1px solid var(--line);border-radius:13px;background:linear-gradient(135deg,var(--surface-2),var(--accent-soft));scrollbar-width:thin}
.ic-word-themes strong{flex:none;margin-right:3px;font-size:12px;color:var(--muted)}
.ic-word-themes button{flex:none;border:1px solid var(--line);border-radius:999px;padding:7px 11px;background:var(--surface);color:var(--ink);font:inherit;font-size:12px;font-weight:850;cursor:pointer;transition:transform .12s,border-color .12s,background .12s}
.ic-word-themes button:hover{transform:translateY(-1px);border-color:var(--accent)}
.ic-word-themes button.active{border-color:var(--accent);background:var(--accent);color:#fff;box-shadow:var(--shadow-1)}
.ic-quota{padding:8px 12px;margin-bottom:10px;border-radius:10px;background:var(--accent-soft);color:var(--accent-ink);font-size:13px;font-weight:800}
.ic-add{border:1px solid var(--line);border-radius:12px;padding:10px 12px;margin-bottom:10px;background:var(--surface-2)}
.ic-add summary{cursor:pointer;font-weight:900}
.ic-add-grid,.ic-editor-grid{display:grid;grid-template-columns:1fr 1fr 1.2fr;gap:10px;margin-top:10px;align-items:end}
.ic-field{display:flex;flex-direction:column;gap:4px;font-size:12px;font-weight:800;color:var(--muted)}
.ic-field select[multiple]{min-height:88px}
.ic-upload{display:flex;align-items:center;justify-content:center;min-height:44px;margin:0;cursor:pointer;text-align:center;color:var(--ink)}
.ic-theme-new{display:flex;gap:8px;margin-top:10px}.ic-theme-new input{flex:1}
.ic-status{min-height:20px;font-size:13px;font-weight:800;color:var(--red);padding:2px 0}.ic-status.ok{color:var(--green)}
.ic-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(155px,1fr));gap:10px}
.ic-card{position:relative;min-width:0;border:1px solid var(--line);border-radius:14px;background:var(--surface);overflow:hidden;transition:border-color .15s,box-shadow .15s}
.ic-card:hover{border-color:var(--accent);box-shadow:var(--shadow-1)}
.ic-choose{display:grid;grid-template-rows:120px auto auto;width:100%;height:100%;gap:5px;padding:10px;border:0;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer}
.ic-choose img{width:100%;height:120px;object-fit:contain;border-radius:9px;background:#fff}
.ic-choose strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ic-choose span{font-size:11px;color:var(--muted);min-height:28px;overflow:hidden}
.ic-card-edit{position:absolute;right:7px;top:7px;width:32px;height:32px;border:1px solid var(--line);border-radius:9px;background:var(--surface);cursor:pointer;box-shadow:var(--shadow-1)}
.ic-editor{position:sticky;bottom:-24px;z-index:4;margin-top:12px;padding:14px;border:2px solid var(--accent);border-radius:14px;background:var(--surface);box-shadow:var(--shadow-2)}
.ic-editor h3{margin:0}.ic-editor-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:10px}
.ic-explorer-thumb{width:64px;height:56px;object-fit:contain;border-radius:8px;background:#fff}
.img-quota-admin{display:flex;flex-direction:column;gap:7px}.img-quota-row{display:grid;grid-template-columns:minmax(180px,1fr) 130px 130px auto;align-items:center}
.img-quota-users{display:flex;flex-direction:column;gap:6px;margin-top:8px}
body.hc .ic-card,body.hc .ic-add,body.hc .ic-tools input,body.hc .ic-tools select,body.hc .ic-editor{border-color:#000}
@media(max-width:760px){
/* dvh i.p.v. vh: met een dynamische browserbalk (telefoon) is 100vh hoger
dan het zichtbare scherm, waardoor titel en sluitknop wegvielen */
.image-catalog-modal{width:100vw;max-height:100dvh;height:100dvh;border-radius:0;padding:16px}
.image-catalog-modal .gallery-head{top:-16px}
.ic-tools{top:20px;grid-template-columns:1fr 1fr;padding-top:4px}.ic-search,.ic-folders{grid-column:1/-1}
.ic-word-themes{margin-inline:-2px;padding:8px}.ic-word-themes strong{position:sticky;left:0;z-index:1;padding:7px 6px;background:var(--surface-2)}
.ic-add-grid,.ic-editor-grid{grid-template-columns:1fr}.ic-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
.ic-choose{grid-template-rows:96px auto auto}.ic-choose img{height:96px}
.img-quota-row{grid-template-columns:1fr 1fr}.img-quota-row .am-name{grid-column:1/-1}
}
@media(max-width:420px){.ic-grid{grid-template-columns:1fr}.ic-editor-actions{flex-wrap:wrap}.ic-editor-actions .tbtn{flex:1}}

View file

@ -0,0 +1,89 @@
/* Grafisch live-klassenoverzicht */
#settingsModal .live-class-overview{display:grid;gap:14px;margin-top:10px}
#settingsModal .live-class-overview>.live-session-head{position:sticky;top:0;z-index:3;margin:0;padding:10px 12px;border:1px solid color-mix(in srgb,var(--blue) 30%,var(--line));border-radius:13px;background:color-mix(in srgb,var(--blue) 6%,var(--surface));box-shadow:0 5px 15px rgba(25,45,80,.06)}
#settingsModal .live-class-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
#settingsModal .live-class-metric{display:flex;align-items:center;gap:9px;padding:10px;border:1px solid var(--line);border-radius:12px;background:var(--surface)}
#settingsModal .live-class-metric strong{display:grid;width:35px;height:35px;place-items:center;border-radius:10px;background:var(--surface-2);font-size:18px}
#settingsModal .live-class-metric span{font-size:11px;font-weight:850}
#settingsModal .live-class-metric.active strong{color:var(--green)}
#settingsModal .live-class-metric.new strong{color:var(--blue)}
#settingsModal .live-class-metric.support strong{color:var(--orange)}
#settingsModal .live-class-metric.complete strong{color:var(--accent)}
#settingsModal .live-help-signals{padding:11px;border:1px solid color-mix(in srgb,var(--orange) 45%,var(--line));border-radius:13px;background:color-mix(in srgb,var(--orange) 9%,var(--surface))}
#settingsModal .live-help-signals>strong{display:block;margin-bottom:8px;font-size:12px}
#settingsModal .live-help-signals>div{display:flex;flex-wrap:wrap;gap:6px}
#settingsModal .live-help-chip{border:1px solid color-mix(in srgb,var(--orange) 45%,var(--line));border-radius:999px;padding:5px 9px;background:var(--surface);color:var(--ink);font:inherit;font-size:11px;font-weight:800;cursor:pointer}
#settingsModal .live-help-chip.errors{color:var(--red)}#settingsModal .live-help-chip.idle{color:var(--orange)}
#settingsModal .live-grid-controls{display:flex;align-items:center;justify-content:space-between;gap:10px}
#settingsModal .live-grid-controls .am-sel{width:auto;min-width:150px}
#settingsModal .live-class-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:9px}
#settingsModal .live-pupil-tile{overflow:hidden;border:1px solid var(--line);border-radius:14px;background:var(--surface);box-shadow:inset 4px 0 var(--line)}
#settingsModal .live-pupil-tile.active{border-color:color-mix(in srgb,var(--green) 40%,var(--line));box-shadow:inset 4px 0 var(--green)}
#settingsModal .live-pupil-tile.new{border-color:color-mix(in srgb,var(--blue) 45%,var(--line));box-shadow:inset 4px 0 var(--blue)}
#settingsModal .live-pupil-tile.support{border-color:color-mix(in srgb,var(--orange) 55%,var(--line));box-shadow:inset 4px 0 var(--orange)}
#settingsModal .live-pupil-tile.paused{border-color:color-mix(in srgb,var(--purple) 45%,var(--line));box-shadow:inset 4px 0 var(--purple)}
#settingsModal .live-pupil-tile.complete{box-shadow:inset 4px 0 var(--accent)}
#settingsModal .live-pupil-main{display:grid;width:100%;grid-template-columns:minmax(0,1fr) 76px;gap:5px 8px;padding:10px 10px 8px 14px;border:0;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer}
#settingsModal .live-pupil-main:hover,#settingsModal .live-pupil-main:focus-visible{background:var(--surface-2);outline:2px solid var(--accent-soft);outline-offset:-2px}
#settingsModal .live-pupil-state{grid-column:1/-1;justify-self:start;padding:2px 7px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:9px;font-weight:900;text-transform:uppercase}
#settingsModal .live-pupil-tile.active .live-pupil-state,#settingsModal .live-pupil-tile.new .live-pupil-state{background:color-mix(in srgb,var(--green) 14%,var(--surface));color:var(--green)}
#settingsModal .live-pupil-tile.support .live-pupil-state{background:color-mix(in srgb,var(--orange) 15%,var(--surface));color:var(--orange)}
#settingsModal .live-pupil-main>strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px}
#settingsModal .live-pupil-task{grid-column:1/-1;overflow:hidden;color:var(--muted);font-size:11px;text-overflow:ellipsis;white-space:nowrap}
#settingsModal .live-pupil-progress{align-self:center;height:6px;overflow:hidden;border-radius:99px;background:var(--surface-2)}
#settingsModal .live-pupil-progress i{display:block;height:100%;border-radius:inherit;background:var(--accent)}
#settingsModal .live-pupil-main small{grid-column:1;color:var(--muted);font-size:10px}
#settingsModal .live-sparkline{grid-column:2;grid-row:3/6;width:76px;height:28px;align-self:end}
#settingsModal .live-sparkline .line{fill:none;stroke:var(--blue);stroke-width:3;stroke-linecap:round;stroke-linejoin:round}
#settingsModal .live-sparkline .empty{stroke:var(--line);stroke-width:2;stroke-dasharray:3 3}
#settingsModal .live-action-toggle{width:100%;padding:6px 10px;border:0;border-top:1px solid var(--line);background:var(--surface-2);color:var(--accent-ink);font:inherit;font-size:10px;font-weight:900;cursor:pointer}
#settingsModal .live-action-row{display:grid;grid-template-columns:1fr 1fr;gap:5px;padding:7px;background:var(--surface-2)}
#settingsModal .live-action-row[hidden]{display:none}
#settingsModal .live-action-row button{min-width:0;padding:6px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--ink);font:inherit;font-size:10px;font-weight:800;cursor:pointer}
#settingsModal .live-action-row button:hover,#settingsModal .live-action-row button:focus-visible{border-color:var(--accent);outline:2px solid var(--accent-soft)}
#settingsModal .live-action-row button:disabled{opacity:.4;cursor:not-allowed}
#settingsModal .live-dashboard-message{min-height:17px;color:var(--accent-ink);font-size:11px;font-weight:800}
#settingsModal .live-graphics-grid{display:grid;grid-template-columns:minmax(230px,.8fr) minmax(330px,1.2fr);gap:10px}
#settingsModal .live-graphic-card{min-width:0;padding:12px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}
#settingsModal .live-graphic-card>strong{display:block;margin-bottom:10px;font-size:13px}
#settingsModal .live-funnel-row{display:grid;grid-template-columns:82px minmax(70px,1fr) 28px;align-items:center;gap:7px;margin:7px 0;font-size:10px}
#settingsModal .live-funnel-track{height:12px;overflow:hidden;border-radius:99px;background:var(--surface-2)}
#settingsModal .live-funnel-track i{display:block;height:100%;border-radius:inherit;background:hsl(calc(198 + var(--funnel-step)*18) 72% 51%)}
#settingsModal .live-funnel-row>strong{text-align:right}
#settingsModal .live-trend svg{display:block;width:100%;height:auto;max-height:190px}
#settingsModal .live-trend .grid{stroke:var(--line);stroke-width:1}.live-trend .axis{fill:var(--muted);font-size:9px}
#settingsModal .live-trend .completion{fill:color-mix(in srgb,var(--purple) 42%,transparent)}
#settingsModal .live-trend .accuracy{fill:none;stroke:var(--blue);stroke-width:4;stroke-linecap:round;stroke-linejoin:round}
#settingsModal .live-trend .accuracy-dot{fill:var(--surface);stroke:var(--blue);stroke-width:3}
#settingsModal .live-chart-legend{display:flex;justify-content:center;gap:15px;color:var(--muted);font-size:10px}
#settingsModal .live-chart-legend span:before{display:inline-block;width:9px;height:9px;margin-right:5px;border-radius:3px;background:var(--blue);content:""}
#settingsModal .live-chart-legend .completed:before{background:color-mix(in srgb,var(--purple) 55%,var(--surface))}
#settingsModal .live-timeline{display:grid;gap:6px}
#settingsModal .live-timeline-row{display:grid;grid-template-columns:minmax(90px,.7fr) minmax(130px,1fr) minmax(80px,1.1fr) 32px;align-items:center;gap:8px;width:100%;padding:7px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink);font:inherit;text-align:left;cursor:pointer}
#settingsModal .live-timeline-row:hover,#settingsModal .live-timeline-row:focus-visible{border-color:var(--accent);outline:2px solid var(--accent-soft)}
#settingsModal .live-timeline-row>strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px}
#settingsModal .live-timeline-row>span{overflow:hidden;color:var(--muted);font-size:10px;text-overflow:ellipsis;white-space:nowrap}
#settingsModal .live-timeline-track{height:8px;border-radius:99px;background:var(--surface-2)}
#settingsModal .live-timeline-track i{display:block;height:100%;border-radius:inherit;background:var(--line)}
#settingsModal .live-timeline-row.live .live-timeline-track i{background:var(--green)}
#settingsModal .live-timeline-duration{text-align:right}
/* Leerling: live bericht en echte pauze */
.pupil-live-notice{position:fixed;top:calc(var(--tbar) + 12px);left:50%;z-index:5900;max-width:min(560px,calc(100vw - 24px));transform:translateX(-50%);padding:11px 16px;border:2px solid var(--blue);border-radius:14px;background:var(--surface);box-shadow:var(--shadow-2);color:var(--ink);font-size:14px;font-weight:850;text-align:center}
.pupil-live-notice.encourage{border-color:var(--green)}.pupil-live-notice.easier{border-color:var(--purple)}
.pupil-live-notice[hidden],.pupil-pause-shield[hidden]{display:none}
.pupil-pause-shield{position:fixed;inset:var(--tbar) 0 0;z-index:5800;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:9px;padding:24px;background:color-mix(in srgb,var(--surface) 88%,transparent);backdrop-filter:blur(5px);color:var(--ink);text-align:center}
.pupil-pause-shield>span:first-child{font-size:46px}.pupil-pause-shield strong{font-size:22px}.pupil-pause-shield>span:last-child{max-width:440px;color:var(--muted);font-size:14px}
body.pupil-session-paused #pupilView .widget-body{pointer-events:none;user-select:none;filter:saturate(.45)}
@media(max-width:760px){
#settingsModal .live-class-metrics{grid-template-columns:1fr 1fr}
#settingsModal .live-graphics-grid{grid-template-columns:1fr}
#settingsModal .live-timeline-row{grid-template-columns:1fr 1fr}
#settingsModal .live-timeline-track{grid-column:1/-1}
}
@media(max-width:480px){
#settingsModal .live-class-grid{grid-template-columns:1fr}
#settingsModal .live-grid-controls{align-items:stretch;flex-direction:column}
#settingsModal .live-grid-controls .am-sel{width:100%}
}

2620
public/css/teach.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,4 @@
Quicksand - ontworpen door Andrew Paglinawan e.a.
Gelicenseerd onder de SIL Open Font License 1.1 (https://openfontlicense.org).
Bron: Google Fonts (https://fonts.google.com/specimen/Quicksand).
Dit bestand (quicksand.woff2) is het latin-subset van de variabele font (gewichten 300-700).

Binary file not shown.

48
public/img/logo.svg Normal file
View file

@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 104" role="img" aria-label="teachus.today uil">
<!-- laag-poly uil: vlakke facetten in het merk-palet, transparante achtergrond -->
<!-- oren -->
<polygon points="33,28 40,10 48,22" fill="#1d3461"/>
<polygon points="62,22 70,10 77,28" fill="#1d3461"/>
<!-- voorhoofd -->
<polygon points="40,10 70,10 62,22 48,22" fill="#2456b8"/>
<!-- gezicht -->
<polygon points="33,28 48,22 44,38" fill="#2b4d94"/>
<polygon points="77,28 62,22 66,38" fill="#2b4d94"/>
<polygon points="48,22 62,22 66,38 44,38" fill="#24407a"/>
<!-- wangen -->
<polygon points="33,28 44,38 36,46" fill="#2f6fe4"/>
<polygon points="77,28 66,38 74,46" fill="#2f6fe4"/>
<!-- ogen -->
<circle cx="48.5" cy="30.5" r="4.6" fill="#cfe0f5"/>
<circle cx="61.5" cy="30.5" r="4.6" fill="#cfe0f5"/>
<circle cx="48.5" cy="30.5" r="2.3" fill="#101d3a"/>
<circle cx="61.5" cy="30.5" r="2.3" fill="#101d3a"/>
<!-- snavel -->
<polygon points="55,36 58.5,41 55,46 51.5,41" fill="#f28c38"/>
<!-- linkervleugel: cyaan/teal -->
<polygon points="36,46 44,38 44,56" fill="#3ac2e8"/>
<polygon points="36,46 44,56 32,62" fill="#18c5c0"/>
<polygon points="32,62 44,56 40,74" fill="#66d4ef"/>
<polygon points="32,62 40,74 30,78" fill="#12a8a4"/>
<polygon points="30,78 40,74 38,88" fill="#18c5c0"/>
<!-- borst: roze/oranje -->
<polygon points="44,38 55,46 44,56" fill="#e83e8c"/>
<polygon points="55,46 66,38 66,56" fill="#f28c38"/>
<polygon points="44,56 55,46 58,64" fill="#c2257f"/>
<polygon points="55,46 66,56 58,64" fill="#f06ba8"/>
<polygon points="44,56 58,64 48,78" fill="#f5a55c"/>
<polygon points="58,64 66,56 68,74" fill="#e83e8c"/>
<polygon points="48,78 58,64 68,74" fill="#8b5cf6"/>
<polygon points="48,78 68,74 58,86" fill="#f28c38"/>
<!-- rechterflank: blauw/navy -->
<polygon points="66,38 74,46 66,56" fill="#2f6fe4"/>
<polygon points="66,56 74,46 76,66" fill="#1d3461"/>
<polygon points="66,56 76,66 68,74" fill="#2456b8"/>
<polygon points="68,74 76,66 70,84" fill="#1d3461"/>
<!-- tak -->
<polygon points="26,92 106,85 106,88.4 26,95.4" fill="#1d3461"/>
<!-- blaadjes -->
<polygon points="92,70 99,78 92,84 85,78" fill="#18c5c0"/>
<polygon points="104,77 110,83 104,88 98,83" fill="#3ac2e8"/>
<polygon points="96,90 102,95 96,100 90,95" fill="#12a8a4"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1,8 @@
# Bronnen euro-afbeeldingen
Deze afbeeldingen worden lokaal meegeleverd, zodat de geldwidget ook zonder internetverbinding werkt.
- **Munten (1, 2, 5, 10, 20 en 50 cent, €1 en €2):** actuele gemeenschappelijke zijden van de Europese Commissie, gedownload van [Common sides of euro coins](https://economy-finance.ec.europa.eu/euro/euro-coins-and-notes/euro-coins/common-sides-euro-coins_en) op 15 juli 2026. De tweedimensionale afbeeldingen zijn eigendom van de Europese Unie en worden hier educatief gebruikt volgens de reproductievoorwaarden van de Europese Commissie.
- **Bankbiljetten (€5, €10, €20 en €50):** voorzijde van de Europa-serie met Lagarde-handtekening uit het officiële [ECB-pakket met 72-dpi SPECIMEN-afbeeldingen](https://www.ecb.europa.eu/euro/shared/img/euro_banknotes_specimen_72dpi.zip), gedownload op 15 juli 2026. De zichtbare SPECIMEN-markering en originele 72-dpi-bestanden worden behouden volgens de [ECB-reproductieregels](https://www.ecb.europa.eu/euro/banknotes/images/html/index.en.html).
De bestandsnamen gebruiken centen als waarde, bijvoorbeeld coin-200.png voor €2 en note-500.jpg voor €5.

BIN
public/img/money/coin-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
public/img/money/coin-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
public/img/money/coin-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

View file

@ -0,0 +1,115 @@
# Wereldoriëntatie beeldbronnen
De lokale WebP-bestanden zijn uitsluitend verkleinde en, waar nodig, gekaderde
afgeleiden van de hieronder genoemde foto's. De soortnamen zijn gecontroleerd
aan de hand van de bestandsbeschrijvingen op Wikimedia Commons.
## Bomen
| Bestand | Bronwerk | Maker | Licentie |
| --- | --- | --- | --- |
| `trees/oak.webp` | [1. English Oak (Quercus robur)](https://commons.wikimedia.org/wiki/File:1._English_Oak_(Quercus_robur)_(3607652442).jpg) | AnemoneProjectors | [CC BY-SA 2.0](https://creativecommons.org/licenses/by-sa/2.0/) |
| `trees/birch.webp` | [Betula pendula winter](https://commons.wikimedia.org/wiki/File:Betula_pendula_winter.jpg) | Maplan | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `trees/horse-chestnut.webp` | [Paardenkastanje2](https://commons.wikimedia.org/wiki/File:Paardenkastanje2.jpg) | Toubib | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `trees/pine.webp` | [Pinus sylvestris Glenmuick](https://commons.wikimedia.org/wiki/File:Pinus_sylvestris_Glenmuick.jpg) | Hello, I am Bruce | [CC BY-SA 2.0](https://creativecommons.org/licenses/by-sa/2.0/) |
| `trees/beech.webp` | [Fagales Fagus sylvatica](https://commons.wikimedia.org/wiki/File:Fagales_-_Fagus_sylvatica.jpg) | Emőke Dénes | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `trees/maple.webp` | [Acer-pseudoplatanus](https://commons.wikimedia.org/wiki/File:Acer-pseudoplatanus.jpg) | MPF | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `trees/willow.webp` | [Salix alba an der Isar](https://commons.wikimedia.org/wiki/File:Salix_alba_an_der_Isar.jpg) | Burkhard Mücke | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `trees/alder.webp` | [Alnus glutinosa 011](https://commons.wikimedia.org/wiki/File:Alnus_glutinosa_011.jpg) | Willow | [CC BY 2.5](https://creativecommons.org/licenses/by/2.5/) |
| `trees/lime.webp` | [Winterlinde (Wiki Loves Earth 2015)](https://commons.wikimedia.org/wiki/File:012_2015_05_06_Winterlinde_(Wiki_Loves_Earth_2015).jpg) | Friedrich Haag | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `trees/ash.webp` | [Fraxinus excelsior 001](https://commons.wikimedia.org/wiki/File:Fraxinus_excelsior_001.jpg) | Willow | [CC BY 2.5](https://creativecommons.org/licenses/by/2.5/) |
| `trees/sweet-chestnut.webp` | [Anatolian sweet chestnut](https://commons.wikimedia.org/wiki/File:Castanea_sativa_-_Anatolian_sweet_chestnut_04.jpg) | Zeynel Cebeci | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `trees/rowan.webp` | [Rowan tree 20081002b](https://commons.wikimedia.org/wiki/File:Rowan_tree_20081002b.jpg) | Eeno11 | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `trees/hawthorn.webp` | [Crataegus monogyna in Joncret](https://commons.wikimedia.org/wiki/File:Crataegus_monogyna_in_Joncret_AR1aJPG.jpg) | Jean-Pol GRANDMONT | [CC BY 2.5](https://creativecommons.org/licenses/by/2.5/) |
| `trees/aspen.webp` | [Populus tremula 004](https://commons.wikimedia.org/wiki/File:Populus_tremula_004.jpg) | Willow | [CC BY-SA 2.5](https://creativecommons.org/licenses/by-sa/2.5/) |
| `trees/spruce.webp` | [Kuusk Keila-Paldiski rdt ääres](https://commons.wikimedia.org/wiki/File:Kuusk_Keila-Paldiski_rdt_%C3%A4%C3%A4res.jpg) | Ivar Leidus | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `trees/larch.webp` | [European larch Larix decidua](https://commons.wikimedia.org/wiki/File:European_larch_-_Larix_decidua.jpg) | MrPanyGoff | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `trees/yew.webp` | [Witchs Yew Tree](https://commons.wikimedia.org/wiki/File:2020_Witch%E2%80%99s_Yew_Tree_IRL.jpg) | Blarney Castle Estate | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `trees/elm.webp` | [Ulmus minor tree](https://commons.wikimedia.org/wiki/File:Ulmus_minor_tree.jpg) | Ptelea | [Publiek domein](https://commons.wikimedia.org/wiki/Commons:Copyright_tags#Public_domain) |
## Bladeren en naalden
| Bestand | Bronwerk | Maker | Licentie |
| --- | --- | --- | --- |
| `leaves/oak.webp` | [Zomereik blad Quercus robur](https://commons.wikimedia.org/wiki/File:Zomereik_blad_Quercus_robur.jpg) | Rasbak | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `leaves/birch.webp` | [Atlas roslin pl Brzoza brodawkowata 1393 6470](https://commons.wikimedia.org/wiki/File:Atlas_roslin_pl_Brzoza_brodawkowata_1393_6470.jpg) | Joanna Boisse | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `leaves/horse-chestnut.webp` | [Aesculus hippocastanum in botanical garden of UKW Bydgoszcz (2)](https://commons.wikimedia.org/wiki/File:Aesculus_hippocastanum_in_botanical_garden_of_UKW_Bydgoszcz_(2).jpg) | Tournasol7 | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) |
| `leaves/pine.webp` | [20151215Pinus sylvestris2](https://commons.wikimedia.org/wiki/File:20151215Pinus_sylvestris2.jpg) | AnRo0002 | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) |
| `leaves/beech.webp` | [20130530Hain Rotbuche](https://commons.wikimedia.org/wiki/File:20130530Hain_Rotbuche.jpg) | AnRo0002 | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) |
| `leaves/maple.webp` | [Acer pseudoplatanus 003](https://commons.wikimedia.org/wiki/File:Acer_pseudoplatanus_003.jpg) | Willow | [CC BY 2.5](https://creativecommons.org/licenses/by/2.5/) |
| `leaves/willow.webp` | [20160721Salix alba1](https://commons.wikimedia.org/wiki/File:20160721Salix_alba1.jpg) | AnRo0002 | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) |
| `leaves/alder.webp` | [20200531Alnus glutinosa2](https://commons.wikimedia.org/wiki/File:20200531Alnus_glutinosa2.jpg) | AnRo0002 | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) |
| `leaves/lime.webp` | [Herbarium Tilia cordata img-007](https://commons.wikimedia.org/wiki/File:2020_year._Herbarium._Tilia_cordata._img-007.jpg) | Dmitry Makeev | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `leaves/ash.webp` | [Herbarium Fraxinus img-015](https://commons.wikimedia.org/wiki/File:2020_year._Herbarium._Fraxinus._img-015.jpg) | Dmitry Makeev | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `leaves/sweet-chestnut.webp` | [Castanea sativa leaves and immature fruit](https://commons.wikimedia.org/wiki/File:Castanea_sativa_leaves_and_immature_fruit.jpg) | Ekir | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `leaves/rowan.webp` | [Herbarium Sorbus aucuparia img-005](https://commons.wikimedia.org/wiki/File:2020_year._Herbarium._Sorbus_aucuparia._img-005.jpg) | Dmitry Makeev | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `leaves/hawthorn.webp` | [Crataegus monogyna leaves and stipules](https://commons.wikimedia.org/wiki/File:(MHNT)_Crataegus_monogyna_-_Leaves_and_stipules.jpg) | Didier Descouens | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `leaves/aspen.webp` | [Herbarium Populus tremula img-017](https://commons.wikimedia.org/wiki/File:2020_year._Herbarium._Populus_tremula._img-017.jpg) | Dmitry Makeev | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `leaves/spruce.webp` | [Picea abies needles in Väsmestorp](https://commons.wikimedia.org/wiki/File:Picea_abies_needles_in_V%C3%A4smestorp_2024-10-29_(0261).jpg) | Achird | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `leaves/larch.webp` | [Larix decidua needles and male cones](https://commons.wikimedia.org/wiki/File:Larix_decidua_needles_and_male_cones.JPG) | Rosser1954 / Roger Griffith | [Publiek domein](https://commons.wikimedia.org/wiki/Commons:Copyright_tags#Public_domain) |
| `leaves/yew.webp` | [Yew berries](https://commons.wikimedia.org/wiki/File:Yew_berries_(50578142398).jpg) | hedera.baltica | [CC BY-SA 2.0](https://creativecommons.org/licenses/by-sa/2.0/) |
| `leaves/elm.webp` | [Elm leaves (Ulmus minor)](https://commons.wikimedia.org/wiki/File:Elm_leaves_(Ulmus_minor).jpg) | Daniel Capilla | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
## Vogels
| Bestand | Bronwerk | Maker | Licentie |
| --- | --- | --- | --- |
| `birds/robin.webp` | [Erithacus rubecula, Roodborstje](https://commons.wikimedia.org/wiki/File:Erithacus_rubecula,_Roodborstje.jpg) | Rasbak | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `birds/great-tit.webp` | [Parus major m](https://commons.wikimedia.org/wiki/File:Parus_major_m.jpg) | Sławek Staszczuk | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `birds/blackbird.webp` | [Solsort](https://commons.wikimedia.org/wiki/File:Solsort.jpg) | Malene Thyssen | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `birds/mallard.webp` | [Duisburg 10401IMG 0401](https://commons.wikimedia.org/wiki/File:Duisburg_10401IMG_0401.JPG) | Mbdortmund | [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/) |
| `birds/blue-tit.webp` | [Cyanistes caeruleus 3 Luc Viatour](https://commons.wikimedia.org/wiki/File:Cyanistes_caeruleus_3_Luc_Viatour.jpg) | Luc Viatour | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `birds/house-sparrow.webp` | [Huismus, man](https://commons.wikimedia.org/wiki/File:Huismus,_man.jpg) | JrPol | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `birds/magpie.webp` | [Сорока (Pica pica), Калининград](https://commons.wikimedia.org/wiki/File:%D0%A1%D0%BE%D1%80%D0%BE%D0%BA%D0%B0_(Pica_pica),_%D0%9A%D0%B0%D0%BB%D0%B8%D0%BD%D0%B8%D0%BD%D0%B3%D1%80%D0%B0%D0%B4.jpg) | Dion Art | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `birds/woodpecker.webp` | [Grote bonte specht](https://commons.wikimedia.org/wiki/File:Grote_bonte_specht.JPG) | Usitea | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `birds/chaffinch.webp` | [Fringilla coelebs chaffinch male](https://commons.wikimedia.org/wiki/File:Fringilla_coelebs_chaffinch_male_edit2.jpg) | MichaelMaggs / Arad | [CC BY-SA 2.5](https://creativecommons.org/licenses/by-sa/2.5/) |
| `birds/starling.webp` | [Sturnus vulgaris England standing](https://commons.wikimedia.org/wiki/File:Sturnus_vulgaris_-England_-standing-8.jpg) | Mark Robinson | [CC BY 2.0](https://creativecommons.org/licenses/by/2.0/) |
| `birds/grey-heron.webp` | [Grey heron 2022 04 03 03](https://commons.wikimedia.org/wiki/File:Grey_heron_2022_04_03_03.jpg) | Alexis Lours | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) |
| `birds/mute-swan.webp` | [Wild Mute swan at Lake Geneva](https://commons.wikimedia.org/wiki/File:093_Wild_Mute_swan_at_Lake_Geneva_during_sunset_Photo_by_Giles_Laurent.jpg) | Giles Laurent | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `birds/coot.webp` | [Fulica atra Common coot](https://commons.wikimedia.org/wiki/File:Fulica_atra_-_Common_coot,_Adana_2016-11-12_01-1.jpg) | Zeynel Cebeci | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `birds/greylag-goose.webp` | [Graugans Anser Anser](https://commons.wikimedia.org/wiki/File:Graugans_Anser_Anser.jpg) | H005 / Pro2 | [Publiek domein](https://commons.wikimedia.org/wiki/Commons:Copyright_tags#Public_domain) |
| `birds/collared-dove.webp` | [Eurasian Collared Dove](https://commons.wikimedia.org/wiki/File:Streptopelia_decaocto_-_Eurasian_Collared_Dove_05.jpg) | Zeynel Cebeci | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `birds/wren.webp` | [Eurasian wren](https://commons.wikimedia.org/wiki/File:Eurasian_wren_(Troglodytes_troglodytes).jpg) | Alexis Lours | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) |
| `birds/buzzard.webp` | [Buteo buteo juv Log](https://commons.wikimedia.org/wiki/File:Buteo_buteo_juv_Log_(2).jpg) | Yerpo | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `birds/barn-owl.webp` | [Tyto alba 1 Luc Viatour](https://commons.wikimedia.org/wiki/File:Tyto_alba_1_Luc_Viatour.jpg) | Luc Viatour | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
## Wilde planten
| Bestand | Bronwerk | Maker | Licentie |
| --- | --- | --- | --- |
| `plants/dandelion.webp` | [Paardenbloem (Taraxacum officinale)](https://commons.wikimedia.org/wiki/File:Paardenbloem_(Taraxacum_officinale)_06.JPG) | Dominicus Johannes Bergsma | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `plants/nettle.webp` | [Brandnetel, Urtica dioica](https://commons.wikimedia.org/wiki/File:Brandnetel,_Urtica_dioica,_Locatie,_Famberhorst.jpg) | Dominicus Johannes Bergsma | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `plants/daisy.webp` | [Bellis perennis full plant](https://commons.wikimedia.org/wiki/File:Bellis_perennis_full_plant.jpg) | Grand-Duc | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `plants/red-clover.webp` | [Trifolium pratense](https://commons.wikimedia.org/wiki/File:Trifolium_pratense_RF.jpg) | Robert Flogaus-Faust | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) |
| `plants/white-deadnettle.webp` | [Lamium album](https://commons.wikimedia.org/wiki/File:Lamium_album_RF.jpg) | Robert Flogaus-Faust | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) |
| `plants/cow-parsley.webp` | [Fluitenkruid (Anthriscus sylvestris)](https://commons.wikimedia.org/wiki/File:Fluitenkruid_(Anthriscus_sylvestris)_26-04-2020._(actm.)_02.jpg) | Agnes Monkelbaan | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `plants/yarrow.webp` | [Achillea millefolium](https://commons.wikimedia.org/wiki/File:Achillea_millefolium_(52354).jpg) | Rhododendrites | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `plants/ribwort-plantain.webp` | [Plantago lanceolata kz09](https://commons.wikimedia.org/wiki/File:Plantago_lanceolata_kz09.jpg) | Krzysztof Ziarnek, Kenraiz | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `plants/common-poppy.webp` | [Papaver rhoeas LC0050](https://commons.wikimedia.org/wiki/File:Papaver_rhoeas_LC0050.jpg) | Jörg Hempel | [CC BY-SA 2.5](https://creativecommons.org/licenses/by-sa/2.5/) |
| `plants/chicory.webp` | [Cichorium intybus](https://commons.wikimedia.org/wiki/File:Cichorium_intybus_TK_2021-07-12_1.jpg) | T. Kebert | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) |
| `plants/foxglove.webp` | [Digitalis purpurea LC0101](https://commons.wikimedia.org/wiki/File:Digitalis_purpurea_LC0101.jpg) | Jörg Hempel | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) |
| `plants/common-sorrel.webp` | [Rumex acetosa in Aveyron](https://commons.wikimedia.org/wiki/File:Rumex_acetosa_in_Aveyron_(7).jpg) | Tournasol7 | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) |
## Topografie
`netherlands-provinces.svg` is een vereenvoudigde afgeleide zonder labels van
[Netherlands provinces map blank.svg](https://commons.wikimedia.org/wiki/File:Netherlands_provinces_map_blank.svg)
door Gust Justice, gelicentieerd onder
[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/). De afgeleide
provinciecontouren worden onder dezelfde licentie verspreid.
De plaatsing van stadspunten is daarnaast gecontroleerd met
[Map provinces Netherlands-en.svg](https://commons.wikimedia.org/wiki/File:Map_provinces_Netherlands-en.svg)
door Alphathon ([CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/)).
`world-continents.svg` is gegenereerd uit de fysieke
[Natural Earth 1:110m Geography Regions Polygons](https://www.naturalearthdata.com/downloads/110m-physical-vectors/110m-physical-labels/)
en, voor de eilanden van Oceanië,
[Admin 0 Countries](https://www.naturalearthdata.com/downloads/110m-cultural-vectors/110m-admin-0-countries/).
Natural Earth stelt deze geografische brondata beschikbaar als publiek domein.
De lokale afgeleide bevat zeven afzonderlijk bedienbare werelddeelcontouren.
De vier Nederlandse landsdelen volgen de
[CBS-indeling voor Gebieden in Nederland 2026](https://www.cbs.nl/nl-nl/cijfers/detail/86247NED).
De selectie en loop van de grote rivieren zijn gecontroleerd aan de hand van het
[Rijkswaterstaat-overzicht Rivieren](https://www.rijkswaterstaat.nl/water/waterbeheer/beheer-en-ontwikkeling-rijkswateren/rivieren).

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Some files were not shown because too many files have changed in this diff Show more