diff --git a/.env.example b/.env.example index e657648..ab8591b 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.forgejo/workflows/dev.yaml b/.forgejo/workflows/dev.yaml index 0486c07..1e4a268 100644 --- a/.forgejo/workflows/dev.yaml +++ b/.forgejo/workflows/dev.yaml @@ -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 diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml index b2585a3..2d0956f 100644 --- a/.forgejo/workflows/release.yaml +++ b/.forgejo/workflows/release.yaml @@ -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 diff --git a/.gitignore b/.gitignore index 4c12cea..f54cbf6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ npm-debug.log* # Postgres data volume bij lokaal draaien pgdata/ +storage/ # OS / editor .DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..41e7702 --- /dev/null +++ b/AGENTS.md @@ -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 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..41e7702 --- /dev/null +++ b/CLAUDE.md @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 196fea0..65d5366 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 1609a30..cd5186c 100644 --- a/README.md +++ b/README.md @@ -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:`. +- **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= DATABASE_URL=postgres://teach:@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= 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://: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 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..034d744 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.4.66-beta diff --git a/compose.yaml b/compose.yaml index f1f33d9..b8f0e71 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/db/002_schools_auth.sql b/db/002_schools_auth.sql new file mode 100644 index 0000000..05bb747 --- /dev/null +++ b/db/002_schools_auth.sql @@ -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); diff --git a/db/003_password_state.sql b/db/003_password_state.sql new file mode 100644 index 0000000..0485c38 --- /dev/null +++ b/db/003_password_state.sql @@ -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; diff --git a/db/004_user_roles.sql b/db/004_user_roles.sql new file mode 100644 index 0000000..5497ab3 --- /dev/null +++ b/db/004_user_roles.sql @@ -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); diff --git a/db/005_assignments.sql b/db/005_assignments.sql new file mode 100644 index 0000000..9cff863 --- /dev/null +++ b/db/005_assignments.sql @@ -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; diff --git a/db/006_security_boundaries.sql b/db/006_security_boundaries.sql new file mode 100644 index 0000000..99fdbe9 --- /dev/null +++ b/db/006_security_boundaries.sql @@ -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(); diff --git a/db/007_progress.sql b/db/007_progress.sql new file mode 100644 index 0000000..efd35d2 --- /dev/null +++ b/db/007_progress.sql @@ -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); diff --git a/db/008_widget_share.sql b/db/008_widget_share.sql new file mode 100644 index 0000000..dd52cd6 --- /dev/null +++ b/db/008_widget_share.sql @@ -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'; diff --git a/db/009_save_system.sql b/db/009_save_system.sql new file mode 100644 index 0000000..13cded7 --- /dev/null +++ b/db/009_save_system.sql @@ -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); diff --git a/db/010_shared_library.sql b/db/010_shared_library.sql new file mode 100644 index 0000000..a89c3c9 --- /dev/null +++ b/db/010_shared_library.sql @@ -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); diff --git a/db/011_shared_readonly.sql b/db/011_shared_readonly.sql new file mode 100644 index 0000000..c95b4f6 --- /dev/null +++ b/db/011_shared_readonly.sql @@ -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; diff --git a/db/012_image_catalog.sql b/db/012_image_catalog.sql new file mode 100644 index 0000000..cd026e3 --- /dev/null +++ b/db/012_image_catalog.sql @@ -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; diff --git a/db/013_parent_portal.sql b/db/013_parent_portal.sql new file mode 100644 index 0000000..3ab038c --- /dev/null +++ b/db/013_parent_portal.sql @@ -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); diff --git a/db/014_cms.sql b/db/014_cms.sql new file mode 100644 index 0000000..10865bd --- /dev/null +++ b/db/014_cms.sql @@ -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 +); diff --git a/db/015_user_fk_cascade.sql b/db/015_user_fk_cascade.sql new file mode 100644 index 0000000..a6854a5 --- /dev/null +++ b/db/015_user_fk_cascade.sql @@ -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 $$; diff --git a/db/016_class_name_unique.sql b/db/016_class_name_unique.sql new file mode 100644 index 0000000..4dce14d --- /dev/null +++ b/db/016_class_name_unique.sql @@ -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)); diff --git a/db/017_class_teacher_roles.sql b/db/017_class_teacher_roles.sql new file mode 100644 index 0000000..8622e41 --- /dev/null +++ b/db/017_class_teacher_roles.sql @@ -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(); diff --git a/db/018_user_avatar.sql b/db/018_user_avatar.sql new file mode 100644 index 0000000..fbdf5ad --- /dev/null +++ b/db/018_user_avatar.sql @@ -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; diff --git a/db/019_pupil_levels.sql b/db/019_pupil_levels.sql new file mode 100644 index 0000000..9e2fd4f --- /dev/null +++ b/db/019_pupil_levels.sql @@ -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; diff --git a/db/020_progress_detail.sql b/db/020_progress_detail.sql new file mode 100644 index 0000000..ce3e69c --- /dev/null +++ b/db/020_progress_detail.sql @@ -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; diff --git a/db/021_learning_paths.sql b/db/021_learning_paths.sql new file mode 100644 index 0000000..feb55ac --- /dev/null +++ b/db/021_learning_paths.sql @@ -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) +); diff --git a/db/022_live_learning_sessions.sql b/db/022_live_learning_sessions.sql new file mode 100644 index 0000000..990a193 --- /dev/null +++ b/db/022_live_learning_sessions.sql @@ -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); diff --git a/db/023_live_classroom_interaction.sql b/db/023_live_classroom_interaction.sql new file mode 100644 index 0000000..fa68730 --- /dev/null +++ b/db/023_live_classroom_interaction.sql @@ -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); diff --git a/db/024_creative_pupil_levels.sql b/db/024_creative_pupil_levels.sql new file mode 100644 index 0000000..dd6170f --- /dev/null +++ b/db/024_creative_pupil_levels.sql @@ -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')); diff --git a/deploy/compose.deploy.yaml b/deploy/compose.deploy.yaml index 3682422..c796f0c 100644 --- a/deploy/compose.deploy.yaml +++ b/deploy/compose.deploy.yaml @@ -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: diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..a9df52a --- /dev/null +++ b/deploy/nginx.conf @@ -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; + } +} diff --git a/docs/curriculum/kerndoelen-po-2026.md b/docs/curriculum/kerndoelen-po-2026.md new file mode 100644 index 0000000..6b4c725 --- /dev/null +++ b/docs/curriculum/kerndoelen-po-2026.md @@ -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 | 1–9 | overkoepelend; communicatie; taal; literatuur | +| Rekenen en wiskunde | 10–18 | wiskundige concepten; denk-werkwijzen; wiskunde en de wereld | +| Burgerschap | 19–21 | democratische oefenplaats; democratische rechtsstaat; betrokkenheid | +| Digitale geletterdheid | 22–24 | kennis en vaardigheden; ontwerpen en maken; gedigitaliseerde wereld | +| Mens en maatschappij | 25–28 | vraagstukken en perspectieven; ruimte; tijd; samenleving | +| Mens en natuur | 29–32 | natuurwetenschappen en technologie; natuur- en scheikunde/techniek; organismen en gezondheid; systeem aarde | +| Moderne vreemde talen: Engels | 33–34 | communicatie; taal- en cultuurbewustzijn | +| Kunst en cultuur | 35–37 | artistiek creatief vermogen; maken; meemaken | +| Bewegen en sport | 38–40 | 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 scenario’s; 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 beweegthema’s (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-ID’s 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. diff --git a/package-lock.json b/package-lock.json index ae76ecf..fe25d0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c8da2c5..7276283 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/public/css/coloring.css b/public/css/coloring.css new file mode 100644 index 0000000..f6faeee --- /dev/null +++ b/public/css/coloring.css @@ -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}} diff --git a/public/css/image-catalog.css b/public/css/image-catalog.css new file mode 100644 index 0000000..11f5695 --- /dev/null +++ b/public/css/image-catalog.css @@ -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}} diff --git a/public/css/live-classroom.css b/public/css/live-classroom.css new file mode 100644 index 0000000..1bc2c76 --- /dev/null +++ b/public/css/live-classroom.css @@ -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%} +} diff --git a/public/css/teach.css b/public/css/teach.css new file mode 100644 index 0000000..41041ec --- /dev/null +++ b/public/css/teach.css @@ -0,0 +1,2620 @@ +/* teach - alle opmaak + Ontwerptaal "teachus.today": minimalistisch, vlakke kleuren uit het uil-logo, + ronde typografie (Quicksand), dunne lijnen, uniforme control-hoogtes. */ + @font-face{ + font-family:"Quicksand"; + src:url("../fonts/quicksand.woff2") format("woff2"); + font-weight:300 700; font-display:swap; + } + :root{ + /* logo-palet (vlakke kleuren, geen neon) */ + --navy:#1d3461; --blue:#2f6fe4; --blue-d:#2456b8; --teal:#18c5c0; + --cyan:#3ac2e8; --pink:#e83e8c; --purple:#8b5cf6; --orange:#f28c38; + --red:#e0554d; --green:#3fae6a; --yellow:#f7c948; + --wood:#f6e7c9; --wood-d:#e8d2a4; + /* semantisch (licht thema) */ + --bg:#f4f7fb; --surface:#ffffff; --surface-2:#eef2f8; --surface-3:#e3e9f4; + --panel:var(--surface); + --ink:#1d3461; --muted:#71809b; --line:#e2e8f2; + --accent:var(--teal); --accent-ink:#0d6e6b; --accent-soft:#e2f8f7; + --red-soft:#fbe3e1; + /* glas-laag (bord/widgets) */ + --glass:rgba(255,255,255,.55); --glass-strong:rgba(255,255,255,.92); + --glass-line:rgba(255,255,255,.6); + /* maten: alle "vakken" even hoog */ + --control-h:40px; --radius-s:8px; --radius:12px; --radius-l:18px; + --shadow-1:0 1px 3px rgba(29,52,97,.08); --shadow-2:0 12px 32px rgba(29,52,97,.16); + --shadow:var(--shadow-2); + --tbar:58px; + /* minimale tikzones voor touch (WCAG/iOS-richtlijn) */ + --tap:44px; --tap-sm:36px; + } + /* donker thema: zelfde tokens, andere waarden (schakelbaar in Instellingen) */ + body.dark{ + --bg:#0f1626; --surface:#18213a; --surface-2:#202b49; --surface-3:#2a3757; + --ink:#e8edf7; --muted:#8fa0bf; --line:#2c3a5e; + --accent-ink:#7fe6e2; --accent-soft:#153a44; --red-soft:#4a2330; + --glass:rgba(24,33,58,.6); --glass-strong:rgba(24,33,58,.92); + --glass-line:rgba(255,255,255,.08); + --shadow-1:0 1px 3px rgba(0,0,0,.35); --shadow-2:0 12px 32px rgba(0,0,0,.5); + } + *{box-sizing:border-box; -webkit-tap-highlight-color:transparent;} + *::-webkit-scrollbar{width:8px; height:8px;} + *::-webkit-scrollbar-thumb{background:#c3cfde; border-radius:4px;} + body.dark ::-webkit-scrollbar-thumb{background:#3a4a70;} + *::-webkit-scrollbar-track{background:transparent;} + /* 100% is de terugval voor oudere browsers; dvh volgt op telefoons de + daadwerkelijk zichtbare hoogte wanneer de browserbalk in- of uitschuift. + Doordat body een containing block is, gebruiken ook de absoluut + gepositioneerde bord-, leerling- en ouderweergaven deze zichtbare hoogte. */ + html,body{ + height:100%; height:100dvh; height:var(--app-height,100dvh); + margin:0; overflow:hidden; + } + body{ + position:relative; + font-family:"Quicksand","Segoe UI Rounded","Arial Rounded MT Bold",ui-rounded,"Segoe UI",Verdana,sans-serif; + background:var(--bg); color:var(--ink); user-select:none; + } + input,textarea{user-select:text;} + /* weergave-instelling "minder beweging" */ + body.no-motion *,body.no-motion *::before,body.no-motion *::after{ + transition-duration:0s !important; animation-duration:0s !important; + } + /* ---------- Menubalk ---------- */ + #toolbar{ + position:fixed; top:0; left:0; right:0; height:var(--tbar); z-index:5000; + background:var(--surface); border-bottom:1px solid var(--line); + display:flex; align-items:center; gap:8px; padding:0 14px; + overflow-x:auto; overflow-y:hidden; scrollbar-width:none; + } + #toolbar::-webkit-scrollbar{display:none;} + #zoomPct{min-width:56px; padding:0 8px; font-size:13px; text-align:center;} + #zoomPct.on{background:var(--accent-soft); color:var(--accent-ink);} + #btnZoomIn:disabled,#btnZoomOut:disabled{opacity:.35; cursor:default;} + /* logo: uil (SVG) + woordmerk "teachus.today" in merk-kleuren */ + .logo{display:flex; align-items:center; gap:9px; line-height:1; flex:none;} + .logo-owl{width:34px; height:30px; display:block;} + .logo-word{font-size:21px; font-weight:700; letter-spacing:.2px; white-space:nowrap;} + .logo-word .lw-teach{color:var(--ink);} + .logo-word .lw-us{color:var(--teal);} + .logo-word .lw-dot{color:var(--orange);} + .logo-word .lw-today{ + background:linear-gradient(90deg,var(--blue),var(--purple),var(--pink),var(--orange)); + -webkit-background-clip:text; background-clip:text; color:transparent; + } + #verTag{font-size:10.5px; font-weight:700; color:var(--muted);} + #verTag.beta{color:#fff; background:var(--orange); padding:1px 7px; border-radius:var(--radius-s);} + #toolbar .spacer{flex:1;} + .tbtn{ + border:none; border-radius:var(--radius); min-height:var(--control-h); + padding:0 14px; font:inherit; font-weight:700; + background:var(--accent); color:#fff; cursor:pointer; font-size:14.5px; + } + .tbtn:hover{filter:brightness(.94);} + .tbtn.ghost{background:var(--surface); color:var(--ink); border:1px solid var(--line);} + .tbtn.ghost:hover{background:var(--surface-3); filter:none;} + /* bewapende bevestigingsstap (reservekopie herstellen e.d.) */ + .tbtn.danger{background:var(--red, #d33); color:#fff; border-color:transparent;} + /* in de menubalk zelf zijn ghost-knoppen transparant (rustiger beeld) */ + #toolbar .tbtn.ghost{background:transparent; border:none;} + #toolbar .tbtn.ghost:hover{background:var(--surface-2);} + .tbtn:disabled{opacity:.55; cursor:default;} + #btnSave{min-width:150px; text-align:center;} + #btnSave.ok{background:var(--green); color:#fff;} + /* mislukte (auto)save: blijft rood staan tot een save wél lukt */ + #toolbar #btnSave.err{background:var(--red); color:#fff;} + #btnUser.on{background:var(--accent-soft); color:var(--accent-ink);} + #langToggle{display:flex; background:var(--surface-2); border-radius:999px; padding:3px; gap:2px;} + #langToggle button{ + border:none; border-radius:999px; padding:6px 13px; font:inherit; font-weight:700; + cursor:pointer; background:transparent; color:var(--muted); font-size:13.5px; + } + #langToggle button.active{background:var(--surface); color:var(--ink); box-shadow:var(--shadow-1);} + /* ---------- Board ---------- */ + #board{position:absolute; inset:var(--tbar) 0 0 0; overflow:hidden;} + #board.bg-dots{ + background-image:radial-gradient(circle,#c9d6e5 1.4px,transparent 1.4px); + background-size:26px 26px; + } + /* board background line options */ + #board.bg-blank{background-image:none;} + #board.bg-lines{background-image:repeating-linear-gradient(#0000 0 39px, #b9cbe3 39px 40px);} + #board.bg-write{background-image:repeating-linear-gradient( + #0000 0 19px,#cddcf0 19px 20px,#0000 20px 39px,#8aa8cf 39px 40.5px,#0000 40.5px 59px,#cddcf0 59px 60px,#0000 60px 79px);} + #board.bg-grid-s{background-image: + repeating-linear-gradient(#0000 0 24px,#cddcf0 24px 25px), + repeating-linear-gradient(90deg,#0000 0 24px,#cddcf0 24px 25px);} + #board.bg-grid-b{background-image: + repeating-linear-gradient(#0000 0 49px,#b9cbe3 49px 50px), + repeating-linear-gradient(90deg,#0000 0 49px,#b9cbe3 49px 50px);} + /* drawing layer on the board: sits ABOVE widgets like a transparency sheet, + so you can annotate over everything; clicks pass through when pen is off */ + #boardInk{position:absolute; inset:0; pointer-events:none; z-index:4000;} + /* live-laag van de markeerstift: dekkend getekend, doorschijnend getoond; + bij het loslaten wordt de haal in één keer op de inktlaag gezet */ + #boardMark{position:absolute; inset:0; pointer-events:none; z-index:4000; opacity:.32;} + body.pen #boardInk{pointer-events:auto; cursor:crosshair; touch-action:none;} + body.pen.pen-text #boardInk{cursor:text;} + body.pen.pen-hand #boardInk{pointer-events:none;} /* hand tool: interact with everything */ + /* ---------- Leerling-omgeving ---------- */ + /* geen whiteboard/tekengereedschap voor leerlingen - alleen wat de leerkracht + heeft toegewezen, als vaste (niet-sleepbare) kaarten in speelmodus */ + #pupilView{display:none;} + body.pupil-mode #board,body.pupil-mode #fab,body.pupil-mode #fabBoardsBtn, + body.pupil-mode #boardStrip,body.pupil-mode #fabmenu, + body.pupil-mode #hud,body.pupil-mode #btnFolders,body.pupil-mode #btnSave, + body.pupil-mode #btnCast{display:none !important;} + body.pupil-mode #pupilView{ + display:flex; flex-wrap:wrap; gap:18px; align-content:flex-start; + position:absolute; inset:var(--tbar) 0 0 0; overflow:auto; padding:18px; box-sizing:border-box; + } + #pupilView .pupil-card{ + position:static; max-width:100%; box-shadow:0 6px 20px rgba(30,50,90,.12); + } + .pupil-card .widget-body{width:100%; height:100%;} + /* kijken-modus: alle interactie geblokkeerd (generiek, werkt voor elk + widgettype zonder eigen readonly-ondersteuning) + zichtbaar meekijk-label */ + .pupil-card.watch{position:relative;} + .pupil-card.watch .widget-body{pointer-events:none; user-select:none;} + .pupil-watch-badge{ + position:absolute; top:8px; right:10px; z-index:2; pointer-events:none; + font-size:12.5px; font-weight:700; color:var(--muted); + background:var(--panel, rgba(255,255,255,.85)); border-radius:999px; padding:3px 10px; + box-shadow:0 2px 8px rgba(30,50,90,.15); + } + .pupil-empty{margin:auto; color:var(--muted); font-size:16px; font-weight:600;} + .pupil-path{flex:1 0 100%;display:grid;grid-template-columns:1fr auto;gap:5px 16px;max-width:860px;margin:0 auto;padding:14px 18px;box-sizing:border-box;border:1px solid var(--line);border-radius:16px;background:var(--surface);box-shadow:0 5px 18px rgba(30,50,90,.08)} + .pupil-path-title{font-size:18px;font-weight:900;color:var(--ink)} + .pupil-path-progress{font-size:13px;font-weight:800;color:var(--muted)} + .pupil-path-dots{grid-column:1/-1;display:flex;align-items:center;gap:7px;overflow:auto} + .pupil-path-dots span{width:28px;height:28px;flex:none;display:grid;place-items:center;border-radius:50%;background:var(--surface-2);border:2px solid var(--line);color:var(--muted);font-weight:900;font-size:12px} + .pupil-path-dots span.complete{background:var(--green);border-color:var(--green);color:#fff} + .pupil-path-dots span.current{border-color:var(--accent);color:var(--accent-ink);background:var(--accent-soft)} + .pupil-path-current{grid-column:1/-1;padding:7px 10px;border-radius:10px;background:var(--accent-soft);color:var(--accent-ink);font-size:12px;font-weight:850} + .pupil-path-actions{flex:1 0 100%;display:flex;justify-content:center;padding:4px 0 24px} + .pupil-path-actions button{min-height:44px;padding:0 22px} + .pupil-path-finish{position:relative;overflow:hidden;min-height:280px;flex:1 0 100%;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;text-align:center;border-radius:20px;background:linear-gradient(145deg,var(--accent-soft),var(--surface))} + .pupil-path-finish strong{font-size:clamp(24px,5vw,38px);color:var(--accent-ink)} + .pupil-path-unlocked{font-weight:900;color:var(--green)} + /* ---------- Ouderportaal ---------- */ + /* ouders zien geen whiteboard: alleen het portaal met hun kinderen */ + #parentView{display:none;} + body.parent-mode #board,body.parent-mode #fab,body.parent-mode #fabBoardsBtn, + body.parent-mode #boardStrip,body.parent-mode #fabmenu, + body.parent-mode #hud,body.parent-mode #btnFolders,body.parent-mode #btnSave, + body.parent-mode #btnCast{display:none !important;} + body.parent-mode #parentView{ + display:block; position:absolute; inset:var(--tbar) 0 0 0; + overflow:auto; padding:24px 18px; box-sizing:border-box; + } + .parent-inner{ + max-width:720px; margin:0 auto; display:flex; flex-direction:column; gap:10px; + } + .parent-title{margin:0 0 4px; font-size:22px;} + .parent-inner .am-row{flex-wrap:wrap;} + .parent-inner .am-row-tools{flex-basis:100%; display:flex; flex-direction:column; gap:8px;} + .parent-inner .am-row-tools:empty{display:none;} + /* floating add-button (FAB) */ + #fab{ + position:fixed; right:24px; bottom:24px; z-index:5600; width:60px; height:60px; + border-radius:50%; border:none; background:var(--blue); color:#fff; font-size:28px; + cursor:pointer; box-shadow:0 8px 24px rgba(59,125,216,.45); opacity:.8; + transition:all .2s; line-height:1; + } + #fab:hover{opacity:1; transform:scale(1.1);} + #fab.open{transform:rotate(45deg); opacity:1;} + body.board-empty #fab{opacity:1; animation:fabpulse 2s infinite;} + @keyframes fabpulse{ + 0%,100%{box-shadow:0 8px 24px rgba(59,125,216,.45);} + 50%{box-shadow:0 8px 38px rgba(59,125,216,.9); transform:scale(1.07);} + } + #fabmenu{ + position:fixed; right:24px; bottom:96px; z-index:5600; display:none; + flex-direction:column; gap:10px; align-items:flex-end; + } + /* board quick-switch: hover strip with live previews */ + #fabBoardsBtn{ + position:fixed; right:96px; bottom:31px; z-index:5590; width:46px; height:46px; + border-radius:50%; border:none; background:var(--purple); color:#fff; font-size:19px; + cursor:pointer; box-shadow:0 6px 18px rgba(155,89,182,.45); opacity:.85; + transition:all .2s; line-height:1; + } + #fabBoardsBtn:hover{opacity:1; transform:scale(1.1);} + #boardStrip{ + position:fixed; right:16px; bottom:92px; z-index:5590; display:none; gap:10px; + padding:10px; border-radius:16px; background:rgba(255,255,255,.93); + backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); + box-shadow:0 10px 28px rgba(30,50,90,.3); border:1px solid rgba(255,255,255,.7); + max-width:calc(100vw - 40px); overflow-x:auto; overflow-y:hidden; + } + #boardStrip.open{display:flex;} + .bs-card{ + width:168px; flex:none; background:var(--surface); border-radius:12px; + border:2.5px solid var(--line); cursor:pointer; overflow:hidden; + } + .bs-card:hover{border-color:var(--blue);} + .bs-card.active{border-color:var(--accent);} + .bs-card canvas{display:block; width:168px; height:105px; background:var(--surface);} + .bs-name{ + font-size:12px; font-weight:800; padding:5px 8px; display:flex; + justify-content:space-between; align-items:center; gap:6px; + } + .bs-name span{overflow:hidden; text-overflow:ellipsis; white-space:nowrap;} + .bs-x{ + border:none; background:var(--red-soft); color:var(--red); border-radius:7px; + min-width:26px; height:24px; font-size:11px; font-weight:900; cursor:pointer; flex:none; + } + .bs-x:hover{background:var(--red); color:#fff;} + .bs-x.arm{background:var(--red); color:#fff; animation:pop .3s;} + .bs-new{ + display:flex; align-items:center; justify-content:center; font-size:30px; + color:#fff; background:var(--green); border-color:var(--green); + width:90px; min-height:130px; font-weight:900; + } + /* mappenverkenner (Windows-stijl, vervangt het oude mappenpaneel) */ + .modal.explorer{width:min(860px,94vw);} + .ex-bar{display:flex; gap:10px; align-items:center; margin:10px 0 4px; flex-wrap:wrap;} + .ex-crumbs{display:flex; gap:4px; align-items:center; flex:1; min-width:0; flex-wrap:wrap;} + .ex-crumb{ + border:none; background:var(--surface-2); border-radius:8px; padding:6px 10px; + cursor:pointer; font:inherit; font-weight:700; font-size:13.5px; + } + .ex-crumb:hover{background:var(--surface-3);} + .ex-sep{color:var(--muted); font-weight:800;} + .ex-tools{display:flex; gap:8px; flex-wrap:wrap;} + .ex-msg{min-height:18px; color:var(--red); font-size:13px; font-weight:700;} + .ex-msg.ok{color:var(--green, #2c8c4b);} + .ex-hint{font-size:11px; color:var(--muted); font-weight:600;} + .ex-grid{ + display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); + gap:12px; margin-top:8px; + } + .ex-tile{ + border:1.5px solid var(--line); border-radius:14px; padding:10px; background:var(--surface); + display:flex; flex-direction:column; align-items:center; gap:6px; cursor:pointer; + user-select:none; touch-action:none; min-height:110px; justify-content:center; + } + .ex-tile:hover{border-color:var(--blue);} + .ex-tile.sel{border-color:var(--blue); background:var(--accent-soft);} + .ex-tile.active-item{border-color:var(--accent); border-width:2.5px;} + .ex-tile .ex-ico{font-size:36px; line-height:1;} + .ex-tile canvas{max-width:100%; border-radius:8px; border:1px solid var(--line);} + .ex-name{font-weight:700; font-size:13.5px; text-align:center; word-break:break-word;} + .ex-count{font-size:12px; color:var(--muted); font-weight:700;} + .ex-tile input{ + border:2px solid var(--blue); border-radius:8px; font:inherit; font-size:13.5px; + width:100%; outline:none; padding:4px 6px; text-align:center; + } + .ex-ghost{position:fixed; z-index:7000; opacity:.75; pointer-events:none; width:150px; left:-999px;} + .ex-drop{outline:3px solid var(--blue); outline-offset:2px;} + #fabmenu.open{display:flex;} + .fab-item{ + display:flex; align-items:center; gap:10px; border:none; + background:rgba(255,255,255,.92); backdrop-filter:blur(10px); + border-radius:14px; padding:11px 16px; font:inherit; font-weight:800; font-size:14px; + color:var(--ink); cursor:pointer; box-shadow:0 6px 18px rgba(30,50,90,.22); + } + .fab-item:hover{background:var(--surface); transform:translateX(-4px);} + .fab-item.on{background:var(--accent-soft); color:var(--accent-ink);} + .fab-item .fi{font-size:18px;} + /* pen palette (section inside #hud, always visible) */ + #penbar{display:flex; gap:6px; align-items:center; flex-wrap:wrap;} + .pen-close{background:var(--surface-2) !important; color:var(--ink) !important;} + .pen-close.on{background:var(--accent-soft) !important; color:var(--accent-ink) !important;} + body:not(.pen) .pen-tools button.on{background:transparent; box-shadow:none;} + .pen-grip{cursor:grab; font-size:15px; color:var(--muted); padding:0 2px; touch-action:none;} + .pen-grip:active{cursor:grabbing;} + #penbar .wp-group{padding:2px;} + #penbar .wp-group button{width:28px; height:28px; font-size:14px;} + #penbar .wp-color i{width:15px; height:15px;} + #penbar .wp-bg i{width:17px; height:17px;} + #penbar .wp-clear{width:28px; height:28px; font-size:13px;} + /* images on the board */ + .bimg{ + position:absolute; z-index:0; border-radius:10px; touch-action:none; cursor:grab; + box-shadow:0 6px 18px rgba(30,50,90,.25); + } + .bimg:active{cursor:grabbing;} + .bimg img{width:100%; display:block; border-radius:10px; pointer-events:none;} + .bimg .dtool-x,.bimg .bimg-rs{opacity:0; transition:opacity .15s;} + .bimg:hover .dtool-x,.bimg:hover .bimg-rs{opacity:1;} + .bimg-rs{ + position:absolute; right:-9px; bottom:-9px; width:20px; height:20px; border-radius:50%; + background:var(--blue); border:2.5px solid #fff; cursor:nwse-resize; + box-shadow:0 2px 6px rgba(0,0,0,.3); + } + body.pen .bimg{pointer-events:none;} + /* frameless (bare) widget for mindmap directly on the board */ + .widget.bare{ + background:transparent !important; border:none; box-shadow:none !important; + backdrop-filter:none; -webkit-backdrop-filter:none; resize:none; border-radius:0; + pointer-events:none; /* let clicks pass through to widgets/board below */ + } + .widget.bare .widget-head,.widget.bare .mm-top,.widget.bare .mm-node, + .widget.bare .mm-pop{pointer-events:auto;} + .widget.bare .widget-head{ + position:absolute; top:8px; right:8px; left:auto; width:auto; z-index:6; + background:rgba(255,255,255,.78); border:none; border-radius:12px; padding:5px 8px; gap:6px; + box-shadow:0 4px 12px rgba(30,50,90,.15); + } + .widget.bare .widget-head .wtitle{display:none;} + .widget.bare .widget-body,.widget.bare .mm-stage{background:transparent;} + /* mindmap node settings popover */ + .mm-cog{ + position:absolute; right:-11px; bottom:-11px; width:22px; height:22px; border-radius:50%; + border:none; background:var(--blue); color:#fff; font-size:11px; cursor:pointer; + line-height:1; display:none; padding:0; + } + .mm-node:hover .mm-cog{display:block;} + .mm-pop{ + position:absolute; z-index:20; background:var(--surface); border-radius:12px; padding:9px 10px; + box-shadow:0 10px 26px rgba(30,50,90,.3); display:flex; flex-direction:column; gap:7px; + } + .mm-pop .mp-row{display:flex; gap:5px; align-items:center;} + .mm-pop .mp-lbl{font-size:11px; font-weight:800; color:var(--muted); width:16px; text-align:center;} + .mm-pop button{border:none; cursor:pointer; padding:0; background:transparent;} + .mm-pop .mp-sw{width:19px; height:19px; border-radius:50%; border:2px solid #fff; box-shadow:0 0 0 1.5px var(--line);} + .mm-pop .mp-sw.on{box-shadow:0 0 0 2.5px var(--ink);} + .mm-pop .mp-fs{font-weight:900; color:var(--ink); width:24px; height:24px; border-radius:7px; background:var(--surface-2);} + .mm-pop .mp-fs.on{background:var(--accent-soft);} + .mm-pop .mp-ls{width:34px; height:24px; border-radius:7px; background:var(--surface-2); position:relative;} + .mm-pop .mp-ls.on{background:var(--accent-soft);} + .mm-pop .mp-ls i{display:block; border-top:2.5px solid var(--ink); width:24px; margin:0 auto;} + .mm-pop .mp-ls[data-ls=dash] i{border-top-style:dashed;} + .mm-pop .mp-ls[data-ls=dot] i{border-top-style:dotted;} + .mm-pop .mp-x{margin-left:auto; font-weight:900; color:var(--red); font-size:12px;} + /* physical tools: ruler, set square, magnifier */ + /* boven de inktlaag (4000): net als een echte lat ligt het gereedschap óp + het bord - ook in penmodus te verplaatsen en in te stellen; tekenen + begint ernaast en kleeft aan de tekenrand */ + .dtool{position:absolute; z-index:4200; touch-action:none; user-select:none; cursor:grab; left:120px; top:120px;} + .dtool:active{cursor:grabbing;} + .dtool .dtool-x{ + position:absolute; top:-10px; right:-10px; width:24px; height:24px; border-radius:50%; + border:none; background:var(--red-soft); color:var(--red); font-weight:900; cursor:pointer; + font-size:12px; line-height:1; box-shadow:0 2px 6px rgba(0,0,0,.2); z-index:2; + } + .dtool .dtool-rot{ + position:absolute; bottom:-10px; right:-10px; width:26px; height:26px; border-radius:50%; + border:none; background:var(--blue); color:#fff; cursor:grab; font-size:13px; + line-height:1; box-shadow:0 2px 6px rgba(0,0,0,.25); z-index:2; + } + .dtool-ruler{width:600px; height:76px;} + .dtool-geo{width:340px; height:184px;} + /* alleen de driehoek zelf is aan te raken - de lege hoeken van de + rechthoekige box laten aanrakingen door naar het bord, anders kon er + niet vlak naast de schuine randen getekend worden */ + .dtool-geo{pointer-events:none;} + .dtool-geo svg polygon,.dtool-geo .dtool-opts,.dtool-geo .dtool-x,.dtool-geo .dtool-rot{pointer-events:auto;} + .dtool-opts{position:absolute; top:-12px; left:8px; display:flex; gap:5px;} + .dtool-opts button{ + width:24px; height:24px; border-radius:50%; border:none; background:var(--surface); + color:var(--ink); font-size:12px; font-weight:900; cursor:pointer; line-height:1; + box-shadow:0 2px 6px rgba(0,0,0,.25); + } + .dtool-opts button:hover{background:var(--accent-soft);} + /* lengteknop toont de maat ("10 cm") en is dus breder dan een rond knopje */ + .dtool-opts .do-wide{width:auto; min-width:48px; padding:0 9px; border-radius:12px;} + /* slot-knop kleurt mee zodra het gereedschap vaststaat */ + .dtool-opts .do-lock.on{background:var(--accent-soft);} + /* ---------- CMS: banner, welkomstwoord en Inhoud-sectie ---------- */ + .cms-banner{ + position:fixed; top:var(--tbar); left:0; right:0; z-index:5800; + display:flex; gap:10px; align-items:center; justify-content:center; + padding:9px 44px 9px 14px; font-weight:700; font-size:14px; text-align:center; + background:var(--accent-soft); color:var(--accent-ink); + box-shadow:0 3px 10px rgba(30,50,90,.12); + } + .cms-banner.warn{background:var(--orange); color:#fff;} + .cms-banner-x{ + position:absolute; right:8px; top:50%; transform:translateY(-50%); + width:28px; height:28px; border:none; border-radius:8px; cursor:pointer; + background:rgba(255,255,255,.35); color:inherit; font-weight:900; line-height:1; + } + #cmsWelcome{ + margin:2px 0 12px; padding:10px 12px; border-radius:12px; + background:var(--accent-soft); color:var(--accent-ink); + font-size:13.5px; font-weight:600; line-height:1.45; white-space:pre-line; + } + .cms-block{display:flex; flex-direction:column; gap:8px; align-items:flex-start;} + .cms-field{display:flex; flex-direction:column; gap:3px; width:100%;} + .cms-ta{width:100%; min-height:64px; resize:vertical; font:inherit; padding:8px 10px;} + .cms-row{display:flex; gap:12px; align-items:center; flex-wrap:wrap;} + .cms-check{display:flex; align-items:center; gap:6px; font-weight:700; cursor:pointer;} + /* live hoek-uitlezing tijdens het draaien van liniaal/geodriehoek */ + .dtool-deg{ + display:none; position:absolute; left:50%; top:50%; margin:-16px 0 0 -24px; + width:48px; height:32px; line-height:32px; text-align:center; pointer-events:none; + background:rgba(30,50,90,.85); color:#fff; font-size:14px; font-weight:800; + border-radius:9px; z-index:3; + } + .dtool-deg.snapped{background:var(--green);} + .dtool-lens{z-index:4500;} /* the glass sits above the ink sheet */ + .dtool-lens canvas{ + border:5px solid #5a6c85; box-shadow:0 8px 22px rgba(0,0,0,.3); + background:var(--surface); display:block; + } + /* =========== Mindmap widget =========== */ + .mm{flex:1; min-height:0; display:flex; flex-direction:column; position:relative;} + .mm-top{ + position:absolute; top:8px; left:8px; z-index:7; display:flex; gap:8px; align-items:center; + padding:6px 8px; background:rgba(255,255,255,.85); backdrop-filter:blur(8px); + border-radius:12px; box-shadow:0 4px 12px rgba(30,50,90,.15); cursor:default; + } + .mm-grip{cursor:grab; color:var(--muted); font-size:14px; padding:0 2px; touch-action:none; user-select:none;} + .mm-grip:active{cursor:grabbing;} + .mm-hint{font-size:12px; font-weight:700; color:var(--muted); max-width:180px;} + .mm-stage{flex:1; min-height:0; position:relative; overflow:hidden; background:var(--surface);} + .mm-stage svg{position:absolute; inset:0; width:100%; height:100%; pointer-events:none;} + .mm-node{ + position:absolute; transform:translate(-50%,-50%); background:var(--surface); + border:2.5px solid var(--blue); padding:8px 16px; font-weight:800; font-size:15px; + cursor:grab; touch-action:none; min-width:64px; text-align:center; + box-shadow:0 3px 10px rgba(30,50,90,.15); border-radius:50px; + } + .mm-node:active{cursor:grabbing;} + .mm-node span{outline:none; cursor:text; display:inline-block; min-width:34px; user-select:text;} + .mm-node span:empty::before{content:"…"; color:#c2cddc;} + .mm-node.root{background:var(--yellow); border-color:#d9a400; font-size:17px; z-index:2;} + .mm.tree .mm-node{border-radius:10px;} + .mm.web .mm-node{border-radius:16px; border-style:dashed;} + .mm-node .mm-add,.mm-node .mm-del{ + position:absolute; width:22px; height:22px; border-radius:50%; border:none; + font-size:13px; font-weight:900; cursor:pointer; line-height:1; display:none; padding:0; + } + .mm-node:hover .mm-add,.mm-node:hover .mm-del{display:block;} + .mm-add{right:-11px; top:50%; transform:translateY(-50%); background:var(--green); color:#fff;} + .mm-del{left:-9px; top:-9px; background:var(--red-soft); color:var(--red);} + /* ---------- Widget windows ---------- */ + .widget{ + position:absolute; background:rgba(255,255,255,.55); border-radius:var(--radius); + backdrop-filter:blur(14px) saturate(1.15); -webkit-backdrop-filter:blur(14px) saturate(1.15); + box-shadow:0 4px 14px rgba(30,50,90,.08); border:1px solid rgba(255,255,255,.6); + display:flex; flex-direction:column; + min-width:280px; min-height:200px; resize:none; overflow:hidden; + transition:background .2s, box-shadow .2s, border-color .2s; + } + .widget:hover,.widget:focus-within{ + background:rgba(255,255,255,.92); + box-shadow:0 12px 30px rgba(30,50,90,.2); + border-color:rgba(255,255,255,.9); + } + .widget .wmin,.widget .wclose{opacity:.4; transition:opacity .15s;} + .widget:hover .wmin,.widget:hover .wclose, + .widget:focus-within .wmin,.widget:focus-within .wclose{opacity:1;} + /* eigen, groter aanraakbare resize-greep i.p.v. de piepkleine native + browser-grip (die met resize:none hierboven is uitgeschakeld) */ + .widget-rs{ + position:absolute; right:4px; bottom:4px; width:34px; height:34px; + border-radius:50%; background:var(--blue); border:3px solid #fff; + cursor:nwse-resize; box-shadow:0 2px 6px rgba(0,0,0,.3); touch-action:none; + opacity:0; transition:opacity .15s; z-index:2; + } + .widget:hover .widget-rs,.widget:focus-within .widget-rs{opacity:.6;} + .widget-rs:hover,.widget-rs:active{opacity:1 !important;} + .widget.bare .widget-rs,.widget[data-max="1"] .widget-rs{display:none;} + /* unified floating bar: pen palette + dock, draggable, combined when both active */ + #hud{ + position:fixed; left:50%; bottom:16px; transform:translateX(-50%); z-index:5500; + display:none; gap:8px; align-items:center; flex-wrap:wrap; max-width:94vw; + padding:5px 8px; border-radius:14px; + background:rgba(255,255,255,.72); backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); + box-shadow:0 6px 18px rgba(30,50,90,.18); border:1px solid rgba(255,255,255,.7); + } + #hud{display:flex;} + .hud-div{display:none; width:1.5px; height:26px; background:#c3cfde; border-radius:2px; flex:none;} + #hud.has-dock .hud-div{display:block;} + #dock{display:none; gap:8px; align-items:center; flex-wrap:wrap;} + #hud.has-dock #dock{display:flex;} + /* collapse + orientation */ + .hud-btn{ + border:none; background:var(--surface-2); border-radius:8px; width:26px; height:26px; + cursor:pointer; font-size:13px; line-height:1; padding:0; flex:none; font-weight:900; color:var(--ink); + } + .hud-btn:hover{background:var(--surface-3);} + #hud.min #penbar,#hud.min #dock,#hud.min .hud-div{display:none !important;} + #hud.vert{ + flex-direction:column; align-items:center; flex-wrap:nowrap; + max-height:calc(100vh - var(--tbar) - 26px); overflow-y:auto; overflow-x:hidden; + max-width:none; width:92px; + } + #hud.vert #penbar{flex-direction:column; flex-wrap:nowrap; align-items:center;} + #hud.vert #penbar .wp-group{display:grid; grid-template-columns:repeat(2,30px); justify-items:center; align-items:center;} + #hud.vert .hud-div{width:26px; height:1.5px;} + #hud.vert #dock{flex-direction:column; flex-wrap:nowrap;} + .dock-item{ + border:none; display:flex; align-items:center; gap:7px; background:var(--surface); + border-radius:10px; padding:6px 12px; font:inherit; font-weight:700; font-size:13px; + color:var(--ink); cursor:pointer; box-shadow:0 2px 6px rgba(30,50,90,.12); + } + .dock-item:hover{background:var(--surface-2); transform:translateY(-2px);} + .dock-item .di{font-size:16px; line-height:1;} + /* sluitknop op de dock-pil: geminimaliseerde widgets zijn direct te sluiten */ + .dock-item .dock-close{ + display:flex; align-items:center; justify-content:center; + width:20px; height:20px; margin:-2px -6px -2px 0; border-radius:7px; + font-size:11px; color:var(--muted); line-height:1; + } + .dock-item .dock-close:hover{background:var(--red-soft); color:var(--red);} + @media (pointer:coarse){ + .dock-item .dock-close{width:28px; height:28px;} + } + .widget-head{ + display:flex; align-items:center; gap:10px; padding:10px 14px; + background:rgba(255,255,255,.4); border-bottom:1px solid rgba(150,170,200,.28); + cursor:grab; flex:none; + } + .widget-head:active{cursor:grabbing;} + .widget-head .wicon{font-size:20px;} + .widget-head .wtitle{font-weight:800; font-size:15px; flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} + .wclose{ + border:none; background:var(--red-soft); color:var(--red); width:28px; height:28px; + border-radius:9px; font-weight:900; cursor:pointer; font-size:14px; line-height:1; flex:none; + } + .wclose:hover{background:var(--red); color:#fff;} + .wmin{ + border:none; background:var(--surface-2); color:var(--ink); width:28px; height:28px; + border-radius:9px; font-weight:900; cursor:pointer; font-size:15px; line-height:1; flex:none; + } + .wmin:hover{background:var(--surface-3);} + .wz{font-size:10.5px; width:auto; min-width:30px; padding:0 5px;} + .wz.on{background:var(--accent-soft); color:var(--accent-ink);} + .widget-body{flex:1; overflow:hidden; position:relative; display:flex; flex-direction:column; min-height:0; container-type:inline-size; container-name:widget;} + /* ---------- Modals ---------- */ + .modalwrap{ + position:fixed; inset:0; z-index:6000; background:rgba(15,22,38,.4); + display:none; align-items:center; justify-content:center; + } + .modalwrap.open{display:flex;} + .modal{ + background:var(--surface); border-radius:var(--radius-l); padding:24px 26px; + width:min(680px,92vw); max-height:88vh; overflow:auto; + box-shadow:var(--shadow-2); border:1px solid var(--line); + } + .modal h2{margin:0 0 14px; font-size:20px; font-weight:700;} + /* galerij: gelijke tegel-hoogtes (grid-rijen strekken mee, vaste inhoud-zones) */ + .gallery{display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); grid-auto-rows:1fr; gap:12px;} + .gcard{ + border:1px solid var(--line); border-radius:var(--radius); padding:16px 14px; + text-align:center; cursor:pointer; transition:border-color .15s, box-shadow .15s; + background:var(--surface); display:flex; flex-direction:column; + align-items:center; justify-content:flex-start; min-height:132px; + } + .gcard:hover{border-color:var(--accent); box-shadow:var(--shadow-1);} + .gcard .gi{font-size:36px; line-height:1;} + .gcard .gn{font-weight:700; margin-top:9px; font-size:15px;} + .gcard .gd{font-size:12.5px; color:var(--muted); margin-top:4px; line-height:1.35;} + /* login + instellingen modals */ + #loginModal{width:min(400px,92vw);} + .login-logo{display:flex; align-items:center; justify-content:center; gap:10px; margin:2px 0 18px;} + .login-logo .logo-owl{width:46px; height:40px;} + .login-logo .logo-word{font-size:24px;} + /* instellingen is breder dan het inlogscherm: het beheerpaneel (tabblad) heeft + de ruimte nodig van wat vroeger een eigen, brede modal was (#adminModal) */ + #settingsModal{width:min(760px,94vw); max-height:86vh; overflow:auto;} + .st-section[data-section="account"]{max-width:420px;} + .st-section[data-section="account"] .am-h3:first-of-type{margin-top:0;} + #loginModal input,#settingsModal input{ + width:100%; border:1px solid var(--line); border-radius:var(--radius); + height:var(--control-h); padding:0 12px; + font:inherit; font-size:15px; outline:none; margin-bottom:10px; + background:var(--surface); color:var(--ink); + } + #loginModal input:focus,#settingsModal input:focus{border-color:var(--accent);} + .lrow{display:flex; gap:8px;} + .lrow .tbtn{flex:1;} + .lrow input{flex:1; min-width:0; margin-bottom:0;} + .lrow .pwtoggle{flex:0 0 auto; white-space:nowrap; padding-left:10px; padding-right:10px;} + .formMsg{min-height:20px; font-size:13.5px; font-weight:700; color:var(--red); margin:6px 0;} + .formMsg.ok{color:var(--green);} + .guestnote{font-size:13px; color:var(--muted); margin-top:12px; line-height:1.45;} + /* instellingen: neutrale kaarten per sectie (.am-reset, hergebruikt van het + beheerpaneel) i.p.v. het speelse bord-uiterlijk - dit scherm is voor volwassenen */ + #settingsModal .am-reset .lrow{margin:0;} + #settingsModal .am-reset .guestnote{margin-top:2px;} + .st-footer{ + display:flex; justify-content:space-between; align-items:center; gap:8px; + margin-top:12px; padding-top:12px; border-top:1.5px solid var(--line); + font-size:11px; color:var(--muted); flex:none; + } + #settingsModal .am-name{font-size:16px;} + /* ---------- Instellingen-schil: nav links, inhoud rechts, zoeken boven ----- */ + .st-modal{ + width:min(980px,96vw); height:min(700px,92vh); max-height:92vh; + display:flex; flex-direction:column; overflow:hidden; padding:18px 22px; + } + .st-head{display:flex; align-items:center; gap:14px; flex:none; padding-bottom:12px;} + .st-head h2{margin:0; font-size:19px;} + #stSearch{ + flex:1; max-width:360px; margin-left:auto; min-width:120px; + height:var(--control-h); padding:0 12px; font-size:14px; + border:1.5px solid var(--line); border-radius:10px; + background:var(--surface-2); color:var(--ink); outline:none; + } + #stSearch:focus{border-color:var(--accent);} + .st-body{display:flex; flex:1; min-height:0; border-top:1.5px solid var(--line);} + #stNav{ + width:200px; flex:none; overflow-y:auto; padding:12px 12px 12px 0; + border-right:1.5px solid var(--line); + display:flex; flex-direction:column; gap:2px; align-content:flex-start; + } + .st-nav-group{ + font-size:10.5px; font-weight:800; color:var(--muted); + text-transform:uppercase; letter-spacing:.5px; margin:12px 6px 4px; + } + .st-nav-group:first-child{margin-top:0;} + .st-nav-btn{ + display:flex; gap:9px; align-items:center; border:none; background:transparent; + padding:8px 10px; border-radius:9px; cursor:pointer; text-align:left; + font-size:14px; font-weight:650; color:var(--ink); + } + .st-nav-btn:hover{background:var(--surface-3);} + .st-nav-btn.active{background:var(--accent-soft); color:var(--accent-ink);} + .st-nav-ico{flex:none; width:20px; text-align:center;} + #stContent{flex:1; min-width:0; overflow-y:auto; padding:14px 4px 14px 18px;} + #stSecHead{margin-bottom:12px;} + #stSecHead:empty{display:none;} + .st-sec-title{font-size:17px; font-weight:800;} + .st-sec-sub{font-size:12.5px; color:var(--muted); margin-top:2px;} + /* curriculumwiki: volwassen, informatief en bruikbaar op tablet/telefoon */ + + .wiki-goal-focus{outline:3px solid var(--accent);outline-offset:3px;animation:wikiFocus 1.4s ease-out} + @keyframes wikiFocus{from{background:var(--accent-soft)}to{background:var(--surface)}} + .winfo[hidden]{display:none} + .widget-curriculum-modal{width:min(620px,94vw);max-height:min(760px,88vh);overflow:auto} + .widget-curriculum-head{display:flex;align-items:center;gap:12px;margin-bottom:8px} + .widget-curriculum-head h2{flex:1;margin:0;font-size:20px} + .widget-curriculum-goals{display:flex;flex-direction:column;gap:8px;margin:14px 0} + .widget-curriculum-goal{display:flex;flex-direction:column;gap:4px;padding:11px 12px;border:1px solid var(--line);border-radius:12px;background:var(--surface-2);color:var(--ink);text-decoration:none} + .widget-curriculum-goal:hover,.widget-curriculum-goal:focus-visible{border-color:var(--accent);outline:2px solid var(--accent-soft)} + .widget-curriculum-goal span{font-size:13px;line-height:1.4}.widget-curriculum-goal small{color:var(--accent-ink);font-weight:750} + .widget-curriculum-empty,.widget-curriculum-careful{padding:10px 12px;border-radius:10px;background:var(--surface-2);font-size:13px;line-height:1.45} + .widget-curriculum-careful{color:var(--muted)}.widget-curriculum-all{display:inline-flex;text-decoration:none} + + #curriculumWiki{display:flex;flex-direction:column;gap:14px;padding-bottom:10px;} + .wiki-hero{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding:18px;border:1px solid var(--line);border-radius:12px;background:linear-gradient(135deg,var(--surface),var(--surface-2));} + .wiki-hero-text{max-width:680px}.wiki-kicker{margin-bottom:5px;color:var(--accent);font-size:11px;font-weight:750;letter-spacing:.6px;text-transform:uppercase} + .wiki-hero h2{margin:0 0 7px;font-size:23px}.wiki-hero p{margin:0;color:var(--muted);line-height:1.5} + .wiki-facts{display:grid;grid-template-columns:auto auto;align-items:baseline;gap:2px 7px;flex:none;padding:10px 12px;border-left:3px solid var(--accent)} + .wiki-facts strong{font-size:24px}.wiki-facts span{color:var(--muted);font-size:11px} + .wiki-status{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:10px;padding:11px 13px;border:1px solid color-mix(in srgb,var(--orange) 45%,var(--line));border-radius:9px;background:color-mix(in srgb,var(--orange) 8%,var(--surface));font-size:12.5px;line-height:1.45} + .wiki-status strong{white-space:nowrap}.wiki-status span{color:var(--muted)} + .wiki-link{width:max-content;max-width:100%;color:var(--accent);font-size:12px;font-weight:700;text-decoration:none}.wiki-link:hover,.wiki-link:focus-visible{text-decoration:underline} + .wiki-tule{border:1px solid var(--line);border-radius:9px;background:var(--surface)}.wiki-tule summary{cursor:pointer;padding:10px 12px;font-weight:700}.wiki-tule p{margin:0;padding:0 12px 12px;color:var(--muted);font-size:12.5px;line-height:1.55} + .wiki-profile-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;padding:0 12px 12px} + .wiki-profile-column{display:flex;flex-direction:column;gap:6px}.wiki-profile-column h3{margin:0 0 3px} + .wiki-profile-row{display:grid;grid-template-columns:74px 1fr;gap:3px 8px;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2)} + .wiki-profile-row small{grid-column:1/-1;color:var(--muted);line-height:1.4} + .wiki-source-links{display:flex;flex-wrap:wrap;gap:8px;padding:0 12px 12px} + .wiki-language-note{margin:0;padding:8px 11px;border-radius:7px;background:var(--surface-2);color:var(--muted);font-size:12px} + .wiki-search{display:flex;flex-direction:column;gap:5px}.wiki-search label{font-size:12px;font-weight:700} + #settingsModal .wiki-search input{margin:0;background:var(--surface)} + .wiki-filters{display:flex;gap:6px;overflow-x:auto;padding-bottom:3px;scrollbar-width:thin} + .wiki-filters button{flex:none;border:1px solid var(--line);border-radius:999px;padding:7px 10px;background:var(--surface);color:var(--ink);font:inherit;font-size:12px;cursor:pointer} + .wiki-filters button[aria-pressed="true"]{border-color:var(--accent);background:var(--accent-soft);color:var(--accent-ink);font-weight:700} + .wiki-results{display:flex;flex-direction:column;gap:22px}.wiki-area{display:flex;flex-direction:column;gap:9px;scroll-margin-top:10px} + .wiki-area-head{display:flex;align-items:center;gap:9px;border-bottom:1px solid var(--line);padding-bottom:7px}.wiki-area-head h3{display:flex;align-items:center;gap:7px;margin:0;font-size:18px}.wiki-area-icon{font-size:20px} + .wiki-range{margin-left:auto;color:var(--muted);font-size:11px;font-weight:650}.wiki-area-intro{max-width:780px;margin:0;color:var(--muted);font-size:12.5px;line-height:1.5} + .wiki-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px} + .wiki-goal{display:flex;flex-direction:column;gap:8px;min-width:0;padding:12px;border:1px solid var(--line);border-radius:9px;background:var(--surface)} + .wiki-goal-head{display:flex;align-items:center;gap:7px}.wiki-goal-id{flex:none;padding:3px 7px;border-radius:5px;background:var(--accent-soft);color:var(--accent-ink);font-size:11px;font-weight:750}.wiki-domain{overflow:hidden;color:var(--muted);font-size:11px;text-overflow:ellipsis;white-space:nowrap} + .wiki-summary{margin:0;font-size:13px;line-height:1.45}.wiki-goal h4{margin:1px 0 -3px;color:var(--muted);font-size:10.5px;text-transform:uppercase;letter-spacing:.4px} + .wiki-parts{display:flex;flex-direction:column;gap:3px;margin:0;padding-left:19px;font-size:12px;line-height:1.35}.wiki-parts strong{color:var(--accent-ink)} + .wiki-coverage-row{display:flex;flex-direction:column;align-items:flex-start;gap:6px;margin-top:auto;padding-top:3px}.wiki-coverage{padding:3px 7px;border-radius:5px;font-size:10.5px;font-weight:750} + .wiki-coverage-partial{background:color-mix(in srgb,var(--green) 14%,var(--surface));color:color-mix(in srgb,var(--green) 70%,var(--ink))} + .wiki-coverage-support{background:color-mix(in srgb,var(--blue) 12%,var(--surface));color:color-mix(in srgb,var(--blue) 70%,var(--ink))} + .wiki-coverage-open{background:var(--surface-2);color:var(--muted)} + .wiki-widgets{display:flex;align-items:center;flex-wrap:wrap;gap:4px}.wiki-widgets-label{color:var(--muted);font-size:10.5px}.wiki-widget{padding:2px 6px;border:1px solid var(--line);border-radius:999px;font-size:10.5px} + .wiki-empty{padding:30px;border:1px dashed var(--line);border-radius:9px;color:var(--muted);text-align:center} + .wiki-disclaimer{padding:11px 13px;border-left:3px solid var(--accent);background:var(--surface-2);font-size:12px;line-height:1.5}.wiki-disclaimer strong{font-weight:750} + /* zoekresultaten: gegroepeerd per sectie, klik = navigeren */ + #stSearchResults{display:flex; flex-direction:column; gap:2px;} + .st-result{ + display:flex; gap:9px; align-items:center; border:none; background:transparent; + padding:8px 10px; border-radius:9px; cursor:pointer; text-align:left; + font-size:14px; color:var(--ink); width:100%; + } + .st-result:hover{background:var(--surface-3);} + .st-result-lbl{font-weight:650; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;} + .st-result-sub{margin-left:auto; flex:none; font-size:12px; color:var(--muted);} + /* smal scherm: nav wordt een horizontale strook boven de inhoud */ + @media (max-width:720px){ + .st-modal{padding:14px;} + .st-body{flex-direction:column;} + #stNav{ + width:auto; flex-direction:row; overflow-x:auto; overflow-y:hidden; + border-right:none; border-bottom:1.5px solid var(--line); + padding:8px 0; gap:4px; + } + .st-nav-group{display:none;} + .st-nav-btn{flex:none; padding:7px 10px;} + #stContent{padding:12px 2px;} + #stSearch{max-width:none;} + .wiki-hero{flex-direction:column}.wiki-facts{grid-template-columns:auto auto;border-left:0;border-top:3px solid var(--accent)} + .wiki-status{grid-template-columns:1fr}.wiki-status strong{white-space:normal} + .wiki-grid{grid-template-columns:1fr} + .wiki-profile-grid{grid-template-columns:1fr} + } + /* ========================================================= + Letter game widget + ==========================================================*/ + .lg{display:flex; flex-direction:column; flex:1; min-height:0; padding:12px 14px; gap:10px;} + .lg-top{display:flex; gap:8px; align-items:center; flex-wrap:wrap;} + .lg-levels{display:flex; background:var(--surface-2); border-radius:999px; padding:3px; gap:2px;} + .lg-levels button{ + border:none; border-radius:999px; background:transparent; padding:6px 12px; + font:inherit; font-weight:700; font-size:13.5px; color:var(--muted); cursor:pointer; + } + .lg-levels button.active{background:var(--surface); color:var(--ink); box-shadow:var(--shadow-1);} + .lg-cards{display:flex; gap:5px; flex-wrap:wrap;} + .lg-cards button{ + border:1px solid var(--line); background:var(--surface); width:34px; height:34px; border-radius:10px; + font:inherit; font-weight:800; color:var(--muted); cursor:pointer; font-size:14px; + } + .lg-cards button.active{border-color:var(--accent); background:var(--accent-soft); color:var(--accent-ink);} + .lg-cards .lg-theme{ + width:auto; padding:0 11px; max-width:120px; overflow:hidden; + text-overflow:ellipsis; white-space:nowrap; + } + .lg-num{ + border:1px solid var(--line); border-radius:10px; height:34px; font:inherit; + font-weight:800; font-size:13px; color:var(--muted); background:var(--surface); + padding:0 4px; outline:none; cursor:pointer; + } + .lg-snd,.lg-mode{ + border:1px solid var(--line); border-radius:10px; width:34px; height:34px; + background:var(--surface); cursor:pointer; font-size:15px; line-height:1; padding:0; flex:none; + } + .lg-snd.off{opacity:.55;} + /* small digit blocks/slots for the maths widgets */ + .blok.sm{--dot:7px; width:46px; height:74px; border-radius:10px; padding-top:4px;} + .blok.sm .bl{font-size:24px; height:30px; color:var(--purple);} + .blok.sm .blok-dots{margin-bottom:7px;} + .slot.sm{--dot:7px; width:46px; height:74px; padding-top:4px;} + .slot.sm .ghost{font-size:22px; height:26px;} + .slot.sm .slot-dots{margin-bottom:6px;} + .mt-slots{display:flex; gap:8px; justify-content:center;} + .mt-tray{gap:8px; padding:10px 8px; min-height:96px;} + .mt.blocks .mt-a,.mt.blocks .mt-pad{display:none;} + .mt:not(.blocks) .mt-slots,.mt:not(.blocks) .mt-tray{display:none;} + /* on-screen letter keyboard (letters widget, type mode) */ + .lg-kbd{gap:6px; padding:10px;} + .lg-kbd button{ + width:40px; height:46px; border-radius:10px; border:2px solid var(--wood-d); + background:linear-gradient(#fdf3dd,var(--wood)); font:inherit; font-weight:900; + font-size:20px; cursor:pointer; padding:0; + } + .lg-kbd button.v{color:var(--red);} + .lg-kbd button.c{color:var(--blue);} + .lg.typemode .lg-tray.blocks-tray{display:none;} + .lg:not(.typemode) .lg-kbd{display:none;} + .lg-ed-themerow{display:flex; gap:8px; align-items:center;} + .ed-theme-sel{ + flex:1; min-width:0; border:1px solid var(--line); border-radius:10px; padding:9px 10px; + font:inherit; font-weight:700; font-size:15px; outline:none; background:var(--surface); cursor:pointer; + } + .ed-theme-name, .ed-theme-folder{ + border:2px solid var(--blue); border-radius:10px; padding:9px 10px; + font:inherit; font-size:15px; outline:none; width:180px; + } + .ed-theme-del{ + border:none; background:var(--red-soft); color:var(--red); border-radius:10px; + width:40px; height:40px; cursor:pointer; font-size:15px; flex:none; + } + .ed-theme-del:hover{background:var(--red); color:#fff;} + .ed-theme-mv{ + border:none; background:var(--surface-2, #eef); color:var(--ink); border-radius:10px; + width:40px; height:40px; cursor:pointer; font-size:15px; flex:none; + } + .lg-progress{margin-left:auto; font-size:13.5px; font-weight:700; color:var(--muted); display:flex; align-items:center; gap:8px; white-space:nowrap;} + .lg-stars{color:#d9a400; font-size:14px; min-width:34px; text-align:right;} + .lg-instr{ + background:var(--surface-2); border:1.5px dashed #bcd4f2; + border-radius:12px; padding:6px 14px; font-size:14.5px; font-weight:700; color:#37588a; + text-align:center; min-height:42px; display:flex; align-items:center; justify-content:center; + flex:none; + } + .lg-card{ + flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; + gap:16px; background:var(--surface); border:1px solid var(--line); + border-radius:18px; padding:12px; min-height:0; position:relative; overflow:hidden; + } + .lg-pic{font-size:64px; line-height:1; filter:drop-shadow(0 4px 6px rgba(0,0,0,.12));} + .lg-pic img{width:80px; height:80px; object-fit:contain; border-radius:12px; vertical-align:middle;} + .lg-slots{display:flex; gap:10px;} + .slot{ + --dot:9px; + width:64px; height:98px; border-radius:12px; position:relative; + background:#e9eef5; border:2.5px dashed #b9c7d9; + display:flex; flex-direction:column; align-items:center; justify-content:flex-start; + padding-top:6px; transition:all .12s; + } + .slot.droptarget{border-color:var(--green); background:#e3f6ea; transform:scale(1.06);} + .slot .ghost{font-size:30px; font-weight:800; color:#c2cddc; height:40px; display:flex; align-items:center;} + .slot.filled{border-style:solid; border-color:transparent; background:transparent; padding:0; justify-content:center;} + .slot.filled .ghost,.slot.filled .slot-dots{display:none;} + .slot.shake{animation:shake .35s;} + @keyframes shake{ + 0%,100%{transform:translateX(0);} 20%{transform:translateX(-7px);} + 40%{transform:translateX(7px);} 60%{transform:translateX(-5px);} 80%{transform:translateX(5px);} + } + .blok{ + --dot:9px; + width:64px; height:98px; border-radius:12px; cursor:grab; touch-action:none; + display:flex; flex-direction:column; align-items:center; padding-top:6px; + background:linear-gradient(#fdf3dd,var(--wood)); border:2.5px solid var(--wood-d); + box-shadow:0 4px 0 var(--wood-d), 0 7px 14px rgba(60,40,0,.16); + position:relative; flex:none; + } + .blok:active{cursor:grabbing;} + .blok .bl{font-size:34px; font-weight:900; height:44px; display:flex; align-items:center;} + .blok.vowel .bl{color:var(--red);} + .blok.cons .bl{color:var(--blue);} + .blok .blok-dots{margin-top:auto; margin-bottom:8px;} + /* Vangnet: het stippenraster houdt altijd zijn maat, het label geeft mee als + een blokformaat krap is. Zonder min-height:0 kan een kolom-flexitem niet + onder zijn inhoud krimpen en werd het raster het blok uit geduwd. */ + .blok .bl,.slot .ghost{min-height:0;} + .blok .blok-dots,.slot .slot-dots{flex:none;} + .blok.in-slot{cursor:pointer; animation:pop .3s;} + .blok.dragging{opacity:.25;} + @keyframes pop{0%{transform:scale(.6);} 60%{transform:scale(1.12);} 100%{transform:scale(1);}} + .slot .slot-dots{margin-top:auto; margin-bottom:8px;} + /* Stippenraster (braillepatroon, zie dotGrid() in data.js). De maat komt uit + één variabele --dot die elk bloktype naast zijn eigen hoogte zet; zo kan een + nieuw blokformaat niet meer per ongeluk het raster van een ander formaat + erven (dat gebeurde bij de geldblokken en in de smalle-widget-query, waar de + stippen onder het blok uit staken). place-self houdt de kleinere "off"-stip + gecentreerd in zijn cel - anders staat die links-boven en oogt het patroon + scheef. Vuistregel: --dot nooit onder 6px (leesbaarheid); past het niet, + dan geeft de labelhoogte of de blokhoogte mee, niet de stip. */ + .dots{display:grid; grid-template-columns:repeat(2,var(--dot)); grid-template-rows:repeat(3,var(--dot)); gap:calc(var(--dot)/3);} + .dots i{width:var(--dot); height:var(--dot); border-radius:50%; background:transparent; place-self:center;} + .blok .dots i.on{background:#b98a3c; box-shadow:inset 0 -1.5px 2px rgba(0,0,0,.35), 0 1px 0 rgba(255,255,255,.5);} + .blok .dots i.off{background:rgba(0,0,0,.05);} + .slot .dots i.on{background:#aebcce; box-shadow:inset 0 2px 3px rgba(30,50,80,.4);} + .slot .dots i.off{background:transparent; border:1px dotted #ccd7e4; width:calc(var(--dot) - 2px); height:calc(var(--dot) - 2px);} + .lg-tray{ + display:flex; gap:12px; justify-content:center; flex-wrap:wrap; + background:var(--surface-2); border-radius:16px; padding:14px 12px; min-height:130px; flex:none; + } + .lg-bottom{display:flex; align-items:center; justify-content:center; gap:12px; min-height:46px; flex:none;} + .lg-next{ + border:none; border-radius:12px; padding:10px 22px; font:inherit; font-weight:800; + background:var(--green); color:#fff; cursor:pointer; font-size:15.5px; display:none; + } + .lg-next.show{display:inline-block; animation:pop .3s;} + .lg-done{ + position:absolute; inset:0; border-radius:18px; display:none; align-items:center; + justify-content:center; flex-direction:column; gap:6px; + background:rgba(255,255,255,.88); backdrop-filter:blur(2px); z-index:5; + } + .lg-done.show{display:flex;} + .lg-done .dg{font-size:52px;} + .lg-done .dt{font-size:24px; font-weight:900; color:var(--green);} + .confetti{position:absolute; width:10px; height:10px; border-radius:3px; top:-12px; z-index:6; pointer-events:none; animation:fall linear forwards;} + @keyframes fall{to{transform:translateY(110vh) rotate(720deg);}} + #dragClone{ + position:fixed; z-index:9000; pointer-events:none; transform:translate(-50%,-60%) scale(1.08); + filter:drop-shadow(0 12px 16px rgba(0,0,0,.28)); + } + /* ---------- custom words editor ---------- */ + .lg-editor{ + position:absolute; inset:0; z-index:20; background:var(--surface); display:none; + flex-direction:column; gap:10px; padding:14px 16px; overflow:auto; + } + .lg-editor.open{display:flex;} + .lg-ed-head{display:flex; align-items:center; justify-content:space-between;} + .lg-ed-head b{font-size:17px;} + .lg-ed-form{ + display:flex; gap:10px; align-items:center; flex-wrap:wrap; + background:var(--surface-2); border-radius:14px; padding:12px; + } + .lg-ed-form input[type=text]{ + border:1px solid var(--line); border-radius:10px; padding:9px 10px; font:inherit; + font-size:16px; outline:none; + } + .lg-ed-form input.ed-word{width:170px; text-transform:lowercase;} + .ed-preview{width:52px; height:52px; display:flex; align-items:center; justify-content:center; + font-size:34px; background:var(--surface); border-radius:10px; border:2px dashed var(--line); overflow:hidden; flex:none;} + .ed-preview img{width:100%; height:100%; object-fit:contain;} + .ed-add{border:none; border-radius:10px; padding:11px 20px; font:inherit; font-weight:800; + background:var(--green); color:#fff; cursor:pointer; font-size:14.5px;} + .ed-msg{width:100%; color:var(--red); font-weight:700; font-size:13.5px; min-height:16px;} + /* modern tabsysteem: underline-tabs die overlopen in hun paneel. + De tabbalk deelt de ondergrond met de inhoud; alleen een hairline scheidt ze + en de accentlijn van de actieve tab valt daaroverheen. */ + .ed-tabs{ + display:flex; gap:2px; border-bottom:1px solid var(--line); margin-bottom:14px; + overflow-x:auto; scrollbar-width:thin; + } + .ed-tabs button{ + border:none; border-radius:0; padding:10px 15px 11px; font:inherit; font-weight:700; + font-size:14px; background:transparent; color:var(--muted); cursor:pointer; + position:relative; margin-bottom:-1px; flex:none; + } + .ed-tabs button:hover{color:var(--ink);} + .ed-tabs button.active{color:var(--ink);} + .ed-tabs button.active::after{ + content:""; position:absolute; left:10px; right:10px; bottom:0; + height:2.5px; border-radius:2px; background:var(--accent); + } + .ed-pane{padding:2px 0 0; min-height:120px;} + /* uniforme segmented control (kleine keuzegroepen: taal, niveau, timer, chips) */ + .ed-cats{display:flex; gap:5px; flex-wrap:wrap; margin-bottom:10px;} + .ed-cats button{ + border:none; background:var(--surface-2); border-radius:999px; padding:6px 12px; font:inherit; + font-weight:700; font-size:12.5px; color:var(--muted); cursor:pointer; + } + .ed-cats button.active{background:var(--surface); color:var(--ink); box-shadow:var(--shadow-1); outline:1.5px solid var(--accent);} + .ed-grid{display:flex; gap:4px; flex-wrap:wrap; max-height:170px; overflow:auto;} + .ed-grid button{ + border:none; background:transparent; border-radius:9px; font-size:26px; + width:42px; height:42px; cursor:pointer; padding:0; line-height:1; + } + .ed-grid button:hover{background:#dbe8f8;} + .ed-grid button.sel{background:var(--accent-soft); outline:1.5px solid var(--accent);} + .ed-pictorow{display:flex; gap:8px; margin-bottom:10px;} + .ed-pictorow input{flex:1; border:1px solid var(--line); border-radius:10px; padding:9px 10px; font:inherit; font-size:15px; outline:none;} + .ed-pictorow .tbtn{flex:none;} + .ed-pgrid{display:flex; gap:8px; flex-wrap:wrap; max-height:180px; overflow:auto;} + .ed-pgrid button{ + border:1px solid var(--line); background:var(--surface); border-radius:12px; width:74px; height:74px; + cursor:pointer; padding:5px; + } + .ed-pgrid button:hover{border-color:var(--blue);} + .ed-pgrid button.sel{border-color:var(--accent); background:var(--accent-soft);} + .ed-pgrid img{width:100%; height:100%; object-fit:contain;} + .ed-credit{font-size:11.5px; color:var(--muted); margin-top:8px; line-height:1.4;} + .ed-credit a{color:var(--blue);} + .ed-status{font-size:13.5px; font-weight:700; color:var(--muted); padding:6px 0;} + .ed-upload{background:var(--surface-2); border-radius:10px; padding:11px 16px; font-weight:700; + font-size:14px; cursor:pointer; display:inline-block;} + .ed-upload input{display:none;} + .ed-uphint{font-size:12.5px; color:var(--muted); margin-top:10px; line-height:1.45;} + .ed-p-library{min-height:120px;padding:18px;border:1px dashed var(--accent);border-radius:14px;background:linear-gradient(135deg,var(--surface-2),var(--accent-soft));text-align:center;} + .ed-open-library{min-height:46px;font-weight:850;} + .ed-library-hint{max-width:520px;margin:10px auto 0;color:var(--muted);font-size:13px;line-height:1.5;} + .lg-ed-list{display:flex; flex-direction:column; gap:6px;} + .lg-ed-list .empty{color:var(--muted); font-weight:700; text-align:center; padding:18px 0;} + .ed-row{display:flex; align-items:center; gap:12px; background:var(--surface-2); border-radius:12px; padding:7px 12px;} + .ed-row .ep{width:40px; height:40px; font-size:28px; display:flex; align-items:center; justify-content:center;} + .ed-row .ep img{width:100%; height:100%; object-fit:contain; border-radius:8px;} + .ed-row .ew{font-weight:800; font-size:17px; letter-spacing:2px; flex:1;} + .ed-row .edel{border:none; background:var(--red-soft); color:var(--red); border-radius:8px; + width:30px; height:30px; cursor:pointer; font-weight:900;} + .ed-row .edel:hover{background:var(--red); color:#fff;} + .ed-row.base{opacity:.65;} + /* =========== Notes widget =========== */ + .notes{flex:1; width:100%; min-height:0; border:none; resize:none; font:inherit; font-size:16px; + padding:16px; outline:none; background:transparent; color:#26354a; line-height:1.55;} + .notes.large{font-size:22px;} + /* =========== Writing pad widget =========== */ + .wp{flex:1; min-height:0; display:flex; flex-direction:column;} + .wp-bar{ + display:flex; gap:10px; padding:8px 10px; flex-wrap:wrap; align-items:center; + flex:none; background:var(--surface-2); border-bottom:1px solid var(--line); + } + .wp-group{display:flex; gap:4px; align-items:center; background:var(--surface-2); border-radius:10px; padding:3px;} + .wp-group button{ + border:none; background:transparent; border-radius:8px; width:32px; height:32px; + cursor:pointer; font-size:16px; line-height:1; padding:0; + display:flex; align-items:center; justify-content:center; + } + .wp-group button.on{background:var(--surface); box-shadow:0 1px 4px rgba(30,50,90,.2);} + .wp-color{border-radius:50% !important; position:relative;} + .wp-color i{width:18px; height:18px; border-radius:50%; display:block;} + .wp-color.on{outline:2.5px solid var(--ink); outline-offset:-3px;} + .wp-size i{border-radius:50%; background:var(--ink); display:block;} + .wp-bg{font-size:0 !important;} + .wp-bg i{ + width:20px; height:20px; display:block; border-radius:4px; border:1.5px solid #9db6d8; + background-color:#fff; background-size:100% 25%; + } + .wp-bg[data-bg=blank] i{background-image:none;} + .wp-bg[data-bg=dots] i{background-image:radial-gradient(circle,#9db6d8 1px,transparent 1px); background-size:5px 5px;} + .wp-bg[data-bg=lines] i{background-image:repeating-linear-gradient(#0000 0 4px, #9db6d8 4px 5px);} + .wp-bg[data-bg=write] i{background-image:repeating-linear-gradient(#0000 0 2px, #c3d5ec 2px 3px, #0000 3px 5px, #6d92c4 5px 6px);} + .wp-bg[data-bg=grid-s] i{background-image:repeating-linear-gradient(#0000 0 4px, #9db6d8 4px 5px),repeating-linear-gradient(90deg,#0000 0 4px, #9db6d8 4px 5px);} + .wp-bg[data-bg=grid-b] i{background-image:repeating-linear-gradient(#0000 0 8px, #9db6d8 8px 9px),repeating-linear-gradient(90deg,#0000 0 8px, #9db6d8 8px 9px);} + .wp-clear{border:none; background:var(--red-soft); color:var(--red); border-radius:10px; + width:34px; height:34px; cursor:pointer; font-size:15px; margin-left:auto;} + .wp-clear:hover{background:var(--red); color:#fff;} + .wp-stage{flex:1; min-height:0; position:relative; background:var(--surface);} + .wp-stage canvas{position:absolute; inset:0; touch-action:none; cursor:crosshair;} + .wp-stage.t-text canvas{cursor:text;} + .wp-text-input{ + position:absolute; z-index:5; background:rgba(255,255,255,.85); + border:1.5px dashed var(--blue); border-radius:6px; padding:2px 6px; + font-family:inherit; font-weight:700; outline:none; min-width:130px; + } + /* paper backgrounds (CSS, so the eraser only removes ink) */ + .wp-stage.bg-lines{background-image:repeating-linear-gradient(#0000 0 31px, #aec5e4 31px 32px);} + .wp-stage.bg-write{background-image:repeating-linear-gradient( + #0000 0 15px, #c3d5ec 15px 16px, #0000 16px 31px, #6d92c4 31px 32.5px, #0000 32.5px 47px, #c3d5ec 47px 48px, #0000 48px 63px);} + .wp-stage.bg-grid-s{background-image: + repeating-linear-gradient(#0000 0 19px, #c9d9ee 19px 20px), + repeating-linear-gradient(90deg,#0000 0 19px, #c9d9ee 19px 20px);} + .wp-stage.bg-grid-b{background-image: + repeating-linear-gradient(#0000 0 39px, #aec5e4 39px 40px), + repeating-linear-gradient(90deg,#0000 0 39px, #aec5e4 39px 40px);} + /* =========== Media widget =========== */ + .media{flex:1; min-height:0; display:flex; flex-direction:column;} + .media-bar{ + display:flex; gap:8px; padding:8px 10px; flex:none; + background:var(--surface-2); border-bottom:1px solid var(--line); + } + .media-bar input{ + flex:1; min-width:0; border:1px solid var(--line); border-radius:10px; + padding:8px 10px; font:inherit; font-size:14px; outline:none; + } + .media-bar input:focus{border-color:var(--blue);} + .media-stage{ + flex:1; min-height:0; background:#141a24; display:flex; + align-items:center; justify-content:center; overflow:hidden; + } + .media-stage iframe{width:100%; height:100%; border:none;} + .media-stage img,.media-stage video{max-width:100%; max-height:100%;} + .media-msg{color:#93a4bd; font-weight:700; font-size:14px; text-align:center; padding:20px; line-height:1.5;} + .media-yt{position:relative; width:100%; height:100%; display:flex; align-items:center; justify-content:center;} + .media-yt img{max-width:100%; max-height:100%;} + .media-play{ + position:absolute; border:none; width:76px; height:54px; border-radius:14px; + background:rgba(224,60,50,.95); color:#fff; font-size:24px; cursor:pointer; + display:flex; align-items:center; justify-content:center; + box-shadow:0 6px 18px rgba(0,0,0,.4); + } + .media-play:hover{transform:scale(1.08);} + /* =========== Math widgets =========== */ + .mt{flex:1; min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px 14px;} + .mt-top{display:flex; gap:8px; align-items:center; flex:none;} + .mt-score{margin-left:auto; font-size:13.5px; font-weight:700; color:var(--muted); white-space:nowrap;} + .mt-card{ + flex:1; min-height:0; display:flex; flex-direction:column; align-items:center; + justify-content:center; gap:12px; background:var(--surface); + border:1px solid var(--line); border-radius:18px; position:relative; padding:10px; overflow:hidden; + } + .mt-q{font-size:40px; font-weight:900; letter-spacing:1px; text-align:center;} + .mt-a{ + min-width:110px; height:56px; border-radius:14px; border:2.5px dashed #b9c7d9; + background:var(--surface-2); font-size:32px; font-weight:900; display:flex; align-items:center; + justify-content:center; color:var(--blue); padding:0 16px; + } + .mt-a.ok{border-style:solid; border-color:var(--green); background:#e3f6ea; color:var(--green);} + .mt-a.shake{animation:shake .35s;} + .mt-fb{min-height:24px; font-size:16px; font-weight:800; color:var(--green); text-align:center;} + .mt-fb.ans{color:var(--red);} + .mt-pad{display:grid; grid-template-columns:repeat(4,1fr); gap:8px; flex:none;} + .mt-pad button{ + border:none; border-radius:12px; background:var(--surface-2); font:inherit; font-weight:900; + font-size:22px; padding:12px 0; cursor:pointer; color:var(--ink); + } + .mt-pad button:hover{background:var(--surface-3);} + .mt-pad .ok{background:var(--green); color:#fff;} + .mt-done{ + position:absolute; inset:0; border-radius:18px; display:none; flex-direction:column; + align-items:center; justify-content:center; gap:8px; background:rgba(255,255,255,.9); + backdrop-filter:blur(2px); z-index:5; + } + .mt-done.show{display:flex;} + .mt-done .dg{font-size:46px;} + .mt-done .dt{font-size:22px; font-weight:900; color:var(--green);} + .mt-done .ds{font-size:16px; font-weight:800; color:var(--muted);} + .mt-again{ + border:none; border-radius:12px; padding:10px 22px; background:var(--green); + color:#fff; font:inherit; font-weight:800; font-size:15px; cursor:pointer; margin-top:6px; + } + /* =========== high-contrast accessibility mode =========== */ + /* wint óók van body.dark: hoog contrast is altijd zwart-op-wit */ + body.hc,body.dark.hc{ + --bg:#fff; --ink:#000; --muted:#2b2b2b; --blue:#0047ab; --red:#c40000; + --green:#006b2d; --yellow:#ffd400; --wood:#ffe9b0; --wood-d:#8a6a00; + --surface:#fff; --surface-2:#f2f2f2; --surface-3:#e2e2e2; --line:#000; + --accent:#0047ab; --accent-ink:#00306f; --accent-soft:#d6e4ff; + --red-soft:#ffd9d6; + --glass:#fff; --glass-strong:#fff; --glass-line:#000; + } + body.hc #board.bg-dots{background-image:radial-gradient(circle,#7d8ea3 1.7px,transparent 1.7px);} + body.hc .widget{background:var(--surface); border:2.5px solid #000; box-shadow:0 4px 14px rgba(0,0,0,.35); backdrop-filter:none;} + body.hc .widget:hover,.body.hc .widget:focus-within{background:var(--surface);} + body.hc .widget .wmin,body.hc .widget .wclose{opacity:1;} + body.hc .widget-rs{opacity:1; border:3px solid #000;} + body.hc .widget-head{background:#f2f2f2; border-bottom:2px solid #000;} + body.hc .slot{border-color:#000; background:var(--surface);} + body.hc .slot .ghost{color:#666;} + body.hc .slot .dots i.on{background:#333;} + body.hc .blok .dots i.on{background:#6a4a00;} + body.hc .lg-instr{background:var(--surface); border-color:#000; color:#000;} + body.hc #hud,body.hc .dock-item,body.hc .fab-item,body.hc #penbar .wp-group{border:2px solid #000; background:var(--surface);} + body.hc #fab{background:#0047ab;} + body.hc :focus-visible{outline:3px solid #0047ab; outline-offset:2px;} + body.hc .mm-node{box-shadow:0 3px 10px rgba(0,0,0,.4); border-width:3px;} + /* high contrast inside every widget */ + body.hc .lg-levels,body.hc .tseg{background:var(--surface); border:2px solid #000; border-radius:12px;} + body.hc .lg-levels button,body.hc .tseg button{color:#000;} + body.hc .lg-levels button.active{background:#006b2d; color:#fff;} + body.hc .lg-cards button,body.hc .lg-num,body.hc .fl-sel,body.hc .lg-snd,body.hc .lg-mode{ + border-color:#000; color:#000; background:var(--surface); + } + body.hc .lg-cards button.active{background:#ffd400; border-color:#000; color:#000;} + body.hc .lg-tray{background:var(--surface); border:2px dashed #000;} + body.hc .lg-card,body.hc .mt-card{background:var(--surface); border-color:#000;} + body.hc .lg-progress,body.hc .mt-score,body.hc .mm-hint,body.hc .dc-sum{color:#000;} + body.hc .lg-stars{color:#7a5d00;} + body.hc .mt-a{border-color:#000; background:var(--surface);} + body.hc .mt-pad button{background:var(--surface); border:2px solid #000; color:#000;} + body.hc .mt-pad .ok{background:#006b2d; border-color:#006b2d; color:#fff;} + body.hc .mt-fb{color:#006b2d;} + body.hc .mt-fb.ans{color:#c40000;} + body.hc .lg-kbd button,body.hc .blok{border-color:#6a4a00;} + body.hc .fl-word.dim{color:#8a8a8a;} + body.hc .slot .dots i.off{border-color:#666;} + body.hc .np textarea,body.hc .media-bar input,body.hc .ed-theme-sel, + body.hc .lg-ed-form input[type=text],body.hc .ed-pictorow input{border-color:#000; color:#000;} + body.hc .np-result{background:#0047ab;} + body.hc .np-row label,body.hc .thint,body.hc .guestnote,body.hc .ed-credit, + body.hc .gcard .gd,body.hc .st-footer{color:#222;} + body.hc .gcard{border-color:#000; background:var(--surface);} + body.hc .gcat-title{color:#000;} + body.hc .tbtn.ghost{background:var(--surface); border:2px solid #000; color:#000;} + body.hc .wmin,body.hc .wclose{border:1.5px solid #000; background:var(--surface);} + body.hc .wclose{color:#c40000;} + body.hc .wz.on,body.hc #zoomPct.on{background:#ffd400; color:#000;} + body.hc .ed-tabs button,body.hc .ed-cats button{color:#000; background:var(--surface); border:1.5px solid #000;} + body.hc .ed-tabs button.active,body.hc .ed-cats button.active{background:#ffd400;} + body.hc .ed-pane{border-color:#000; background:var(--surface);} + body.hc .timer .presets button,body.hc .timer .main button,body.hc .ticon, + body.hc .hud-btn,body.hc .mm-seg button{background:var(--surface); border:1.5px solid #000; color:#000;} + body.hc .timer .main .bstart{background:#006b2d; border-color:#006b2d; color:#fff;} + body.hc .dock-item{color:#000;} + body.hc .media-msg{color:#ddd;} + /* =========== Flash words widget =========== */ + .fl-word{font-size:58px; font-weight:900; letter-spacing:3px; min-height:70px; display:flex; align-items:center;} + .fl-word.dim{color:#c2cddc;} + .fl-pic{font-size:46px; min-height:56px; display:flex; align-items:center;} + .fl-pic img{width:56px; height:56px; object-fit:contain;} + .fl-sel{ + border:1px solid var(--line); border-radius:10px; height:34px; font:inherit; + font-weight:800; font-size:13px; color:var(--muted); background:var(--surface); + padding:0 6px; outline:none; cursor:pointer; + } + /* =========== Chop & blend widget =========== */ + .hk-word{min-height:70px; display:flex; align-items:center; gap:4px; flex-wrap:wrap; justify-content:center;} + .hk-whole{font-size:52px; font-weight:900; letter-spacing:3px;} + .hk-l{font-size:46px; font-weight:900; animation:pop .4s both;} + .hk-l.v{color:var(--red);} + .hk-l.c{color:var(--blue);} + .hk-sep{font-size:30px; color:var(--muted); font-weight:700; margin:0 3px;} + /* =========== Balloon word widget =========== */ + .bl-balloons{font-size:34px; letter-spacing:4px; min-height:44px;} + .bl-word{font-size:44px; font-weight:900; letter-spacing:6px; min-height:56px; display:flex; align-items:center;} + .bl-msg{min-height:26px; font-size:17px; font-weight:800;} + .bl-msg.win{color:var(--green);} + .bl-msg.lose{color:var(--red);} + .bl-kbd button:disabled{cursor:default;} + /* =========== Anchor words widget =========== */ + .an{flex:1; min-height:0; display:flex; flex-direction:column;} + .an-hint{ + flex:none; font-size:11.5px; font-weight:700; color:var(--muted); + padding:6px 10px; background:var(--surface-2); border-bottom:1px solid var(--line); + } + /* bibliotheekknoppen: bewaar (💾) en verkenner (🗂) rechtsboven op de stage */ + .an{position:relative;} + .an-tools{position:absolute; top:34px; right:8px; z-index:3; display:flex; gap:6px;} + .an-tools button{ + border:1px solid var(--line); background:var(--surface); border-radius:10px; + width:34px; height:34px; cursor:pointer; font-size:15px; opacity:.85; + } + .an-tools button:hover{opacity:1; border-color:var(--blue);} + .an-stage{flex:1; min-height:0; position:relative; overflow:hidden; background:var(--surface); cursor:grab; touch-action:none;} + .an-stage:active{cursor:grabbing;} + .an-layer{position:absolute; inset:0;} + .an-cell{ + position:absolute; width:128px; height:56px; border-radius:12px; border:2px dashed #dbe4f0; + display:flex; align-items:center; justify-content:center; gap:6px; font-weight:800; font-size:15px; + color:var(--ink); padding:2px 8px; overflow:hidden; text-align:center; cursor:pointer; + } + .an-cell:hover{border-color:#b6c6dc;} + .an-cell.filled{border-style:solid; border-color:#cdd7e4; background:var(--surface); box-shadow:0 3px 8px rgba(30,50,90,.12);} + .an-cell.root{border:2.5px solid #d9a400; background:#fff8dd;} + .an-cell.root.filled{background:var(--yellow); font-size:16px;} + .an-cell input{width:100%; border:none; outline:none; font:inherit; text-align:center; background:transparent;} + .an-word{overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:100%;} + .an-pic{ + flex:none; width:22px; height:22px; display:flex; align-items:center; + justify-content:center; font-size:16px; line-height:1; + } + .an-pic img{width:100%; height:100%; object-fit:contain;} + /* geen automatisch plaatje (nog) - lege, klikbare plek i.p.v. een nep-ankersymbool, + dient als snelkoppeling naar de plaatjeskiezer */ + .an-pic-empty{ + border:1.5px dashed #cdd7e4; border-radius:6px; cursor:pointer; opacity:.6; + transition:opacity .15s, border-color .15s; + } + .an-pic-empty:hover{opacity:1; border-color:var(--blue);} + .an-dot{ + position:absolute; right:4px; bottom:4px; width:15px; height:15px; border-radius:50%; + border:1.5px solid #fff; box-shadow:0 1px 3px rgba(0,0,0,.3); cursor:pointer; padding:0; + opacity:.55; transition:opacity .15s; + } + .an-cell:hover .an-dot{opacity:1;} + body.hc .an-cell{border-color:#555; color:#000;} + /* =========== Dice widget =========== */ + .dc{flex:1; min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px; align-items:center;} + .dc-row{flex:1; display:flex; gap:14px; align-items:center; justify-content:center; flex-wrap:wrap; cursor:pointer;} + .die{ + width:92px; height:92px; background:var(--surface); border:3.5px solid var(--ink); border-radius:18px; + display:grid; grid-template-columns:repeat(3,1fr); grid-template-rows:repeat(3,1fr); + padding:10px; box-shadow:0 5px 0 rgba(0,0,0,.15); + } + .die.rolling{animation:shake .3s infinite;} + .die i{width:16px; height:16px; border-radius:50%; background:var(--ink); place-self:center; visibility:hidden;} + .die i.on{visibility:visible;} + .dc-sum{font-size:22px; font-weight:900; min-height:28px;} + /* =========== Traffic light widget =========== */ + .tl{flex:1; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; padding:14px;} + .tl-house{width:min(100%,390px); background:#22272f; border-radius:26px; padding:14px; display:flex; flex-direction:column; gap:9px; box-shadow:0 10px 26px rgba(0,0,0,.35);} + .tl-lamp{ + width:70px; height:70px; flex:none; border-radius:50%; border:4px solid rgba(255,255,255,.13); cursor:pointer; + opacity:.22; transition:all .2s; padding:0; + } + .tl-lamp.red{background:#e33; color:#e33;} + .tl-lamp.orange{background:#f5a623; color:#f5a623;} + .tl-lamp.green{background:#2ecc71; color:#2ecc71;} + .tl-lamp.on{opacity:1; box-shadow:0 0 46px currentColor;} + .tl-option{display:flex;align-items:center;gap:15px;color:#fff}.tl-option>span{display:flex;flex-direction:column;gap:2px}.tl-option strong{font-size:18px}.tl-option small{color:#cbd3df;font-size:13px;font-weight:700}.tl-status{min-height:25px;color:var(--ink);font-size:17px;font-weight:900;text-align:center} + .tl-mini{padding:12px 10px; gap:10px; border-radius:20px;} + .tl-mini .tl-lamp{width:62px; height:62px;} + /* =========== Name picker widget =========== */ + .np{flex:1; min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px;} + .np textarea{ + flex:1; min-height:0; border:1px solid var(--line); border-radius:12px; padding:10px; + font:inherit; font-size:14.5px; resize:none; outline:none; + } + .np-result{ + min-height:72px; display:flex; align-items:center; justify-content:center; + font-size:32px; font-weight:900; color:#fff; text-align:center; padding:8px 12px; + background:linear-gradient(135deg,var(--blue),var(--purple)); + border-radius:18px; position:relative; overflow:hidden; + text-shadow:0 2px 6px rgba(0,0,0,.3); letter-spacing:1px; + } + .np-result.spin{animation:npspin .25s infinite; background:linear-gradient(135deg,var(--orange),var(--red));} + .np-result.winner{animation:pop .45s;} + @keyframes npspin{0%,100%{transform:scale(1) rotate(-1deg);} 50%{transform:scale(1.04) rotate(1deg);}} + .np-row{display:flex; gap:10px; align-items:center; flex:none;} + .np-row label{font-size:13px; font-weight:700; color:var(--muted); display:flex; gap:5px; align-items:center; margin-left:auto;} + .np-progress{margin:-3px 0;color:var(--muted);font-size:12px;font-weight:900;text-align:center} + /* gallery categories */ + .gcat-title{font-size:13.5px; font-weight:800; color:var(--muted); margin:16px 0 8px; text-transform:uppercase; letter-spacing:.5px;} + .gcat-title:first-child{margin-top:0;} + /* =========== Timer widget =========== */ + .timer{display:flex; flex-direction:column; align-items:center; flex:1; min-height:0; gap:10px; padding:14px;} + .tstage{ + flex:1; min-height:0; width:100%; display:flex; flex-direction:column; + align-items:center; justify-content:center; gap:8px; + } + .topts{display:flex; justify-content:space-between; align-items:center; width:100%; flex:none;} + .tseg{display:flex; background:var(--surface-2); border-radius:999px; padding:3px; gap:2px;} + .tseg button{border:none; border-radius:999px; background:transparent; padding:6px 12px; font-size:15px; cursor:pointer; line-height:1; color:var(--muted);} + .tseg button.on{background:var(--surface); color:var(--ink); box-shadow:var(--shadow-1);} + .mm-seg button{font:inherit; font-weight:700; font-size:13px;} + .ticon{border:none; background:var(--surface-2); border-radius:var(--radius); padding:7px 11px; font-size:15px; cursor:pointer; line-height:1;} + .ticon.on{background:var(--accent-soft); color:var(--accent-ink);} + .timer .tpie{ + flex:1 1 0; min-height:80px; aspect-ratio:1/1; width:auto; max-width:100%; max-height:220px; + display:none; cursor:grab; touch-action:none; + } + .timer .tpie:active{cursor:grabbing;} + .timer.m-pie .tpie,.timer.m-both .tpie{display:block;} + .timer .tdisp{font-size:46px; font-weight:900; font-variant-numeric:tabular-nums; flex:none;} + .timer.m-both .tdisp{font-size:26px;} + .timer.m-pie .tdisp{font-size:17px; color:var(--muted);} + .timer .tdisp.warn{color:var(--red); animation:blink 1s infinite;} + @keyframes blink{50%{opacity:.35;}} + .timer .trow{display:flex; gap:8px; flex-wrap:wrap; justify-content:center; flex:none;} + .thint{flex:none;} + .timer .presets button{ + border:none; border-radius:999px; padding:6px 13px; background:var(--surface-2); font:inherit; + font-weight:800; font-size:12.5px; color:var(--muted); cursor:pointer; + } + .timer .presets button:hover{background:var(--surface-3); color:var(--ink);} + .timer .main button{ + border:none; border-radius:12px; padding:10px 26px; font:inherit; font-weight:800; + font-size:15.5px; cursor:pointer; background:var(--surface-2); color:var(--ink); + } + .timer .main .bstart{background:var(--green); color:#fff; min-width:110px;} + .thint{font-size:11.5px; color:var(--muted); text-align:center; line-height:1.4;} + .timer.flash{animation:tflash .5s 4;} + @keyframes tflash{50%{background:#fdeaa0;}} + +/* ---------- login-extra's (school, koppelcode) ---------- */ +#liSchool{ + width:100%; border:1px solid var(--line); border-radius:var(--radius); + height:var(--control-h); padding:0 12px; color:var(--ink); + font:inherit; font-size:15px; outline:none; margin-bottom:10px; background:var(--surface); +} +.lisub{font-size:13px; font-weight:700; color:var(--muted); margin-bottom:8px;} +#ownPwBlock .lrow{margin-bottom:10px;} +#ownPwBlock input{flex:1; min-width:0;} + +/* systeemmanager: veilige voorbeeldweergave van vaste rollen */ +.vw-role-block[hidden],#rolePreviewBanner[hidden],#rolePreviewTeacherActions[hidden]{display:none} +.vw-role-block{margin-bottom:8px;padding:10px 12px;border:1px solid var(--accent-soft);border-radius:12px;background:var(--surface-2)} +.vw-role-block .vw-row{align-items:flex-start;padding-top:0}.vw-role-seg{flex-wrap:wrap;justify-content:flex-end;max-width:min(620px,72%)} +.vw-role-note{margin-top:7px} +#rolePreviewBanner{position:fixed;top:calc(var(--tbar) + 8px);left:50%;z-index:5700;display:flex;align-items:center;gap:8px;transform:translateX(-50%);padding:7px 9px 7px 12px;border:1px solid var(--orange);border-radius:999px;background:var(--surface);box-shadow:var(--shadow-2);font-size:12px} +#rolePreviewBanner span{font-weight:800;color:var(--orange)}#rolePreviewBanner button{border:0;border-radius:999px;padding:5px 9px;background:color-mix(in srgb,var(--orange) 16%,var(--surface));color:var(--ink);font:inherit;font-weight:800;cursor:pointer} +#rolePreviewTeacherActions{display:flex;align-items:center;gap:5px} +#rolePreviewTeacherActions button{background:var(--accent);color:#fff} +#rolePreviewTeacherActions button:hover,#rolePreviewTeacherActions button:focus-visible{filter:brightness(1.08);outline:2px solid var(--accent-soft);outline-offset:1px} +.role-preview-note{max-width:760px;margin:0 auto 14px;padding:11px 14px;border:1px solid var(--orange);border-radius:12px;background:color-mix(in srgb,var(--orange) 16%,var(--surface));color:var(--ink);font-size:13px;line-height:1.45} +body.role-preview-active #parentView button:disabled,body.role-preview-active #parentView input:disabled,body.role-preview-active #parentView select:disabled{opacity:.55;cursor:not-allowed} +body.role-preview-pupil #pupilView .widget-body{pointer-events:none;user-select:none} + +.role-preview-note .role-preview-add-widget{display:flex;margin-top:9px} +.role-preview-widget-picker{width:min(760px,94vw);max-height:88vh;overflow:auto} +.role-preview-widget-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;margin-top:12px} +.role-preview-widget-card{width:100%;display:grid;grid-template-columns:50px minmax(0,1fr);align-items:center;gap:10px;padding:11px;border:1px solid var(--line);border-radius:14px;background:var(--surface);color:var(--ink);text-align:left;cursor:pointer} +.role-preview-widget-card:hover,.role-preview-widget-card:focus-visible{border-color:var(--accent);outline:2px solid var(--accent-soft)} +.role-preview-widget-card .gi{font-size:30px;text-align:center}.role-preview-widget-card>span:last-child{min-width:0}.role-preview-widget-card .gn,.role-preview-widget-card .gd{display:block}.role-preview-widget-card .gd{margin-top:3px;color:var(--muted);font-size:11px;line-height:1.35} +body.role-preview-updating #board{visibility:hidden} +@media(max-width:560px){.role-preview-widget-grid{grid-template-columns:1fr}} +body.tb-left #rolePreviewBanner,body.tb-right #rolePreviewBanner{top:8px} +@media(max-width:720px){.vw-role-block .vw-row{align-items:stretch;flex-direction:column}.vw-role-seg{max-width:none;justify-content:flex-start}#rolePreviewBanner{max-width:calc(100vw - 24px);flex-wrap:wrap;justify-content:center;border-radius:16px;white-space:nowrap}#rolePreviewBanner strong{display:none}#rolePreviewTeacherActions{order:3;width:100%;justify-content:center}} + +/* ---------- weergave-instellingen (tabblad "Weergave") ---------- */ +.vw-row{ + display:flex; align-items:center; justify-content:space-between; gap:12px; + min-height:var(--control-h); padding:7px 0; border-bottom:1px solid var(--line); +} +.vw-row:last-of-type{border-bottom:none;} +.vw-lbl{font-weight:700; font-size:14.5px;} +.seg{display:flex; background:var(--surface-2); border-radius:999px; padding:3px; gap:2px; flex:none;} +.seg button{ + border:none; border-radius:999px; padding:6px 13px; font:inherit; font-weight:700; + cursor:pointer; background:transparent; color:var(--muted); font-size:13.5px; +} +.seg button.active{background:var(--surface); color:var(--ink); box-shadow:var(--shadow-1);} +/* ---------- beheerpaneel (tabblad "Beheer" binnen Instellingen) ---------- + alle velden/knoppen in een rij zijn even hoog: var(--control-h) */ +.am-head{display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;} +.am-head h2{margin:0; font-size:20px;} +.am-h3{margin:14px 0 6px; font-size:14px; color:var(--muted); text-transform:uppercase; letter-spacing:.5px;} +/* School/Klassen/Gebruikers staan nu na elkaar in één tabblad i.p.v. drie + losse - wat lucht onder elk blok zodat de volgende kop niet knelt, en een + duidelijke scheidingslijn zodra er een nieuwe sectie begint (alleen ná een + sectie, dus niet boven de allereerste kop van de pagina) */ +.am-section{margin-bottom:14px;} +.am-section + .am-h3{margin-top:22px; padding-top:16px; border-top:1px solid var(--line);} + +/* ---- "School" drill-down navigator: tegel-overzicht -> gefocust detail ---- */ +.am-nav-header{display:flex; gap:8px; margin-bottom:12px;} +.am-nav-header .am-filter{flex:1;} +.am-crumb-back{ + background:none; border:none; color:var(--accent-ink); font-weight:700; + font-size:14px; cursor:pointer; padding:8px 6px; font:inherit; border-radius:8px; +} +.am-crumb-back:hover{background:var(--surface-2);} +.am-stat-strip{display:flex; gap:10px; margin-bottom:14px;} +.am-stat{flex:1; background:var(--surface-2); border-radius:var(--radius-s); padding:8px 10px; text-align:center;} +.am-stat b{display:block; font-size:18px; font-variant-numeric:tabular-nums;} +.am-stat span{font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.3px;} +.am-tilegrid{display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:6px;} +.am-tilegrid > .am-add-wrap{grid-column:1 / -1;} +.am-tile{ + background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); + padding:12px 14px; text-align:left; cursor:pointer; font:inherit; color:var(--ink); + display:flex; flex-direction:column; gap:8px; +} +.am-tile:hover{background:var(--surface-2);} +.am-tile-name{font-weight:700; font-size:14.5px;} +.am-tile-chips{display:flex; flex-wrap:wrap; gap:5px;} +.am-tile.wide{grid-column:1 / -1; flex-direction:row; align-items:center; justify-content:space-between;} +.am-tile.warn{border-color:color-mix(in srgb, var(--orange) 45%, var(--line));} +.am-tile .am-chip.flag{background:var(--orange); color:#1d1200; border-color:transparent;} +.am-tile.add{ + border-style:dashed; border-color:var(--accent); color:var(--accent-ink); background:var(--accent-soft); + align-items:center; justify-content:center; flex-direction:row; font-weight:700; +} +.am-kebab-row{display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; gap:8px;} +.am-nav-title{font-family:"Quicksand","Segoe UI Rounded",ui-rounded,sans-serif; font-size:17px; margin:0;} +.am-kebab{ + border:1px solid var(--line); background:var(--surface); border-radius:999px; + width:32px; height:32px; cursor:pointer; color:var(--muted); font-size:16px; flex:none; +} +.am-kebab:hover{background:var(--surface-2);} +.am-kebab-panel{ + background:var(--surface-2); border-radius:var(--radius-s); padding:10px; + margin-bottom:14px; display:flex; flex-direction:column; gap:8px; +} +.am-section-label{ + font-size:11.5px; font-weight:700; text-transform:uppercase; letter-spacing:.4px; + color:var(--muted); margin:14px 0 6px; +} +.am-person,.am-target{cursor:pointer;} +.am-person-info{flex:1; display:flex; flex-direction:column; min-width:0; gap:1px;} +.am-person-info .am-name{font-size:14px;} +.am-person-info .am-role{font-size:11.5px; color:var(--muted); font-weight:600;} +.am-sheet-backdrop{position:fixed; inset:0; background:rgba(15,22,38,.4); z-index:60; display:none;} +.am-sheet-backdrop.open{display:block;} +/* de Cast-sheet opent vanaf het bord zelf (buiten Instellingen om), dus moet + boven de zwevende pen-HUD (#hud, z-index 5500) uitkomen - zelfde laag als + .modalwrap (6000), niet de 60 die volstaat zodra je al in Instellingen zit */ +#castBackdrop{z-index:6000;} +.am-sheet{ + position:absolute; left:0; right:0; bottom:0; max-width:520px; margin:0 auto; + background:var(--surface); border-radius:18px 18px 0 0; padding:16px 18px 24px; + max-height:82vh; overflow-y:auto; box-shadow:var(--shadow-2); +} +@media (min-width:560px){ + .am-sheet{border-radius:var(--radius-l); bottom:auto; top:50%; transform:translateY(-50%); max-height:76vh;} +} + +.am-msg{ + min-height:20px; font-size:13.5px; font-weight:700; color:var(--green); margin:4px 0; + display:flex; align-items:center; gap:8px; flex-wrap:wrap; +} +/* vaste schoolkiezer voor de systeemmanager, boven alle beheer-tabs */ +.am-schoolbar{ + display:flex; align-items:center; gap:8px; margin:0 0 10px; + padding:8px 10px; background:var(--surface-2); border-radius:12px; +} +.am-schoolbar-lbl{font-size:16px;} +.am-schoolbar .am-sel{flex:1; min-width:0;} +/* de dichtgeklapte "+ ..."-knop hoort rechts, niet als volle-breedte-rij + links uitgelijnd zoals een gebruikersrij - dat maakt meteen duidelijk dat + het om een aparte actie gaat, niet om nóg een item in de lijst. Eenmaal + uitgeklapt neemt het formulier zelf wél de volle breedte (align-self niet + gezet op .am-add, dus terug naar de stretch van de kolom-flex hierboven). */ +.am-add-wrap{display:flex; flex-direction:column;} +.am-add-wrap > .tbtn{align-self:flex-end;} +/* een uitgeklapt toevoegformulier moet nooit op een bestaande-gebruikersrij + lijken: duidelijk eigen kader (accentkleur, stippellijn) i.p.v. de neutrale + achtergrond van .am-row/.am-row-tools, plus een titel die herhaalt wát je + aan het toevoegen bent zodra de knoptekst zelf uit beeld is. */ +.am-add{ + display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:8px 0; + background:var(--accent-soft); border:1.5px dashed var(--accent); + border-radius:var(--radius); padding:10px; +} +.am-add-title{ + flex:1 1 100%; font-weight:800; font-size:12.5px; color:var(--accent-ink); + text-transform:uppercase; letter-spacing:.4px; +} +.am-inp,.am-sel,.am-pw{ + border:1px solid var(--line); border-radius:var(--radius); height:var(--control-h); + padding:0 11px; font:inherit; font-size:14px; outline:none; + background:var(--surface); color:var(--ink); min-width:0; +} +.am-inp:focus,.am-sel:focus,.am-pw:focus{border-color:var(--accent);} +/* de generieke settings-input-regel (width:100%/marge) geldt niet in het beheerpaneel: + daar bepalen de am-*-regels de maat, zodat alles in een rij gelijk uitlijnt */ +#settingsModal #adminModal input{margin-bottom:0;} +.am-inp{flex:1; min-width:140px;} +.am-pw{width:120px;} +.am-list{display:flex; flex-direction:column; gap:5px;} +.am-group{font-weight:700; font-size:13px; margin-top:10px; color:var(--muted); text-transform:uppercase; letter-spacing:.4px;} +/* inklapbare groepskop (leerlingen per klas): klikbaar, pijltje in de tekst */ +.am-group-toggle{cursor:pointer; user-select:none;} +.am-group-toggle:hover{color:var(--ink);} +/* formulier-fout direct bij het formulier zelf */ +.am-form-err{width:100%; min-height:0; color:var(--red); font-size:13px; font-weight:700;} +/* Systeem-tab: schoolrij duidelijk zwaarder dan de beheerder-subrijen */ +.am-row-school{background:var(--accent-soft);} +.am-row-school > .am-name{font-size:15px;} +.am-row-sub{ + margin-left:22px; background:var(--surface); + border-left:3px solid var(--line); border-radius:var(--radius-s); +} +/* uitklapbaar gereedschapsblok per gebruikersrij (achter het ✏-knopje) */ +.am-row-tools{ + display:flex; gap:8px; align-items:center; flex-wrap:wrap; width:100%; + margin-top:6px; padding-top:8px; border-top:1px dashed var(--line); +} +.am-edit{ + border:none; background:transparent; border-radius:8px; cursor:pointer; + font-size:14px; padding:4px 8px; opacity:.6; +} +.am-edit:hover{opacity:1; background:var(--surface-3);} +.am-edit.on{opacity:1; background:var(--accent-soft);} +/* uniforme maat: álle knoppen en velden in het beheer op controlhoogte, + zodat formulieren en actieknoppen in elke rij gelijk uitlijnen */ +#adminModal .tbtn{ + min-height:var(--control-h); height:var(--control-h); + padding:0 14px; font-size:14px; +} +/* één interactiesysteem: mini-label boven elk veld/select/knopgroep */ +.am-field{display:flex; flex-direction:column; gap:3px; min-width:0;} +.am-field-lbl{ + font-size:10.5px; font-weight:800; color:var(--muted); + text-transform:uppercase; letter-spacing:.4px; +} +/* de rij-flexregels van .am-inp (flex-basis 0/100%) gelden binnen de + kolom-flex van het label verticaal en drukten het veld plat - binnenin + een veldlabel dus altijd de vaste controlhoogte, breedte via het label */ +.am-field > .am-inp, .am-field > .am-sel, .am-field > .am-pw, .am-field > .am-inline, +.am-field > .am-classes, .am-field > .am-actions{flex:none; width:100%;} +.am-add .am-field{flex:1 1 100%;} +.am-row-tools .am-field{flex:0 1 auto; min-width:150px;} +.am-inline{display:flex; gap:6px; align-items:center;} +.am-inline > .am-inp, .am-inline > .am-pw{flex:1 1 auto; min-width:0;} + +.learning-profile-badge{ + margin:8px 10px 0;padding:6px 10px;border-radius:10px; + background:color-mix(in srgb,var(--accent) 12%,var(--surface)); + color:var(--muted);font-size:12px;font-weight:700;line-height:1.3; +} +.learning-level-card{ + margin:10px 0;padding:12px;border:1px solid var(--line);border-radius:12px; + background:var(--surface-2); +} +.learning-level-card h3{margin:0 0 6px;} +.learning-level-card .guestnote{margin:4px 0 10px;} +.learning-source-note{margin-top:14px;} +.parent-levels{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:8px;} +.parent-levels .learning-level-card{margin:0;} +.learning-level-summary{margin:8px 0 0;} +.managed-learning-level .lg-levels, +.managed-learning-level .clock-level-control, +.managed-learning-level .place-level-control, +.managed-learning-level .money-level-control{display:none;} +.board-learning-level{display:grid;grid-template-columns:auto auto minmax(130px,1fr);align-items:end;gap:8px;padding:8px 10px;border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--accent) 7%,var(--surface));font-size:11px} +.board-learning-level label{display:grid;gap:3px;color:var(--muted);font-weight:800} +.board-learning-select{min-width:104px;border:1px solid var(--line);border-radius:8px;padding:6px 8px;background:var(--surface);color:var(--ink);font:inherit;font-weight:750} +.board-learning-summary{align-self:center;overflow:hidden;color:var(--muted);font-weight:700;text-overflow:ellipsis;white-space:nowrap} +@media(max-width:620px){.board-learning-level{grid-template-columns:1fr 1fr}.board-learning-summary{grid-column:1/-1}.board-learning-select{width:100%;min-width:0}} +@media(max-width:620px){.learning-level-card .am-inline{align-items:stretch;flex-direction:column;}} +/* gevaarlijke acties: afwijkende kleur + tweede klik als bevestiging */ +.am-btn.am-danger{background:var(--red-soft); color:var(--red); font-weight:800;} +.am-btn.am-danger.armed{background:var(--red); color:#fff;} +/* credential-resultaat direct bij de gebruikersrij */ +.am-result{ + display:flex; align-items:center; gap:8px; width:100%; + background:var(--accent-soft); color:var(--accent-ink); + border-radius:10px; padding:8px 12px; font-weight:800; +} +.am-result code{font-size:16px; letter-spacing:.5px;} +.am-row{ + display:flex; gap:8px; align-items:center; background:var(--surface-2); + border-radius:var(--radius); padding:7px 10px; flex-wrap:wrap; +} +.am-name{font-weight:700; font-size:14px; flex:1; min-width:120px;} +.am-role{font-size:12px; font-weight:700; color:var(--muted);} +.am-code{font-size:12.5px; font-weight:700; color:var(--orange);} +.am-actions{display:flex; align-items:center; gap:8px; flex-wrap:wrap;} +.am-btn,.am-copy{ + background:var(--surface); border-radius:var(--radius); + height:var(--control-h); padding:0 12px; border:1px solid var(--line); + font:inherit; font-weight:700; font-size:12.5px; cursor:pointer; color:var(--ink); line-height:1; +} +.am-btn:hover,.am-copy:hover{background:var(--surface-3);} +/* actieve knop in een keuzerij (vakgebied-tabs in Klassenmanagement/Voortgang) - + ontbrak nog, waardoor niet te zien was welk vakgebied geselecteerd was. */ +.am-btn.active{border-color:var(--accent); background:var(--accent-soft); color:var(--accent-ink);} +.am-del{ + border:1px solid var(--line); background:var(--red-soft); border-radius:var(--radius); + width:var(--control-h); height:var(--control-h); cursor:pointer; font-size:13px; flex:none; +} +.am-del:hover{background:var(--red);} +.am-classes{display:flex; gap:6px; flex-wrap:wrap; align-items:center;} +.am-chip{ + display:inline-flex; gap:6px; align-items:center; background:var(--surface); + border:1px solid var(--line); border-radius:999px; padding:5px 12px; + font-weight:700; font-size:13px; +} +button.am-chip{cursor:pointer; color:var(--muted);} +button.am-chip.on{border-color:var(--accent); background:var(--accent-soft); color:var(--accent-ink);} +.am-chip.active{border-color:var(--green); background:color-mix(in srgb, var(--green) 15%, var(--surface)); color:var(--green);} +.am-chipx{border:none; background:transparent; color:var(--red); font-weight:900; cursor:pointer; padding:0; font-size:11px;} +.am-reset{ + background:var(--surface-2); border:1px solid var(--line); border-radius:12px; + padding:10px 12px; margin:6px 0; display:flex; flex-direction:column; gap:8px; +} +.am-reset-head{display:flex; align-items:center; justify-content:space-between; gap:8px;} +.am-reset-list{display:flex; flex-direction:column; gap:5px; max-height:260px; overflow:auto;} +.am-reset-row{ + display:flex; gap:8px; align-items:center; background:var(--surface); + border-radius:8px; padding:5px 8px; flex-wrap:wrap; +} +.am-reset-pw{font-weight:800; font-family:monospace; font-size:13px; color:var(--green);} +body.hc .am-row,body.hc .am-chip{border:1.5px solid #000; background:var(--surface);} +body.hc .am-reset,body.hc .am-reset-row{border:1.5px solid #000; background:var(--surface);} + +/* ============================================================ + Mobiel & tablet: responsieve aanpassingen. Alles hieronder is + additief - de bovenstaande desktopstijl blijft de standaard. + ============================================================ */ + +/* ---------- tablet (≤1024px) ---------- */ +@media (max-width:1024px){ +} + +/* ---------- telefoon (≤600px) ---------- */ +@media (max-width:600px){ + #toolbar{padding:0 8px; gap:6px; overflow-x:auto;} + /* alleen de uil op kleine schermen, woordmerk verdwijnt */ + .logo-word{display:none;} + .tbtn{padding:0 10px; font-size:13px;} + /* de balk moet écht passen: zonder dit vielen 💾 Opslaan en ⚙ Instellingen + buiten beeld en zat 🖼 (afbeeldingscatalogus) precies onder de duim - + elke mis-tik opende dan het schermvullende afbeeldingenscherm. De + tekstzoom (A−/A+) is toegankelijkheid en blijft dus zichtbaar; alleen + het percentage, de catalogusknop (blijft via +-menu bereikbaar) en + volledig scherm verdwijnen, en opslaan toont alleen 💾. */ + #zoomPct,#btnImages,#btnFs{display:none;} + #btnSave span{display:none;} + #btnSave{min-width:0;} + #userLbl{display:inline-block; max-width:96px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; vertical-align:bottom;} + /* tekenbalk en zwevende knoppen deelden op een telefoon de onderrand en + lagen over elkaar heen: wissen/sluiten op de tekenbalk raakten dan de + +- of bordenknop, waardoor "steeds" het bordenpaneel of +-menu + (→ afbeeldingenscherm) opende. Nu ligt de tekenbalk in een eigen laag + bóven de knoppenrij (hij groeit omhoog, dus dit klopt bij elke + schermbreedte), en houden + en borden de onderrand voor zichzelf. + Gecentreerd op inhoudsbreedte (geen vaste randen), zodat de balk + geminimaliseerd ook echt smal is. */ + #hud{bottom:88px; max-width:calc(100vw - 16px);} + + /* beheerpaneel: dichte rijen worden gestapeld i.p.v. verdrukt */ + .am-row{flex-direction:column; align-items:stretch;} + .am-row .am-pw{width:100%;} + .am-row .am-actions{display:flex; flex-direction:row; justify-content:flex-end; gap:6px;} + .am-reset-row{flex-direction:column; align-items:stretch;} + .am-add .am-inp{flex:1 1 100%;} +} + +/* ---------- aanraakschermen (los van breedte) ---------- */ +@media (pointer:coarse){ + /* alle controls (ook invoervelden) groeien mee naar tikformaat */ + :root{--control-h:44px;} + .hud-btn{width:var(--tap-sm); height:var(--tap-sm); font-size:15px;} + #penbar .wp-group button{width:var(--tap-sm); height:var(--tap-sm);} + #hud.vert{width:104px;} + #hud.vert #penbar .wp-group{grid-template-columns:repeat(2,36px);} + .am-chip{padding:7px 14px;} + .am-chipx{padding:4px;} + /* geen hover op aanraakschermen: de greep moet altijd zichtbaar zijn */ + .widget-rs{opacity:.7; width:38px; height:38px;} +} + +/* ============================================================ + Menubalk-positie: links of rechts (Weergave-instelling). + De balk wordt een verticale kolom; bord en zwevende elementen + schuiven mee via dezelfde body-class. + ============================================================ */ +body.tb-left #toolbar,body.tb-right #toolbar{ + top:0; bottom:0; width:var(--tbar); height:auto; + flex-direction:column; padding:12px 0; gap:10px; + overflow-y:auto; overflow-x:hidden; +} +body.tb-left #toolbar{left:0; right:auto; border-right:1px solid var(--line); border-bottom:none;} +body.tb-right #toolbar{right:0; left:auto; border-left:1px solid var(--line); border-bottom:none;} +body.tb-left .logo-word,body.tb-right .logo-word, +body.tb-left #verTag,body.tb-right #verTag, +body.tb-left #btnSave span,body.tb-right #btnSave span, +body.tb-left #userLbl,body.tb-right #userLbl{display:none;} +body.tb-left #toolbar .tbtn,body.tb-right #toolbar .tbtn{padding:0; width:var(--control-h); min-width:0; flex:none;} +body.tb-left #btnSave,body.tb-right #btnSave{min-width:0;} +body.tb-left #zoomPct,body.tb-right #zoomPct{min-width:0; font-size:11.5px;} +body.tb-left #board,body.tb-left #pupilView{inset:0 0 0 var(--tbar);} +body.tb-right #board,body.tb-right #pupilView{inset:0 var(--tbar) 0 0;} +body.tb-right #fab{right:calc(var(--tbar) + 16px);} +body.tb-right #fabBoardsBtn{right:calc(var(--tbar) + 88px);} +body.tb-right #boardStrip{right:calc(var(--tbar) + 12px);} +body.tb-left #fabmenu{right:24px;} +body.tb-right #fabmenu{right:calc(var(--tbar) + 16px);} + +/* ============================================================ + Weergave-instelling "glas-effect uit": ondoorzichtige panelen + i.p.v. blur - rustiger en lichter voor oudere digiborden. + ============================================================ */ +body.no-glass .widget,body.no-glass #hud,body.no-glass .fab-item, +body.no-glass #boardStrip,body.no-glass .mm-top, +body.no-glass .widget.bare .widget-head{ + backdrop-filter:none; -webkit-backdrop-filter:none; + background:var(--surface); +} +body.no-glass .widget-head{background:var(--surface-2);} + +/* ============================================================ + Donker thema: bord-achtergrondpatronen en glas-laag. + (De chrome-laag volgt automatisch via de tokens in body.dark.) + ============================================================ */ +body.dark #board.bg-dots{background-image:radial-gradient(circle,#2c3a5e 1.4px,transparent 1.4px);} +body.dark #board.bg-lines{background-image:repeating-linear-gradient(#0000 0 39px,#2c3a5e 39px 40px);} +body.dark #board.bg-write{background-image:repeating-linear-gradient( + #0000 0 19px,#243052 19px 20px,#0000 20px 39px,#3a4a70 39px 40.5px,#0000 40.5px 59px,#243052 59px 60px,#0000 60px 79px);} +body.dark #board.bg-grid-s{background-image: + repeating-linear-gradient(#0000 0 24px,#243052 24px 25px), + repeating-linear-gradient(90deg,#0000 0 24px,#243052 24px 25px);} +body.dark #board.bg-grid-b{background-image: + repeating-linear-gradient(#0000 0 49px,#2c3a5e 49px 50px), + repeating-linear-gradient(90deg,#0000 0 49px,#2c3a5e 49px 50px);} +body.dark .widget{background:var(--glass); border-color:var(--glass-line);} +body.dark .widget:hover,body.dark .widget:focus-within{background:var(--glass-strong); border-color:rgba(255,255,255,.14);} +body.dark .widget-head{background:rgba(255,255,255,.04); border-bottom-color:rgba(255,255,255,.08);} +body.dark #hud{background:var(--glass); border-color:var(--glass-line);} +body.dark .fab-item{background:var(--glass-strong); color:var(--ink);} +body.dark .fab-item:hover{background:var(--surface-3);} +body.dark #boardStrip{background:var(--glass-strong); border-color:var(--glass-line);} +body.dark .widget.bare .widget-head{background:var(--glass-strong);} +body.dark .mm-top{background:var(--glass-strong);} +body.dark .mm-pop{background:var(--surface); box-shadow:var(--shadow-2);} +body.dark .dock-item{background:var(--surface-2); color:var(--ink);} +body.dark .dock-item:hover{background:var(--surface-3);} +body.dark .wp-group button.on{background:var(--surface-3); box-shadow:none;} +body.dark .lg-kbd button,body.dark .blok{filter:saturate(.9) brightness(.96);} +body.dark .bs-card canvas{background:#fff;} /* bord-preview blijft de echte (witte) bordkleur tonen */ + + +/* ---------- compacte tekenwerkbalk met selectie-popovers ---------- */ +#penbar{flex-wrap:nowrap; gap:4px; overflow:visible;} +.pen-picker{position:relative; flex:none;} +.pen-current{ + width:42px; height:32px; padding:0 5px; border:1px solid transparent; border-radius:9px; + background:var(--surface-2); color:var(--ink); cursor:pointer; display:flex; + align-items:center; justify-content:center; gap:3px; font:inherit; +} +.pen-current:hover,.pen-picker.open .pen-current{background:var(--surface-3); border-color:var(--line);} +.pen-current-value{font-size:16px; line-height:1; min-width:18px; text-align:center;} +.pen-caret{font-size:8px; color:var(--muted); transform:rotate(0); transition:transform .15s;} +.pen-picker.open .pen-caret{transform:rotate(180deg);} +.pen-current-color{width:18px; height:18px; border-radius:50%; box-shadow:inset 0 0 0 1px rgba(0,0,0,.16);} +.pen-current-size{display:block; border-radius:50%; background:var(--ink);} +.pen-current-bg{width:19px; height:19px; display:block; border:1.5px solid #9db6d8; border-radius:4px; background:#fff;} +.pen-current-bg.bg-dots{background-image:radial-gradient(circle,#9db6d8 1px,transparent 1px); background-size:5px 5px;} +.pen-current-bg.bg-lines{background-image:repeating-linear-gradient(#0000 0 4px,#9db6d8 4px 5px);} +.pen-current-bg.bg-write{background-image:repeating-linear-gradient(#0000 0 2px,#c3d5ec 2px 3px,#0000 3px 5px,#6d92c4 5px 6px);} +.pen-current-bg.bg-grid-s{background-image:repeating-linear-gradient(#0000 0 4px,#9db6d8 4px 5px),repeating-linear-gradient(90deg,#0000 0 4px,#9db6d8 4px 5px);} +.pen-current-bg.bg-grid-b{background-image:repeating-linear-gradient(#0000 0 8px,#9db6d8 8px 9px),repeating-linear-gradient(90deg,#0000 0 8px,#9db6d8 8px 9px);} +#penbar .pen-popover{ + display:none; position:absolute; left:50%; bottom:calc(100% + 10px); transform:translateX(-50%); + z-index:20; width:max-content; max-width:min(300px,calc(100vw - 24px)); padding:7px; + background:var(--surface); border:1px solid var(--line); border-radius:12px; + box-shadow:var(--shadow-2); gap:5px; +} +#penbar .pen-picker.open>.pen-popover{display:flex;} +#penbar .pen-color-popover{width:210px; flex-direction:column; align-items:stretch;} +#penbar .pen-color-popover .wp-group{display:grid; grid-template-columns:repeat(6,28px); background:transparent; padding:0;} +.pen-pop-label{font-size:10px; font-weight:800; color:var(--muted); text-transform:uppercase; letter-spacing:.45px; margin:2px 2px 0;} +.pen-custom{display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:3px; padding-top:7px; border-top:1px solid var(--line); font-size:12px; font-weight:700;} +.pen-custom input{width:34px; height:26px; padding:0; border:0; border-radius:7px; background:transparent; cursor:pointer;} +#hud.vert #penbar .pen-popover{left:calc(100% + 10px); bottom:auto; top:50%; transform:translateY(-50%);} +#hud.vert #penbar .pen-color-popover{display:none;} +#hud.vert #penbar .pen-picker.open>.pen-color-popover{display:flex;} +body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000;} +@media (max-width:600px),(pointer:coarse){ + #penbar{gap:3px;} + .pen-current{width:40px; height:36px;} + #penbar .pen-popover{position:fixed; left:50%; bottom:70px; transform:translateX(-50%);} +} + + +/* ============================================================ + Nieuwe widgets: verjaardagstaart, getallenlijn en zinsbouwer + ============================================================ */ +/* Verjaardagstaart */ +.bd{height:100%; min-height:0; display:grid; grid-template-columns:minmax(180px,220px) 1fr; background:var(--surface);} +.bd-controls{padding:12px; overflow:auto; display:flex; flex-direction:column; gap:8px; background:var(--surface-2); border-right:1px solid var(--line);} +.bd-controls input[type=text],.bd-controls input[type=number],.bd-controls select{ + width:100%; height:36px; border:1px solid var(--line); border-radius:9px; padding:0 9px; background:var(--surface); color:var(--ink); font:inherit; font-size:13px; +} +.bd-controls label{display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:800; color:var(--muted);} +.bd-controls input[type=range]{width:96px;}.bd-color input{width:42px; height:30px; padding:1px; border:0; background:transparent; cursor:pointer;} +.bd-check{justify-content:flex-start !important;}.bd-party{margin-top:auto; min-height:38px;} +.bd-stage{min-width:0; position:relative; overflow:hidden; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:54px 16px 34px; background:radial-gradient(circle at 50% 20%,#fff 0,var(--surface-2) 72%);} +.bd-title{position:absolute; top:16px; left:12px; right:12px; text-align:center; font-size:clamp(20px,4vw,34px); font-weight:900; color:var(--pink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.bd-cake{width:min(100%,390px); display:flex; flex-direction:column; align-items:center; filter:drop-shadow(0 10px 10px rgba(29,52,97,.18)); z-index:2;} +.bd-layer{height:72px; position:relative; background:var(--cake); border-radius:20px 20px 12px 12px; border-bottom:8px solid color-mix(in srgb,var(--cake),#8b4b2b 18%); box-shadow:inset 0 13px 0 var(--icing); flex:none;} +.bd-layer-1{width:94%;}.bd-layer-2{width:76%;}.bd-layer-3{width:58%;} +.bd-deco{position:absolute; inset:16px 8px auto; text-align:center; color:#fff; font-weight:900; letter-spacing:5px; white-space:nowrap; overflow:hidden; text-shadow:0 1px 2px rgba(0,0,0,.25);} +.bd-deco.sprinkles{color:#fff5a5;}.bd-deco.stars{color:#fff0a8;font-size:18px}.bd-deco.fruit{font-size:17px;letter-spacing:2px;} +.bd-candle-row{position:absolute; left:8%; right:8%; top:-31px; height:34px; display:flex; justify-content:center; align-items:flex-end; gap:3px;} +.bd-candle{display:block; width:5px; height:25px; border-radius:2px; background:repeating-linear-gradient(45deg,#fff 0 3px,var(--blue) 3px 6px); position:relative;} +.bd-candle::before{content:""; position:absolute; width:8px; height:12px; left:-1.5px; top:-12px; border-radius:70% 30% 60% 40%; background:linear-gradient(#fff7a0,var(--orange)); box-shadow:0 0 8px var(--orange);} +.bd-age-badge{position:absolute; right:8%; top:20%; z-index:4; min-width:58px; height:58px; padding:0 10px; border-radius:20px; display:grid; place-items:center; color:#fff; font-size:29px; font-weight:900; transform:rotate(7deg); box-shadow:0 5px 12px rgba(0,0,0,.2); border:3px solid #fff;} +.bd-plate{width:min(94%,420px); height:30px; border-radius:50%; margin-top:-12px; box-shadow:0 8px 12px rgba(29,52,97,.22); z-index:1;} +/* =========== Personage (avatar): schaalbare SVG met anatomische ankerpunten + en vaste voor-/achterlagen voor haar en accessoires. ====================== */ +.avatar-face{display:inline-block; flex:none; overflow:visible;} +.avatar-face.sm{width:24px; height:24px;} +.avatar-face.md{width:44px; height:44px;} +.avatar-face.lg{width:180px; height:180px;} +.avatar-dance-host{isolation:isolate;} +.avatar-dance-stage{position:absolute;inset:0;z-index:30;display:flex;align-items:center;justify-content:center;pointer-events:none;overflow:hidden;animation:avDanceStage 3.35s ease both;} +.avatar-dance-stage::before{content:"";position:absolute;width:min(70%,260px);aspect-ratio:1;border-radius:50%;background:radial-gradient(circle,color-mix(in srgb,var(--accent) 22%,transparent),transparent 68%);} +.avatar-dance-stage .avatar-face{position:relative;width:clamp(92px,34%,150px);height:clamp(92px,55%,150px);filter:drop-shadow(0 10px 8px rgba(29,52,97,.22));transform-box:fill-box;transform-origin:50% 88%;will-change:transform;} +.avatar-dance-stage.av-dance-bounce .avatar-face{animation:avDanceBounce 2.8s cubic-bezier(.36,.07,.19,.97) both;} +.avatar-dance-stage.av-dance-twist .avatar-face{animation:avDanceTwist 2.8s ease-in-out both;} +.avatar-dance-stage.av-dance-spin .avatar-face{animation:avDanceSpin 2.8s cubic-bezier(.4,0,.2,1) both;} +.avatar-dance-stage.av-dance-shuffle .avatar-face{animation:avDanceShuffle 2.8s ease-in-out both;} +.avatar-dance-stage.av-dance-wiggle .avatar-face{animation:avDanceWiggle 2.8s ease-in-out both;} +.avatar-dance-stage.av-dance-robot .avatar-face{animation:avDanceRobot 2.8s steps(2,end) both;} +@keyframes avDanceStage{0%{opacity:0}8%,82%{opacity:1}100%{opacity:0}} +@keyframes avDanceBounce{0%,100%{transform:translateY(0) scale(1)}10%,36%,62%{transform:translateY(5px) scale(1.08,.92)}22%,48%,74%{transform:translateY(-34px) scale(.96,1.04)}86%{transform:translateY(-12px) rotate(5deg)}} +@keyframes avDanceTwist{0%,100%{transform:rotate(0) translateX(0)}12%,36%,60%,84%{transform:rotate(-13deg) translateX(-10px)}24%,48%,72%{transform:rotate(13deg) translateX(10px)}90%{transform:scale(1.08)}} +@keyframes avDanceSpin{0%,10%,100%{transform:rotate(0) scale(1)}20%{transform:translateY(-12px) rotate(-12deg)}62%{transform:translateY(-20px) rotate(372deg) scale(1.08)}78%{transform:translateY(0) rotate(360deg) scale(1)}88%{transform:scale(1.1,.9)}} +@keyframes avDanceShuffle{0%,100%{transform:translateX(0) rotate(0)}12%,36%,60%{transform:translateX(-30px) translateY(-7px) rotate(-7deg)}24%,48%,72%{transform:translateX(30px) translateY(0) rotate(7deg)}82%{transform:translateX(-10px) scale(1.08,.92)}90%{transform:translateX(10px) scale(.96,1.04)}} +@keyframes avDanceWiggle{0%,100%{transform:rotate(0) scale(1)}10%,30%,50%,70%{transform:rotate(-10deg) scale(1.04)}20%,40%,60%,80%{transform:rotate(10deg) scale(1.04)}88%{transform:translateY(-24px) scale(.95,1.08)}94%{transform:translateY(0) scale(1.1,.9)}} +@keyframes avDanceRobot{0%,100%{transform:translate(0) rotate(0)}15%{transform:translateX(-24px) rotate(-8deg)}30%{transform:translateX(-24px) rotate(8deg)}45%{transform:translateY(-26px) scale(1.06)}60%{transform:translateX(24px) rotate(8deg)}75%{transform:translateX(24px) rotate(-8deg)}90%{transform:translateY(-10px) scale(.96,1.08)}} +.avatar-dance-stage.av-dance-reduced{animation:none;} +.avatar-dance-stage.av-dance-reduced .avatar-face{animation:none;transform:none;} +.avatar-editor{display:flex; flex-direction:column; gap:12px; padding:14px; height:100%; min-height:0; overflow:auto;} +.avatar-preview{display:flex; justify-content:center; padding:10px; background:var(--surface-2); border-radius:16px; flex:none;} +.avatar-controls{display:flex; flex-direction:column; gap:10px;} +.avatar-row{display:flex; align-items:center; gap:8px; flex-wrap:wrap;} +.avatar-row label{display:flex; flex-direction:column; gap:3px; font-size:11px; font-weight:800; color:var(--muted); flex:1; min-width:110px;} +.avatar-row select{height:36px; border:1px solid var(--line); border-radius:9px; padding:0 8px; background:var(--surface); color:var(--ink); font:inherit;} +.avatar-row input[type=color]{width:44px; height:34px; padding:1px; border:0; background:transparent; cursor:pointer;} +.avatar-species-row{display:flex; gap:8px;} +.avatar-species-row button{flex:1; height:40px; border:2px solid var(--line); border-radius:12px; background:var(--surface); color:var(--ink); font:inherit; font-weight:800; cursor:pointer;} +.avatar-species-row button.active{border-color:var(--accent); background:var(--accent-soft); color:var(--accent-ink);} +.avatar-save-msg{min-height:20px; font-size:12.5px; font-weight:800; color:var(--green); text-align:center;} +/* aanmoedigings-badge naast een goed-antwoord-melding (math.js-pilot) */ +.av-cheer{display:inline-flex; vertical-align:middle; margin-left:8px; animation:avCheerBounce .6s ease;} +@keyframes avCheerBounce{0%{transform:scale(.4) translateY(6px); opacity:0;}60%{transform:scale(1.15) translateY(-4px); opacity:1;}100%{transform:scale(1) translateY(0);}} +/* Getallenlijn */ +.nlw{height:100%; min-height:0; display:flex; flex-direction:column; gap:11px; padding:14px; background:var(--surface);} +.nlw-settings{display:flex; align-items:end; gap:8px; flex-wrap:wrap;} +.nlw-settings label{font-size:11px; font-weight:800; color:var(--muted); display:flex; flex-direction:column; gap:3px;} +.nlw-settings input,.nlw-settings select{width:72px; height:34px; border:1px solid var(--line); border-radius:9px; padding:0 8px; background:var(--surface); color:var(--ink); font:inherit;} +.nlw-new{margin-left:auto; min-height:34px;}.nlw-playtop{display:flex;align-items:center;justify-content:center;gap:9px;flex-wrap:wrap}.nlw-task{text-align:center; font-size:clamp(18px,3vw,27px); font-weight:900; color:var(--blue);}.nlw-score{padding:5px 9px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:11px;font-weight:900} +.nlw-scroll{flex:1; min-height:115px; overflow-x:auto; overflow-y:hidden; border-radius:14px; background:var(--surface-2); padding:20px 18px 8px;} +.nlw-line{position:relative; min-width:max-content; height:88px; display:flex; align-items:center; padding:0 18px;} +.nlw-line::before{content:""; position:absolute; height:4px; left:18px; right:18px; top:38px; border-radius:4px; background:var(--ink);} +.nlw-tick{width:52px; height:76px; flex:none; border:0; background:transparent; color:var(--ink); position:relative; cursor:pointer; font:inherit; font-weight:800; padding:0;} +.nlw-tick i{position:absolute; width:3px; height:18px; background:var(--ink); top:31px; left:calc(50% - 1.5px);} +.nlw-tick span{position:absolute; top:53px; left:0; right:0; text-align:center; font-size:12px;} +.nlw-tick.selected::before{content:""; position:absolute; width:23px; height:23px; border-radius:50%; background:var(--pink); border:3px solid #fff; left:calc(50% - 14px); top:27px; z-index:2; box-shadow:0 2px 6px rgba(0,0,0,.25);} +.nlw-actions{display:flex; align-items:center; justify-content:center; gap:8px;}.nlw-value{min-width:76px; text-align:center; font-size:28px; font-weight:900; color:var(--pink);} +.nlw-feedback{min-height:24px; text-align:center; font-weight:800;}.nlw-feedback.ok{color:var(--green);}.nlw-feedback.bad{color:var(--red);} +/* Zinsbouwer */ +.sb{height:100%; min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px; background:var(--surface);} +.sb-sentence{min-height:72px; border:2px dashed var(--line); border-radius:14px; padding:12px; display:flex; align-items:center; justify-content:center; flex-wrap:wrap; gap:7px; font-size:clamp(17px,3vw,25px); font-weight:900;} +.sb-sentence.empty{color:var(--muted); font-size:14px; font-weight:700;}.sb-part{padding:7px 11px; border-radius:10px;}.sb-part.who{background:#dce9ff;color:#174c9b}.sb-part.does{background:#dcf7e7;color:#176b3a}.sb-part.what{background:#fff0cf;color:#855b00} +.sb-banks{flex:1; min-height:0; overflow:auto; display:grid; grid-template-columns:repeat(3,1fr); gap:8px;}.sb-bank{min-width:0; border-radius:12px; padding:9px; background:var(--surface-2);}.sb-bank h3{margin:0 0 7px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.4px;} +.sb-chips{display:flex; flex-wrap:wrap; gap:6px;}.sb-chip-wrap{display:inline-flex;align-items:stretch}.sb-chip{border:0; border-radius:999px; padding:7px 10px; font:inherit; font-size:12.5px; font-weight:800; cursor:pointer; text-align:left;}.sb-bank.who .sb-chip{background:#dce9ff;color:#174c9b}.sb-bank.does .sb-chip{background:#dcf7e7;color:#176b3a}.sb-bank.what .sb-chip{background:#fff0cf;color:#855b00} +.sb-chip-wrap .sb-chip{border-radius:999px 0 0 999px}.sb-chip-remove{border:0;border-radius:0 999px 999px 0;padding:0 7px;background:var(--surface-3);color:var(--muted);font-weight:900;cursor:pointer}.sb-chip-remove:hover{background:var(--red-soft);color:var(--red)} +.sb-add,.sb-actions{display:flex; align-items:center; gap:7px; flex-wrap:wrap;}.sb-add select,.sb-add input{height:36px; border:1px solid var(--line); border-radius:9px; padding:0 9px; background:var(--surface); color:var(--ink); font:inherit;}.sb-add input{flex:1; min-width:150px;}.sb-actions{justify-content:flex-end;}.sb-back{font-size:20px;} +body.dark .bd-stage{background:radial-gradient(circle at 50% 20%,var(--surface-2),var(--surface));} +@media(max-width:600px){.bd{grid-template-columns:1fr}.bd-controls{max-height:155px;border-right:0;border-bottom:1px solid var(--line);display:grid;grid-template-columns:repeat(2,1fr)}.bd-party{margin-top:0}.sb-banks{grid-template-columns:1fr}.nlw-settings{gap:5px}} + +/* Educatieve widgets en compacte widgetbibliotheek */ +.gallery-head,.gallery-tools,.gallery-data{display:flex;align-items:center;gap:8px}.gallery-head h2{flex:1}.gallery-tools{position:sticky;top:-24px;z-index:2;background:var(--surface);padding:6px 0 10px}.gallery-tools input{flex:1;height:42px;border:1px solid var(--line);border-radius:12px;padding:0 13px;background:var(--surface);color:var(--ink);font:inherit}.gallery-data{margin:8px 0 14px;flex-wrap:wrap}#galleryTemplates{display:flex;gap:7px;overflow-x:auto;padding:2px 0 8px}.gcat-title{margin:18px 0 8px;font-size:13px;font-weight:900;color:var(--muted);text-transform:uppercase}.gcard{position:relative}.gstar{position:absolute;right:7px;top:7px;border:0;background:transparent;color:var(--orange);font-size:22px;cursor:pointer}.gallery-empty,.edu-empty{text-align:center;color:var(--muted);padding:28px}.widget.locked .widget-rs{display:none}.widget.locked{outline:2px solid var(--accent-soft)}.widget.preview .widget-head{position:absolute;z-index:4;width:100%;opacity:0}.widget.preview:hover .widget-head,.widget.preview:focus-within .widget-head{opacity:1}.widget.preview .widget-rs{display:none}.widget.preview{border-color:transparent;box-shadow:none}.wpreview.on{background:var(--accent-soft);color:var(--accent-ink)} +.edu{height:100%;display:flex;flex-direction:column;background:var(--surface);color:var(--ink)}.edu-settings{flex:none;background:var(--surface-2);border-bottom:1px solid var(--line)}.edu-settings summary{cursor:pointer;padding:9px 12px;font-weight:800;font-size:13px}.edu-controls{display:flex;align-items:end;gap:8px;flex-wrap:wrap;padding:0 12px 10px}.edu-controls label{display:flex;flex-direction:column;gap:3px;font-size:11px;font-weight:800;color:var(--muted)}.edu-controls [hidden]{display:none}.edu-controls input,.edu-controls select,.edu-controls textarea{min-height:34px;border:1px solid var(--line);border-radius:9px;padding:5px 8px;background:var(--surface);color:var(--ink);font:inherit}.edu-controls textarea{height:65px;resize:vertical}.edu-stage{flex:1;min-height:0;overflow:auto;padding:16px;display:flex;align-items:center;justify-content:center} +.fraction-game{width:min(100%,620px);display:flex;flex-direction:column;align-items:center;gap:9px}.fraction-summary{min-height:34px;padding:5px 13px;border-radius:999px;background:var(--surface-2);color:var(--purple);font-size:18px;font-weight:950}.fraction-wall{width:100%;display:flex;flex-direction:column;gap:5px}.fraction-wall>div{display:flex;gap:4px}.fraction-piece{flex:1;min-height:38px;border:2px solid hsl(var(--fraction-hue) 55% 43%);border-radius:7px;background:hsl(var(--fraction-hue) 75% 90%);font-weight:800;color:#20304a;cursor:pointer;transition:transform .12s,filter .12s}.fraction-piece:hover{filter:brightness(.96);transform:translateY(-1px)}.fraction-piece.selected{background:hsl(var(--fraction-hue) 70% 55%);color:#fff}.fraction-clear{align-self:flex-end;min-height:34px} +.place-grid{display:grid;grid-template-columns:repeat(6,1fr);width:100%;border:1px solid var(--line);border-radius:14px;overflow:hidden}.place-grid div{text-align:center;border-right:1px solid var(--line)}.place-grid b{display:block;padding:8px;background:var(--surface-2);font-size:12px}.place-grid output{display:block;padding:18px 4px;font-size:clamp(25px,5vw,48px);font-weight:900;color:var(--blue)} +.money-app{width:100%;min-height:100%;align-self:flex-start;display:flex;flex-direction:column;justify-content:flex-start;color:var(--ink)} +.money-top{position:sticky;top:-16px;z-index:6;width:100%;display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px;padding:8px 4px;background:var(--surface);border-bottom:1px solid var(--line)}.money-level-control{display:flex;align-items:center;gap:6px}.money-level-control>span{color:var(--muted);font-size:12px;font-weight:900}.money-levels button{min-width:31px;padding:6px 9px;white-space:nowrap}.money-mode{width:36px;padding:0;font-size:18px}.money-score{margin-left:auto;padding:6px 10px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:12px;font-weight:900} +.money-task-card{width:min(100%,650px);margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px;border:1px solid var(--line);border-radius:20px;background:var(--surface);box-shadow:var(--shadow-1)}.money-task-card h3{margin:0;text-align:center;font-size:clamp(19px,3vw,25px)}.money-level-hint{margin:-7px 0 0;text-align:center;color:var(--muted);font-size:12px;font-weight:800} +.money-target{width:100%;min-height:98px;display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;padding:12px;border-radius:16px;background:var(--surface-2)}.money-target>span:first-child{flex-basis:100%;color:var(--muted);font-size:12px;font-weight:900;text-transform:uppercase;letter-spacing:.5px}.money-assignment{display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;text-align:center}.money-task-amount{font-size:clamp(45px,10vw,72px);line-height:1;font-weight:950;color:var(--green)}.money-task-words{flex-basis:100%;margin:0;font-size:clamp(21px,4vw,31px);line-height:1.15;font-weight:950;color:var(--purple)}.money-assignment.compact .money-task-amount{font-size:clamp(38px,8vw,58px)}.money-assignment.compact .money-task-words{font-size:clamp(19px,4vw,27px)} +.money-wallet{width:100%;min-height:112px;padding:10px;border:3px dashed var(--line);border-radius:18px;background:linear-gradient(145deg,var(--surface-2),var(--surface));transition:border-color .15s,background .15s,transform .15s}.money-wallet>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}.money-wallet output{font-size:24px;font-weight:950;color:var(--green)}.money-wallet>p{margin:22px auto;color:var(--muted);font-weight:800;text-align:center}.money-wallet.droptarget{border-color:var(--green);background:#e4f6eb;transform:scale(1.01)}.money-wallet.money-remove-target{border-color:var(--red);background:#fff0ed;box-shadow:0 0 0 4px rgba(190,55,45,.1)} +.money-pile{display:flex;align-items:center;justify-content:center;gap:7px;flex-wrap:wrap;min-height:58px} +.money-piece{position:relative;display:block;flex:none;overflow:hidden;padding:0;border:0;background:transparent;color:#26354a;filter:drop-shadow(0 3px 3px rgba(20,35,60,.28));user-select:none}.money-piece:is(button){appearance:none;cursor:grab;touch-action:none}.money-piece:is(button):active{cursor:grabbing}.money-piece:is(button):focus-visible{outline:3px solid var(--accent);outline-offset:3px}.money-piece-image{display:block;width:100%;height:100%;object-fit:contain;pointer-events:none;user-select:none}.money-piece-fallback{display:none;width:100%;height:100%;place-items:center;border:1px solid rgba(30,40,55,.35);background:var(--surface-2);font:900 12px/1 system-ui}.money-piece.image-missing .money-piece-image{display:none}.money-piece.image-missing .money-piece-fallback{display:grid} +.money-piece.coin{width:44px;height:44px;border-radius:50%}.money-piece.coin[data-value="1"]{width:36px;height:36px}.money-piece.coin[data-value="2"]{width:42px;height:42px}.money-piece.coin[data-value="5"]{width:47px;height:47px}.money-piece.coin[data-value="10"]{width:44px;height:44px}.money-piece.coin[data-value="20"]{width:49px;height:49px}.money-piece.coin[data-value="50"]{width:54px;height:54px}.money-piece.coin[data-value="100"]{width:52px;height:52px}.money-piece.coin[data-value="200"]{width:57px;height:57px} +.money-piece.note{height:43px;border-radius:2px}.money-piece.note .money-piece-fallback{border-radius:2px}.money-piece.note[data-value="500"]{width:82px;height:42px}.money-piece.note[data-value="1000"]{width:86px;height:45px}.money-piece.note[data-value="2000"]{width:89px;height:48px}.money-piece.note[data-value="5000"]{width:93px;height:51px}.money-drag-clone{opacity:.98}.money-drag-clone.from-wallet{opacity:.92;filter:drop-shadow(0 14px 18px rgba(160,45,45,.3))} +.money-tray-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}.money-tray-wrap>b{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.4px}.money-cash-tray{width:100%;display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;padding:11px;border:1px solid var(--line);border-radius:16px;background:var(--surface-2)} +.money-count-task{width:100%;min-height:128px;display:grid;place-items:center;padding:14px;border-radius:17px;background:linear-gradient(145deg,#eef7e9,var(--surface-2));border:1px solid var(--line)}.money-count-task .money-piece{transform:rotate(var(--money-tilt,0deg))} +.money-shop-task{width:100%;display:grid;grid-template-columns:minmax(0,1fr) minmax(150px,.8fr);gap:12px}.money-product,.money-paid{min-height:150px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;padding:12px;border:1px solid var(--line);border-radius:17px;background:var(--surface-2)}.money-product-icon{font-size:clamp(52px,8vw,70px);line-height:1;filter:drop-shadow(0 5px 5px rgba(30,45,70,.2))}.money-product-name{max-width:100%;font-size:17px;font-weight:950;text-align:center}.money-product>b,.money-paid>b{color:var(--muted);font-size:12px;text-transform:uppercase}.money-product .money-task-amount{font-size:37px}.money-product .money-task-words{font-size:19px}.money-paid>output{font-size:31px;font-weight:950;color:var(--blue)} +.money-digit-answer,.money-type-answer{width:min(100%,300px);display:flex;flex-direction:column;align-items:center;gap:10px}.money-digit-answer>p,.money-type-answer>p{margin:0;color:var(--muted);font-weight:800;text-align:center}.money-answer-slots{display:flex;align-items:center;justify-content:center;gap:6px}.money-answer-slots>strong{font-size:29px}.money-digit-slot.slot.sm{width:43px;height:64px;padding-top:1px}.money-digit-slot.slot.sm .ghost{height:24px;font-size:20px}.money-digit-slot.slot.sm .slot-dots{margin-bottom:3px}.money-digit-slot.filled{padding:0}.money-digit-slot.fixed{border-style:solid;background:var(--surface-2);opacity:.72}.money-digit-block.blok,.money-digit-slot.slot.sm{--dot:6px}.money-digit-block.blok{width:39px;height:62px;min-height:62px;padding-top:2px;cursor:default}.money-digit-block.blok .bl{height:26px;font-size:25px;color:var(--purple)}.money-digit-block.blok .blok-dots{margin-bottom:3px}.money-digit-tray.lg-tray{display:grid;grid-template-columns:repeat(5,39px);gap:7px;min-height:0;padding:11px 14px}.money-digit-tray .money-drag-digit{cursor:grab}.money-digit-tray .money-drag-digit:active{cursor:grabbing}.money-digit-slot.droptarget{border-color:var(--green);background:#e3f6ea;transform:scale(1.06)} +.money-type-display{min-width:190px;padding:9px 14px;border:3px dashed var(--line);border-radius:14px;background:var(--surface-2);font-size:34px;font-weight:950;letter-spacing:2px;color:var(--green)}.money-keypad{width:100%;display:grid;grid-template-columns:repeat(4,1fr);gap:7px}.money-keypad .tbtn{min-width:0;padding:0;font-size:20px} +.money-feedback{min-height:24px;margin:0;text-align:center;font-weight:900}.money-feedback.ok{color:var(--green)}.money-feedback.bad{color:var(--red)}.money-actions{display:flex;justify-content:center;gap:8px;flex-wrap:wrap}.money-actions .tbtn:not(.ghost){background:var(--green);color:#fff} +@media(max-width:520px){.money-task-card{gap:9px;padding:10px}.money-level-control>span{display:none}.money-top{top:-10px;gap:5px}.money-shop-task{grid-template-columns:minmax(0,.8fr) minmax(0,1.2fr);gap:7px}.money-product,.money-paid{min-height:112px;padding:8px 6px;gap:4px}.money-product-icon{font-size:46px}.money-product-name{font-size:14px}.money-product .money-task-amount{font-size:32px}.money-product .money-task-words{font-size:16px}.money-paid>output{font-size:26px}.money-cash-tray{gap:6px;padding:9px}} +.wordtypes{display:flex;gap:9px;flex-wrap:wrap;justify-content:center}.wordtypes button{border:0;border-radius:10px;padding:10px 13px;font:inherit;font-weight:900;background:var(--surface-2)}.wordtypes button[data-type=noun]{background:#dce9ff;color:#174c9b}.wordtypes button[data-type=verb]{background:#dcf7e7;color:#176b3a}.wordtypes button[data-type=adjective]{background:#fff0cf;color:#855b00}.wordtypes p{flex-basis:100%;text-align:center;color:var(--muted);font-size:12px} +.schedule{width:min(100%,450px);display:flex;flex-direction:column;gap:7px}.schedule>div{display:flex;align-items:center;gap:12px;padding:9px;background:var(--surface-2);border-radius:12px;border-left:4px solid var(--widget-accent)}.schedule-icon{width:33px;height:33px;display:grid;place-items:center;flex:none;border-radius:10px;background:var(--surface);font-size:20px;font-style:normal}.schedule time{font-weight:900;color:var(--blue)}.schedule span{flex:1;font-weight:800}.schedule .tbtn{height:30px;min-height:30px;padding:0 9px}.class-total{flex-basis:100%;width:100%;margin:0 0 3px;color:var(--muted);font-size:12px;font-weight:900;text-align:center}.mood{display:flex;gap:8px;flex-wrap:wrap}.mood .tbtn{flex:1;min-width:62px;font-size:36px;height:auto;padding:12px;background:var(--surface);border:2px solid var(--line);color:var(--ink)}.mood span{display:block;font-size:14px}.poll{width:min(100%,440px);display:flex;flex-direction:column;gap:8px}.poll h3{margin-bottom:0;text-align:center}.poll .tbtn{position:relative;overflow:hidden;text-align:left}.poll .tbtn i{position:absolute;inset:0 auto 0 0;background:color-mix(in srgb,var(--widget-accent) 22%,var(--surface));z-index:0;transition:width .25s}.poll .tbtn span{position:relative;float:right;font-weight:900}.poll .tbtn{isolation:isolate} +@media(max-width:600px){.gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.gcard{min-height:120px;padding:13px 8px}.gcard .gd{display:none}.widget .wdup,.widget .wpreview{display:none}.edu-stage{padding:10px}.place-grid b{font-size:9px}.place-grid output{padding:12px 2px}} +/* =========== Geluidsmeter =========== */ +.nm{flex:1;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:14px} +.nm-stage{display:flex;align-items:flex-end;gap:14px;flex:1;min-height:0} +.nm-gauge{position:relative;width:70px;height:min(280px,100%);min-height:120px;border-radius:35px;background:var(--surface-2);border:1px solid var(--line);overflow:hidden;box-shadow:inset 0 2px 8px rgba(20,36,52,.12)} +.nm-fill{position:absolute;left:0;right:0;bottom:0;height:0%;border-radius:35px 35px 0 0;transition:height .25s ease-out,background-color .25s;background:var(--surface-2)} +.nm-fill.green{background:var(--green)} +.nm-fill.yellow{background:var(--yellow)} +.nm-fill.red{background:var(--red)} +.nm-mark{position:absolute;left:0;right:0;height:2px;background:rgba(255,255,255,.8);box-shadow:0 0 0 1px rgba(20,36,52,.25)} +.nm-face{font-size:52px;line-height:1} +.nm-gauge.warn{animation:nmwarn 1s infinite} +@keyframes nmwarn{0%,100%{box-shadow:inset 0 2px 8px rgba(20,36,52,.12)}50%{box-shadow:inset 0 2px 8px rgba(20,36,52,.12),0 0 0 4px color-mix(in srgb,var(--red) 45%,transparent)}} +.nm-status{min-height:26px;font-size:16px;font-weight:900;text-align:center} +.nm-zones{width:100%;max-width:320px;display:flex;flex-direction:column;gap:6px} +.nm-zone-row{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:800;color:var(--muted)} +.nm-zone-row input{flex:1} +.nm-zone-lbl{flex:none;min-width:96px} +.nm-privacy{margin:0;max-width:320px;color:var(--muted);font-size:11px;text-align:center;line-height:1.4} +/* =========== Gevoelszones =========== */ +.fz{flex:1;min-height:0;display:flex;flex-direction:column;gap:14px;padding:14px;overflow:auto} +.fz-zones{display:grid;grid-template-columns:repeat(2,1fr);gap:10px} +.fz-zone{display:flex;flex-direction:column;align-items:center;gap:6px;padding:14px 10px;border-radius:18px;border:2px solid var(--line);background:var(--surface);cursor:pointer;font:inherit;color:var(--ink)} +.fz-zone.blue{border-color:color-mix(in srgb,var(--blue) 40%,var(--line))} +.fz-zone.green{border-color:color-mix(in srgb,var(--green) 40%,var(--line))} +.fz-zone.yellow{border-color:color-mix(in srgb,var(--yellow) 55%,var(--line))} +.fz-zone.red{border-color:color-mix(in srgb,var(--red) 40%,var(--line))} +.fz-zone.active.blue{background:color-mix(in srgb,var(--blue) 16%,var(--surface))} +.fz-zone.active.green{background:color-mix(in srgb,var(--green) 16%,var(--surface))} +.fz-zone.active.yellow{background:color-mix(in srgb,var(--yellow) 22%,var(--surface))} +.fz-zone.active.red{background:color-mix(in srgb,var(--red) 16%,var(--surface))} +.fz-emoji{font-size:32px} +.fz-label{font-weight:900;font-size:14px} +.fz-detail{padding:12px 16px;border-radius:16px;border:1px solid var(--line);background:var(--surface-2);text-align:center} +.fz-detail-title{margin:0 0 4px;font-size:16px} +.fz-detail-desc{margin:0 0 6px;font-weight:800;color:var(--ink)} +.fz-detail-tip{margin:0;color:var(--muted);font-size:13px;line-height:1.4} +.fz-detail.blue{border-color:color-mix(in srgb,var(--blue) 35%,var(--line))} +.fz-detail.green{border-color:color-mix(in srgb,var(--green) 35%,var(--line))} +.fz-detail.yellow{border-color:color-mix(in srgb,var(--yellow) 45%,var(--line))} +.fz-detail.red{border-color:color-mix(in srgb,var(--red) 35%,var(--line))} +/* =========== Ademhalingsoefening =========== */ +.bre{flex:1;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;padding:14px} +.bre-stage{flex:1;min-height:0;width:100%;display:flex;align-items:center;justify-content:center} +.bre-circle{width:120px;height:120px;border-radius:50%;background:radial-gradient(circle at 35% 30%,color-mix(in srgb,var(--blue) 55%,#fff),var(--blue));display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;color:#fff;font-weight:900;transform:scale(1);transition:transform 1s ease-in-out;box-shadow:0 12px 30px rgba(20,54,97,.28)} +.bre-circle.hold{filter:brightness(.92)} +.bre-word{font-size:15px} +.bre-count{font-size:26px;font-variant-numeric:tabular-nums} +.bre-rounds{color:var(--muted);font-size:13px;font-weight:800} +.bre-row{display:flex;gap:10px;align-items:center} +/* =========== Complimentenmachine =========== */ +.cpl{flex:1;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:14px} +.cpl-card{width:min(100%,420px);min-height:140px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:20px;border-radius:22px;background:linear-gradient(135deg,var(--pink),var(--purple));color:#fff;text-align:center;box-shadow:0 14px 30px rgba(139,92,246,.28);opacity:.55;transform:scale(.96);transition:opacity .2s,transform .2s} +.cpl-card.show{opacity:1;transform:scale(1)} +.cpl-icon{font-size:34px} +.cpl-text{margin:0;font-size:17px;font-weight:800;line-height:1.4;text-shadow:0 2px 6px rgba(0,0,0,.2)} + +/* Wereldoriëntatie: natuurherkenning en topografie */ +.world-app{width:100%;min-height:100%;align-self:flex-start;display:flex;flex-direction:column;justify-content:flex-start;color:var(--ink)} +.world-top{position:sticky;top:-16px;z-index:6;width:100%;display:flex;align-items:center;flex-wrap:wrap;justify-content:space-between;gap:8px;margin-bottom:10px;padding:9px 4px;background:color-mix(in srgb,var(--surface) 94%,transparent);border-bottom:1px solid var(--line);backdrop-filter:blur(10px)}.world-top>strong{font-size:15px}.world-top>span{padding:6px 10px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:12px;font-weight:900;white-space:nowrap} +.world-level-control{display:flex;align-items:center;gap:6px}.world-level-control>span{color:var(--muted);font-size:12px;font-weight:900}.world-levels button{min-width:31px;padding:6px 9px;white-space:nowrap} +.world-task-card{width:min(100%,680px);margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:13px;padding:18px;border:1px solid color-mix(in srgb,var(--line) 80%,var(--accent));border-radius:24px;background:linear-gradient(155deg,var(--surface),color-mix(in srgb,var(--surface-2) 68%,var(--surface)));box-shadow:0 18px 40px rgba(24,42,66,.12)}.world-task-card>h3{max-width:560px;margin:0;text-align:center;font-size:clamp(19px,4cqi,27px);line-height:1.2}.world-intro{width:100%;margin:0;padding:10px 14px;border-radius:14px;background:linear-gradient(120deg,var(--accent-soft),color-mix(in srgb,var(--accent-soft) 55%,var(--surface)));color:var(--accent-ink);font-weight:850;line-height:1.4;text-align:center} +.world-visual{width:min(100%,460px);display:flex;align-items:flex-start;justify-content:center;gap:12px}.world-visual.mixed{width:min(100%,620px);display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.world-photo{position:relative;min-width:0;margin:0;overflow:hidden;border:1px solid color-mix(in srgb,var(--line) 75%,var(--ink));border-radius:19px;background:#152432;box-shadow:0 10px 24px rgba(20,36,52,.18)}.world-picture{display:block;width:100%;height:auto;aspect-ratio:4/3;object-fit:cover}.world-photo-badge{position:absolute;left:9px;top:9px;padding:5px 9px;border:1px solid rgba(255,255,255,.35);border-radius:999px;background:rgba(10,27,42,.78);color:#fff;font-size:10px;font-weight:950;letter-spacing:.45px;text-transform:uppercase;box-shadow:0 2px 8px rgba(0,0,0,.18);backdrop-filter:blur(6px)}.world-photo-credit{position:absolute;right:7px;bottom:7px;max-width:calc(100% - 14px);overflow:hidden;padding:4px 7px;border-radius:8px;background:rgba(10,27,42,.8);color:#fff!important;font-size:9px;font-weight:750;line-height:1.2;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;backdrop-filter:blur(6px)}.world-photo-credit:hover,.world-photo-credit:focus-visible{text-decoration:underline;outline:2px solid #fff;outline-offset:1px} +.world-identity{display:flex;flex-direction:column;align-items:center;gap:2px;text-align:center}.world-identity h3{margin:0;font-size:18px;line-height:1.2}.world-identity em{color:var(--muted);font-size:12px;font-family:Georgia,serif;font-weight:650} +.world-answers{width:min(100%,560px);display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.world-answers .tbtn{min-width:0;min-height:48px;padding:8px 11px;border:1px solid var(--line);background:var(--surface);box-shadow:0 3px 8px rgba(20,36,52,.06)}.world-answers .tbtn:hover{border-color:var(--accent);background:var(--accent-soft)}.world-answers .tbtn.correct{background:var(--green);color:#fff;border-color:transparent}.world-feedback{min-height:24px;margin:0;text-align:center;font-weight:900}.world-feedback.ok{color:var(--green)}.world-feedback.bad{color:var(--red)}.world-actions{display:flex;justify-content:center}.world-fact{width:min(100%,560px);margin:0;padding:11px 14px;border:1px solid color-mix(in srgb,var(--green) 28%,var(--line));border-radius:15px;background:#eef7e9;color:#2f6f3e;text-align:center}.world-fact .world-identity em{color:#527c5b}.world-fact p{margin:6px 0 0;font-weight:800;line-height:1.4} +.world-answers .tbtn.wrong{background:color-mix(in srgb,var(--red) 14%,var(--surface));color:var(--red);border-color:var(--red)}.world-clue{width:min(100%,560px);padding:18px 20px;border:1px solid color-mix(in srgb,var(--accent) 35%,var(--line));border-radius:18px;background:linear-gradient(135deg,var(--accent-soft),var(--surface));color:var(--accent-ink);font-size:clamp(16px,3.2cqi,21px);font-weight:850;line-height:1.5;text-align:center}.world-clue-latin{font-family:Georgia,serif;font-size:clamp(23px,4.6cqi,34px);font-style:italic;font-weight:700} +.world-photo-answers{width:min(100%,640px);display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.world-photo-choice{position:relative;min-width:0;margin:0;overflow:hidden;border:3px solid transparent;border-radius:18px;background:#152432;box-shadow:0 8px 18px rgba(20,36,52,.16);transition:border-color .15s,transform .15s,box-shadow .15s}.world-image-button{display:block;width:100%;margin:0;padding:0;border:0;background:#152432;cursor:pointer}.world-image-button .world-picture{transition:transform .18s,filter .18s}.world-photo-choice:hover{transform:translateY(-2px);box-shadow:0 12px 22px rgba(20,36,52,.2)}.world-image-button:hover .world-picture{transform:scale(1.025)}.world-image-button:focus-visible{outline:4px solid var(--accent);outline-offset:-4px}.world-choice-number{position:absolute;left:8px;top:8px;display:grid;width:28px;height:28px;place-items:center;border:2px solid rgba(255,255,255,.7);border-radius:50%;background:rgba(10,27,42,.86);color:#fff;font-size:13px;font-weight:950;pointer-events:none}.world-photo-choice.correct{border-color:var(--green);box-shadow:0 0 0 3px color-mix(in srgb,var(--green) 25%,transparent)}.world-photo-choice.wrong{border-color:var(--red)}.world-photo-choice.wrong .world-picture{filter:saturate(.65) brightness(.82)} +.world-gallery{width:100%;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.world-specimen{min-width:0;display:flex;flex-direction:column;align-items:center;gap:9px;padding:11px;border:1px solid var(--line);border-radius:20px;background:linear-gradient(155deg,var(--surface),var(--surface-2));box-shadow:0 8px 22px rgba(20,36,52,.08)}.world-specimen .world-visual{width:100%}.world-specimen p{margin:0;padding:0 5px 3px;color:var(--muted);font-size:12px;font-weight:720;line-height:1.4;text-align:center} +/* flitskaarten: zelfde opbouw als flits.js (naam kort tonen, dan zelftoets), + hier met de foto/kaart altijd zichtbaar en de naam geflitst i.p.v. andersom */ +.world-flash-card{width:100%;display:flex;flex-direction:column;align-items:center;gap:10px} +.world-flash-name{font-size:clamp(24px,5vw,34px);font-weight:900;letter-spacing:1px;color:var(--blue);min-height:44px;display:flex;align-items:center} +.world-flash-name.dim{color:var(--muted)} +.world-flash-hint{margin:0;color:var(--muted);font-weight:800;text-align:center} +.world-flash-reflect{width:100%;padding:10px 14px;border-radius:14px;background:var(--surface-2);text-align:center} +.world-flash-reflect>p{margin:0 0 8px;font-weight:900} +/* letterblokken: hergebruikt de blok/vak/dots-stijl van letters.js/de rekenwidgets */ +.world-spell-hint{margin:0;color:var(--muted);font-weight:800;text-align:center} +.world-spell-answer{width:100%;display:flex;flex-direction:column;align-items:center;gap:12px} +.world-spell-word{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:6px;max-width:100%} +.world-spell-gap{width:14px;flex:none} +.world-letter-slot.slot.sm,.world-letter-block.blok.sm{--dot:6px;width:40px;height:62px;padding-top:3px} +.world-letter-slot.slot.sm .ghost{height:24px;font-size:19px} +.world-letter-slot.slot.sm .slot-dots{margin-bottom:4px} +.world-letter-block.blok.sm .bl{height:26px;font-size:22px} +.world-letter-block.blok.sm .blok-dots{margin-bottom:4px} +.world-spell-tray.lg-tray{max-width:min(100%,480px)} +.topo-card{width:min(100%,740px)}.topo-map-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:7px;overflow:hidden;padding:10px;border:1px solid var(--line);border-radius:20px;background:linear-gradient(155deg,#edf8fd,#dff2fb)}.topo-map{display:block;width:min(100%,510px);height:auto;max-height:590px;touch-action:manipulation;filter:drop-shadow(0 9px 13px rgba(29,60,76,.13))}.topo-water{fill:#dff3fb}.topo-province{stroke:#fff;stroke-width:4.5;stroke-linejoin:round;vector-effect:non-scaling-stroke;transition:filter .15s,opacity .15s,fill .15s}.topo-province[role=button]{cursor:pointer}.topo-province[role=button]:hover,.topo-province[role=button]:focus{filter:brightness(.88) saturate(1.12);outline:none}.topo-province[role=button]:focus-visible{stroke:var(--navy);stroke-width:8}.topo-province.picked{fill:var(--red)!important}.topo-province.target{fill:var(--green)!important}.topo-label,.topo-city-label{fill:#18324e;paint-order:stroke;stroke:#fff;stroke-width:5px;stroke-linejoin:round;font-size:18px;font-weight:950;text-anchor:middle;pointer-events:none}.topo-city-label{text-anchor:start;font-size:16px}.topo-city-label.left{text-anchor:end}.topo-marker{cursor:pointer}.topo-hit{fill:transparent}.topo-dot{fill:var(--navy);stroke:#fff;stroke-width:4;transition:fill .15s,stroke-width .15s}.topo-marker:hover .topo-dot,.topo-marker:focus .topo-dot{fill:var(--orange);outline:none}.topo-marker:focus-visible .topo-dot{stroke:var(--navy);stroke-width:8}.topo-marker.picked .topo-dot{fill:var(--red)}.topo-marker.target .topo-dot{fill:var(--green);stroke-width:7}.topo-source{align-self:flex-end;color:#3b657b;font-size:9px;font-weight:750;text-decoration:none}.topo-source:hover,.topo-source:focus-visible{text-decoration:underline} +.topo-province.inactive{opacity:.32}.topo-region[role=button],.topo-river[role=button],.world-continent[role=button]{cursor:pointer}.topo-region.picked .topo-province{fill:var(--red)!important}.topo-region.target .topo-province{fill:var(--green)!important}.topo-region:hover .topo-province,.topo-region:focus .topo-province{filter:brightness(.88) saturate(1.12)}.topo-region:focus-visible .topo-province{stroke:var(--navy);stroke-width:8}.topo-region-label{font-size:20px} +.topo-river.inactive{opacity:.22}.topo-river-hit,.topo-river-line{fill:none;stroke-linecap:round;stroke-linejoin:round}.topo-river-hit{stroke:transparent;stroke-width:38;pointer-events:stroke}.topo-river-line{stroke:var(--river-color);stroke-width:10;pointer-events:none;transition:stroke .15s,stroke-width .15s}.topo-river:hover .topo-river-line,.topo-river:focus .topo-river-line{stroke-width:15}.topo-river:focus-visible .topo-river-line{stroke:var(--navy);stroke-width:18}.topo-river.picked .topo-river-line{stroke:var(--red)}.topo-river.target .topo-river-line{stroke:var(--green);stroke-width:16}.topo-river-label{fill:#18324e;paint-order:stroke;stroke:#fff;stroke-width:5px;stroke-linejoin:round;font-size:20px;font-style:italic;font-weight:950;text-anchor:middle;pointer-events:none} +.world-map-wrap{padding:12px}.world-map{width:100%;max-width:680px;max-height:none}.world-continent{stroke:#fff;stroke-width:1.4;stroke-linejoin:round;vector-effect:non-scaling-stroke;transition:filter .15s,opacity .15s,fill .15s}.world-continent.inactive{opacity:.28}.world-continent:hover,.world-continent:focus{filter:brightness(.88) saturate(1.12);outline:none}.world-continent:focus-visible{stroke:var(--navy);stroke-width:4}.world-continent.picked{fill:var(--red)!important}.world-continent.target{fill:var(--green)!important}.world-continent-label{fill:#18324e;paint-order:stroke;stroke:#fff;stroke-width:3px;stroke-linejoin:round;font-size:14px;font-weight:950;text-anchor:middle;pointer-events:none} +.topo-world-legend{width:100%;display:flex;flex-wrap:wrap;justify-content:center;gap:6px}.topo-world-legend span{display:flex;align-items:center;gap:5px;padding:5px 8px;border:1px solid color-mix(in srgb,var(--line) 75%,transparent);border-radius:999px;background:var(--surface);color:var(--ink);font-size:11px;font-weight:850}.topo-world-legend i{width:10px;height:10px;flex:none;border:1px solid rgba(24,50,78,.18);border-radius:50%} +body.dark .world-fact{background:#1d3b2a;color:#a9e5b7}body.dark .world-fact .world-identity em{color:#a9e5b7}body.dark .topo-map-wrap{background:linear-gradient(155deg,#193446,#122c3e)}body.dark .topo-water{fill:#173145}body.dark .topo-label,body.dark .topo-city-label,body.dark .topo-river-label,body.dark .world-continent-label{fill:#f7fbff;stroke:#18324e}body.dark .topo-source{color:#a9cadb} + +.clock-app{width:100%;min-height:100%;display:flex;flex-direction:column;justify-content:flex-start;color:var(--ink)} +.clock-top{position:sticky;top:-16px;z-index:6;width:100%;display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px;padding:8px 4px;background:var(--surface);border-bottom:1px solid var(--line)}.clock-level-control{display:flex;align-items:center;gap:6px}.clock-level-control>span{color:var(--muted);font-size:12px;font-weight:900}.clock-levels button{min-width:31px;padding:6px 9px;white-space:nowrap}.clock-mode{width:36px;padding:0;font-size:18px}.clock-score{margin-left:auto;padding:6px 10px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:12px;font-weight:900} +.clock-task-card{width:min(100%,620px);margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px;border:1px solid var(--line);border-radius:20px;background:var(--surface);box-shadow:var(--shadow-1)} +.clock-task-card h3{margin:0;text-align:center;font-size:clamp(18px,3vw,24px)}.clock-intro{max-width:440px;margin:0;text-align:center;color:var(--muted);font-weight:700;line-height:1.5} +.clock-target{width:100%;min-height:88px;display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;padding:10px;border-radius:15px;background:var(--surface-2)}.clock-target>span:first-child{flex-basis:100%;color:var(--muted);font-size:12px;font-weight:900;text-transform:uppercase;letter-spacing:.5px}.clock-target-words{font-size:20px;color:var(--purple)}.clock-assignment{display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap;text-align:center}.clock-assignment.compact{width:100%}.clock-assignment.compact .clock-task-digital{font-size:44px}.clock-assignment.compact .clock-task-words{font-size:24px}.clock-task-digital{font-size:clamp(48px,10vw,78px);line-height:1;font-weight:950;letter-spacing:2px;color:var(--blue)}.clock-task-words{flex-basis:100%;margin:0;font-size:clamp(24px,5vw,36px);line-height:1.15;font-weight:950;color:var(--purple)}.clock-daypart{padding:6px 11px;border-radius:999px;background:var(--purple);color:#fff;font-size:14px;font-weight:900}.clock-target .clock-reference{--clock-size:112px;border-width:5px;box-shadow:inset 0 0 0 2px var(--line),0 4px 10px rgba(20,35,60,.12)}.clock-reference b{font-size:9px}.clock-reference .clock-pin{left:calc(50% - 6px);top:calc(50% - 6px);width:12px;height:12px;border-width:2px} +.clock-work{display:flex;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap}.clock{--clock-size:min(230px,62vw);width:var(--clock-size);height:var(--clock-size);flex:none;position:relative;overflow:hidden;border:9px solid var(--ink);border-radius:50%;background:radial-gradient(circle at 50% 46%,var(--surface) 0 62%,var(--surface-2) 100%);box-shadow:inset 0 0 0 3px var(--line),0 8px 20px rgba(20,35,60,.14)} +.clock-set-answer{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px} +.clock-period{display:flex;align-items:center;justify-content:center;gap:3px;padding:3px;border-radius:999px;background:var(--surface-2)} +.clock-period .tbtn{min-height:36px;padding:0 12px;border-radius:999px} +.clock-period .tbtn.active{background:var(--purple);color:#fff;border-color:transparent} +.clock-tick{position:absolute;z-index:1;border-radius:4px}.clock-tick.hour{left:calc(50% - 2px);top:3%;width:4px;height:5%;background:var(--blue);transform-origin:50% 940%}.clock-tick.minor{left:calc(50% - 1px);top:4%;width:2px;height:4%;background:var(--ink);opacity:.28;transform-origin:50% 1150%} +.clock b{position:absolute;z-index:2;left:calc(50% - 12px);top:calc(50% - 12px);width:24px;height:24px;display:grid;place-items:center;font-size:14px;transform:rotate(var(--clock-angle)) translateY(calc(var(--clock-size)*-.34)) rotate(calc(-1 * var(--clock-angle)))} +.clock-hand{position:absolute;z-index:4;left:calc(50% - 5px);bottom:50%;width:10px;border-radius:9px;transform-origin:50% 100%;background:var(--ink);cursor:grab;touch-action:none}.clock-hand:active{cursor:grabbing}.clock-hour{height:28%}.clock-min{left:calc(50% - 4px);width:8px;height:39%;background:var(--pink)}.clock-pin{position:absolute;z-index:5;left:calc(50% - 8px);top:calc(50% - 8px);width:16px;height:16px;border:3px solid var(--surface);border-radius:50%;background:var(--pink);box-shadow:0 1px 4px rgba(0,0,0,.28)} +.clock.interactive{touch-action:none;user-select:none;outline:4px solid transparent;transition:outline-color .15s,transform .15s}.clock.interactive:hover,.clock.interactive:focus-within{outline-color:var(--accent-soft)} +.clock-time-blocks{display:flex;align-items:center;justify-content:center;gap:5px;margin:0;padding:6px;border:0;border-radius:14px;background:transparent;color:var(--ink);font:inherit}.clock-time-blocks:is(button){cursor:pointer}.clock-time-blocks:is(button):hover,.clock-time-blocks:is(button):focus-visible{background:var(--accent-soft);outline:2px solid var(--accent)}.clock-time-blocks>strong{font-size:30px} +.clock-digit-block.blok,.clock-digit-slot.slot.sm{--dot:6px} +.clock-digit-block.blok{width:42px;height:66px;min-height:66px;padding-top:3px;cursor:default}.clock-digit-block.blok .bl{height:29px;font-size:27px;color:var(--purple)}.clock-digit-block.blok .blok-dots{margin-bottom:4px} +.clock-live-answer{padding:6px 12px;border:2px dashed var(--line);border-radius:16px;background:var(--surface-2)}.clock-read-layout{width:100%;display:flex;align-items:center;justify-content:center;gap:16px;flex-wrap:wrap}.clock-read-layout>.clock-assignment{flex:1;min-width:190px}.clock-read-layout .clock{--clock-size:180px}.clock-block-answer{width:min(100%,250px);display:flex;flex-direction:column;align-items:center;gap:10px}.clock-block-answer>p{margin:0;color:var(--muted);font-weight:800;text-align:center}.clock-answer-slots{display:flex;align-items:center;justify-content:center;gap:7px}.clock-digit-slot.slot.sm{width:46px;height:66px;padding-top:2px}.clock-digit-slot.slot.sm .ghost{height:25px;font-size:20px}.clock-digit-slot.slot.sm .slot-dots{margin-bottom:4px}.clock-digit-slot.filled{padding:0}.clock-slot-colon{font-size:30px;margin:0 2px}.clock-block-tray.lg-tray{display:grid;grid-template-columns:repeat(5,38px);gap:7px;min-height:0;padding:11px 14px}.clock-block-tray .clock-drag-block{width:38px;height:66px;min-height:66px;cursor:grab}.clock-block-tray .clock-drag-block:active{cursor:grabbing}.clock-block-tray .clock-drag-block:focus-visible{outline:3px solid var(--accent);outline-offset:2px}.clock-digit-slot.droptarget{border-color:var(--green);background:#e3f6ea;transform:scale(1.06)} +.clock-choices{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}.clock-choices.wood .clock-time-blocks{border:1px solid var(--line);background:var(--surface-2)}.clock-word-choice{min-height:48px;border:2px solid var(--wood-d)!important;background:linear-gradient(#fdf3dd,var(--wood))!important;color:var(--ink)!important;box-shadow:0 3px 0 var(--wood-d)}.clock-digital-choice{min-width:118px;min-height:58px;font-size:28px!important;color:var(--blue)!important}.clock-type-answer{width:min(100%,250px);display:flex;flex-direction:column;align-items:center;gap:9px}.clock-type-answer>p{margin:0;color:var(--muted);font-weight:800;text-align:center}.clock-type-display{min-width:180px;padding:9px 14px;border:3px dashed var(--line);border-radius:14px;background:var(--surface-2);font-size:34px;font-weight:950;letter-spacing:3px;color:var(--blue)}.clock-keypad{width:100%;display:grid;grid-template-columns:repeat(4,1fr);gap:7px}.clock-keypad .tbtn{min-width:0;padding:0;font-size:20px}.clock-keypad .ok{background:var(--green);color:#fff} +.clock-feedback{min-height:24px;margin:0;text-align:center;font-weight:900}.clock-feedback.ok{color:var(--green)}.clock-feedback.bad{color:var(--red)}.clock-actions{display:flex;justify-content:center;gap:8px;flex-wrap:wrap}.clock-actions .tbtn:not(.ghost){background:var(--green);color:#fff}.clock-drag-hint{color:var(--muted);font-weight:800;text-align:center} +@media(max-width:480px){.clock-task-card{padding:12px}.clock-level-control>span{display:none}.clock-top{gap:5px}.clock{--clock-size:min(210px,68vw)}.clock-target{min-height:72px}.clock-block-tray.lg-tray{gap:7px;padding:9px}} + +/* plaatswaarde: "Bouwen"-oefening hergebruikt hetzelfde blok/vak/tray-systeem + als de oefenklok/het geldwidget, alleen met een variabel aantal kolommen + (2/3/6 al naar niveau) en een kolomlabel (HD/TD/D/H/T/E) boven elk vak. */ +.place-app{width:100%;min-height:100%;display:flex;flex-direction:column;justify-content:flex-start;color:var(--ink)} +.place-top{position:sticky;top:-16px;z-index:6;width:100%;display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px;padding:8px 4px;background:var(--surface);border-bottom:1px solid var(--line)} +.place-level-control{display:flex;align-items:center;gap:6px}.place-level-control>span{color:var(--muted);font-size:12px;font-weight:900} +.place-levels button{min-width:31px;padding:6px 9px;white-space:nowrap} +.place-score{margin-left:auto;padding:6px 10px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:12px;font-weight:900} +.place-task-card{width:min(100%,620px);margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px;border:1px solid var(--line);border-radius:20px;background:var(--surface);box-shadow:var(--shadow-1)} +.place-target{width:100%;min-height:88px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:10px;border-radius:15px;background:var(--surface-2)} +.place-target>span:first-child{color:var(--muted);font-size:12px;font-weight:900;text-transform:uppercase;letter-spacing:.5px} +.place-task-digital{font-size:clamp(48px,10vw,78px);line-height:1;font-weight:950;letter-spacing:2px;color:var(--blue)} +.place-block-answer{width:min(100%,520px);display:flex;flex-direction:column;align-items:center;gap:10px}.place-block-answer>p{margin:0;color:var(--muted);font-weight:800;text-align:center} +.place-answer-slots{display:flex;align-items:flex-end;justify-content:center;gap:7px;flex-wrap:wrap} +.place-slot-col{display:flex;flex-direction:column;align-items:center;gap:4px}.place-slot-col>b{font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase} +.place-digit-slot.slot.sm{width:46px;height:66px;padding-top:2px}.place-digit-slot.slot.sm .ghost{height:25px;font-size:20px}.place-digit-slot.slot.sm .slot-dots{margin-bottom:4px}.place-digit-slot.filled{padding:0} +.place-digit-tray.lg-tray{display:grid;grid-template-columns:repeat(5,38px);gap:7px;min-height:0;padding:11px 14px}.place-digit-tray .place-drag-block{width:38px;height:56px;min-height:56px;cursor:grab}.place-digit-tray .place-drag-block:active{cursor:grabbing}.place-digit-tray .place-drag-block:focus-visible{outline:3px solid var(--accent);outline-offset:2px} +.place-digit-slot.droptarget{border-color:var(--green);background:#e3f6ea;transform:scale(1.06)} +.place-digit-block.blok,.place-digit-slot.slot.sm{--dot:6px} +.place-digit-block.blok{width:42px;height:66px;min-height:66px;padding-top:3px;cursor:default}.place-digit-block.blok .bl{height:29px;font-size:27px;color:var(--purple)}.place-digit-block.blok .blok-dots{margin-bottom:4px} +.place-feedback{min-height:24px;margin:0;text-align:center;font-weight:900}.place-feedback.ok{color:var(--green)}.place-feedback.bad{color:var(--red)} +.place-actions{display:flex;justify-content:center;gap:8px;flex-wrap:wrap}.place-actions .tbtn:not(.ghost){background:var(--green);color:#fff} +@media(max-width:480px){.place-task-card{padding:12px}.place-level-control>span{display:none}.place-top{gap:5px}.place-digit-tray.lg-tray{gap:7px;padding:9px}} + +/* Letter- en cijferdoelen staan intrinsiek in het midden. safe center + valt bij een te lange woordrij terug op het begin van de scrollstrook, + zodat de eerste vakken bereikbaar blijven in plaats van links af te snijden. */ +.lg-slots,.mt-slots,.clock-answer-slots,.money-answer-slots,.place-answer-slots,.world-spell-word{ + width:max-content;max-width:100%;margin-inline:auto;justify-content:safe center; +} +.lg-slots,.mt-slots{overflow-x:auto;scrollbar-width:thin} +.lg-slots>.slot,.mt-slots>.slot,.clock-answer-slots>.slot,.money-answer-slots>.slot, +.world-spell-word>.slot,.place-answer-slots>.place-slot-col{flex:none} + +/* ============================================================ + Kindvriendelijke spel-laag + Eén accent, duidelijke missies en tastbare bediening voor elk widgettype. + ============================================================ */ +.widget{--widget-accent:var(--teal);--widget-soft:color-mix(in srgb,var(--widget-accent) 12%,var(--surface))} +.widget:is([data-widget="anchor"],[data-widget="letters"],[data-widget="flits"],[data-widget="hak"],[data-widget="ball"],[data-widget="sentence"],[data-widget="wordtypes"]){--widget-accent:var(--purple)} +.widget:is([data-widget="madd"],[data-widget="msub"],[data-widget="mmul"],[data-widget="mdiv"],[data-widget="mpct"],[data-widget="numberline"],[data-widget="fractions"],[data-widget="clock"],[data-widget="placevalue"],[data-widget="money"]){--widget-accent:var(--blue)} +.widget:is([data-widget="trees"],[data-widget="birds"],[data-widget="plants"],[data-widget="topography"]){--widget-accent:var(--green)} +.widget:is([data-widget="birthday"],[data-widget="schedule"],[data-widget="light"],[data-widget="mood"],[data-widget="poll"]){--widget-accent:var(--pink)} +.widget[data-widget="media"]{--widget-accent:var(--orange)} +.widget:not(.bare){border-top:4px solid var(--widget-accent)} +.widget:not(.bare) .widget-head .wicon{width:34px;height:34px;display:grid;place-items:center;flex:none;border-radius:11px;background:var(--widget-soft);font-size:20px;transform:rotate(-3deg)} +.widget:not(.bare) .widget-head .wtitle{color:color-mix(in srgb,var(--widget-accent) 74%,var(--ink))} +.widget-body{background-image:radial-gradient(circle at 96% 4%,color-mix(in srgb,var(--widget-accent) 8%,transparent) 0 54px,transparent 55px)} +.widget-body .tbtn{transition:transform .12s,filter .12s,box-shadow .12s}.widget-body .tbtn:not(.ghost){background:var(--widget-accent);box-shadow:0 3px 0 color-mix(in srgb,var(--widget-accent) 72%,#18233a)}.widget-body .tbtn:active{transform:translateY(2px) scale(.985);box-shadow:none}.widget-body :is(button,input,select,textarea):focus-visible{outline:3px solid color-mix(in srgb,var(--widget-accent) 72%,#fff);outline-offset:2px} +.play-mission{width:min(100%,560px);margin:0 auto;padding:8px 12px;border:1px solid color-mix(in srgb,var(--widget-accent) 25%,var(--line));border-radius:13px;background:var(--widget-soft);color:color-mix(in srgb,var(--widget-accent) 68%,var(--ink));font-size:13px;font-weight:850;line-height:1.35;text-align:center} +.fl-reflect,.hk-reflect{width:100%;padding:8px 10px;border-radius:14px;background:var(--widget-soft);text-align:center}.fl-reflect p,.hk-reflect p{margin:0 0 7px;font-weight:900}.fl-reflect>div,.hk-reflect>div{display:flex;justify-content:center;gap:7px;flex-wrap:wrap}.fl-reflect[hidden],.hk-reflect[hidden]{display:none} +.dc-history{min-height:28px;display:flex;align-items:center;justify-content:center;gap:5px;flex-wrap:wrap}.dc-history span{margin-right:3px;color:var(--muted);font-size:11px;font-weight:850}.dc-history b{min-width:28px;height:28px;display:grid;place-items:center;border-radius:9px;background:var(--widget-soft);color:var(--widget-accent)} +.notes-app{height:100%;display:flex;flex-direction:column;background:#fff8cf;transition:background .15s}.notes-app[data-theme="mint"]{background:#e5f7df}.notes-app[data-theme="sky"]{background:#e2f3ff}.notes-app[data-theme="lilac"]{background:#f0e8ff}.notes-tools{min-height:42px;display:flex;align-items:center;gap:7px;padding:6px 8px;border-bottom:1px solid rgba(29,52,97,.12);color:#52627b;font-size:11px;font-weight:900}.notes-swatches{display:flex;gap:4px}.notes-swatches button{width:24px;height:24px;padding:0;border:2px solid rgba(29,52,97,.16);border-radius:50%;cursor:pointer}.notes-swatches button[data-theme="sun"]{background:#fff0a8}.notes-swatches button[data-theme="mint"]{background:#cdeebe}.notes-swatches button[data-theme="sky"]{background:#c6e9ff}.notes-swatches button[data-theme="lilac"]{background:#dfd0ff}.notes-swatches button.on{outline:2px solid var(--ink);outline-offset:1px}.notes-size{height:28px;padding:0 7px;border:1px solid rgba(29,52,97,.18);border-radius:8px;background:rgba(255,255,255,.5);color:#33445f;font-weight:900;cursor:pointer}.notes-size.on{background:#fff}.notes-count{margin-left:auto;white-space:nowrap} +.mt-card,.lg-card,.money-task-card,.clock-task-card,.world-task-card,.sb-sentence,.nlw-scroll{box-shadow:0 7px 20px rgba(29,52,97,.09)} +@media(pointer:coarse){.widget-body :is(.tbtn,.sb-chip,.nlw-tick,.fraction-piece,.tl-lamp){min-height:44px}} +@media(prefers-reduced-motion:reduce){.widget-body *,.widget-body *::before,.widget-body *::after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}} +@container widget (max-width:520px){.play-mission{padding:6px 9px;font-size:12px}.tl{padding:8px}.tl-house{padding:9px}.tl-lamp{width:58px;height:58px}.tl-option{gap:11px}.tl-option strong{font-size:16px}.tl-option small{font-size:11px}.notes-color-label{display:none}.notes-tools{gap:5px}.notes-count{font-size:10px}.fraction-game{gap:6px}.fraction-piece{min-height:34px;font-size:11px}.dc-history span{flex-basis:100%;text-align:center}.sb-surprise{flex-basis:100%}} + +/* ============================================================ + Widgetinhoud op telefoonformaat + De widget-body is de container: dit werkt zowel op het bord als + in de leerlingweergave en ook wanneer een widget zelf smal is. + ============================================================ */ +@media(max-width:600px){ + /* De langere selector is nodig om de desktopregel + body.pupil-mode #pupilView werkelijk te overschrijven. De veilige + ondermarge houdt de laatste opdracht vrij van de mobiele browserbalk. */ + body.pupil-mode #pupilView{ + padding:8px 8px max(8px,env(safe-area-inset-bottom)); + gap:12px; + overscroll-behavior:contain; + } + #pupilView .pupil-card{width:100%!important;max-width:100%;min-width:0!important} + /* Op een laag telefoonscherm wordt een bordwidget begrensd tot het zichtbare + bord. De inhoud moet dan kunnen scrollen; overflow:hidden sneed vooral de + opdrachtkaart en blokkenbak af. Letter/rekenroots mogen daarbij hoger + worden dan hun viewport, waarna widget-body de scroll afhandelt. */ + .widget-body{ + overflow-y:auto; + overscroll-behavior:contain; + -webkit-overflow-scrolling:touch; + } + .widget-body>:is(.lg,.mt){flex:none;min-height:100%} + .lg-card{min-height:210px} + .mt-card{min-height:180px} + /* De knop is buiten een opgelost woord display:none. De oude vaste 46px + liet desondanks een lege witte "footer" onder de blokken staan. */ + .lg-bottom{min-height:0} + .widget:not(.bare){max-width:calc(100% - 8px)} + .widget-head{gap:4px;padding:6px 8px} + .widget-head .wicon{font-size:18px} + .wmin,.wclose{width:36px;height:36px;border-radius:10px} + .wz{min-width:36px;padding:0 4px} +} +@media(pointer:coarse){ + .lg-levels button,.lg-cards button,.lg-snd,.lg-mode,.fl-sel,.tseg button, + .nlw-settings input,.nlw-settings select,.sb-add input,.sb-add select, + .edu-controls input,.edu-controls select{min-height:var(--tap-sm)} + .mm-node .mm-add,.mm-node .mm-del,.mm-node .mm-cog{display:block} + .an-tools button{width:var(--tap-sm);height:var(--tap-sm)} +} + +@container widget (max-width:520px){ + /* gedeelde taal-, reken- en gereedschapskaarten */ + .lg,.mt,.dc,.np,.timer,.nlw,.sb{gap:8px;padding:8px} + .lg-top,.mt-top,.topts,.np-row,.nlw-settings{flex-wrap:wrap} + .lg-top>*,.mt-top>*{max-width:100%} + .lg-levels{max-width:100%;overflow-x:auto;scrollbar-width:thin} + .lg-levels button{padding:6px 9px} + .lg-progress,.mt-score{min-width:0;margin-left:0;flex:1;justify-content:flex-end;text-align:right} + .lg-card,.mt-card{gap:8px;padding:8px} + .lg-pic{font-size:clamp(42px,16cqi,60px)} + .lg-pic img{width:clamp(58px,20cqi,76px);height:clamp(58px,20cqi,76px)} + .lg-instr{padding:6px 8px;font-size:13px} + .lg-slots{gap:7px;overflow-x:auto;padding:3px 1px 8px} + .lg-tray{gap:7px;min-height:0;max-height:44%;overflow:auto;padding:8px} + .lg-kbd{gap:5px;padding:7px} + .lg-kbd button{width:36px;height:42px;font-size:18px} + .lg-ed-form{gap:7px;padding:9px} + .lg-ed-form input.ed-word,.ed-theme-name,.ed-theme-folder{width:min(100%,170px)} + .ed-pictorow{flex-wrap:wrap} + .fl-sel{min-width:0} + .fl-word{min-height:54px;font-size:clamp(30px,14cqi,48px);letter-spacing:2px;text-align:center} + .hk-word{min-height:54px} + .hk-whole{font-size:clamp(30px,13cqi,44px)} + .hk-l{font-size:clamp(27px,12cqi,40px)} + .hk-sep{font-size:22px;margin:0 1px} + .bl-balloons{font-size:clamp(24px,9cqi,32px);letter-spacing:2px} + .bl-word{min-height:45px;font-size:clamp(28px,12cqi,40px);letter-spacing:3px;text-align:center} + .mt-q{font-size:clamp(28px,12cqi,38px)} + .mt-a{min-width:90px;height:50px;font-size:28px} + .mt-pad{gap:6px} + .mt-pad button{min-height:44px;padding:8px 0} + + /* schrijfblad, media, mindmap, anker, dobbelsteen, namen en timer */ + .wp-bar{gap:4px;padding:5px;max-height:45%;overflow:auto} + .wp-group{gap:2px;padding:2px} + .wp-group button{width:30px;height:30px} + .wp-clear{width:34px;height:34px} + .media-bar{flex-wrap:wrap;padding:7px} + .media-bar input{flex-basis:190px} + .mm-top{max-width:calc(100% - 16px);gap:4px;padding:4px 6px} + .mm-hint{display:none} + .mm-seg button{padding:5px 8px;font-size:12px} + .mm-node{max-width:46%;padding:7px 10px} + .mm-pop{max-width:calc(100% - 8px)} + .an-hint{min-height:42px;padding:6px 92px 6px 8px} + .an-tools{top:4px;right:4px} + .an-cell{width:112px;height:52px;padding:2px 5px;font-size:13px} + .dc-row{gap:8px} + .die{width:clamp(64px,25cqi,84px);height:clamp(64px,25cqi,84px);padding:8px} + .die i{width:clamp(10px,4cqi,14px);height:clamp(10px,4cqi,14px)} + .np-result{min-height:62px;padding:7px;font-size:clamp(23px,9cqi,30px)} + .np-row{gap:7px} + .np-row .tbtn{flex:1} + .np-row label{margin-left:0} + .timer{gap:7px;padding:8px} + .timer .tpie{max-height:190px} + .timer .tdisp{font-size:clamp(36px,15cqi,46px)} + .timer .presets button{padding:7px 10px} + .timer .main button{padding:10px 16px} + .tseg button{padding:6px 9px} + + /* verjaardag, getallenlijn en zinsbouwer */ + .bd{grid-template-columns:1fr} + .bd-controls{max-height:165px;border-right:0;border-bottom:1px solid var(--line); + display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;padding:8px} + .bd-controls>*{min-width:0} + .bd-controls input[type=range]{width:min(96px,100%)} + .bd-party{margin-top:0} + .bd-stage{padding:48px 10px 25px} + .bd-layer{height:62px} + .bd-age-badge{min-width:48px;height:48px;font-size:24px} + .nlw-settings{align-items:end;gap:5px} + .nlw-settings label{flex:1;min-width:64px} + .nlw-settings input,.nlw-settings select{width:100%} + .nlw-new{margin-left:0} + .nlw-scroll{padding-inline:8px} + .nlw-actions{gap:5px;flex-wrap:wrap} + .nlw-value{min-width:58px;font-size:24px} + .sb-banks{grid-template-columns:1fr} + .sb-sentence{min-height:60px;padding:8px} + .sb-add{gap:5px} + .sb-add input{min-width:120px} + .sb-actions{gap:5px} + + /* educatieve widgets: dezelfde compacte opbouw als de oefenklok */ + .edu-stage{align-items:safe center;justify-content:safe center;padding:10px} + .edu-controls{gap:6px;padding:0 8px 8px} + .fraction-wall,.place-grid,.wordtypes,.schedule,.mood,.poll{align-self:center} + .place-grid b{padding:6px 2px;font-size:9px;overflow-wrap:anywhere} + .place-grid output{padding:12px 2px;font-size:clamp(22px,8cqi,36px)} + .wordtypes,.mood{width:100%;justify-content:center} + .schedule>div{gap:7px;padding:8px} + .schedule span{min-width:0;overflow-wrap:anywhere} + .mood .tbtn{font-size:30px;padding:9px} + .poll h3{margin:8px 0} + + .money-task-card,.clock-task-card,.world-task-card,.place-task-card{gap:9px;padding:10px} + .world-top{top:-10px;gap:5px} + .world-gallery{grid-template-columns:1fr} + .world-visual.mixed{gap:6px} + .world-photo-badge{left:6px;top:6px;padding:4px 7px;font-size:8px} + .world-photo-credit{right:5px;bottom:5px;max-width:calc(100% - 10px);font-size:8px} + .world-photo-answers{gap:6px} + .world-clue{padding:13px 12px} + .topo-map-wrap{padding:6px} + .topo-map{max-height:450px} + .world-map-wrap{padding:4px} + .world-map{max-height:none} + .world-continent-label{font-size:12px} + .topo-world-legend{gap:4px} + .topo-world-legend span{padding:4px 6px;font-size:10px} + .money-top,.clock-top,.place-top{top:-10px;gap:5px} + .money-level-control>span,.clock-level-control>span,.place-level-control>span,.world-level-control>span{display:none} + .money-shop-task{grid-template-columns:minmax(0,.8fr) minmax(0,1.2fr);gap:7px} + .money-product,.money-paid{min-height:112px;padding:8px 6px;gap:4px} + .money-product-icon{font-size:46px} + .money-product-name{font-size:14px} + .money-product .money-task-amount{font-size:32px} + .money-product .money-task-words{font-size:16px} + .money-paid>output{font-size:26px} + .money-cash-tray{gap:6px;padding:9px} + .clock{--clock-size:min(210px,68cqi)} + .clock-target{min-height:72px} + .clock-read-layout{gap:10px} + .clock-read-layout>.clock-assignment{min-width:0;flex-basis:100%} + .clock-block-tray.lg-tray{gap:7px;padding:9px} +} + +@container widget (max-width:340px){ + .lg,.mt,.dc,.np,.timer,.nlw,.sb{padding:6px} + .lg-cards{gap:3px} + .lg-cards button,.lg-snd,.lg-mode{width:32px;height:34px} + /* smallere blokken: label én stipmaat moeten mee, anders steekt het raster + onder het blok uit (dat gebeurde hier met de cijferblokken) */ + .lg .slot:not(.clock-digit-slot):not(.money-digit-slot):not(.place-digit-slot), + .lg .blok:not(.clock-digit-block):not(.money-digit-block):not(.place-digit-block){--dot:7px;width:52px;height:82px} + .lg .blok:not(.clock-digit-block):not(.money-digit-block):not(.place-digit-block) .bl{font-size:28px;height:36px} + .lg .slot:not(.clock-digit-slot):not(.money-digit-slot):not(.place-digit-slot) .ghost{font-size:24px;height:32px} + .clock-answer-slots,.money-answer-slots,.place-answer-slots{gap:4px} + .clock-digit-slot.slot.sm,.money-digit-slot.slot.sm,.place-digit-slot.slot.sm{width:39px} + /* de blokhoogte geeft mee (de widget is te smal, niet te laag), zodat de + cijfers leesbaar blijven en het raster er alsnog binnen past */ + .clock-digit-block.blok,.money-digit-block.blok,.place-digit-block.blok{width:34px} + .clock-block-tray.lg-tray,.money-digit-tray.lg-tray,.place-digit-tray.lg-tray{ + grid-template-columns:repeat(5,34px);gap:4px;padding:7px} + .clock-block-tray .clock-drag-block,.place-digit-tray .place-drag-block{width:34px} + .clock-type-display,.money-type-display{min-width:0;width:100%;font-size:30px} + .world-answers{grid-template-columns:1fr} + .world-visual.mixed{gap:5px} + .schedule>div{flex-wrap:wrap} + .schedule span{flex-basis:calc(100% - 70px)} + .sb-add input{flex-basis:100%} + .bd-controls{grid-template-columns:1fr;max-height:180px} +} + +/* ============================================================ + Zakelijk thema: Instellingen (#settingsModal) + Het bord blijft speels; het beheer is voor volwassenen. De ID-scope + verslaat alle .am- en .st-regels hierboven, dus dit blok kan alles + overschrijven zonder het bord of het ouderportaal te raken. + ============================================================ */ +#settingsModal{ + --control-h:32px; --radius:7px; --radius-s:6px; --radius-l:10px; + font-family:ui-sans-serif, system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} +@media (pointer:coarse){ #settingsModal{--control-h:40px;} } +#settingsModal.st-modal{width:min(1100px,96vw);} +#settingsModal .st-head h2{font-size:16px; font-weight:650; letter-spacing:.2px;} +#settingsModal #stSearch{border-radius:var(--radius-s); background:var(--surface); font-size:13px;} +/* navigatie: neutraal, gedempte iconen, accent alleen als inzetlijn */ +#settingsModal .st-nav-btn{font-size:13.5px; font-weight:550; border-radius:var(--radius-s); padding:7px 10px;} +#settingsModal .st-nav-btn.active{background:var(--surface-3); color:var(--ink); box-shadow:inset 3px 0 0 var(--accent);} +#settingsModal .st-nav-ico{filter:grayscale(1); opacity:.6; font-size:13px;} +#settingsModal .st-nav-btn.active .st-nav-ico{opacity:.9;} +#settingsModal .st-nav-group{font-size:10px; letter-spacing:.7px;} +#settingsModal .st-sec-title{font-size:15px; font-weight:650; display:flex; gap:8px; align-items:center;} +#settingsModal .st-sec-ico{filter:grayscale(1); opacity:.6; font-size:14px;} +#settingsModal .st-sec-sub{font-size:12px;} +#settingsModal .st-result{border-radius:var(--radius-s);} +/* platte rijen: scheidingslijnen i.p.v. losse pastel-pills - LET OP: geen + flex-direction hier, de bestaande mobiel-stack-regel moet blijven werken */ +#settingsModal .am-list{gap:0; border:1px solid var(--line); border-radius:var(--radius-s); overflow:hidden; background:var(--surface);} +#settingsModal .am-row{ + background:transparent; border-radius:0; box-shadow:none; + /* meer verticale ruimte dan voorheen (was 7px): rijen zonder gap ertussen + stonden zo dicht op elkaar dat je makkelijk de verkeerde raakte, zeker + op een telefoon */ + border-bottom:1px solid var(--line); margin:0; padding:12px 10px; font-size:13.5px; +} +#settingsModal .am-list > .am-row:last-child, +#settingsModal .am-list > .am-add-wrap:last-child{border-bottom:none;} +#settingsModal .am-row:hover{background:var(--surface-2);} +#settingsModal .am-row-school{background:transparent; font-weight:600;} +#settingsModal .am-row-sub{border-left:none; border-radius:0; padding-left:30px; background:transparent;} +#settingsModal .am-name{font-size:13.5px; font-weight:600;} +#settingsModal .am-role{font-size:12.5px;} +/* groepskoppen: subtiele sticky sectierijen binnen de lijst */ +#settingsModal .am-group{ + position:sticky; top:0; z-index:2; margin:0; padding:7px 10px 5px; + background:var(--surface-2); border-bottom:1px solid var(--line); + font-size:11px; letter-spacing:.6px; color:var(--muted); +} +/* controls: vlak, omrand, compact */ +#settingsModal .am-inp, #settingsModal .am-sel, #settingsModal .am-pw{ + border-radius:var(--radius-s); font-size:13px; background:var(--surface); +} +#settingsModal .am-btn, #settingsModal .am-copy, #settingsModal .tbtn{ + border:1px solid var(--line); border-radius:var(--radius-s); background:var(--surface); + color:var(--ink); /* .tbtn is standaard wit-op-blauw; hier wit-op-wit dus overschrijven */ + font-size:12.5px; font-weight:550; box-shadow:none; padding:0 12px; +} +#settingsModal .am-btn:hover, #settingsModal .tbtn:hover{background:var(--surface-2);} +/* de primaire knop (accent) houdt bewust witte tekst */ +#settingsModal .am-btn.am-primary, #settingsModal .tbtn.am-primary{color:#fff;} +#settingsModal .tbtn:disabled{opacity:.5;} +#settingsModal .am-btn.am-danger{background:transparent; border:1px solid var(--line); color:var(--red); font-weight:600;} +#settingsModal .am-btn.am-danger.armed{background:var(--red); border-color:var(--red); color:#fff;} +#settingsModal .am-edit{border:1px solid transparent; border-radius:var(--radius-s); font-size:12.5px; opacity:.75;} +#settingsModal .am-edit:hover{opacity:1; border-color:var(--line); background:var(--surface-2);} +#settingsModal .am-chip{border-radius:var(--radius-s); padding:3px 9px; font-size:12px;} +#settingsModal .am-result{ + background:transparent; border:1px solid var(--accent); color:var(--accent-ink); + border-radius:var(--radius-s); font-weight:600; padding:7px 11px; +} +#settingsModal .am-schoolbar{background:transparent; border:1px solid var(--line); border-radius:var(--radius-s); padding:6px 10px;} +#settingsModal .am-reset{background:var(--surface); border:1px solid var(--line); border-radius:var(--radius-s); box-shadow:none;} +#settingsModal .am-reset-row{background:transparent; border-radius:0; border-bottom:1px solid var(--line);} +#settingsModal .am-h3{font-size:12px; letter-spacing:.5px;} +#settingsModal .am-field-lbl{font-size:10px; letter-spacing:.5px;} +#settingsModal .am-add{border-radius:var(--radius-s);} +#settingsModal .am-add-wrap > .tbtn{border-style:dashed; border-color:var(--accent); color:var(--accent-ink); font-weight:550;} +#settingsModal .am-add-wrap > .tbtn:hover{color:var(--ink); background:var(--accent-soft);} +/* weergave-sectie: vlakke segmentknoppen */ +#settingsModal .seg{border:1px solid var(--line); border-radius:var(--radius-s); background:var(--surface); padding:2px; box-shadow:none;} +#settingsModal .seg button{border-radius:5px; box-shadow:none; font-size:12.5px;} +#settingsModal .seg button.on{background:var(--surface-3); color:var(--ink); box-shadow:none;} +#settingsModal .vw-lbl{font-size:12.5px; font-weight:600;} +/* hoog contrast: de ID-scope verslaat de body.hc-regels, dus expliciet terug */ +body.hc #settingsModal .am-row{border-bottom:1.5px solid #000;} +body.hc #settingsModal .am-list, body.hc #settingsModal .am-reset, +body.hc #settingsModal .am-btn, body.hc #settingsModal .tbtn, +body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000;} + +/* ---- gebruikers: master-detail (tabel links, bewerkpaneel rechts) ---- */ +#settingsModal .am-toolbar{display:flex; gap:8px; margin:0 0 10px; align-items:center;} +#settingsModal .am-toolbar .am-filter{flex:1; min-width:0;} +#settingsModal .am-btn.am-primary{background:var(--accent); border-color:var(--accent); color:#fff; font-weight:600;} +#settingsModal .am-btn.am-primary:hover{background:var(--accent); opacity:.9;} +#settingsModal .am-split{display:flex; align-items:flex-start;} +#settingsModal .am-master{flex:1; min-width:0;} +#settingsModal .am-detail{ + width:330px; flex:none; position:sticky; top:0; margin-left:16px; padding-left:16px; + border-left:1px solid var(--line); display:flex; flex-direction:column; gap:10px; + max-height:calc(min(700px,92vh) - 220px); overflow-y:auto; +} +#settingsModal .am-detail .am-field{width:100%;} +#settingsModal .am-detail .am-tools{display:flex; flex-direction:column; gap:10px; width:100%;} +#settingsModal .am-detail-empty{color:var(--muted); font-size:12.5px; line-height:1.55; padding:10px 2px;} +#settingsModal .am-detail-head{display:flex; gap:10px; align-items:center;} +#settingsModal .am-detail-head h4{margin:0; font-size:13.5px; font-weight:650;} +#settingsModal .am-detail-head .am-role{display:block; font-size:11.5px;} +#settingsModal .am-detail-title{min-width:0; flex:1;} +#settingsModal .am-detail-close{flex:none;} +/* de tabel: per-rij grid met vaste kolomverhoudingen lijnt over rijen heen uit */ +#settingsModal .am-tr{ + display:grid; grid-template-columns:minmax(0,1.8fr) .9fr 1fr .9fr; + gap:10px; align-items:center; cursor:pointer; +} +#settingsModal .am-thead{ + display:grid; grid-template-columns:minmax(0,1.8fr) .9fr 1fr .9fr; gap:10px; + padding:0 10px 5px; font-size:10px; font-weight:700; letter-spacing:.6px; + text-transform:uppercase; color:var(--muted); +} +#settingsModal .am-tr.sel{background:var(--surface-2); box-shadow:inset 3px 0 0 var(--accent);} +#settingsModal .am-tr .am-name{display:flex; gap:8px; align-items:center; min-width:0;} +#settingsModal .am-name-txt{overflow:hidden; text-overflow:ellipsis; white-space:nowrap;} +#settingsModal .am-username{color:var(--muted); font-weight:500; font-size:11px;} +#settingsModal .am-cell{ + font-size:12.5px; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; +} +#settingsModal .am-avatar{ + width:24px; height:24px; border-radius:50%; flex:none; + background:var(--surface-3); color:var(--muted); font-size:10px; font-weight:700; + display:inline-flex; align-items:center; justify-content:center; letter-spacing:.3px; +} +#settingsModal .am-avatar.big{width:34px; height:34px; font-size:12.5px;} +#settingsModal .am-status .am-chip{background:transparent; border:1px solid var(--line); color:var(--muted); font-size:11px;} +#settingsModal .am-status .am-chip.on{border-color:var(--accent); color:var(--accent-ink);} +/* smal scherm: het detailpaneel wordt een overlay óver de tabel (onder de + tabel stapelen betekent bij grote scholen eindeloos scrollen) */ +@media (max-width:760px){ + #settingsModal .am-thead{display:none;} + #settingsModal .am-tr{grid-template-columns:minmax(0,1fr) auto;} + #settingsModal .am-tr .am-cell{display:none;} + #settingsModal .am-tr .am-cell:nth-of-type(2){display:block; text-align:right;} + #settingsModal .am-detail{display:none;} + #settingsModal .am-split.has-sel .am-detail{ + display:flex; position:fixed; inset:10% 4% 4%; z-index:6500; margin:0; + background:var(--surface); border:1px solid var(--line); border-radius:10px; + padding:14px; max-height:none; box-shadow:var(--shadow-2); + } +} + +/* ---- groepskaart: één kader om items die bij elkaar horen ---- */ +#settingsModal .am-card{ + border:1px solid var(--line); border-radius:var(--radius-s); background:var(--surface); + overflow:hidden; margin-bottom:10px; +} +#settingsModal .am-card-head{ + display:flex; gap:9px; align-items:center; padding:9px 12px; + background:var(--surface-2); border-bottom:1px solid var(--line); +} +#settingsModal .am-card:has(.am-card-subs[style*="none"]):not(:has(.am-card-tools:not([style*="none"]))) .am-card-head{border-bottom:none;} +#settingsModal .am-ico{filter:grayscale(1); opacity:.55; font-size:13px; flex:none;} +#settingsModal .am-card-title{font-weight:650; font-size:13.5px; flex:1; min-width:80px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;} +#settingsModal .am-card-sum{margin-left:auto; flex:0 1 auto; min-width:0; font-size:12px; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;} +#settingsModal .am-card-toggle{cursor:pointer; user-select:none; padding:4px 6px; border-radius:5px;} +#settingsModal .am-card-toggle:hover{background:var(--surface-3); color:var(--ink);} +#settingsModal .am-card-tools{padding:10px 12px; border-bottom:1px solid var(--line); display:flex; flex-wrap:wrap; gap:10px;} +#settingsModal .am-card-subs > .am-row{border-bottom:1px solid var(--line);} +#settingsModal .am-card-subs > .am-row:last-child{border-bottom:none;} +/* compacte subrij binnen een kaart */ +#settingsModal .am-card-sub{font-size:12.5px; padding:5px 12px 5px 30px; flex-wrap:wrap;} +#settingsModal .am-card-sub .am-name{font-size:12.5px; font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;} + +/* Visuele opdracht- en reeksbouwer voor groepsleiding */ +#castBackdrop .cast-composer-sheet{width:min(960px,calc(100vw - 32px));max-width:960px;max-height:min(90dvh,900px);padding:18px 20px 0;overscroll-behavior:contain} +.cast-composer-intro{max-width:72ch;margin:0 0 10px;color:var(--muted);font-size:13px;line-height:1.45} +.cast-status{min-height:0;margin:0 0 10px;padding:0;font-size:13px;font-weight:850} +.cast-status:empty{display:none}.cast-status.error{padding:9px 11px;border-radius:10px;background:var(--red-soft);color:var(--red)}.cast-status.success{padding:9px 11px;border-radius:10px;background:color-mix(in srgb,var(--green) 14%,var(--surface));color:var(--green)} +.cast-guide{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:0 0 14px;padding:0;list-style:none} +.cast-guide li{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:12px;background:var(--surface-2);color:var(--muted);font-size:11px} +.cast-guide li span{width:24px;height:24px;display:grid;place-items:center;flex:none;border-radius:50%;background:var(--accent);color:#fff;font-weight:950}.cast-guide li strong{color:var(--ink)} +.cast-composer-card{display:flex;flex-direction:column;gap:10px;margin-bottom:14px;padding:15px;border:1px solid var(--line);border-radius:18px;background:var(--surface);box-shadow:0 3px 14px rgba(20,35,60,.05)} +.cast-composer-card>h3,.cast-section-head h3{margin:0;font-size:16px}.cast-composer-card>.am-group,.cast-pupil-head>.am-group{margin:4px 0 0} +.cast-kind-tabs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px} +.cast-kind-button{display:flex;min-width:0;min-height:66px;flex-direction:column;align-items:flex-start;justify-content:center;gap:3px;padding:10px 12px;border:1.5px solid var(--line);border-radius:13px;background:var(--surface-2);color:var(--ink);font:inherit;text-align:left;cursor:pointer} +.cast-kind-button:hover{border-color:var(--accent)}.cast-kind-button.active{border-color:var(--accent);background:var(--accent-soft);box-shadow:inset 0 0 0 1px var(--accent)}.cast-kind-button span{color:var(--muted);font-size:11px;line-height:1.25}.cast-kind-button:disabled{opacity:.55;cursor:default} +.cast-live-hint{margin:0;padding:8px 10px;border-left:3px solid var(--blue);border-radius:0 9px 9px 0;background:color-mix(in srgb,var(--blue) 7%,var(--surface));color:var(--muted);font-size:11px;line-height:1.4} +.cast-field{display:flex;flex-direction:column;gap:5px;color:var(--muted);font-size:11.5px;font-weight:850}.cast-field .am-inp,.cast-field .am-sel{width:100%}.cast-mode-field{max-width:360px} +.cast-section-head,.cast-pupil-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.cast-section-head>div:first-child{display:flex;min-width:0;flex-direction:column;gap:2px}.cast-section-head small{color:var(--muted);font-size:11px;font-weight:650;line-height:1.35}.cast-mini-actions{display:flex!important;flex-direction:row!important;flex:none;gap:6px} +.cast-widget-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(178px,1fr));gap:8px} +.cast-widget-card{display:grid;grid-template-columns:38px minmax(0,1fr) 28px;align-items:center;gap:8px;min-height:62px;padding:9px 10px;border:1.5px solid var(--line);border-radius:13px;background:var(--surface-2);color:var(--ink);font:inherit;text-align:left;cursor:pointer} +.cast-widget-card:hover{border-color:var(--accent);transform:translateY(-1px)}.cast-widget-card.selected{border-color:var(--accent);background:var(--accent-soft);box-shadow:inset 0 0 0 1px var(--accent)}.cast-widget-card:disabled{opacity:.5;cursor:default;transform:none} +.cast-widget-icon{width:38px;height:38px;display:grid;place-items:center;border-radius:11px;background:var(--surface);font-size:22px}.cast-widget-copy{display:flex;min-width:0;flex-direction:column;gap:2px}.cast-widget-copy strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}.cast-widget-copy small{color:var(--muted);font-size:10.5px;font-weight:750}.cast-widget-plus,.cast-widget-count{width:25px;height:25px;display:grid;place-items:center;border-radius:50%;background:var(--accent);color:#fff;font-size:15px;font-weight:950}.cast-widget-count{background:var(--green)} +.cast-path-list{display:flex;flex-direction:column;gap:7px}.cast-path-step{display:grid;grid-template-columns:28px 34px minmax(105px,1fr) minmax(155px,1fr) minmax(130px,165px) auto;align-items:center;gap:8px;padding:8px;border:1px solid var(--line);border-radius:12px;background:var(--surface-2)} +.cast-path-number{width:26px;height:26px;display:grid;place-items:center;border-radius:50%;background:var(--accent);color:#fff;font-size:12px;font-weight:900}.cast-step-icon{font-size:22px;text-align:center}.cast-step-copy{display:flex;min-width:0;flex-direction:column;gap:2px}.cast-step-copy strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}.cast-step-copy small{color:var(--muted);font-size:10px}.cast-step-mode{min-width:0;width:100%}.cast-step-controls{display:flex;gap:4px}.cast-path-step .am-btn{min-width:32px;min-height:34px;padding:4px 7px}.cast-step-remove{color:var(--red)!important}.cast-sequence-count{flex:none;padding:4px 8px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:10.5px;font-weight:850}.cast-path-progress{width:100%;height:7px;accent-color:var(--green)} +.cast-step-option{display:flex;min-width:0;flex-direction:column;gap:3px}.cast-step-option>span:first-child{overflow:hidden;color:var(--muted);font-size:9.5px;font-weight:800;text-overflow:ellipsis;white-space:nowrap}.cast-step-theme{min-width:0;width:100%}.cast-theme-field{max-width:420px}.cast-theme-na{display:flex;min-height:35px;align-items:center;padding:0 9px;border:1px dashed var(--line);border-radius:9px;color:var(--muted);font-size:11px;font-weight:700} +.cast-path-empty,.cast-empty,.cast-kind-note{margin:0;padding:12px;border:1px dashed var(--line);border-radius:12px;background:var(--surface-2);color:var(--muted);font-size:12px;line-height:1.45} +.cast-audience{gap:8px}.cast-target-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(205px,1fr));gap:7px}.cast-target-card{display:flex;min-width:0;align-items:center;gap:9px;padding:9px 10px;border:1px solid var(--line);border-radius:12px;background:var(--surface-2);cursor:pointer}.cast-target-card:hover{border-color:var(--accent)}.cast-target-card:has(.cast-target-check:checked){border-color:var(--accent);background:var(--accent-soft)}.cast-target-check{width:19px;height:19px;flex:none;accent-color:var(--accent)}.cast-target-via{flex:none;padding:3px 7px;border-radius:999px;background:var(--accent);color:#fff;font-size:9.5px;font-weight:900}.cast-pupil-search{width:100%}.cast-pupil-grid{max-height:250px;overflow-y:auto;padding:2px}.cast-target-card[hidden]{display:none} +.cast-sendbar{position:sticky;z-index:2;bottom:0;display:flex;align-items:center;justify-content:space-between;gap:12px;margin:8px -20px 0;padding:13px 20px calc(13px + env(safe-area-inset-bottom));border-top:1px solid var(--line);background:color-mix(in srgb,var(--surface) 94%,transparent);backdrop-filter:blur(14px)} +.cast-send-summary{display:flex;min-width:0;flex-direction:column;gap:2px}.cast-send-summary strong{font-size:13px}.cast-send-summary span{color:var(--muted);font-size:11px}.cast-validation{color:var(--orange);font-size:10.5px;font-weight:800}.cast-send-button{min-height:44px;flex:none;padding:0 18px;border:0;border-radius:12px;background:var(--accent);color:#fff;font:inherit;font-weight:900;cursor:pointer;box-shadow:0 4px 12px color-mix(in srgb,var(--accent) 30%,transparent)}.cast-send-button:hover{filter:brightness(.94)}.cast-send-button:disabled{opacity:.5;cursor:default;box-shadow:none} +body.hc .cast-kind-button,body.hc .cast-widget-card,body.hc .cast-target-card,body.hc .cast-composer-card{border-width:2px} + +/* Leerkrachtdashboard: scanbare status per leerling */ +#settingsModal .teacher-dashboard{margin:10px 0 20px} +#settingsModal .dashboard-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:10px} +#settingsModal .dashboard-summary-chip{display:flex;align-items:center;gap:7px;padding:9px 10px;border-radius:10px;background:var(--surface-2);color:var(--muted)} +#settingsModal .dashboard-summary-chip strong{font-size:20px;color:var(--ink)} +#settingsModal .dashboard-summary-chip span{font-size:11px;font-weight:800} +#settingsModal .dashboard-summary-chip.complete{box-shadow:inset 3px 0 var(--green)} +#settingsModal .dashboard-summary-chip.active{box-shadow:inset 3px 0 var(--blue)} +#settingsModal .dashboard-summary-chip.support{box-shadow:inset 3px 0 var(--orange)} +#settingsModal .dashboard-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:8px} +#settingsModal .dashboard-card{display:flex;flex-direction:column;align-items:stretch;gap:5px;text-align:left;padding:11px;border:1px solid var(--line);border-radius:12px;background:var(--surface);color:var(--ink);font:inherit;cursor:pointer} +#settingsModal .dashboard-card:hover{border-color:var(--accent);box-shadow:0 4px 14px rgba(30,50,90,.1)} +#settingsModal .dashboard-status{align-self:flex-start;padding:2px 7px;border-radius:99px;background:var(--surface-2);color:var(--muted);font-size:10px;font-weight:900} +#settingsModal .dashboard-card.complete .dashboard-status{background:color-mix(in srgb,var(--green) 16%,var(--surface));color:var(--green)} +#settingsModal .dashboard-card.support .dashboard-status{background:color-mix(in srgb,var(--orange) 16%,var(--surface));color:var(--orange)} +#settingsModal .dashboard-name{font-size:14px} +#settingsModal .dashboard-path{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +#settingsModal .dashboard-path-bar{display:block;height:5px;border-radius:99px;background:var(--surface-3);overflow:hidden} +#settingsModal .dashboard-path-bar i{display:block;height:100%;border-radius:inherit;background:var(--green)} +#settingsModal .dashboard-score{font-size:11.5px;font-weight:800;color:var(--muted)} +@media (max-width:700px){ + #castBackdrop .cast-composer-sheet{width:100%;max-width:none;max-height:94dvh;padding:14px 14px 0;border-radius:18px 18px 0 0} + .cast-guide li{justify-content:center;padding:7px}.cast-guide li strong{display:none} + .cast-kind-tabs{grid-template-columns:1fr}.cast-kind-button{min-height:54px}.cast-kind-button span{display:none} + .cast-section-head{align-items:flex-start;flex-direction:column}.cast-section-head .cast-mini-actions{align-self:stretch}.cast-mini-actions .am-btn{flex:1} + .cast-widget-grid{grid-template-columns:1fr 1fr}.cast-widget-card{grid-template-columns:34px minmax(0,1fr) 24px;min-height:58px;padding:7px}.cast-widget-icon{width:34px;height:34px;font-size:19px} + .cast-path-step{grid-template-columns:26px 30px minmax(0,1fr) auto}.cast-step-theme-option{grid-column:2 / -1;grid-row:2}.cast-step-option:not(.cast-step-theme-option){grid-column:2 / -1;grid-row:3}.cast-step-controls{grid-column:4;grid-row:1}.cast-step-copy small{display:none} + .cast-target-grid{grid-template-columns:1fr}.cast-pupil-head{align-items:flex-end}.cast-pupil-head .am-btn{font-size:11px} + .cast-sendbar{margin-left:-14px;margin-right:-14px;padding-left:14px;padding-right:14px}.cast-send-summary span{display:none}.cast-send-button{padding:0 13px} +} +@media (max-width:420px){.cast-widget-grid{grid-template-columns:1fr}.cast-send-summary small{max-width:170px}.cast-sequence-count{white-space:normal}} +@media (max-width:600px){#settingsModal .dashboard-summary{grid-template-columns:1fr 1fr}} + +/* Voortgang-tab: klassenmanagement-koppeling + grafiek (v0.4.35-beta e.v.) */ +/* Realtime leersessies bovenin het klasdashboard */ +#settingsModal .live-session-overview{margin-bottom:14px;padding:12px;border:1px solid color-mix(in srgb,var(--blue) 32%,var(--line));border-radius:14px;background:color-mix(in srgb,var(--blue) 5%,var(--surface))} +#settingsModal .live-session-head,#settingsModal .live-session-group-head{display:flex;align-items:center;justify-content:space-between;gap:8px} +#settingsModal .live-session-head{margin-bottom:10px;font-size:14px} +#settingsModal .live-connection{padding:3px 8px;border-radius:99px;background:var(--surface-2);color:var(--orange);font-size:10px;font-weight:900} +#settingsModal .live-connection.connected{background:color-mix(in srgb,var(--green) 14%,var(--surface));color:var(--green)} +#settingsModal .live-session-group+.live-session-group{margin-top:12px} +#settingsModal .live-session-group-head{margin-bottom:6px;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.35px} +#settingsModal .live-session-group-head span{min-width:22px;padding:2px 6px;border-radius:99px;background:var(--surface-2);text-align:center;font-weight:900} +#settingsModal .live-session-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:7px} +#settingsModal .live-session-card{display:flex;min-width:0;flex-direction:column;align-items:flex-start;gap:4px;padding:10px;border:1px solid var(--line);border-radius:11px;background:var(--surface);color:var(--ink);font:inherit;text-align:left;cursor:pointer} +#settingsModal .live-session-card:hover,#settingsModal .live-session-card:focus-visible{border-color:var(--accent);outline:2px solid var(--accent-soft);box-shadow:0 4px 12px rgba(30,50,90,.08)} +#settingsModal .live-session-card.live{border-color:color-mix(in srgb,var(--green) 45%,var(--line));box-shadow:inset 3px 0 var(--green)} +#settingsModal .live-session-card.recent{box-shadow:inset 3px 0 var(--line)} +#settingsModal .live-session-status{padding:2px 7px;border-radius:99px;background:var(--surface-2);color:var(--muted);font-size:10px;font-weight:900} +#settingsModal .live-session-card.live .live-session-status{background:color-mix(in srgb,var(--green) 15%,var(--surface));color:var(--green)} +#settingsModal .live-session-name{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px} +#settingsModal .live-session-widget,#settingsModal .live-session-time,#settingsModal .live-session-score{max-width:100%;color:var(--muted);font-size:11px;line-height:1.35} +#settingsModal .live-session-score{font-weight:850;color:var(--accent-ink)} + +#settingsModal .am-combo-shell{display:flex;flex-direction:column;gap:14px} +#settingsModal .am-combo-hero{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(280px,.85fr);gap:14px;align-items:stretch;padding:16px;border:1px solid color-mix(in srgb,var(--accent) 18%,var(--line));border-radius:18px;background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 10%,var(--surface)),var(--surface))} +#settingsModal .am-combo-hero-copy{display:flex;flex-direction:column;gap:8px} +#settingsModal .am-combo-kicker{color:var(--accent-ink);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.48px} +#settingsModal .am-combo-title{margin:0;font-size:clamp(18px,3vw,26px);line-height:1.05} +#settingsModal .am-combo-lead{margin:0;max-width:62ch;color:var(--muted);font-size:13px;line-height:1.45} +#settingsModal .am-combo-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-top:4px} +#settingsModal .am-combo-stat{padding:10px 11px;border-radius:12px;background:var(--surface-2);display:flex;flex-direction:column;gap:2px} +#settingsModal .am-combo-stat strong{font-size:19px;line-height:1;color:var(--ink)} +#settingsModal .am-combo-stat span{font-size:10.5px;font-weight:850;color:var(--muted);text-transform:uppercase;letter-spacing:.35px} +#settingsModal .am-combo-chart{width:100%;min-height:140px;align-self:center} +#settingsModal .am-combo-chart rect{filter:drop-shadow(0 3px 8px rgba(20,35,60,.12))} +#settingsModal .am-combo-chart-value{fill:#fff;font-size:18px;font-weight:900} +#settingsModal .am-combo-chart-label{fill:var(--muted);font-size:10px;font-weight:800} +#settingsModal .am-combo-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px;align-items:start} +#settingsModal .am-combo-pane{display:flex;flex-direction:column;gap:10px;padding:14px;border:1px solid var(--line);border-radius:18px;background:var(--surface);box-shadow:0 3px 14px rgba(20,35,60,.05)} +#settingsModal .am-combo-pane-title{font-size:13px;font-weight:900;text-transform:uppercase;letter-spacing:.35px;color:var(--accent-ink)} +#settingsModal .am-combo-pane .teacher-dashboard{margin-top:0} +#settingsModal .am-combo-pane .am-group:first-child{margin-top:0} +#settingsModal .assignment-composer-cta{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px;border:1px solid color-mix(in srgb,var(--accent) 35%,var(--line));border-radius:14px;background:var(--accent-soft)} +#settingsModal .assignment-composer-cta>div{display:flex;min-width:0;flex-direction:column;gap:3px}#settingsModal .assignment-composer-cta strong{font-size:13px;color:var(--accent-ink)}#settingsModal .assignment-composer-cta span{color:var(--muted);font-size:11px;line-height:1.35}#settingsModal .assignment-composer-cta .tbtn{flex:none;background:var(--accent);color:#fff} +@media (max-width:920px){#settingsModal .am-combo-hero{grid-template-columns:1fr}#settingsModal .am-combo-layout{grid-template-columns:1fr}} +@media (max-width:600px){#settingsModal .am-combo-stats{grid-template-columns:1fr 1fr}#settingsModal .assignment-composer-cta{align-items:stretch;flex-direction:column}#settingsModal .assignment-composer-cta .tbtn{width:100%}} + +#settingsModal .progress-level-badge{ + display:inline-flex; align-self:flex-start; padding:4px 10px; margin-bottom:10px; + border-radius:999px; background:var(--accent-soft); color:var(--accent-ink); + font-size:12.5px; font-weight:700; +} +#settingsModal .progress-summary{ + display:flex; align-items:center; gap:16px; padding:14px; margin-bottom:12px; + border:1px solid var(--line); border-radius:var(--radius-s); background:var(--surface); +} +#settingsModal .progress-ring{width:64px; height:64px; flex:none;} +#settingsModal .progress-ring-pct{font-size:22px; font-weight:800; fill:var(--ink);} +#settingsModal .progress-summary-text{flex:none; min-width:110px;} +#settingsModal .progress-summary-count{font-weight:700; font-size:14px; color:var(--ink);} +#settingsModal .progress-summary-hint{font-size:11.5px; color:var(--muted);} +#settingsModal .progress-bars{flex:1; min-width:0; height:56px;} +#settingsModal .am-card-sub .am-role{font-size:11.5px; margin-left:auto;} +#settingsModal .am-card-sub .am-edit{flex:none;} +#settingsModal .am-card-sub .am-row-tools{width:100%;} +#settingsModal .img-quota-pick{margin-bottom:8px; max-width:420px;} +/* status-glyph (● actief / ○ wacht) met uitleg via title */ +#settingsModal .am-glyph{flex:none; font-size:10px; color:var(--muted); cursor:default;} +#settingsModal .am-glyph.ok{color:var(--green);} +/* mobiel: rijen binnen het beheer niet meer verticaal stapelen - de generieke + stapelregel maakte school en subrij ononderscheidbaar; wrap vangt overloop */ +@media (max-width:600px){ + #settingsModal .am-row{flex-direction:row; align-items:center; flex-wrap:wrap;} + /* kaartkop mag wrappen: titel houdt regel 1, samenvatting+Bewerken eronder */ + #settingsModal .am-card-head{flex-wrap:wrap;} + /* compacte subrij op telefoon: naam + Bewerken op regel 1, verbruik eronder */ + #settingsModal .am-card-sub .am-role{order:3; flex-basis:100%; margin-left:0;} + #settingsModal .am-card-sub .am-edit{margin-left:auto;} +} + +/* ---- inklapbare koppen: wachtwoordblok (Account) en Inhoud-kaarten ---- */ +#settingsModal .am-h3-toggle{cursor:pointer; user-select:none;} +#settingsModal .am-h3-toggle::after{content:" ▸";} +#settingsModal .am-h3-toggle.open::after{content:" ▾";} +#settingsModal .am-h3-toggle:hover{color:var(--ink);} +#settingsModal #ownPwBlock{display:none;} +#settingsModal #ownPwBlock.open{display:flex;} +#settingsModal .am-card-head.am-card-toggle{cursor:pointer;} +#settingsModal .cms-card-body{padding:10px 12px;} +#settingsModal .cms-card-body .am-reset{border:none; padding:0; background:transparent;} + +/* ---- uniforme control-maat: elk veld/knop in het beheer even hoog ---- */ +#settingsModal .am-btn, +#settingsModal .am-copy, +#settingsModal .tbtn, +#settingsModal .am-edit, +#settingsModal .am-inp, +#settingsModal .am-sel, +#settingsModal .am-pw, +#settingsModal input[type="search"], +#settingsModal input[type="number"], +#settingsModal input[type="password"], +#settingsModal input[type="text"]{ + height:var(--control-h); min-height:var(--control-h); box-sizing:border-box; + font-size:13px; line-height:1; +} +#settingsModal .am-edit{padding:0 12px; opacity:.75;} +#settingsModal .am-edit:hover{opacity:1;} +/* textarea's (CMS) mogen hoger; niet vastpinnen */ +#settingsModal textarea.am-inp, #settingsModal .cms-ta{height:auto; min-height:64px;} +/* labels boven de velden even breed als hun veld zodat rijen uitlijnen */ +#settingsModal .am-field{gap:4px;} diff --git a/public/fonts/LICENSE-Quicksand.txt b/public/fonts/LICENSE-Quicksand.txt new file mode 100644 index 0000000..2c90211 --- /dev/null +++ b/public/fonts/LICENSE-Quicksand.txt @@ -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). diff --git a/public/fonts/quicksand.woff2 b/public/fonts/quicksand.woff2 new file mode 100644 index 0000000..b29be8d Binary files /dev/null and b/public/fonts/quicksand.woff2 differ diff --git a/public/img/logo.svg b/public/img/logo.svg new file mode 100644 index 0000000..b77b8da --- /dev/null +++ b/public/img/logo.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/money/SOURCES.md b/public/img/money/SOURCES.md new file mode 100644 index 0000000..118234b --- /dev/null +++ b/public/img/money/SOURCES.md @@ -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. diff --git a/public/img/money/coin-1.png b/public/img/money/coin-1.png new file mode 100644 index 0000000..b9ad3a3 Binary files /dev/null and b/public/img/money/coin-1.png differ diff --git a/public/img/money/coin-10.png b/public/img/money/coin-10.png new file mode 100644 index 0000000..955fcb7 Binary files /dev/null and b/public/img/money/coin-10.png differ diff --git a/public/img/money/coin-100.png b/public/img/money/coin-100.png new file mode 100644 index 0000000..68ae9fd Binary files /dev/null and b/public/img/money/coin-100.png differ diff --git a/public/img/money/coin-2.png b/public/img/money/coin-2.png new file mode 100644 index 0000000..944750f Binary files /dev/null and b/public/img/money/coin-2.png differ diff --git a/public/img/money/coin-20.png b/public/img/money/coin-20.png new file mode 100644 index 0000000..faa3977 Binary files /dev/null and b/public/img/money/coin-20.png differ diff --git a/public/img/money/coin-200.png b/public/img/money/coin-200.png new file mode 100644 index 0000000..4da6960 Binary files /dev/null and b/public/img/money/coin-200.png differ diff --git a/public/img/money/coin-5.png b/public/img/money/coin-5.png new file mode 100644 index 0000000..79ac525 Binary files /dev/null and b/public/img/money/coin-5.png differ diff --git a/public/img/money/coin-50.png b/public/img/money/coin-50.png new file mode 100644 index 0000000..8daf625 Binary files /dev/null and b/public/img/money/coin-50.png differ diff --git a/public/img/money/note-1000.jpg b/public/img/money/note-1000.jpg new file mode 100644 index 0000000..1ce44c1 Binary files /dev/null and b/public/img/money/note-1000.jpg differ diff --git a/public/img/money/note-2000.jpg b/public/img/money/note-2000.jpg new file mode 100644 index 0000000..8898bbf Binary files /dev/null and b/public/img/money/note-2000.jpg differ diff --git a/public/img/money/note-500.jpg b/public/img/money/note-500.jpg new file mode 100644 index 0000000..ad85f93 Binary files /dev/null and b/public/img/money/note-500.jpg differ diff --git a/public/img/money/note-5000.jpg b/public/img/money/note-5000.jpg new file mode 100644 index 0000000..c42b6d8 Binary files /dev/null and b/public/img/money/note-5000.jpg differ diff --git a/public/img/world/ATTRIBUTION.md b/public/img/world/ATTRIBUTION.md new file mode 100644 index 0000000..16319f1 --- /dev/null +++ b/public/img/world/ATTRIBUTION.md @@ -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` | [Witch’s 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). diff --git a/public/img/world/birds/barn-owl.webp b/public/img/world/birds/barn-owl.webp new file mode 100644 index 0000000..af020c0 Binary files /dev/null and b/public/img/world/birds/barn-owl.webp differ diff --git a/public/img/world/birds/blackbird.webp b/public/img/world/birds/blackbird.webp new file mode 100644 index 0000000..f8f59bb Binary files /dev/null and b/public/img/world/birds/blackbird.webp differ diff --git a/public/img/world/birds/blue-tit.webp b/public/img/world/birds/blue-tit.webp new file mode 100644 index 0000000..f8d1aa5 Binary files /dev/null and b/public/img/world/birds/blue-tit.webp differ diff --git a/public/img/world/birds/buzzard.webp b/public/img/world/birds/buzzard.webp new file mode 100644 index 0000000..3a26689 Binary files /dev/null and b/public/img/world/birds/buzzard.webp differ diff --git a/public/img/world/birds/chaffinch.webp b/public/img/world/birds/chaffinch.webp new file mode 100644 index 0000000..18940a4 Binary files /dev/null and b/public/img/world/birds/chaffinch.webp differ diff --git a/public/img/world/birds/collared-dove.webp b/public/img/world/birds/collared-dove.webp new file mode 100644 index 0000000..697842f Binary files /dev/null and b/public/img/world/birds/collared-dove.webp differ diff --git a/public/img/world/birds/coot.webp b/public/img/world/birds/coot.webp new file mode 100644 index 0000000..a8faa54 Binary files /dev/null and b/public/img/world/birds/coot.webp differ diff --git a/public/img/world/birds/great-tit.webp b/public/img/world/birds/great-tit.webp new file mode 100644 index 0000000..cc452dc Binary files /dev/null and b/public/img/world/birds/great-tit.webp differ diff --git a/public/img/world/birds/grey-heron.webp b/public/img/world/birds/grey-heron.webp new file mode 100644 index 0000000..ba10791 Binary files /dev/null and b/public/img/world/birds/grey-heron.webp differ diff --git a/public/img/world/birds/greylag-goose.webp b/public/img/world/birds/greylag-goose.webp new file mode 100644 index 0000000..b90604b Binary files /dev/null and b/public/img/world/birds/greylag-goose.webp differ diff --git a/public/img/world/birds/house-sparrow.webp b/public/img/world/birds/house-sparrow.webp new file mode 100644 index 0000000..c0a0e58 Binary files /dev/null and b/public/img/world/birds/house-sparrow.webp differ diff --git a/public/img/world/birds/magpie.webp b/public/img/world/birds/magpie.webp new file mode 100644 index 0000000..db80bf3 Binary files /dev/null and b/public/img/world/birds/magpie.webp differ diff --git a/public/img/world/birds/mallard.webp b/public/img/world/birds/mallard.webp new file mode 100644 index 0000000..46422dc Binary files /dev/null and b/public/img/world/birds/mallard.webp differ diff --git a/public/img/world/birds/mute-swan.webp b/public/img/world/birds/mute-swan.webp new file mode 100644 index 0000000..cb2329c Binary files /dev/null and b/public/img/world/birds/mute-swan.webp differ diff --git a/public/img/world/birds/robin.webp b/public/img/world/birds/robin.webp new file mode 100644 index 0000000..eb5c238 Binary files /dev/null and b/public/img/world/birds/robin.webp differ diff --git a/public/img/world/birds/starling.webp b/public/img/world/birds/starling.webp new file mode 100644 index 0000000..d093a63 Binary files /dev/null and b/public/img/world/birds/starling.webp differ diff --git a/public/img/world/birds/woodpecker.webp b/public/img/world/birds/woodpecker.webp new file mode 100644 index 0000000..122c3ea Binary files /dev/null and b/public/img/world/birds/woodpecker.webp differ diff --git a/public/img/world/birds/wren.webp b/public/img/world/birds/wren.webp new file mode 100644 index 0000000..cc6bd22 Binary files /dev/null and b/public/img/world/birds/wren.webp differ diff --git a/public/img/world/leaves/alder.webp b/public/img/world/leaves/alder.webp new file mode 100644 index 0000000..0efcb15 Binary files /dev/null and b/public/img/world/leaves/alder.webp differ diff --git a/public/img/world/leaves/ash.webp b/public/img/world/leaves/ash.webp new file mode 100644 index 0000000..6e33f55 Binary files /dev/null and b/public/img/world/leaves/ash.webp differ diff --git a/public/img/world/leaves/aspen.webp b/public/img/world/leaves/aspen.webp new file mode 100644 index 0000000..b012fda Binary files /dev/null and b/public/img/world/leaves/aspen.webp differ diff --git a/public/img/world/leaves/beech.webp b/public/img/world/leaves/beech.webp new file mode 100644 index 0000000..83e0bfe Binary files /dev/null and b/public/img/world/leaves/beech.webp differ diff --git a/public/img/world/leaves/birch.webp b/public/img/world/leaves/birch.webp new file mode 100644 index 0000000..061adba Binary files /dev/null and b/public/img/world/leaves/birch.webp differ diff --git a/public/img/world/leaves/elm.webp b/public/img/world/leaves/elm.webp new file mode 100644 index 0000000..9227e4d Binary files /dev/null and b/public/img/world/leaves/elm.webp differ diff --git a/public/img/world/leaves/hawthorn.webp b/public/img/world/leaves/hawthorn.webp new file mode 100644 index 0000000..bbcc94e Binary files /dev/null and b/public/img/world/leaves/hawthorn.webp differ diff --git a/public/img/world/leaves/horse-chestnut.webp b/public/img/world/leaves/horse-chestnut.webp new file mode 100644 index 0000000..a2c2f33 Binary files /dev/null and b/public/img/world/leaves/horse-chestnut.webp differ diff --git a/public/img/world/leaves/larch.webp b/public/img/world/leaves/larch.webp new file mode 100644 index 0000000..da3d326 Binary files /dev/null and b/public/img/world/leaves/larch.webp differ diff --git a/public/img/world/leaves/lime.webp b/public/img/world/leaves/lime.webp new file mode 100644 index 0000000..ebcbaee Binary files /dev/null and b/public/img/world/leaves/lime.webp differ diff --git a/public/img/world/leaves/maple.webp b/public/img/world/leaves/maple.webp new file mode 100644 index 0000000..9c61c01 Binary files /dev/null and b/public/img/world/leaves/maple.webp differ diff --git a/public/img/world/leaves/oak.webp b/public/img/world/leaves/oak.webp new file mode 100644 index 0000000..dba612a Binary files /dev/null and b/public/img/world/leaves/oak.webp differ diff --git a/public/img/world/leaves/pine.webp b/public/img/world/leaves/pine.webp new file mode 100644 index 0000000..0015c03 Binary files /dev/null and b/public/img/world/leaves/pine.webp differ diff --git a/public/img/world/leaves/rowan.webp b/public/img/world/leaves/rowan.webp new file mode 100644 index 0000000..e779fc2 Binary files /dev/null and b/public/img/world/leaves/rowan.webp differ diff --git a/public/img/world/leaves/spruce.webp b/public/img/world/leaves/spruce.webp new file mode 100644 index 0000000..e09023b Binary files /dev/null and b/public/img/world/leaves/spruce.webp differ diff --git a/public/img/world/leaves/sweet-chestnut.webp b/public/img/world/leaves/sweet-chestnut.webp new file mode 100644 index 0000000..5a09972 Binary files /dev/null and b/public/img/world/leaves/sweet-chestnut.webp differ diff --git a/public/img/world/leaves/willow.webp b/public/img/world/leaves/willow.webp new file mode 100644 index 0000000..37049c0 Binary files /dev/null and b/public/img/world/leaves/willow.webp differ diff --git a/public/img/world/leaves/yew.webp b/public/img/world/leaves/yew.webp new file mode 100644 index 0000000..1c4c791 Binary files /dev/null and b/public/img/world/leaves/yew.webp differ diff --git a/public/img/world/netherlands-provinces.svg b/public/img/world/netherlands-provinces.svg new file mode 100644 index 0000000..bc86f4d --- /dev/null +++ b/public/img/world/netherlands-provinces.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/public/img/world/plants/chicory.webp b/public/img/world/plants/chicory.webp new file mode 100644 index 0000000..c948d16 Binary files /dev/null and b/public/img/world/plants/chicory.webp differ diff --git a/public/img/world/plants/common-poppy.webp b/public/img/world/plants/common-poppy.webp new file mode 100644 index 0000000..50be211 Binary files /dev/null and b/public/img/world/plants/common-poppy.webp differ diff --git a/public/img/world/plants/common-sorrel.webp b/public/img/world/plants/common-sorrel.webp new file mode 100644 index 0000000..c5017cc Binary files /dev/null and b/public/img/world/plants/common-sorrel.webp differ diff --git a/public/img/world/plants/cow-parsley.webp b/public/img/world/plants/cow-parsley.webp new file mode 100644 index 0000000..e5af540 Binary files /dev/null and b/public/img/world/plants/cow-parsley.webp differ diff --git a/public/img/world/plants/daisy.webp b/public/img/world/plants/daisy.webp new file mode 100644 index 0000000..6145949 Binary files /dev/null and b/public/img/world/plants/daisy.webp differ diff --git a/public/img/world/plants/dandelion.webp b/public/img/world/plants/dandelion.webp new file mode 100644 index 0000000..d37fd0d Binary files /dev/null and b/public/img/world/plants/dandelion.webp differ diff --git a/public/img/world/plants/foxglove.webp b/public/img/world/plants/foxglove.webp new file mode 100644 index 0000000..480a58d Binary files /dev/null and b/public/img/world/plants/foxglove.webp differ diff --git a/public/img/world/plants/nettle.webp b/public/img/world/plants/nettle.webp new file mode 100644 index 0000000..88efd57 Binary files /dev/null and b/public/img/world/plants/nettle.webp differ diff --git a/public/img/world/plants/red-clover.webp b/public/img/world/plants/red-clover.webp new file mode 100644 index 0000000..f22d64e Binary files /dev/null and b/public/img/world/plants/red-clover.webp differ diff --git a/public/img/world/plants/ribwort-plantain.webp b/public/img/world/plants/ribwort-plantain.webp new file mode 100644 index 0000000..9751e09 Binary files /dev/null and b/public/img/world/plants/ribwort-plantain.webp differ diff --git a/public/img/world/plants/white-deadnettle.webp b/public/img/world/plants/white-deadnettle.webp new file mode 100644 index 0000000..f5563c5 Binary files /dev/null and b/public/img/world/plants/white-deadnettle.webp differ diff --git a/public/img/world/plants/yarrow.webp b/public/img/world/plants/yarrow.webp new file mode 100644 index 0000000..852acf3 Binary files /dev/null and b/public/img/world/plants/yarrow.webp differ diff --git a/public/img/world/trees/alder.webp b/public/img/world/trees/alder.webp new file mode 100644 index 0000000..151a543 Binary files /dev/null and b/public/img/world/trees/alder.webp differ diff --git a/public/img/world/trees/ash.webp b/public/img/world/trees/ash.webp new file mode 100644 index 0000000..60186e4 Binary files /dev/null and b/public/img/world/trees/ash.webp differ diff --git a/public/img/world/trees/aspen.webp b/public/img/world/trees/aspen.webp new file mode 100644 index 0000000..1769081 Binary files /dev/null and b/public/img/world/trees/aspen.webp differ diff --git a/public/img/world/trees/beech.webp b/public/img/world/trees/beech.webp new file mode 100644 index 0000000..5ed1fa4 Binary files /dev/null and b/public/img/world/trees/beech.webp differ diff --git a/public/img/world/trees/birch.webp b/public/img/world/trees/birch.webp new file mode 100644 index 0000000..4a8e3a3 Binary files /dev/null and b/public/img/world/trees/birch.webp differ diff --git a/public/img/world/trees/elm.webp b/public/img/world/trees/elm.webp new file mode 100644 index 0000000..dc8c563 Binary files /dev/null and b/public/img/world/trees/elm.webp differ diff --git a/public/img/world/trees/hawthorn.webp b/public/img/world/trees/hawthorn.webp new file mode 100644 index 0000000..ec18a3e Binary files /dev/null and b/public/img/world/trees/hawthorn.webp differ diff --git a/public/img/world/trees/horse-chestnut.webp b/public/img/world/trees/horse-chestnut.webp new file mode 100644 index 0000000..6e9b0f5 Binary files /dev/null and b/public/img/world/trees/horse-chestnut.webp differ diff --git a/public/img/world/trees/larch.webp b/public/img/world/trees/larch.webp new file mode 100644 index 0000000..ec67193 Binary files /dev/null and b/public/img/world/trees/larch.webp differ diff --git a/public/img/world/trees/lime.webp b/public/img/world/trees/lime.webp new file mode 100644 index 0000000..80edde6 Binary files /dev/null and b/public/img/world/trees/lime.webp differ diff --git a/public/img/world/trees/maple.webp b/public/img/world/trees/maple.webp new file mode 100644 index 0000000..6f43ca1 Binary files /dev/null and b/public/img/world/trees/maple.webp differ diff --git a/public/img/world/trees/oak.webp b/public/img/world/trees/oak.webp new file mode 100644 index 0000000..d5c7947 Binary files /dev/null and b/public/img/world/trees/oak.webp differ diff --git a/public/img/world/trees/pine.webp b/public/img/world/trees/pine.webp new file mode 100644 index 0000000..5f24196 Binary files /dev/null and b/public/img/world/trees/pine.webp differ diff --git a/public/img/world/trees/rowan.webp b/public/img/world/trees/rowan.webp new file mode 100644 index 0000000..1da8d66 Binary files /dev/null and b/public/img/world/trees/rowan.webp differ diff --git a/public/img/world/trees/spruce.webp b/public/img/world/trees/spruce.webp new file mode 100644 index 0000000..a140bec Binary files /dev/null and b/public/img/world/trees/spruce.webp differ diff --git a/public/img/world/trees/sweet-chestnut.webp b/public/img/world/trees/sweet-chestnut.webp new file mode 100644 index 0000000..899e16c Binary files /dev/null and b/public/img/world/trees/sweet-chestnut.webp differ diff --git a/public/img/world/trees/willow.webp b/public/img/world/trees/willow.webp new file mode 100644 index 0000000..749b3fa Binary files /dev/null and b/public/img/world/trees/willow.webp differ diff --git a/public/img/world/trees/yew.webp b/public/img/world/trees/yew.webp new file mode 100644 index 0000000..d59de8a Binary files /dev/null and b/public/img/world/trees/yew.webp differ diff --git a/public/img/world/world-continents.svg b/public/img/world/world-continents.svg new file mode 100644 index 0000000..beb35c1 --- /dev/null +++ b/public/img/world/world-continents.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/index.html b/public/index.html index 7a70d8b..df96ba6 100644 --- a/public/index.html +++ b/public/index.html @@ -3,954 +3,115 @@ -teach - +teachus.today + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
- +
+
+
-
+
+ @@ -960,11 +121,32 @@
-
-
-
-
-
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
@@ -972,3741 +154,194 @@
-
+
+
+
- diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..4cd2b8b --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,1876 @@ +/* teach - beheerpaneel (👥): scholen, klassen en gebruikers als één drill-down + navigator (tegel-overzicht → gefocust detail, i.p.v. alles tegelijk + uitgeklapt op één lange pagina). Zichtbaar voor super (alles), admin + (eigen school) en teacher (leerlingen). */ +"use strict"; +(function(){ + /* het beheerpaneel is een tabblad binnen Instellingen (settingsWrap/settingsModal), + geen los venster meer - modal is alleen nog de content-container voor render() */ + const modal = document.getElementById("adminModal"); + let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null; + /* schoolbeheerders van alle scholen, voor de schooltegels op "alle scholen" */ + let SYS_ADMINS = []; + /* huidige klas-toewijzingen (leerling-omgeving), per klas-id - opnieuw + opgehaald bij elke reload() zoals CLASSES/USERS hierboven */ + let ASSIGNMENTS = {}; + /* openstaande aanmeldverzoeken van ouders (thuiskind -> deze school) */ + let LINK_REQUESTS = []; + /* resultaat van een laatste multireset (klas/school): blijft zichtbaar + totdat de beheerder het venster zelf sluit, zodat wachtwoorden niet + meteen weer verdwijnen na een reload() */ + let resetResults = null; + /* actief tabblad, blijft behouden over her-renders heen (na elke actie) */ + let adminTab = "school"; + /* Klassenmanagement: welke klas/vakgebied nu getoond wordt, blijft behouden + over her-renders heen zoals adminTab hierboven. */ + let kmClass = null, kmSubject = "rekenen"; + const KM_SUBJECTS = ["taal", "rekenen", "world", "creatief"]; + let stopDashboardLive=()=>{}; + let liveDashboardState="reconnecting"; + + /* navigatie binnen "School": welk niveau van de boom je ziet. + {type:"overview"} - tegels (scholen op "alle scholen", anders + klassen+platte groepen van de gekozen school) + {type:"class", id} - gefocust detail van één klas + {type:"flat", kind} - gefocust detail van één platte groep + (admins/supers/parents/noschool/unassigned/requests) + Blijft behouden over her-renders, reset bij het wisselen van school en bij + in-/uitloggen - anders belandt een systeemmanager na het kiezen van een + andere school midden in de klas van de vórige school. */ + let navLevel = { type:"overview" }; + /* welke sheet (persoon of aanmelding) open staat; blijft ook over reload() + heen zodat een actie ín de sheet (nieuw wachtwoord, klas koppelen) de + sheet niet laat verdwijnen maar met verse data laat herladen - zelfde + reden als waarom rowResults/resetResults hieronder blijven staan. */ + let openSheet = null; + /* uitgeklapt "⋯"-actiepaneel (klas- of schoolbeheer) op het huidige niveau; + hoort bij navLevel, dus reset zodra dat wijzigt */ + let kebabOpen = false; + /* systeembrede zoekopdracht: vult, op het tegel-niveau, het scherm met een + platte resultatenlijst i.p.v. tegels */ + let searchQuery = ""; + + /* welke rollen de ingelogde gebruiker mag aanmaken: unie over hoofdrol + extra rollen */ + const creatableRoles = () => + [...new Set(activeRoles().flatMap(r => CREATABLE_ROLES[r] || []))]; + + /* wie er als groepsleiding aan een klas gekoppeld kan staan - zelfde rollenset + als de server accepteert in POST /admin/classes/:id/teachers (niet alleen + "teacher": een schoolbeheerder of systeemmanager mag ook aan een klas + gekoppeld worden, en moet dan ook als zodanig getoond worden). */ + const isClassStaff = u => ["teacher","admin","super"].includes(u.role); + + const esc = s => String(s ?? "").replace(/&/g,"&").replace(/{ + const el = document.createElement(tag); + if(cls) el.className = cls; + if(text != null) el.textContent = text; + return el; + }; + /* wachtwoorden/koppelcodes zijn maar eenmalig zichtbaar - een kopieerknop + zorgt dat ze veilig bewaard kunnen worden voordat het scherm ververst */ + async function copyText(btn, text){ + try{ await navigator.clipboard.writeText(text); } + catch(e){ + const ta = document.createElement("textarea"); + ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; + document.body.appendChild(ta); ta.select(); + try{ document.execCommand("copy"); }catch(_){ /* niets meer te doen */ } + document.body.removeChild(ta); + } + const orig = btn.textContent; + btn.textContent = "✓"; + setTimeout(()=>{ btn.textContent = orig; }, 1200); + } + function copyBtn(text, cls){ + const b = h("button", cls || "am-btn", T("amCopy")); + b.type = "button"; + b.addEventListener("click", e=>{ e.preventDefault(); if(text) copyText(b, text); }); + return b; + } + const msg = (t, copyValue) => { + const m = modal.querySelector(".am-msg"); + if(!m) return; + m.innerHTML = ""; + if(!t) return; + m.appendChild(document.createTextNode(t)); + if(copyValue) m.appendChild(copyBtn(copyValue)); + }; + + /* ---- één interactiesysteem voor het hele beheer --------------------------- + field(): elk invoerveld/select krijgt een zichtbaar mini-label erboven + (een placeholder alleen is geen toelichting). + dangerBtn(): acties met gevolgen (wachtwoord/koppelcode vernieuwen, + verwijderen, resetten) zien er anders uit dan gewone knoppen + en vragen een tweede klik als bevestiging (zelfde + arm-then-confirm als elders in de app); wapening loopt + vanzelf af. Geen native confirm()-dialogen meer. + rowResults: nieuw gegenereerde wachtwoorden/codes verschijnen direct bij + de gebruiker in de sheet (met kopieerknop). */ + function field(labelKey, control){ + const w = h("label","am-field"); + w.appendChild(h("span","am-field-lbl", T(labelKey))); + w.appendChild(control); + return w; + } + function dangerBtn(label, warnKey, fn){ + const b = h("button","am-btn am-danger", label); + b.type = "button"; + if(warnKey) b.title = T(warnKey); + let timer = null; + b.addEventListener("click", async ()=>{ + if(!timer){ + b.classList.add("armed"); + b.textContent = T("amSure"); + timer = setTimeout(()=>{ timer = null; b.classList.remove("armed"); b.textContent = label; }, 4000); + return; + } + clearTimeout(timer); timer = null; + b.classList.remove("armed"); b.textContent = label; + await fn(b); + }); + return b; + } + /* per gebruiker het laatste credential-resultaat, overleeft de re-render + na de actie zodat het bij de rij zichtbaar is tot het paneel sluit */ + const rowResults = new Map(); + document.addEventListener("userchange", ()=>rowResults.clear()); + function resultBadge(u){ + const r = rowResults.get(u.id); + if(!r) return null; + const badge = h("div","am-result"); + badge.appendChild(h("span", null, r.label + ":")); + const code = h("code", null, r.value); + badge.appendChild(code); + badge.appendChild(copyBtn(r.value)); + return badge; + } + + /* ---- groepskaart: één kader om wat bij elkaar hoort ------------------------ + Nog gebruikt door assignmentRow (Toewijzingen) en de Opslag-tab + (image-catalog.js) - niet meer door dit bestand zelf. */ + function groupCard(opts){ + const openSet = opts.openSet || new Set(); + const editSet = opts.editSet || new Set(); + const card = h("div","am-card"); + const head = h("div","am-card-head"); + card.appendChild(head); + if(opts.icon) head.appendChild(h("span","am-ico", opts.icon)); + head.appendChild(h("span","am-card-title", opts.title)); + const sum = h("span","am-card-sum"); + head.appendChild(sum); + let tools = null; + if(opts.edit){ + tools = h("div","am-card-tools"); + tools.appendChild(opts.edit.tools); + const btn = h("button","am-edit", T("amEdit")); + btn.type = "button"; + const ekey = opts.edit.key; + const esync = ()=>{ + const open = editSet.has(ekey); + tools.style.display = open ? "" : "none"; + btn.classList.toggle("on", open); + }; + btn.addEventListener("click", ()=>{ + if(editSet.has(ekey)) editSet.delete(ekey); else editSet.add(ekey); + esync(); + }); + esync(); + head.appendChild(btn); + card.appendChild(tools); + } + if(opts.subs && opts.subs.length){ + const subHost = h("div","am-card-subs"); + opts.subs.forEach(el=>subHost.appendChild(el)); + card.appendChild(subHost); + sum.classList.add("am-card-toggle"); + const sync = ()=>{ + const open = openSet.has(opts.key); + sum.textContent = `${opts.summary} ${open ? "▾" : "▸"}`; + subHost.style.display = open ? "" : "none"; + }; + sum.addEventListener("click", ()=>{ + if(openSet.has(opts.key)) openSet.delete(opts.key); else openSet.add(opts.key); + sync(); + }); + sync(); + }else if(opts.summary){ + sum.textContent = opts.summary; + } + return card; + } + window.amGroupCard = groupCard; + + /* ---- beheer-secties in het instellingen-register (settings.js) ------------ + Elke sectie meldt zichzelf aan met rol-zichtbaarheid, een activate() en + zoekgegevens; de schil bouwt daar navigatie + zoeken uit. Scholen, klassen + en gebruikers zijn hier bewust ÉÉN sectie ("school") die intern een eigen + drill-down navigator opbouwt (zie renderSchoolNavigator). */ + const ADMIN_SECTIONS = [ + { tab:"school", icon:"🏫", group:"school", order:10, labelKey:"amSchoolMenu", subKey:"stSchoolMenuSub", + show:()=>true, + keywords:()=>[T("amPupils"), T("roleTeacher"), T("roleAdmin"), T("amParents"), T("amLinkRequests"), + T("amNewPw"), T("amParentCode"), "leerling","pupil","ouder","parent","wachtwoord","password", + "koppelcode","aanmelding","enrolment", + T("amNewClass"), T("amResetClass"), "klas","class","groep","group", + T("amLevelYearGroup"), T("amLevelLevel"), "leerjaar","year group","niveau","level","differentiatie", + T("amNewSchool"), T("amNewSuper"), "school","systeemmanager","system manager"], + search:q=>[ + ...USERS + .filter(u=>`${u.displayName} ${u.username}`.toLowerCase().includes(q)) + .map(u=>({ icon: u.role==="pupil" ? "🧒" : u.role==="parent" ? "👪" : "🧑‍🏫", + label:u.displayName, sub:T(roleKey(u.role)), + go:()=>goSettingsSection("school", { openPerson:u.id }) })), + ...CLASSES + .filter(c=>c.name.toLowerCase().includes(q)) + .map(c=>({ icon:"🏷", label:c.name, sub:T("amClasses"), + go:()=>{ navLevel={type:"class", id:c.id}; kebabOpen=false; goSettingsSection("school"); } })), + ...SCHOOLS + .filter(s=>s.name.toLowerCase().includes(q)) + .map(s=>({ icon:"🏫", label:s.name, sub:T("amSchools"), + go:()=>{ selSchool=String(s.id); navLevel={type:"overview"}; goSettingsSection("school"); } })), + ] }, + { tab:"toewijzingen", icon:"🎯", group:"school", order:12, labelKey:"amAssignmentsLevels", subKey:"amAssignmentsLevelsSub", + show:()=>can("assignments.manage"), + keywords:()=>[T("amModeWatch"), T("amModeWork"), T("amDashboardTitle"), T("castPathTitle"), + T("amLevelYearGroup"), T("amLevelLevel"), T("amAssignmentsLevels"), + "bord","board","delen","share","toewijzen","assign","widget","dashboard","opdrachtenreeks","learning path", + "niveau","level","groep","niveaugroep","leerjaar","year group","differentiatie"] }, + { tab:"voortgang", icon:"📈", group:"school", order:13, labelKey:"amProgress", subKey:"stVoortgangSub", + show:()=>can("progress.view"), + keywords:()=>["voortgang","progress","score","resultaten","results","tijdlijn","timeline"] }, + { tab:"klassenmanagement", icon:"🎓", group:"school", order:14, labelKey:"amLevels", subKey:"amLevelsSub", + show:()=>false, + keywords:()=>[T("amLevelYearGroup"), T("amLevelLevel"), "niveau","level","groep","niveaugroep", + "leerjaar","year group","differentiatie"] }, + { tab:"opslag", icon:"🖼", group:"system", order:32, labelKey:"stOpslag", subKey:"stOpslagSub", + show:()=>activeRole()==="super", + keywords:()=>["opslag","storage","afbeeldingen","images","quota"] }, + ]; + const adminTabDefs = ()=>ADMIN_SECTIONS.filter(d=>d.show()).map(d=>[d.tab, T(d.labelKey)]); + /* data één keer per bezoek aan Instellingen laden; secties onderling + hergebruiken de lading (in-flight promise voorkomt dubbel laden) */ + let loadPromise = null; + function ensureLoaded(force){ + if(force) loadPromise = null; + if(!loadPromise){ + loadPromise = (async ()=>{ + /* super: selSchool null = nog niet gekozen (reload() zet dan "alle + scholen" als default). Andere rollen: eigen school. */ + if(currentUser.role!=="super") selSchool = currentUser.schoolId; + await reload(); + })(); + } + return loadPromise; + } + /* settings.js roept dit bij het openen aan: verse data + zoekindex vullen */ + window.adminPrefetch = ()=>{ ensureLoaded(true).catch(()=>{}); }; + ADMIN_SECTIONS.forEach(d=>{ + registerSettingsSection({ + id:d.tab, icon:d.icon, group:d.group, order:d.order, + label:()=>T(d.labelKey), sub:()=>T(d.subKey), + visible:()=>!!currentUser && can("admin.access") && d.show(), + async activate(payload){ + await ensureLoaded(); + if(d.tab==="school" && payload && payload.openPerson !== undefined) openSheet = { type:"person", id:payload.openPerson }; + showAdminHost(); + switchAdminTab(d.tab); + if(d.tab==="school") render(); + }, + keywords:d.keywords, + searchItems:d.search, + }); + }); + document.addEventListener("userchange", ()=>{ + if(!currentUser) resetResults = null; + loadPromise = null; + selSchool = null; /* nieuwe sessie: laat reload() opnieuw defaulten */ + navLevel = { type:"overview" }; + openSheet = null; + kebabOpen = false; + searchQuery = ""; + }); + + async function reload(){ + let reloadError = null; + try{ + if(currentUser.role==="super"){ + SCHOOLS = await api("/schools"); + /* eerste bezoek: standaard "alle scholen" (het scholenoverzicht) i.p.v. + meteen een school te kiezen - de systeemmanager beheert immers over + scholen heen; een specifieke school kiezen is een bewuste stap. */ + if(selSchool == null) selSchool = ""; + } + const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : ""; + USERS = (await api("/admin/users"+q)).users; + /* schooltegels op "alle scholen" tonen schoolbeheerders van élke school; + zonder schoolfilter is USERS al compleet en is de extra call overbodig */ + if(currentUser.role==="super"){ + SYS_ADMINS = q ? ((await api("/admin/users")).users || []).filter(u=>u.role==="admin") + : USERS.filter(u=>u.role==="admin"); + } + const cq = currentUser.role==="super" && selSchool ? "?school="+selSchool : ""; + CLASSES = (await api("/admin/classes"+cq)).classes || []; + ASSIGNMENTS = {}; + if(CLASSES.length){ + const results = await Promise.all(CLASSES.map(c=>api("/assignments/class/"+c.id).catch(()=>({assignment:null})))); + CLASSES.forEach((c,i)=>{ ASSIGNMENTS[c.id] = results[i].assignment; }); + } + LINK_REQUESTS = []; + if(currentUser.role!=="super" || selSchool){ + const lq = currentUser.role==="super" ? "?school="+selSchool : ""; + LINK_REQUESTS = ((await api("/admin/link-requests"+lq).catch(()=>({}))).requests) || []; + } + }catch(e){ USERS = []; CLASSES = []; ASSIGNMENTS = {}; LINK_REQUESTS = []; reloadError = e; } + render(); + /* render() bouwt de modal (incl. .am-msg) helemaal opnieuw op, dus de + melding pas ná render() zetten - anders wordt hij meteen weer gewist. + Zonder dit bleef het paneel bij een fout stilletjes helemaal leeg staan + zonder enige aanwijzing waarom. */ + if(reloadError) msg(T("amLoadError") + ": " + reloadError.message); + } + + function classSel(current){ + const sel = h("select","am-sel"); + sel.appendChild(new Option(T("amNoClass"), "")); + CLASSES.forEach(c=>sel.appendChild(new Option(c.name, c.id))); + sel.value = current ?? ""; + return sel; + } + + function avatar(nameStr, big){ + const parts = String(nameStr || "?").trim().split(/\s+/); + const ini = ((parts[0][0] || "?") + (parts[1] ? parts[1][0] : "")).toUpperCase(); + return h("span", "am-avatar" + (big ? " big" : ""), ini); + } + /* status compact als glyph met uitleg-title: ● in gebruik, ○ wacht nog op + eerste login/activatie. */ + function userStatusGlyph(u){ + const waiting = u.pending || (u.role==="pupil" && !u.everLoggedIn); + const label = u.pending ? T("amPending") + : (u.role==="pupil" && !u.everLoggedIn) ? T("amNeverLoggedIn") + : T("amStatusActive"); + const g = h("span","am-glyph" + (waiting ? "" : " ok"), waiting ? "○" : "●"); + g.title = label; + g.setAttribute("aria-label", label); + return g; + } + + /* het bewerkformulier van één gebruiker; wordt in de sheet getoond zodra + iemand op een personRow tikt. */ + function userTools(u, note){ + note = note || msg; + const tools = h("div","am-tools"); + const actions = h("div","am-actions"); + const isStaff = ["teacher","admin","super"].includes(u.role); + /* school koppelen/losmaken (systeemmanager): losse accounts terugzetten + naar hun school, of bewust losmaken - super/ouder zijn per definitie + schoolloos en vallen hierbuiten */ + if(activeRole()==="super" && ["teacher","admin","pupil"].includes(u.role)){ + const ss = h("select","am-sel"); + ss.appendChild(new Option(T("amNoSchool"), "")); + SCHOOLS.forEach(sc=>ss.appendChild(new Option(sc.name, sc.id))); + ss.value = u.schoolId ?? ""; + ss.addEventListener("change", async ()=>{ + try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{ schoolId: ss.value ? +ss.value : null }}); reload(); } + catch(e){ note(e.message); ss.value = u.schoolId ?? ""; } + }); + tools.appendChild(field("amFieldSchool", ss)); + } + /* rolwijziging: alleen tussen groepsleiding/schoolbeheerder onderling. + Systeemmanager is hier bewust nooit een optie - die rol ontstaat alleen + via de aparte "Nieuwe systeemmanager"-aanmaak (met bevestiging van het + eigen wachtwoord), nooit door een bestaand account te promoveren. Een + bestaande systeemmanager toont dit veld daarom ook niet: er is geen + veilige weg om die rol via dit simpele dropdown-veld te wijzigen. */ + if(isStaff && u.role!=="super" && can("users.role.change") && u.id!==currentUser.id){ + const rs = h("select","am-sel"); + ["teacher","admin"].forEach(r=>rs.appendChild(new Option(T(roleKey(r)), r))); + rs.value = u.role; + rs.addEventListener("change", async ()=>{ + try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{role: rs.value}}); reload(); } + catch(e){ note(e.message); } + }); + tools.appendChild(field("amFieldRole", rs)); + } + /* elke staf-rol eenvoudig aan klassen koppelen (groepsleiding, + schoolbeheerder én systeemmanager): huidige klassen als chips met ✕, + en een select om een klas toe te voegen */ + if(isStaff && can("classes.manage") && CLASSES.length){ + const wrap = h("div","am-classes"); + (u.classIds||[]).forEach(cid=>{ + const c = CLASSES.find(x=>Number(x.id)===Number(cid)); + if(!c) return; + const chip = h("span","am-chip on", c.name); + const x = h("button","am-chipx","✕"); + x.type = "button"; + x.title = T("amClassRemove"); + x.addEventListener("click", async ()=>{ + try{ await api("/admin/classes/"+cid+"/teachers", {body:{ userId: u.id, remove:true }}); reload(); } + catch(e){ note(e.message); } + }); + chip.appendChild(x); + wrap.appendChild(chip); + }); + const rest = CLASSES.filter(c=>!(u.classIds||[]).some(id=>Number(id)===Number(c.id))); + if(rest.length){ + const addSel = h("select","am-sel"); + addSel.appendChild(new Option(T("amClassAdd"), "")); + rest.forEach(c=>addSel.appendChild(new Option(c.name, c.id))); + addSel.addEventListener("change", async ()=>{ + if(!addSel.value) return; + try{ await api("/admin/classes/"+addSel.value+"/teachers", {body:{ userId: u.id }}); reload(); } + catch(e){ note(e.message); } + }); + wrap.appendChild(addSel); + } + tools.appendChild(field("amFieldClasses", wrap)); + } + if(u.role==="pupil"){ + /* klas (uitwisselen met andere klassen) */ + const cs = classSel(u.classId); + cs.addEventListener("change", async ()=>{ + try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{classId: cs.value ? +cs.value : null}}); reload(); } + catch(e){ note(e.message); } + }); + tools.appendChild(field("amFieldClass", cs)); + /* wachtwoord inzien en aanpassen - alleen zichtbaar zolang de leerling + nog niet is ingelogd; daarna kan alleen een nieuw wachtwoord gezet worden */ + const pwWrap = h("div","am-inline"); + const pw = h("input","am-pw"); + pw.value = u.password || ""; + pw.placeholder = u.everLoggedIn ? T("amPwHidden") : T("amPwTypePh"); + pw.addEventListener("keydown", ev=>ev.stopPropagation()); + pw.addEventListener("change", async ()=>{ + if(pw.value.length < 4) return; + try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{password: pw.value}}); reload(); } + catch(e){ note(e.message); } + }); + pwWrap.appendChild(pw); + const pwCopy = h("button","am-btn", T("amCopy")); + pwCopy.type = "button"; + pwCopy.addEventListener("click", e=>{ e.preventDefault(); if(pw.value) copyText(pwCopy, pw.value); }); + pwWrap.appendChild(pwCopy); + tools.appendChild(field("amFieldPw", pwWrap)); + if(u.everLoggedIn){ + actions.appendChild(dangerBtn(T("amNewPw"), "amNewPwWarn", async ()=>{ + try{ + const { password } = await api("/admin/genpw?role=pupil"); + await api("/admin/users/"+u.id, {method:"PATCH", body:{password}}); + rowResults.set(u.id, { label: T("amResultPw"), value: password }); + reload(); + }catch(e){ note(e.message); } + })); + } + /* eenmalige ouder-code waarmee een ouder dit kind aan het eigen + ouderaccount koppelt (ouderportaal); een eerdere code vervalt */ + actions.appendChild(dangerBtn(T("amParentCode"), "amParentCodeWarn", async ()=>{ + try{ + const r = await api("/admin/users/"+u.id+"/parent-code", { body:{} }); + rowResults.set(u.id, { label: T("amResultParentCode"), value: r.parentCode }); + reload(); + }catch(e){ note(e.message); } + })); + }else{ + if(u.pending && u.linkCode){ + tools.appendChild(h("span","am-code", T("amCode")+": "+u.linkCode)); + } + if(can("users.staffCredentials") && u.role!=="pupil" && u.id!==currentUser.id){ + actions.appendChild(dangerBtn(T("amNewCode"), "amNewCodeWarn", async ()=>{ + try{ + const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{newLinkCode:true}}); + rowResults.set(u.id, { label: T("amResultCode"), value: r.linkCode }); + reload(); + }catch(e){ note(e.message); } + })); + /* direct een nieuw wachtwoord zetten, zonder de koppelcode-activatie */ + actions.appendChild(dangerBtn(T("amNewPw"), "amNewPwWarn", async ()=>{ + try{ + const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}}); + rowResults.set(u.id, { label: T("amResultPw"), value: r.password }); + reload(); + }catch(e){ note(e.message); } + })); + } + } + if(u.id !== currentUser.id){ + const del = dangerBtn(T("amDelete"), "amDeleteWarn", async ()=>{ + try{ await api("/admin/users/"+u.id, {method:"DELETE"}); openSheet = null; reload(); } + catch(e){ note(e.message); } + }); + actions.appendChild(del); + } + if(actions.childNodes.length) tools.appendChild(field("amFieldActions", actions)); + return tools; + } + + /* toevoegen vereist een bewuste extra stap: begint ingeklapt als alleen de "+ …"-knop, + pas na een klik verschijnen de invoervelden. presetClassId zet de klas-select alvast op + de klas waar deze toevoegrij bij hoort (leerkracht/beheerder kan dit nog wijzigen). */ + function addForm(role, presetClassId){ + const label = T(role==="pupil" ? "amNewPupil" : role==="teacher" ? "amNewTeacher" : role==="admin" ? "amNewAdmin" : "amNewSuper"); + const wrap = h("div","am-add-wrap"); + const toggle = h("button","tbtn ghost", label); + toggle.type = "button"; + wrap.appendChild(toggle); + + const f = h("div","am-add"); + f.style.display = "none"; + /* het formulier zelf herhaalt wat je aan het toevoegen bent, zodat het + nooit met een bestaande-gebruikersrij te verwarren is zodra de + knoptekst erboven uit beeld is (zie ook .am-add's eigen kader in css) */ + f.appendChild(h("div","am-add-title", label)); + const name = h("input","am-inp"); + name.placeholder = T("amUserPh"); + name.addEventListener("keydown", ev=>ev.stopPropagation()); + f.appendChild(field("amFieldName", name)); + let cs = null; + if(role==="pupil"){ cs = classSel(presetClassId ?? ""); f.appendChild(field("amFieldClass", cs)); } + let pwInp = null; + if(role!=="pupil"){ + /* optioneel: meteen zelf een wachtwoord instellen i.p.v. een koppelcode + (die de gebruiker normaal zelf gebruikt om een wachtwoord te kiezen) */ + const pwWrap = h("div","am-inline"); + pwInp = h("input","am-inp am-pwinp"); + pwInp.type = "password"; + pwInp.autocomplete = "new-password"; + pwInp.placeholder = T("amPwOptional"); + pwInp.addEventListener("keydown", ev=>ev.stopPropagation()); + pwWrap.appendChild(pwInp); + const gen = h("button","am-btn", T("amGenerate")); + gen.type = "button"; + gen.addEventListener("click", async ()=>{ + try{ + const { password } = await api("/admin/genpw"); + pwInp.type = "text"; + pwInp.value = password; + }catch(e){ msg(e.message); } + }); + pwWrap.appendChild(gen); + f.appendChild(field("amFieldPw", pwWrap)); + } + /* een systeemmanager aanmaken is de meest ingrijpende actie in dit + beheer (school-onafhankelijk, alle rechten overal) - vereist daarom + als extra stap het eigen wachtwoord van de aanmaker zelf, ter + bevestiging. De server controleert dit ook; dit is niet louter cosmetisch. */ + let confirmInp = null; + if(role==="super"){ + confirmInp = h("input","am-inp"); + confirmInp.type = "password"; + confirmInp.autocomplete = "current-password"; + confirmInp.placeholder = T("amOwnPasswordPh"); + confirmInp.addEventListener("keydown", ev=>ev.stopPropagation()); + f.appendChild(field("amOwnPassword", confirmInp)); + } + const go = h("button","tbtn", label); + go.type = "button"; + go.disabled = true; + const sync = ()=>{ go.disabled = name.value.trim().length < 2 || (confirmInp && !confirmInp.value); }; + name.addEventListener("input", sync); + if(confirmInp) confirmInp.addEventListener("input", sync); + /* fouten horen bij het formulier zelf, niet ergens bovenaan het paneel */ + const errEl = h("div","am-form-err"); + go.addEventListener("click", async ()=>{ + errEl.textContent = ""; + const body = { role, username: name.value.trim(), displayName: name.value.trim() }; + if(role==="pupil" && cs && cs.value) body.classId = +cs.value; + if(pwInp && pwInp.value.trim()) body.password = pwInp.value.trim(); + if(currentUser.role==="super" && role!=="super") body.school = +selSchool; + if(confirmInp) body.confirmPassword = confirmInp.value; + try{ + const r = await api("/admin/users", { body }); + /* groepsleiding die vanuit een klasrij wordt aangemaakt, hoort ook + meteen aan die klas gekoppeld te zijn - anders staat de nieuwe + leerkracht wel in "Groepsleiding" toegevoegd, maar (nog) niet echt + bij die klas. Hergebruikt de al bestaande koppel-route. */ + if(role==="teacher" && presetClassId){ + await api("/admin/classes/"+presetClassId+"/teachers", { body:{ userId: r.user.id } }); + } + name.value = ""; + if(pwInp) pwInp.value = ""; + if(confirmInp) confirmInp.value = ""; + rowResults.set(r.user.id, { + label: r.user.password ? T("amResultPw") : T("amResultCode"), + value: r.user.password || r.user.linkCode, + }); + reload(); + }catch(e){ errEl.textContent = e.message; } + }); + f.appendChild(go); + f.appendChild(errEl); + wrap.appendChild(f); + + toggle.addEventListener("click", ()=>{ + toggle.style.display = "none"; + f.style.display = "flex"; + name.focus(); + }); + return wrap; + } + + /* paneel met het resultaat van een multireset (klas/school): een lijst + nieuwe leerlingwachtwoorden die stuk voor stuk of allemaal ineens + gekopieerd kunnen worden. Blijft staan tot de beheerder ✕ klikt. */ + function resetPanel(){ + if(!resetResults) return null; + const box = h("div","am-reset"); + const head = h("div","am-reset-head"); + head.appendChild(h("strong",null, resetResults.title)); + const close = h("button","wclose","✕"); + close.addEventListener("click", ()=>{ resetResults = null; render(); }); + head.appendChild(close); + box.appendChild(head); + if(!resetResults.entries.length){ + box.appendChild(h("div","am-msg", T("amResetNone"))); + return box; + } + const all = resetResults.entries.map(en=>`${en.displayName}\t${en.password}`).join("\n"); + const copyAll = h("button","am-btn", T("amCopyAll")); + copyAll.type = "button"; + copyAll.addEventListener("click", e=>{ e.preventDefault(); copyText(copyAll, all); }); + box.appendChild(copyAll); + /* export als uitdeelvel - alleen mogelijk zolang dit scherm openstaat: de + wachtwoorden zijn hierna nergens meer in leesbare vorm te achterhalen + (alleen het gehashte wachtwoord blijft in de database), dus dit is + bewust "exporteer wat nu net gegenereerd is", niet "haal wachtwoorden + van bestaande accounts op". Gebruikersnaam (het echte inlog-veld) staat + er wél bij, ook al toont de schermlijst hierboven alleen de naam. */ + const exportBtn = h("button","am-btn", T("amExportCsv")); + exportBtn.type = "button"; + exportBtn.addEventListener("click", e=>{ + e.preventDefault(); + const header = [T("amExportColPupil"), T("amExportColUsername"), T("amResultPw")]; + const rows = [header, ...resetResults.entries.map(en=>[en.displayName, en.username, en.password])]; + const safeName = resetResults.title.replace(/[^a-z0-9]+/gi, "-"); + downloadCsv(`${safeName}.csv`, rows); + }); + box.appendChild(exportBtn); + const list = h("div","am-reset-list"); + resetResults.entries.forEach(en=>{ + const row = h("div","am-reset-row"); + row.appendChild(h("span","am-name", en.displayName)); + row.appendChild(h("span","am-reset-pw", en.password)); + row.appendChild(copyBtn(`${en.displayName}: ${en.password}`)); + list.appendChild(row); + }); + box.appendChild(list); + return box; + } + + /* toewijzingsrij: overzicht van de huidige toewijzing (aangemaakt via de + Cast-knop op het whiteboard, cast.js) + loskoppelen. Aanmaken/wijzigen + gebeurt niet meer hier - zie cast.js. */ + function describeCurrent(current){ + if(!current) return ""; + if(Array.isArray(current.sequence)&¤t.sequence.length){ + return `${current.title||T("castPathDefault")} · ${T("castAssignmentsCount").replace("{count}",current.sequence.length)}`; + } + let s = `${T("amAssignedAs")} “${current.boardName || current.boardId}”`; + if(current.widgetId){ + const wName = current.widgetType ? T("wg_"+current.widgetType) : current.widgetId; + s += ` › ${wName} (${current.mode==="kijken" ? T("amModeWatch") : T("amModeWork")})`; + } + return s; + } + function assignmentRow(target, current){ + const info = h("span","am-code", describeCurrent(current) || T("amNoAssignment")); + const tools = h("div","am-tools"); + if(current){ + const del = dangerBtn(T("amUnlink"), null, async ()=>{ + try{ + await api(`/assignments/${target.type}/${target.id}`, {method:"DELETE"}); + if(target.type==="class"){ await reload(); } + else{ + info.textContent = T("amNoAssignment"); + del.remove(); + msg(T("amAssignSaved")); + } + }catch(e){ msg(e.message); } + }); + tools.appendChild(del); + } + tools.appendChild(info); + return groupCard({ + title: target.label, + summary: describeCurrent(current) || T("amNoAssignment"), + edit: { key: "assign:"+target.type+":"+target.id, tools }, + }); + } + + /* vult een + + + + +
+
+
+
+ +
+ + + +
+
+
+
+
+ +
`; + document.body.append(wrap);imageCatalogWrap=wrap; + const q=s=>wrap.querySelector(s); + q(".ic-close").onclick=closeImageCatalog; + wrap.onclick=e=>{if(e.target===wrap)closeImageCatalog()}; + q(".ic-search").oninput=renderImageCatalogGrid; + q(".ic-theme-filter").onchange=()=>{renderImageCatalogGrid();renderLanguageThemeChips()}; + q(".ic-scope-filter").onchange=renderImageCatalogGrid; + q(".ic-folder-filter").onchange=renderImageCatalogGrid; + q(".ic-folders").onclick=openImageFolderExplorer; + q(".ic-cancel").onclick=()=>{q(".ic-editor").hidden=true;imageCatalogDeleteArmed=false}; + q(".ic-save").onclick=saveCatalogImage; + q(".ic-delete").onclick=deleteCatalogImage; + q(".ic-theme-new button").onclick=createCatalogTheme; + q(".ic-upload input").onchange=async e=>{ + const files=[...e.target.files];e.target.value=""; + if(!files.length)return; + const folder=q(".ic-upload-folder").value; + const themeIds=[...q(".ic-upload-themes").selectedOptions].map(o=>+o.value); + setImageCatalogStatus(T("imgUploading")); + try{ + for(const file of files)await uploadImageToCatalog(file,{folder,themeIds}); + await loadImageCatalog();renderImageCatalog();setImageCatalogStatus(T("imgUploaded"),true); + }catch(err){setImageCatalogStatus(err.message)} + }; + return wrap; +} + +function setImageCatalogStatus(text,ok=false){ + if(!imageCatalogWrap)return; + const status=imageCatalogWrap.querySelector(".ic-status"); + status.textContent=text||"";status.classList.toggle("ok",!!ok); +} +function closeImageCatalog(){ + if(imageCatalogWrap)imageCatalogWrap.classList.remove("open"); + imageCatalogSelect=null;imageCatalogDeleteArmed=false; +} +function catalogOwnFolders(){ + const scope=IMAGE_CATALOG.uploadScope; + const paths=new Set([""]); + IMAGE_CATALOG.folders.filter(f=>f.scope===scope&&f.canManage).forEach(f=>paths.add(f.path)); + IMAGE_CATALOG.images.filter(i=>i.scope===scope&&i.canManage&&i.folder).forEach(i=>paths.add(i.folder)); + return [...paths].sort((a,b)=>a.localeCompare(b,LANG)); +} +function fillImageSelect(select,items,value,label){ + select.innerHTML=""; + if(label!=null)select.append(new Option(label,"")); + items.forEach(item=>select.append(new Option(item.label,item.value))); + if([...select.options].some(o=>o.value===String(value)))select.value=String(value); +} +function fillThemeSelect(select,selected=[],includeAll=false,asset=null){ + const current=new Set((selected||[]).map(Number));select.innerHTML=""; + if(includeAll)select.append(new Option(T("imgAllThemes"),"")); + IMAGE_CATALOG.themes.filter(theme=>(includeAll||!theme.filterOnly)&&(!asset||theme.scope==="global" + ||(asset.scope==="school"&&theme.scope==="school") + ||(asset.scope==="personal"&&theme.scope==="personal"))) + .forEach(theme=>{ + const option=new Option(imageThemeName(theme),theme.id); + option.selected=current.has(theme.id);select.append(option); + }); +} +function renderLanguageThemeChips(){ + if(!imageCatalogWrap)return; + const host=imageCatalogWrap.querySelector(".ic-word-themes"); + const select=imageCatalogWrap.querySelector(".ic-theme-filter"); + const themes=IMAGE_CATALOG.themes.filter(theme=>theme.languageBuiltin); + host.innerHTML="";host.hidden=!themes.length; + if(!themes.length)return; + const label=document.createElement("strong");label.textContent=T("imgWordThemes");host.append(label); + themes.forEach(theme=>{ + const button=document.createElement("button");button.type="button"; + button.textContent=(theme.icon||"🖼️")+" "+imageThemeName(theme); + button.classList.toggle("active",select.value===String(theme.id)); + button.onclick=()=>{ + select.value=select.value===String(theme.id)?"":String(theme.id); + renderImageCatalogGrid();renderLanguageThemeChips(); + }; + host.append(button); + }); +} +function applyImageCatalogLabels(){ + const q=s=>imageCatalogWrap.querySelector(s); + q(".ic-title").textContent=T("imgCatalog"); + q(".ic-close").title=T("close"); + q(".ic-search").placeholder=T("imgSearch"); + q(".ic-folders").textContent="🗂 "+T("imgFolders"); + q(".ic-add summary").textContent="+ "+T("imgUpload"); + q(".ic-upload span").textContent=T("imgChooseFiles"); + q(".ic-upload-folder-label").textContent=T("imgFolder"); + q(".ic-upload-themes-label").textContent=T("imgThemes"); + q(".ic-theme-new input").placeholder=T("imgNewTheme"); + q(".ic-theme-new button").textContent=T("imgAddTheme"); + q(".ic-editor h3").textContent=T("imgEdit"); + q(".ic-name-label").textContent=T("imgName"); + q(".ic-folder-label").textContent=T("imgFolder"); + q(".ic-themes-label").textContent=T("imgThemes"); + q(".ic-save").textContent=T("save"); + q(".ic-delete").textContent=T("exDelete"); + q(".ic-cancel").textContent=T("cancel"); +} +function renderImageCatalog(){ + const q=s=>imageCatalogWrap.querySelector(s); + refreshWordPictureCatalog(); + applyImageCatalogLabels(); + const themeValue=q(".ic-theme-filter").value,scopeValue=q(".ic-scope-filter").value,folderValue=q(".ic-folder-filter").value; + fillThemeSelect(q(".ic-theme-filter"),[],true); + fillImageSelect(q(".ic-scope-filter"),[ + {value:"global",label:imageScopeName("global")}, + {value:"school",label:imageScopeName("school")}, + {value:"personal",label:imageScopeName("personal")} + ],scopeValue,T("imgAllScopes")); + const allFolders=[...new Set(IMAGE_CATALOG.images.map(i=>i.folder).concat(IMAGE_CATALOG.folders.map(f=>f.path)).filter(Boolean))] + .sort((a,b)=>a.localeCompare(b,LANG)).map(path=>({value:path,label:path})); + fillImageSelect(q(".ic-folder-filter"),allFolders,folderValue,T("imgAllFolders")); + if(themeValue&&[...q(".ic-theme-filter").options].some(o=>o.value===themeValue))q(".ic-theme-filter").value=themeValue; + const ownFolders=catalogOwnFolders().map(path=>({value:path,label:path||T("imgFolderRoot")})); + fillImageSelect(q(".ic-upload-folder"),ownFolders,q(".ic-upload-folder").value); + fillThemeSelect(q(".ic-upload-themes"),[...q(".ic-upload-themes").selectedOptions].map(o=>+o.value),false,{scope:IMAGE_CATALOG.uploadScope}); + renderLanguageThemeChips(); + const quota=IMAGE_CATALOG.quota||{},limit=quota.limitBytes; + let text=T("imgQuotaUsed").replace("{used}",imageBytes(quota.usedBytes)); + text+=" · "+(limit==null?T("imgUnlimited"):T("imgQuotaOf").replace("{limit}",imageBytes(limit))); + if(quota.schoolLimitBytes!=null)text+=" · "+T("imgSchoolQuota").replace("{used}",imageBytes(quota.schoolUsedBytes)).replace("{limit}",imageBytes(quota.schoolLimitBytes)); + q(".ic-quota").textContent=text; + renderImageCatalogGrid(); +} +function renderImageCatalogGrid(){ + if(!imageCatalogWrap)return; + const q=s=>imageCatalogWrap.querySelector(s),grid=q(".ic-grid"); + const search=q(".ic-search").value.trim().toLowerCase(),theme=+q(".ic-theme-filter").value||null; + const scope=q(".ic-scope-filter").value,folder=q(".ic-folder-filter").value; + const themeNames=new Map(IMAGE_CATALOG.themes.map(t=>[t.id,imageThemeName(t)])); + const images=IMAGE_CATALOG.images.filter(image=>{ + const hay=[image.name,image.folder,...(image.tags||[]),...image.themes.map(id=>themeNames.get(id)||"")].join(" ").toLowerCase(); + return(!search||hay.includes(search))&&(!theme||image.themes.includes(theme)) + &&(!scope||image.scope===scope)&&(!folder||image.folder===folder||image.folder.startsWith(folder+"/")); + }); + grid.innerHTML=""; + images.forEach(image=>{ + const card=document.createElement("article");card.className="ic-card"; + const choose=document.createElement("button");choose.type="button";choose.className="ic-choose"; + const img=document.createElement("img");img.src=image.src;img.alt=image.name;img.loading="lazy"; + const name=document.createElement("strong");name.textContent=image.name; + const meta=document.createElement("span");meta.textContent=[imageScopeName(image.scope),image.folder].filter(Boolean).join(" · "); + choose.append(img,name,meta);choose.onclick=()=>{if(imageCatalogSelect){imageCatalogSelect(image.src,image);closeImageCatalog()}else if(image.canManage)openCatalogImageEditor(image)}; + card.append(choose); + if(image.canManage){ + const edit=document.createElement("button");edit.type="button";edit.className="ic-card-edit";edit.textContent="✏"; + edit.title=T("imgEdit");edit.onclick=()=>openCatalogImageEditor(image);card.append(edit); + } + grid.append(card); + }); + if(!images.length){const empty=document.createElement("p");empty.className="gallery-empty";empty.textContent=T("imgNoResults");grid.append(empty)} +} +function openCatalogImageEditor(image){ + const q=s=>imageCatalogWrap.querySelector(s),editor=q(".ic-editor"); + editor.hidden=false;editor.dataset.id=image.id;q(".ic-edit-name").value=image.name;q(".ic-edit-folder").value=image.folder; + fillThemeSelect(q(".ic-edit-themes"),image.themes,false,image); + q(".ic-delete").hidden=!image.canDelete;q(".ic-delete").textContent=T("exDelete"); + imageCatalogDeleteArmed=false;editor.scrollIntoView({block:"nearest"}); +} +async function saveCatalogImage(){ + const q=s=>imageCatalogWrap.querySelector(s),editor=q(".ic-editor"),id=+editor.dataset.id; + const themeIds=[...q(".ic-edit-themes").selectedOptions].map(o=>+o.value); + try{ + await api("/images/"+id,{method:"PATCH",body:{name:q(".ic-edit-name").value,folder:q(".ic-edit-folder").value,themeIds}}); + await loadImageCatalog();editor.hidden=true;renderImageCatalog();setImageCatalogStatus(T("imgSaved"),true); + }catch(err){setImageCatalogStatus(err.message)} +} +async function deleteCatalogImage(){ + const q=s=>imageCatalogWrap.querySelector(s),button=q(".ic-delete"); + if(!imageCatalogDeleteArmed){imageCatalogDeleteArmed=true;button.textContent="⚠ "+T("exConfirm");return} + try{ + await api("/images/"+q(".ic-editor").dataset.id,{method:"DELETE"}); + await loadImageCatalog();q(".ic-editor").hidden=true;renderImageCatalog();setImageCatalogStatus(T("imgDeleted"),true); + }catch(err){setImageCatalogStatus(err.message)} + imageCatalogDeleteArmed=false; +} +async function createCatalogTheme(){ + const input=imageCatalogWrap.querySelector(".ic-theme-new input"),name=input.value.trim(); + if(!name)return; + try{ + await api("/images/themes",{method:"POST",body:{name}}); + input.value="";await loadImageCatalog();renderImageCatalog();setImageCatalogStatus(T("imgThemeAdded"),true); + }catch(err){setImageCatalogStatus(err.message)} +} + +function imageScopeBranches(){ + const scopes=["global"]; + if(currentUser&¤tUser.schoolId!=null)scopes.push("school"); + scopes.push("personal"); + return scopes.map(scope=>({scope,name:(scope==="global"?"🌐 ":scope==="school"?"🏫 ":"👤 ")+imageScopeName(scope)})); +} +function openImageFolderExplorer(){ + const branches=imageScopeBranches(),branchFor=path=>branches.find(b=>b.name===path[0]); + const join=path=>path.join("/"); + const sameScope=(item,branch)=>item.scope===branch.scope; + const adapter={ + title:()=>T("imgFolders"),allowRootItems:false, + list(path){ + if(!path.length)return{folders:branches.map(b=>({name:b.name,count:IMAGE_CATALOG.images.filter(i=>sameScope(i,b)).length,fixed:true})),items:[]}; + const branch=branchFor(path);if(!branch)return{folders:[],items:[]}; + const rel=join(path.slice(1)),prefix=rel?rel+"/":"",folderNames=new Map(); + const paths=IMAGE_CATALOG.folders.filter(f=>sameScope(f,branch)).map(f=>f.path) + .concat(IMAGE_CATALOG.images.filter(i=>sameScope(i,branch)).map(i=>i.folder)).filter(Boolean); + paths.forEach(full=>{if(rel&&full!==rel&&!full.startsWith(prefix))return;const rest=rel?full.slice(prefix.length):full;if(!rest)return;const seg=rest.split("/")[0],child=prefix+seg;folderNames.set(seg,IMAGE_CATALOG.images.filter(i=>sameScope(i,branch)&&(i.folder===child||i.folder.startsWith(child+"/"))).length)}); + const items=IMAGE_CATALOG.images.filter(i=>sameScope(i,branch)&&i.folder===rel).map(image=>({ + key:image.id,name:image.name,movable:image.canManage,deletable:image.canDelete, + tile(){const im=document.createElement("img");im.className="ic-explorer-thumb";im.src=image.src;im.alt="";im.loading="lazy";return im} + })); + return{folders:[...folderNames].map(([name,count])=>({name,count})),items}; + }, + async createFolder(path,name){ + const branch=branchFor(path);if(!branch||branch.scope!==IMAGE_CATALOG.uploadScope)return T("imgNoManage"); + try{await api("/images/folders",{method:"POST",body:{path:join([...path.slice(1),name])}});await loadImageCatalog();return null}catch(err){return err.message} + }, + async renameFolder(path,oldName,newName){return this.moveFolder([...path,oldName],path,newName)}, + async moveFolder(srcPath,targetPath,newName){ + const source=branchFor(srcPath),target=branchFor(targetPath); + if(!source||!target||source.scope!==target.scope)return T("exWrongBranch"); + const from=join(srcPath.slice(1)),folder=IMAGE_CATALOG.folders.find(f=>f.scope===source.scope&&f.path===from); + if(!folder||!folder.canManage)return T("imgNoManage"); + const to=join([...targetPath.slice(1),newName||srcPath.at(-1)]); + try{await api("/images/folders/"+folder.id,{method:"PATCH",body:{path:to}});await loadImageCatalog();return null}catch(err){return err.message} + }, + async deleteFolder(path,name){ + const branch=branchFor(path),full=join([...path.slice(1),name]); + const folder=branch&&IMAGE_CATALOG.folders.find(f=>f.scope===branch.scope&&f.path===full); + if(!folder||!folder.canManage)return T("imgNoManage"); + try{await api("/images/folders/"+folder.id,{method:"DELETE"});await loadImageCatalog();return null}catch(err){return err.message} + }, + async renameItem(id,name){ + try{await api("/images/"+id,{method:"PATCH",body:{name}});await loadImageCatalog();return null}catch(err){return err.message} + }, + async moveItem(id,targetPath){ + const image=IMAGE_CATALOG.images.find(i=>i.id===+id),branch=branchFor(targetPath); + if(!image||!branch||image.scope!==branch.scope||!image.canManage)return T("imgNoManage"); + try{await api("/images/"+id,{method:"PATCH",body:{folder:join(targetPath.slice(1))}});await loadImageCatalog();return null}catch(err){return err.message} + }, + async deleteItem(id){ + const image=IMAGE_CATALOG.images.find(i=>i.id===+id);if(!image?.canDelete)return T("imgNoManage"); + try{await api("/images/"+id,{method:"DELETE"});await loadImageCatalog();return null}catch(err){return err.message} + }, + openItem(id){ + const image=IMAGE_CATALOG.images.find(i=>i.id===+id);if(!image)return T("imgNoResults"); + if(imageCatalogSelect)imageCatalogSelect(image.src,image);closeImageCatalog();return null; + } + }; + imageCatalogWrap.style.visibility="hidden"; + openExplorer(adapter,()=>{imageCatalogWrap.style.visibility="";if(imageCatalogWrap.classList.contains("open"))renderImageCatalog()}); +} + +async function openImageCatalog(onSelect){ + if(!currentUser){alert(T("imgLoginRequired"));return} + const wrap=ensureImageCatalog();imageCatalogSelect=onSelect||null;setImageCatalogStatus(T("imgLoading")); + wrap.classList.add("open"); + try{await loadImageCatalog();renderImageCatalog();setImageCatalogStatus("")} + catch(err){setImageCatalogStatus(err.message)} +} + +/* Systeembeheer: limieten per gebruiker en school. Wordt door admin.js als + extra Instellingen-tab aangeroepen. */ +/* Opslag-tab: eerst een school kiezen (of "Systeem" voor school-loze accounts), + daarna één groepskaart met de schoolquota in de kop en de gebruikers van die + school als compacte subrijen. Bewerkvelden zijn verborgen tot "Bewerken". + Keuze en open-status leven op moduleniveau: de save-handler en admin-render() + herbouwen het paneel en mogen niets vergeten. */ +let imageQuotaSchool = ""; +const imageQuotaOpen = new Set(); +let imageQuotaFilter = ""; +function renderImageStoragePanel(panel){ + panel.innerHTML=""; + const status=document.createElement("div");status.className="am-msg";panel.append(status); + const host=document.createElement("div");host.className="img-quota-admin";host.textContent=T("imgLoading");panel.append(host); + const fmt=value=>value==null?T("imgUnlimited"):imageBytes(value); + /* het bewerkblok (limiet-keuze + opslaan) achter de Bewerken-knop per kaart/rij */ + const editorTools=(item,type)=>{ + const wrap=document.createElement("div");wrap.className="am-inline"; + const mode=document.createElement("select");mode.className="am-sel"; + const defaultLabel=type==="users"&&item.defaultBytes!=null + ? T("imgQuotaDefaultOf").replace("{limit}",imageBytes(item.defaultBytes)) + : T("imgQuotaDefault"); + [[defaultLabel,"default"],[T("imgQuotaCustom"),"custom"],[T("imgUnlimited"),"unlimited"]] + .forEach(([label,v])=>mode.add(new Option(label,v))); + mode.value=item.overrideBytes==null?"default":item.overrideBytes===-1?"unlimited":"custom"; + const amount=document.createElement("input");amount.className="am-inp";amount.type="number";amount.min="0";amount.max=String(10*1024);amount.step="10"; + amount.value=item.overrideBytes!=null&&item.overrideBytes>=0?Math.round(item.overrideBytes/(1024*1024)):""; + amount.placeholder=T("imgQuotaMb");amount.hidden=mode.value!=="custom";mode.onchange=()=>amount.hidden=mode.value!=="custom"; + const save=document.createElement("button");save.className="am-btn";save.type="button";save.textContent=T("save"); + save.onclick=async()=>{ + const limitBytes=mode.value==="default"?null:mode.value==="unlimited"?-1:Math.round(Number(amount.value)*1024*1024); + if(mode.value==="custom"&&(!Number.isFinite(limitBytes)||limitBytes<0)){status.textContent=T("imgQuotaInvalid");return} + try{await api("/images/admin/"+type+"/"+item.id+"/quota",{method:"PATCH",body:{limitBytes}});renderImageStoragePanel(panel)} + catch(err){status.textContent=err.message} + }; + wrap.append(mode,amount,save); + return wrap; + }; + const usage=item=>T("imgQuotaUsage").replace("{used}",imageBytes(item.usedBytes)).replace("{limit}",fmt(item.limitBytes)); + /* compacte gebruikersrij binnen de kaart: naam + verbruik, Bewerken klapt uit */ + const userSub=u=>{ + const row=document.createElement("div");row.className="am-row am-card-sub"; + const nm=document.createElement("span");nm.className="am-name";nm.textContent=u.displayName; + const use=document.createElement("span");use.className="am-role";use.textContent=usage(u); + row.append(nm,use); + const tools=document.createElement("div");tools.className="am-row-tools"; + tools.appendChild(editorTools(u,"users")); + const btn=document.createElement("button");btn.className="am-edit";btn.type="button";btn.textContent=T("amEdit"); + const key="u:"+u.id; + const sync=()=>{const open=imageQuotaOpen.has(key);tools.style.display=open?"":"none";btn.classList.toggle("on",open)}; + btn.addEventListener("click",()=>{imageQuotaOpen.has(key)?imageQuotaOpen.delete(key):imageQuotaOpen.add(key);sync()}); + sync(); + row.append(btn,tools); + return row; + }; + (async()=>{ + try{ + const data=await api("/images/admin/quotas");host.innerHTML=""; + /* stap 1: kies een school (of Systeem) - geen eindeloze platte lijsten */ + const pick=document.createElement("select");pick.className="am-sel img-quota-pick"; + pick.append(new Option(T("imgPickSchool"),"")); + data.schools.forEach(s=>pick.append(new Option(s.name,String(s.id)))); + pick.append(new Option(T("imgSystemScope"),"none")); + pick.value=imageQuotaSchool; + if(pick.value!==imageQuotaSchool) imageQuotaSchool=""; /* school verdwenen */ + pick.onchange=()=>{imageQuotaSchool=pick.value;imageQuotaFilter="";if(pick.value)imageQuotaOpen.add("school:"+pick.value);renderImageStoragePanel(panel)}; + host.append(pick); + if(!imageQuotaSchool){ + const hint=document.createElement("div");hint.className="guestnote";hint.textContent=T("amPickSchoolHint"); + host.append(hint); + return; + } + const system=imageQuotaSchool==="none"; + const school=system?null:data.schools.find(s=>String(s.id)===imageQuotaSchool); + let users=data.users.filter(u=>system?u.schoolId==null:Number(u.schoolId)===Number(imageQuotaSchool)); + /* zoeken binnen de gekozen school */ + const filter=document.createElement("input");filter.className="am-inp";filter.type="search"; + filter.placeholder=T("imgSearchUsers");filter.value=imageQuotaFilter; + filter.oninput=()=>{imageQuotaFilter=filter.value;draw()}; + host.append(filter); + const cardHost=document.createElement("div"); + host.append(cardHost); + const draw=()=>{ + cardHost.innerHTML=""; + const term=imageQuotaFilter.trim().toLowerCase(); + const shown=users.filter(u=>(u.displayName+" "+u.username).toLowerCase().includes(term)); + const card=amGroupCard({ + icon:"🏫", + title:system?T("imgSystemScope"):school.name, + summary:(school?usage(school)+" · ":"")+shown.length+" "+T("imgUsers").toLowerCase(), + key:"school:"+imageQuotaSchool, + openSet:imageQuotaOpen, editSet:imageQuotaOpen, + edit:school?{key:"s:"+school.id,tools:editorTools(school,"schools")}:null, + subs:shown.map(userSub), + }); + cardHost.append(card); + }; + draw(); + }catch(err){host.textContent="";status.textContent=err.message} + })(); +} + +const imageToolbarButton=document.getElementById("btnImages"); +if(imageToolbarButton)imageToolbarButton.addEventListener("click",()=>openImageCatalog()); +function refreshImageToolbar(){if(imageToolbarButton)imageToolbarButton.title=T("imgCatalog")} +document.addEventListener("langchange",()=>{refreshImageToolbar();if(imageCatalogWrap?.classList.contains("open"))renderImageCatalog()}); +refreshImageToolbar(); diff --git a/public/js/learning-progressions.js b/public/js/learning-progressions.js new file mode 100644 index 0000000..dbe1209 --- /dev/null +++ b/public/js/learning-progressions.js @@ -0,0 +1,237 @@ +/* teach - gedeelde leerjaarprofielen voor taal, rekenen en creatief + Praktische Teach-uitwerking van de nieuwe kerndoelen po (SLO, juli 2026) + en TULE. SLO ontwikkelt de nieuwe leerlijnen nog en kent ze geen wettelijke + status toe; deze profielen zijn daarom geen officiele SLO-jaarnormen. */ +"use strict"; + +const LEARNING_LEVEL_LABELS=Object.freeze({ + nl:{1:"Basis",2:"Op niveau",3:"Extra uitdaging"}, + en:{1:"Foundation",2:"At level",3:"Extra challenge"} +}); +const lp=(phase,title,goals)=>Object.freeze({phase,title,goals:Object.freeze(goals)}); +const LEARNING_YEAR_PROFILES=Object.freeze({ + taal:Object.freeze([ + null, + lp("1-2","Luisteren, spreken en taal ontdekken",["luisteren en vertellen","rijm en klanken herkennen","woordenschat rond de leefwereld"]), + lp("1-2","Klanken, letters en beginnende geletterdheid",["klanken aan letters koppelen","begin- en eindklank herkennen","eenvoudige woorden en zinnen begrijpen"]), + lp("3-4","Aanvankelijk lezen en spellen",["klankzuivere woorden lezen en spellen","woorden vlot herkennen","eenvoudige zinnen maken en begrijpen"]), + lp("3-4","Vlot lezen, spellingpatronen en zinnen",["clusters en tweetekenklanken lezen","veelvoorkomende spellingpatronen toepassen","zinnen uitbreiden en teksten begrijpen"]), + lp("5-6","Lezen met begrip en doelgericht schrijven",["leesstrategieen inzetten","meerlettergrepige woorden spellen","teksten en zinnen doelgericht opbouwen"]), + lp("5-6","Taalverzorging, woordenschat en tekstverband",["spellingregels beredeneren","onbekende woorden uit context begrijpen","zinsbouw en tekstverband verzorgen"]), + lp("7-8","Bronnen, argumentatie en verzorgde taal",["informatie vergelijken en beoordelen","meningen onderbouwen","werkwoordspelling en zinsbouw verzorgen"]), + lp("7-8","Kritisch en bewust taal gebruiken",["bronnen kritisch verwerken","teksten plannen, formuleren en reviseren","taalkeuzes en strategieen verantwoorden"]) + ]), + rekenen:Object.freeze([ + null, + lp("1-2","Tellen, vergelijken en ordenen",["hoeveelheden tot 20 herkennen","tellen, vergelijken en splitsen","vormen, patronen en dagelijkse tijd verkennen"]), + lp("1-2","Getalbegrip en informeel rekenen",["getallen tot 100 verkennen","optellen en aftrekken tot 20","meten, geld en tijd in situaties gebruiken"]), + lp("3-4","Basisbewerkingen tot 20 en getallen tot 100",["optellen en aftrekken tot 20 automatiseren","getalstructuur tot 100 gebruiken","tafels van 2, 5 en 10 opbouwen"]), + lp("3-4","Rekenen tot 100 en tafels automatiseren",["handig optellen en aftrekken tot 100","tafels en deeltafels automatiseren","tijd, geld en meten met passende eenheden"]), + lp("5-6","Getallen tot 1.000 en breuken",["bewerkingen tot 1.000 uitvoeren","tafels flexibel gebruiken","breuken als deel, verhouding en getal herkennen"]), + lp("5-6","Grote getallen, kommagetallen en procenten",["standaardprocedures gebruiken","kommagetallen en breuken verbinden","eenvoudige procenten en verhoudingen berekenen"]), + lp("7-8","Verhoudingen, procenten en meten",["breuken, kommagetallen en procenten verbinden","bewerkingen doelmatig kiezen","meetkundige problemen oplossen"]), + lp("7-8","Redeneren en rekenen in samenhang",["meer-stapsproblemen modelleren","schatten en uitkomsten beoordelen","rekenwijzen helder uitleggen"]) + ]), + creatief:Object.freeze([ + null, + lp("1-2","Grote vormen en vrij kleur ontdekken",["grote vlakken doelgericht kleuren","potloodgreep en druk verkennen","herkenbare thema's uit de leefwereld kiezen"]), + lp("1-2","Kleur en eenvoudige patronen",["binnen duidelijke contouren werken","kleuren combineren","eenvoudige herhaling herkennen"]), + lp("3-4","Vormen combineren en details zien",["middelgrote vlakken vullen","details met een potlood toevoegen","kleurkeuzes bij een thema uitleggen"]), + lp("3-4","Detail, herhaling en contrast",["kleinere vakken nauwkeurig kleuren","patronen voortzetten","licht-donkercontrast toepassen"]), + lp("5-6","Ruimte, natuur en verbeelding",["voor- en achtergrond onderscheiden","thematische kleuren onderzoeken","eigen details aan lijnwerk toevoegen"]), + lp("5-6","Kleurharmonie en complexe vormen",["kleurfamilies bewust combineren","complexere contouren beheersen","techniek en sfeer verbinden"]), + lp("7-8","Complexe patronen en betekenis",["veel detail planmatig uitwerken","kleurcontrast doelgericht inzetten","beeldkeuzes aan een thema koppelen"]), + lp("7-8","Zelfstandige stijl en verfijning",["een eigen kleurpalet ontwikkelen","gelaagd en nauwkeurig werken","artistieke keuzes toelichten"]) + ]) +}); +const LANGUAGE_WIDGET_TASKS=Object.freeze({ + letters:["","letters en beginklanken","letters en ontbrekende klanken","klankzuivere woorden opbouwen","clusters en tweetekenklanken","meerlettergrepige woorden","regelwoorden en langere woorden","complexe spelling en werkwoorden","spellingkennis zelfstandig toepassen"], + flits:["","letters herkennen en benoemen","korte klankzuivere woorden","woorden vlot herkennen","langere woorden automatiseren","complexere woorden vlot lezen","schooltaalwoorden automatiseren","laagfrequente schooltaalwoorden","complexe woorden nauwkeurig lezen"], + hak:["","beginklanken horen en koppelen","klanken hakken en plakken","klankgroepen samenvoegen","spellingpatronen herkennen","lettergrepen onderzoeken","complexe klankgroepen","woordopbouw en morfemen","woordstructuur verklaren"], + ball:["","letters bij beeldankers kiezen","korte woorden raden","woorden actief herkennen","langere woorden spellen","complexere spelling","woordenschat consolideren","complexe woordbeelden","gevorderde spelling"], + sentence:["","eenvoudige zinnen vertellen","wie-doet-wat-zinnen","enkelvoudige zinnen","zinnen uitbreiden","informatieve zinnen","zinsverband en leestekens","argumenterende zinnen","gevarieerde teksten formuleren"], + wordtypes:["","woorden benoemen","naamwoorden en doe-woorden","naamwoord en werkwoord","drie woordsoorten","woordsoorten in context","woordsoorten functioneel benoemen","woordsoort en zinsfunctie","taalstructuur analyseren"] +}); +const MATH_WIDGET_TASKS=Object.freeze({ + madd:["","optellen tot 10","optellen tot 20","optellen tot 20","optellen tot 100","optellen tot 1.000","optellen tot 10.000","grote getallen optellen","optellen en schatten"], + msub:["","aftrekken tot 10","aftrekken tot 20","aftrekken tot 20","aftrekken tot 100","aftrekken tot 1.000","aftrekken tot 10.000","grote getallen aftrekken","verschillen en schattingen"], + mmul:["","groepjes maken","herhaald optellen","tafels 2, 5 en 10","tafels tot en met 10","tafels flexibel gebruiken","grotere vermenigvuldigingen","vermenigvuldigen in context","samengestelde vermenigvuldigingen"], + mdiv:["","eerlijk verdelen","verdelen in groepjes","delen vanuit bekende tafels","deeltafels tot en met 10","delen met tafelfeiten","delen en rest interpreteren","delen in context","verhoudingsgewijs delen"], + mpct:["","deel van een geheel","helft en geheel","helft en kwart","breuk als deel","breuk en percentage","10%, 25%, 50%, 75%, 100%","veelvoorkomende percentages","percentages en verhoudingen"], + numberline:["","tellen tot 20","sprongen tot 50","getallen tot 100","sprongen tot 100","grotere sprongen","getallen positioneren","negatieve en grote getallen","schaal en bereik"], + fractions:["","helft en geheel","helft en kwart","eenvoudige breuken","breuken vergelijken","gelijkwaardige breuken","vereenvoudigen en vergelijken","breuken en verhoudingen","breuk, verhouding en percentage"], + clock:["","dagelijkse tijd","hele en halve uren","hele, halve en kwartieren","tijd per vijf minuten","tijd en tijdsduur","24-uurs tijd","24-uurs tijdsproblemen","tijdzones en tijdsduur"], + placevalue:["","hoeveelheid en cijfer","tientallen en eenheden","getallen tot 100","getallen tot 1.000","getallen tot 10.000","getallen tot 100.000","getallen tot 1.000.000","grote getalstructuur"], + money:["","munten herkennen","hele eurobedragen","eenvoudige bedragen","euros en centen","bedragen en teruggeven","precies rekenen met centen","korting en wisselgeld","budget en procentuele verandering"] +}); +const CREATIVE_WIDGET_TASKS=Object.freeze({ + coloring:["","grote kleurvlakken","duidelijke vormen en patronen","middelgrote vakken en details","meer detail en contrast","thematische composities","complexe contouren","fijne patronen en kleurplan","zelfstandige kleurstijl"] +}); + +function learningYearGroup(value){const year=Number(value);return Number.isInteger(year)&&year>=1&&year<=8?year:null} +function learningLevel(value){return Math.max(1,Math.min(3,Number(value)||1))} +function learningProfile(subject,yearGroup,level){ + const year=learningYearGroup(yearGroup),profile=year&&LEARNING_YEAR_PROFILES[subject]&&LEARNING_YEAR_PROFILES[subject][year]; + return profile?Object.assign({subject:subject,yearGroup:year,level:learningLevel(level)},profile):null +} +function learningLevelLabel(level){return (LEARNING_LEVEL_LABELS[LANG]||LEARNING_LEVEL_LABELS.nl)[learningLevel(level)]} +function learningProfileSummary(subject,yearGroup,level,widgetId){ + const p=learningProfile(subject,yearGroup,level);if(!p)return""; + const tasks=subject==="taal"?LANGUAGE_WIDGET_TASKS:subject==="rekenen"?MATH_WIDGET_TASKS:CREATIVE_WIDGET_TASKS; + const task=tasks[widgetId]&&tasks[widgetId][p.yearGroup]; + return (LANG==="nl"?"Groep ":"Year ")+p.yearGroup+" | "+learningLevelLabel(p.level)+" | "+(task||p.title) +} +function languageWordComplexity(word){ + const w=String(word||"").toLocaleLowerCase("nl-NL").replace(/[^a-z]/g,""); + const clusters=(w.match(/[bcdfghjklmnpqrstvwxz]{2,}/g)||[]).reduce((n,x)=>n+x.length-1,0); + const patterns=(w.match(/aai|ooi|oei|eeuw|ieuw|sch|ng|nk|ch|aa|ee|oo|uu|oe|ie|ei|ij|ou|au|ui|eu/g)||[]).length; + const syllables=(w.match(/[aeiouy]+/g)||[]).length; + return w.length+clusters*2+patterns+Math.max(0,syllables-1)*2 +} +function languageWordsForYear(words,yearGroup,level){ + if(!Array.isArray(words)||!words.length)return[]; + const year=learningYearGroup(yearGroup);if(!year)return words; + const introduced=words.filter(item=>!Array.isArray(item)||item[2]==null||Number(item[2])<=year); + const hasExplicitYears=words.some(item=>Array.isArray(item)&&item[2]!=null); + if(!introduced.length&&hasExplicitYears)return[]; + const available=introduced.length?introduced:words; + const caps=[0,5,7,9,12,15,18,22,99],cap=caps[year]+(learningLevel(level)-2)*2; + if(year===1){ + const anchors=available.filter(item=>Array.isArray(item)&&Number(item[2])===1); + const custom=available.filter(item=>!Array.isArray(item)||item[2]==null).filter(item=>languageWordComplexity(Array.isArray(item)?item[0]:item)<=cap); + return anchors.concat(custom) + } + const filtered=available.filter(item=>languageWordComplexity(Array.isArray(item)?item[0]:item)<=cap); + if(filtered.length>=Math.min(4,available.length))return filtered; + return [...available].sort((a,b)=>languageWordComplexity(a[0])-languageWordComplexity(b[0])).slice(0,Math.min(4,available.length)) +} +function languageWidgetWords(words,yearGroup,level,widgetId){ + const selected=languageWordsForYear(words,yearGroup,level),year=learningYearGroup(yearGroup); + if(year!==1||!["flits","hak","ball"].includes(widgetId))return selected; + const initials=new Map(); + selected.forEach(item=>{ + const word=String(Array.isArray(item)?item[0]:item||"").toLocaleLowerCase("nl-NL"); + const letter=(word.match(/[a-z]/)||[])[0]; + if(letter&&!initials.has(letter))initials.set(letter,[letter,Array.isArray(item)?item[1]:"",1,word]); + }); + return [...initials.values()] +} +function languageTaskRules(yearGroup,level){ + const year=learningYearGroup(yearGroup);if(!year)return null;const l=learningLevel(level); + return{literacyMode:year===1?"letters":year===2?"sounds":"words",letterLevel:year<=2?Math.min(2,l):year===3?Math.max(2,l):3,flashSeconds:year<=2?3:year<=4?2:year<=6?1:.5,lives:year<=3?7:year<=5?6:5,wordtypeCount:year<=3?2:year===4?3:4} +} +/* Voortgang bij taal: één gedeelde vorm voor het "moment" dat een taalwidget + aflevert. math.js logt sinds v0.4.35-beta per som mét een detail (welke som, + gegeven en juist antwoord); de taalwidgets leverden alleen een kale telling, + waardoor de leerkracht bij taal wel zag DAT er geoefend was maar nooit welk + woord moeite kostte. attempts telt de pogingen (1 + aantal fouten) zodat de + goed/fout-grafiek in de Voortgang-tab (wrong = attempts - correct) klopt. + detail.correct betekent hier "foutloos afgerond": letterblokken kent geen + faalmoment (je kunt blijven proberen), dus zonder dat criterium zou de + foutenlijst daar altijd leeg blijven. */ +function languageProgressMistakes(mistakes){ + return (Array.isArray(mistakes)?mistakes:[]) + .filter(m=>m&&(m.given!=null||m.expected!=null)) + .slice(0,6) + .map(m=>({expected:String(m.expected==null?"":m.expected).slice(0,40),given:String(m.given==null?"":m.given).slice(0,40)})) +} +function languageProgressPayload(options){ + const o=options||{},list=languageProgressMistakes(o.mistakes),first=list[0]||null; + const task=String(o.word==null?"":o.word).slice(0,80),solved=o.solved!==false; + const detail={ + question:task, + expected:o.expected!==undefined?String(o.expected==null?"":o.expected).slice(0,80):(first?first.expected:task), + /* bij een zelfcheck (flits/hak) typt de leerling niets in: er ís geen + antwoord om aan de leerkracht te tonen, alleen "lukte het zelf of niet" */ + answer:o.answer!==undefined?(o.answer==null?null:String(o.answer).slice(0,80)):(o.selfCheck?null:(first?first.given:task)), + correct:solved&&!list.length + }; + if(list.length)detail.mistakes=list; + if(o.selfCheck)detail.selfCheck=true; + return{ + attempts:((solved?1:0)+list.length)||1, + correct:solved?1:0, + stars:solved?Math.max(0,Number(o.stars)||0):0, + detail + } +} +function mathTaskRules(yearGroup,level){ + const year=learningYearGroup(yearGroup);if(!year)return null;const l=learningLevel(level),factor=l===1?.25:l===2?.6:1; + const addCap=[0,10,20,20,100,1000,10000,100000,1000000][year],numberMax=[0,20,50,100,100,1000,10000,100000,1000000][year]; + const lineStep=[0,1,1,1,2,20,200,2000,20000][year]; + return{addSub:Math.max(10,Math.round(addCap*factor/5)*5),timesTable:year<3?2:year===3?(l===1?2:l===2?5:10):year===4?(l===1?5:10):12,factorMax:year<=3?5:year<=4?10:year<=6?12:20,numberlineMax:Math.max(20,Math.round(numberMax*(l===1?.4:l===2?.7:1))),numberlineMin:year>=7&&l>=2?-100:0,numberlineStep:lineStep,fractionDen:year===1?2:year<=3?4:year===4?8:12,clockLevel:year<=2?1:year<=4?2:3,clockMinutes:year===1?[0]:year===2?[0,30]:year===3?[0,15,30,45]:null,placeLevel:year<=2?1:year<=4?2:3,moneyLevel:year<=2?1:year<=4?2:3,percentSets:year<=5?[50,100]:year===6?[10,25,50,75,100]:year===7?[5,10,20,25,50,75,100]:[5,10,15,20,25,30,40,50,60,70,75,80,90,100]} +} + +const LEARNING_SUBJECTS=["taal","rekenen","creatief"]; +let SELF_LEARNING_LEVELS={taal:null,rekenen:null,creatief:null}; +async function refreshSelfLearningLevels(){ + if(!currentUser||currentUser.role!=="pupil"||currentUser.schoolId!=null){SELF_LEARNING_LEVELS={taal:null,rekenen:null,creatief:null};return SELF_LEARNING_LEVELS} + const pairs=await Promise.all(LEARNING_SUBJECTS.map(async subject=>{try{return[subject,await api("/my/level?subject="+subject)]}catch(_){return[subject,null]}})); + SELF_LEARNING_LEVELS=Object.fromEntries(pairs);return SELF_LEARNING_LEVELS +} +function applyLearningLevelToState(def,state,level,managed){ + const next=state?Object.assign({},state):{};if(!def||!LEARNING_SUBJECTS.includes(def.cat)||!level||level.yearGroup==null)return next; + next.yearGroup=learningYearGroup(level.yearGroup);next.level=learningLevel(level.level);next.managedLearningLevel=managed!==false;return next +} +function learningStateForWidget(def,state){ + if(!currentUser||currentUser.role!=="pupil"||currentUser.schoolId!=null)return state; + return applyLearningLevelToState(def,state,SELF_LEARNING_LEVELS[def.cat],true) +} +/* Op het eigen auteursbord kiest de leerkracht/systeemmanager een leerjaar en + differentiatie per widget. Een systeemmanager blijft eigenaar van dit bord + in de systeem-, schoolbeheerder- en leerkrachtweergave. Ouder- en + leerlingvoorbeelden gebruiken een aparte, alleen-lezen voorbeeldweergave. */ +function canConfigureBoardLearningLevel(def){ + if(!def||!LEARNING_SUBJECTS.includes(def.cat)||!currentUser)return false; + const role=typeof activeRole==="function"?activeRole():currentUser.role; + return currentUser.role==="teacher"|| + (currentUser.role==="super"&&["super","admin","teacher"].includes(role)) +} +function boardLearningSelection(def,state){ + if(!canConfigureBoardLearningLevel(def))return null; + const savedLevel=state&&state.level!=null?Number(state.level):null; + return{yearGroup:learningYearGroup(state&&state.yearGroup)||4,level:Number.isInteger(savedLevel)?learningLevel(savedLevel):2} +} +function boardLearningMountState(def,state,selection){ + return selection?applyLearningLevelToState(def,state,selection,true):state +} +function appendBoardLearningLevelControls(root,def,selection,onChange){ + if(!root||!selection||typeof onChange!=="function")return null; + const bar=document.createElement("div");bar.className="board-learning-level"; + const yearLabel=document.createElement("label"),yearText=document.createElement("span"),year=document.createElement("select"); + const levelLabel=document.createElement("label"),levelText=document.createElement("span"),level=document.createElement("select"); + const summary=document.createElement("span");summary.className="board-learning-summary"; + year.className=level.className="board-learning-select"; + for(let n=1;n<=8;n++)year.add(new Option((LANG==="nl"?"Groep ":"Year ")+n,n)); + for(let n=1;n<=3;n++)level.add(new Option(learningLevelLabel(n),n)); + year.value=selection.yearGroup;level.value=selection.level; + const labels=()=>{yearText.textContent=LANG==="nl"?"Leerjaar":"Year";levelText.textContent=LANG==="nl"?"Niveau":"Level";summary.textContent=learningProfileSummary(def.cat,+year.value,+level.value,def.id)}; + const changed=()=>{labels();onChange({yearGroup:+year.value,level:+level.value})}; + year.addEventListener("change",changed);level.addEventListener("change",changed); + yearLabel.append(yearText,year);levelLabel.append(levelText,level);bar.append(yearLabel,levelLabel,summary);labels();root.prepend(bar);return bar +} +function appendLearningProfileBadge(root,subject,state,widgetId){ + const summary=learningProfileSummary(subject,state&&state.yearGroup,state&&state.level,widgetId);if(!summary)return; + const old=root.querySelector(":scope > .learning-profile-badge");if(old)old.remove(); + const badge=document.createElement("div");badge.className="learning-profile-badge";badge.textContent=summary; + const p=learningProfile(subject,state.yearGroup,state.level);badge.title=p?p.goals.join(" | "):"";root.prepend(badge) +} + +(function registerOwnLearningLevelSettings(){ + const host=document.createElement("div");host.className="st-section";host.dataset.section="learning-level";host.style.display="none";host.id="learningLevelSettings";document.getElementById("stContent").appendChild(host); + const text=()=>LANG==="nl"?{title:"Leerniveau",sub:"Kies het passende leerjaar voor taal, rekenen en creatief.",note:"Deze keuze bepaalt woorden, getalgrenzen, kleurplaatdetail en opdrachten. De profielen zijn een praktische Teach-uitwerking; geen officiele jaarnormen.",save:"Opslaan",saved:"Leerniveau opgeslagen.",year:"Groep",taal:"Taal",rekenen:"Rekenen",creatief:"Creatief"}:{title:"Learning level",sub:"Choose the suitable year for language, maths and creative work.",note:"This choice determines words, number ranges, colouring-page detail and tasks. The profiles are a practical Teach interpretation; not official year standards.",save:"Save",saved:"Learning level saved.",year:"Year",taal:"Language",rekenen:"Maths",creatief:"Creative"}; + const render=async()=>{ + host.innerHTML="";const tx=text(),msg=document.createElement("div");msg.className="formMsg";host.appendChild(msg);await refreshSelfLearningLevels(); + for(const subject of LEARNING_SUBJECTS){ + const current=SELF_LEARNING_LEVELS[subject]||{yearGroup:4,level:2},card=document.createElement("section");card.className="learning-level-card"; + const h=document.createElement("h3");h.textContent=tx[subject];const summary=document.createElement("p");summary.className="guestnote";const controls=document.createElement("div");controls.className="am-inline"; + const year=document.createElement("select"),level=document.createElement("select");year.className=level.className="am-sel"; + for(let n=1;n<=8;n++)year.add(new Option(tx.year+" "+n,n));for(let n=1;n<=3;n++)level.add(new Option(learningLevelLabel(n),n));year.value=current.yearGroup||4;level.value=current.level||2; + const update=()=>{const p=learningProfile(subject,+year.value,+level.value);summary.textContent=p?p.title+" - "+p.goals.join(" | "):""};year.onchange=level.onchange=update;update(); + const save=document.createElement("button");save.type="button";save.className="am-btn";save.textContent=tx.save;save.onclick=async()=>{try{await api("/my/level",{method:"PUT",body:{subject:subject,yearGroup:+year.value,level:+level.value}});await refreshSelfLearningLevels();msg.textContent=tx.saved;msg.classList.add("ok");loadCurrentSlot()}catch(e){msg.textContent=e.message;msg.classList.remove("ok")}}; + controls.append(year,level,save);card.append(h,summary,controls);host.appendChild(card) + } + const note=document.createElement("p");note.className="guestnote learning-source-note";note.textContent=tx.note;host.appendChild(note) + }; + registerSettingsSection({id:"learning-level",icon:"🎯",group:"personal",order:1.5,label:()=>text().title,sub:()=>text().sub,visible:()=>!!currentUser&¤tUser.role==="pupil"&¤tUser.schoolId==null,activate(){showSettingsDom("learning-level");return render()},keywords:()=>[text().title,text().year,"taal","rekenen","creatief","groep","niveau"]}) +})(); diff --git a/public/js/live-classroom-dashboard.js b/public/js/live-classroom-dashboard.js new file mode 100644 index 0000000..01ada84 --- /dev/null +++ b/public/js/live-classroom-dashboard.js @@ -0,0 +1,371 @@ +/* Grafisch live-klassenoverzicht: klasraster, hulpsignalen, funnel, + zevendaagse voortgang, sessietijdlijn en directe leerkrachtacties. */ +"use strict"; + +function liveSessionTime(value){ + const seconds=Math.max(0,Math.round((Date.now()-new Date(value).getTime())/1000)); + if(seconds<60)return T("amLiveJustNow"); + const minutes=Math.floor(seconds/60); + if(minutes<60)return T("amLiveMinutes").replace("{count}",minutes); + const hours=Math.floor(minutes/60); + return T("amLiveHours").replace("{count}",hours); +} + +function liveDuration(start,end){ + const seconds=Math.max(1,Math.round((new Date(end).getTime()-new Date(start).getTime())/1000)); + if(seconds<60)return seconds+"s"; + const minutes=Math.round(seconds/60); + return minutes<60?minutes+"m":Math.round(minutes/60)+"u"; +} + +function updateLiveConnectionState(host,state){ + const badge=host.querySelector(".live-connection"); + if(!badge)return; + badge.className="live-connection "+state; + badge.textContent=T(state==="connected"?"amLiveConnected":"amLiveReconnecting"); +} + +function liveSvg(tag,attrs={}){ + const element=document.createElementNS("http://www.w3.org/2000/svg",tag); + Object.entries(attrs).forEach(([key,value])=>element.setAttribute(key,String(value))); + return element; +} + +function liveSparkline(points){ + const svg=liveSvg("svg",{class:"live-sparkline",viewBox:"0 0 100 28", + role:"img","aria-label":T("amTrendAccuracy")}); + const usable=(points||[]).map((point,index)=>({index,value:point.accuracy})) + .filter(point=>Number.isFinite(point.value)); + if(!usable.length){ + svg.appendChild(liveSvg("line",{x1:2,y1:25,x2:98,y2:25,class:"empty"})); + return svg; + } + const d=usable.map((point,index)=>{ + const x=2+(point.index/Math.max(1,(points.length-1)))*96; + const y=26-(point.value/100)*23; + return(index?"L":"M")+x.toFixed(1)+" "+y.toFixed(1); + }).join(" "); + svg.appendChild(liveSvg("path",{d,class:"line"})); + return svg; +} + +function renderLiveFunnel(host,dashboard,live){ + const card=document.createElement("section"); + card.className="live-graphic-card live-funnel"; + const title=document.createElement("strong"); + title.textContent=T("amFunnelTitle"); + card.appendChild(title); + const base=Math.max(1,dashboard.funnel?.total||dashboard.pupils?.length||0); + const active=live.liveCount||0; + const started=Math.max(dashboard.funnel?.started||0,active); + const steps=[ + ["amFunnelAssigned",dashboard.funnel?.assigned||0], + ["amFunnelStarted",started], + ["amFunnelActive",active], + ["amFunnelCompleted",dashboard.funnel?.completed||0], + ]; + steps.forEach(([key,value],index)=>{ + const row=document.createElement("div"); + row.className="live-funnel-row"; + const label=document.createElement("span"); + label.textContent=T(key); + const track=document.createElement("span"); + track.className="live-funnel-track"; + const fill=document.createElement("i"); + fill.style.width=Math.max(value?6:0,Math.round((value/base)*100))+"%"; + fill.style.setProperty("--funnel-step",String(index)); + track.appendChild(fill); + const count=document.createElement("strong"); + count.textContent=String(value); + row.append(label,track,count); + card.appendChild(row); + }); + host.appendChild(card); +} + +function renderLiveTrend(host,live){ + const card=document.createElement("section"); + card.className="live-graphic-card live-trend"; + const title=document.createElement("strong"); + title.textContent=T("amTrendTitle"); + card.appendChild(title); + const points=live.classTrend||[]; + const svg=liveSvg("svg",{viewBox:"0 0 600 190",role:"img", + "aria-label":T("amTrendDescription")}); + [0,25,50,75,100].forEach(value=>{ + const y=155-(value/100)*125; + svg.appendChild(liveSvg("line",{x1:42,y1:y,x2:580,y2:y,class:"grid"})); + const label=liveSvg("text",{x:35,y:y+4,class:"axis","text-anchor":"end"}); + label.textContent=value+"%";svg.appendChild(label); + }); + const maxCompleted=Math.max(1,...points.map(point=>Number(point.completed||0))); + points.forEach((point,index)=>{ + const x=52+index*(520/Math.max(1,points.length-1)); + const height=(Number(point.completed||0)/maxCompleted)*48; + svg.appendChild(liveSvg("rect",{x:x-10,y:155-height,width:20,height, + rx:4,class:"completion"})); + const day=liveSvg("text",{x,y:177,class:"axis","text-anchor":"middle"}); + day.textContent=new Date(point.date+"T12:00:00").toLocaleDateString( + LANG==="nl"?"nl-NL":"en-GB",{weekday:"short"}); + svg.appendChild(day); + }); + const usable=points.map((point,index)=>({index,value:point.accuracy})) + .filter(point=>Number.isFinite(point.value)); + if(usable.length){ + const d=usable.map((point,index)=>{ + const x=52+point.index*(520/Math.max(1,points.length-1)); + const y=155-(point.value/100)*125; + return(index?"L":"M")+x.toFixed(1)+" "+y.toFixed(1); + }).join(" "); + svg.appendChild(liveSvg("path",{d,class:"accuracy"})); + usable.forEach(point=>{ + const x=52+point.index*(520/Math.max(1,points.length-1)); + const y=155-(point.value/100)*125; + svg.appendChild(liveSvg("circle",{cx:x,cy:y,r:4,class:"accuracy-dot"})); + }); + } + card.appendChild(svg); + const legend=document.createElement("div"); + legend.className="live-chart-legend"; + const accuracy=document.createElement("span");accuracy.className="accuracy"; + accuracy.textContent=T("amTrendAccuracy"); + const completed=document.createElement("span");completed.className="completed"; + completed.textContent=T("amTrendCompleted"); + legend.append(accuracy,completed);card.appendChild(legend); + host.appendChild(card); +} + +function renderLiveTimeline(host,sessions,serverTime,onPupil){ + const card=document.createElement("section"); + card.className="live-graphic-card live-timeline"; + const title=document.createElement("strong"); + title.textContent=T("amTimelineTitle"); + card.appendChild(title); + const items=(sessions||[]).slice(0,12); + if(!items.length){ + const empty=document.createElement("p");empty.className="guestnote"; + empty.textContent=T("amLiveRecentNone");card.appendChild(empty); + host.appendChild(card);return; + } + const durations=items.map(session=>Math.max(1000, + new Date(session.endedAt||session.lastSeenAt||serverTime).getTime()-new Date(session.startedAt).getTime())); + const max=Math.max(...durations); + items.forEach((session,index)=>{ + const row=document.createElement("button"); + row.type="button";row.className="live-timeline-row "+session.status; + const name=document.createElement("strong");name.textContent=session.pupilName; + const meta=document.createElement("span"); + meta.textContent=(T("wg_"+session.widgetType)||session.widgetType)+" · "+ + liveSessionTime(session.startedAt); + const track=document.createElement("span");track.className="live-timeline-track"; + const fill=document.createElement("i"); + fill.style.width=Math.max(8,Math.round((durations[index]/max)*100))+"%"; + track.appendChild(fill); + const duration=document.createElement("span");duration.className="live-timeline-duration"; + duration.textContent=session.paused?T("amStatePaused"): + liveDuration(session.startedAt,session.endedAt||session.lastSeenAt||serverTime); + row.append(name,meta,track,duration); + row.addEventListener("click",()=>onPupil(session.pupilId)); + card.appendChild(row); + }); + host.appendChild(card); +} + +function renderLiveClassOverview(host,dashboard,live,onPupil,onAction){ + const section=document.createElement("section"); + section.className="live-class-overview"; + const head=document.createElement("div"); + head.className="live-session-head"; + const title=document.createElement("strong");title.textContent=T("amLiveSessions"); + const connection=document.createElement("span"); + connection.className="live-connection connecting"; + connection.textContent=T("amLiveReconnecting"); + head.append(title,connection);section.appendChild(head); + + const activeByPupil=new Map(); + const recentByPupil=new Map(); + (live.sessions||[]).forEach(session=>{ + const map=session.status==="live"?activeByPupil:recentByPupil; + if(!map.has(session.pupilId))map.set(session.pupilId,session); + }); + const pupils=(dashboard.pupils||[]).map(pupil=>{ + const session=activeByPupil.get(pupil.pupilId)||null; + const recent=recentByPupil.get(pupil.pupilId)||null; + const signal=session?.helpSignal||(pupil.status==="support"?"errors":null); + const justStarted=!!session&&new Date(live.serverTime||Date.now()).getTime() + -new Date(session.startedAt).getTime()<60000; + const state=session?.paused?"paused":signal?"support":justStarted?"new": + session?"active":pupil.status; + return{...pupil,session,recent,signal,state, + trend:live.pupilTrends?.[String(pupil.pupilId)]||[]}; + }); + + const metrics=document.createElement("div"); + metrics.className="live-class-metrics"; + [ + ["active",T("amMetricWorking"),pupils.filter(p=>p.state==="active").length], + ["new",T("amMetricNew"),pupils.filter(p=>p.state==="new").length], + ["support",T("amMetricHelp"),pupils.filter(p=>p.signal).length], + ["complete",T("amMetricComplete"),pupils.filter(p=>p.status==="complete").length], + ].forEach(([state,label,value])=>{ + const metric=document.createElement("div");metric.className="live-class-metric "+state; + const count=document.createElement("strong");count.textContent=String(value); + const text=document.createElement("span");text.textContent=label; + metric.append(count,text);metrics.appendChild(metric); + }); + section.appendChild(metrics); + + const help=pupils.filter(pupil=>pupil.signal); + if(help.length){ + const signals=document.createElement("section");signals.className="live-help-signals"; + const signalTitle=document.createElement("strong");signalTitle.textContent=T("amHelpSignals"); + signals.appendChild(signalTitle); + const list=document.createElement("div"); + help.forEach(pupil=>{ + const button=document.createElement("button");button.type="button"; + button.className="live-help-chip "+pupil.signal; + button.textContent="⚑ "+pupil.pupilName+" · "+T( + pupil.signal==="idle"?"amHelpIdle":"amHelpErrors"); + button.addEventListener("click",()=>onPupil(pupil.pupilId)); + list.appendChild(button); + }); + signals.appendChild(list);section.appendChild(signals); + } + + const controls=document.createElement("div");controls.className="live-grid-controls"; + const gridTitle=document.createElement("strong");gridTitle.textContent=T("amClassGrid"); + const sort=document.createElement("select");sort.className="am-sel"; + [["priority","amSortPriority"],["active","amSortActive"],["name","amSortName"]] + .forEach(([value,key])=>sort.appendChild(new Option(T(key),value))); + controls.append(gridTitle,sort);section.appendChild(controls); + const grid=document.createElement("div");grid.className="live-class-grid"; + section.appendChild(grid); + const actionMessage=document.createElement("div"); + actionMessage.className="live-dashboard-message";actionMessage.setAttribute("role","status"); + section.appendChild(actionMessage); + + const stateRank={support:0,paused:1,new:2,active:3,notStarted:4,complete:5}; + const drawGrid=()=>{ + grid.innerHTML=""; + if(!pupils.length){ + const empty=document.createElement("p");empty.className="guestnote"; + empty.textContent=T("castNoPupils");grid.appendChild(empty);return; + } + const ordered=[...pupils].sort((a,b)=>{ + if(sort.value==="name")return a.pupilName.localeCompare(b.pupilName); + if(sort.value==="active")return Number(!!b.session)-Number(!!a.session) + ||a.pupilName.localeCompare(b.pupilName); + return(stateRank[a.state]??9)-(stateRank[b.state]??9) + ||a.pupilName.localeCompare(b.pupilName); + }); + ordered.forEach(pupil=>{ + const tile=document.createElement("article"); + tile.className="live-pupil-tile "+pupil.state; + const top=document.createElement("button");top.type="button"; + top.className="live-pupil-main"; + const badge=document.createElement("span");badge.className="live-pupil-state"; + const stateKey={ + support:pupil.signal==="idle"?"amHelpIdle":"amHelpErrors", + paused:"amStatePaused",new:"amLiveNew",active:"amLiveWorking", + complete:"amDashboardStatus_complete",notStarted:"amDashboardStatus_notStarted", + }[pupil.state]||"amDashboardStatus_active"; + badge.textContent=T(stateKey); + const name=document.createElement("strong");name.textContent=pupil.pupilName; + const task=document.createElement("span");task.className="live-pupil-task"; + const session=pupil.session||pupil.recent; + task.textContent=session?(T("wg_"+session.widgetType)||session.widgetType): + pupil.sequence?.title||T("amDashboardNoActivity"); + const progress=document.createElement("span");progress.className="live-pupil-progress"; + const fill=document.createElement("i"); + const percent=pupil.sequence?Math.round((pupil.sequence.completed/pupil.sequence.total)*100): + pupil.attempts?pupil.accuracy:0; + fill.style.width=Math.max(0,Math.min(100,percent))+"%"; + progress.appendChild(fill); + const score=document.createElement("small"); + score.textContent=pupil.sequence? + T("amDashboardSteps").replace("{done}",pupil.sequence.completed).replace("{total}",pupil.sequence.total): + pupil.attempts?T("amLiveScore").replace("{correct}",pupil.correct) + .replace("{attempts}",pupil.attempts):T("amDashboardNoActivity"); + top.append(badge,name,task,progress,score,liveSparkline(pupil.trend)); + top.addEventListener("click",()=>onPupil(pupil.pupilId)); + tile.appendChild(top); + + const toggle=document.createElement("button");toggle.type="button"; + toggle.className="live-action-toggle";toggle.textContent="⚡ "+T("amActions"); + const actions=document.createElement("div");actions.className="live-action-row"; + actions.hidden=true; + toggle.addEventListener("click",()=>{actions.hidden=!actions.hidden;}); + let sending=false; + const trigger=async(kind,payload={})=>{ + if(sending)return; + sending=true;tile.classList.add("sending"); + actionMessage.textContent=T("amActionSending"); + try{ + await onAction(pupil.pupilId,kind,payload); + actionMessage.textContent=T("amActionSent"); + }catch(error){actionMessage.textContent=error.message;} + finally{sending=false;tile.classList.remove("sending");} + }; + const actionButton=(label,kind,handler,disabled=false)=>{ + const button=document.createElement("button");button.type="button"; + button.textContent=label;button.disabled=disabled; + button.addEventListener("click",handler||(()=>trigger(kind))); + actions.appendChild(button); + }; + actionButton("💡 "+T("amActionHint"),"hint",()=>{ + const message=window.prompt(T("amActionHintPrompt"),T("amActionHintDefault")); + if(message&&message.trim())trigger("hint",{message:message.trim()}); + }); + actionButton("👏 "+T("amActionEncourage"),"encourage",()=>{ + const message=window.prompt(T("amActionEncouragePrompt"),T("amActionEncourageDefault")); + if(message&&message.trim())trigger("encourage",{message:message.trim()}); + }); + const subject=typeof REGISTRY!=="undefined" + ?REGISTRY.find(def=>def.id===pupil.session?.widgetType)?.cat:null; + actionButton("↘ "+T("amActionEasier"),"easier", + ()=>{if(window.confirm(T("amActionEasierConfirm")))trigger("easier",{subject});}, + !pupil.session||!["taal","rekenen","world","creatief"].includes(subject)); + const pauseKind=pupil.session?.paused?"resume":"pause"; + actionButton((pauseKind==="pause"?"⏸ ":"▶ ")+ + T(pauseKind==="pause"?"amActionPause":"amActionResume"),pauseKind, + ()=>trigger(pauseKind),!pupil.session); + tile.append(toggle,actions);grid.appendChild(tile); + }); + }; + sort.addEventListener("change",drawGrid);drawGrid(); + + const graphics=document.createElement("div");graphics.className="live-graphics-grid"; + renderLiveFunnel(graphics,dashboard,live); + renderLiveTrend(graphics,live); + section.appendChild(graphics); + renderLiveTimeline(section,live.sessions,live.serverTime,onPupil); + host.appendChild(section); +} + +function connectLiveClassDashboard(classId,onChange,isActive,onState){ + let source=null; + let stopped=false; + let reloadTimer=null; + const queueReload=()=>{ + clearTimeout(reloadTimer); + reloadTimer=setTimeout(()=>{if(!stopped&&isActive())onChange();},180); + }; + if(typeof EventSource!=="undefined"){ + source=new EventSource("/api/progress/live/class/"+encodeURIComponent(classId)+"/stream"); + source.onopen=()=>onState("connected"); + source.onerror=()=>onState("reconnecting"); + source.addEventListener("sessions",queueReload); + } + const fallback=setInterval(()=>{ + if(!isActive()){stop();return;} + onChange(); + },30000); + function stop(){ + if(stopped)return; + stopped=true; + clearInterval(fallback); + clearTimeout(reloadTimer); + if(source)source.close(); + } + return stop; +} diff --git a/public/js/live-classroom-i18n.js b/public/js/live-classroom-i18n.js new file mode 100644 index 0000000..ff77b42 --- /dev/null +++ b/public/js/live-classroom-i18n.js @@ -0,0 +1,89 @@ +/* Teksten voor het bidirectionele live-klaslokaal, apart gehouden zodat het + omvangrijke centrale woordenboek overzichtelijk blijft. */ +"use strict"; + +Object.assign(I18N.nl,{ + amFunnelTitle:"Opdrachtfunnel", + amFunnelAssigned:"Ontvangen", + amFunnelStarted:"Gestart", + amFunnelActive:"Nu actief", + amFunnelCompleted:"Afgerond", + amTrendTitle:"Voortgang laatste 7 dagen", + amTrendDescription:"Nauwkeurigheid en afgeronde stappen per dag", + amTrendAccuracy:"Nauwkeurigheid", + amTrendCompleted:"Afgeronde opdrachten", + amTimelineTitle:"Sessietijdlijn", + amStatePaused:"Gepauzeerd", + amMetricWorking:"Nu bezig", + amMetricNew:"Net gestart", + amMetricHelp:"Hulp nodig", + amMetricComplete:"Klaar", + amHelpSignals:"Hulpsignalen", + amHelpIdle:"Lang geen voortgang", + amHelpErrors:"Veel fouten", + amClassGrid:"Live klassenraster", + amSortPriority:"Hulp eerst", + amSortActive:"Actief eerst", + amSortName:"Op naam", + amActions:"Directe acties", + amActionSending:"Actie wordt verstuurd…", + amActionSent:"Actie is direct naar de leerling gestuurd.", + amActionHint:"Hint", + amActionHintPrompt:"Welke korte hint wil je sturen?", + amActionHintDefault:"Kijk nog eens rustig naar de opdracht.", + amActionEncourage:"Aanmoedigen", + amActionEncouragePrompt:"Welke positieve boodschap wil je sturen?", + amActionEncourageDefault:"Goed bezig, blijf proberen!", + amActionEasier:"Eenvoudiger", + amActionEasierConfirm:"Weet je zeker dat je voor deze leerling een eenvoudiger individueel niveau wilt instellen?", + amActionPause:"Pauzeren", + amActionResume:"Hervatten", + pupilPausedTitle:"Even pauze", + pupilPausedText:"Je leerkracht heeft deze opdracht tijdelijk gepauzeerd.", + pupilEasierReady:"Een eenvoudiger niveau staat klaar: groep {group}, niveau {level}.", + pupilResumeMessage:"Je kunt weer verder met de opdracht.", + pupilTeacherMessage:"Je leerkracht heeft een bericht gestuurd.", +}); + +Object.assign(I18N.en,{ + amFunnelTitle:"Assignment funnel", + amFunnelAssigned:"Assigned", + amFunnelStarted:"Started", + amFunnelActive:"Active now", + amFunnelCompleted:"Completed", + amTrendTitle:"Progress over the last 7 days", + amTrendDescription:"Accuracy and completed steps per day", + amTrendAccuracy:"Accuracy", + amTrendCompleted:"Completed assignments", + amTimelineTitle:"Session timeline", + amStatePaused:"Paused", + amMetricWorking:"Working now", + amMetricNew:"Just started", + amMetricHelp:"Needs help", + amMetricComplete:"Complete", + amHelpSignals:"Help signals", + amHelpIdle:"No progress for a while", + amHelpErrors:"Many errors", + amClassGrid:"Live class grid", + amSortPriority:"Help first", + amSortActive:"Active first", + amSortName:"By name", + amActions:"Direct actions", + amActionSending:"Sending action…", + amActionSent:"The action was sent directly to the pupil.", + amActionHint:"Hint", + amActionHintPrompt:"Which short hint would you like to send?", + amActionHintDefault:"Take another calm look at the assignment.", + amActionEncourage:"Encourage", + amActionEncouragePrompt:"Which positive message would you like to send?", + amActionEncourageDefault:"You are doing well, keep trying!", + amActionEasier:"Easier", + amActionEasierConfirm:"Are you sure you want to set an easier individual level for this pupil?", + amActionPause:"Pause", + amActionResume:"Resume", + pupilPausedTitle:"Time for a short pause", + pupilPausedText:"Your teacher has temporarily paused this assignment.", + pupilEasierReady:"An easier level is ready: year {group}, level {level}.", + pupilResumeMessage:"You can continue with the assignment.", + pupilTeacherMessage:"Your teacher sent you a message.", +}); diff --git a/public/js/parent.js b/public/js/parent.js new file mode 100644 index 0000000..02e3c35 --- /dev/null +++ b/public/js/parent.js @@ -0,0 +1,280 @@ +/* teach - ouderportaal: ouders (role parent) zien geen whiteboard maar een + portaal met hun gekoppelde kinderen. Per kind: school/klas (of "thuis"), + voortgangstijdlijn, en voor thuiskinderen een aanmelding bij een school + (doorzoekbare lijst; de school handelt het verzoek af in het beheer). + Kinderen komen erbij door ze zelf aan te maken (thuiskind, wachtwoord + eenmalig zichtbaar) of via een eenmalige ouder-code van de school. */ +"use strict"; +const parentView = document.getElementById("parentView"); +/* opengeklapte voortgang/aanmeldformulieren en eenmalige resultaten + (kind-wachtwoord) blijven behouden over her-renders van het portaal */ +const parentOpen = new Set(); +const parentResults = new Map(); +let PARENT_SCHOOLS = []; + +function prH(tag, cls, text){ + const el = document.createElement(tag); + if(cls) el.className = cls; + if(text != null) el.textContent = text; + return el; +} +/* arm-then-confirm zoals dangerBtn in het beheer: eerste klik wapent ("zeker?"), + tweede klik voert uit, na 4s ontwapent hij vanzelf */ +function prDangerBtn(label, warnKey, fn){ + const b = prH("button","am-btn am-danger", label); + b.type = "button"; + if(warnKey) b.title = T(warnKey); + let timer = null; + b.addEventListener("click", async ()=>{ + if(!timer){ + b.classList.add("armed"); + b.textContent = "⚠ " + T("amSure"); + timer = setTimeout(()=>{ timer = null; b.classList.remove("armed"); b.textContent = label; }, 4000); + return; + } + clearTimeout(timer); timer = null; + b.classList.remove("armed"); b.textContent = label; + await fn(b); + }); + return b; +} +function prMsg(t){ + const m = parentView.querySelector(".am-msg"); + if(m) m.textContent = t || ""; +} + +async function parentReload(){ + let children = []; + try{ children = (await api("/parent/children")).children || []; } + catch(e){ /* render toont dan een lege lijst; melding via prMsg hieronder */ } + renderParentView(children); +} + +function parentChildRow(c){ + const row = prH("div","am-row"); + row.appendChild(prH("span","am-name","🧒 " + c.displayName + (c.displayName!==c.username ? ` (${c.username})` : ""))); + const where = c.schoolName + ? c.schoolName + (c.className ? ` · ${c.className}` : "") + : T("parentHome") + (c.appliedTo ? ` · ${T("parentApplied")} ${c.appliedTo}` : ""); + row.appendChild(prH("span","am-role", where)); + + const tools = prH("div","am-row-tools"); + const r = parentResults.get(c.id); + if(r){ + const res = prH("div","am-result"); + res.appendChild(prH("span","am-result-lbl", r.label + ": ")); + const code = prH("code", null, r.value); + res.appendChild(code); + res.appendChild(prH("div","guestnote", T("parentPwNote"))); + tools.appendChild(res); + } + + /* voortgangstijdlijn (zelfde regelopbouw als het beheer: widget, score, tijd) */ + const progHolder = prH("div","am-list"); + async function loadProgress(){ + progHolder.innerHTML = ""; + let events = []; + try{ events = (await api("/progress/pupil/"+c.id)).events || []; } + catch(e){ progHolder.appendChild(prH("div","guestnote", e.message)); return; } + if(!events.length){ progHolder.appendChild(prH("div","guestnote", T("parentProgressEmpty"))); return; } + events.forEach(ev=>{ + const line = prH("div","am-row"); + line.appendChild(prH("span","am-name", T("wg_"+ev.widgetType) || ev.widgetType)); + const scoreParts = []; + if(ev.attempts) scoreParts.push(`${ev.correct}/${ev.attempts} ${T("mGoed")}`); + if(ev.stars) scoreParts.push("★".repeat(ev.stars)); + line.appendChild(prH("span","am-code", scoreParts.join(" · "))); + line.appendChild(prH("span","am-code", new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB"))); + progHolder.appendChild(line); + }); + } + const progBtn = prH("button","am-btn", T("parentProgress")); + progBtn.type = "button"; + progBtn.addEventListener("click", ()=>{ + const key = "prog:"+c.id; + if(parentOpen.has(key)){ parentOpen.delete(key); progHolder.innerHTML = ""; } + else{ parentOpen.add(key); loadProgress(); } + }); + const actions = prH("div","am-actions"); + actions.appendChild(progBtn); + + /* Ouder en thuiskind delen exact dezelfde instelling in pupil_levels. + Schoolkinderen staan hier bewust niet tussen: hun leerkracht is leidend. */ + const levelHolder = prH("div","parent-levels"); + levelHolder.style.display = "none"; + async function loadLevels(){ + levelHolder.innerHTML = ""; + try{ + const data = await api(`/parent/children/${c.id}/levels`); + ["taal","rekenen","creatief"].forEach(subject=>{ + const current = data.levels?.[subject] || {yearGroup:4,level:2}; + const card = prH("section","learning-level-card"); + const subjectKey={taal:"parentLevelTaal",rekenen:"parentLevelRekenen",creatief:"parentLevelCreative"}[subject]; + card.appendChild(prH("h3",null,T(subjectKey))); + const summary = prH("p","guestnote"); + const controls = prH("div","am-inline"); + const year = prH("select","am-sel"), level = prH("select","am-sel"); + for(let n=1;n<=8;n++) year.add(new Option(T("parentLevelYear")+" "+n,n)); + for(let n=1;n<=3;n++) level.add(new Option(learningLevelLabel(n),n)); + year.value=current.yearGroup; level.value=current.level; + const update=()=>{ + const p=learningProfile(subject,+year.value,+level.value); + summary.textContent=p ? p.title+" — "+p.goals.join(" · ") : ""; + }; + year.onchange=level.onchange=update; update(); + const save=prH("button","am-btn",T("parentLevelSave"));save.type="button"; + save.onclick=async()=>{ + try{ + await api(`/parent/children/${c.id}/level`,{method:"PUT",body:{subject,yearGroup:+year.value,level:+level.value}}); + prMsg(T("parentLevelSaved")); await loadLevels(); + }catch(e){prMsg(e.message);} + }; + controls.append(year,level,save);card.append(summary,controls);levelHolder.appendChild(card); + }); + }catch(e){levelHolder.appendChild(prH("div","guestnote",e.message));} + } + if(!c.schoolId){ + const levelBtn=prH("button","am-btn",T("parentLevel"));levelBtn.type="button"; + levelBtn.addEventListener("click",()=>{ + const key="level:"+c.id; + if(parentOpen.has(key)){parentOpen.delete(key);levelHolder.style.display="none";} + else{parentOpen.add(key);levelHolder.style.display="";loadLevels();} + }); + actions.appendChild(levelBtn); + if(parentOpen.has("level:"+c.id)){levelHolder.style.display="";loadLevels();} + } + + /* thuiskind zonder lopende aanmelding: aanmelden bij een school via een + doorzoekbare lijst (input + datalist gevuld uit de publieke /schools) */ + const applyHolder = prH("div","am-add"); + applyHolder.style.display = "none"; + if(!c.schoolId && !c.appliedTo){ + const applyBtn = prH("button","am-btn", T("parentApply")); + applyBtn.type = "button"; + const inp = prH("input","am-inp"); + inp.placeholder = T("parentSchoolPh"); + inp.setAttribute("list","parentSchoolList"); + const go = prH("button","am-btn", T("parentApplyBtn")); + go.type = "button"; + go.addEventListener("click", async ()=>{ + const s = PARENT_SCHOOLS.find(x=>x.name.toLowerCase()===inp.value.trim().toLowerCase()); + if(!s){ prMsg(T("parentSchoolPh")); return; } + try{ await api(`/parent/children/${c.id}/apply`, { body:{ schoolId: s.id } }); parentOpen.delete("apply:"+c.id); parentReload(); } + catch(e){ prMsg(e.message); } + }); + applyHolder.appendChild(inp); + applyHolder.appendChild(go); + applyBtn.addEventListener("click", ()=>{ + const key = "apply:"+c.id; + if(parentOpen.has(key)) parentOpen.delete(key); else parentOpen.add(key); + applyHolder.style.display = parentOpen.has(key) ? "" : "none"; + if(parentOpen.has(key)) inp.focus(); + }); + applyHolder.style.display = parentOpen.has("apply:"+c.id) ? "" : "none"; + actions.appendChild(applyBtn); + } + + actions.appendChild(prDangerBtn(T("parentUnlink"), "parentUnlinkWarn", async ()=>{ + try{ await api("/parent/children/"+c.id, { method:"DELETE" }); parentResults.delete(c.id); parentReload(); } + catch(e){ prMsg(e.message); } + })); + tools.appendChild(actions); + tools.appendChild(levelHolder); + tools.appendChild(applyHolder); + tools.appendChild(progHolder); + row.appendChild(tools); + if(parentOpen.has("prog:"+c.id)) loadProgress(); + return row; +} + +/* ingeklapt formulier ("+ …"-knop) zoals addForm in het beheer */ +function parentCollapsedForm(label, build){ + const wrap = prH("div","am-add-wrap"); + const toggle = prH("button","tbtn ghost", label); + toggle.type = "button"; + const f = prH("div","am-add"); + f.style.display = "none"; + toggle.addEventListener("click", ()=>{ + f.style.display = f.style.display==="none" ? "" : "none"; + const first = f.querySelector("input"); + if(f.style.display!=="none" && first) first.focus(); + }); + build(f); + wrap.appendChild(toggle); + wrap.appendChild(f); + return wrap; +} + +function renderParentView(children){ + parentView.innerHTML = ""; + const inner = prH("div","parent-inner"); + inner.appendChild(prH("h2","parent-title", T("parentTitle"))); + inner.appendChild(prH("div","am-msg")); + + const list = prH("div","am-list"); + if(!children.length) list.appendChild(prH("div","guestnote", T("parentNoChildren"))); + children.forEach(c=>list.appendChild(parentChildRow(c))); + inner.appendChild(list); + + /* datalist voor de doorzoekbare schoolkeuze (gedeeld door alle rijen) */ + const dl = document.createElement("datalist"); + dl.id = "parentSchoolList"; + PARENT_SCHOOLS.forEach(s=>dl.appendChild(new Option(s.name))); + inner.appendChild(dl); + + /* kind aanmaken (thuiskind): naam -> account zonder school + wachtwoord */ + inner.appendChild(parentCollapsedForm(T("parentAddChild"), f=>{ + const inp = prH("input","am-inp"); + inp.placeholder = T("parentChildNamePh"); + inp.maxLength = 30; + const go = prH("button","am-btn", T("parentAddBtn")); + go.type = "button"; + go.addEventListener("click", async ()=>{ + try{ + const r = await api("/parent/children", { body:{ name: inp.value.trim() } }); + parentResults.set(r.child.id, { label: T("amFieldPw"), value: r.child.password }); + parentReload(); + }catch(e){ prMsg(e.message); } + }); + inp.addEventListener("keydown", ev=>{ if(ev.key==="Enter") go.click(); }); + f.appendChild(inp); + f.appendChild(go); + })); + + /* schoolkind koppelen met de eenmalige ouder-code van de school */ + inner.appendChild(parentCollapsedForm(T("parentLinkTitle"), f=>{ + f.appendChild(prH("div","guestnote", T("parentLinkNote"))); + const inp = prH("input","am-inp"); + inp.placeholder = T("parentLinkPh"); + inp.maxLength = 9; + inp.style.textTransform = "uppercase"; + const go = prH("button","am-btn", T("parentLinkBtn")); + go.type = "button"; + go.addEventListener("click", async ()=>{ + try{ + await api("/parent/children/link", { body:{ code: inp.value.trim().toUpperCase() } }); + inp.value = ""; + parentReload(); + }catch(e){ prMsg(e.message); } + }); + inp.addEventListener("keydown", ev=>{ if(ev.key==="Enter") go.click(); }); + f.appendChild(inp); + f.appendChild(go); + })); + + parentView.appendChild(inner); +} + +function startParentView(){ + document.body.classList.add("parent-mode"); + parentOpen.clear(); + parentResults.clear(); + (async ()=>{ + try{ PARENT_SCHOOLS = await api("/schools"); }catch(e){ PARENT_SCHOOLS = []; } + parentReload(); + })(); +} +function stopParentView(){ + document.body.classList.remove("parent-mode"); + parentView.innerHTML = ""; +} diff --git a/public/js/permissions.js b/public/js/permissions.js new file mode 100644 index 0000000..31ab48d --- /dev/null +++ b/public/js/permissions.js @@ -0,0 +1,49 @@ +/* teach - centrale permissiematrix voor de vaste rollen, inclusief ouder. + Gespiegeld in src/permissions.js (geen bundler in dit project, dus twee bestanden + die dezelfde waarden hand-in-hand bijhouden). */ +const PERMISSIONS = { + 'admin.access': ['super', 'admin', 'teacher'], + 'curriculum.view': ['super', 'admin', 'teacher', 'parent'], + 'password.own.change': ['super', 'admin', 'teacher'], + 'schools.manage': ['super'], + 'schools.resetPasswords': ['super', 'admin'], + 'classes.view': ['super', 'admin', 'teacher'], + 'classes.manage': ['super', 'admin'], + 'users.manage': ['super', 'admin', 'teacher'], + 'users.staffCredentials': ['super', 'admin'], + 'users.genpw': ['super', 'admin', 'teacher'], + 'users.role.change': ['super'], + 'assignments.manage': ['super', 'admin', 'teacher'], + 'progress.view': ['super', 'admin', 'teacher'], + 'shared.global.manage': ['super'], + 'content.manage': ['super'], + 'shared.school.manage': ['super', 'admin', 'teacher'], +}; + +const CREATABLE_ROLES = { + super: ['super', 'admin', 'teacher', 'pupil'], + admin: ['admin', 'teacher', 'pupil'], + teacher: ['pupil'], +}; + +/* Een systeemmanager kan uitsluitend de clientweergave van een andere rol + simuleren. Het HttpOnly-account en alle serverautorisatie blijven ongewijzigd. */ +const VIEW_ROLES = ["super","admin","teacher","parent","pupil"]; +let VIEW_AS_ROLE = "super"; +function setActiveViewRole(role){ + VIEW_AS_ROLE = VIEW_ROLES.includes(role) ? role : "super"; +} +function activeRole(){ + if(!currentUser) return null; + return currentUser.role==="super" ? VIEW_AS_ROLE : currentUser.role; +} +function activeRoles(){ + if(!currentUser) return []; + const role=activeRole(); + return role===currentUser.role ? [currentUser.role,...(currentUser.extraRoles||[])] : [role]; +} +/* can(action) gebruikt het actieve weergaveprofiel. De server blijft iedere + aanvraag vanzelf tegen het echte account autoriseren. */ +function can(action){ + return (PERMISSIONS[action] || []).some(r => activeRoles().includes(r)); +} diff --git a/public/js/picker.js b/public/js/picker.js new file mode 100644 index 0000000..6cd9326 --- /dev/null +++ b/public/js/picker.js @@ -0,0 +1,182 @@ +/* teach - herbruikbare plaatjeskiezer (bibliotheek / emoji / ARASAAC / upload). + Bouwt voort op dezelfde bouwstenen als de woordkaart-editor in letters.js + (EMOJI_CATS, arasaacSearch/arasaacUrl, setPic, dezelfde CSS-klassen) i.p.v. + een tweede plaatjessysteem te maken. Gebruikt door widgets die snel een los + plaatje willen laten kiezen zonder de volledige woordkaart-editor te openen + (bv. Ankerwoorden, als snelkoppeling vanaf een woord zonder automatisch plaatje). */ +"use strict"; +let pickerWrap = null, pickerCat = 0, pickerOnSelect = null; + +function choosePic(pic){ + if(pickerOnSelect) pickerOnSelect(pic); + closePicPicker(); +} +function closePicPicker(){ + if(pickerWrap) pickerWrap.classList.remove("open"); + pickerOnSelect = null; +} + +function ensurePicPicker(){ + if(pickerWrap) return pickerWrap; + const wrap = document.createElement("div"); + wrap.className = "modalwrap"; + wrap.innerHTML = ` + `; + document.body.appendChild(wrap); + pickerWrap = wrap; + const q = s => wrap.querySelector(s); + + wrap.addEventListener("click", e=>{ if(e.target===wrap) closePicPicker(); }); + q(".pp-close").addEventListener("click", closePicPicker); + + let tab = "emoji"; + function setTab(t){ tab = t; refreshTabs(); } + function applyLabels(){ + q(".ed-uplbl").textContent = T("upload"); + q(".ed-uphint").textContent = T("upHint"); + q(".ed-credit").innerHTML = T("pictoCredit"); + q(".ed-pgo").textContent = T("pictoSearch"); + q(".ed-psearch").placeholder = T("pictoPh"); + q(".ed-open-library").textContent = "🗂 "+T("imgOpenLibrary"); + q(".ed-library-hint").textContent = T("imgLanguageHint"); + q(".pp-close").title = T("close"); + refreshTabs(); + } + function refreshTabs(){ + wrap.querySelectorAll(".ed-tabs button").forEach(b=>{ + b.textContent = T("tab"+b.dataset.t[0].toUpperCase()+b.dataset.t.slice(1)); + b.classList.toggle("active", b.dataset.t===tab); + }); + q(".ed-p-emoji").style.display = tab==="emoji" ? "block" : "none"; + q(".ed-p-picto").style.display = tab==="picto" ? "block" : "none"; + q(".ed-p-upload").style.display = tab==="upload" ? "block" : "none"; + q(".ed-p-library").style.display = tab==="library" ? "block" : "none"; + } + wrap.querySelectorAll(".ed-tabs button").forEach(b=>{ + b.addEventListener("click", ()=>{ tab = b.dataset.t; refreshTabs(); }); + }); + + function renderCats(){ + const cats = q(".ed-cats"); + cats.innerHTML = ""; + T("cats").forEach((name,i)=>{ + const b = document.createElement("button"); + b.type = "button"; b.textContent = name; + b.classList.toggle("active", i===pickerCat); + b.addEventListener("click", ()=>{ pickerCat=i; renderCats(); }); + cats.appendChild(b); + }); + const grid = q(".ed-grid"); + grid.innerHTML = ""; + EMOJI_CATS[pickerCat].forEach(em=>{ + const b = document.createElement("button"); + b.type = "button"; b.textContent = em; + b.addEventListener("click", ()=>choosePic(em)); + grid.appendChild(b); + }); + } + + async function searchPictos(){ + const term = q(".ed-psearch").value.trim(); + if(!term) return; + q(".ed-status").textContent = T("pictoLoad"); + q(".ed-pgrid").innerHTML = ""; + try{ + const arr = await arasaacSearch(term, LANG); + if(!arr.length){ q(".ed-status").textContent = T("pictoNone"); return; } + q(".ed-status").textContent = ""; + arr.slice(0,12).forEach(p=>{ + const url = arasaacUrl(p._id); + const b = document.createElement("button"); + b.type = "button"; + const im = document.createElement("img"); + im.src = url; im.loading = "lazy"; im.alt = term; + b.appendChild(im); + b.addEventListener("click", ()=>choosePic(url)); + q(".ed-pgrid").appendChild(b); + }); + }catch(e){ q(".ed-status").textContent = T("pictoErr"); } + } + q(".ed-pgo").addEventListener("click", searchPictos); + q(".ed-psearch").addEventListener("keydown", e=>{ if(e.key==="Enter") searchPictos(); }); + q(".ed-open-library").addEventListener("click", ()=>{ + openImageCatalog((src,image)=>choosePic(image?.value||src)); + }); + + q(".ed-p-upload input").addEventListener("change", async e=>{ + const f = e.target.files[0]; if(!f) return; + e.target.value = ""; + if(currentUser){ + const hint=q(".ed-uphint"),old=hint.textContent; + hint.textContent=T("imgUploading"); + try{ + const image=await uploadImageToCatalog(f,{folder:"Taal/Mijn woordkaarten"}); + choosePic(image.src); + }catch(err){hint.textContent=err.message;setTimeout(()=>{hint.textContent=old},3500)} + return; + } + const img = new Image(); + img.onload = ()=>{ + const c = document.createElement("canvas"); + const s = Math.min(1, 128/Math.max(img.width, img.height)); + c.width = Math.round(img.width*s); c.height = Math.round(img.height*s); + c.getContext("2d").drawImage(img, 0, 0, c.width, c.height); + choosePic(c.toDataURL("image/png")); + URL.revokeObjectURL(img.src); + }; + img.src = URL.createObjectURL(f); + }); + + wrap.__applyLabels = applyLabels; + wrap.__renderCats = renderCats; + wrap.__searchPictos = searchPictos; + wrap.__setTab = setTab; + return wrap; +} + +function openPicPicker(word, onSelect){ + const wrap = ensurePicPicker(); + pickerOnSelect = onSelect; + wrap.__applyLabels(); + wrap.__renderCats(); + wrap.querySelector(".ed-psearch").value = word || ""; + /* Bij een woord staat de herbruikbare, thematisch ingedeelde bibliotheek + voorop. Zonder account blijft de bestaande online pictozoeker beschikbaar. */ + wrap.__setTab(word ? (currentUser ? "library" : "picto") : "emoji"); + wrap.classList.add("open"); + if(word && !currentUser) wrap.__searchPictos(); +} diff --git a/public/js/pupil-live-actions.js b/public/js/pupil-live-actions.js new file mode 100644 index 0000000..1ec5f58 --- /dev/null +++ b/public/js/pupil-live-actions.js @@ -0,0 +1,115 @@ +/* Live terugkanaal voor leerkrachtacties. Berichten worden via SSE direct + gemeld en daarna via de duurzame API opgehaald en bevestigd. */ +"use strict"; + +let pupilActionSource=null; +let pupilActionFallback=null; +let pupilActionNoticeTimer=null; +let pupilActionLoading=false; + +function ensurePupilActionUi(){ + let notice=document.getElementById("pupilLiveNotice"); + if(!notice){ + notice=document.createElement("aside"); + notice.id="pupilLiveNotice"; + notice.className="pupil-live-notice"; + notice.setAttribute("role","status"); + notice.setAttribute("aria-live","polite"); + notice.hidden=true; + document.body.appendChild(notice); + } + let pause=document.getElementById("pupilPauseShield"); + if(!pause){ + pause=document.createElement("aside"); + pause.id="pupilPauseShield"; + pause.className="pupil-pause-shield"; + pause.setAttribute("role","status"); + const icon=document.createElement("span");icon.textContent="⏸"; + const title=document.createElement("strong");title.textContent=T("pupilPausedTitle"); + const text=document.createElement("span");text.textContent=T("pupilPausedText"); + pause.append(icon,title,text); + document.body.appendChild(pause); + } + return{notice,pause}; +} + +function setPupilPaused(paused){ + const ui=ensurePupilActionUi(); + document.body.classList.toggle("pupil-session-paused",!!paused); + ui.pause.hidden=!paused; +} + +function showPupilLiveAction(action){ + const ui=ensurePupilActionUi(); + if(action.kind==="pause"){setPupilPaused(true);return;} + if(action.kind==="resume")setPupilPaused(false); + let text=action.message||""; + if(action.kind==="easier"){ + text=T("pupilEasierReady") + .replace("{group}",action.payload?.yearGroup||"–") + .replace("{level}",action.payload?.level||"–"); + pupilLastSig=null; + pupilPoll(); + }else if(!text){ + text=T(action.kind==="resume"?"pupilResumeMessage":"pupilTeacherMessage"); + } + ui.notice.className="pupil-live-notice "+action.kind; + ui.notice.textContent=(action.kind==="encourage"?"👏 ":action.kind==="hint"?"💡 ":"")+" "+text; + ui.notice.hidden=false; + clearTimeout(pupilActionNoticeTimer); + pupilActionNoticeTimer=setTimeout(()=>{ui.notice.hidden=true;},8000); +} + +async function loadPupilLiveActions(){ + if(pupilActionLoading||!currentUser||currentUser.role!=="pupil")return; + pupilActionLoading=true; + try{ + const result=await api("/my/live-actions"); + setPupilPaused(result.paused); + for(const action of result.actions||[]){ + showPupilLiveAction(action); + await api("/my/live-actions/"+encodeURIComponent(action.id)+"/ack",{method:"POST"}); + } + }catch(error){ + if(error.status===401||error.status===403)stopPupilLiveActions(); + }finally{pupilActionLoading=false;} +} + +function startPupilLiveActions(){ + stopPupilLiveActions(); + ensurePupilActionUi(); + loadPupilLiveActions(); + if(typeof EventSource!=="undefined"){ + pupilActionSource=new EventSource("/api/my/live-actions/stream"); + pupilActionSource.addEventListener("actions",loadPupilLiveActions); + } + pupilActionFallback=setInterval(loadPupilLiveActions,15000); +} + +function stopPupilLiveActions(){ + if(pupilActionSource){pupilActionSource.close();pupilActionSource=null;} + clearInterval(pupilActionFallback);pupilActionFallback=null; + clearTimeout(pupilActionNoticeTimer); + setPupilPaused(false); + const notice=document.getElementById("pupilLiveNotice"); + if(notice)notice.hidden=true; +} + +document.addEventListener("langchange",()=>{ + const pause=document.getElementById("pupilPauseShield"); + if(pause){ + pause.querySelector("strong").textContent=T("pupilPausedTitle"); + pause.querySelector("span:last-child").textContent=T("pupilPausedText"); + } +}); + +const startPupilViewWithoutActions=startPupilView; +startPupilView=function(){ + startPupilViewWithoutActions(); + startPupilLiveActions(); +}; +const stopPupilViewWithoutActions=stopPupilView; +stopPupilView=function(){ + stopPupilLiveActions(); + stopPupilViewWithoutActions(); +}; diff --git a/public/js/pupil.js b/public/js/pupil.js new file mode 100644 index 0000000..173ed3e --- /dev/null +++ b/public/js/pupil.js @@ -0,0 +1,263 @@ +/* teach - leerling-omgeving: toont enkel de widgets die de leerkracht heeft + klaargezet (via een toewijzing aan de klas of de leerling zelf), in + speelmodus (readonly) en zonder de sleep/vergroot/sluit-chrome van het bord. + Geen momentopname: /my/assignment zoekt live in het bord van de leerkracht, + dus deze pagina pollt periodiek zodat nieuwe woorden vanzelf doorkomen. + Modus per toewijzing: "werken" (default, leerling werkt in de widget en + voortgang wordt gelogd) of "kijken" (alleen meekijken: alle interactie + geblokkeerd via een overlay, snellere poll zodat wijzigingen van de + leerkracht vlot doorkomen). */ +const PUPIL_WIDGET_CATS = ["taal", "rekenen", "creatief"]; +const PUPIL_POLL_MS = { kijken: 5000, werken: 20000 }; +const PUPIL_LIVE_HEARTBEAT_MS = 12000; +const pupilView = document.getElementById("pupilView"); +let pupilPollTimer = null; +let pupilLastSig = null; +let pupilMode = "werken"; +let pupilPendingRewards = []; +let pupilLiveSession = null; +let pupilLiveStart = null; +let pupilLiveHeartbeatTimer = null; + +async function endPupilLiveSession(keepalive=false){ + const current=pupilLiveSession; + pupilLiveSession=null;pupilLiveStart=null; + if(!current)return; + const path="/api/my/live-session/"+encodeURIComponent(current.id)+"/end"; + if(keepalive){ + fetch(path,{method:"POST",credentials:"same-origin",keepalive:true}).catch(()=>{}); + return; + } + try{await api("/my/live-session/"+encodeURIComponent(current.id)+"/end",{method:"POST"});} + catch(e){ /* een verlopen sessie is al effectief afgesloten */ } +} +async function ensurePupilLiveSession(widgetId,widgetType,mode){ + const key=widgetId+"|"+widgetType+"|"+mode; + if(pupilLiveSession?.key===key)return pupilLiveSession.id; + if(pupilLiveStart?.key===key)return pupilLiveStart.promise; + if(pupilLiveSession)await endPupilLiveSession(); + const promise=api("/my/live-session/start",{method:"POST",body:{widgetId,widgetType,mode}}) + .then(result=>{ + pupilLiveSession={id:result.session.id,key}; + pupilLiveStart=null; + return pupilLiveSession.id; + }) + .catch(error=>{ + pupilLiveStart=null; + console.warn("live sessie starten mislukt:",error.message); + return null; + }); + pupilLiveStart={key,promise}; + return promise; +} +async function heartbeatPupilLiveSession(){ + const current=pupilLiveSession; + if(!current||document.visibilityState==="hidden")return; + try{await api("/my/live-session/"+encodeURIComponent(current.id)+"/heartbeat",{method:"POST"});} + catch(e){if(pupilLiveSession?.id===current.id)pupilLiveSession=null;} +} +window.addEventListener("pagehide",()=>endPupilLiveSession(true)); + +function pupilAllowedDefs(){ + return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat)); +} + +async function renderPupilWidgets(widgets, mode, preview=false, sequence=null){ + if(!preview)await endPupilLiveSession(); + pupilView.innerHTML = ""; + const defs = pupilAllowedDefs(); + const watch = mode === "kijken"; + if(sequence){ + const path = document.createElement("section"); + path.className = "pupil-path" + (sequence.done ? " done" : ""); + const title = document.createElement("div"); + title.className = "pupil-path-title"; + title.textContent = sequence.title || T("pupilPath"); + const progress = document.createElement("div"); + progress.className = "pupil-path-progress"; + progress.textContent = sequence.done ? T("pupilPathComplete") + : T("pupilPathStep").replace("{current}", sequence.currentIndex + 1).replace("{total}", sequence.total); + const dots = document.createElement("div"); + dots.className = "pupil-path-dots"; + (sequence.steps || []).forEach((step, index)=>{ + const dot = document.createElement("span"); + dot.className = step.completed ? "complete" : index === sequence.currentIndex ? "current" : ""; + dot.textContent = step.completed ? "✓" : String(index + 1); + dots.appendChild(dot); + }); + path.append(title, progress, dots); + const currentStep=sequence.steps?.[sequence.currentIndex]; + if(!sequence.done&¤tStep?.widgetType){ + const context=document.createElement("div"); + context.className="pupil-path-current"; + context.textContent=T("wg_"+currentStep.widgetType) + +(currentStep.themeName?" · "+T("castTheme")+": "+currentStep.themeName:""); + path.appendChild(context); + } + pupilView.appendChild(path); + if(sequence.done){ + const finish = document.createElement("div"); + finish.className = "pupil-path-finish"; + const heading = document.createElement("strong"); heading.textContent = T("pupilPathWellDone"); + const rewardText = document.createElement("span"); rewardText.textContent = T("pupilPathReward"); + finish.append(heading, rewardText); + if(pupilPendingRewards.length){ + const reward = document.createElement("span"); + reward.className = "pupil-path-unlocked"; + reward.textContent = T("pupilRewardUnlocked").replace("{count}", pupilPendingRewards.length); + finish.appendChild(reward); + pupilPendingRewards = []; + } + pupilView.appendChild(finish); + if(typeof celebrateAvatar === "function") celebrateAvatar(finish); + return; + } + } + const usable = widgets + .map(w => ({ w, def: defs.find(d => d.id === w.id) })) + .filter(x => x.def); + if(!usable.length){ + const hint = document.createElement("div"); + hint.className = "pupil-empty"; + hint.textContent = T("pupilEmpty"); + pupilView.appendChild(hint); + return; + } + /* klassenmanagement: groep/niveau per vakgebied ophalen vóór het mounten, + zodat een widget zonder eigen opgeslagen niveau met de door de + groepsleiding ingestelde waarde start i.p.v. altijd stilzwijgend niveau 1 + - één keer per vakgebied dat hier daadwerkelijk voorkomt, niet per widget. */ + const subjects = [...new Set(usable.map(x => x.def.cat))]; + const levels = {}; + if(!preview)await Promise.all(subjects.map(async subject=>{ + try{ levels[subject] = await api("/my/level?subject="+subject); } + catch(e){ levels[subject] = null; } + })); + let stepButton = null; + let stepReady = watch || !!sequence?.ready; + usable.forEach(({ w, def }, idx)=>{ + const card = document.createElement("div"); + card.className = "widget pupil-card" + (watch ? " watch" : ""); + card.dataset.widget = def.id; + card.style.width = `min(100%, ${def.w}px)`; + card.style.minWidth = "0"; + card.style.minHeight = def.h + "px"; + const body = document.createElement("div"); + body.className = "widget-body"; + card.appendChild(body); + if(watch){ + const badge = document.createElement("div"); + badge.className = "pupil-watch-badge"; + badge.textContent = "👁 " + T("pupilWatching"); + card.appendChild(badge); + } + pupilView.appendChild(card); + /* borden die vóór de invoering van stabiele widget-ids zijn opgeslagen + hebben nog geen wid - dan is positie in de lijst de terugval-identiteit, + stabiel zolang de leerkracht de widgets op het bord niet herschikt */ + const widgetId = w.wid || (def.id + ":" + idx); + const startLive=()=>ensurePupilLiveSession(widgetId,def.id,mode); + if(!preview){ + card.addEventListener("pointerdown",startLive); + card.addEventListener("focusin",startLive); + card.addEventListener("keydown",startLive); + if(watch&&idx===0)startLive(); + } + /* in kijken-modus geen voortgang: de leerling doet zelf niets. In + werken-modus moet de leerling juist wél kunnen werken - readonly:true + stond hier sinds v0.3.15-beta abusievelijk ook in de werken-tak (een + kopieerfout toen kijken/werken werd toegevoegd). De twee widgets die + opts.readonly echt lezen (letters.js/anchor.js) gebruiken het alleen om + hun eigen bewerk-gereedschap (woordenlijst-editor, opslaan/bibliotheek) + te verbergen, niet om de kernoefening te blokkeren - maar dat gereedschap + hoort een leerling in werken-modus wél te zien, dus dit was hoe dan ook + verkeerd om, los van de vraag of dit "voortgang werkt niet" verklaart. */ + const opts = watch ? { readonly:true } : { onProgress: (p)=>{ + /* eerder een stille .catch(()=>{}) - een mislukte log (verlopen sessie, + geen toewijzing meer, servergolf) verdween daarmee spoorloos, wat een + "voortgang werkt niet"-klacht onmogelijk te onderzoeken maakt. Nu + zichtbaar in de console (geen storende UI voor de leerling, wél een + spoor voor wie het probleem moet natrekken). */ + ensurePupilLiveSession(widgetId,def.id,mode) + .then(liveSessionId=>api("/my/progress", { method:"POST", + body: { widgetId, widgetType: def.id, liveSessionId, ...p } })) + .then(()=>{ + stepReady = true; + if(stepButton){ stepButton.disabled = false; stepButton.textContent = T("pupilPathNext"); } + }) + .catch(e=>console.warn("voortgang loggen mislukt:", e.message)); + }}; + /* De vakinstelling van de leerkracht is per leerling en dus leidend boven + de algemene widgetstate van het bord. Zo krijgt dezelfde opdracht voor + twee leerlingen toch passende woorden en getalgrenzen. */ + const lvl = levels[def.cat]; + const state = typeof applyLearningLevelToState === "function" + ? applyLearningLevelToState(def, w.state, lvl, true) + : (w.state ? { ...w.state } : {}); + def.mount(body, Object.keys(state).length ? state : null, opts); + if(typeof appendLearningProfileBadge === "function") + appendLearningProfileBadge(body, def.cat, state, def.id); + }); + if(sequence && !preview){ + const actions = document.createElement("div"); + actions.className = "pupil-path-actions"; + stepButton = document.createElement("button"); + stepButton.type = "button"; + stepButton.className = "primary"; + stepButton.disabled = !stepReady; + stepButton.textContent = stepReady ? T("pupilPathNext") : T("pupilPathFirst"); + stepButton.addEventListener("click", async ()=>{ + const step = sequence.steps[sequence.currentIndex]; + if(!step) return; + stepButton.disabled = true; + try{ + const result = await api("/my/assignment/steps/"+encodeURIComponent(step.id)+"/complete", {method:"POST"}); + pupilPendingRewards = result.unlocked || []; + if(typeof avatarAcceptRewards === "function") avatarAcceptRewards(pupilPendingRewards); + pupilLastSig = null; + await pupilPoll(); + }catch(e){ stepButton.disabled = false; stepButton.textContent = e.message; } + }); + actions.appendChild(stepButton); + pupilView.appendChild(actions); + } +} + +async function pupilPoll(){ + try{ + const res = await api("/my/assignment"); + const mode = res.mode === "kijken" ? "kijken" : "werken"; + /* modus in de signature zodat een moduswissel ook zonder widget-wijziging + een her-render (en dus de juiste overlay) oplevert */ + const sig = mode + "|" + JSON.stringify(res.widgets || []) + "|" + JSON.stringify(res.sequence || null); + if(sig !== pupilLastSig){ + pupilLastSig = sig; + await renderPupilWidgets(res.widgets || [], mode, false, res.sequence || null); + } + if(mode !== pupilMode){ + pupilMode = mode; + clearInterval(pupilPollTimer); + pupilPollTimer = setInterval(pupilPoll, PUPIL_POLL_MS[mode]); + } + }catch(e){ /* stille mislukking - volgende poll probeert opnieuw */ } +} + +function startPupilView(){ + document.body.classList.add("pupil-mode"); + pupilLastSig = null; + pupilMode = "werken"; + pupilPoll(); + clearInterval(pupilPollTimer); + pupilPollTimer = setInterval(pupilPoll, PUPIL_POLL_MS[pupilMode]); + clearInterval(pupilLiveHeartbeatTimer); + pupilLiveHeartbeatTimer=setInterval(heartbeatPupilLiveSession,PUPIL_LIVE_HEARTBEAT_MS); +} +function stopPupilView(){ + document.body.classList.remove("pupil-mode"); + clearInterval(pupilPollTimer); + pupilPollTimer = null; + clearInterval(pupilLiveHeartbeatTimer); + pupilLiveHeartbeatTimer=null; + endPupilLiveSession(); + pupilView.innerHTML = ""; +} diff --git a/public/js/role-preview.js b/public/js/role-preview.js new file mode 100644 index 0000000..f7274ac --- /dev/null +++ b/public/js/role-preview.js @@ -0,0 +1,198 @@ +/* teach - veilige rolvoorbeeldweergave voor systeemmanagers. + Alleen de clientinterface en zichtbare menu's veranderen. De sessiecookie, + currentUser en serverautorisatie blijven altijd van de systeemmanager. */ +"use strict"; + +(function(){ + const roleRow=document.getElementById("superViewRole"); + const roleSeg=document.getElementById("segViewRole"); + const banner=document.getElementById("rolePreviewBanner"); + const bannerLabel=document.getElementById("rolePreviewLabel"); + const teacherActions=document.getElementById("rolePreviewTeacherActions"); + const pathButton=document.getElementById("rolePreviewPath"); + const dashboardButton=document.getElementById("rolePreviewDashboard"); + const resetButton=document.getElementById("rolePreviewReset"); + const ROLE_KEYS={ + super:"vwRoleSystem",admin:"vwRoleAdmin",teacher:"vwRoleTeacher", + parent:"vwRoleParent",pupil:"vwRolePupil" + }; + let renderSequence=0; + let pupilWidgetPicker=null; + + function closePupilWidgetPicker(){ + if(pupilWidgetPicker)pupilWidgetPicker.classList.remove("open"); + } + function openPupilWidgetPicker(){ + if(!isSystemManager()||activeRole()!=="pupil")return; + if(!pupilWidgetPicker){ + pupilWidgetPicker=document.createElement("div"); + pupilWidgetPicker.className="modalwrap"; + pupilWidgetPicker.id="pupilPreviewWidgetPicker"; + pupilWidgetPicker.setAttribute("role","dialog"); + pupilWidgetPicker.setAttribute("aria-modal","true"); + pupilWidgetPicker.addEventListener("click",event=>{ + if(event.target===pupilWidgetPicker)closePupilWidgetPicker(); + }); + document.body.appendChild(pupilWidgetPicker); + } + const modal=document.createElement("div"); + modal.className="modal role-preview-widget-picker"; + const head=document.createElement("div"); + head.className="gallery-head"; + const title=document.createElement("h2"); + title.id="pupilPreviewWidgetTitle"; + title.textContent=T("vwPupilChooseWidget"); + const close=document.createElement("button"); + close.type="button";close.className="wclose";close.textContent="✕"; + close.title=T("close");close.setAttribute("aria-label",T("close")); + close.addEventListener("click",closePupilWidgetPicker); + head.append(title,close);modal.appendChild(head); + const note=document.createElement("p"); + note.className="guestnote";note.textContent=T("vwPupilAddWidgetNote"); + modal.appendChild(note); + const host=document.createElement("div"); + pupilAllowedDefs().forEach(def=>{ + const card=document.createElement("button"); + card.type="button";card.className="gcard role-preview-widget-card"; + const icon=document.createElement("span"); + icon.className="gi";icon.textContent=def.icon; + const text=document.createElement("span"); + const name=document.createElement("strong"); + name.className="gn";name.textContent=T("wg_"+def.id); + const desc=document.createElement("small"); + desc.className="gd";desc.textContent=T("wg_"+def.id+"_d"); + text.append(name,desc);card.append(icon,text); + card.addEventListener("click",()=>addPupilPreviewWidget(def)); + host.appendChild(card); + }); + host.className="role-preview-widget-grid"; + modal.appendChild(host); + pupilWidgetPicker.setAttribute("aria-labelledby",title.id); + pupilWidgetPicker.replaceChildren(modal); + pupilWidgetPicker.classList.add("open"); + close.focus(); + } + async function addPupilPreviewWidget(def){ + if(!isSystemManager()||activeRole()!=="pupil"||!pupilAllowedDefs().includes(def))return; + closePupilWidgetPicker(); + const previewRole=activeRole(); + document.body.classList.add("role-preview-updating"); + stopPupilView(); + setActiveViewRole("super"); + try{ + makeWidget(def); + await doSave(); + }finally{ + setActiveViewRole(previewRole); + document.body.classList.remove("role-preview-updating"); + renderPreviewSurface(); + } + } + + + const isSystemManager=()=>!!currentUser&¤tUser.role==="super"; + const roleLabel=role=>T(ROLE_KEYS[role]||ROLE_KEYS.super); + + function storedRole(){ + try{ + const value=localStorage.getItem("teach.superViewRole"); + return VIEW_ROLES.includes(value)?value:"super"; + }catch(e){return"super";} + } + function storeRole(role){ + try{localStorage.setItem("teach.superViewRole",role);}catch(e){} + } + function previewNotice(text){ + const note=document.createElement("aside"); + note.className="role-preview-note"; + const strong=document.createElement("strong"); + strong.textContent=T("vwPreview")+": "; + note.append(strong,document.createTextNode(text)); + return note; + } + function clearPreviewSurface(){ + renderSequence++; + document.body.classList.remove("role-preview-active","role-preview-parent","role-preview-pupil"); + banner.hidden=true; + closePupilWidgetPicker(); + if(isSystemManager()){ + stopPupilView(); + stopParentView(); + } + } + function syncControls(){ + const enabled=isSystemManager(); + roleRow.hidden=!enabled; + const role=enabled?activeRole():"super"; + roleSeg.querySelectorAll("button").forEach(button=>{ + const selected=button.dataset.role===role; + button.classList.toggle("active",selected); + button.setAttribute("aria-pressed",String(selected)); + }); + banner.hidden=!enabled||role==="super"; + bannerLabel.textContent=enabled&&role!=="super"?roleLabel(role):""; + teacherActions.hidden=!enabled||role!=="teacher"; + } + async function renderPreviewSurface(){ + const sequence=++renderSequence; + if(!isSystemManager()){clearPreviewSurface();syncControls();return;} + const role=activeRole(); + closePupilWidgetPicker(); + document.body.classList.remove("role-preview-active","role-preview-parent","role-preview-pupil"); + stopPupilView(); + stopParentView(); + if(role==="parent"){ + document.body.classList.add("role-preview-active","role-preview-parent","parent-mode"); + renderParentView([]); + const inner=parentView.querySelector(".parent-inner"); + if(inner)inner.prepend(previewNotice(T("vwParentPreview"))); + parentView.querySelectorAll("button,input,select,textarea").forEach(control=>{control.disabled=true;}); + }else if(role==="pupil"){ + document.body.classList.add("role-preview-active","role-preview-pupil","pupil-mode"); + const snapshot=serializeBoard(); + await renderPupilWidgets(snapshot.widgets||[],"kijken",true); + if(sequence!==renderSequence)return; + const note=previewNotice(T("vwPupilPreview")); + const add=document.createElement("button"); + add.type="button";add.className="tbtn role-preview-add-widget"; + add.textContent="+ "+T("vwPupilAddWidget"); + add.addEventListener("click",openPupilWidgetPicker); + note.appendChild(add); + pupilView.prepend(note); + } + syncControls(); + } + function chooseRole(role){ + if(!isSystemManager()||!VIEW_ROLES.includes(role))return; + setActiveViewRole(role); + storeRole(role); + updateUserUI(); + document.dispatchEvent(new CustomEvent("userchange")); + } + + roleSeg.querySelectorAll("button").forEach(button=>{ + button.addEventListener("click",()=>chooseRole(button.dataset.role)); + }); + pathButton.addEventListener("click",()=>{ + if(isSystemManager()&&activeRole()==="teacher")document.getElementById("btnCast").click(); + }); + dashboardButton.addEventListener("click",()=>{ + if(isSystemManager()&&activeRole()==="teacher")openSettings("toewijzingen"); + }); + resetButton.addEventListener("click",()=>chooseRole("super")); + document.addEventListener("userchange",()=>{ + if(isSystemManager()){ + const saved=storedRole(); + if(activeRole()!==saved)setActiveViewRole(saved); + }else{ + setActiveViewRole("super"); + } + syncControls(); + renderPreviewSurface(); + }); + document.addEventListener("langchange",()=>{ + syncControls(); + if(isSystemManager()&&["parent","pupil"].includes(activeRole()))renderPreviewSurface(); + }); + syncControls(); +})(); diff --git a/public/js/settings.js b/public/js/settings.js new file mode 100644 index 0000000..1e0e0ef --- /dev/null +++ b/public/js/settings.js @@ -0,0 +1,176 @@ +/* teach - instellingen-schil: één sectieregister als enige bron van waarheid. + Elke sectie meldt zich aan met zichtbaarheid per rol, een activate() en + zoekgegevens; de schil bouwt daar de navigatie (gegroepeerd: Persoonlijk / + School / Systeem), de sectiekop en de zoekfunctie uit op. Zo bepaalt de rol + van de ingelogde gebruiker vanzelf wat er te zien is: een gast alleen + Weergave, een leerling/ouder Account + Weergave, staf ook de beheerdelen + (admin.js registreert die zelf - de schil kent het beheer niet van binnen). + De zoekbalk zoekt door sectienamen, instelling-trefwoorden én data + (gebruikers/klassen/scholen via searchItems van de secties). */ +"use strict"; +const ST_SECTIONS = []; +function registerSettingsSection(sec){ ST_SECTIONS.push(sec); } + +(function(){ + const wrap = document.getElementById("settingsWrap"); + const nav = document.getElementById("stNav"); + const content = document.getElementById("stContent"); + const secHead = document.getElementById("stSecHead"); + const searchInp = document.getElementById("stSearch"); + const resultsEl = document.getElementById("stSearchResults"); + let activeSection = null; + + const el = (tag, cls, text)=>{ + const e = document.createElement(tag); + if(cls) e.className = cls; + if(text != null) e.textContent = text; + return e; + }; + const visibleSections = ()=> + ST_SECTIONS.filter(s=>s.visible()).sort((a,b)=>(a.order||99)-(b.order||99)); + + /* ---- ingebouwde secties: Account en Weergave ------------------------------ */ + registerSettingsSection({ + id:"account", icon:"👤", group:"personal", order:1, + label:()=>T("stAccount"), sub:()=>T("stAccountSub"), + visible:()=>!!currentUser, + activate(){ showDomSection("account"); loadBackups(); }, + keywords:()=>[T("stPassword"), T("stBackups"), T("stSession"), T("logout"), + "wachtwoord","password","reservekopie","backup","herstel","restore", + "uitloggen","logout","sessie","session","naam","name"], + }); + registerSettingsSection({ + id:"view", icon:"🎨", group:"personal", order:2, + label:()=>T("stView"), sub:()=>T("stViewSub"), + visible:()=>true, + activate(){ showDomSection("view"); }, + keywords:()=>[T("vwRole"), T("vwRoleSystem"), T("vwRoleAdmin"), T("vwRoleTeacher"), T("vwRoleParent"), T("vwRolePupil"), T("vwTheme"), T("vwContrast"), T("vwLang"), T("vwTbar"), T("vwMotion"), T("vwGlass"), + "thema","theme","donker","dark","licht","light","contrast","taal","language", + "menubalk","toolbar","animaties","animations","glas","glass"], + }); + + /* de drie DOM-secties (account/view/admin-host); beheer-secties delen de + admin-host, admin.js wisselt daarbinnen zelf van paneel */ + function showDomSection(id){ + resultsEl.style.display = "none"; + content.querySelectorAll(".st-section").forEach(s=>{ + s.style.display = s.dataset.section===id ? "" : "none"; + }); + } + window.showAdminHost = ()=>showDomSection("admin"); + /* modules met een eigen paneel (bv. cms.js) tonen hun sectie-div hiermee */ + window.showSettingsDom = showDomSection; + + const GROUPS = [["personal","stGroupPersonal"], ["school","stGroupSchool"], ["system","stGroupSystem"]]; + function buildNav(){ + nav.innerHTML = ""; + const secs = visibleSections(); + GROUPS.forEach(([g, key])=>{ + const inGroup = secs.filter(s=>s.group===g); + if(!inGroup.length) return; + /* groepskoppen pas als er echt iets te groeperen valt (staf); een + gast/leerling met 1-2 secties krijgt een kale, rustige lijst */ + if(secs.length > 3) nav.appendChild(el("div","st-nav-group", T(key))); + inGroup.forEach(s=>{ + const b = el("button","st-nav-btn"); + b.type = "button"; + b.dataset.section = s.id; + b.appendChild(el("span","st-nav-ico", s.icon)); + b.appendChild(el("span","st-nav-lbl", s.label())); + b.addEventListener("click", ()=>goSection(s.id)); + nav.appendChild(b); + }); + }); + } + + async function goSection(id, payload){ + const s = ST_SECTIONS.find(x=>x.id===id && x.visible()); + if(!s) return; + activeSection = id; + searchInp.value = ""; + nav.querySelectorAll(".st-nav-btn").forEach(b=>b.classList.toggle("active", b.dataset.section===id)); + secHead.innerHTML = ""; + const title = el("div","st-sec-title"); + title.appendChild(el("span","st-sec-ico", s.icon)); + title.appendChild(el("span", null, s.label())); + secHead.appendChild(title); + if(s.sub) secHead.appendChild(el("div","st-sec-sub", s.sub())); + content.scrollTop = 0; + await s.activate(payload); + } + window.goSettingsSection = goSection; + + /* ---- zoeken --------------------------------------------------------------- + drie lagen per zichtbare sectie: (1) sectienaam, (2) trefwoorden van de + instellingen erin, (3) live data (searchItems: gebruikers/klassen/scholen). + Resultaten vervangen de inhoud; een klik navigeert (en filtert waar kan). */ + function doSearch(){ + const q = searchInp.value.trim().toLowerCase(); + if(!q){ + if(activeSection) goSection(activeSection); + return; + } + nav.querySelectorAll(".st-nav-btn").forEach(b=>b.classList.remove("active")); + secHead.innerHTML = ""; + resultsEl.innerHTML = ""; + content.querySelectorAll(".st-section").forEach(s=>{ s.style.display = "none"; }); + let total = 0; + visibleSections().forEach(s=>{ + const hits = []; + if(s.label().toLowerCase().includes(q)){ + hits.push({ icon:s.icon, label:s.label(), sub:T("stOpenSection"), go:()=>goSection(s.id) }); + }else{ + const kw = (s.keywords ? s.keywords() : []).find(k=>String(k).toLowerCase().includes(q)); + if(kw) hits.push({ icon:s.icon, label:String(kw), sub:s.label(), go:()=>goSection(s.id) }); + } + if(s.searchItems) hits.push(...s.searchItems(q)); + if(!hits.length) return; + total += hits.length; + resultsEl.appendChild(el("div","st-nav-group", s.label())); + hits.slice(0, 8).forEach(hit=>{ + const row = el("button","st-result"); + row.type = "button"; + row.appendChild(el("span","st-nav-ico", hit.icon || s.icon)); + row.appendChild(el("span","st-result-lbl", hit.label)); + if(hit.sub) row.appendChild(el("span","st-result-sub", hit.sub)); + row.addEventListener("click", ()=>hit.go()); + resultsEl.appendChild(row); + }); + }); + if(!total) resultsEl.appendChild(el("div","guestnote", T("stNoResults"))); + resultsEl.style.display = ""; + } + searchInp.addEventListener("input", doSearch); + searchInp.addEventListener("keydown", e=>{ + e.stopPropagation(); + if(e.key==="Escape"){ searchInp.value = ""; doSearch(); } + if(e.key==="Enter"){ const first = resultsEl.querySelector(".st-result"); if(first) first.click(); } + }); + + /* ---- openen ---------------------------------------------------------------- */ + window.openSettings = function(section, payload){ + const msg = document.getElementById("settingsMsg"); + msg.textContent = ""; msg.classList.remove("ok"); + searchInp.value = ""; + searchInp.placeholder = T("stSearchPh"); + buildNav(); + goSection(section || (currentUser ? "account" : "view"), payload); + /* beheerdata alvast op de achtergrond laden zodat zoeken op namen van + gebruikers/klassen/scholen meteen wat oplevert (admin.js bewaakt zelf + dat er maar één keer geladen wordt) */ + if(currentUser && can("admin.access") && window.adminPrefetch) window.adminPrefetch(); + wrap.classList.add("open"); + }; + document.addEventListener("langchange", ()=>{ + searchInp.placeholder = T("stSearchPh"); + if(wrap.classList.contains("open")){ buildNav(); if(activeSection) goSection(activeSection); } + }); + document.addEventListener("userchange", ()=>{ + if(wrap.classList.contains("open")){ + buildNav(); + const secs = visibleSections(); + if(!secs.some(s=>s.id===activeSection)) goSection(secs[0] ? secs[0].id : "view"); + else goSection(activeSection); + } + }); +})(); diff --git a/public/js/widgets/anchor.js b/public/js/widgets/anchor.js new file mode 100644 index 0000000..60076da --- /dev/null +++ b/public/js/widgets/anchor.js @@ -0,0 +1,412 @@ +/* widget: Ankerwoorden */ +/* ========================================================= + Anchor words — unlimited word grid around a theme +==========================================================*/ +function mountAnchor(root, initState, opts){ + const readonly = !!(opts && opts.readonly); + const CW = 128, CH = 56, G = 10; + /* colour cycle for connecting words: none → green → blue → red → purple → orange → yellow */ + const COLS = [null,"#3fae6a","#3b7dd8","#e0554d","#9b59b6","#f28c38","#d9a400"]; + const S = { cells:{}, pan:{x:0, y:0}, pics:{}, themeId:null }; + if(initState){ + const src = initState.cells || {}; + Object.keys(src).forEach(k=>{ + const v = src[k]; + /* migrate: old format stored plain strings */ + S.cells[k] = (typeof v==="string") ? {t:v, c:null} : {t:v.t||"", c:v.c||null}; + }); + S.pan = initState.pan || {x:0, y:0}; + S.pics = initState.pics || {}; /* word -> resolved picture (undefined/null/URL) */ + /* migrate: older boards stored the literal anchor emoji as a "picture" - treat + that the same as "no match found" now that it's no longer shown as a picture */ + Object.keys(S.pics).forEach(w=>{ if(S.pics[w] === "⚓") S.pics[w] = null; }); + S.themeId = initState.themeId || null; + } + /* S.pics[word]: undefined = not tried yet, null = tried, no ARASAAC match + (shown empty, no fake anchor icon), string = resolved picture URL */ + const fetching = new Set(); + function fetchPic(word){ + if(S.pics[word] !== undefined || fetching.has(word)) return; + fetching.add(word); + arasaacSearch(word, LANG).then(arr=>{ + S.pics[word] = (arr && arr.length) ? arasaacUrl(arr[0]._id) : null; + }).catch(()=>{ + S.pics[word] = null; + }).finally(()=>{ + fetching.delete(word); + syncSource(); + render(); + persistWords(); + }); + } + root.innerHTML = ` +
+
+
+ + +
+
+
`; + const stage = root.querySelector(".an-stage"), + layer = root.querySelector(".an-layer"), + hint = root.querySelector(".an-hint"), + toolsEl = root.querySelector(".an-tools"), + saveBtn = root.querySelector(".an-save"), + libBtn = root.querySelector(".an-lib"); + if(readonly) toolsEl.remove(); + const key = (x,y)=>x+","+y; + + /* register this grid as a word source for the language widgets */ + const reg = { el: root, name: T("wg_anchor"), words: [] }; + ANCHORS.push(reg); + /* mirror words that already have a real picture into a linked theme, so the + existing woordkaart-editor (letters.js) can manage/fix their pictures too - + the anchor grid stays the source of truth for which words exist */ + function syncTheme(){ + if(!S.themeId) S.themeId = "an-"+Math.random().toString(36).slice(2,10); + const withPic = reg.words.filter(([,p]) => typeof p === "string"); + const list = THEMES[LANG]; + const idx = list.findIndex(t => t.id === S.themeId); + if(!withPic.length){ + if(idx >= 0) list[idx].words = []; + return; + } + const name = "⚓ " + reg.name; + /* vaste map "⚓" zodat ankerthema's gegroepeerd staan in de editor- en + widget-dropdowns (zie de optgroup-weergave in letters.js/data.js) */ + if(idx < 0) list.push({ id: S.themeId, name, folder: "⚓", words: withPic.slice() }); + else{ list[idx].name = name; list[idx].folder = "⚓"; list[idx].words = withPic.slice(); } + } + function syncSource(quiet){ + const center = S.cells["0,0"]; + reg.name = (center && center.t) ? center.t : T("wg_anchor"); + const seen = new Set(); + reg.words = []; + Object.keys(S.cells).forEach(k=>{ + const w = (S.cells[k].t||"").trim().toLowerCase(); + if(/^[a-zà-ÿ]{2,24}$/.test(w) && !seen.has(w)){ + seen.add(w); + if(S.pics[w] === undefined) fetchPic(w); + reg.words.push([w, S.pics[w] ?? null]); + } + }); + syncTheme(); + if(!quiet) document.dispatchEvent(new CustomEvent("wordschange")); + } + + function applyPan(){ layer.style.transform = `translate(${-S.pan.x}px,${-S.pan.y}px)`; } + function render(){ + layer.innerHTML = ""; + const w = stage.offsetWidth, h = stage.offsetHeight; + if(w < 2) return; + const cx = w/2, cy = h/2; + const x0 = Math.floor((S.pan.x - cx - CW)/(CW+G)), x1 = Math.ceil((S.pan.x + w - cx + CW)/(CW+G)); + const y0 = Math.floor((S.pan.y - cy - CH)/(CH+G)), y1 = Math.ceil((S.pan.y + h - cy + CH)/(CH+G)); + for(let gx=x0; gx<=x1; gx++){ + for(let gy=y0; gy<=y1; gy++){ + const c = document.createElement("div"); + const cell = S.cells[key(gx,gy)]; + const word = cell && cell.t; + const isRoot = gx===0 && gy===0; + c.className = "an-cell" + (word ? " filled" : "") + (isRoot ? " root" : ""); + c.style.left = (cx + gx*(CW+G) - CW/2)+"px"; + c.style.top = (cy + gy*(CH+G) - CH/2)+"px"; + if(word){ + const normWord = word.trim().toLowerCase(); + const wPic = S.pics[normWord]; + const pic = document.createElement("div"); + pic.className = "an-pic"; + if(wPic){ + setPic(pic, wPic); + }else{ + /* no automatic match (or not tried yet) - stays empty, but doubles as a + shortcut to manually pick a picture instead of showing a fake anchor icon. + Geen eigen pointer/click-handlers meer: die blokkeerden het pannen en + openden de kiezer bij elke veeg die op dit vak begon (op telefoons was + dat vrijwel elke aanraking). De tik loopt nu via de stage-logica + hieronder, mét de sleep-drempel. */ + pic.classList.add("an-pic-empty"); + if(!readonly) pic.title = T("pickPic"); + } + c.appendChild(pic); + const span = document.createElement("span"); + span.className = "an-word"; + span.textContent = word; + c.appendChild(span); + /* only the theme word is styled by default; other words are plain + until the teacher gives them a colour to show a connection */ + if(!isRoot){ + if(cell.c){ + c.style.borderColor = cell.c; + c.style.background = cell.c + "1c"; + } + if(!readonly){ + const dot = document.createElement("button"); + dot.className = "an-dot"; + dot.style.background = cell.c || "#cdd7e4"; + dot.title = T("anchorColor"); + dot.addEventListener("pointerdown", ev=>ev.stopPropagation()); + dot.addEventListener("click", ev=>{ + ev.stopPropagation(); + cell.c = COLS[(COLS.indexOf(cell.c)+1) % COLS.length]; + render(); + }); + c.appendChild(dot); + } + } + } + c.dataset.gx = gx; c.dataset.gy = gy; + layer.appendChild(c); + } + } + applyPan(); + hint.textContent = T(readonly ? "anchorHintReadonly" : "anchorHint"); + } + /* pan by dragging, click (without moving) to type */ + let pd = null; + stage.addEventListener("pointerdown", e=>{ + if(e.target.tagName==="INPUT") return; + if(e.pointerType==="mouse" && e.button!==0) return; + e.preventDefault(); + stage.setPointerCapture(e.pointerId); + const sc = stage.getBoundingClientRect().width / stage.offsetWidth; + pd = { x:e.clientX/sc, y:e.clientY/sc, px:S.pan.x, py:S.pan.y, + moved:false, target:e.target.closest(".an-cell"), + pic:e.target.closest(".an-pic-empty"), sc }; + }); + stage.addEventListener("pointermove", e=>{ + if(!pd) return; + const dx = e.clientX/pd.sc - pd.x, dy = e.clientY/pd.sc - pd.y; + if(Math.abs(dx) > 6 || Math.abs(dy) > 6) pd.moved = true; + if(pd.moved){ S.pan.x = pd.px - dx; S.pan.y = pd.py - dy; applyPan(); } + }); + stage.addEventListener("pointerup", ()=>{ + if(!pd) return; + const {moved, target, pic} = pd; + pd = null; + if(moved){ render(); return; } + if(readonly || !target) return; + /* stilstaande tik op een leeg plaatjesvak: plaatje kiezen voor dat woord */ + if(pic){ + const cell = S.cells[key(+target.dataset.gx, +target.dataset.gy)]; + const normWord = cell && cell.t && cell.t.trim().toLowerCase(); + if(normWord){ + openPicPicker(normWord, chosen=>{ + S.pics[normWord] = chosen; + syncSource(); + render(); + persistWords(); + }); + return; + } + } + editCell(target); + }); + stage.addEventListener("pointercancel", ()=>{ + const moved = pd && pd.moved; + pd = null; + if(moved) render(); + }); + function editCell(cell){ + if(cell.querySelector("input")) return; + const gx = +cell.dataset.gx, gy = +cell.dataset.gy, k = key(gx,gy); + const inp = document.createElement("input"); + inp.maxLength = 24; + inp.value = S.cells[k] ? S.cells[k].t : ""; + if(gx===0 && gy===0) inp.placeholder = T("anchorPh"); + cell.textContent = ""; + cell.appendChild(inp); + setTimeout(()=>{ inp.focus(); inp.select(); }, 0); + let done = false; + const commit = ()=>{ + if(done) return; done = true; + const v = inp.value.trim(); + if(v) S.cells[k] = { t:v, c:(S.cells[k] && S.cells[k].c) || null }; + else delete S.cells[k]; + syncSource(); + render(); + persistWords(); + }; + inp.addEventListener("keydown", ev=>{ + ev.stopPropagation(); + if(ev.key==="Enter") commit(); + if(ev.key==="Escape"){ done = true; render(); } + }); + inp.addEventListener("blur", commit); + inp.addEventListener("pointerdown", ev=>ev.stopPropagation()); + } + /* een bewaard/gedeeld diagram in dít widget laden (dubbelklik in de + verkenner die via het 🗂-knopje van dit widget is geopend) */ + function loadDiagram(entry){ + S.cells = structuredClone(entry.cells || {}); + S.pics = structuredClone(entry.pics || {}); + S.pan = {x:0, y:0}; + syncSource(); + render(); + persistWords(); + /* programmatisch geladen: de bord-autosave-listeners zien dit niet */ + markBoardChange(); + return null; + } + if(!readonly){ + saveBtn.title = T("anchorLibSave"); + libBtn.title = T("anchorLibOpen"); + saveBtn.addEventListener("click", ()=>{ + if(!Object.keys(S.cells).length){ hint.textContent = T("anchorLibEmptyGrid"); return; } + ANCHOR_LIB.push({ + id: "al-"+Math.random().toString(36).slice(2,10), + name: reg.name, + cells: structuredClone(S.cells), + pics: structuredClone(S.pics), + }); + scheduleSave("anchors"); + const orig = saveBtn.textContent; + saveBtn.textContent = "✓"; + setTimeout(()=>{ saveBtn.textContent = orig; }, 1200); + }); + libBtn.addEventListener("click", ()=>openExplorer( + withSharedBranches("anchor", anchorLibraryAdapter(loadDiagram), anchorLibrarySharedHooks(loadDiagram)))); + } + + new ResizeObserver(render).observe(stage); + document.addEventListener("langchange", render); + syncSource(true); + render(); + if(reg.words.length) document.dispatchEvent(new CustomEvent("wordschange")); + return { getState: ()=>({ cells:S.cells, pan:S.pan, pics:S.pics, themeId:S.themeId }) }; +} + +/* ---- ankerbibliotheek: diagrammen los van een bord bewaren en hergebruiken. + Opslag in ANCHOR_LIB (eigen save-sectie "anchors"), beheer via de + Windows-stijl mappenverkenner. Op module-niveau zodat zowel het 🗂-knopje + op een widget (openen = in dát widget laden) als de vaste ⚓-tak in de + borden-verkenner (openen = nieuw widget op het huidige bord) dezelfde + adapter gebruiken - alleen de onOpen-actie verschilt. */ +function anchorLibraryAdapter(onOpen){ + const split = n => (n||"").split("/").map(s=>s.trim()).filter(Boolean); + const join = p => p.join("/"); + const wordCount = it => Object.keys(it.cells||{}).length; + const pending = new Set(); + const changed = ()=>scheduleSave("anchors"); + return { + title: ()=>T("anchorLibTitle"), + allowRootItems: true, + list(path){ + const p = join(path); + const folderMap = new Map(); + const items = []; + const seen = new Set(pending); + ANCHOR_LIB.forEach((it, i)=>{ + const j = join(split(it.folder)); + if(j === p) items.push({ key: i, name: `${it.name} (${wordCount(it)})`, icon: "⚓" }); + else if(!p || j.startsWith(p + "/")) seen.add(j); + }); + seen.forEach(j=>{ + if(j === p) return; + if(p && !j.startsWith(p + "/")) return; + if(!p && !j) return; + const seg = (p ? j.slice(p.length + 1) : j).split("/")[0]; + if(!seg) return; + const full = p ? p + "/" + seg : seg; + const under = ANCHOR_LIB.filter(it=>{ const ij = join(split(it.folder)); return ij === full || ij.startsWith(full + "/"); }).length; + folderMap.set(seg, under); + }); + const folders = [...folderMap].map(([name, count])=>({name, count})) + .sort((a,b)=>a.name.localeCompare(b.name)); + return { folders, items }; + }, + createFolder(path, name){ + if(this.list(path).folders.some(f=>f.name===name)) return T("exExists"); + pending.add(join([...path, name])); + return null; + }, + renameFolder(path, oldName, newName){ + return this.moveFolder([...path, oldName], path, newName); + }, + moveFolder(srcPath, targetPath, newName){ + const from = join(srcPath); + const to = join([...targetPath, newName || srcPath[srcPath.length-1]]); + if(from === to) return null; + if(ANCHOR_LIB.some(it=>{ const j = join(split(it.folder)); return j === to || j.startsWith(to + "/"); }) || pending.has(to)) return T("exExists"); + ANCHOR_LIB.forEach(it=>{ + const j = join(split(it.folder)); + if(j === from || j.startsWith(from + "/")) it.folder = to + j.slice(from.length); + }); + [...pending].forEach(j=>{ + if(j === from || j.startsWith(from + "/")){ pending.delete(j); pending.add(to + j.slice(from.length)); } + }); + changed(); + return null; + }, + deleteFolder(path, name){ + const full = join([...path, name]); + if(ANCHOR_LIB.some(it=>{ const j = join(split(it.folder)); return j === full || j.startsWith(full + "/"); })) return T("exFolderNotEmpty"); + [...pending].forEach(j=>{ if(j === full || j.startsWith(full + "/")) pending.delete(j); }); + return null; + }, + renameItem(i, name){ + if(!ANCHOR_LIB[i]) return null; + ANCHOR_LIB[i].name = name; + changed(); + return null; + }, + moveItem(i, targetPath){ + const it = ANCHOR_LIB[i]; + if(!it) return null; + if(targetPath.length) it.folder = join(targetPath); else delete it.folder; + pending.delete(join(targetPath)); + changed(); + return null; + }, + deleteItem(i){ + if(!ANCHOR_LIB[i]) return null; + ANCHOR_LIB.splice(i, 1); + changed(); + return null; + }, + openItem(i){ + const it = ANCHOR_LIB[i]; + return it ? onOpen(it) : null; + }, + }; +} +/* koppelstuk voor de gedeelde bibliotheek: diagrammen als {name, data} */ +function anchorLibrarySharedHooks(onOpen){ + const split = n => (n||"").split("/").map(s=>s.trim()).filter(Boolean); + const join = p => p.join("/"); + return { + exportItem(i){ + const it = ANCHOR_LIB[i]; + return it ? { name: it.name, data: { cells: structuredClone(it.cells||{}), pics: structuredClone(it.pics||{}) } } : null; + }, + exportFolder(path){ + const p = join(path); + const out = []; + ANCHOR_LIB.forEach(it=>{ + const j = join(split(it.folder)); + if(j === p || j.startsWith(p + "/")){ + const rel = j === p ? [] : j.slice(p.length + 1).split("/"); + out.push({ name: it.name, rel, data: { cells: structuredClone(it.cells||{}), pics: structuredClone(it.pics||{}) } }); + } + }); + return out; + }, + copyIn(item, destPath){ + const entry = { + id: "al-"+Math.random().toString(36).slice(2,10), + name: item.name, + cells: structuredClone(item.data.cells || {}), + pics: structuredClone(item.data.pics || {}), + }; + if(destPath.length) entry.folder = join(destPath); + ANCHOR_LIB.push(entry); + scheduleSave("anchors"); + return null; + }, + openShared(item){ + return onOpen({ name: item.name, cells: item.data.cells || {}, pics: item.data.pics || {} }); + }, + }; +} + diff --git a/public/js/widgets/ball.js b/public/js/widgets/ball.js new file mode 100644 index 0000000..1c9d4ae --- /dev/null +++ b/public/js/widgets/ball.js @@ -0,0 +1,131 @@ +/* widget: Ballonnenwoord */ +/* ========================================================= + Balloon word widget — guess before the balloons pop +==========================================================*/ +function mountBall(root, initState, opts){ + const onProgress = opts && opts.onProgress; + const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, snd:true, lives:6, guessed:new Set(), over:false, order:[], idx:0, stars:0, assignmentWords:null, assignmentThemeName:"", wrong:[] }; + if(initState){S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0; S.snd = initState.snd !== false;S.assignmentWords=Array.isArray(initState.assignmentWords)?initState.assignmentWords:null;S.assignmentThemeName=typeof initState.assignmentThemeName==="string"?initState.assignmentThemeName:"";} + const assignedRules=languageTaskRules(S.yearGroup,S.level),maxLives=assignedRules?.lives||6; + const play = f=>{ if(S.snd) f(); }; + + root.innerHTML = ` +
+
+ + +
+
+
+
+
+
+
+ +
+
+
`; + const el = s => root.querySelector(s); + const srcSel = el(".bl-src"), balloonsEl = el(".bl-balloons"), picEl=el(".bl-pic"), + wordEl = el(".bl-word"), msgEl = el(".bl-msg"), + nextBtn = el(".bl-next"), kbdEl = el(".bl-kbd"), + scoreEl = el(".bl-score"), sndBtn = el(".lg-snd"); + + function list(){ + if(S.assignmentWords?.length)return languageWidgetWords(S.assignmentWords,S.yearGroup,S.level,"ball"); + S.card = normWordSource(S.card);const selected=languageWidgetWords(wordSourceList(S.card),S.yearGroup,S.level,"ball"); + if(selected.length)return selected;S.card="g";return languageWidgetWords(wordSourceList("g"),S.yearGroup,S.level,"ball"); + } + function buildSrc(){if(S.assignmentWords?.length){srcSel.innerHTML="";srcSel.appendChild(new Option("🎯 "+(S.assignmentThemeName||T("castTheme")),"assignment"));srcSel.disabled=true;}else{srcSel.disabled=false;fillWordSourceSel(srcSel,S.card,S.yearGroup,S.level);S.card=srcSel.value||"g";}} + "abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{ + const b = document.createElement("button"); + b.textContent = l; b.dataset.l = l; + b.className = VOWELS.includes(l) ? "v" : "c"; + b.addEventListener("click", ()=>guess(l, b)); + kbdEl.appendChild(b); + }); + function word(){ return list()[S.order[S.idx]][0]; } + function drawWord(reveal){ + wordEl.textContent = word().split("").map(l=>(reveal || S.guessed.has(l)) ? l : "_").join(" "); + } + function drawBalloons(){ + balloonsEl.textContent = "🎈".repeat(S.lives) + "💥".repeat(maxLives-S.lives); + } + function newWord(){ + if(!S.order.length || S.idx >= S.order.length){ + S.order = shuffle(list().map((_,i)=>i)); + S.idx = 0; + } + S.lives = maxLives; S.guessed = new Set(); S.over = false; S.wrong = []; + msgEl.textContent = ""; msgEl.className = "bl-msg"; + nextBtn.classList.remove("show"); + kbdEl.querySelectorAll("button").forEach(b=>{ b.disabled = false; b.style.opacity = 1; }); + const current=list()[S.order[S.idx]];setPic(picEl,current[1]);picEl.title=current[3]||current[0]; + drawWord(false); drawBalloons(); + scoreEl.textContent = S.stars ? S.stars+" ★" : ""; + } + function guess(l, btn){ + if(S.over || btn.disabled) return; + btn.disabled = true; btn.style.opacity = .3; + if(word().includes(l)){ + S.guessed.add(l); + play(sndGood); + drawWord(false); + if(word().split("").every(x=>S.guessed.has(x))){ + S.over = true; S.stars++; + msgEl.textContent = T("ballWin"); + msgEl.classList.add("win"); + play(sndWin); confetti(el(".mt-card")); + nextBtn.classList.add("show"); + scoreEl.textContent = S.stars+" ★"; + if(onProgress) onProgress(languageProgressPayload({ + word: word(), mistakes: S.wrong.map(x=>({given:x, expected:word()})), + answer: S.wrong.join(", ") || word(), expected: word(), solved: true, stars: 1 })); + } + }else{ + S.lives--; + S.wrong.push(l); + play(sndBad); + drawBalloons(); + if(S.lives <= 0){ + S.over = true; + drawWord(true); + msgEl.textContent = `${T("ballLose")} ${word()}`; + msgEl.classList.add("lose"); + nextBtn.classList.add("show"); + /* een kapotgegokt woord leverde tot nu toe géén enkele voortgang op - + precies de opdracht die de leerkracht wél zou willen zien */ + if(onProgress) onProgress(languageProgressPayload({ + word: word(), mistakes: S.wrong.map(x=>({given:x, expected:word()})), + answer: word().split("").map(x=>S.guessed.has(x)?x:"_").join(""), + expected: word(), solved: false })); + } + } + } + nextBtn.addEventListener("click", ()=>{ + S.idx++; + if(S.idx >= S.order.length) celebrateAvatar(root); + newWord(); + }); + srcSel.addEventListener("change", ()=>{ + S.card = srcSel.value; + S.order = []; newWord(); + }); + sndBtn.addEventListener("click", ()=>{ + S.snd = !S.snd; + sndBtn.textContent = S.snd ? "🔔" : "🔕"; + sndBtn.classList.toggle("off", !S.snd); + }); + function labels(){ + sndBtn.textContent = S.snd ? "🔔" : "🔕"; + sndBtn.classList.toggle("off", !S.snd); + sndBtn.title = T("sndWidget"); + nextBtn.textContent = T("next"); + buildSrc(); + } + document.addEventListener("langchange", ()=>{ if(!S.assignmentWords)S.card=0; S.order=[]; labels(); newWord(); }); + document.addEventListener("wordschange", ()=>{ buildSrc(); S.order=[]; newWord(); }); + labels(); newWord(); + root.classList.toggle("managed-learning-level",S.managedLearningLevel); + return { getState: ()=>({card:S.card, snd:S.snd}) }; +} diff --git a/public/js/widgets/birthday.js b/public/js/widgets/birthday.js new file mode 100644 index 0000000..b833d13 --- /dev/null +++ b/public/js/widgets/birthday.js @@ -0,0 +1,74 @@ +/* widget: volledig aanpasbare verjaardagstaart */ +function mountBirthday(root, initState){ + const S = Object.assign({ + name:"", age:8, layers:2, cake:"#f2b880", icing:"#ff78a8", plate:"#57c7d4", + decoration:"sprinkles", candles:true, + }, initState || {}); + root.innerHTML = ` +
+
+ + + + + + + + + +
+
+
+
+
+
+
+
`; + const q=s=>root.querySelector(s), name=q('.bd-name'), age=q('.bd-age'), layers=q('.bd-layers'), + cakeColor=q('.bd-cake-color'), icingColor=q('.bd-icing-color'), plateColor=q('.bd-plate-color'), decoration=q('.bd-decoration'), + candles=q('.bd-candles'), cake=q('.bd-cake'), title=q('.bd-title'), badge=q('.bd-age-badge'); + name.value=S.name; age.value=S.age; layers.value=S.layers; cakeColor.value=S.cake; + icingColor.value=S.icing; plateColor.value=S.plate; decoration.value=S.decoration; candles.checked=S.candles; + function sync(){ + S.name=name.value.trim(); S.age=Math.max(1,Math.min(120,+age.value||1)); S.layers=+layers.value; + S.cake=cakeColor.value; S.icing=icingColor.value; S.plate=plateColor.value; S.decoration=decoration.value; S.candles=candles.checked; + render(); + } + function render(){ + cake.innerHTML=""; + for(let n=S.layers;n>=1;n--){ + const layer=document.createElement('div'); layer.className='bd-layer bd-layer-'+n; + layer.style.setProperty('--cake',S.cake); layer.style.setProperty('--icing',S.icing); + if(S.decoration!=="none"){ + const deco=document.createElement('div'); deco.className='bd-deco '+S.decoration; + deco.textContent=S.decoration==='fruit'?'🍓 🍒 🍓':S.decoration==='stars'?'★ ✦ ★ ✦ ★':'• • • • • •'; + layer.appendChild(deco); + } + if(n===S.layers && S.candles){ + const row=document.createElement('div'); row.className='bd-candle-row'; + const count=Math.min(S.age,20); + for(let i=0;ie.stopPropagation()); + [name,age,layers,cakeColor,icingColor,plateColor,decoration,candles].forEach(el=>el.addEventListener('input',sync)); + q('.bd-party').addEventListener('click',()=>{ confetti(q('.bd-stage')); sndWin(); }); + function labels(){ + name.placeholder=T('birthdayName'); q('.bd-age-label').textContent=T('birthdayAge'); + q('.bd-layers-label').textContent=T('birthdayLayers'); q('.bd-cake-label').textContent=T('birthdayCakeColor'); + q('.bd-icing-label').textContent=T('birthdayIcing'); q('.bd-plate-label').textContent=T('birthdayPlate'); q('.bd-candles-label').textContent=T('birthdayCandles'); + q('.bd-party').textContent=T('birthdayCelebrate'); + const keys=['birthdaySprinkles','birthdayFruit','birthdayStars','birthdayNone']; + [...decoration.options].forEach((o,i)=>o.textContent=T(keys[i])); render(); + } + document.addEventListener('langchange',labels); labels(); + return { getState:()=>({...S}) }; +} diff --git a/public/js/widgets/breathe.js b/public/js/widgets/breathe.js new file mode 100644 index 0000000..95cc8d7 --- /dev/null +++ b/public/js/widgets/breathe.js @@ -0,0 +1,81 @@ +/* widget: Ademhalingsoefening */ +/* ========================================================= + Rustgevende ademhaling met een groeiende/krimpende cirkel, drie tempo's. + De cirkelgrootte wordt via inline style/transition-duration gestuurd + (niet via CSS-klassen) zodat elke fase precies aansluit op de vorige + zonder terugspringen. +==========================================================*/ +const BREATHE_PATTERNS = { "4-4": [["in",4],["out",4]], "4-7-8": [["in",4],["hold",7],["out",8]], "4-4-4-4": [["in",4],["hold",4],["out",4],["hold",4]] }; +const BREATHE_SCALE = { in: 1.4, hold: null, out: .7 }; +function mountBreathe(root, initState){ + let pattern = (initState && BREATHE_PATTERNS[initState.pattern]) ? initState.pattern : "4-4"; + let running = false, phase = -1, phaseStart = 0, rafId = null, rounds = 0; + root.innerHTML = ` +
+

+
+
+
+ +
+ + +
+
`; + const circle = root.querySelector(".bre-circle"), word = root.querySelector(".bre-word"), + count = root.querySelector(".bre-count"), roundsEl = root.querySelector(".bre-rounds"), + patternSel = root.querySelector(".bre-pattern"), toggle = root.querySelector(".bre-toggle"); + Object.keys(BREATHE_PATTERNS).forEach(k=>patternSel.appendChild(new Option("", k))); + patternSel.value = pattern; + + function updateRounds(){ roundsEl.textContent = T("breRounds").replace("{n}", rounds); } + function enterPhase(index){ + const [name, dur] = BREATHE_PATTERNS[pattern][index]; + circle.style.transitionDuration = dur + "s"; + circle.classList.toggle("in", name === "in"); + circle.classList.toggle("out", name === "out"); + if(BREATHE_SCALE[name] !== null) circle.style.transform = "scale(" + BREATHE_SCALE[name] + ")"; + word.textContent = T("bre_" + name); + } + function step(ts){ + if(!running || !document.body.contains(root)) return; + const steps = BREATHE_PATTERNS[pattern]; + if(phase < 0){ phase = 0; phaseStart = ts; enterPhase(phase); } + const [, dur] = steps[phase]; + const elapsed = (ts - phaseStart) / 1000; + count.textContent = Math.max(0, Math.ceil(dur - elapsed)) || ""; + if(elapsed >= dur){ + phase = (phase + 1) % steps.length; + phaseStart = ts; + if(phase === 0){ rounds++; updateRounds(); } + enterPhase(phase); + } + rafId = requestAnimationFrame(step); + } + function start(){ + running = true; phase = -1; rounds = 0; + updateRounds(); + labels(); + rafId = requestAnimationFrame(step); + } + function stop(){ + running = false; + if(rafId) cancelAnimationFrame(rafId); + rafId = null; + circle.classList.remove("in","out"); + circle.style.transform = ""; circle.style.transitionDuration = ""; + word.textContent = ""; count.textContent = ""; + labels(); + } + toggle.addEventListener("click", ()=>{ if(running) stop(); else start(); }); + patternSel.addEventListener("change", ()=>{ pattern = patternSel.value; if(running) stop(); }); + function labels(){ + root.querySelector(".bre-mission").textContent = T("wg_breathe_d"); + toggle.textContent = running ? "⏸ " + T("breStop") : "🌬️ " + T("breStart"); + [...patternSel.options].forEach(o=>{ o.textContent = T("brePattern_" + o.value); }); + updateRounds(); + } + document.addEventListener("langchange", labels); + labels(); + return { getState: ()=>({ pattern }) }; +} diff --git a/public/js/widgets/coloring-scenes-extra.js b/public/js/widgets/coloring-scenes-extra.js new file mode 100644 index 0000000..fa95541 --- /dev/null +++ b/public/js/widgets/coloring-scenes-extra.js @@ -0,0 +1,338 @@ +/* teach - aanvullende, herkenbare SVG-kleurplaten */ +"use strict"; + +function sceneFarmyard(){return coloringScene( + coloringPart("rect","farm-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","farm-grass",'d="M 4 410 Q 180 370 365 420 T 796 395 L 796 596 L 4 596 Z"')+ + coloringPart("rect","barn",'x="65" y="245" width="250" height="235" rx="8"')+ + coloringPart("path","barn-roof",'d="M 35 245 L 190 105 L 345 245 Z"')+ + coloringPart("circle","barn-window",'cx="190" cy="210" r="42"',2)+ + coloringPart("rect","barn-door-left",'x="115" y="330" width="75" height="150"')+ + coloringPart("rect","barn-door-right",'x="190" y="330" width="75" height="150"')+ + coloringPart("ellipse","cow-body",'cx="535" cy="390" rx="155" ry="95"')+ + coloringPart("path","cow-head",'d="M 620 315 Q 720 300 738 380 Q 710 460 620 425 Z"')+ + coloringPart("ellipse","cow-snout",'cx="700" cy="400" rx="58" ry="35"',2)+ + coloringPart("path","cow-ear-left",'d="M 645 330 Q 600 285 580 330 Q 610 360 650 355 Z"',2)+ + coloringPart("path","cow-ear-right",'d="M 710 325 Q 750 280 775 325 Q 755 360 715 355 Z"',2)+ + [420,500,575,640].map((x,i)=>coloringPart("rect",`cow-leg-${i}`,`x="${x}" y="445" width="35" height="105" rx="14"`)).join("")+ + [[480,365,42,28],[555,420,35,25],[590,350,30,22]].map(([x,y,rx,ry],i)=>coloringPart("ellipse",`cow-spot-${i}`,`cx="${x}" cy="${y}" rx="${rx}" ry="${ry}"`,2)).join("")+ + coloringPart("circle","farm-sun",'cx="690" cy="95" r="54"')+ + coloringLine("path",'d="M 115 330 L 265 480 M 265 330 L 115 480 M 148 210 H 232 M 190 168 V 252" fill="none"')+ + coloringLine("circle",'cx="675" cy="372" r="6" fill="#26344a" stroke="none"')+coloringLine("circle",'cx="720" cy="372" r="6" fill="#26344a" stroke="none"')+ + coloringLine("path",'d="M 688 405 q 12 12 24 0 M 650 425 Q 695 455 735 425" fill="none"',2)+ + coloringLine("path",'d="M 420 550 h 35 M 500 550 h 35 M 575 550 h 35 M 640 550 h 35" fill="none"',2)+ + coloringLine("path",'d="M 355 470 H 390 M 360 440 H 400 M 370 410 H 405" fill="none"',3) +)} + +function sceneRainbowWeather(){return coloringScene( + coloringPart("rect","weather-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","weather-hill",'d="M 4 425 Q 180 370 355 430 T 796 400 L 796 596 L 4 596 Z"')+ + coloringPart("path","cloud-left",'d="M 65 160 Q 75 100 135 115 Q 165 55 225 95 Q 285 80 300 145 Q 285 190 225 180 L 110 180 Q 70 185 65 160 Z"')+ + coloringPart("path","cloud-right",'d="M 535 155 Q 550 95 605 115 Q 640 65 690 105 Q 750 95 765 155 Q 745 195 690 180 L 575 180 Q 540 190 535 155 Z"')+ + coloringPart("path","rainbow-red",'d="M 120 410 Q 400 20 680 410 L 635 410 Q 400 90 165 410 Z"')+ + coloringPart("path","rainbow-orange",'d="M 165 410 Q 400 90 635 410 L 595 410 Q 400 150 205 410 Z"')+ + coloringPart("path","rainbow-yellow",'d="M 205 410 Q 400 150 595 410 L 555 410 Q 400 205 245 410 Z"',2)+ + coloringPart("path","rainbow-green",'d="M 245 410 Q 400 205 555 410 L 515 410 Q 400 260 285 410 Z"',2)+ + coloringPart("path","rainbow-blue",'d="M 285 410 Q 400 260 515 410 L 470 410 Q 400 320 330 410 Z"',3)+ + coloringPart("path","umbrella-left",'d="M 455 405 Q 530 300 605 405 Z"')+ + coloringPart("path","umbrella-middle",'d="M 530 405 Q 605 300 605 405 Z"',2)+ + coloringPart("path","umbrella-right",'d="M 605 405 Q 680 300 755 405 Z"',2)+ + coloringPart("path","boot-left",'d="M 520 470 V 545 H 610 Q 620 505 575 505 V 470 Z"')+ + coloringPart("path","boot-right",'d="M 635 465 V 540 H 725 Q 735 500 690 500 V 465 Z"')+ + [[105,245],[155,275],[225,230],[590,230],[670,255],[735,225]].map(([x,y],i)=>coloringPart("path",`raindrop-${i}`,`d="M ${x} ${y-20} Q ${x-22} ${y+12} ${x} ${y+26} Q ${x+22} ${y+12} ${x} ${y-20} Z"`,i>2?3:2)).join("")+ + coloringLine("path",'d="M 605 405 V 505 Q 605 535 580 520" fill="none"')+ + coloringLine("path",'d="M 545 405 Q 565 385 585 405 M 625 405 Q 650 385 675 405" fill="none"',2)+ + coloringLine("path",'d="M 535 470 H 575 M 650 465 H 690" fill="none"',2)+ + coloringLine("path",'d="M 92 145 Q 125 130 155 145 M 650 145 Q 680 125 715 145" fill="none"',3) +)} + +function sceneConstruction(){return coloringScene( + coloringPart("rect","build-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","build-ground",'d="M 4 420 L 796 420 L 796 596 L 4 596 Z"')+ + coloringPart("rect","digger-track",'x="150" y="450" width="360" height="90" rx="45"')+ + coloringPart("rect","digger-track-inner",'x="195" y="475" width="270" height="40" rx="20"',2)+ + coloringPart("path","digger-body",'d="M 205 450 L 240 325 H 475 L 525 450 Z"')+ + coloringPart("path","digger-cab",'d="M 290 325 L 330 220 H 440 L 475 325 Z"')+ + coloringPart("path","digger-window",'d="M 325 305 L 350 240 H 420 L 445 305 Z"',2)+ + coloringPart("path","arm-upper",'d="M 455 335 L 555 175 L 600 195 L 510 365 Z"')+ + coloringPart("path","arm-lower",'d="M 555 175 L 700 280 L 675 320 L 575 225 Z"',2)+ + coloringPart("path","bucket",'d="M 665 300 Q 755 295 775 385 Q 720 435 650 390 Z"')+ + coloringPart("circle","digger-pivot",'cx="485" cy="350" r="28"',2)+ + coloringPart("path","cone-left",'d="M 65 520 L 110 405 L 155 520 Z"')+ + coloringPart("rect","cone-left-base",'x="45" y="515" width="130" height="30" rx="8"',2)+ + coloringPart("path","cone-right",'d="M 590 535 L 625 445 L 660 535 Z"',2)+ + coloringPart("rect","cone-right-base",'x="570" y="530" width="110" height="25" rx="8"',3)+ + coloringPart("circle","build-sun",'cx="120" cy="100" r="55"')+ + coloringLine("path",'d="M 220 495 H 440 M 250 470 V 520 M 310 470 V 520 M 370 470 V 520 M 430 470 V 520" fill="none"')+ + coloringLine("path",'d="M 360 240 V 305 M 325 273 H 445" fill="none"',2)+ + coloringLine("path",'d="M 575 205 L 675 295 M 495 330 L 570 205" fill="none"',2)+ + coloringLine("path",'d="M 80 480 H 140 M 600 500 H 650" fill="none"',3)+ + coloringLine("path",'d="M 25 395 H 170 M 650 395 H 785" fill="none"',3) +)} + +function sceneForestPicnic(){return coloringScene( + coloringPart("rect","picnic-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","picnic-grass",'d="M 4 390 Q 190 350 380 410 T 796 375 L 796 596 L 4 596 Z"')+ + coloringPart("path","picnic-trunk",'d="M 85 460 Q 125 285 110 90 H 225 Q 200 290 245 460 Z"')+ + coloringPart("circle","picnic-crown-a",'cx="120" cy="110" r="95"')+coloringPart("circle","picnic-crown-b",'cx="225" cy="135" r="105"')+ + coloringPart("path","blanket",'d="M 250 455 L 610 420 L 720 570 L 315 580 Z"')+ + coloringPart("path","blanket-panel-a",'d="M 250 455 L 430 438 L 480 505 L 285 520 Z"',2)+ + coloringPart("path","blanket-panel-b",'d="M 430 438 L 610 420 L 665 490 L 480 505 Z"',2)+ + coloringPart("path","basket",'d="M 330 390 Q 405 365 475 390 L 455 490 H 350 Z"')+ + coloringPart("path","basket-handle",'d="M 350 390 Q 400 290 455 390 L 430 390 Q 400 335 375 390 Z"',2)+ + coloringPart("circle","apple",'cx="520" cy="455" r="34"')+ + coloringPart("path","sandwich",'d="M 560 520 L 650 455 L 705 535 Z"',2)+ + coloringPart("ellipse","rabbit-body",'cx="655" cy="320" rx="80" ry="65"')+ + coloringPart("circle","rabbit-head",'cx="690" cy="245" r="58"')+ + coloringPart("ellipse","rabbit-ear-a",'cx="665" cy="160" rx="25" ry="75" transform="rotate(-15 665 160)"')+ + coloringPart("ellipse","rabbit-ear-b",'cx="720" cy="160" rx="25" ry="75" transform="rotate(15 720 160)"')+ + coloringPart("circle","rabbit-tail",'cx="575" cy="315" r="38"',2)+ + coloringLine("path",'d="M 135 150 Q 170 190 205 150 M 150 235 Q 180 265 210 235" fill="none"',2)+ + coloringLine("path",'d="M 365 410 H 450 M 380 440 H 445 M 395 470 H 440" fill="none"',3)+ + coloringLine("circle",'cx="705" cy="235" r="6" fill="#26344a" stroke="none"')+ + coloringLine("path",'d="M 715 260 q 15 12 28 0 M 690 265 l -45 5 M 690 275 l -45 20 M 735 270 l 45 -5 M 735 280 l 42 20" fill="none"',2)+ + coloringLine("path",'d="M 520 420 q 8 -28 22 -35 M 585 495 H 670" fill="none"',3) +)} + +function sceneDinosaurValley(){return coloringScene( + coloringPart("rect","dino-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","dino-ground",'d="M 4 430 Q 185 370 360 435 T 796 405 L 796 596 L 4 596 Z"')+ + coloringPart("path","volcano",'d="M 35 430 L 175 145 L 320 430 Z"')+ + coloringPart("path","lava",'d="M 135 225 L 175 145 L 220 235 L 205 320 L 170 275 L 145 335 Z"',2)+ + coloringPart("circle","smoke-a",'cx="145" cy="105" r="45"',2)+coloringPart("circle","smoke-b",'cx="205" cy="75" r="55"',3)+ + coloringPart("ellipse","dino-body",'cx="500" cy="380" rx="170" ry="95"')+ + coloringPart("path","dino-neck",'d="M 575 345 Q 610 170 690 155 Q 750 160 742 225 Q 690 255 660 235 Q 650 330 625 405 Z"')+ + coloringPart("ellipse","dino-head",'cx="705" cy="190" rx="72" ry="52"')+ + coloringPart("path","dino-tail",'d="M 350 360 Q 225 300 250 390 Q 290 445 370 425 Z"')+ + [390,475,555,615].map((x,i)=>coloringPart("path",`dino-leg-${i}`,`d="M ${x} 425 L ${x-10} 545 H ${x+55} L ${x+35} 430 Z"`,i>1?2:1)).join("")+ + [[385,320],[440,290],[500,280],[555,295]].map(([x,y],i)=>coloringPart("path",`dino-plate-${i}`,`d="M ${x-25} ${y+20} L ${x} ${y-35} L ${x+25} ${y+20} Z"`,i>1?2:1)).join("")+ + coloringPart("circle","dino-sun",'cx="690" cy="85" r="42"')+ + coloringLine("circle",'cx="725" cy="180" r="6" fill="#26344a" stroke="none"')+ + coloringLine("path",'d="M 715 210 Q 735 225 755 208" fill="none"')+ + coloringLine("path",'d="M 405 400 Q 500 440 590 395 M 435 345 q 20 20 40 0 M 520 335 q 20 20 40 0" fill="none"',2)+ + coloringLine("path",'d="M 380 545 h 60 M 465 545 h 60 M 545 545 h 60 M 605 545 h 60" fill="none"',2)+ + coloringLine("path",'d="M 90 450 q 25 -55 50 0 M 250 475 q 25 -65 50 0 M 720 455 q 25 -60 50 0" fill="none"',3) +)} + +function sceneSpringNest(){return coloringScene( + coloringPart("rect","nest-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","nest-hill",'d="M 4 465 Q 180 420 380 470 T 796 445 L 796 596 L 4 596 Z"')+ + coloringPart("path","nest-branch",'d="M 45 410 Q 250 340 480 390 Q 620 420 755 345 L 770 405 Q 600 475 455 445 Q 220 405 50 475 Z"')+ + coloringPart("path","nest-bowl",'d="M 250 350 Q 395 305 535 350 Q 500 470 390 475 Q 285 460 250 350 Z"')+ + [315,390,465].map((x,i)=>coloringPart("ellipse",`nest-egg-${i}`,`cx="${x}" cy="350" rx="38" ry="55"`,i===2?2:1)).join("")+ + coloringPart("ellipse","bird-body",'cx="590" cy="255" rx="105" ry="85"')+ + coloringPart("circle","bird-head",'cx="655" cy="185" r="63"')+ + coloringPart("path","bird-wing",'d="M 520 245 Q 595 185 625 300 Q 560 345 520 245 Z"',2)+ + coloringPart("path","bird-beak",'d="M 710 180 L 780 205 L 710 225 Z"')+ + coloringPart("path","bird-tail-a",'d="M 505 285 L 405 330 L 525 330 Z"',2)+coloringPart("path","bird-tail-b",'d="M 520 305 L 445 375 L 555 335 Z"',3)+ + [[125,300,-20],[175,340,25],[665,390,-25],[705,350,20]].map(([x,y,a],i)=>coloringPart("path",`spring-leaf-${i}`,`d="M ${x-35} ${y} Q ${x} ${y-45} ${x+35} ${y} Q ${x} ${y+45} ${x-35} ${y} Z" transform="rotate(${a} ${x} ${y})"`,i>1?2:1)).join("")+ + coloringPetals(135,165,"spring-nest-flower",3)+ + coloringLine("circle",'cx="670" cy="175" r="6" fill="#26344a" stroke="none"')+ + coloringLine("path",'d="M 645 205 Q 665 220 685 205" fill="none"',2)+ + coloringLine("path",'d="M 535 250 Q 575 270 610 245 M 530 275 Q 575 300 615 275" fill="none"',2)+ + coloringLine("path",'d="M 275 370 Q 390 330 515 370 M 285 400 Q 390 360 505 400 M 310 430 Q 390 395 480 430" fill="none"',3)+ + coloringLine("path",'d="M 125 300 L 80 270 M 175 340 L 215 300 M 665 390 L 620 360 M 705 350 L 750 315" fill="none"',3) +)} + +function sceneFireStation(){return coloringScene( + coloringPart("rect","fire-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","fire-road",'d="M 4 455 H 796 V 596 H 4 Z"')+ + coloringPart("rect","station",'x="45" y="170" width="330" height="285" rx="8"')+ + coloringPart("path","station-roof",'d="M 20 170 L 210 65 L 400 170 Z"')+ + coloringPart("circle","station-badge",'cx="210" cy="145" r="42"',2)+ + coloringPart("rect","station-door-a",'x="80" y="255" width="115" height="200" rx="8"')+ + coloringPart("rect","station-door-b",'x="225" y="255" width="115" height="200" rx="8"')+ + coloringPart("path","engine-body",'d="M 360 350 H 640 L 730 425 V 500 H 355 Z"')+ + coloringPart("path","engine-cab",'d="M 580 350 H 665 L 730 425 H 580 Z"')+ + coloringPart("path","engine-window",'d="M 610 370 H 655 L 695 415 H 610 Z"',2)+ + coloringPart("rect","engine-panel",'x="385" y="375" width="165" height="90" rx="12"',2)+ + coloringPart("circle","engine-wheel-a",'cx="445" cy="500" r="55"')+coloringPart("circle","engine-wheel-b",'cx="665" cy="500" r="55"')+ + coloringPart("circle","engine-hub-a",'cx="445" cy="500" r="22"',2)+coloringPart("circle","engine-hub-b",'cx="665" cy="500" r="22"',2)+ + coloringPart("rect","engine-light",'x="470" y="315" width="55" height="35" rx="16"',2)+ + coloringPart("rect","hydrant",'x="755" y="420" width="30" height="90" rx="10"',3)+ + coloringLine("path",'d="M 100 300 H 175 M 100 350 H 175 M 100 400 H 175 M 245 300 H 320 M 245 350 H 320 M 245 400 H 320" fill="none"')+ + coloringLine("path",'d="M 390 355 L 555 300 M 400 375 L 565 320 M 430 345 L 445 375 M 475 330 L 490 360 M 520 315 L 535 345" fill="none"',2)+ + coloringLine("path",'d="M 420 400 H 520 M 420 430 H 520 M 470 375 V 465" fill="none"',3)+ + coloringLine("path",'d="M 210 112 V 178 M 177 145 H 243" fill="none"',2)+ + coloringLine("path",'d="M 25 545 H 180 M 280 545 H 360 M 735 545 H 790" fill="none"',3) +)} + +function sceneBeeMeadow(){return coloringScene( + coloringPart("rect","bee-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","bee-grass",'d="M 4 420 Q 180 385 355 430 T 796 405 L 796 596 L 4 596 Z"')+ + coloringPart("ellipse","bee-body",'cx="430" cy="250" rx="135" ry="82"')+ + coloringPart("circle","bee-head",'cx="560" cy="235" r="68"')+ + coloringPart("ellipse","bee-wing-a",'cx="385" cy="150" rx="75" ry="45" transform="rotate(-25 385 150)"')+ + coloringPart("ellipse","bee-wing-b",'cx="475" cy="145" rx="75" ry="45" transform="rotate(25 475 145)"')+ + coloringPart("path","bee-stripe-a",'d="M 350 188 Q 395 165 415 180 L 405 320 Q 365 315 335 285 Z"',2)+ + coloringPart("path","bee-stripe-b",'d="M 445 170 Q 485 175 510 195 L 490 315 Q 455 328 430 320 Z"',2)+ + coloringPart("path","hive",'d="M 610 385 Q 685 300 760 385 L 745 535 H 625 Z"')+ + coloringPart("ellipse","hive-door",'cx="685" cy="475" rx="38" ry="52"',2)+ + [[115,430],[220,460],[330,440]].map(([x,y],i)=>coloringPetals(x,y,`bee-flower-${i}`,i===2?3:2)).join("")+ + [[620,400],[685,385],[745,410]].map(([x,y],i)=>coloringPart("path",`honey-cell-${i}`,`d="M ${x-22} ${y} L ${x-11} ${y-20} H ${x+11} L ${x+22} ${y} L ${x+11} ${y+20} H ${x-11} Z"`,3)).join("")+ + coloringLine("circle",'cx="580" cy="220" r="6" fill="#26344a" stroke="none"')+ + coloringLine("path",'d="M 575 255 Q 595 270 615 250 M 540 180 Q 520 135 495 125 M 580 175 Q 600 130 625 125" fill="none"')+ + coloringLine("path",'d="M 380 320 L 350 365 M 425 325 L 415 375 M 470 315 L 490 360" fill="none"',2)+ + coloringLine("path",'d="M 625 420 H 745 M 620 455 H 750 M 620 495 H 750" fill="none"',2)+ + coloringLine("path",'d="M 115 455 V 545 M 220 485 V 550 M 330 465 V 545" fill="none"',3) +)} + +function sceneCoralReef(){return coloringScene( + coloringPart("rect","reef-water",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","reef-sand",'d="M 4 475 Q 175 425 345 490 T 796 455 L 796 596 L 4 596 Z"')+ + coloringPart("ellipse","clownfish-body",'cx="355" cy="260" rx="155" ry="82"')+ + coloringPart("path","clownfish-tail",'d="M 205 260 L 105 180 Q 130 260 105 340 Z"')+ + coloringPart("path","clownfish-stripe-a",'d="M 290 188 Q 330 178 355 185 L 350 338 Q 315 345 285 328 Q 320 260 290 188 Z"',2)+ + coloringPart("path","clownfish-stripe-b",'d="M 405 190 Q 445 205 470 235 L 460 300 Q 435 325 400 333 Q 430 260 405 190 Z"',2)+ + coloringPart("circle","clownfish-eye",'cx="445" cy="235" r="16"')+ + coloringPart("path","seahorse-body",'d="M 610 220 Q 680 165 710 225 Q 660 270 675 345 Q 700 430 625 450 Q 570 440 600 390 Q 650 405 635 350 Q 590 285 610 220 Z"')+ + coloringPart("path","seahorse-fin",'d="M 610 285 Q 540 300 585 360 Q 620 345 625 315 Z"',2)+ + coloringPart("circle","seahorse-eye",'cx="670" cy="220" r="13"',2)+ + coloringPart("path","coral-red",'d="M 120 520 Q 80 455 115 390 Q 145 430 145 470 Q 180 420 205 445 Q 185 490 180 530 Z"')+ + coloringPart("path","coral-fan",'d="M 470 535 Q 445 455 480 405 Q 500 455 510 490 Q 530 425 560 420 Q 555 490 540 540 Z"',2)+ + coloringPart("path","shell",'d="M 260 525 Q 320 445 380 525 Q 320 570 260 525 Z"',3)+ + [[80,95,14],[125,130,9],[575,95,18],[730,115,12],[690,70,8]].map(([x,y,r],i)=>coloringPart("circle",`reef-bubble-${i}`,`cx="${x}" cy="${y}" r="${r}"`,i>1?3:2)).join("")+ + coloringLine("path",'d="M 455 270 Q 480 285 500 268 M 330 195 L 320 330 M 420 205 L 410 320" fill="none"')+ + coloringLine("circle",'cx="447" cy="232" r="5" fill="#26344a" stroke="none"')+coloringLine("circle",'cx="672" cy="217" r="4" fill="#26344a" stroke="none"')+ + coloringLine("path",'d="M 620 400 Q 655 430 680 395 M 635 445 Q 680 465 700 430" fill="none"',2)+ + coloringLine("path",'d="M 285 520 Q 320 490 355 520 M 300 535 Q 320 510 340 535" fill="none"',3)+ + coloringLine("path",'d="M 35 555 Q 110 530 185 555 M 590 550 Q 675 520 760 550" fill="none"',3) +)} + +function sceneMountainCamp(){return coloringScene( + coloringPart("rect","camp-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","mountain-back",'d="M 5 400 L 210 115 L 400 400 Z"')+ + coloringPart("path","mountain-front",'d="M 245 420 L 520 75 L 795 420 Z"')+ + coloringPart("path","snow-back",'d="M 150 200 L 210 115 L 275 215 L 240 195 L 215 225 L 190 195 Z"',2)+ + coloringPart("path","snow-front",'d="M 430 190 L 520 75 L 615 195 L 565 175 L 525 220 L 485 175 Z"',2)+ + coloringPart("path","camp-ground",'d="M 4 395 Q 190 360 370 420 T 796 390 L 796 596 L 4 596 Z"')+ + coloringPart("path","tent-left",'d="M 105 520 L 270 310 L 365 520 Z"')+ + coloringPart("path","tent-right",'d="M 270 310 L 520 520 H 365 Z"',2)+ + coloringPart("path","tent-door",'d="M 270 355 L 340 520 H 200 Z"',2)+ + coloringPart("path","fire-outer",'d="M 545 515 Q 490 450 555 390 Q 545 455 600 425 Q 660 475 610 525 Z"')+ + coloringPart("path","fire-inner",'d="M 555 505 Q 535 465 575 440 Q 570 475 605 465 Q 625 500 590 520 Z"',2)+ + coloringPart("rect","log-a",'x="500" y="520" width="135" height="28" rx="14" transform="rotate(12 568 534)"',2)+ + coloringPart("rect","log-b",'x="510" y="520" width="135" height="28" rx="14" transform="rotate(-12 578 534)"',2)+ + coloringPart("path","pine-crown-a",'d="M 690 225 L 620 390 H 760 Z"')+ + coloringPart("path","pine-crown-b",'d="M 690 300 L 595 485 H 785 Z"',2)+ + coloringPart("rect","pine-trunk",'x="670" y="455" width="40" height="90"',3)+ + coloringPart("circle","camp-moon",'cx="690" cy="95" r="48"')+ + coloringLine("path",'d="M 270 310 V 535 M 105 520 H 520" fill="none"')+ + coloringLine("path",'d="M 220 430 H 315 M 245 480 H 335" fill="none"',2)+ + coloringLine("path",'d="M 520 534 H 635 M 555 390 Q 585 430 600 425" fill="none"',2)+ + coloringLine("path",'d="M 620 390 H 760 M 610 435 H 770" fill="none"',3)+ + coloringLine("path",'d="M 45 560 Q 110 535 175 560 M 355 565 Q 420 535 485 565" fill="none"',3) +)} + +function sceneRobotWorkshop(){return coloringScene( + coloringPart("rect","robot-wall",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","robot-floor",'d="M 4 450 H 796 V 596 H 4 Z"')+ + coloringPart("rect","robot-head",'x="285" y="95" width="230" height="145" rx="28"')+ + coloringPart("rect","robot-face",'x="320" y="125" width="160" height="80" rx="18"',2)+ + coloringPart("circle","robot-eye-a",'cx="360" cy="165" r="20"')+coloringPart("circle","robot-eye-b",'cx="440" cy="165" r="20"')+ + coloringPart("rect","robot-body",'x="250" y="250" width="300" height="210" rx="25"')+ + coloringPart("rect","robot-screen",'x="305" y="285" width="190" height="90" rx="15"',2)+ + coloringPart("circle","robot-button-a",'cx="335" cy="415" r="18"',2)+coloringPart("circle","robot-button-b",'cx="400" cy="415" r="18"',2)+coloringPart("circle","robot-button-c",'cx="465" cy="415" r="18"',3)+ + coloringPart("path","robot-arm-a",'d="M 250 285 L 140 330 L 165 395 L 265 360 Z"')+ + coloringPart("path","robot-arm-b",'d="M 550 285 L 660 330 L 635 395 L 535 360 Z"')+ + coloringPart("circle","robot-hand-a",'cx="135" cy="390" r="42"',2)+coloringPart("circle","robot-hand-b",'cx="665" cy="390" r="42"',2)+ + coloringPart("rect","robot-leg-a",'x="285" y="460" width="85" height="95" rx="20"')+coloringPart("rect","robot-leg-b",'x="430" y="460" width="85" height="95" rx="20"')+ + coloringPart("path","gear-a",'d="M 105 130 l 18 -28 l 28 18 l 30 -12 l 12 32 l 35 5 l -4 35 l 25 24 l -22 28 l 8 34 l -34 10 l -16 31 l -30 -18 l -33 12 l -11 -33 l -35 -6 l 5 -34 l -25 -25 l 22 -27 l -8 -35 l 34 -9 Z"',3)+ + coloringPart("circle","gear-a-hole",'cx="145" cy="205" r="35"',3)+ + coloringLine("path",'d="M 400 95 V 45 M 375 45 H 425" fill="none"')+ + coloringLine("path",'d="M 355 165 H 365 M 435 165 H 445 M 365 205 Q 400 225 435 205" fill="none"')+ + coloringLine("path",'d="M 330 325 L 365 350 L 400 310 L 435 340 L 470 300" fill="none"',2)+ + coloringLine("path",'d="M 275 275 H 525 M 275 440 H 525 M 328 460 V 555 M 472 460 V 555" fill="none"',2)+ + coloringLine("path",'d="M 20 500 H 160 M 640 500 H 780 M 75 550 H 220 M 580 550 H 725" fill="none"',3) +)} + +function sceneCanalStreet(){return coloringScene( + coloringPart("rect","canal-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("rect","house-a",'x="55" y="190" width="175" height="260"')+ + coloringPart("path","house-a-roof",'d="M 45 190 L 143 95 L 240 190 Z"')+ + coloringPart("rect","house-b",'x="230" y="145" width="180" height="305"')+ + coloringPart("path","house-b-roof",'d="M 230 145 Q 320 45 410 145 Z"')+ + coloringPart("rect","house-c",'x="410" y="180" width="175" height="270"')+ + coloringPart("path","house-c-roof",'d="M 400 180 L 585 180 L 550 105 L 435 105 Z"')+ + [[95,235],[175,235],[95,320],[175,320],[270,210],[350,210],[270,300],[350,300],[450,230],[530,230],[450,320],[530,320]].map(([x,y],i)=>coloringPart("rect",`canal-window-${i}`,`x="${x-25}" y="${y-30}" width="50" height="60" rx="5"`,i>7?3:i>3?2:1)).join("")+ + coloringPart("rect","canal-door-a",'x="115" y="365" width="58" height="85" rx="12"')+ + coloringPart("rect","canal-door-b",'x="290" y="355" width="58" height="95" rx="12"',2)+ + coloringPart("rect","canal-door-c",'x="470" y="360" width="58" height="90" rx="12"',2)+ + coloringPart("path","canal-water",'d="M 4 455 Q 190 425 385 465 T 796 445 L 796 596 L 4 596 Z"')+ + coloringPart("path","canal-bridge",'d="M 555 455 Q 665 325 795 455 L 795 505 Q 665 410 555 505 Z"',2)+ + coloringPart("path","canal-boat",'d="M 250 525 H 470 Q 440 575 290 570 Z"',3)+ + coloringPart("rect","boat-cabin",'x="325" y="470" width="95" height="55" rx="8"',3)+ + coloringLine("path",'d="M 95 205 V 265 M 175 205 V 265 M 70 235 H 200 M 270 180 V 240 M 350 180 V 240 M 245 210 H 375" fill="none"')+ + coloringLine("path",'d="M 450 200 V 260 M 530 200 V 260 M 425 230 H 555 M 450 290 V 350 M 530 290 V 350 M 425 320 H 555" fill="none"',2)+ + coloringLine("path",'d="M 590 455 Q 665 370 760 455 M 625 455 Q 665 415 715 455" fill="none"',2)+ + coloringLine("path",'d="M 35 535 Q 120 510 205 535 M 500 555 Q 610 520 750 550" fill="none"',3)+ + coloringLine("path",'d="M 350 470 V 445 M 330 445 H 430 M 290 550 H 440" fill="none"',3) +)} + +function sceneSolarObservatory(){return coloringScene( + coloringPart("rect","observatory-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","observatory-hill",'d="M 4 420 Q 185 350 360 425 T 796 385 L 796 596 L 4 596 Z"')+ + coloringPart("path","observatory-dome",'d="M 85 400 Q 95 170 310 170 Q 520 180 535 400 Z"')+ + coloringPart("rect","observatory-base",'x="105" y="390" width="410" height="145" rx="12"')+ + coloringPart("path","dome-slit",'d="M 275 175 Q 330 175 365 205 L 335 390 H 245 Z"',2)+ + coloringPart("path","telescope-tube",'d="M 300 275 L 575 125 L 620 190 L 350 340 Z"')+ + coloringPart("ellipse","telescope-lens",'cx="600" cy="158" rx="35" ry="55" transform="rotate(-30 600 158)"',2)+ + coloringPart("path","telescope-mount",'d="M 325 325 L 395 505 H 290 Z"',2)+ + coloringPart("circle","planet-ringed",'cx="675" cy="305" r="48"')+ + coloringPart("path","planet-ring",'d="M 600 295 Q 675 345 750 295 Q 680 380 600 295 Z"',2)+ + coloringPart("circle","planet-small",'cx="110" cy="100" r="38"',2)+ + coloringPart("circle","moon-small",'cx="175" cy="90" r="18"',3)+ + coloringStars([[520,70,18],[710,75,14],[65,245,15],[750,205,12],[420,115,11]],"observatory-star",3)+ + coloringPart("rect","control-panel",'x="570" y="425" width="155" height="95" rx="12"',3)+ + coloringLine("path",'d="M 105 390 H 515 M 310 170 V 390" fill="none"')+ + coloringLine("path",'d="M 335 340 L 575 205 M 360 325 L 600 190" fill="none"',2)+ + coloringLine("path",'d="M 290 505 H 395 M 325 325 V 505" fill="none"',2)+ + coloringLine("path",'d="M 595 450 H 700 M 595 475 H 650 M 670 475 H 700 M 595 500 H 625" fill="none"',3)+ + coloringLine("path",'d="M 505 70 L 535 70 M 520 55 V 85 M 695 75 H 725 M 710 60 V 90" fill="none"',3) +)} + +function sceneSavannaWeb(){return coloringScene( + coloringPart("rect","savanna-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","savanna-ground",'d="M 4 415 Q 180 380 360 425 T 796 400 L 796 596 L 4 596 Z"')+ + coloringPart("path","acacia-trunk",'d="M 95 450 Q 145 300 135 185 H 205 Q 190 310 235 450 Z"')+ + coloringPart("path","acacia-crown",'d="M 35 210 Q 65 95 165 125 Q 250 70 320 155 Q 365 235 270 260 H 90 Q 30 260 35 210 Z"')+ + coloringPart("ellipse","lion-body",'cx="485" cy="390" rx="145" ry="80"')+ + coloringPart("circle","lion-mane",'cx="625" cy="325" r="105"')+ + coloringPart("circle","lion-face",'cx="625" cy="325" r="68"',2)+ + coloringPart("ellipse","lion-muzzle",'cx="625" cy="350" rx="45" ry="30"',2)+ + [390,470,540,590].map((x,i)=>coloringPart("rect",`lion-leg-${i}`,`x="${x}" y="435" width="38" height="110" rx="15"`,i>1?2:1)).join("")+ + coloringPart("path","lion-tail",'d="M 355 385 Q 285 350 300 475 Q 325 510 350 475 Q 325 410 370 410 Z"',2)+ + coloringPart("ellipse","zebra-body",'cx="225" cy="505" rx="120" ry="60"',2)+ + coloringPart("path","zebra-head",'d="M 315 470 Q 370 425 405 465 L 380 545 Q 340 560 305 525 Z"',2)+ + coloringPart("path","zebra-ear-a",'d="M 335 465 L 330 420 L 365 455 Z"',3)+coloringPart("path","zebra-ear-b",'d="M 375 460 L 395 420 L 400 470 Z"',3)+ + coloringPart("circle","savanna-sun",'cx="690" cy="95" r="58"')+ + coloringLine("circle",'cx="605" cy="315" r="6" fill="#26344a" stroke="none"')+coloringLine("circle",'cx="645" cy="315" r="6" fill="#26344a" stroke="none"')+ + coloringLine("path",'d="M 625 338 l -10 10 l 10 10 l 10 -10 Z M 595 365 Q 625 385 655 365" fill="none"')+ + coloringLine("path",'d="M 420 350 Q 455 390 430 455 M 470 330 Q 505 380 485 460 M 520 340 Q 550 385 535 455" fill="none"',2)+ + coloringLine("path",'d="M 145 485 l 30 45 M 190 460 l 35 75 M 240 455 l 35 75 M 290 470 l 28 50 M 325 475 l 35 55" fill="none"',3)+ + coloringLine("path",'d="M 120 545 v 35 M 190 545 v 35 M 255 545 v 35 M 320 540 v 40" fill="none"',3) +)} + +function sceneJapaneseGarden(){return coloringScene( + coloringPart("rect","garden-sky-jp",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","garden-bank-jp",'d="M 4 405 Q 180 365 350 420 T 796 395 L 796 596 L 4 596 Z"')+ + coloringPart("ellipse","garden-pond-jp",'cx="420" cy="500" rx="335" ry="85"')+ + coloringPart("rect","pagoda-base",'x="520" y="310" width="175" height="140"')+ + coloringPart("path","pagoda-roof-low",'d="M 475 310 Q 610 270 740 310 L 700 345 H 515 Z"')+ + coloringPart("rect","pagoda-upper",'x="550" y="205" width="120" height="85"',2)+ + coloringPart("path","pagoda-roof-high",'d="M 510 205 Q 610 160 710 205 L 670 235 H 550 Z"',2)+ + coloringPart("path","bridge",'d="M 85 470 Q 230 300 385 470 L 385 520 Q 230 390 85 520 Z"')+ + coloringPart("rect","bridge-post-a",'x="95" y="405" width="25" height="115"',2)+coloringPart("rect","bridge-post-b",'x="350" y="405" width="25" height="115"',2)+ + coloringPart("path","cherry-trunk",'d="M 80 405 Q 135 260 115 95 H 205 Q 175 260 235 405 Z"')+ + coloringPart("circle","cherry-crown-a",'cx="110" cy="120" r="85"')+coloringPart("circle","cherry-crown-b",'cx="205" cy="130" r="95"')+coloringPart("circle","cherry-crown-c",'cx="155" cy="70" r="70"',2)+ + coloringPetals(95,95,"cherry-blossom-a",2)+coloringPetals(205,105,"cherry-blossom-b",3)+ + coloringPart("path","koi-a",'d="M 430 500 Q 490 450 550 500 Q 490 550 430 500 Z"',2)+ + coloringPart("path","koi-tail-a",'d="M 430 500 L 385 465 L 395 535 Z"',2)+ + coloringPart("path","stone-lantern",'d="M 705 430 L 740 365 L 775 430 L 760 450 H 720 Z"',3)+ + coloringPart("rect","lantern-post",'x="732" y="445" width="18" height="90"',3)+ + coloringLine("path",'d="M 120 445 Q 230 335 350 445 M 120 475 Q 230 375 350 475" fill="none"')+ + coloringLine("path",'d="M 150 405 V 470 M 220 360 V 435 M 290 380 V 455" fill="none"',2)+ + coloringLine("path",'d="M 570 350 V 450 M 645 350 V 450 M 550 400 H 670 M 580 235 V 290 M 640 235 V 290" fill="none"',2)+ + coloringLine("path",'d="M 455 500 Q 490 480 525 500 M 475 490 L 500 510 M 455 545 Q 500 525 555 545" fill="none"',3)+ + coloringLine("path",'d="M 40 555 Q 140 525 240 555 M 565 555 Q 650 530 760 555" fill="none"',3) +)} diff --git a/public/js/widgets/coloring.js b/public/js/widgets/coloring.js new file mode 100644 index 0000000..f441bfa --- /dev/null +++ b/public/js/widgets/coloring.js @@ -0,0 +1,461 @@ +/* teach - thematische kleurplaten met gekleurd potlood en vlakvulling */ +"use strict"; + +const COLORING_THEME_KEYS=Object.freeze({ + all:"colorThemeAll",animals:"colorThemeAnimals",seasons:"colorThemeSeasons", + nature:"colorThemeNature",science:"colorThemeScience",culture:"colorThemeCulture" +}); +const COLORING_COLORS=Object.freeze([ + "#26344a","#e0554d","#f28c3c","#f7c948","#3fae6a","#2fb7a8", + "#3b7dd8","#7557c8","#d85aa5","#8b5a3c","#f2b38f","#ffffff" +]); + +function coloringPart(tag,id,attrs="",level=1){ + return `<${tag} data-fillable="true" data-part="${id}"${level>1?` data-level="${level}"`:""} ${attrs}/>`; +} +function coloringLine(tag,attrs="",level=1){ + return `<${tag} class="coloring-detail"${level>1?` data-level="${level}"`:""} ${attrs}/>`; +} +function coloringScene(content){ + return ``; +} +function coloringPetals(cx,cy,prefix,level=2){ + return [0,60,120,180,240,300].map((angle,index)=>{ + const rad=angle*Math.PI/180,x=(cx+Math.cos(rad)*27).toFixed(1),y=(cy+Math.sin(rad)*27).toFixed(1); + return coloringPart("circle",`${prefix}-petal-${index}`,`cx="${x}" cy="${y}" r="20"`,level); + }).join("")+coloringPart("circle",`${prefix}-heart`,`cx="${cx}" cy="${cy}" r="17"`,level); +} +function coloringStars(points,prefix,level=2){ + return points.map(([x,y,r],index)=>coloringPart("path",`${prefix}-${index}`, + `d="M ${x} ${y-r} L ${x+r*.24} ${y-r*.25} L ${x+r} ${y} L ${x+r*.24} ${y+r*.25} L ${x} ${y+r} L ${x-r*.24} ${y+r*.25} L ${x-r} ${y} L ${x-r*.24} ${y-r*.25} Z"`,level)).join(""); +} + +function sceneButterfly(){return coloringScene( + coloringPart("rect","sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","grass",'d="M 4 430 Q 170 390 320 438 T 796 420 L 796 596 L 4 596 Z"')+ + coloringPart("circle","sun",'cx="685" cy="105" r="62"')+ + coloringPart("ellipse","wing-left-top",'cx="304" cy="248" rx="112" ry="135" transform="rotate(-28 304 248)"')+ + coloringPart("ellipse","wing-right-top",'cx="496" cy="248" rx="112" ry="135" transform="rotate(28 496 248)"')+ + coloringPart("ellipse","wing-left-bottom",'cx="323" cy="386" rx="92" ry="105" transform="rotate(22 323 386)"')+ + coloringPart("ellipse","wing-right-bottom",'cx="477" cy="386" rx="92" ry="105" transform="rotate(-22 477 386)"')+ + coloringPart("ellipse","body",'cx="400" cy="316" rx="35" ry="142"')+ + coloringPart("circle","head",'cx="400" cy="155" r="42"')+ + coloringLine("path",'d="M 380 122 Q 340 72 315 82 M 420 122 Q 460 72 485 82" fill="none"')+ + [[293,220],[507,220],[325,382],[475,382]].map(([x,y],i)=>coloringPart("circle",`wing-dot-${i}`,`cx="${x}" cy="${y}" r="28"`,2)).join("")+ + coloringLine("path",'d="M 385 155 Q 400 170 415 155" fill="none"',2)+ + coloringPetals(125,475,"flower-a",2)+coloringPetals(665,492,"flower-b",3) +)} +function sceneAutumn(){return coloringScene( + coloringPart("rect","sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","ground",'d="M 4 440 Q 190 405 380 448 T 796 430 L 796 596 L 4 596 Z"')+ + coloringPart("path","trunk",'d="M 337 500 Q 365 380 358 250 Q 398 285 420 250 Q 415 390 463 500 Z"')+ + [[390,162,115],[285,215,95],[505,225,104],[340,290,92],[460,315,88]].map(([x,y,r],i)=> + coloringPart("circle",`crown-${i}`,`cx="${x}" cy="${y}" r="${r}"`,i>2?2:1)).join("")+ + [[115,175,-18],[660,165,20],[175,355,12],[610,390,-24],[90,505,15],[705,500,-8]].map(([x,y,a],i)=> + coloringPart("path",`leaf-${i}`,`d="M ${x-35} ${y} Q ${x} ${y-45} ${x+35} ${y} Q ${x} ${y+45} ${x-35} ${y} Z" transform="rotate(${a} ${x} ${y})"`,2)).join("")+ + coloringStars([[155,90,20],[585,95,18],[730,300,16]],"wind-leaf",3)+ + coloringLine("path",'d="M 85 120 Q 170 80 240 118 M 560 120 Q 645 80 720 118" fill="none"',3) +)} +function sceneCake(){return coloringScene( + coloringPart("rect","party-wall",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","table",'d="M 4 445 L 796 445 L 796 596 L 4 596 Z"')+ + coloringPart("rect","cake-bottom",'x="190" y="360" width="420" height="120" rx="24"')+ + coloringPart("rect","cake-middle",'x="245" y="265" width="310" height="105" rx="22"')+ + coloringPart("rect","cake-top",'x="305" y="180" width="190" height="95" rx="20"')+ + coloringPart("path","icing-bottom",'d="M 190 390 Q 225 430 260 390 Q 300 430 340 390 Q 380 430 420 390 Q 460 430 500 390 Q 555 430 610 390 L 610 360 L 190 360 Z"',2)+ + coloringPart("path","icing-middle",'d="M 245 295 Q 285 330 325 295 Q 365 330 405 295 Q 450 330 495 295 Q 525 325 555 295 L 555 265 L 245 265 Z"',2)+ + coloringPart("rect","candle",'x="382" y="93" width="36" height="92" rx="8"')+ + coloringPart("path","flame",'d="M 400 92 Q 355 55 400 20 Q 445 55 400 92 Z"')+ + [[110,115],[690,120],[135,280],[665,275]].map(([x,y],i)=>coloringPart("path",`balloon-${i}`,`d="M ${x} ${y+55} C ${x-65} ${y+20} ${x-48} ${y-65} ${x} ${y-65} C ${x+48} ${y-65} ${x+65} ${y+20} ${x} ${y+55} Z"`,i>1?2:1)).join("")+ + coloringLine("path",'d="M 110 170 Q 130 260 95 350 M 690 175 Q 670 260 705 350 M 135 335 Q 160 390 140 440 M 665 330 Q 640 390 660 440" fill="none"')+ + coloringStars([[205,95,22],[595,72,22],[90,405,15],[720,410,15]],"confetti",3) +)} +function sceneOcean(){return coloringScene( + coloringPart("rect","water",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","sand",'d="M 4 475 Q 140 430 275 482 T 535 470 T 796 458 L 796 596 L 4 596 Z"')+ + coloringPart("ellipse","fish-body",'cx="325" cy="260" rx="145" ry="88"')+ + coloringPart("path","fish-tail",'d="M 185 260 L 85 178 Q 110 260 85 342 Z"')+ + coloringPart("circle","fish-eye",'cx="395" cy="230" r="18"')+ + coloringPart("path","turtle-shell",'d="M 510 390 Q 585 285 665 390 Q 600 485 510 390 Z"')+ + coloringPart("circle","turtle-head",'cx="690" cy="382" r="38"')+ + [[530,330,-28],[550,450,28],[635,330,25],[625,450,-25]].map(([x,y,a],i)=>coloringPart("ellipse",`flipper-${i}`,`cx="${x}" cy="${y}" rx="48" ry="20" transform="rotate(${a} ${x} ${y})"`,2)).join("")+ + coloringLine("path",'d="M 270 220 Q 325 265 270 310 M 335 190 Q 370 260 335 330" fill="none"',2)+ + coloringLine("path",'d="M 542 380 L 648 400 M 570 335 L 610 450 M 545 390 L 590 330 L 645 390 L 590 450 Z" fill="none"',3)+ + [[115,105,18],[150,75,11],[640,100,22],[675,65,13],[730,145,15]].map(([x,y,r],i)=>coloringPart("circle",`bubble-${i}`,`cx="${x}" cy="${y}" r="${r}"`,3)).join("")+ + coloringPart("path","seaweed-a",'d="M 120 525 Q 70 455 120 385 Q 165 455 120 525 Z"',2)+ + coloringPart("path","seaweed-b",'d="M 190 535 Q 145 470 205 410 Q 240 475 190 535 Z"',3) +)} +function sceneGarden(){return coloringScene( + coloringPart("rect","garden-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","garden-ground",'d="M 4 390 Q 160 350 320 400 T 796 385 L 796 596 L 4 596 Z"')+ + coloringPart("circle","sun",'cx="690" cy="95" r="58"')+ + coloringPart("path","snail-shell",'d="M 120 405 C 120 290 300 290 305 405 C 280 490 155 490 120 405 Z"')+ + coloringPart("path","snail-body",'d="M 95 455 Q 250 470 365 430 Q 410 415 438 450 Q 400 500 300 500 L 110 500 Z"')+ + coloringPart("circle","snail-head",'cx="410" cy="410" r="48"')+ + coloringLine("path",'d="M 388 370 L 370 315 M 427 370 L 450 318" fill="none"')+ + coloringPart("circle","ladybird",'cx="600" cy="385" r="82"')+ + coloringPart("circle","ladybird-head",'cx="600" cy="298" r="43"')+ + coloringLine("path",'d="M 600 305 L 600 465 M 540 328 Q 600 385 540 440 M 660 328 Q 600 385 660 440" fill="none"',2)+ + [[565,360],[635,360],[560,415],[640,415]].map(([x,y],i)=>coloringPart("circle",`ladybird-dot-${i}`,`cx="${x}" cy="${y}" r="15"`,2)).join("")+ + coloringPetals(165,165,"garden-flower-a",2)+coloringPetals(430,145,"garden-flower-b",3)+ + coloringLine("path",'d="M 165 205 L 165 315 M 430 185 L 430 280" fill="none"',2) +)} +function sceneWindmill(){return coloringScene( + coloringPart("rect","dutch-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","polder",'d="M 4 410 Q 190 380 365 420 T 796 405 L 796 596 L 4 596 Z"')+ + coloringPart("path","water",'d="M 4 505 Q 200 475 400 515 T 796 500 L 796 596 L 4 596 Z"',2)+ + coloringPart("path","mill-body",'d="M 310 470 L 350 185 L 485 185 L 535 470 Z"')+ + coloringPart("path","mill-roof",'d="M 335 185 Q 420 78 500 185 Z"')+ + coloringPart("rect","mill-door",'x="392" y="375" width="65" height="95" rx="25"')+ + coloringPart("circle","mill-hub",'cx="418" cy="220" r="35"')+ + [["M 405 190 L 320 55 L 345 40 L 430 205 Z"],["M 447 208 L 630 120 L 645 150 L 448 235 Z"],["M 430 250 L 515 410 L 485 425 L 405 248 Z"],["M 389 232 L 205 325 L 190 295 L 388 205 Z"]].map((d,i)=>coloringPart("path",`sail-${i}`,`d="${d}"`,1)).join("")+ + coloringPart("rect","house",'x="80" y="345" width="165" height="125" rx="8"',2)+ + coloringPart("path","house-roof",'d="M 55 345 L 165 260 L 270 345 Z"',2)+ + coloringPart("rect","house-window-a",'x="105" y="375" width="45" height="45"',3)+ + coloringPart("rect","house-window-b",'x="175" y="375" width="45" height="45"',3)+ + coloringLine("path",'d="M 35 185 Q 75 155 115 185 Q 155 155 195 185 M 595 75 Q 630 50 665 75 Q 700 50 735 75" fill="none"',3) +)} +function sceneRocket(){return coloringScene( + coloringPart("rect","space",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("circle","planet-large",'cx="650" cy="150" r="100"')+ + coloringPart("path","planet-ring",'d="M 535 120 Q 650 195 765 120 Q 650 255 535 120 Z"',2)+ + coloringPart("path","rocket-body",'d="M 320 420 Q 315 185 400 75 Q 485 185 480 420 Z"')+ + coloringPart("circle","rocket-window",'cx="400" cy="225" r="48"')+ + coloringPart("path","rocket-left-fin",'d="M 325 335 Q 245 385 245 480 L 350 410 Z"')+ + coloringPart("path","rocket-right-fin",'d="M 475 335 Q 555 385 555 480 L 450 410 Z"')+ + coloringPart("path","flame-outer",'d="M 350 420 Q 330 525 400 575 Q 470 525 450 420 Z"')+ + coloringPart("path","flame-inner",'d="M 380 420 Q 365 495 400 530 Q 435 495 420 420 Z"',2)+ + coloringStars([[110,105,30],[210,225,22],[680,350,28],[120,430,20],[575,80,15]],"space-star",2)+ + coloringPart("circle","moon",'cx="680" cy="475" r="62"',3)+ + [[655,458,13],[700,490,17],[690,445,9]].map(([x,y,r],i)=>coloringPart("circle",`crater-${i}`,`cx="${x}" cy="${y}" r="${r}"`,3)).join("") +)} +function sceneRainforest(){return coloringScene( + coloringPart("rect","forest-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","forest-floor",'d="M 4 475 Q 190 430 360 490 T 796 455 L 796 596 L 4 596 Z"')+ + coloringPart("path","tree-trunk-left",'d="M 40 500 Q 95 330 70 45 L 205 45 Q 165 330 235 500 Z"')+ + coloringPart("path","tree-trunk-right",'d="M 585 500 Q 640 300 615 35 L 760 35 Q 720 300 785 500 Z"')+ + coloringLine("path",'d="M 170 55 Q 300 175 230 315 Q 170 430 300 485 Q 400 525 455 425 Q 505 330 430 265" fill="none"',2)+ + coloringPart("path","parrot-body",'d="M 340 270 Q 390 155 490 230 Q 520 330 430 405 Q 330 390 340 270 Z"')+ + coloringPart("circle","parrot-head",'cx="450" cy="205" r="75"')+ + coloringPart("path","parrot-beak",'d="M 505 205 Q 590 225 515 270 Z"')+ + coloringPart("path","parrot-wing",'d="M 365 275 Q 455 245 465 365 Q 390 400 365 275 Z"',2)+ + coloringPart("circle","parrot-eye",'cx="470" cy="185" r="14"')+ + coloringPart("path","parrot-tail",'d="M 385 395 L 355 520 L 425 430 L 440 525 L 465 395 Z"',2)+ + [[285,125,-25],[540,100,18],[265,405,15],[525,450,-20]].map(([x,y,a],i)=>coloringPart("path",`forest-leaf-${i}`,`d="M ${x-55} ${y} Q ${x} ${y-70} ${x+55} ${y} Q ${x} ${y+70} ${x-55} ${y} Z" transform="rotate(${a} ${x} ${y})"`,i<2?2:3)).join("")+ + coloringLine("path",'d="M 285 70 L 285 180 M 540 30 L 540 170 M 265 335 L 265 475 M 525 375 L 525 525" fill="none"',3) +)} +function sceneCastle(){return coloringScene( + coloringPart("rect","castle-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","castle-hill",'d="M 4 445 Q 200 380 400 450 T 796 420 L 796 596 L 4 596 Z"')+ + coloringPart("rect","castle-main",'x="225" y="245" width="350" height="250"')+ + coloringPart("rect","tower-left",'x="115" y="195" width="155" height="300"')+ + coloringPart("rect","tower-right",'x="530" y="195" width="155" height="300"')+ + coloringPart("path","roof-left",'d="M 85 195 L 193 75 L 300 195 Z"')+ + coloringPart("path","roof-right",'d="M 500 195 L 608 75 L 715 195 Z"')+ + coloringPart("path","gate",'d="M 350 495 L 350 385 Q 400 325 450 385 L 450 495 Z"')+ + coloringPart("rect","flag-left",'x="193" y="55" width="92" height="50"',2)+ + coloringPart("rect","flag-right",'x="608" y="55" width="92" height="50"',2)+ + [[160,255],[225,255],[575,255],[640,255],[280,300],[475,300]].map(([x,y],i)=>coloringPart("rect",`window-${i}`,`x="${x-20}" y="${y-25}" width="40" height="58" rx="18"`,i>3?3:2)).join("")+ + coloringLine("path",'d="M 225 345 L 575 345 M 155 385 L 230 385 M 570 385 L 645 385" fill="none"',3)+ + coloringPart("path","path",'d="M 350 596 Q 365 510 400 445 Q 435 510 470 596 Z"',2) +)} +function sceneFourSeasons(){return coloringScene( + coloringPart("rect","season-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","season-ground",'d="M 4 455 Q 190 420 390 465 T 796 440 L 796 596 L 4 596 Z"')+ + coloringPart("path","season-trunk",'d="M 345 510 Q 380 350 375 210 Q 420 255 445 205 Q 425 365 475 510 Z"')+ + coloringPart("path","spring-crown",'d="M 385 230 Q 245 250 210 165 Q 225 65 355 85 Q 430 125 385 230 Z"')+ + coloringPart("path","summer-crown",'d="M 395 230 Q 430 80 545 75 Q 675 95 620 220 Q 520 285 395 230 Z"')+ + coloringPart("path","autumn-crown",'d="M 405 240 Q 560 220 635 295 Q 670 415 535 425 Q 420 390 405 240 Z"')+ + coloringPart("path","winter-crown",'d="M 380 235 Q 335 385 225 420 Q 105 390 165 280 Q 250 210 380 235 Z"')+ + coloringPetals(280,145,"spring-flower",2)+ + coloringPart("circle","summer-fruit-a",'cx="515" cy="165" r="24"',2)+coloringPart("circle","summer-fruit-b",'cx="570" cy="215" r="24"',3)+ + [[500,310,-15],[575,355,20],[460,375,-30]].map(([x,y,a],i)=>coloringPart("path",`autumn-leaf-${i}`,`d="M ${x-28} ${y} Q ${x} ${y-38} ${x+28} ${y} Q ${x} ${y+38} ${x-28} ${y} Z" transform="rotate(${a} ${x} ${y})"`,i===2?3:2)).join("")+ + coloringStars([[205,310,17],[285,345,15],[160,365,13]],"snowflake",3) +)} +function sceneEcoCity(){return coloringScene( + coloringPart("rect","city-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","city-ground",'d="M 4 455 L 796 455 L 796 596 L 4 596 Z"')+ + coloringPart("rect","tower",'x="95" y="190" width="155" height="265" rx="8"')+ + coloringPart("rect","school",'x="285" y="275" width="235" height="180" rx="8"')+ + coloringPart("path","school-roof",'d="M 260 275 L 402 185 L 545 275 Z"')+ + coloringPart("rect","apartments",'x="565" y="135" width="145" height="320" rx="8"')+ + [[135,235],[205,235],[135,305],[205,305],[135,375],[205,375],[600,185],[670,185],[600,255],[670,255],[600,325],[670,325]].map(([x,y],i)=>coloringPart("rect",`city-window-${i}`,`x="${x-22}" y="${y-25}" width="44" height="50"`,i>5?2:1)).join("")+ + coloringPart("rect","school-door",'x="375" y="365" width="55" height="90" rx="12"')+ + coloringPart("circle","tree-crown",'cx="60" cy="410" r="55"',2)+coloringPart("rect","tree-trunk",'x="48" y="430" width="24" height="75"',2)+ + coloringLine("path",'d="M 250 515 L 315 515 L 285 470 L 250 515 L 330 465" fill="none"',3)+ + coloringLine("circle",'cx="240" cy="520" r="34" fill="none"',3)+coloringLine("circle",'cx="330" cy="520" r="34" fill="none"',3)+ + coloringPart("path","solar-a",'d="M 100 190 L 125 135 L 245 135 L 225 190 Z"',2)+coloringPart("path","solar-b",'d="M 580 135 L 600 80 L 705 80 L 690 135 Z"',2)+ + coloringLine("path",'d="M 125 160 L 235 160 M 165 135 L 155 190 M 205 135 L 195 190 M 600 105 L 700 105 M 635 80 L 625 135 M 670 80 L 660 135" fill="none"',3) +)} +function sceneMandala(){ + const rings=[]; + for(let i=0;i<16;i++){ + const angle=i*Math.PI/8,cx=400+Math.cos(angle)*155,cy=300+Math.sin(angle)*155; + rings.push(coloringPart("ellipse",`mandala-outer-${i}`,`cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" rx="38" ry="82" transform="rotate(${i*22.5+90} ${cx.toFixed(1)} ${cy.toFixed(1)})"`,i%2?3:2)); + } + for(let i=0;i<12;i++){ + const angle=i*Math.PI/6,cx=400+Math.cos(angle)*92,cy=300+Math.sin(angle)*92; + rings.push(coloringPart("path",`mandala-inner-${i}`,`d="M 400 300 Q ${cx.toFixed(1)} ${cy.toFixed(1)} ${(400+Math.cos(angle+.28)*145).toFixed(1)} ${(300+Math.sin(angle+.28)*145).toFixed(1)} Q ${cx.toFixed(1)} ${cy.toFixed(1)} 400 300 Z"`,1)); + } + return coloringScene( + coloringPart("rect","mandala-bg",'x="4" y="4" width="792" height="592" rx="28"')+ + rings.join("")+coloringPart("circle","mandala-heart",'cx="400" cy="300" r="62"')+ + coloringStars([[400,300,34]],"mandala-star",3) + ); +} +function sceneEcosystem(){return coloringScene( + coloringPart("rect","pond-sky",'x="4" y="4" width="792" height="592" rx="28"')+ + coloringPart("path","pond-bank",'d="M 4 390 Q 160 335 315 395 T 620 375 T 796 390 L 796 596 L 4 596 Z"')+ + coloringPart("ellipse","pond-water",'cx="405" cy="485" rx="310" ry="90"')+ + coloringPart("path","frog-body",'d="M 305 435 Q 345 350 410 430 Q 465 350 505 435 Q 460 500 405 470 Q 350 500 305 435 Z"')+ + coloringPart("circle","frog-eye-a",'cx="350" cy="385" r="34"')+coloringPart("circle","frog-eye-b",'cx="460" cy="385" r="34"')+ + coloringPart("path","lily-pad-a",'d="M 120 505 Q 205 430 280 510 Q 205 555 120 505 Z"',2)+ + coloringPart("path","lily-pad-b",'d="M 525 515 Q 610 435 690 510 Q 610 560 525 515 Z"',2)+ + coloringPart("path","dragonfly-body",'d="M 585 220 Q 600 300 585 365 Q 570 300 585 220 Z"',2)+ + [[545,250,-25],[625,250,25],[550,310,25],[620,310,-25]].map(([x,y,a],i)=>coloringPart("ellipse",`dragonfly-wing-${i}`,`cx="${x}" cy="${y}" rx="55" ry="22" transform="rotate(${a} ${x} ${y})"`,2)).join("")+ + coloringPart("circle","sun",'cx="115" cy="105" r="55"')+ + coloringPart("path","reed-a",'d="M 105 440 Q 75 320 105 210 Q 135 320 105 440 Z"',3)+coloringPart("path","reed-b",'d="M 160 430 Q 130 300 165 185 Q 195 310 160 430 Z"',3)+ + coloringLine("path",'d="M 105 310 L 65 270 M 160 325 L 205 270" fill="none"',3) +)} + +const COLORING_REFINEMENTS=Object.freeze({ + butterfly: + coloringLine("circle",'cx="385" cy="150" r="5" fill="#26344a" stroke="none"')+ + coloringLine("circle",'cx="415" cy="150" r="5" fill="#26344a" stroke="none"')+ + coloringLine("path",'d="M 375 250 Q 315 275 270 330 M 425 250 Q 485 275 530 330" fill="none"',2)+ + coloringLine("path",'d="M 372 300 L 428 300 M 368 345 L 432 345 M 375 390 L 425 390" fill="none"',2)+ + coloringLine("path",'d="M 95 430 l -18 -28 M 145 425 l 18 -30 M 645 435 l -15 -30 M 700 430 l 20 -28" fill="none"',3), + autumn: + coloringLine("path",'d="M 370 470 Q 385 390 382 305 M 425 470 Q 405 390 410 305" fill="none"')+ + coloringLine("path",'d="M 385 320 Q 320 270 275 235 M 410 300 Q 475 255 535 235" fill="none"',2)+ + coloringLine("path",'d="M 90 175 L 140 175 M 635 165 L 685 165 M 150 355 L 200 355 M 585 390 L 635 390" fill="none"',2)+ + coloringLine("path",'d="M 350 505 q 18 -20 35 0 M 410 510 q 18 -22 38 0" fill="none"',3), + cake: + coloringLine("ellipse",'cx="400" cy="492" rx="255" ry="24" fill="none"')+ + coloringLine("path",'d="M 235 445 Q 275 410 315 445 Q 355 410 395 445 Q 440 410 485 445 Q 525 410 565 445" fill="none"',2)+ + coloringLine("path",'d="M 325 245 H 475 M 270 340 H 530" fill="none"',2)+ + coloringLine("path",'d="M 382 115 L 418 135 M 382 145 L 418 165" fill="none"',3)+ + coloringLine("circle",'cx="340" cy="320" r="9" fill="none"',3)+coloringLine("circle",'cx="460" cy="320" r="9" fill="none"',3), + ocean: + coloringLine("path",'d="M 430 270 Q 455 285 475 270 M 375 200 Q 360 260 375 315" fill="none"')+ + coloringLine("path",'d="M 250 250 Q 290 215 330 250 Q 290 285 250 250 M 300 305 Q 335 275 365 305" fill="none"',2)+ + coloringLine("circle",'cx="701" cy="374" r="5" fill="#26344a" stroke="none"')+ + coloringLine("path",'d="M 705 397 Q 720 407 733 396" fill="none"',2)+ + coloringLine("path",'d="M 60 545 Q 115 520 170 545 M 625 545 Q 680 520 740 545" fill="none"',3), + garden: + coloringLine("path",'d="M 175 405 C 175 340 265 340 265 405 C 265 455 205 455 205 410 C 205 382 240 382 240 407" fill="none"')+ + coloringLine("circle",'cx="394" cy="402" r="5" fill="#26344a" stroke="none"')+coloringLine("circle",'cx="425" cy="402" r="5" fill="#26344a" stroke="none"')+ + coloringLine("path",'d="M 393 427 Q 410 440 428 426 M 565 292 Q 545 265 530 260 M 635 292 Q 655 265 670 260" fill="none"',2)+ + coloringLine("path",'d="M 525 365 L 485 345 M 525 410 L 480 420 M 675 365 L 715 345 M 675 410 L 720 420" fill="none"',3), + windmill: + coloringLine("path",'d="M 380 250 H 455 V 320 H 380 Z M 417 250 V 320 M 380 285 H 455" fill="none"')+ + coloringLine("path",'d="M 328 65 L 414 210 M 625 135 L 445 220 M 500 410 L 422 245 M 200 310 L 392 220" fill="none"',2)+ + coloringLine("path",'d="M 325 82 L 345 68 M 605 135 L 615 160 M 500 388 L 475 402 M 218 304 L 230 330" fill="none"',3)+ + coloringLine("path",'d="M 65 520 Q 130 500 195 520 M 545 520 Q 620 495 700 520" fill="none"',2), + rocket: + coloringLine("path",'d="M 335 330 H 465 M 352 385 H 448" fill="none"')+ + coloringLine("circle",'cx="400" cy="225" r="32" fill="none"',2)+ + coloringLine("path",'d="M 383 225 Q 400 240 417 225" fill="none"',3)+ + coloringLine("circle",'cx="382" cy="210" r="4" fill="#26344a" stroke="none"',3)+coloringLine("circle",'cx="418" cy="210" r="4" fill="#26344a" stroke="none"',3)+ + coloringLine("path",'d="M 350 170 L 370 182 M 450 170 L 430 182 M 375 455 L 400 510 L 425 455" fill="none"',2), + rainforest: + coloringLine("path",'d="M 385 285 Q 415 315 445 345 M 375 315 Q 410 345 440 370" fill="none"',2)+ + coloringLine("path",'d="M 510 225 L 545 238 M 410 405 L 380 485 M 440 405 L 430 500" fill="none"')+ + coloringLine("path",'d="M 360 485 L 335 500 M 380 485 L 398 505 M 410 500 L 390 520 M 430 500 L 450 520" fill="none"',2)+ + coloringLine("path",'d="M 115 80 Q 135 165 110 250 M 680 65 Q 700 165 675 260" fill="none"',3)+ + coloringLine("path",'d="M 465 207 Q 478 215 488 204" fill="none"',3), + castle: + coloringLine("path",'d="M 365 400 V 490 M 400 365 V 490 M 435 400 V 490" fill="none"')+ + coloringLine("path",'d="M 240 280 H 330 M 470 280 H 560 M 280 370 H 350 M 450 370 H 525 M 140 330 H 245 M 555 330 H 665" fill="none"',2)+ + coloringLine("path",'d="M 160 255 V 290 M 225 255 V 290 M 575 255 V 290 M 640 255 V 290" fill="none"',2)+ + coloringLine("path",'d="M 193 55 V 25 M 608 55 V 25 M 193 80 H 285 M 608 80 H 700" fill="none"',3)+ + coloringLine("path",'d="M 365 535 Q 400 515 435 535 M 350 570 Q 400 545 450 570" fill="none"',3), + "four-seasons": + coloringLine("path",'d="M 395 230 Q 325 180 265 135 M 400 230 Q 485 175 565 135 M 405 250 Q 505 300 565 355 M 385 250 Q 300 315 225 355" fill="none"')+ + coloringLine("path",'d="M 495 165 l 18 -28 M 548 215 l 20 -25" fill="none"',2)+ + coloringLine("path",'d="M 500 310 l 45 45 M 545 310 l -45 45" fill="none"',3)+ + coloringLine("path",'d="M 170 310 h 70 M 205 275 v 70 M 180 285 l 50 50 M 230 285 l -50 50" fill="none"',3), + "eco-city": + coloringLine("circle",'cx="402" cy="240" r="22" fill="none"')+coloringLine("path",'d="M 402 240 L 402 225 M 402 240 L 415 248" fill="none"')+ + coloringLine("path",'d="M 120 160 H 235 M 160 135 L 150 190 M 205 135 L 195 190 M 600 105 H 700 M 635 80 L 625 135 M 670 80 L 660 135" fill="none"',2)+ + coloringLine("circle",'cx="285" cy="495" r="10" fill="none"',3)+ + coloringLine("path",'d="M 15 545 H 160 M 360 545 H 520 M 625 545 H 780" fill="none"',3), + mandala: + coloringLine("circle",'cx="400" cy="300" r="118" fill="none"',2)+coloringLine("circle",'cx="400" cy="300" r="205" fill="none"',3)+ + coloringLine("path",'d="M 400 95 V 505 M 195 300 H 605 M 255 155 L 545 445 M 545 155 L 255 445" fill="none"',3)+ + coloringLine("circle",'cx="400" cy="300" r="18" fill="none"',3), + ecosystem: + coloringLine("circle",'cx="350" cy="385" r="7" fill="#26344a" stroke="none"')+coloringLine("circle",'cx="460" cy="385" r="7" fill="#26344a" stroke="none"')+ + coloringLine("path",'d="M 365 438 Q 405 465 445 438" fill="none"')+ + coloringLine("path",'d="M 320 465 l -35 28 M 340 475 l -20 35 M 470 465 l 35 28 M 450 475 l 20 35" fill="none"',2)+ + coloringLine("path",'d="M 570 255 H 600 M 568 280 H 602 M 570 305 H 600 M 220 500 Q 405 540 585 500" fill="none"',3) +}); +function coloringRefined(id,svg){ + const details=COLORING_REFINEMENTS[id]||""; + return svg.replace("\n \n ",`\n ${details}\n \n `); +} + +const COLORING_PAGES=Object.freeze([ + {id:"butterfly",theme:"animals",years:[1,2],title:{nl:"Blije vlinder",en:"Happy butterfly"},svg:coloringRefined("butterfly",sceneButterfly())}, + {id:"autumn",theme:"seasons",years:[1,2],title:{nl:"Herfstboom",en:"Autumn tree"},svg:coloringRefined("autumn",sceneAutumn())}, + {id:"cake",theme:"culture",years:[1,2],title:{nl:"Feesttaart",en:"Party cake"},svg:coloringRefined("cake",sceneCake())}, + {id:"farmyard",theme:"animals",years:[1,2],title:{nl:"Boerderijvrienden",en:"Farmyard friends"},svg:coloringRefined("farmyard",sceneFarmyard())}, + {id:"rainbow-weather",theme:"seasons",years:[1,2],title:{nl:"Regenboogweer",en:"Rainbow weather"},svg:coloringRefined("rainbow-weather",sceneRainbowWeather())}, + {id:"construction-site",theme:"science",years:[1,2],title:{nl:"Grote graafmachine",en:"Big excavator"},svg:coloringRefined("construction-site",sceneConstruction())}, + {id:"forest-picnic",theme:"nature",years:[1,2],title:{nl:"Picknick in het bos",en:"Forest picnic"},svg:coloringRefined("forest-picnic",sceneForestPicnic())}, + {id:"ocean",theme:"animals",years:[3,4],title:{nl:"Vrienden in zee",en:"Ocean friends"},svg:coloringRefined("ocean",sceneOcean())}, + {id:"garden",theme:"nature",years:[3,4],title:{nl:"Kleine tuindieren",en:"Little garden animals"},svg:coloringRefined("garden",sceneGarden())}, + {id:"windmill",theme:"culture",years:[3,4],title:{nl:"Molen in de polder",en:"Windmill in the polder"},svg:coloringRefined("windmill",sceneWindmill())}, + {id:"dinosaur-valley",theme:"science",years:[3,4],title:{nl:"Dinosaurusvallei",en:"Dinosaur valley"},svg:coloringRefined("dinosaur-valley",sceneDinosaurValley())}, + {id:"spring-nest",theme:"seasons",years:[3,4],title:{nl:"Vogelnest in de lente",en:"Spring bird nest"},svg:coloringRefined("spring-nest",sceneSpringNest())}, + {id:"fire-station",theme:"culture",years:[3,4],title:{nl:"Brandweerkazerne",en:"Fire station"},svg:coloringRefined("fire-station",sceneFireStation())}, + {id:"bee-meadow",theme:"nature",years:[3,4],title:{nl:"Bijen in de bloemenwei",en:"Bees in the meadow"},svg:coloringRefined("bee-meadow",sceneBeeMeadow())}, + {id:"rocket",theme:"science",years:[5,6],title:{nl:"Raket door de ruimte",en:"Rocket through space"},svg:coloringRefined("rocket",sceneRocket())}, + {id:"rainforest",theme:"nature",years:[5,6],title:{nl:"Regenwoud",en:"Rainforest"},svg:coloringRefined("rainforest",sceneRainforest())}, + {id:"castle",theme:"culture",years:[5,6],title:{nl:"Kasteel en dorp",en:"Castle and village"},svg:coloringRefined("castle",sceneCastle())}, + {id:"coral-reef",theme:"animals",years:[5,6],title:{nl:"Koraalrif",en:"Coral reef"},svg:coloringRefined("coral-reef",sceneCoralReef())}, + {id:"mountain-camp",theme:"seasons",years:[5,6],title:{nl:"Bergkamp",en:"Mountain camp"},svg:coloringRefined("mountain-camp",sceneMountainCamp())}, + {id:"robot-workshop",theme:"science",years:[5,6],title:{nl:"Robotwerkplaats",en:"Robot workshop"},svg:coloringRefined("robot-workshop",sceneRobotWorkshop())}, + {id:"canal-street",theme:"culture",years:[5,6],title:{nl:"Straat langs de gracht",en:"Canal street"},svg:coloringRefined("canal-street",sceneCanalStreet())}, + {id:"four-seasons",theme:"seasons",years:[7,8],title:{nl:"Boom van vier seizoenen",en:"Four seasons tree"},svg:coloringRefined("four-seasons",sceneFourSeasons())}, + {id:"eco-city",theme:"science",years:[7,8],title:{nl:"Duurzame stad",en:"Sustainable city"},svg:coloringRefined("eco-city",sceneEcoCity())}, + {id:"mandala",theme:"culture",years:[7,8],title:{nl:"Geometrische mandala",en:"Geometric mandala"},svg:coloringRefined("mandala",sceneMandala())}, + {id:"ecosystem",theme:"nature",years:[7,8],title:{nl:"Leven rond de vijver",en:"Life around the pond"},svg:coloringRefined("ecosystem",sceneEcosystem())}, + {id:"solar-observatory",theme:"science",years:[7,8],title:{nl:"Sterrenwacht",en:"Solar observatory"},svg:coloringRefined("solar-observatory",sceneSolarObservatory())}, + {id:"savanna-web",theme:"animals",years:[7,8],title:{nl:"Leven op de savanne",en:"Life on the savanna"},svg:coloringRefined("savanna-web",sceneSavannaWeb())}, + {id:"japanese-garden",theme:"culture",years:[7,8],title:{nl:"Japanse tuin",en:"Japanese garden"},svg:coloringRefined("japanese-garden",sceneJapaneseGarden())} +]); + +function coloringPagesFor(yearGroup,theme="all"){ + const year=Math.max(1,Math.min(8,Number(yearGroup)||4)); + return COLORING_PAGES.filter(page=>year>=page.years[0]&&year<=page.years[1]&&(theme==="all"||page.theme===theme)); +} +function coloringPencilWidth(size,pressure,pointerType){ + const base=Math.max(2,Math.min(18,Number(size)||6)); + return pointerType==="pen"&&pressure>0?base*(.45+pressure*1.2):base; +} +function coloringSafeColor(value,fallback=COLORING_COLORS[1]){ + return /^#[0-9a-f]{6}$/i.test(String(value||""))?String(value).toLowerCase():fallback; +} + +function mountColoring(root,initState,opts={}){ + const yearGroup=Math.max(1,Math.min(8,Number(initState&&initState.yearGroup)||4)); + const level=Math.max(1,Math.min(3,Number(initState&&initState.level)||2)); + let tool=["pencil","bucket","eraser"].includes(initState&&initState.tool)?initState.tool:"pencil"; + let color=coloringSafeColor(initState&&initState.color); + let size=[4,7,12].includes(Number(initState&&initState.size))?Number(initState.size):7; + let theme=COLORING_THEME_KEYS[initState&&initState.theme]?initState.theme:"all"; + let pageId=String(initState&&initState.pageId||""); + let fills=initState&&initState.pageId===pageId&&initState.fills&&typeof initState.fills==="object"?{...initState.fills}:{}; + let completed=!!(initState&&initState.completed),restoreInk=initState&&initState.ink; + + root.innerHTML=`
+
+ + + +
+
+
+
+ +
+ + +
+
+
+
`; + const app=root.querySelector(".coloring-app"),themeSel=root.querySelector(".coloring-theme"),pageSel=root.querySelector(".coloring-page"); + const art=root.querySelector(".coloring-art"),paper=root.querySelector(".coloring-paper"),canvas=root.querySelector("canvas"),ctx=canvas.getContext("2d"); + const toolHost=root.querySelector(".coloring-tool-buttons"),palette=root.querySelector(".coloring-palette"),sizes=root.querySelector(".coloring-sizes"); + const custom=root.querySelector(".coloring-custom input"),reset=root.querySelector(".coloring-reset"),done=root.querySelector(".coloring-done"),hint=root.querySelector(".coloring-hint"); + const TOOLS=[["pencil","✏️","colorToolPencil"],["bucket","🪣","colorToolBucket"],["eraser","🧽","eraser"]]; + + TOOLS.forEach(([id,icon])=>{const button=document.createElement("button");button.type="button";button.dataset.tool=id;button.textContent=icon;button.onclick=()=>{tool=id;refreshTools()};toolHost.appendChild(button)}); + COLORING_COLORS.forEach(value=>{const button=document.createElement("button");button.type="button";button.className="coloring-color";button.dataset.color=value;const dot=document.createElement("i");dot.style.background=value;button.appendChild(dot);button.onclick=()=>{color=value;custom.value=value;if(tool==="eraser")tool="pencil";refreshTools()};palette.appendChild(button)}); + [4,7,12].forEach(value=>{const button=document.createElement("button");button.type="button";button.className="coloring-size";button.dataset.size=value;const dot=document.createElement("i");dot.style.width=dot.style.height=(value+5)+"px";button.appendChild(dot);button.onclick=()=>{size=value;refreshTools()};sizes.appendChild(button)}); + custom.value=color;custom.oninput=()=>{color=coloringSafeColor(custom.value,color);if(tool==="eraser")tool="pencil";refreshTools()}; + + function pageTitle(page){return page.title[LANG]||page.title.nl} + function eligibleThemes(){return [...new Set(coloringPagesFor(yearGroup).map(page=>page.theme))]} + function currentPages(){return coloringPagesFor(yearGroup,theme)} + function rebuildThemeOptions(){ + const previous=theme;themeSel.replaceChildren(); + const all=new Option(T("colorThemeAll"),"all");themeSel.add(all); + eligibleThemes().forEach(id=>themeSel.add(new Option(T(COLORING_THEME_KEYS[id]),id))); + theme=themeSel.querySelector(`option[value="${previous}"]`)?previous:"all";themeSel.value=theme; + } + function rebuildPageOptions(prefer=pageId){ + const pages=currentPages();pageSel.replaceChildren(); + pages.forEach(page=>pageSel.add(new Option(pageTitle(page),page.id))); + pageId=pages.some(page=>page.id===prefer)?prefer:(pages[0]&&pages[0].id)||"";pageSel.value=pageId; + } + function clearInk(){ctx.clearRect(0,0,canvas.width,canvas.height)} + function loadInk(url){ + clearInk();if(!url)return; + const image=new Image();image.onload=()=>{clearInk();ctx.drawImage(image,0,0,canvas.width,canvas.height)};image.src=url; + } + function renderPage(keepState){ + const page=COLORING_PAGES.find(item=>item.id===pageId)||currentPages()[0];if(!page)return; + pageId=page.id;art.innerHTML=page.svg; + const svg=art.querySelector("svg");paper.setAttribute("aria-label",pageTitle(page)); + svg.querySelectorAll("[data-level]").forEach(node=>node.toggleAttribute("hidden",Number(node.dataset.level)>level)); + if(!keepState){fills={};completed=false;restoreInk=null;clearInk()} + svg.querySelectorAll("[data-fillable][data-part]").forEach(node=>{ + const saved=coloringSafeColor(fills[node.dataset.part],"");if(saved)node.setAttribute("fill",saved); + }); + if(keepState&&restoreInk){loadInk(restoreInk);restoreInk=null} + refreshTools();updateTexts(); + } + function updateTexts(){ + root.querySelector(".coloring-theme-label").textContent=T("colorTheme"); + root.querySelector(".coloring-page-label").textContent=T("colorPage"); + root.querySelector(".coloring-fit").textContent=T("colorProfile").replace("{year}",yearGroup).replace("{level}",level); + TOOLS.forEach(([id,,key])=>{const button=toolHost.querySelector(`[data-tool="${id}"]`);button.title=T(key);button.setAttribute("aria-label",T(key))}); + custom.parentElement.querySelector("span").textContent=T("customColor"); + reset.title=T("colorReset");reset.setAttribute("aria-label",T("colorReset")); + done.textContent=completed?"✓ "+T("colorFinished"):T("colorDone"); + hint.textContent=T(tool==="bucket"?"colorBucketHint":tool==="eraser"?"colorEraserHint":"colorPencilHint"); + const selected=pageId;rebuildPageOptions(selected); + } + function refreshTools(){ + toolHost.querySelectorAll("button").forEach(button=>{const on=button.dataset.tool===tool;button.classList.toggle("on",on);button.setAttribute("aria-pressed",String(on))}); + palette.querySelectorAll("button").forEach(button=>button.classList.toggle("on",button.dataset.color===color&&tool!=="eraser")); + sizes.querySelectorAll("button").forEach(button=>{button.classList.toggle("on",Number(button.dataset.size)===size);button.title=T("penSize")}); + app.dataset.tool=tool;canvas.style.pointerEvents=tool==="bucket"?"none":"auto";art.style.pointerEvents=tool==="bucket"?"auto":"none"; + hint.textContent=T(tool==="bucket"?"colorBucketHint":tool==="eraser"?"colorEraserHint":"colorPencilHint"); + } + themeSel.onchange=()=>{theme=themeSel.value;rebuildPageOptions("");renderPage(false)}; + pageSel.onchange=()=>{pageId=pageSel.value;renderPage(false)}; + art.addEventListener("click",event=>{ + if(tool!=="bucket")return;const part=event.target.closest("[data-fillable][data-part]");if(!part||part.hasAttribute("hidden"))return; + part.setAttribute("fill",color);fills[part.dataset.part]=color;completed=false;updateTexts(); + }); + + const point=event=>{const rect=canvas.getBoundingClientRect();return{x:(event.clientX-rect.left)*canvas.width/rect.width,y:(event.clientY-rect.top)*canvas.height/rect.height}}; + let drawing=false,last=null,mid=null; + function pencilSegment(next,event){ + const width=coloringPencilWidth(size,event.pressure,event.pointerType),erase=tool==="eraser"; + ctx.save();ctx.globalCompositeOperation=erase?"destination-out":"source-over";ctx.strokeStyle=color;ctx.globalAlpha=erase?1:.27;ctx.lineCap="round";ctx.lineJoin="round";ctx.lineWidth=erase?width*3.5:width; + const nextMid={x:(last.x+next.x)/2,y:(last.y+next.y)/2};ctx.beginPath();ctx.moveTo(mid?mid.x:last.x,mid?mid.y:last.y);ctx.quadraticCurveTo(last.x,last.y,nextMid.x,nextMid.y);ctx.stroke(); + if(!erase){for(let n=0;n<2;n++){const jitter=(Math.random()-.5)*width*.65;ctx.globalAlpha=.07;ctx.lineWidth=Math.max(1,width*.28);ctx.beginPath();ctx.moveTo(last.x+jitter,last.y-jitter);ctx.lineTo(next.x-jitter,next.y+jitter);ctx.stroke()}} + ctx.restore();last=next;mid=nextMid; + } + canvas.addEventListener("pointerdown",event=>{if(tool==="bucket"||(event.pointerType==="mouse"&&event.button!==0))return;event.preventDefault();canvas.setPointerCapture(event.pointerId);drawing=true;last=point(event);mid=null;pencilSegment({x:last.x+.01,y:last.y+.01},event);completed=false;updateTexts()}); + canvas.addEventListener("pointermove",event=>{if(!drawing)return;(event.getCoalescedEvents?event.getCoalescedEvents():[event]).forEach(sample=>pencilSegment(point(sample),sample))}); + const stop=()=>{drawing=false;last=mid=null};canvas.addEventListener("pointerup",stop);canvas.addEventListener("pointercancel",stop); + reset.onclick=()=>{fills={};completed=false;clearInk();renderPage(false)}; + done.onclick=()=>{completed=true;updateTexts();if(typeof opts.onProgress==="function")opts.onProgress({attempts:1,correct:1,stars:1,detail:{question:pageTitle(COLORING_PAGES.find(page=>page.id===pageId)),answer:T("colorFinished"),correct:true}})}; + document.addEventListener("langchange",()=>{rebuildThemeOptions();updateTexts();renderPage(true)}); + + rebuildThemeOptions();rebuildPageOptions(pageId);renderPage(true); + return{getState:()=>{ + let ink=null;try{ink=canvas.toDataURL("image/png")}catch(_){/* een leeg canvas blijft gewoon herstelbaar */} + return{theme,pageId,fills:{...fills},ink,tool,color,size,completed,yearGroup,level,managedLearningLevel:!!(initState&&initState.managedLearningLevel)}; + }}; +} diff --git a/public/js/widgets/compliments.js b/public/js/widgets/compliments.js new file mode 100644 index 0000000..2e9720e --- /dev/null +++ b/public/js/widgets/compliments.js @@ -0,0 +1,66 @@ +/* widget: Complimentenmachine */ +/* ========================================================= + Trek een willekeurig compliment - zelfde spin-en-onthul-mechaniek als + de namenkiezer (names.js) +==========================================================*/ +const COMPLIMENTS = Object.freeze([ + { nl:"Jij bent een fijne klasgenoot!", en:"You're a great classmate!" }, + { nl:"Wat knap dat je dat zelf hebt uitgezocht!", en:"Great job figuring that out yourself!" }, + { nl:"Jij luistert altijd goed naar anderen.", en:"You're always a great listener." }, + { nl:"Je hebt vandaag hard gewerkt, goed gedaan!", en:"You worked hard today, well done!" }, + { nl:"Jij deelt graag met anderen.", en:"You love sharing with others." }, + { nl:"Wat fijn dat jij er bent!", en:"We're so glad you're here!" }, + { nl:"Jij bent heel behulpzaam.", en:"You're very helpful." }, + { nl:"Je hebt een mooie glimlach.", en:"You have a lovely smile." }, + { nl:"Jij geeft nooit op, dat is knap!", en:"You never give up, that's impressive!" }, + { nl:"Jij bent een echte teamspeler.", en:"You're a real team player." }, + { nl:"Wat een goed idee had jij daar!", en:"That was a great idea you had!" }, + { nl:"Jij maakt anderen graag blij.", en:"You love making others happy." }, + { nl:"Je hebt heel goed je best gedaan.", en:"You tried really hard." }, + { nl:"Jij bent creatief en origineel.", en:"You're creative and original." }, + { nl:"Jij zegt altijd eerlijk wat je denkt.", en:"You always speak your mind honestly." }, + { nl:"Jij bent een fijne vriend(in).", en:"You're a great friend." }, + { nl:"Wat kun jij goed samenwerken!", en:"You're so good at working together!" }, + { nl:"Jij maakt de klas een fijnere plek.", en:"You make our class a nicer place." }, + { nl:"Jij durft dingen te proberen, goed zo!", en:"You're brave enough to try new things!" }, + { nl:"Jij bent trots op jezelf, en dat mag!", en:"You're proud of yourself, and that's great!" }, +]); +function mountCompliments(root, initState){ + let current = null, spinning = false; + root.innerHTML = ` +
+

+
💝

+ +
`; + const card = root.querySelector(".cpl-card"), text = root.querySelector(".cpl-text"), drawBtn = root.querySelector(".cpl-draw"); + function pickRandom(){ return COMPLIMENTS[Math.floor(Math.random() * COMPLIMENTS.length)]; } + function draw(){ + if(spinning) return; + spinning = true; + card.classList.remove("show"); + let n = 0; + const iv = setInterval(()=>{ + text.textContent = pickRandom()[LANG]; + tone(500 + Math.random() * 200, 0.03, 0, "square", 0.04); + if(++n >= 10){ + clearInterval(iv); + current = pickRandom(); + text.textContent = current[LANG]; + card.classList.add("show"); + confetti(card); + sndWin(); + spinning = false; + } + }, 70); + } + drawBtn.addEventListener("click", draw); + function labels(){ + root.querySelector(".cpl-mission").textContent = T("wg_compliments_d"); + drawBtn.textContent = "💝 " + T("complimentsDraw"); + if(current) text.textContent = current[LANG]; + } + document.addEventListener("langchange", labels); + labels(); + return { getState: ()=>({}) }; +} diff --git a/public/js/widgets/dice.js b/public/js/widgets/dice.js new file mode 100644 index 0000000..02d34f5 --- /dev/null +++ b/public/js/widgets/dice.js @@ -0,0 +1,79 @@ +/* widget: Dobbelstenen */ +/* ========================================================= + Dice widget +==========================================================*/ +function mountDice(root, initState){ + let count = (initState && initState.count) || 2, rolling = false, history=Array.isArray(initState?.history)?initState.history.slice(-5):[]; + const PIPS = {1:[4],2:[0,8],3:[0,4,8],4:[0,2,6,8],5:[0,2,4,6,8],6:[0,2,3,5,6,8]}; + root.innerHTML = ` +
+
+
+ +
+
+
+

+
+
+
+ +
`; + const row = root.querySelector(".dc-row"), + sumEl = root.querySelector(".dc-sum"), + rollBtn = root.querySelector(".dc-roll"); + function face(die, v){ + die.querySelectorAll("i").forEach((p,i)=>p.classList.toggle("on", PIPS[v].includes(i))); + die.dataset.v = v; + } + function build(){ + row.innerHTML = ""; + for(let i=0;id.classList.add("rolling")); + let n = 0; + const iv = setInterval(()=>{ + dice.forEach(d=>face(d, 1+Math.floor(Math.random()*6))); + tone(300+Math.random()*250, 0.04, 0, "square", 0.05); + if(++n >= 9){ + clearInterval(iv); + dice.forEach(d=>d.classList.remove("rolling")); + const total = dice.reduce((s,d)=>s + +d.dataset.v, 0); + if(count>1) sumEl.textContent = `${T("diceSum")}: ${total}`; + history.push(total);history=history.slice(-5);renderHistory(); + rolling = false; + } + }, 90); + } + function renderHistory(){const box=root.querySelector('.dc-history');box.innerHTML='';if(!history.length)return;const label=document.createElement('span');label.textContent=T('diceHistory');box.append(label,...history.map(value=>{const chip=document.createElement('b');chip.textContent=value;return chip}))} + row.addEventListener("click", roll); + rollBtn.addEventListener("click", roll); + root.querySelectorAll(".dc-seg button").forEach(b=>{ + b.addEventListener("click", ()=>{ + count = +b.dataset.n; + root.querySelectorAll(".dc-seg button").forEach(x=>x.classList.toggle("on", x===b)); + build(); + }); + }); + function labels(){ + rollBtn.textContent = T("diceRoll"); + root.querySelector('.dc-mission').textContent=T('diceMission'); + root.querySelectorAll(".dc-seg button").forEach(x=>x.classList.toggle("on", +x.dataset.n===count)); + renderHistory(); + } + document.addEventListener("langchange", labels); + labels(); build(); + return { getState: ()=>({count,history:[...history]}) }; +} diff --git a/public/js/widgets/feelings.js b/public/js/widgets/feelings.js new file mode 100644 index 0000000..e83b3b3 --- /dev/null +++ b/public/js/widgets/feelings.js @@ -0,0 +1,60 @@ +/* widget: Gevoelszones */ +/* ========================================================= + Individual check-in (Zones of Regulation-stijl): kies je zone, krijg een + passende tip - anders dan de Gevoelsmeter (mood.js), die is een anonieme + klaspoll, geen persoonlijke check-in met uitleg. +==========================================================*/ +function mountFeelings(root, initState){ + let picked = (initState && initState.picked) || null; + const ZONES = ["blue","green","yellow","red"]; + const EMOJI = { blue:"💧", green:"🌿", yellow:"⚡", red:"🔥" }; + root.innerHTML = ` +
+

+
+ +
`; + const zonesEl = root.querySelector(".fz-zones"), detail = root.querySelector(".fz-detail"); + function pick(id){ + picked = id; + tone(id==="blue"?294:id==="green"?392:id==="yellow"?523:220, 0.14, 0, "sine", .08); + draw(); + } + function draw(){ + zonesEl.innerHTML = ""; + ZONES.forEach(id=>{ + const b = document.createElement("button"); + b.className = "fz-zone " + id + (picked === id ? " active" : ""); + b.setAttribute("aria-pressed", String(picked === id)); + const emoji = document.createElement("span"); + emoji.className = "fz-emoji"; + emoji.textContent = EMOJI[id]; + const label = document.createElement("span"); + label.className = "fz-label"; + label.textContent = T("fzZone_" + id); + b.append(emoji, label); + b.addEventListener("click", ()=>pick(id)); + zonesEl.appendChild(b); + }); + if(picked){ + detail.hidden = false; + detail.className = "fz-detail " + picked; + detail.querySelector(".fz-detail-title").textContent = EMOJI[picked] + " " + T("fzZone_" + picked); + detail.querySelector(".fz-detail-desc").textContent = T("fzDesc_" + picked); + detail.querySelector(".fz-detail-tip").textContent = T("fzTip_" + picked); + }else{ + detail.hidden = true; + } + } + function labels(){ + root.querySelector(".fz-mission").textContent = T("wg_feelings_d"); + draw(); + } + document.addEventListener("langchange", labels); + labels(); + return { getState: ()=>({ picked }) }; +} diff --git a/public/js/widgets/flits.js b/public/js/widgets/flits.js new file mode 100644 index 0000000..3432108 --- /dev/null +++ b/public/js/widgets/flits.js @@ -0,0 +1,124 @@ +/* widget: Flitswoorden */ +/* ========================================================= + Flash words widget +==========================================================*/ +function mountFlits(root, initState, opts){ + const onProgress = opts && opts.onProgress; + const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, dur:2, idx:0, order:[], score:0, tries:0, assignmentWords:null, assignmentThemeName:"" }; + if(initState){ S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0; S.dur = initState.dur || 2; S.score=+initState.score||0; S.tries=+initState.tries||0;S.assignmentWords=Array.isArray(initState.assignmentWords)?initState.assignmentWords:null;S.assignmentThemeName=typeof initState.assignmentThemeName==="string"?initState.assignmentThemeName:""; } + const assignedRules=languageTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&assignedRules)S.dur=assignedRules.flashSeconds; + let flashTimer = null, assessed=false; + + root.innerHTML = ` +
+
+ + +
+
+

+
+
• • •
+
+
+ +
+ + + +
+
`; + const el = s => root.querySelector(s); + const srcSel = el(".fl-src"), durSel = el(".fl-dur"), + wordEl = el(".fl-word"), picEl = el(".fl-pic"), cntEl = el(".fl-cnt"), reflect=el(".fl-reflect"); + + function list(){ + if(S.assignmentWords?.length)return languageWidgetWords(S.assignmentWords,S.yearGroup,S.level,"flits"); + S.card = normWordSource(S.card);const selected=languageWidgetWords(wordSourceList(S.card),S.yearGroup,S.level,"flits"); + if(selected.length)return selected;S.card="g";return languageWidgetWords(wordSourceList("g"),S.yearGroup,S.level,"flits"); + } + function buildSrc(){ + if(S.assignmentWords?.length){srcSel.innerHTML="";srcSel.appendChild(new Option("🎯 "+(S.assignmentThemeName||T("castTheme")),"assignment"));srcSel.disabled=true;} + else{srcSel.disabled=false;fillWordSourceSel(srcSel, S.card,S.yearGroup,S.level);S.card=srcSel.value||"g";} + durSel.innerHTML = ""; + [0.5,1,2,3,5].forEach(d=>{ + const o = document.createElement("option"); + o.value = d; o.textContent = d+" "+T("flSec"); + durSel.appendChild(o); + }); + durSel.value = S.dur; + } + function newRound(){ + S.order = shuffle(list().map((_,i)=>i)); + S.idx = 0; + } + function cur(){ return list()[S.order[S.idx]]; } + function score(){ cntEl.textContent = `${T("word")} ${S.idx+1}/${S.order.length} · ${T("flScore").replace("{score}",S.score)}`; } + function hide(ask=false){ + wordEl.textContent = "• • •"; + wordEl.classList.add("dim"); + picEl.textContent = ""; + reflect.hidden=!ask||assessed; + } + function flash(){ + clearTimeout(flashTimer); + assessed=false; + wordEl.textContent = cur()[0]; + wordEl.classList.remove("dim"); + picEl.textContent = ""; + reflect.hidden=true; score(); + flashTimer = setTimeout(()=>hide(true), S.dur*1000); + } + function show(){ + clearTimeout(flashTimer); + wordEl.textContent = cur()[0]; + wordEl.classList.remove("dim"); + setPic(picEl, cur()[1]); + reflect.hidden=assessed; + } + function assess(ok){ + if(assessed)return;assessed=true; + S.tries++; if(ok){S.score++;sndGood();confetti(el('.mt-card'));}else sndBad(); + /* de zelfcheck van de leerling ("gelukt" / "nog oefenen") was tot nu toe + alleen een sessieteller; het is het enige goed/fout-signaal dat deze + widget heeft en dus precies wat de voortgang bij taal miste */ + if(onProgress) onProgress(languageProgressPayload({ + word: cur()[0], solved: ok, stars: ok?1:0, selfCheck: true })); + score(); reflect.hidden=true; + } + el(".fl-flash").addEventListener("click", flash); + el(".fl-show").addEventListener("click", show); + el(".fl-got").addEventListener("click",()=>assess(true)); + el(".fl-again").addEventListener("click",()=>assess(false)); + el(".fl-next").addEventListener("click", ()=>{ + S.idx++; + if(S.idx >= S.order.length){ celebrateAvatar(root); newRound(); } + flash(); + }); + srcSel.addEventListener("change", ()=>{ + S.card = srcSel.value; + newRound(); hide(); + cntEl.textContent = ""; + }); + durSel.addEventListener("change", ()=>{ S.dur = +durSel.value; }); + function labels(){ + el(".fl-flash").textContent = T("flFlash"); + el(".fl-show").textContent = T("flShow"); + el(".fl-next").textContent = T("flNext"); + const letters=languageTaskRules(S.yearGroup,S.level)?.literacyMode==="letters"; + el(".fl-mission").textContent=letters?(LANG==="nl"?"Bekijk en benoem de letter.":"Look at and name the letter."):T("flMission"); + el(".fl-question").textContent=letters?(LANG==="nl"?"Herkende je de letter?":"Did you recognise the letter?"):T("flQuestion"); + el(".fl-got").textContent=T("flGotIt"); + el(".fl-again").textContent=T("flAgain"); + buildSrc(); + if(S.order.length)score(); + } + document.addEventListener("langchange", ()=>{ if(!S.assignmentWords)S.card=0; labels(); newRound(); hide(); }); + document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); hide(); }); + labels(); newRound(); + root.classList.toggle("managed-learning-level",S.managedLearningLevel); + return { getState: ()=>({card:S.card, dur:S.dur, score:S.score, tries:S.tries}) }; +} diff --git a/public/js/widgets/hak.js b/public/js/widgets/hak.js new file mode 100644 index 0000000..2ac819c --- /dev/null +++ b/public/js/widgets/hak.js @@ -0,0 +1,147 @@ +/* widget: Hakken & plakken */ +/* Veelvoorkomende spellingpatronen blijven als herkenbare klankgroep bij elkaar. + De langste groep wint, zodat bijvoorbeeld 'sch' niet als 's' + 'ch' eindigt. */ +function soundChunks(word, lang=LANG){ + const patterns=(lang==='nl' + ? ['sch','aai','ooi','oei','eeuw','ieuw','ng','nk','ch','aa','ee','oo','uu','oe','ie','ei','ij','ou','au','ui','eu'] + : ['tch','igh','sh','ch','th','ph','ng','ee','oo','ea','ou','ow','ai','ay','oi']).sort((a,b)=>b.length-a.length); + const chunks=[]; + for(let i=0;irest.startsWith(pattern)); + const length=match?match.length:1; + chunks.push(word.slice(i,i+length)); i+=length; + } + return chunks; +} + +/* ========================================================= + Chop & blend widget — words in sounds, with read-aloud +==========================================================*/ +function mountHak(root, initState, opts){ + const onProgress = opts && opts.onProgress; + const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, idx:0, order:[], chopped:false, assignmentWords:null, assignmentThemeName:"" }; + /* deze widget had als enige taalwidget geen goed/fout-moment: hakken, plakken + en voorlezen kun je niet fout doen. De zelfcheck van flits.js (lukte het + zelf?) geeft de leerling er één zonder de oefening zelf te veranderen - + hij verschijnt pas nadat het woord onthuld is, dus na een eerlijke poging */ + let assessed = false; + if(initState){S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0;S.assignmentWords=Array.isArray(initState.assignmentWords)?initState.assignmentWords:null;S.assignmentThemeName=typeof initState.assignmentThemeName==="string"?initState.assignmentThemeName:"";} + const canSpeak = "speechSynthesis" in window; + + root.innerHTML = ` +
+
+ +
+
+

+
+
+
+
+ +
+ + + + +
+
`; + const el = s => root.querySelector(s); + const srcSel = el(".hk-src"), wordEl = el(".hk-word"), + picEl = el(".hk-pic"), cntEl = el(".hk-cnt"), reflect = el(".hk-reflect"); + + function list(){ + if(S.assignmentWords?.length)return languageWidgetWords(S.assignmentWords,S.yearGroup,S.level,"hak"); + S.card = normWordSource(S.card);const selected=languageWidgetWords(wordSourceList(S.card),S.yearGroup,S.level,"hak"); + if(selected.length)return selected;S.card="g";return languageWidgetWords(wordSourceList("g"),S.yearGroup,S.level,"hak"); + } + function buildSrc(){if(S.assignmentWords?.length){srcSel.innerHTML="";srcSel.appendChild(new Option("🎯 "+(S.assignmentThemeName||T("castTheme")),"assignment"));srcSel.disabled=true;}else{srcSel.disabled=false;fillWordSourceSel(srcSel,S.card,S.yearGroup,S.level);S.card=srcSel.value||"g";}} + function cur(){ return list()[S.order[S.idx]]; } + function newRound(){ S.order = shuffle(list().map((_,i)=>i)); S.idx = 0; } + function display(){ + const [w, pic,, anchor] = cur(); + const letters=languageTaskRules(S.yearGroup,S.level)?.literacyMode==="letters"; + wordEl.innerHTML = ""; + if(letters&&!S.chopped){ + const s=document.createElement("span");s.className="hk-whole";s.textContent="?";wordEl.appendChild(s); + }else if(S.chopped){ + soundChunks(w).forEach((l,i)=>{ + if(i>0){ + const sep = document.createElement("span"); + sep.className = "hk-sep"; sep.textContent = "–"; + wordEl.appendChild(sep); + } + const s = document.createElement("span"); + s.className = "hk-l " + (/[aeiouyáéíóúäëïöü]/i.test(l) ? "v" : "c"); + s.textContent = l; + s.style.animationDelay = (i*0.08)+"s"; + wordEl.appendChild(s); + }); + }else{ + const s = document.createElement("span"); + s.className = "hk-whole"; + s.textContent = w; + wordEl.appendChild(s); + } + setPic(picEl, pic); + picEl.title = anchor||w; + cntEl.textContent = `${T("word")} ${S.idx+1}/${S.order.length}`; + } + function speak(text, rate){ + if(!canSpeak) return; + speechSynthesis.cancel(); + const u = new SpeechSynthesisUtterance(text); + u.lang = LANG==="nl" ? "nl-NL" : "en-GB"; + u.rate = rate; + speechSynthesis.speak(u); + } + el(".hk-say").addEventListener("click", ()=>{ + const [word,,,anchor]=cur(),letters=languageTaskRules(S.yearGroup,S.level)?.literacyMode==="letters"; + if(letters)speak(anchor||word,0.75); + else if(S.chopped)speak(soundChunks(word).join(", "),0.7); + else speak(word,0.85); + }); + function assess(ok){ + if(assessed) return; + assessed = true; + if(ok){ sndGood(); confetti(el(".mt-card")); } else sndBad(); + if(onProgress) onProgress(languageProgressPayload({ + word: cur()[0], solved: ok, stars: ok?1:0, selfCheck: true })); + reflect.hidden = true; + } + function askSelfCheck(){ reflect.hidden = assessed; } + el(".hk-chop").addEventListener("click", ()=>{ S.chopped = true; display(); play(sndGood); askSelfCheck(); }); + el(".hk-glue").addEventListener("click", ()=>{ S.chopped = false; display(); play(sndGood); askSelfCheck(); }); + el(".hk-got").addEventListener("click", ()=>assess(true)); + el(".hk-again").addEventListener("click", ()=>assess(false)); + el(".hk-next").addEventListener("click", ()=>{ + S.idx++; if(S.idx >= S.order.length) newRound(); + S.chopped = false; assessed = false; reflect.hidden = true; display(); + }); + const play = f=>f(); + srcSel.addEventListener("change", ()=>{ + S.card = srcSel.value; + newRound(); S.chopped = false; assessed = false; reflect.hidden = true; display(); + }); + function labels(){ + const letters=languageTaskRules(S.yearGroup,S.level)?.literacyMode==="letters"; + el(".hk-question").textContent = T(letters?"hakCheckLetter":"hakCheck"); + el(".hk-got").textContent = T("flGotIt"); + el(".hk-again").textContent = T("flAgain"); + el(".hk-say").textContent = T("hakSay"); + el(".hk-chop").textContent = letters?(LANG==="nl"?"Toon letter":"Show letter"):T("hakChop"); + el(".hk-glue").textContent = letters?(LANG==="nl"?"Verberg letter":"Hide letter"):T("hakGlue"); + el(".hk-hint").textContent = letters?(LANG==="nl"?"Luister naar het woord. Welke letter hoor je vooraan?":"Listen to the word. Which letter do you hear first?"):T("hakHint"); + buildSrc(); + } + document.addEventListener("langchange", ()=>{ if(!S.assignmentWords)S.card=0; labels(); newRound(); S.chopped=false; assessed=false; reflect.hidden=true; display(); }); + document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); S.chopped=false; assessed=false; reflect.hidden=true; display(); }); + labels(); newRound(); display(); + root.classList.toggle("managed-learning-level",S.managedLearningLevel); + return { getState: ()=>({card:S.card}) }; +} diff --git a/public/js/widgets/learning.js b/public/js/widgets/learning.js new file mode 100644 index 0000000..d9f9843 --- /dev/null +++ b/public/js/widgets/learning.js @@ -0,0 +1,838 @@ +const MONEY_LEVELS=Object.freeze({ + "1": { + "min": 100, + "max": 900, + "step": 100, + "denominations": [ + 200, + 100 + ], + "payments": [ + 500, + 1000 + ], + "fixedIndices": [ + 0, + 2, + 3 + ], + "emptyDigits": [ + 0, + null, + 0, + 0 + ] + }, + "2": { + "min": 50, + "max": 995, + "step": 5, + "denominations": [ + 500, + 200, + 100, + 50, + 20, + 10, + 5 + ], + "payments": [ + 500, + 1000, + 2000 + ], + "fixedIndices": [ + 0 + ], + "emptyDigits": [ + 0, + null, + null, + null + ] + }, + "3": { + "min": 1, + "max": 4999, + "step": 1, + "denominations": [ + 2000, + 1000, + 500, + 200, + 100, + 50, + 20, + 10, + 5, + 2, + 1 + ], + "payments": [ + 100, + 200, + 500, + 1000, + 2000, + 5000 + ], + "fixedIndices": [], + "emptyDigits": [ + null, + null, + null, + null + ] + } +}); +const MONEY_PRODUCTS=Object.freeze([ + { + "id": "apple", + "icon": "🍎", + "category": "food", + "nl": "Appel", + "en": "Apple", + "prices": { + "1": [ + 100 + ], + "2": [ + 75, + 100 + ], + "3": [ + 69, + 79, + 99 + ] + } + }, + { + "id": "bananas", + "icon": "🍌", + "category": "food", + "nl": "Tros bananen", + "en": "Bunch of bananas", + "prices": { + "1": [ + 100, + 200 + ], + "2": [ + 100, + 150 + ], + "3": [ + 99, + 145, + 149 + ] + } + }, + { + "id": "juice", + "icon": "🧃", + "category": "food", + "nl": "Pakje sap", + "en": "Juice box", + "prices": { + "1": [ + 200, + 300 + ], + "2": [ + 200, + 250 + ], + "3": [ + 199, + 229, + 259 + ] + } + }, + { + "id": "sandwich", + "icon": "🥪", + "category": "food", + "nl": "Broodje", + "en": "Sandwich", + "prices": { + "1": [ + 400, + 500 + ], + "2": [ + 350, + 450, + 550 + ], + "3": [ + 349, + 449, + 549 + ] + } + }, + { + "id": "yogurt", + "icon": "🥣", + "category": "food", + "nl": "Yoghurt", + "en": "Yogurt", + "prices": { + "1": [ + 100, + 200 + ], + "2": [ + 100, + 150, + 200 + ], + "3": [ + 99, + 159, + 189 + ] + } + }, + { + "id": "cookie", + "icon": "🍪", + "category": "food", + "nl": "Koek", + "en": "Cookie", + "prices": { + "1": [ + 100, + 200 + ], + "2": [ + 100, + 150, + 250 + ], + "3": [ + 99, + 149, + 249 + ] + } + }, + { + "id": "water", + "icon": "💧", + "category": "food", + "nl": "Flesje water", + "en": "Bottle of water", + "prices": { + "1": [ + 100, + 200 + ], + "2": [ + 100, + 150, + 200 + ], + "3": [ + 99, + 149, + 199 + ] + } + }, + { + "id": "icecream", + "icon": "🍦", + "category": "food", + "nl": "IJsje", + "en": "Ice cream", + "prices": { + "1": [ + 200, + 300 + ], + "2": [ + 200, + 250, + 350 + ], + "3": [ + 199, + 249, + 349 + ] + } + }, + { + "id": "pencil", + "icon": "✏️", + "category": "school", + "nl": "Potlood", + "en": "Pencil", + "prices": { + "1": [ + 100 + ], + "2": [ + 50, + 100 + ], + "3": [ + 49, + 79, + 99 + ] + } + }, + { + "id": "eraser", + "icon": "🧽", + "category": "school", + "nl": "Gum", + "en": "Eraser", + "prices": { + "1": [ + 100 + ], + "2": [ + 50, + 100 + ], + "3": [ + 49, + 69, + 99 + ] + } + }, + { + "id": "ruler", + "icon": "📏", + "category": "school", + "nl": "Liniaal", + "en": "Ruler", + "prices": { + "1": [ + 100, + 200 + ], + "2": [ + 100, + 150, + 200 + ], + "3": [ + 99, + 149, + 199 + ] + } + }, + { + "id": "notebook", + "icon": "📓", + "category": "school", + "nl": "Schrift", + "en": "Notebook", + "prices": { + "1": [ + 300, + 400 + ], + "2": [ + 250, + 350, + 450 + ], + "3": [ + 249, + 349, + 449 + ] + } + }, + { + "id": "book", + "icon": "📘", + "category": "school", + "nl": "Boek", + "en": "Book", + "prices": { + "1": [ + 700, + 900 + ], + "2": [ + 695, + 895, + 995 + ], + "3": [ + 699, + 899, + 1299 + ] + } + }, + { + "id": "markers", + "icon": "🖍️", + "category": "school", + "nl": "Stiften", + "en": "Markers", + "prices": { + "1": [ + 500, + 700 + ], + "2": [ + 495, + 695, + 895 + ], + "3": [ + 499, + 699, + 899 + ] + } + }, + { + "id": "scissors", + "icon": "✂️", + "category": "school", + "nl": "Schaar", + "en": "Scissors", + "prices": { + "1": [ + 300, + 400 + ], + "2": [ + 250, + 350, + 450 + ], + "3": [ + 249, + 349, + 449 + ] + } + }, + { + "id": "backpack", + "icon": "🎒", + "category": "school", + "nl": "Rugzak", + "en": "Backpack", + "prices": { + "3": [ + 2499, + 2999, + 3499 + ] + } + }, + { + "id": "calculator", + "icon": "🧮", + "category": "school", + "nl": "Rekenmachine", + "en": "Calculator", + "prices": { + "1": [ + 900 + ], + "2": [ + 895, + 995 + ], + "3": [ + 899, + 1299, + 1799 + ] + } + }, + { + "id": "ball", + "icon": "⚽", + "category": "play", + "nl": "Bal", + "en": "Ball", + "prices": { + "1": [ + 600, + 900 + ], + "2": [ + 600, + 900 + ], + "3": [ + 599, + 899, + 999 + ] + } + }, + { + "id": "teddy", + "icon": "🧸", + "category": "play", + "nl": "Knuffel", + "en": "Teddy bear", + "prices": { + "1": [ + 900 + ], + "2": [ + 995 + ], + "3": [ + 999, + 1499, + 1899 + ] + } + }, + { + "id": "puzzle", + "icon": "🧩", + "category": "play", + "nl": "Puzzel", + "en": "Puzzle", + "prices": { + "1": [ + 800, + 900 + ], + "2": [ + 795, + 995 + ], + "3": [ + 799, + 999, + 1499 + ] + } + }, + { + "id": "rope", + "icon": "➰", + "category": "play", + "nl": "Springtouw", + "en": "Skipping rope", + "prices": { + "1": [ + 300, + 600 + ], + "2": [ + 300, + 600 + ], + "3": [ + 299, + 599, + 699 + ] + } + }, + { + "id": "kite", + "icon": "🪁", + "category": "play", + "nl": "Vlieger", + "en": "Kite", + "prices": { + "1": [ + 700, + 900 + ], + "2": [ + 695, + 895 + ], + "3": [ + 699, + 999, + 1299 + ] + } + }, + { + "id": "blocks", + "icon": "🧱", + "category": "play", + "nl": "Bouwblokken", + "en": "Building blocks", + "prices": { + "1": [ + 900 + ], + "2": [ + 895, + 995 + ], + "3": [ + 999, + 1499, + 1999 + ] + } + }, + { + "id": "boardgame", + "icon": "🎲", + "category": "play", + "nl": "Spel", + "en": "Game", + "prices": { + "1": [ + 900 + ], + "2": [ + 995 + ], + "3": [ + 999, + 1499, + 2499 + ] + } + }, + { + "id": "skateboard", + "icon": "🛹", + "category": "play", + "nl": "Skateboard", + "en": "Skateboard", + "prices": { + "3": [ + 2999, + 3999, + 4999 + ] + } + } +]); + +const moneyPriceFitsLevel=(price,level)=>{const rule=MONEY_LEVELS[level];return Number.isInteger(price)&&price>=rule.min&&price<=rule.max&&(price-rule.min)%rule.step===0}; +function createMoneyTask(level,game,category='all',previousCents=null,previousProductId=null,random=Math.random){ + const rule=MONEY_LEVELS[level],rnd=max=>Math.floor(random()*max),randomAmount=()=>rule.min+rnd(Math.floor((rule.max-rule.min)/rule.step)+1)*rule.step; + let targetCents,productId=previousProductId; + if(game==='change'){ + const pool=MONEY_PRODUCTS.filter(item=>(category==='all'||item.category===category)&&(item.prices[level]||[]).some(price=>moneyPriceFitsLevel(price,level))); + const pick=()=>{const product=pool[rnd(pool.length)],prices=product.prices[level].filter(price=>moneyPriceFitsLevel(price,level));return{product,price:prices[rnd(prices.length)]}}; + let offer=pick();for(let attempt=0;attempt<8&&offer.product.id===previousProductId&&offer.price===previousCents;attempt++)offer=pick(); + targetCents=offer.price;productId=offer.product.id; + }else{targetCents=randomAmount();if(targetCents===previousCents)targetCents=randomAmount()} + const paidCents=rule.payments.find(value=>value>targetCents)||rule.payments[rule.payments.length-1]; + return{targetCents,paidCents,productId}; +} + +/* Acht compacte educatieve en klaswidgets. Alle invoer blijft tekstContent-safe. */ +function learningShell(root, settings, stage){ + root.innerHTML='
'; + root.querySelector('summary span').textContent=T('widgetSettings'); + root.querySelector('.edu-controls').append(...settings); + root.querySelector('.edu-stage').append(stage); +} +function field(label, el){const w=document.createElement('label');const s=document.createElement('span');s.textContent=label;w.append(s,el);return w} +function btn(text,fn,kind='ghost'){const b=document.createElement('button');b.type='button';b.className='tbtn '+kind;b.textContent=text;b.onclick=fn;return b} +function num(value,min,max){const i=document.createElement('input');i.type='number';i.value=value;i.min=min;i.max=max;return i} +function persistedLearningState(state,extra={}){ + /* Leerlinggebonden velden worden niet door de widget zelf bewaard. board.js + voegt op het auteursbord de expliciete leerjaarkeuze na getState weer toe. */ + const copy={...state,...extra};delete copy.yearGroup;delete copy.managedLearningLevel;return copy +} + +function mountFractions(root,init){ + const S=Object.assign({level:1,yearGroup:null,managedLearningLevel:false,den:4,show:true,activeD:0,activeN:0},init||{}),rules=mathTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&rules)S.den=rules.fractionDen;const den=num(S.den,2,12), show=document.createElement('input'), stage=document.createElement('div');show.type='checkbox';show.checked=S.show;den.disabled=!!S.managedLearningLevel; + const gcd=(a,b)=>b?gcd(b,a%b):a,summary=()=>{if(!S.activeD)return'';const divisor=gcd(S.activeN,S.activeD),fraction=`${S.activeN}/${S.activeD}`,simple=`${S.activeN/divisor}/${S.activeD/divisor}`;return T('fractionSelected').replace('{fraction}',divisor>1?`${fraction} = ${simple}`:fraction)}; + const draw=()=>{S.den=Math.max(2,Math.min(12,+den.value||4));S.show=show.checked;if(S.activeD>S.den){S.activeD=0;S.activeN=0}stage.className='fraction-game';stage.innerHTML='';const hint=document.createElement('p');hint.className='play-mission';hint.textContent=T('fractionHint');const result=document.createElement('output');result.className='fraction-summary';result.textContent=summary();const wall=document.createElement('div');wall.className='fraction-wall';for(let d=1;d<=S.den;d++){const row=document.createElement('div');for(let n=0;n{S.activeD=d;S.activeN=n+1;draw()};row.append(c)}wall.append(row)}const clear=btn(T('fractionClear'),()=>{S.activeD=0;S.activeN=0;draw()});clear.classList.add('fraction-clear');stage.append(hint,result,wall,clear)}; + den.onchange=show.onchange=draw;learningShell(root,[field(T('fractionRows'),den),field(T('fractionLabels'),show)],stage);root.classList.toggle('managed-learning-level',!!S.managedLearningLevel);draw();return{getState:()=>persistedLearningState(S)}; +} +const clockTurnDelta=(next,previous)=>((next-previous+540)%360)-180; +const clockHourCrossing=(next,previous)=>{const delta=clockTurnDelta(next,previous);return delta>0&&nextprevious?-1:0}; +const clockStepHour=(hour,step,use24)=>use24?(hour+step+24)%24:((hour-1+step+12)%12)+1; +const clock24Angle=(angle,delta)=>(angle+delta+720)%720; +const clock24Hour=(angle,minute)=>((Math.round((angle-minute/2)/30)%24)+24)%24; +function mountClock(root,init){ + const S=Object.assign({h:12,m:0,level:1,yearGroup:null,managedLearningLevel:false,style:'both',game:'set',amode:'blocks',input:'',linkHands:true,use24:true,task:true,targetH:3,targetM:30,score:0,streak:0,solved:false,feedback:'',choices:[],answerDigits:[null,null,null,null]},init||{}); + if(typeof S.level==='string')S.level={hour:1,quarter:2,five:2,minute:3}[S.level]||1;S.level=Math.max(1,Math.min(3,+S.level||1));const assignedRules=mathTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&assignedRules)S.level=assignedRules.clockLevel;S.amode=S.amode==='type'?'type':'blocks';S.input=String(S.input||'').replace(/\D/g,'').slice(0,4); + const style=document.createElement('select'),game=document.createElement('select'),linkHands=document.createElement('input'),use24=document.createElement('input'),stage=document.createElement('div'),add=(el,items)=>items.forEach(([value,key])=>el.add(new Option(T(key),value))); + add(style,[['analogue','clockStyleAnalogue'],['digital','clockStyleDigital'],['words','clockStyleWords'],['both','clockStyleBoth']]);add(game,[['set','clockGameSet'],['read','clockGameRead'],['choose','clockGameChoose']]);style.value=S.style;game.value=S.game;linkHands.type='checkbox';linkHands.checked=S.linkHands;linkHands.onchange=()=>{S.linkHands=linkHands.checked};use24.type='checkbox';use24.checked=S.use24;use24.onchange=()=>{S.use24=use24.checked;newTask()};const linkSetting=field(T('clockLinkHands'),linkHands),hour24Setting=field(T('clock24Hour'),use24),syncSettings=()=>{linkSetting.hidden=game.value!=='set';hour24Setting.hidden=S.level!==3}; + const minuteValues=()=>assignedRules?.clockMinutes||(S.level===1?[0,30]:S.level===2?Array.from({length:12},(_,i)=>i*5):Array.from({length:60},(_,i)=>i)),time=(hour,minute)=>`${hour}:${String(minute).padStart(2,'0')}`; + const uses24=()=>S.level===3&&S.use24; + const daypartKey=hour=>hour<6?'clockNight':hour<12?'clockMorning':hour<18?'clockAfternoon':'clockEvening',clockHour=hour=>hour%12||12; + const words=(hour,minute)=>{const shown=clockHour(hour),next=shown===12?1:shown+1;let result;if(minute===0)result=T('clockWordsHour').replace('{hour}',shown);else if(minute===15)result=T('clockWordsQuarterPast').replace('{hour}',shown);else if(minute===30)result=T('clockWordsHalf').replace('{hour}',next);else if(minute===45)result=T('clockWordsQuarterTo').replace('{hour}',next);else result=T(minute<30?'clockWordsPast':'clockWordsTo').replace('{minutes}',minute<30?minute:60-minute).replace('{hour}',minute<30?shown:next);return uses24()?result+' '+T(daypartKey(hour)):result}; + const randomTime=()=>{const values=minuteValues();return{h:uses24()?Math.floor(Math.random()*24):1+Math.floor(Math.random()*12),m:values[Math.floor(Math.random()*values.length)]}}; + const woodDigit=value=>{const block=document.createElement('span');block.className='blok sm clock-digit-block';const label=document.createElement('span');label.className='bl';label.textContent=String(value);const dots=dotGrid(Number(value));dots.classList.add('blok-dots');block.append(label,dots);return block}; + const timeBlocks=(hour,minute,click)=>{const el=document.createElement(click?'button':'div');if(click)el.type='button';el.className='clock-time-blocks';el.setAttribute('aria-label',time(hour,minute));const digits=String(hour).padStart(2,'0')+String(minute).padStart(2,'0');el.append(woodDigit(digits[0]),woodDigit(digits[1]));const colon=document.createElement('strong');colon.textContent=':';el.append(colon,woodDigit(digits[2]),woodDigit(digits[3]));if(click)el.onclick=click;return el}; + const makeClock=(hour,minute,interactive)=>{ + const face=document.createElement('div');face.className='clock'+(interactive?' interactive':'');face.setAttribute('role','img');face.setAttribute('aria-label',time(hour,minute)); + for(let i=0;i<60;i++){const tick=document.createElement('i');tick.className=i%5===0?'clock-tick hour':'clock-tick minor';tick.style.transform=`rotate(${i*6}deg)`;face.append(tick)} + [12,1,2,3,4,5,6,7,8,9,10,11].forEach((n,i)=>{const mark=document.createElement('b');mark.textContent=n;mark.style.setProperty('--clock-angle',`${i*30}deg`);face.append(mark)}); + const hourHand=document.createElement('i'),minuteHand=document.createElement('i'),pin=document.createElement('span');hourHand.className='clock-hand clock-hour';minuteHand.className='clock-hand clock-min';pin.className='clock-pin'; + const paint=()=>{hourHand.style.transform=`rotate(${S.h*30+S.m/2}deg)`;minuteHand.style.transform=`rotate(${S.m*6}deg)`};if(interactive)paint();else{hourHand.style.transform=`rotate(${hour*30+minute/2}deg)`;minuteHand.style.transform=`rotate(${minute*6}deg)`}face.append(hourHand,minuteHand,pin); + if(interactive){ + const updateAnswer=()=>{face.setAttribute('aria-label',time(S.h,S.m));const answer=stage.querySelector('.clock-live-answer');if(answer){answer.innerHTML='';answer.append(timeBlocks(S.h,S.m))}stage.querySelectorAll('.clock-period .tbtn').forEach(button=>{const active=+button.dataset.period===(S.h>=12?12:0);button.classList.toggle('active',active);button.setAttribute('aria-pressed',String(active))})}; + const move=e=>{const r=face.getBoundingClientRect(),dx=e.clientX-r.left-r.width/2,dy=e.clientY-r.top-r.height/2,deg=(Math.atan2(dx,-dy)*180/Math.PI+360)%360; + if(face.dataset.hand==='minute'){const previous=face._lastPointerAngle;if(Number.isFinite(previous)){const step=clockHourCrossing(deg,previous);if(step)S.h=clockStepHour(S.h,step,uses24())}face._lastPointerAngle=deg;const wanted=Math.round(deg/6)%60,allowed=minuteValues(),distance=v=>Math.min(Math.abs(v-wanted),60-Math.abs(v-wanted));S.m=allowed.reduce((best,v)=>distance(v){const angle=hour*30+minute/2,difference=Math.min(Math.abs(angle-face._dragAngle),720-Math.abs(angle-face._dragAngle));if(difference{const angle=(hour%12)*30+minute/2,difference=Math.min(Math.abs(angle-deg),360-Math.abs(angle-deg));if(difference{delete face.dataset.hand;delete face._dragAngle;delete face._lastPointerAngle}; + const start=(e,hand)=>{if(S.solved)return;e.preventDefault();e.stopPropagation();face.dataset.hand=hand;if(hand==='minute')face._lastPointerAngle=null;else if(uses24()){face._dragAngle=S.h*30+S.m/2;face._lastPointerAngle=null}face.setPointerCapture(e.pointerId);move(e)}; + hourHand.onpointerdown=e=>start(e,'hour');minuteHand.onpointerdown=e=>start(e,'minute'); + face.onpointermove=e=>{if(face.hasPointerCapture(e.pointerId))move(e)}; + face.onpointerup=e=>{move(e);face.releasePointerCapture(e.pointerId);clearDrag()}; + face.onpointercancel=e=>{if(face.hasPointerCapture(e.pointerId))face.releasePointerCapture(e.pointerId);clearDrag()} + } + return face + }; + const assignment=(hour,minute,compact=false)=>{const wrap=document.createElement('div');wrap.className='clock-assignment'+(compact?' compact':'');if(['analogue','both'].includes(S.style)){const clock=makeClock(hour,minute,false);if(compact)clock.classList.add('clock-reference');wrap.append(clock)}if(['digital','both'].includes(S.style)){const digital=document.createElement('output');digital.className='clock-task-digital';digital.textContent=time(hour,minute);wrap.append(digital)}if(['words','both'].includes(S.style)){const written=document.createElement('p');written.className='clock-task-words';written.textContent=words(hour,minute);wrap.append(written)}if(uses24()&&S.style==='analogue'){const period=document.createElement('span');period.className='clock-daypart';period.textContent=T(daypartKey(hour));wrap.append(period)}return wrap}; + const periodControl=()=>{const control=document.createElement('div');control.className='clock-period';control.setAttribute('aria-label',T('clock24Hour'));[0,12].forEach(period=>{const button=btn(period?'12–23':'00–11',()=>{if(S.solved)return;const dial=clockHour(S.h);S.h=(dial%12)+period;S.feedback='';draw()},'ghost');button.dataset.period=period;button.title=period?'12:00–23:59':'00:00–11:59';const active=(S.h>=12?12:0)===period;button.classList.toggle('active',active);button.setAttribute('aria-pressed',String(active));control.append(button)});return control}; + const blockAnswer=()=>{const answer=document.createElement('div');answer.className='clock-block-answer';const instruction=document.createElement('p');instruction.textContent=T('clockBuildHint');const slots=document.createElement('div');slots.className='clock-answer-slots'; + /* zelfde patroon als letterblokken/rekenblokken: het vak toont altijd het + puntenpatroon van het júíste cijfer op die plek (niet van wat er nu in + staat) - dat is de tastbare hint, ongeacht of er al iets ligt. */ + const targetDigits=String(S.targetH).padStart(2,'0')+String(S.targetM).padStart(2,'0'); + const makeSlot=index=>{const slot=document.createElement('div');slot.className='slot sm clock-digit-slot'+(S.answerDigits[index]!==null?' filled':'');slot.dataset.index=index;const ghost=document.createElement('div');ghost.className='ghost';ghost.textContent=T('clockDigitGhost');const dots=dotGrid(targetDigits[index]);dots.classList.add('slot-dots');slot.append(ghost,dots);if(S.answerDigits[index]!==null){const placed=woodDigit(S.answerDigits[index]);placed.classList.add('in-slot','clock-drag-block');slot.append(placed);slot.onclick=()=>{if(!S.solved){S.answerDigits[index]=null;S.feedback='';draw()}}}return slot};slots.append(makeSlot(0),makeSlot(1));const colon=document.createElement('strong');colon.className='clock-slot-colon';colon.textContent=':';slots.append(colon,makeSlot(2),makeSlot(3));const tray=document.createElement('div');tray.className='clock-block-tray lg-tray';const attachDrag=block=>{block.onpointerdown=e=>{if(S.solved||(e.pointerType==='mouse'&&e.button!==0))return;e.preventDefault();try{block.releasePointerCapture(e.pointerId)}catch(_){}const clone=block.cloneNode(true);clone.id='dragClone';document.body.append(clone);block.classList.add('dragging');const moveTo=ev=>{const zoom=VZ();clone.style.left=ev.clientX/zoom+'px';clone.style.top=ev.clientY/zoom+'px'},hit=ev=>{let found=null;slots.querySelectorAll('.clock-digit-slot').forEach(slot=>{const r=slot.getBoundingClientRect(),pad=8;if(ev.clientX>=r.left-pad&&ev.clientX<=r.right+pad&&ev.clientY>=r.top-pad&&ev.clientY<=r.bottom+pad)found=slot});return found};moveTo(e);let hovered=null;const move=ev=>{moveTo(ev);const target=hit(ev);if(hovered&&hovered!==target)hovered.classList.remove('droptarget');hovered=target;if(hovered)hovered.classList.add('droptarget')},clean=()=>{document.removeEventListener('pointermove',move);document.removeEventListener('pointerup',up);document.removeEventListener('pointercancel',cancel);clone.remove();block.classList.remove('dragging');if(hovered)hovered.classList.remove('droptarget')},up=ev=>{const target=hit(ev);clean();if(target){S.answerDigits[+target.dataset.index]=+block.dataset.digit;S.feedback='';draw()}},cancel=()=>clean();document.addEventListener('pointermove',move);document.addEventListener('pointerup',up);document.addEventListener('pointercancel',cancel)}};'0123456789'.split('').forEach(d=>{const block=woodDigit(+d);block.classList.add('clock-drag-block');block.dataset.digit=d;block.setAttribute('role','button');block.setAttribute('aria-label',T('clockDigit').replace('{digit}',d));block.tabIndex=0;block.onkeydown=e=>{if((e.key==='Enter'||e.key===' ')&&!S.solved){e.preventDefault();const index=S.answerDigits.indexOf(null);if(index>=0){S.answerDigits[index]=+d;draw()}}};attachDrag(block);tray.append(block)});answer.append(instruction,slots,tray);return answer}; + const setFeedback=ok=>{if(S.solved)return;if(ok){S.solved=true;S.score++;S.streak++;S.feedback='good';sndGood()}else{S.streak=0;S.feedback='bad';sndBad()}draw()}; + const check=()=>{if(!S.task)return;if(S.game==='read'){if(S.amode==='blocks'){if(S.answerDigits.some(x=>x===null)){S.feedback='incomplete';sndBad();draw();return}S.h=Number(''+S.answerDigits[0]+S.answerDigits[1]);S.m=Number(''+S.answerDigits[2]+S.answerDigits[3])}else{if(S.input.length<4){S.feedback='incomplete';sndBad();draw();return}S.h=+S.input.slice(0,2);S.m=+S.input.slice(2,4)}}const expectedHour=S.game==='set'?(uses24()?S.targetH:clockHour(S.targetH)):S.targetH;setFeedback(S.h===expectedHour&&S.m===S.targetM)}; + const typeAnswer=()=>{const wrap=document.createElement('div');wrap.className='clock-type-answer';const hint=document.createElement('p');hint.textContent=T('clockTypeHint');const display=document.createElement('output');display.className='clock-type-display';const shown=(S.input+'____').slice(0,4);display.textContent=shown.slice(0,2)+':'+shown.slice(2);const pad=document.createElement('div');pad.className='clock-keypad';['7','8','9','⌫','4','5','6','0','1','2','3','✓'].forEach(key=>{const button=btn(key,()=>{if(S.solved)return;if(key==='⌫')S.input=S.input.slice(0,-1);else if(key==='✓'){check();return}else if(S.input.length<4)S.input+=key;S.feedback='';draw()});if(key==='✓')button.classList.add('ok');pad.append(button)});wrap.append(hint,display,pad);return wrap}; + const newTask=()=>{const target=randomTime();S.targetH=target.h;S.targetM=target.m;S.h=uses24()?0:12;S.m=0;S.task=true;S.solved=false;S.feedback='';S.input='';S.choices=[];S.answerDigits=[null,null,null,null];if(S.game==='choose'){S.choices=[target];while(S.choices.length<3){const option=randomTime();if(!S.choices.some(x=>x.h===option.h&&x.m===option.m))S.choices.push(option)}S.choices.sort(()=>Math.random()-.5)}draw()}; + const chooseLevel=value=>{S.level=value;S.score=0;S.streak=0;syncSettings();newTask()},toggleMode=()=>{S.amode=S.amode==='blocks'?'type':'blocks';S.feedback='';S.input='';S.answerDigits=[null,null,null,null];draw()}; + const draw=()=>{S.style=style.value;S.game=game.value;stage.className='clock-app';stage.innerHTML='';const top=document.createElement('div');top.className='clock-top';const levelControl=document.createElement('div');levelControl.className='clock-level-control';const label=document.createElement('span');label.textContent=T('clockLevel');const levels=document.createElement('div');levels.className='lg-levels clock-levels';[1,2,3].forEach(value=>{const button=document.createElement('button');button.type='button';button.textContent=T('lvl'+value);button.title=T('clockLevel'+value+'Hint');button.classList.toggle('active',S.level===value);button.onclick=()=>chooseLevel(value);levels.append(button)});levelControl.append(label,levels);top.append(levelControl);if(S.game!=='set'){const mode=btn(S.amode==='blocks'?'🧱':'⌨️',toggleMode);mode.classList.add('clock-mode');mode.title=T('amode');top.append(mode)}const score=document.createElement('span');score.className='clock-score';score.textContent=T('clockScore').replace('{score}',S.score).replace('{streak}',S.streak);top.append(score);stage.append(top);const card=document.createElement('section');card.className='clock-task-card';const title=document.createElement('h3');title.textContent=S.game==='set'?T('clockSetTitle'):S.game==='read'?T('clockReadTitle'):T('clockChooseTitle');card.append(title); + if(S.task&&S.game==='set'){const prompt=document.createElement('div');prompt.className='clock-target';const label=document.createElement('span');label.textContent=T('clockYourTask');prompt.append(label,assignment(S.targetH,S.targetM,true));card.append(prompt);const work=document.createElement('div');work.className='clock-work';const answer=document.createElement('div');answer.className='clock-set-answer';if(uses24())answer.append(periodControl());const live=document.createElement('div');live.className='clock-live-answer';live.append(timeBlocks(S.h,S.m));answer.append(live);work.append(makeClock(S.h,S.m,true),answer);card.append(work);} + if(S.task&&S.game==='read'){const layout=document.createElement('div');layout.className='clock-read-layout';layout.append(assignment(S.targetH,S.targetM),S.amode==='blocks'?blockAnswer():typeAnswer());card.append(layout)} + if(S.task&&S.game==='choose'){card.append(assignment(S.targetH,S.targetM));if(!S.solved){const choices=document.createElement('div');choices.className='clock-choices '+(S.amode==='blocks'?'wood':'plain');const asWords=['digital','both'].includes(S.style);S.choices.forEach(option=>{const choose=()=>setFeedback(option.h===S.targetH&&option.m===S.targetM);if(asWords){const button=btn(words(option.h,option.m),choose);button.classList.add('clock-word-choice');choices.append(button)}else if(S.amode==='blocks')choices.append(timeBlocks(option.h,option.m,choose));else{const button=btn(time(option.h,option.m),choose);button.classList.add('clock-digital-choice');choices.append(button)}});card.append(choices)}} + const feedback=document.createElement('p');feedback.className='clock-feedback '+(S.feedback==='good'?'ok':S.feedback?'bad':'');feedback.setAttribute('role','status');if(S.feedback)feedback.textContent=T(S.feedback==='good'?'clockCorrect':S.feedback==='incomplete'?'clockIncomplete':'clockTryAgain');if(S.feedback==='good'&¤tUser&¤tUser.avatar){const cheer=renderAvatarFace(currentUser.avatar,'md');cheer.classList.add('av-cheer');feedback.append(cheer)}card.append(feedback);const actions=document.createElement('div');actions.className='clock-actions';if(S.task&&S.game!=='choose'&&!S.solved&&!(S.game==='read'&&S.amode==='type'))actions.append(btn(T('numberlineCheck'),check,''));actions.append(btn(T('clockNextTask'),newTask,'ghost'));card.append(actions);if(S.task&&S.game==='set'){const hint=document.createElement('small');hint.className='clock-drag-hint';hint.textContent=T('clockDragHint');card.append(hint)}stage.append(card)}; + style.onchange=draw;game.onchange=()=>{syncSettings();newTask()};learningShell(root,[field(T('clockLearningStyle'),style),field(T('clockGame'),game),linkSetting,hour24Setting],stage);root.classList.toggle('managed-learning-level',!!S.managedLearningLevel);syncSettings();newTask();return{getState:()=>persistedLearningState(S,{choices:S.choices.map(x=>({...x})),answerDigits:[...S.answerDigits]})}; +} +const PLACE_COLUMNS=['HD','TD','D','H','T','E']; +const placeLevelDigits=level=>level===1?2:level===2?3:6; +function mountPlacevalue(root,init){ + const S=Object.assign({number:1342,game:'explore',level:1,yearGroup:null,managedLearningLevel:false,target:null,answerDigits:null,score:0,streak:0,solved:false,feedback:''},init||{}); + S.level=Math.max(1,Math.min(3,+S.level||1));const assignedRules=mathTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&assignedRules)S.level=assignedRules.placeLevel;S.game=S.game==='build'?'build':'explore';if(!Array.isArray(S.answerDigits))S.answerDigits=null; + const input=num(S.number,0,999999),game=document.createElement('select'),stage=document.createElement('div'); + game.add(new Option(T('placeGameExplore'),'explore'));game.add(new Option(T('placeGameBuild'),'build'));game.value=S.game; + const gameSetting=field(T('placeGame'),game),numberSetting=field(T('placeNumber'),input),diceBtn=btn('🎲',()=>{input.value=Math.floor(Math.random()*999999);drawExplore()}); + const syncSettings=()=>{numberSetting.hidden=S.game!=='explore';diceBtn.hidden=S.game!=='explore'}; + const activeCols=()=>PLACE_COLUMNS.slice(6-placeLevelDigits(S.level)); + const maxForLevel=()=>Math.pow(10,placeLevelDigits(S.level))-1; + const woodDigit=value=>{const block=document.createElement('span');block.className='blok sm place-digit-block';const label=document.createElement('span');label.className='bl';label.textContent=String(value);const dots=dotGrid(Number(value));dots.classList.add('blok-dots');block.append(label,dots);return block}; + const drawExplore=()=>{S.number=Math.max(0,Math.min(999999,+input.value||0));const digits=String(S.number).padStart(6,'0').split('');stage.className='place-grid';stage.innerHTML='';PLACE_COLUMNS.forEach((x,i)=>{const d=document.createElement('div');const b=document.createElement('b');b.textContent=x;const o=document.createElement('output');o.textContent=digits[i];d.append(b,o);stage.append(d)})}; + const setFeedback=ok=>{if(S.solved)return;if(ok){S.solved=true;S.score++;S.streak++;S.feedback='good';sndGood()}else{S.streak=0;S.feedback='bad';sndBad()}drawBuild()}; + const check=()=>{if(S.answerDigits.some(d=>d===null)){S.feedback='incomplete';sndBad();drawBuild();return}setFeedback(Number(S.answerDigits.join(''))===S.target)}; + const needsNewTask=()=>S.target===null||!Array.isArray(S.answerDigits)||S.answerDigits.length!==activeCols().length; + const newTask=()=>{S.target=Math.floor(Math.random()*(maxForLevel()+1));S.answerDigits=activeCols().map(()=>null);S.solved=false;S.feedback='';drawBuild()}; + const chooseLevel=value=>{S.level=value;S.score=0;S.streak=0;newTask()}; + const buildAnswer=()=>{ + const answer=document.createElement('div');answer.className='place-block-answer';const instruction=document.createElement('p');instruction.textContent=T('placeBuildHint'); + const slots=document.createElement('div');slots.className='place-answer-slots';const cols=activeCols(); + /* zelfde patroon als letterblokken/rekenblokken: het vak toont altijd het + puntenpatroon van het júíste cijfer op die plek. */ + const targetDigits=String(S.target).padStart(cols.length,'0'); + const makeSlot=index=>{const wrap=document.createElement('div');wrap.className='place-slot-col';const colLabel=document.createElement('b');colLabel.textContent=cols[index]; + const slot=document.createElement('div');slot.className='slot sm place-digit-slot'+(S.answerDigits[index]!==null?' filled':'');slot.dataset.index=index; + const ghost=document.createElement('div');ghost.className='ghost';ghost.textContent=T('placeDigitGhost');const dots=dotGrid(targetDigits[index]);dots.classList.add('slot-dots');slot.append(ghost,dots); + if(S.answerDigits[index]!==null){const placed=woodDigit(S.answerDigits[index]);placed.classList.add('in-slot','place-drag-block');slot.append(placed);slot.onclick=()=>{if(!S.solved){S.answerDigits[index]=null;S.feedback='';drawBuild()}}} + wrap.append(colLabel,slot);return wrap}; + cols.forEach((_,i)=>slots.append(makeSlot(i))); + const tray=document.createElement('div');tray.className='place-digit-tray lg-tray'; + const attachDrag=block=>{block.onpointerdown=e=>{if(S.solved||(e.pointerType==='mouse'&&e.button!==0))return;e.preventDefault();try{block.releasePointerCapture(e.pointerId)}catch(_){}const clone=block.cloneNode(true);clone.id='dragClone';document.body.append(clone);block.classList.add('dragging');const moveTo=ev=>{const zoom=VZ();clone.style.left=ev.clientX/zoom+'px';clone.style.top=ev.clientY/zoom+'px'},hit=ev=>{let found=null;slots.querySelectorAll('.place-digit-slot').forEach(slot=>{const r=slot.getBoundingClientRect(),pad=8;if(ev.clientX>=r.left-pad&&ev.clientX<=r.right+pad&&ev.clientY>=r.top-pad&&ev.clientY<=r.bottom+pad)found=slot});return found};moveTo(e);let hovered=null;const move=ev=>{moveTo(ev);const target=hit(ev);if(hovered&&hovered!==target)hovered.classList.remove('droptarget');hovered=target;if(hovered)hovered.classList.add('droptarget')},clean=()=>{document.removeEventListener('pointermove',move);document.removeEventListener('pointerup',up);document.removeEventListener('pointercancel',cancel);clone.remove();block.classList.remove('dragging');if(hovered)hovered.classList.remove('droptarget')},up=ev=>{const target=hit(ev);clean();if(target){S.answerDigits[+target.dataset.index]=+block.dataset.digit;S.feedback='';drawBuild()}},cancel=()=>clean();document.addEventListener('pointermove',move);document.addEventListener('pointerup',up);document.addEventListener('pointercancel',cancel)}}; + '0123456789'.split('').forEach(d=>{const block=woodDigit(+d);block.classList.add('place-drag-block');block.dataset.digit=d;block.setAttribute('role','button');block.setAttribute('aria-label',T('clockDigit').replace('{digit}',d));block.tabIndex=0;block.onkeydown=e=>{if((e.key==='Enter'||e.key===' ')&&!S.solved){e.preventDefault();const index=S.answerDigits.indexOf(null);if(index>=0){S.answerDigits[index]=+d;drawBuild()}}};attachDrag(block);tray.append(block)}); + answer.append(instruction,slots,tray);return answer; + }; + const drawBuild=()=>{ + stage.className='place-app';stage.innerHTML='';const top=document.createElement('div');top.className='place-top'; + const levelControl=document.createElement('div');levelControl.className='place-level-control';const label=document.createElement('span');label.textContent=T('placeLevel'); + const levels=document.createElement('div');levels.className='lg-levels place-levels'; + [1,2,3].forEach(value=>{const button=document.createElement('button');button.type='button';button.textContent=T('lvl'+value);button.title=T('placeLevel'+value+'Hint');button.classList.toggle('active',S.level===value);button.onclick=()=>chooseLevel(value);levels.append(button)}); + levelControl.append(label,levels);top.append(levelControl); + const score=document.createElement('span');score.className='place-score';score.textContent=T('placeScore').replace('{score}',S.score).replace('{streak}',S.streak);top.append(score);stage.append(top); + const card=document.createElement('section');card.className='place-task-card';const title=document.createElement('h3');title.textContent=T('placeBuildTitle');card.append(title); + const target=document.createElement('div');target.className='place-target';const targetLabel=document.createElement('span');targetLabel.textContent=T('placeYourTask'); + const targetValue=document.createElement('output');targetValue.className='place-task-digital';targetValue.textContent=String(S.target);target.append(targetLabel,targetValue);card.append(target,buildAnswer()); + const feedback=document.createElement('p');feedback.className='place-feedback '+(S.feedback==='good'?'ok':S.feedback?'bad':'');feedback.setAttribute('role','status'); + if(S.feedback)feedback.textContent=T(S.feedback==='good'?'placeCorrect':S.feedback==='incomplete'?'placeIncomplete':'placeTryAgain');if(S.feedback==='good'&¤tUser&¤tUser.avatar){const cheer=renderAvatarFace(currentUser.avatar,'md');cheer.classList.add('av-cheer');feedback.append(cheer)}card.append(feedback); + const actions=document.createElement('div');actions.className='place-actions';if(!S.solved)actions.append(btn(T('numberlineCheck'),check,''));actions.append(btn(T('placeNextTask'),newTask,'ghost'));card.append(actions);stage.append(card); + }; + input.oninput=drawExplore;game.onchange=()=>{S.game=game.value;syncSettings();if(S.game!=='build')drawExplore();else if(needsNewTask())newTask();else drawBuild()}; + learningShell(root,[gameSetting,numberSetting,diceBtn],stage);root.classList.toggle('managed-learning-level',!!S.managedLearningLevel);syncSettings(); + if(S.game!=='build')drawExplore();else if(needsNewTask())newTask();else drawBuild(); + return{getState:()=>persistedLearningState(S,{answerDigits:S.answerDigits?[...S.answerDigits]:null})}; +} +function mountMoney(root,init){ + const legacy=init||{},S=Object.assign({level:1,yearGroup:null,managedLearningLevel:false,style:'both',game:'make',category:'all',productId:'juice',amode:'blocks',targetCents:375,paidCents:500,selected:[],given:[],input:'',answerDigits:[0,null,0,0],score:0,streak:0,solved:false,feedback:''},legacy); + if(legacy.targetCents==null&&Number.isFinite(legacy.target))S.targetCents=Math.round(legacy.target*100); + S.level=Math.max(1,Math.min(3,+S.level||1));const assignedRules=mathTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&assignedRules)S.level=assignedRules.moneyLevel;S.style=['amount','words','both'].includes(S.style)?S.style:'both';S.game=['make','count','change'].includes(S.game)?S.game:'make';S.category=['all','food','school','play'].includes(S.category)?S.category:'all';S.amode=S.amode==='type'?'type':'blocks';S.selected=Array.isArray(S.selected)?S.selected.map(Number).filter(Number.isFinite):[];S.given=Array.isArray(S.given)?S.given.map(Number).filter(Number.isFinite):[]; + const style=document.createElement('select'),game=document.createElement('select'),category=document.createElement('select'),stage=document.createElement('div'),add=(el,items)=>items.forEach(([value,key])=>el.add(new Option(T(key),value))); + add(style,[['amount','moneyStyleAmount'],['words','moneyStyleWords'],['both','moneyStyleBoth']]);add(game,[['make','moneyGameMake'],['count','moneyGameCount'],['change','moneyGameChange']]);add(category,[['all','moneyCategoryAll'],['food','moneyCategoryFood'],['school','moneyCategorySchool'],['play','moneyCategoryPlay']]);style.value=S.style;game.value=S.game;category.value=S.category; + const styleSetting=field(T('moneyLearningStyle'),style),gameSetting=field(T('moneyGame'),game),categorySetting=field(T('moneyCategory'),category),syncSettings=()=>{styleSetting.hidden=S.game==='count';categorySetting.hidden=S.game!=='change'}; + const format=cents=>{const value=(Math.max(0,cents)/100).toFixed(2);return '€ '+(LANG==='nl'?value.replace('.',','):value)}; + const nlSmall=['nul','een','twee','drie','vier','vijf','zes','zeven','acht','negen','tien','elf','twaalf','dertien','veertien','vijftien','zestien','zeventien','achttien','negentien'],nlTens=['','','twintig','dertig','veertig','vijftig','zestig','zeventig','tachtig','negentig']; + const enSmall=['zero','one','two','three','four','five','six','seven','eight','nine','ten','eleven','twelve','thirteen','fourteen','fifteen','sixteen','seventeen','eighteen','nineteen'],enTens=['','','twenty','thirty','forty','fifty','sixty','seventy','eighty','ninety']; + const numberWords=value=>{const n=Math.max(0,Math.min(99,value));if(LANG==='nl'){if(n<20)return nlSmall[n];const ones=n%10;return ones?nlSmall[ones]+nlTens[Math.floor(n/10)]:nlTens[Math.floor(n/10)]}if(n<20)return enSmall[n];const ones=n%10;return enTens[Math.floor(n/10)]+(ones?'-'+enSmall[ones]:'')}; + const amountWords=cents=>{const euros=Math.floor(cents/100),rest=cents%100;if(LANG==='nl')return numberWords(euros)+' euro'+(rest?' en '+numberWords(rest)+' cent':'');return numberWords(euros)+' euro'+(euros===1?'':'s')+(rest?' and '+numberWords(rest)+' cent'+(rest===1?'':'s'):'')}; + const levelRule=()=>MONEY_LEVELS[S.level],denominations=()=>levelRule().denominations; + const breakdown=amount=>{const result=[];let rest=amount;denominations().forEach(value=>{while(rest>=value&&result.length<24){result.push(value);rest-=value}});return result.sort(()=>Math.random()-.5)}; + const fixedIndices=()=>levelRule().fixedIndices,emptyDigits=()=>[...levelRule().emptyDigits],currentProduct=()=>MONEY_PRODUCTS.find(item=>item.id===S.productId)||MONEY_PRODUCTS[0]; + const legacyProduct=MONEY_PRODUCTS.find(item=>item.icon===legacy.product);if(!MONEY_PRODUCTS.some(item=>item.id===S.productId))S.productId=legacyProduct?.id||'juice';delete S.product; + const piece=(value,interactive=false)=>{const el=document.createElement(interactive?'button':'span');if(interactive)el.type='button';const type=value>=500?'note':'coin',extension=value>=500?'jpg':'png';el.className='money-piece '+type;el.dataset.value=value;const image=document.createElement('img'),fallback=document.createElement('span');image.className='money-piece-image';image.src=`/img/money/${type}-${value}.${extension}`;image.alt='';image.draggable=false;image.decoding='async';image.setAttribute('aria-hidden','true');image.onerror=()=>el.classList.add('image-missing');fallback.className='money-piece-fallback';fallback.textContent=value>=100?'€'+value/100:value+'c';el.append(image,fallback);el.setAttribute('aria-label',format(value));return el}; + const pile=(values,interactive=false,wallet=null)=>{const wrap=document.createElement('div');wrap.className='money-pile'+(interactive?' interactive':' readonly');values.forEach((value,index)=>{const el=piece(value,interactive);if(interactive){el.title=T('moneyRemove');el.onpointerdown=e=>{if(S.solved||(e.pointerType==='mouse'&&e.button!==0))return;e.preventDefault();const startX=e.clientX,startY=e.clientY,clone=el.cloneNode(true);clone.id='dragClone';clone.classList.add('money-drag-clone','from-wallet');document.body.append(clone);const moveTo=ev=>{const zoom=VZ();clone.style.left=ev.clientX/zoom+'px';clone.style.top=ev.clientY/zoom+'px'},over=ev=>{const r=wallet.getBoundingClientRect();return ev.clientX>=r.left&&ev.clientX<=r.right&&ev.clientY>=r.top&&ev.clientY<=r.bottom},hasMoved=ev=>Math.hypot(ev.clientX-startX,ev.clientY-startY)>=6;moveTo(e);const move=ev=>{moveTo(ev);wallet.classList.toggle('money-remove-target',hasMoved(ev)&&!over(ev))},clean=()=>{document.removeEventListener('pointermove',move);document.removeEventListener('pointerup',up);document.removeEventListener('pointercancel',cancel);wallet.classList.remove('money-remove-target');clone.remove()},remove=()=>{S.selected.splice(index,1);S.feedback='';draw()},up=ev=>{const moved=hasMoved(ev),outside=!over(ev);clean();if(!moved||(moved&&outside))remove()},cancel=()=>clean();document.addEventListener('pointermove',move);document.addEventListener('pointerup',up);document.addEventListener('pointercancel',cancel)};el.onkeydown=e=>{if((e.key==='Enter'||e.key===' '||e.key==='Delete'||e.key==='Backspace')&&!S.solved){e.preventDefault();S.selected.splice(index,1);S.feedback='';draw()}}}wrap.append(el)});return wrap}; + const amountDisplay=(cents,compact=false)=>{const wrap=document.createElement('div');wrap.className='money-assignment'+(compact?' compact':'');if(['amount','both'].includes(S.style)){const output=document.createElement('output');output.className='money-task-amount';output.textContent=format(cents);wrap.append(output)}if(['words','both'].includes(S.style)){const words=document.createElement('p');words.className='money-task-words';words.textContent=amountWords(cents);wrap.append(words)}return wrap}; + const woodDigit=value=>{const block=document.createElement('span');block.className='blok sm money-digit-block';block.dataset.digit=value;const label=document.createElement('span');label.className='bl';label.textContent=String(value);const dots=dotGrid(Number(value));dots.classList.add('blok-dots');block.append(label,dots);return block}; + const answerValue=()=>{if(S.amode==='blocks'){if(S.answerDigits.some(value=>value===null))return null;return Number(S.answerDigits.join(''))}if(!S.input)return null;const parts=S.input.replace('.',',').split(',');if(parts.length>2||parts[1]?.length>2)return null;return (+parts[0]||0)*100+Number((parts[1]||'').padEnd(2,'0')||0)}; + const digitAnswer=()=>{const answer=document.createElement('div');answer.className='money-digit-answer';const instruction=document.createElement('p');instruction.textContent=T('moneyBuildHint');const slots=document.createElement('div');slots.className='money-answer-slots';const fixed=new Set(fixedIndices()); + /* zelfde patroon als letterblokken/rekenblokken: het vak toont altijd het + puntenpatroon van het júíste cijfer op die plek. */ + const targetDigits=String(expected()).padStart(4,'0'); + const makeSlot=index=>{const slot=document.createElement('div');slot.className='slot sm money-digit-slot'+(fixed.has(index)?' fixed':'')+(S.answerDigits[index]!==null?' filled':'');slot.dataset.index=index;const ghost=document.createElement('div');ghost.className='ghost';ghost.textContent='?';const dots=dotGrid(targetDigits[index]);dots.classList.add('slot-dots');slot.append(ghost,dots);if(S.answerDigits[index]!==null){const placed=woodDigit(S.answerDigits[index]);placed.classList.add('in-slot','money-drag-digit');slot.append(placed);if(!fixed.has(index))slot.onclick=()=>{if(!S.solved){S.answerDigits[index]=null;S.feedback='';draw()}}}return slot}; + const euro=document.createElement('strong');euro.textContent='€';slots.append(euro,makeSlot(0),makeSlot(1));const comma=document.createElement('strong');comma.textContent=LANG==='nl'?',':'.';slots.append(comma,makeSlot(2),makeSlot(3)); + const tray=document.createElement('div');tray.className='money-digit-tray lg-tray';const attachDrag=block=>{block.onpointerdown=e=>{if(S.solved||(e.pointerType==='mouse'&&e.button!==0))return;e.preventDefault();const clone=block.cloneNode(true);clone.id='dragClone';document.body.append(clone);block.classList.add('dragging');const moveTo=ev=>{const zoom=VZ();clone.style.left=ev.clientX/zoom+'px';clone.style.top=ev.clientY/zoom+'px'},hit=ev=>{let found=null;slots.querySelectorAll('.money-digit-slot:not(.fixed)').forEach(slot=>{const r=slot.getBoundingClientRect(),pad=8;if(ev.clientX>=r.left-pad&&ev.clientX<=r.right+pad&&ev.clientY>=r.top-pad&&ev.clientY<=r.bottom+pad)found=slot});return found};moveTo(e);let hovered=null;const move=ev=>{moveTo(ev);const target=hit(ev);if(hovered&&hovered!==target)hovered.classList.remove('droptarget');hovered=target;if(hovered)hovered.classList.add('droptarget')},clean=()=>{document.removeEventListener('pointermove',move);document.removeEventListener('pointerup',up);document.removeEventListener('pointercancel',cancel);clone.remove();block.classList.remove('dragging');if(hovered)hovered.classList.remove('droptarget')},up=ev=>{const target=hit(ev);clean();if(target){S.answerDigits[+target.dataset.index]=+block.dataset.digit;S.feedback='';draw()}},cancel=()=>clean();document.addEventListener('pointermove',move);document.addEventListener('pointerup',up);document.addEventListener('pointercancel',cancel)}};'0123456789'.split('').forEach(digit=>{const block=woodDigit(+digit);block.classList.add('money-drag-digit');block.setAttribute('role','button');block.setAttribute('aria-label',T('clockDigit').replace('{digit}',digit));block.tabIndex=0;block.onkeydown=e=>{if((e.key==='Enter'||e.key===' ')&&!S.solved){e.preventDefault();const index=S.answerDigits.findIndex((value,i)=>value===null&&!fixed.has(i));if(index>=0){S.answerDigits[index]=+digit;draw()}}};attachDrag(block);tray.append(block)});answer.append(instruction,slots,tray);return answer}; + const typeAnswer=()=>{const wrap=document.createElement('div');wrap.className='money-type-answer';const hint=document.createElement('p');hint.textContent=T('moneyTypeHint');const display=document.createElement('output');display.className='money-type-display';display.textContent='€ '+(S.input||'…');const pad=document.createElement('div');pad.className='money-keypad';['7','8','9','⌫','4','5','6',LANG==='nl'?',':'.','1','2','3','0'].forEach(key=>{const button=btn(key,()=>{if(S.solved)return;if(key==='⌫')S.input=S.input.slice(0,-1);else if(key===','||key==='.'){if(!/[,.]/.test(S.input))S.input=(S.input||'0')+key}else{const parts=S.input.split(/[,.]/);if((parts.length===1&&parts[0].length<2)||(parts.length===2&&parts[1].length<2))S.input+=key}S.feedback='';draw()});pad.append(button)});wrap.append(hint,display,pad);return wrap}; + const addMoney=value=>{if(!S.solved){S.selected.push(value);S.feedback='';draw()}}; + const moneyTray=wallet=>{const section=document.createElement('section');section.className='money-tray-wrap';const label=document.createElement('b');label.textContent=T('moneyTray');const tray=document.createElement('div');tray.className='money-cash-tray';denominations().forEach(value=>{const item=piece(value,true);item.onpointerdown=e=>{if(S.solved||(e.pointerType==='mouse'&&e.button!==0))return;e.preventDefault();const startX=e.clientX,startY=e.clientY,clone=item.cloneNode(true);clone.id='dragClone';clone.classList.add('money-drag-clone');document.body.append(clone);const moveTo=ev=>{const zoom=VZ();clone.style.left=ev.clientX/zoom+'px';clone.style.top=ev.clientY/zoom+'px'},over=ev=>{const r=wallet.getBoundingClientRect(),pad=12;return ev.clientX>=r.left-pad&&ev.clientX<=r.right+pad&&ev.clientY>=r.top-pad&&ev.clientY<=r.bottom+pad};moveTo(e);const move=ev=>{moveTo(ev);wallet.classList.toggle('droptarget',over(ev))},clean=()=>{document.removeEventListener('pointermove',move);document.removeEventListener('pointerup',up);document.removeEventListener('pointercancel',cancel);wallet.classList.remove('droptarget');clone.remove()},up=ev=>{const tapped=Math.hypot(ev.clientX-startX,ev.clientY-startY)<6,target=over(ev);clean();if(tapped||target)addMoney(value)},cancel=()=>clean();document.addEventListener('pointermove',move);document.addEventListener('pointerup',up);document.addEventListener('pointercancel',cancel)};item.onkeydown=e=>{if((e.key==='Enter'||e.key===' ')&&!S.solved){e.preventDefault();addMoney(value)}};tray.append(item)});section.append(label,tray);return section}; + const expected=()=>S.game==='change'?S.paidCents-S.targetCents:S.targetCents,setFeedback=ok=>{if(ok){S.solved=true;S.score++;S.streak++;S.feedback='good';sndGood()}else{S.streak=0;S.feedback='bad';sndBad()}draw()}; + const check=()=>{const value=S.game==='make'?S.selected.reduce((sum,item)=>sum+item,0):answerValue();if(value===null){S.feedback='incomplete';sndBad();draw();return}setFeedback(value===expected())}; + const feedbackText=()=>{if(S.feedback==='good')return T('moneyCorrect');if(S.feedback==='incomplete')return T('moneyIncomplete');if(S.feedback==='bad'&&S.game==='make'){const total=S.selected.reduce((sum,item)=>sum+item,0),difference=S.targetCents-total;return T(difference>0?'moneyNeed':'moneyTooMuch').replace('{amount}',format(Math.abs(difference)))}return T('moneyTryAgain')}; + const resetAnswer=()=>{S.selected=[];S.input='';S.answerDigits=emptyDigits();S.feedback='';S.solved=false;draw()}; + const newTask=()=>{const task=createMoneyTask(S.level,S.game,S.category,S.targetCents,S.productId);S.targetCents=task.targetCents;S.paidCents=task.paidCents;S.productId=task.productId;S.selected=[];S.given=breakdown(task.targetCents);S.input='';S.answerDigits=emptyDigits();S.solved=false;S.feedback='';draw()}; + const chooseLevel=value=>{S.level=value;S.score=0;S.streak=0;newTask()},toggleMode=()=>{S.amode=S.amode==='blocks'?'type':'blocks';S.input='';S.answerDigits=emptyDigits();S.feedback='';draw()}; + const draw=()=>{S.style=style.value;S.game=game.value;stage.className='money-app';stage.innerHTML='';const top=document.createElement('div');top.className='money-top';const levelControl=document.createElement('div');levelControl.className='money-level-control';const levelLabel=document.createElement('span');levelLabel.textContent=T('moneyLevel');const levels=document.createElement('div');levels.className='lg-levels money-levels';[1,2,3].forEach(value=>{const button=document.createElement('button');button.type='button';button.textContent=T('lvl'+value);button.title=T('moneyLevel'+value+'Hint');button.classList.toggle('active',S.level===value);button.onclick=()=>chooseLevel(value);levels.append(button)});levelControl.append(levelLabel,levels);top.append(levelControl);if(S.game!=='make'){const mode=btn(S.amode==='blocks'?'🧱':'⌨️',toggleMode);mode.classList.add('money-mode');mode.title=T('amode');top.append(mode)}const score=document.createElement('span');score.className='money-score';score.textContent=T('moneyScore').replace('{score}',S.score).replace('{streak}',S.streak);top.append(score);stage.append(top); + const card=document.createElement('section');card.className='money-task-card';const title=document.createElement('h3'),levelHint=document.createElement('p');title.textContent=T(S.game==='make'?'moneyMakeTitle':S.game==='count'?'moneyCountTitle':'moneyChangeTitle');levelHint.className='money-level-hint';levelHint.textContent=T('moneyLevel'+S.level+'Hint');card.append(title,levelHint); + if(S.game==='make'){const target=document.createElement('div');target.className='money-target';const label=document.createElement('span');label.textContent=T('moneyYourTask');target.append(label,amountDisplay(S.targetCents,true));card.append(target);const wallet=document.createElement('div');wallet.className='money-wallet';const walletHead=document.createElement('div');const walletLabel=document.createElement('b');walletLabel.textContent=T('moneyWallet');const total=document.createElement('output');total.textContent=format(S.selected.reduce((sum,item)=>sum+item,0));walletHead.append(walletLabel,total);wallet.append(walletHead);if(S.selected.length)wallet.append(pile(S.selected,true,wallet));else{const hint=document.createElement('p');hint.textContent=T('moneyWalletHint');wallet.append(hint)}card.append(wallet,moneyTray(wallet))} + if(S.game==='count'){const target=document.createElement('div');target.className='money-count-task';target.append(pile(S.given));card.append(target,S.amode==='blocks'?digitAnswer():typeAnswer())} + if(S.game==='change'){const item=currentProduct(),shop=document.createElement('div');shop.className='money-shop-task';const product=document.createElement('div');product.className='money-product';const emoji=document.createElement('span'),productName=document.createElement('strong');emoji.className='money-product-icon';emoji.textContent=item.icon;productName.className='money-product-name';productName.textContent=LANG==='nl'?item.nl:item.en;const priceLabel=document.createElement('b');priceLabel.textContent=T('moneyPrice');product.append(emoji,productName,priceLabel,amountDisplay(S.targetCents,true));const paid=document.createElement('div');paid.className='money-paid';const paidLabel=document.createElement('b');paidLabel.textContent=T('moneyPaid');const paidAmount=document.createElement('output');paidAmount.textContent=format(S.paidCents);paid.append(paidLabel,paidAmount,pile([S.paidCents]));shop.append(product,paid);card.append(shop,S.amode==='blocks'?digitAnswer():typeAnswer())} + const feedback=document.createElement('p');feedback.className='money-feedback '+(S.feedback==='good'?'ok':S.feedback?'bad':'');feedback.setAttribute('role','status');feedback.textContent=S.feedback?feedbackText():'';if(S.feedback==='good'&¤tUser&¤tUser.avatar){const cheer=renderAvatarFace(currentUser.avatar,'md');cheer.classList.add('av-cheer');feedback.append(cheer)}card.append(feedback);const actions=document.createElement('div');actions.className='money-actions';if(!S.solved)actions.append(btn(T('numberlineCheck'),check,''));if(!S.solved)actions.append(btn(T('moneyClear'),resetAnswer,'ghost'));actions.append(btn(T('moneyNextTask'),newTask,'ghost'));card.append(actions);stage.append(card)}; + style.onchange=draw;game.onchange=()=>{S.game=game.value;syncSettings();newTask()};category.onchange=()=>{S.category=category.value;newTask()};learningShell(root,[styleSetting,gameSetting,categorySetting],stage);root.classList.toggle('managed-learning-level',!!S.managedLearningLevel);syncSettings();newTask();return{getState:()=>persistedLearningState(S,{selected:[...S.selected],given:[...S.given],answerDigits:[...S.answerDigits]})}; +} + +function mountWordtypes(root,init){ + const year=learningYearGroup(init?.yearGroup),defaultSentence=year&&year<=3?(LANG==='nl'?'De hond rent':'The dog runs'):year&&year>=7?(LANG==='nl'?'De zorgvuldige onderzoeker beoordeelt de bron kritisch':'The careful researcher assesses the source critically'):(LANG==='nl'?'De vrolijke hond rent snel':'The cheerful dog runs quickly'),savedSentence=init?.sentence,isLegacyDefault=['De vrolijke hond rent snel','The cheerful dog runs quickly'].includes(savedSentence),base=init?.managedLearningLevel&&isLegacyDefault?{...init,sentence:defaultSentence}:init,S=Object.assign({level:1,yearGroup:null,managedLearningLevel:false,sentence:defaultSentence,answers:{}},base||{}),input=document.createElement('input'),stage=document.createElement('div');input.value=S.sentence; + const rules=languageTaskRules(S.yearGroup,S.level),allTypes=['noun','verb','adjective','other'],types=rules?allTypes.slice(0,rules.wordtypeCount):allTypes,draw=()=>{S.sentence=input.value.slice(0,120);stage.className='wordtypes';stage.innerHTML='';S.sentence.split(/\s+/).filter(Boolean).forEach((word,i)=>{const w=document.createElement('button');w.type='button';w.textContent=word;const type=S.answers[i]||'other';w.dataset.type=type;w.onclick=()=>{S.answers[i]=types[(types.indexOf(w.dataset.type)+1)%types.length];draw()};stage.append(w)});const legend=document.createElement('p');legend.textContent=T('wordtypeHint');stage.append(legend)};input.oninput=draw;learningShell(root,[field(T('wordtypeSentence'),input)],stage);root.classList.toggle('managed-learning-level',!!S.managedLearningLevel);draw();return{getState:()=>persistedLearningState(S)}; +} +function mountSchedule(root,init){ + const defaults=LANG==='nl'?[{time:'08:30',text:'Start'},{time:'10:15',text:'Pauze'}]:[{time:'08:30',text:'Start'},{time:'10:15',text:'Break'}],S=Object.assign({items:defaults},init||{}),time=document.createElement('input'),text=document.createElement('input'),stage=document.createElement('div');time.type='time';time.value='09:00';text.placeholder=T('scheduleActivity'); + const activityIcon=value=>{const text=value.toLocaleLowerCase();if(/pauze|break|buiten|outside/.test(text))return'🛝';if(/eten|lunch|fruit|eat/.test(text))return'🍎';if(/lees|taal|read|language/.test(text))return'📚';if(/reken|math/.test(text))return'🔢';if(/gym|sport|move/.test(text))return'⚽';if(/crea|teken|muziek|art|music/.test(text))return'🎨';return'⭐'}; + const draw=()=>{stage.className='schedule';stage.innerHTML='';const status=document.createElement('p');status.className='class-total';status.textContent=T('scheduleCount').replace('{count}',S.items.length);stage.append(status);S.items.sort((a,b)=>a.time.localeCompare(b.time)).forEach((it,i)=>{const row=document.createElement('div');const icon=document.createElement('i');icon.className='schedule-icon';icon.textContent=activityIcon(it.text);const t=document.createElement('time');t.textContent=it.time;const s=document.createElement('span');s.textContent=it.text;row.append(icon,t,s,btn('×',()=>{S.items.splice(i,1);draw()}));stage.append(row)});if(!S.items.length){const e=document.createElement('p');e.className='edu-empty';e.textContent=T('scheduleEmpty');stage.append(e)}}; + learningShell(root,[time,text,btn(T('addWord'),()=>{if(text.value.trim()){S.items.push({time:time.value,text:text.value.trim().slice(0,50)});text.value='';draw()}})],stage);draw();return{getState:()=>({...S,items:S.items.map(x=>({...x}))})}; +} +function mountMood(root,init){ + const S=Object.assign({votes:[0,0,0,0,0]},init||{}),stage=document.createElement('div'),faces=['😢','😕','😐','🙂','🤩']; + const draw=()=>{stage.className='mood';stage.innerHTML='';const total=document.createElement('p');total.className='class-total';total.textContent=T('moodTotal').replace('{count}',S.votes.reduce((sum,n)=>sum+n,0));stage.append(total);faces.forEach((f,i)=>{const b=btn(f,()=>{S.votes[i]++;draw()});b.setAttribute('aria-label',T('moodVote').replace('{face}',f));const n=document.createElement('span');n.textContent=S.votes[i];b.append(n);stage.append(b)})};learningShell(root,[btn(T('pollReset'),()=>{S.votes=[0,0,0,0,0];draw()})],stage);draw();return{getState:()=>({...S,votes:[...S.votes]})}; +} +function mountPoll(root,init){ + const S=Object.assign({question:LANG==='nl'?'Wat kies jij?':'What do you choose?',options:[{text:'A',votes:0},{text:'B',votes:0}]},init||{}),question=document.createElement('input'),options=document.createElement('textarea'),stage=document.createElement('div');question.value=S.question;options.value=S.options.map(x=>x.text).join('\n'); + const sync=()=>{S.question=question.value.slice(0,100);const old=new Map(S.options.map(x=>[x.text,x.votes]));S.options=options.value.split('\n').map(x=>x.trim()).filter(Boolean).slice(0,6).map(text=>({text,votes:old.get(text)||0}));draw()},draw=()=>{stage.className='poll';stage.innerHTML='';const h=document.createElement('h3');h.textContent=S.question;const total=S.options.reduce((sum,item)=>sum+item.votes,0),totalEl=document.createElement('p');totalEl.className='class-total';totalEl.textContent=T('pollTotal').replace('{count}',total);stage.append(h,totalEl);const max=Math.max(1,...S.options.map(x=>x.votes));S.options.forEach(o=>{const b=btn(o.text,()=>{o.votes++;draw()});b.setAttribute('aria-label',`${o.text}: ${o.votes}`);const bar=document.createElement('i');bar.style.width=(o.votes/max*100)+'%';const n=document.createElement('span');n.textContent=o.votes;b.append(bar,n);stage.append(b)})};question.oninput=options.oninput=sync;learningShell(root,[field(T('pollQuestion'),question),field(T('pollOptions'),options),btn(T('pollReset'),()=>{S.options.forEach(x=>x.votes=0);draw()})],stage);draw();return{getState:()=>({...S,options:S.options.map(x=>({...x}))})}; +} diff --git a/public/js/widgets/letters.js b/public/js/widgets/letters.js new file mode 100644 index 0000000..f6b222a --- /dev/null +++ b/public/js/widgets/letters.js @@ -0,0 +1,811 @@ +/* widget: Letterblokken (Ik leer lezen) */ +function mountLetterGame(root, initState, opts){ + const readonly = !!(opts && opts.readonly); + const onProgress = opts && opts.onProgress; + /* wordMistakes: de missers binnen het huidige woord (welke letter hoorde er, + welke koos de leerling) - gaat mee als voortgangdetail zodra het woord af is */ + const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks", assignmentWords:null, assignmentThemeName:"", wordMistakes:[] }; + if(initState){ + S.level = initState.level||1; + S.yearGroup = initState.yearGroup||null; + S.managedLearningLevel = !!initState.managedLearningLevel; + S.card = initState.card ?? "g"; + S.count = initState.count||6; + S.snd = initState.snd !== false; + S.amode = initState.amode||"blocks"; + S.assignmentWords = Array.isArray(initState.assignmentWords) ? initState.assignmentWords : null; + S.assignmentThemeName = typeof initState.assignmentThemeName==="string" ? initState.assignmentThemeName : ""; + } + const play = f=>{ if(S.snd) f(); }; + + root.innerHTML = ` +
+
+ + +
+ +
+
+ +
+
+
+
+
+
+
🌟
+
+
+
+
+
+
+
+ + + + + +
+
+ +
?
+ +
+
+
+ +
+
+
+
+
+
+ + + +
+
+
+
`; + + const el = s => root.querySelector(s); + const slotsEl = el(".lg-slots"), trayEl = el(".blocks-tray"), picEl = el(".lg-pic"), + instrEl = el(".lg-instr"), nextBtn = el(".lg-next"), doneEl = el(".lg-done"), + countEl = el(".lg-count"), starsEl = el(".lg-stars"), cardsEl = el(".lg-cards"); + + /* card buttons (built dynamically: N cards + star + edit) */ + const numSel = root.querySelector(".lg-num"); + function buildNumSel(){ + numSel.innerHTML = ""; + [4,6,8,10,"all"].forEach(v=>{ + const o = document.createElement("option"); + o.value = v; + o.textContent = v==="all" ? T("all") : v; + numSel.appendChild(o); + }); + numSel.value = S.count; + numSel.title = T("numWords"); + } + numSel.addEventListener("change", ()=>{ + S.count = numSel.value==="all" ? "all" : +numSel.value; + newRound(); render(); + }); + const sndBtn = root.querySelector(".lg-snd"); + sndBtn.addEventListener("click", ()=>{ + S.snd = !S.snd; + updateSndBtn(); + }); + function updateSndBtn(){ + sndBtn.textContent = S.snd ? "🔔" : "🔕"; + sndBtn.classList.toggle("off", !S.snd); + sndBtn.title = T("sndWidget"); + modeBtn.textContent = S.amode==="blocks" ? "🧱" : "⌨️"; + modeBtn.title = T("amode"); + root.querySelector(".lg").classList.toggle("typemode", S.amode==="type"); + } + const modeBtn = root.querySelector(".lg-mode"); + modeBtn.addEventListener("click", ()=>{ + S.amode = S.amode==="blocks" ? "type" : "blocks"; + updateSndBtn(); + }); + + /* on-screen letter keyboard for type mode */ + const kbdEl = root.querySelector(".lg-kbd"); + "abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{ + const b = document.createElement("button"); + b.textContent = l; + b.className = VOWELS.includes(l) ? "v" : "c"; + b.addEventListener("click", ()=>typeLetter(l)); + kbdEl.appendChild(b); + }); + function typeLetter(l){ + if(doneEl.classList.contains("show") || edEl.classList.contains("open")) return; + const slot = slotsEl.querySelector(".slot:not(.filled)"); + if(!slot) return; + if(slot.dataset.letter === l){ + const b = makeBlock(l); + b.classList.add("in-slot"); + b.dataset.placedAt = Date.now(); + slot.appendChild(b); + slot.classList.add("filled"); + play(sndGood); + checkComplete(); + }else{ + S.wordMistakes.push({expected: slot.dataset.letter, given: l}); + slot.classList.add("shake"); + setTimeout(()=>slot.classList.remove("shake"), 400); + play(sndBad); + } + } + + /* word source: one compact dropdown (general card + themes + anchor grids) */ + let srcSel = null; + function buildCardButtons(){ + cardsEl.innerHTML = ""; + srcSel = document.createElement("select"); + srcSel.className = "fl-sel"; + if(S.assignmentWords?.length){ + srcSel.appendChild(new Option("🎯 "+(S.assignmentThemeName||T("castTheme")),"assignment")); + srcSel.disabled=true; + }else{ + fillWordSourceSel(srcSel, S.card,S.yearGroup,S.level); + S.card=srcSel.value||"g"; + srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; newRound(); render(); }); + } + cardsEl.appendChild(srcSel); + if(!readonly){ + const edit = document.createElement("button"); + edit.className = "lg-edit"; edit.textContent = "✎"; + edit.addEventListener("click", openEditor); + cardsEl.appendChild(edit); + } + } + buildCardButtons(); + + root.querySelectorAll(".lg-levels button").forEach(b=>{ + b.addEventListener("click", ()=>{ S.level = +b.dataset.l; newRound(); render(); }); + b.disabled = S.managedLearningLevel; + }); + nextBtn.addEventListener("click", ()=>{ + S.wordIdx++; + if(S.wordIdx >= S.order.length){ showCardDone(); } else render(); + }); + document.addEventListener("langchange", ()=>{ + S.lang=LANG; if(!S.assignmentWords)S.card="g"; buildCardButtons(); buildNumSel(); newRound(); render(); + }); + document.addEventListener("wordschange", ()=>{ + buildCardButtons(); newRound(); render(); + if(edEl.classList.contains("open")){ renderThemeSel(); renderEdList(); } + }); + + function fullList(){ + if(S.assignmentWords?.length)return languageWordsForYear(S.assignmentWords,S.yearGroup,S.level); + S.card = normWordSource(S.card); + const selected=languageWordsForYear(wordSourceList(S.card),S.yearGroup,S.level); + if(selected.length)return selected; + S.card="g";return languageWordsForYear(wordSourceList("g"),S.yearGroup,S.level); + } + function newRound(){ + const list = fullList(); + const n = S.count==="all" ? list.length : Math.min(+S.count, list.length); + S.order = shuffle(list.map((_,i)=>i)).slice(0,n); + S.wordIdx = 0; S.stars = 0; + } + function currentWord(){ return fullList()[S.order[S.wordIdx]]; } + + function makeBlock(letter){ + const b = document.createElement("div"); + b.className = "blok " + (VOWELS.includes(letter) ? "vowel" : "cons"); + b.dataset.letter = letter; + b.innerHTML = `
${letter}
`; + const dg = dotGrid(letter); dg.classList.add("blok-dots"); + b.appendChild(dg); + attachDrag(b); + return b; + } + function makeSlot(letter, showGhost){ + const s = document.createElement("div"); + s.className = "slot"; + s.dataset.letter = letter; + s.innerHTML = `
${showGhost ? letter : ""}
`; + const dg = dotGrid(letter); dg.classList.add("slot-dots"); + s.appendChild(dg); + s.addEventListener("click", ()=>{ + const placed = s.querySelector(".blok"); + /* ignore the synthetic click that follows a successful drop */ + if(placed && !placed.classList.contains("fixed") && + Date.now() - (+placed.dataset.placedAt || 0) > 400){ + placed.classList.remove("in-slot"); + trayEl.appendChild(placed); + s.classList.remove("filled"); + } + }); + return s; + } + function distractors(word, n){ + const pool = "abcdefghijklmnoprstuvwz".split("").filter(c=>!word.includes(c)); + return shuffle(pool).slice(0,n); + } + + function render(){ + if(!S.order.length || S.wordIdx >= S.order.length) newRound(); + doneEl.classList.remove("show"); + nextBtn.classList.remove("show"); + nextBtn.textContent = T("next"); + root.querySelectorAll(".lg-levels button").forEach(b=>{ + b.textContent = T("lvl"+b.dataset.l); + b.classList.toggle("active", +b.dataset.l===S.level); + }); + const editBtn = cardsEl.querySelector(".lg-edit"); + if(editBtn) editBtn.title = T("manage"); + numSel.title = T("numWords"); + const allOpt = numSel.querySelector('option[value="all"]'); + if(allOpt) allOpt.textContent = T("all"); + + const [word, pic] = currentWord(); + S.wordMistakes = []; /* nieuw woord op het scherm: missers tellen per woord */ + const literacyMode=languageTaskRules(S.yearGroup,S.level)?.literacyMode||"words"; + setPic(picEl, pic); + countEl.textContent = `${T("word")} ${S.wordIdx+1}/${S.order.length}`; + starsEl.textContent = S.stars ? S.stars+" ★" : ""; + slotsEl.innerHTML = ""; trayEl.innerHTML = ""; + + const letters = word.split(""); + if(literacyMode==="letters"){ + const first=letters[0]; + instrEl.textContent=LANG==="nl"?"Welke letter hoor je vooraan?":"Which letter do you hear first?"; + slotsEl.appendChild(makeSlot(first,false)); + shuffle([first].concat(distractors(word,S.level===1?2:3))).forEach(l=>trayEl.appendChild(makeBlock(l))); + } + else if(S.level===1){ + instrEl.innerHTML = T("instr1"); + letters.forEach(l=>slotsEl.appendChild(makeSlot(l,true))); + shuffle(letters.concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l))); + } + else if(S.level===2){ + const types = ["begin","end","mid"]; + const posType = types[S.wordIdx % 3]; + let idx = posType==="begin" ? 0 : posType==="end" ? letters.length-1 : Math.floor(letters.length/2); + instrEl.innerHTML = T("instr2_"+posType); + letters.forEach((l,i)=>{ + if(i===idx){ slotsEl.appendChild(makeSlot(l,false)); } + else{ + const s = makeSlot(l,true); + s.classList.add("filled"); + const b = makeBlock(l); b.classList.add("in-slot","fixed"); + b.style.pointerEvents = "none"; + s.appendChild(b); + slotsEl.appendChild(s); + } + }); + shuffle([letters[idx]].concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l))); + } + else{ + instrEl.innerHTML = T("instr3"); + letters.forEach(l=>slotsEl.appendChild(makeSlot(l,false))); + shuffle(letters.concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l))); + } + } + + function checkComplete(){ + const open = slotsEl.querySelectorAll(".slot:not(.filled)").length; + if(open===0){ + S.stars++; + starsEl.textContent = S.stars ? S.stars+" ★" : ""; + play(sndWin); confetti(el(".lg-card")); + doneEl.querySelector(".dt").textContent = T("welldone"); + doneEl.querySelector(".dp").textContent = currentWord()[0]; + doneEl.classList.add("show"); + nextBtn.classList.add("show"); + /* het woord is af (er is geen faalmoment), maar de missers onderweg + vertellen de leerkracht welke letter lastig was */ + if(onProgress) onProgress(languageProgressPayload({ + word: currentWord()[0], mistakes: S.wordMistakes, solved: true, stars: 1 })); + S.wordMistakes = []; + } + } + function showCardDone(){ + doneEl.querySelector(".dt").textContent = T("cardDone"); + doneEl.querySelector(".dp").textContent = T("pickCard"); + doneEl.classList.add("show"); + nextBtn.classList.remove("show"); + celebrateAvatar(root); + newRound(); + } + + /* ---------- custom words editor ---------- */ + const edEl = el(".lg-editor"), edWord = el(".ed-word"), + edPrev = el(".ed-preview"), edMsg = el(".ed-msg"), edList = el(".lg-ed-list"), + edFile = el(".ed-p-upload input"), edPSearch = el(".ed-psearch"), + edPGrid = el(".ed-pgrid"), edStatus = el(".ed-status"), + edThemeSel = el(".ed-theme-sel"), edThemeAdd = el(".ed-theme-add"), + edThemeName = el(".ed-theme-name"), edThemeDel = el(".ed-theme-del"), + edThemeMv = el(".ed-theme-mv"); + let edPic = "", edTab = "emoji", edCat = 0, edTheme = "g"; + + /* ---- word target: the general card or one of the themes ---- */ + function renderThemeSel(){ + const list = THEMES[S.lang]; + if(edTheme !== "g" && edTheme >= list.length) edTheme = "g"; + edThemeSel.innerHTML = ""; + const og = document.createElement("option"); + og.value = "g"; + og.textContent = `⭐ ${T("cardStart")} (${generalList().length})`; + edThemeSel.appendChild(og); + /* thema's zonder map bovenaan, daarna per map gegroepeerd - zelfde + indeling als fillWordSourceSel (data.js) in de widget-dropdowns */ + const groups = new Map(); + list.forEach((th,i)=>{ + const f = th.folder || ""; + if(!groups.has(f)) groups.set(f, []); + groups.get(f).push([th, i]); + }); + const addOpt = (parent, th, i)=>{ + const o = document.createElement("option"); + o.value = i; o.textContent = `★ ${th.name} (${th.words.length})`; + parent.appendChild(o); + }; + (groups.get("") || []).forEach(([th,i])=>addOpt(edThemeSel, th, i)); + [...groups.keys()].filter(f=>f).sort().forEach(f=>{ + const grp = document.createElement("optgroup"); + grp.label = "📁 " + f; + groups.get(f).forEach(([th,i])=>addOpt(grp, th, i)); + edThemeSel.appendChild(grp); + }); + edThemeSel.value = edTheme; + edThemeAdd.title = T("newTheme"); + edThemeDel.title = T("delTheme"); + edThemeMv.title = T("themeFolder"); + edThemeName.placeholder = T("themeNamePh"); + } + edThemeSel.addEventListener("change", ()=>{ + edTheme = edThemeSel.value==="g" ? "g" : +edThemeSel.value; + renderEdList(); + }); + edThemeAdd.addEventListener("click", ()=>{ + edThemeName.style.display = "block"; + edThemeName.value = ""; + edThemeName.focus(); + }); + let themeDone = false; + function commitTheme(){ + if(themeDone) return; themeDone = true; + const nm = edThemeName.value.trim(); + if(nm){ + THEMES[S.lang].push({name:nm, words:[]}); + edTheme = THEMES[S.lang].length-1; + persistWords(); + renderThemeSel(); renderEdList(); + document.dispatchEvent(new CustomEvent("wordschange")); + } + edThemeName.style.display = "none"; + setTimeout(()=>{ themeDone = false; }, 100); + } + edThemeName.addEventListener("keydown", ev=>{ + ev.stopPropagation(); + if(ev.key==="Enter") commitTheme(); + if(ev.key==="Escape"){ themeDone=true; edThemeName.style.display="none"; setTimeout(()=>{themeDone=false;},100); } + }); + edThemeName.addEventListener("blur", commitTheme); + edThemeDel.addEventListener("click", ()=>{ + if(edTheme==="g" || !THEMES[S.lang].length) return; + THEMES[S.lang].splice(edTheme,1); + edTheme = "g"; + persistWords(); + renderThemeSel(); renderEdList(); + document.dispatchEvent(new CustomEvent("wordschange")); + }); + /* 🗂 opent de mappenverkenner voor thema's: zelfde Windows-stijl verkenner + als voor de borden (explorer.js), met de thema's van de actieve taal. + Nesting zit in het folder-veld als pad met "/"-scheiding. Ankerthema's + (id "an-...") staan vast in de map "⚓" - anchor.js zet die toch terug. */ + function themesExplorerAdapter(){ + const split = n => (n||"").split("/").map(s=>s.trim()).filter(Boolean); + const join = p => p.join("/"); + const themes = ()=>THEMES[S.lang]; + const isAnchor = th => typeof th.id === "string" && th.id.startsWith("an-"); + /* mappen zijn afgeleid van themavelden; nieuwe lege mappen bestaan + sessie-lokaal tot er een thema in wordt gezet */ + const pending = new Set(); + const changed = ()=>{ + persistWords(); + document.dispatchEvent(new CustomEvent("wordschange")); + }; + return { + title: ()=>T("themeFolder"), + allowRootItems: true, + newItemLabel: ()=>T("newTheme"), + list(path){ + const p = join(path); + const folderMap = new Map(); + const items = []; + const seen = new Set(pending); + themes().forEach((th, i)=>{ + const j = join(split(th.folder)); + if(j === p){ + items.push({ + key: i, name: `${th.name} (${th.words.length})`, + icon: isAnchor(th) ? "⚓" : "★", + movable: !isAnchor(th), deletable: !isAnchor(th), + active: edTheme === i, + }); + }else if(!p || j.startsWith(p + "/")){ + seen.add(j); + } + }); + seen.forEach(j=>{ + if(!(j === p || (!p && j) || j.startsWith(p + "/"))) return; + if(j === p) return; + const seg = (p ? j.slice(p.length + 1) : j).split("/")[0]; + if(!seg) return; + const under = themes().filter(t=>{ const tj = join(split(t.folder)); const full = p ? p+"/"+seg : seg; return tj === full || tj.startsWith(full + "/"); }).length; + folderMap.set(seg, under); + }); + const folders = [...folderMap].map(([name, count])=>({name, count, fixed: name === "⚓" && !p})) + .sort((a,b)=>a.name.localeCompare(b.name)); + return { folders, items }; + }, + createFolder(path, name){ + const full = join([...path, name]); + if(this.list(path).folders.some(f=>f.name===name)) return T("exExists"); + pending.add(full); + return null; + }, + renameFolder(path, oldName, newName){ + return this.moveFolder([...path, oldName], path, newName); + }, + moveFolder(srcPath, targetPath, newName){ + const from = join(srcPath); + if(from === "⚓") return T("exFixedFolder"); + const to = join([...targetPath, newName || srcPath[srcPath.length-1]]); + if(from === to) return null; + if(themes().some(t=>{ const j = join(split(t.folder)); return j === to || j.startsWith(to + "/"); }) || pending.has(to)) return T("exExists"); + themes().forEach(t=>{ + const j = join(split(t.folder)); + if(j === from || j.startsWith(from + "/")) t.folder = to + j.slice(from.length); + }); + [...pending].forEach(j=>{ + if(j === from || j.startsWith(from + "/")){ pending.delete(j); pending.add(to + j.slice(from.length)); } + }); + changed(); + return null; + }, + deleteFolder(path, name){ + const full = join([...path, name]); + if(full === "⚓") return T("exFixedFolder"); + if(themes().some(t=>{ const j = join(split(t.folder)); return j === full || j.startsWith(full + "/"); })) return T("exFolderNotEmpty"); + [...pending].forEach(j=>{ if(j === full || j.startsWith(full + "/")) pending.delete(j); }); + return null; + }, + createItem(path){ + const nm = (S.lang==="nl" ? "Thema " : "Theme ") + (themes().length + 1); + const th = { name: nm, words: [] }; + if(path.length) th.folder = join(path); + themes().push(th); + pending.delete(join(path)); + changed(); + return null; + }, + renameItem(i, name){ + const th = themes()[i]; + if(!th || isAnchor(th)) return isAnchor(th) ? T("exFixedFolder") : null; + th.name = name; + changed(); + return null; + }, + moveItem(i, targetPath){ + const th = themes()[i]; + if(!th || isAnchor(th)) return th ? T("exFixedFolder") : null; + if(targetPath.length) th.folder = join(targetPath); else delete th.folder; + pending.delete(join(targetPath)); + changed(); + return null; + }, + deleteItem(i){ + const th = themes()[i]; + if(!th || isAnchor(th)) return th ? T("exFixedFolder") : null; + themes().splice(i, 1); + if(edTheme === i) edTheme = "g"; + else if(edTheme !== "g" && edTheme > i) edTheme--; + changed(); + return null; + }, + openItem(i){ + if(themes()[i]) edTheme = i; + }, + }; + } + edThemeMv.addEventListener("click", ()=>{ + openExplorer(themesExplorerAdapter(), ()=>{ renderThemeSel(); renderEdList(); }); + }); + + function selectPic(p, btn){ + edPic = p; setPic(edPrev, p); + root.querySelectorAll(".ed-grid button.sel,.ed-pgrid button.sel").forEach(b=>b.classList.remove("sel")); + if(btn) btn.classList.add("sel"); + } + + /* tabs */ + root.querySelectorAll(".ed-tabs button").forEach(b=>{ + b.addEventListener("click", ()=>{ edTab = b.dataset.t; refreshTabs(); }); + }); + function refreshTabs(){ + root.querySelectorAll(".ed-tabs button").forEach(b=>{ + b.textContent = T("tab"+b.dataset.t[0].toUpperCase()+b.dataset.t.slice(1)); + b.classList.toggle("active", b.dataset.t===edTab); + }); + el(".ed-p-emoji").style.display = edTab==="emoji" ? "block" : "none"; + el(".ed-p-picto").style.display = edTab==="picto" ? "block" : "none"; + el(".ed-p-upload").style.display = edTab==="upload" ? "block" : "none"; + el(".ed-p-library").style.display = edTab==="library" ? "block" : "none"; + if(edTab==="picto" && !edPSearch.value) edPSearch.value = edWord.value.trim(); + } + + /* emoji tab */ + function renderEmojiCats(){ + const cats = el(".ed-cats"); + cats.innerHTML = ""; + T("cats").forEach((name,i)=>{ + const b = document.createElement("button"); + b.textContent = name; + b.classList.toggle("active", i===edCat); + b.addEventListener("click", ()=>{ edCat=i; renderEmojiCats(); }); + cats.appendChild(b); + }); + const grid = el(".ed-grid"); + grid.innerHTML = ""; + EMOJI_CATS[edCat].forEach(em=>{ + const b = document.createElement("button"); + b.type="button"; b.textContent = em; + b.classList.toggle("sel", em===edPic); + b.addEventListener("click", ()=>selectPic(em, b)); + grid.appendChild(b); + }); + } + + /* picto tab (ARASAAC) */ + async function searchPictos(){ + const term = edPSearch.value.trim(); + if(!term) return; + edStatus.textContent = T("pictoLoad"); + edPGrid.innerHTML = ""; + try{ + const arr = await arasaacSearch(term, S.lang); + if(!arr.length){ edStatus.textContent = T("pictoNone"); return; } + edStatus.textContent = ""; + arr.slice(0,12).forEach(p=>{ + const url = arasaacUrl(p._id); + const b = document.createElement("button"); + b.type="button"; + const im = document.createElement("img"); + im.src = url; im.loading = "lazy"; im.alt = term; + b.appendChild(im); + b.addEventListener("click", ()=>selectPic(url, b)); + edPGrid.appendChild(b); + }); + }catch(e){ edStatus.textContent = T("pictoErr"); } + } + el(".ed-pgo").addEventListener("click", searchPictos); + edPSearch.addEventListener("keydown", e=>{ if(e.key==="Enter") searchPictos(); }); + el(".ed-open-library").addEventListener("click", ()=>{ + openImageCatalog((src,image)=>selectPic(image?.value||src)); + }); + /* typing a word auto-fills picto search */ + edWord.addEventListener("input", ()=>{ edPSearch.value = edWord.value.trim(); }); + + /* upload tab */ + edFile.addEventListener("change", async ()=>{ + const f = edFile.files[0]; if(!f) return; + edFile.value = ""; + if(currentUser){ + const hint=el(".ed-uphint"),old=hint.textContent; + hint.textContent=T("imgUploading"); + try{ + const image=await uploadImageToCatalog(f,{name:edWord.value.trim(),folder:"Taal/Mijn woordkaarten"}); + selectPic(image.src); + }catch(err){hint.textContent=err.message;setTimeout(()=>{hint.textContent=old},3500)} + return; + } + const img = new Image(); + img.onload = ()=>{ + const c = document.createElement("canvas"); + const s = Math.min(1, 128/Math.max(img.width, img.height)); + c.width = Math.round(img.width*s); c.height = Math.round(img.height*s); + c.getContext("2d").drawImage(img, 0, 0, c.width, c.height); + selectPic(c.toDataURL("image/png")); + URL.revokeObjectURL(img.src); + }; + img.src = URL.createObjectURL(f); + }); + + el(".ed-add").addEventListener("click", ()=>{ + const w = edWord.value.trim().toLowerCase(); + if(!/^[a-z]{2,5}$/.test(w)){ edMsg.textContent = T("badWord"); return; } + if(!edPic){ edMsg.textContent = T("needPic"); return; } + edMsg.textContent = ""; + if(edTheme==="g"){ + GENERAL_EXTRA[S.lang].push([w, edPic]); /* extend the general card */ + }else{ + THEMES[S.lang][edTheme].words.push([w, edPic]); + } + persistWords(); + renderThemeSel(); + edWord.value = ""; edPSearch.value = ""; edPic = ""; edPrev.textContent = "?"; + root.querySelectorAll(".ed-grid button.sel,.ed-pgrid button.sel").forEach(b=>b.classList.remove("sel")); + renderEdList(); + play(sndGood); + document.dispatchEvent(new CustomEvent("wordschange")); + }); + el(".lg-ed-close").addEventListener("click", ()=>{ + edEl.classList.remove("open"); + buildCardButtons(); + newRound(); + render(); + }); + function openEditor(){ + el(".ed-title").textContent = T("manage"); + el(".ed-uplbl").textContent = T("upload"); + el(".ed-uphint").textContent = T("upHint"); + el(".ed-open-library").textContent = "🗂 "+T("imgOpenLibrary"); + el(".ed-library-hint").textContent = T("imgLanguageHint"); + el(".ed-credit").innerHTML = T("pictoCredit"); + el(".ed-pgo").textContent = T("pictoSearch"); + edPSearch.placeholder = T("pictoPh"); + edWord.placeholder = T("wordPh"); + el(".ed-add").textContent = T("addWord"); + el(".lg-ed-close").title = T("close"); + edMsg.textContent = ""; edStatus.textContent = ""; + refreshTabs(); + renderEmojiCats(); + renderThemeSel(); + renderEdList(); + edEl.classList.add("open"); + } + function renderEdList(){ + edList.innerHTML = ""; + const isGen = edTheme==="g"; + const base = isGen ? WORDS[S.lang] : []; + const list = isGen ? GENERAL_EXTRA[S.lang] : (THEMES[S.lang][edTheme] ? THEMES[S.lang][edTheme].words : []); + if(!base.length && !list.length){ + edList.innerHTML = `
${T("noWords")}
`; + return; + } + const addRow = (entry, delFn)=>{ + const row = document.createElement("div"); + row.className = "ed-row" + (delFn ? "" : " base"); + const p = document.createElement("div"); p.className = "ep"; setPic(p, entry[1]); + const wv = document.createElement("div"); wv.className = "ew"; wv.textContent = entry[0]; + row.appendChild(p); row.appendChild(wv); + if(delFn){ + const del = document.createElement("button"); del.className = "edel"; del.textContent = "✕"; + del.addEventListener("click", delFn); + row.appendChild(del); + } + edList.appendChild(row); + }; + /* own words first (deletable), built-in general words below (fixed) */ + list.forEach((entry, i)=>addRow(entry, ()=>{ + list.splice(i,1); + persistWords(); + renderThemeSel(); renderEdList(); + document.dispatchEvent(new CustomEvent("wordschange")); + })); + base.forEach(entry=>addRow(entry, null)); + } + + /* ---------- drag & drop ---------- + Hit-testing is geometric (cursor vs slot rectangles) instead of + elementFromPoint, so overlays/transforms can never break drops. */ + function attachDrag(block){ + block.addEventListener("pointerdown", e=>{ + if(block.classList.contains("in-slot")) return; + if(e.pointerType==="mouse" && e.button!==0) return; + e.preventDefault(); + /* avoid implicit touch capture retargeting the follow-up click */ + try{ block.releasePointerCapture(e.pointerId); }catch(_){} + const clone = block.cloneNode(true); + clone.id = "dragClone"; + document.body.appendChild(clone); + block.classList.add("dragging"); + const moveTo = ev=>{ const zf = VZ(); clone.style.left = (ev.clientX/zf)+"px"; clone.style.top = (ev.clientY/zf)+"px"; }; + moveTo(e); + let hovered = null; + const hitSlot = ev=>{ + const pad = 8; + let found = null; + slotsEl.querySelectorAll(".slot:not(.filled)").forEach(s=>{ + const r = s.getBoundingClientRect(); + if(ev.clientX >= r.left-pad && ev.clientX <= r.right+pad && + ev.clientY >= r.top-pad && ev.clientY <= r.bottom+pad) found = s; + }); + return found; + }; + const move = ev=>{ + moveTo(ev); + const slot = hitSlot(ev); + if(hovered && hovered!==slot) hovered.classList.remove("droptarget"); + hovered = slot; + if(hovered) hovered.classList.add("droptarget"); + }; + const up = ev=>{ + document.removeEventListener("pointermove",move); + document.removeEventListener("pointerup",up); + document.removeEventListener("pointercancel",cancel); + clone.remove(); + block.classList.remove("dragging"); + if(hovered) hovered.classList.remove("droptarget"); + const target = hitSlot(ev); + if(target){ + if(target.dataset.letter === block.dataset.letter){ + target.classList.add("filled"); + block.classList.add("in-slot"); + block.dataset.placedAt = Date.now(); + target.appendChild(block); + play(sndGood); + checkComplete(); + }else{ + S.wordMistakes.push({expected: target.dataset.letter, given: block.dataset.letter}); + target.classList.add("shake"); + setTimeout(()=>target.classList.remove("shake"), 400); + play(sndBad); + } + } + }; + const cancel = ()=>{ + document.removeEventListener("pointermove",move); + document.removeEventListener("pointerup",up); + document.removeEventListener("pointercancel",cancel); + clone.remove(); + block.classList.remove("dragging"); + if(hovered) hovered.classList.remove("droptarget"); + }; + document.addEventListener("pointermove",move); + document.addEventListener("pointerup",up); + document.addEventListener("pointercancel",cancel); + }); + } + + buildNumSel(); + const assignedRules=languageTaskRules(S.yearGroup,S.level); + if(S.managedLearningLevel&&assignedRules)S.level=assignedRules.letterLevel; + root.classList.toggle("managed-learning-level",S.managedLearningLevel); + updateSndBtn(); + newRound(); + render(); + document.addEventListener("langchange", updateSndBtn); + return { + getState: ()=>({ level:S.level, card:S.card, count:S.count, snd:S.snd, amode:S.amode }), + setState: st=>{ if(st){ S.level=st.level||1; S.yearGroup=st.yearGroup||null; S.managedLearningLevel=!!st.managedLearningLevel; const rules=languageTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&rules)S.level=rules.letterLevel; S.card=st.card??0; S.count=st.count||6; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; S.assignmentWords=Array.isArray(st.assignmentWords)?st.assignmentWords:null; S.assignmentThemeName=typeof st.assignmentThemeName==="string"?st.assignmentThemeName:""; buildCardButtons(); buildNumSel(); updateSndBtn(); newRound(); render(); } } + }; +} + +function confetti(container){ + const colors = ["#e0554d","#3b7dd8","#3fae6a","#f7c948","#9b59b6"]; + const rect = container.getBoundingClientRect(); + for(let i=0;i<28;i++){ + const c = document.createElement("div"); + c.className = "confetti"; + c.style.left = Math.random()*rect.width + "px"; + c.style.background = colors[i%colors.length]; + c.style.animationDuration = (1.2+Math.random()*1.4)+"s"; + c.style.animationDelay = (Math.random()*0.3)+"s"; + container.appendChild(c); + setTimeout(()=>c.remove(), 3200); + } +} diff --git a/public/js/widgets/light.js b/public/js/widgets/light.js new file mode 100644 index 0000000..bd7e623 --- /dev/null +++ b/public/js/widgets/light.js @@ -0,0 +1,35 @@ +/* stoplicht (bordgereedschap-variant zit in board.js) */ +/* ========================================================= + Traffic light widget +==========================================================*/ +function mountLight(root, initState){ + let on = (initState && initState.on) || "green"; + root.innerHTML = ` +
+

+
+
+
+
+
+ +
`; + const keys={red:['lightRed','lightRedHint'],orange:['lightOrange','lightOrangeHint'],green:['lightGreen','lightGreenHint']}; + function apply(){ + root.querySelectorAll(".tl-lamp").forEach(l=>{ + const active=l.dataset.c===on; l.classList.toggle("on",active);l.setAttribute('aria-pressed',String(active)); + }); + const [color,hint]=keys[on]; + root.querySelector('.tl-status').textContent=T('lightStatus').replace('{color}',T(color)).replace('{hint}',T(hint)); + } + root.querySelectorAll(".tl-lamp").forEach(l=>{ + l.addEventListener("click", ()=>{ on = l.dataset.c; apply(); tone(on==='red'?260:on==='orange'?390:520,.12,0,'sine',.06); }); + }); + function labels(){ + root.querySelector('.tl-mission').textContent=T('wg_light_d'); + root.querySelectorAll('.tl-option').forEach(option=>{const lamp=option.querySelector('.tl-lamp'),[color,hint]=keys[lamp.dataset.c];lamp.setAttribute('aria-label',`${T(color)}: ${T(hint)}`);option.querySelector('strong').textContent=T(color);option.querySelector('small').textContent=T(hint)}); + apply(); + } + document.addEventListener('langchange',labels);labels(); + return { getState: ()=>({on}) }; +} diff --git a/public/js/widgets/math.js b/public/js/widgets/math.js new file mode 100644 index 0000000..3c42b16 --- /dev/null +++ b/public/js/widgets/math.js @@ -0,0 +1,327 @@ +/* widgets: Rekenen (+ - x : %) */ +/* ========================================================= + Math practice engine — add / sub / mul / div / percentages +==========================================================*/ +function mountMath(root, initState, mode, opts){ + const ROUND = 10; + const onProgress = opts && opts.onProgress; + const S = { level:1, yearGroup:null, managedLearningLevel:false, snd:true, amode:"blocks", q:null, tries:0, done:0, good:0, input:"", busy:false }; + if(initState){ + S.level = initState.level||1; + /* yearGroup komt bij leerlingen van klassenmanagement (pupil.js). Op een + auteursbord bewaart board.js de aparte leerjaarkeuze rondom getState(). */ + S.yearGroup = initState.yearGroup || null; + S.managedLearningLevel = !!initState.managedLearningLevel; + S.snd = initState.snd!==false; + S.amode = initState.amode||"blocks"; + } + const play = f=>{ if(S.snd) f(); }; + const rnd = n => Math.floor(Math.random()*n); + + root.innerHTML = ` +
+
+ + +
+ +
+
+
+
+
+
+
+
+
+
🌟
+ +
+
+
+
+
`; + + const el = s => root.querySelector(s); + /* pilot voor "personage moedigt aan" (roadmap-gerelateerd punt): naast het + bestaande "Goed!"-bericht verschijnt kort het eigen personage, als de + leerling er één heeft ingesteld. Puur visueel, geen nieuw feedbacktype - + zonder personage blijft dit exact het oude gedrag. */ + function showGoodFeedback(){ + fbEl.innerHTML = ""; + fbEl.appendChild(document.createTextNode(T("mGood"))); + if(currentUser && currentUser.avatar){ + const cheer = renderAvatarFace(currentUser.avatar, "md"); + cheer.classList.add("av-cheer"); + fbEl.appendChild(cheer); + } + } + const mtEl = el(".mt"), qEl = el(".mt-q"), aEl = el(".mt-a"), fbEl = el(".mt-fb"), + scoreEl = el(".mt-score"), doneEl = el(".mt-done"), + sndBtn = el(".lg-snd"), modeBtn = el(".lg-mode"), + padEl = el(".mt-pad"), slotsEl = el(".mt-slots"), trayEl = el(".mt-tray"); + + /* ---- digit blocks with dot patterns (like the letter blocks) ---- */ + function makeDigitBlock(d){ + const b = document.createElement("div"); + b.className = "blok sm"; + b.dataset.digit = d; + b.innerHTML = `
${d}
`; + const dg = dotGrid(d); dg.classList.add("blok-dots"); + b.appendChild(dg); + attachDigitDrag(b); + return b; + } + "0123456789".split("").forEach(d=>trayEl.appendChild(makeDigitBlock(d))); + + function makeDigitSlot(d){ + const s = document.createElement("div"); + s.className = "slot sm"; + s.dataset.digit = d; + s.innerHTML = `
`; + const dg = dotGrid(d); dg.classList.add("slot-dots"); + s.appendChild(dg); + s.addEventListener("click", ()=>{ + const placed = s.querySelector(".blok"); + if(placed && Date.now() - (+placed.dataset.placedAt||0) > 400){ + placed.remove(); + s.classList.remove("filled"); + } + }); + return s; + } + function attachDigitDrag(block){ + block.addEventListener("pointerdown", e=>{ + if(e.pointerType==="mouse" && e.button!==0) return; + if(S.busy || doneEl.classList.contains("show")) return; + e.preventDefault(); + try{ block.releasePointerCapture(e.pointerId); }catch(_){} + const clone = block.cloneNode(true); + clone.id = "dragClone"; + document.body.appendChild(clone); + const moveTo = ev=>{ const zf = VZ(); clone.style.left = (ev.clientX/zf)+"px"; clone.style.top = (ev.clientY/zf)+"px"; }; + moveTo(e); + let hovered = null; + const hit = ev=>{ + const pad = 8; + let found = null; + slotsEl.querySelectorAll(".slot:not(.filled)").forEach(s=>{ + const r = s.getBoundingClientRect(); + if(ev.clientX>=r.left-pad && ev.clientX<=r.right+pad && + ev.clientY>=r.top-pad && ev.clientY<=r.bottom+pad) found = s; + }); + return found; + }; + const move = ev=>{ + moveTo(ev); + const s = hit(ev); + if(hovered && hovered!==s) hovered.classList.remove("droptarget"); + hovered = s; + if(hovered) hovered.classList.add("droptarget"); + }; + const up = ev=>{ + document.removeEventListener("pointermove",move); + document.removeEventListener("pointerup",up); + document.removeEventListener("pointercancel",cancel); + clone.remove(); + if(hovered) hovered.classList.remove("droptarget"); + const target = hit(ev); + if(target){ + if(target.dataset.digit === block.dataset.digit){ + const copy = block.cloneNode(true); + copy.classList.add("in-slot"); + copy.dataset.placedAt = Date.now(); + target.appendChild(copy); + target.classList.add("filled"); + play(sndGood); + if(!slotsEl.querySelector(".slot:not(.filled)")) blocksSolved(); + }else{ + target.classList.add("shake"); + setTimeout(()=>target.classList.remove("shake"), 400); + play(sndBad); + } + } + }; + const cancel = ()=>{ + document.removeEventListener("pointermove",move); + document.removeEventListener("pointerup",up); + document.removeEventListener("pointercancel",cancel); + clone.remove(); + if(hovered) hovered.classList.remove("droptarget"); + }; + document.addEventListener("pointermove",move); + document.addEventListener("pointerup",up); + document.addEventListener("pointercancel",cancel); + }); + } + function blocksSolved(){ + S.busy = true; + S.good++; + showGoodFeedback(); + play(sndWin); + confetti(el(".mt-card")); + setTimeout(next, 1000); + } + + function gen(){ + const L = S.level; + /* klassenmanagement geeft een groep (leerjaar) mee via yearGroup - dan + sturen de SLO-gebaseerde getalgrenzen (data.js: mathRangeFor) de + inhoud, niet meer de vaste 10/20/100-klem. Zonder groep (geen + klassenmanagement ingesteld voor deze leerling/dit bord) blijft het + oude gedrag exact hetzelfde. */ + const slo = mathRangeFor(S.yearGroup, L); + const rules = mathTaskRules(S.yearGroup, L); + let a, b, ans, txt; + if(mode==="add"){ + const max = rules ? rules.addSub : (slo ? slo.addSub : (L===1 ? 10 : L===2 ? 20 : 100)); + a = rnd(max+1); b = rnd(max-a+1); ans = a+b; + txt = `${a} + ${b} =`; + }else if(mode==="sub"){ + const max = rules ? rules.addSub : (slo ? slo.addSub : (L===1 ? 10 : L===2 ? 20 : 100)); + a = rnd(max+1); b = rnd(a+1); ans = a-b; + txt = `${a} − ${b} =`; + }else if(mode==="mul"){ + const tmax = rules ? rules.timesTable : (slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12)); + a = 1+rnd(tmax); b = 1+rnd(rules ? rules.factorMax : (L===3 ? 12 : 10)); ans = a*b; + txt = `${a} × ${b} =`; + }else if(mode==="div"){ + const tmax = rules ? rules.timesTable : (slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12)); + const t = 1+rnd(tmax), q = 1+rnd(rules ? rules.factorMax : (L===3 ? 12 : 10)); + a = t*q; ans = q; + txt = `${a} ${S.lang==="nl" || LANG==="nl" ? ":" : "÷"} ${t} =`; + }else{ /* pct */ + const sets = rules ? rules.percentSets : (L===1 ? [10,50,100] : L===2 ? [10,20,25,50,75,100] : [5,10,20,25,30,40,50,60,70,75,80,90]); + const p = sets[rnd(sets.length)]; + const step = {5:20,10:10,15:20,20:5,25:4,30:10,40:5,50:2,60:5,70:10,75:4,80:5,90:10,100:1}[p]; + const base = step*(1+rnd(12)); + ans = p*base/100; + txt = `${p}% ${T("mOf")} ${base} =`; + } + return { txt, ans }; + } + + function newQuestion(){ + let q = gen(); + if(S.q && q.txt===S.q.txt) q = gen(); /* avoid the same sum twice in a row */ + S.q = q; S.tries = 0; S.input = ""; S.busy = false; + qEl.textContent = q.txt; + aEl.textContent = ""; + aEl.classList.remove("ok"); + fbEl.textContent = ""; fbEl.classList.remove("ans"); + slotsEl.innerHTML = ""; + if(S.amode==="blocks"){ + String(q.ans).split("").forEach(d=>slotsEl.appendChild(makeDigitSlot(d))); + } + updateScore(); + } + function updateScore(){ + scoreEl.textContent = `${S.good} ${T("mGoed")} · ${Math.min(S.done+1,ROUND)}/${ROUND}`; + } + function endRound(){ + doneEl.querySelector(".dt").textContent = T("mDoneT"); + doneEl.querySelector(".ds").textContent = `${S.good}/${ROUND} ${T("mGoed")}`; + doneEl.querySelector(".dg").textContent = S.good>=8 ? "🌟" : S.good>=5 ? "👍" : "💪"; + doneEl.classList.add("show"); + if(S.good>=8) play(sndWin); + celebrateAvatar(root); + } + function next(){ + S.done++; + if(S.done >= ROUND){ endRound(); } + else newQuestion(); + } + function check(){ + if(S.busy || S.input==="") return; + if(+S.input === S.q.ans){ + S.busy = true; + S.good++; + aEl.classList.add("ok"); + showGoodFeedback(); + play(sndGood); + confetti(el(".mt-card")); + /* per som loggen (niet meer één keer per blok van 10 in endRound()) - + zo levert een sessie ook echt een groep aparte momenten op in de + voortgang, en weet de leerkracht welke som het was. */ + if(onProgress) onProgress({ attempts: S.tries+1, correct: 1, stars: 0, + detail: { question: S.q.txt, answer: +S.input, expected: S.q.ans, correct: true } }); + setTimeout(next, 900); + }else{ + S.tries++; + aEl.classList.add("shake"); + setTimeout(()=>aEl.classList.remove("shake"), 400); + play(sndBad); + const wrongAnswer = S.input; + S.input = ""; aEl.textContent = ""; + if(S.tries >= 3){ + S.busy = true; + fbEl.textContent = `${T("mAnsWas")} ${S.q.ans}`; + fbEl.classList.add("ans"); + if(onProgress) onProgress({ attempts: S.tries, correct: 0, stars: 0, + detail: { question: S.q.txt, answer: +wrongAnswer, expected: S.q.ans, correct: false } }); + setTimeout(next, 1800); + } + } + } + + ["7","8","9","⌫","4","5","6","0","1","2","3","✓"].forEach(k=>{ + const b = document.createElement("button"); + b.textContent = k; + if(k==="✓") b.className = "ok"; + b.addEventListener("click", ()=>{ + if(doneEl.classList.contains("show")) return; + if(k==="⌫"){ if(!S.busy){ S.input = S.input.slice(0,-1); aEl.textContent = S.input; } } + else if(k==="✓") check(); + else if(!S.busy && S.input.length<8){ S.input += k; aEl.textContent = S.input; } + }); + padEl.appendChild(b); + }); + + el(".mt-again").addEventListener("click", ()=>{ + S.done = 0; S.good = 0; + doneEl.classList.remove("show"); + newQuestion(); + }); + root.querySelectorAll(".lg-levels button").forEach(b=>{ + b.disabled = S.managedLearningLevel; + b.addEventListener("click", ()=>{ + S.level = +b.dataset.l; + S.done = 0; S.good = 0; + doneEl.classList.remove("show"); + labels(); newQuestion(); + }); + }); + sndBtn.addEventListener("click", ()=>{ + S.snd = !S.snd; + sndBtn.textContent = S.snd ? "🔔" : "🔕"; + sndBtn.classList.toggle("off", !S.snd); + }); + modeBtn.addEventListener("click", ()=>{ + S.amode = S.amode==="blocks" ? "type" : "blocks"; + labels(); newQuestion(); + }); + + function labels(){ + root.querySelectorAll(".lg-levels button").forEach(b=>{ + b.textContent = T("lvl"+b.dataset.l); + b.classList.toggle("active", +b.dataset.l===S.level); + }); + sndBtn.textContent = S.snd ? "🔔" : "🔕"; + sndBtn.classList.toggle("off", !S.snd); + sndBtn.title = T("sndWidget"); + modeBtn.textContent = S.amode==="blocks" ? "🧱" : "⌨️"; + modeBtn.title = T("amode"); + mtEl.classList.toggle("blocks", S.amode==="blocks"); + el(".mt-again").textContent = T("mAgain"); + updateScore(); + } + document.addEventListener("langchange", ()=>{ labels(); newQuestion(); }); + + labels(); + newQuestion(); + root.classList.toggle("managed-learning-level",S.managedLearningLevel); + return { + getState: ()=>({ level:S.level, snd:S.snd, amode:S.amode }), + setState: st=>{ if(st){ S.level=st.level||1;S.yearGroup=st.yearGroup||null;S.managedLearningLevel=!!st.managedLearningLevel; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; labels(); newQuestion(); } } + }; +} diff --git a/public/js/widgets/media.js b/public/js/widgets/media.js new file mode 100644 index 0000000..2cde79b --- /dev/null +++ b/public/js/widgets/media.js @@ -0,0 +1,107 @@ +/* widget: Foto & video */ +/* ========================================================= + Media widget — photos and videos from external sites +==========================================================*/ +function mountMedia(root, initState){ + let cur = null; + root.innerHTML = ` +
+
+ + + +
+
+
`; + const urlInp = root.querySelector(".m-url"), + goBtn = root.querySelector(".m-go"), + extBtn = root.querySelector(".m-ext"), + stage = root.querySelector(".media-stage"); + + function labels(){ + urlInp.placeholder = T("mediaPh"); + goBtn.textContent = T("mediaShow"); + extBtn.title = T("mediaOpen"); + const msg = stage.querySelector(".media-msg"); + if(msg) msg.textContent = T(msg.dataset.err ? "mediaErr" : "mediaEmpty"); + } + function showMsg(err){ + stage.innerHTML = ""; + const m = document.createElement("div"); + m.className = "media-msg"; + if(err) m.dataset.err = "1"; + m.textContent = T(err ? "mediaErr" : "mediaEmpty"); + stage.appendChild(m); + } + function show(raw){ + let url = raw.trim(); + if(!url){ showMsg(false); return; } + if(!/^https?:\/\//i.test(url)) url = "https://"+url; + let node = null; + const yt = url.match(/(?:youtube\.com\/(?:watch\?[^#]*v=|shorts\/|embed\/|live\/)|youtu\.be\/)([\w-]{6,})/); + const vi = url.match(/vimeo\.com\/(?:video\/)?(\d+)/); + if(yt){ + /* show the thumbnail first (always works); play attempts the embed */ + const id = yt[1]; + stage.innerHTML = ""; + const wrap = document.createElement("div"); + wrap.className = "media-yt"; + const im = document.createElement("img"); + im.src = "https://i.ytimg.com/vi/"+id+"/hqdefault.jpg"; im.alt = ""; + const pb = document.createElement("button"); + pb.className = "media-play"; pb.textContent = "▶"; + pb.addEventListener("click", ()=>{ + const f = document.createElement("iframe"); + f.src = "https://www.youtube.com/embed/"+id+"?rel=0&autoplay=1"; + f.referrerPolicy = "strict-origin-when-cross-origin"; + f.allow = "autoplay; encrypted-media; picture-in-picture"; + f.allowFullscreen = true; + stage.innerHTML = ""; + stage.appendChild(f); + }); + wrap.appendChild(im); wrap.appendChild(pb); + stage.appendChild(wrap); + cur = url; urlInp.value = url; + return; + } + if(vi){ + node = document.createElement("iframe"); + node.src = "https://player.vimeo.com/video/"+vi[1]; + node.allow = "autoplay; fullscreen; picture-in-picture"; + node.allowFullscreen = true; + }else if(/\.(jpe?g|png|gif|webp|svg|avif)([?#]|$)/i.test(url)){ + node = document.createElement("img"); + node.src = url; node.alt = ""; + node.addEventListener("error", ()=>showMsg(true)); + }else if(/\.(mp4|webm|ogg|mov)([?#]|$)/i.test(url)){ + node = document.createElement("video"); + node.src = url; node.controls = true; + node.addEventListener("error", ()=>showMsg(true)); + } + if(node){ + stage.innerHTML = ""; + stage.appendChild(node); + cur = url; + urlInp.value = url; + }else{ + showMsg(true); + } + } + goBtn.addEventListener("click", ()=>show(urlInp.value)); + extBtn.addEventListener("click", ()=>{ + let u = (cur || urlInp.value).trim(); + if(!u) return; + if(!/^https?:\/\//i.test(u)) u = "https://"+u; + window.open(u, "_blank"); + }); + urlInp.addEventListener("keydown", e=>{ + e.stopPropagation(); + if(e.key==="Enter") show(urlInp.value); + }); + document.addEventListener("langchange", labels); + if(initState && initState.url){ urlInp.value = initState.url; show(initState.url); } + else showMsg(false); + labels(); + return { getState: ()=>({url: cur || ""}) }; +} + diff --git a/public/js/widgets/mind.js b/public/js/widgets/mind.js new file mode 100644 index 0000000..bd94ac8 --- /dev/null +++ b/public/js/widgets/mind.js @@ -0,0 +1,252 @@ +/* widget: Mindmap */ +/* ========================================================= + Mindmap widget — spider, tree or word web +==========================================================*/ +function mountMind(root, initState){ + const S = { layout:"spider", nodes:[{id:1, x:0.5, y:0.45, t:"", parent:null}], nextId:2 }; + if(initState){ + S.layout = initState.layout || "spider"; + if(Array.isArray(initState.nodes) && initState.nodes.length) S.nodes = initState.nodes; + S.nextId = initState.nextId || (Math.max(...S.nodes.map(n=>n.id))+1); + } + const PALETTE = ["#3b7dd8","#3fae6a","#e0554d","#9b59b6","#f28c38"]; + + root.innerHTML = ` +
+
+ +
+
+
+
`; + const mmEl = root.querySelector(".mm"); + /* toolbar row wrapper */ + const topRow = document.createElement("div"); + topRow.className = "mm-top"; + mmEl.insertBefore(topRow, mmEl.firstChild); + const mmGrip = document.createElement("span"); + mmGrip.className = "mm-grip"; mmGrip.textContent = "⠿"; + topRow.appendChild(mmGrip); + topRow.appendChild(root.querySelector(".mm-seg")); + topRow.appendChild(root.querySelector(".mm-hint")); + mmGrip.addEventListener("pointerdown", e=>{ + e.preventDefault(); e.stopPropagation(); + const mr = mmEl.getBoundingClientRect(); + const sc = mr.width / mmEl.offsetWidth; /* effective zoom on this widget */ + const ox = e.clientX/sc - topRow.offsetLeft, oy = e.clientY/sc - topRow.offsetTop; + const mv = ev=>{ + topRow.style.left = Math.max(0, Math.min(mmEl.offsetWidth - topRow.offsetWidth, ev.clientX/sc - ox))+"px"; + topRow.style.top = Math.max(0, Math.min(mmEl.offsetHeight - topRow.offsetHeight, ev.clientY/sc - oy))+"px"; + }; + const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; + document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); + }); + + const stage = root.querySelector(".mm-stage"), + svg = stage.querySelector("svg"); + + function nodeById(id){ return S.nodes.find(n=>n.id===id); } + function depth(n){ let d=0, c=n; while(c && c.parent!=null){ c = nodeById(c.parent); d++; } return d; } + + function drawLines(){ + const w = stage.clientWidth, h = stage.clientHeight; + let paths = ""; + S.nodes.forEach(n=>{ + if(n.parent==null) return; + const p = nodeById(n.parent); + if(!p) return; + const x1 = p.x*w, y1 = p.y*h, x2 = n.x*w, y2 = n.y*h; + const col = n.lc || PALETTE[(depth(n)-1) % PALETTE.length]; + const dash = n.ls==="dash" ? ' stroke-dasharray="11 8"' : n.ls==="dot" ? ' stroke-dasharray="2.5 7"' : ""; + if(S.layout==="spider"){ + const mx = (x1+x2)/2 + (y2-y1)*0.18, my = (y1+y2)/2 - (x2-x1)*0.18; + paths += ``; + }else if(S.layout==="tree"){ + const my = (y1+y2)/2; + paths += ``; + }else{ + paths += ``; + } + }); + svg.innerHTML = paths; + } + + function applyNodeStyle(n, d){ + if(n.parent!=null) d.style.borderColor = n.c || PALETTE[(depth(n)-1) % PALETTE.length]; + else if(n.c) d.style.borderColor = n.c; + d.style.fontSize = (n.fs || (n.parent==null ? 17 : 15))+"px"; + } + function buildNode(n){ + const d = document.createElement("div"); + d.className = "mm-node" + (n.parent==null ? " root" : ""); + applyNodeStyle(n, d); + d.dataset.id = n.id; + const span = document.createElement("span"); + span.contentEditable = "true"; + span.spellcheck = false; + span.textContent = n.t || (n.parent==null ? T("mmRoot") : ""); + span.addEventListener("input", ()=>{ n.t = span.textContent; }); + span.addEventListener("keydown", e=>e.stopPropagation()); + span.addEventListener("pointerdown", e=>e.stopPropagation()); + d.appendChild(span); + const add = document.createElement("button"); + add.className = "mm-add"; add.textContent = "+"; + add.addEventListener("pointerdown", e=>e.stopPropagation()); + add.addEventListener("click", ()=>{ + const a = Math.random()*Math.PI*2; + S.nodes.push({ + id: S.nextId++, parent: n.id, + x: Math.min(.95, Math.max(.05, n.x + Math.cos(a)*0.17)), + y: Math.min(.92, Math.max(.06, n.y + Math.sin(a)*0.2)), + t: "" + }); + render(); + }); + d.appendChild(add); + const cog = document.createElement("button"); + cog.className = "mm-cog"; cog.textContent = "🎨"; + cog.addEventListener("pointerdown", e=>e.stopPropagation()); + cog.addEventListener("click", ()=>openNodePop(n, d)); + d.appendChild(cog); + if(n.parent!=null){ + const del = document.createElement("button"); + del.className = "mm-del"; del.textContent = "✕"; + del.addEventListener("pointerdown", e=>e.stopPropagation()); + del.addEventListener("click", ()=>{ + const doomed = new Set([n.id]); + let grew = true; + while(grew){ + grew = false; + S.nodes.forEach(m=>{ + if(m.parent!=null && doomed.has(m.parent) && !doomed.has(m.id)){ doomed.add(m.id); grew = true; } + }); + } + S.nodes = S.nodes.filter(m=>!doomed.has(m.id)); + render(); + }); + d.appendChild(del); + } + /* drag */ + d.addEventListener("pointerdown", e=>{ + if(e.pointerType==="mouse" && e.button!==0) return; + e.preventDefault(); + const r = stage.getBoundingClientRect(); + const mv = ev=>{ + n.x = Math.min(.98, Math.max(.02, (ev.clientX-r.left)/r.width)); + n.y = Math.min(.96, Math.max(.04, (ev.clientY-r.top)/r.height)); + d.style.left = (n.x*100)+"%"; + d.style.top = (n.y*100)+"%"; + drawLines(); + }; + const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; + document.addEventListener("pointermove",mv); + document.addEventListener("pointerup",up); + }); + d.style.left = (n.x*100)+"%"; + d.style.top = (n.y*100)+"%"; + return d; + } + + /* per-node settings popover: colour, font size, line colour & style */ + function closeNodePop(){ stage.querySelectorAll(".mm-pop").forEach(x=>x.remove()); } + function openNodePop(n, nodeEl){ + closeNodePop(); + const pop = document.createElement("div"); + pop.className = "mm-pop"; + const w = stage.clientWidth, h = stage.clientHeight; + pop.style.left = Math.min(n.x*w + 30, w-190)+"px"; + pop.style.top = Math.min(n.y*h + 24, h-150)+"px"; + pop.addEventListener("pointerdown", e=>e.stopPropagation()); + + const restyle = ()=>{ applyNodeStyle(n, nodeEl); drawLines(); refreshPop(); }; + const row = (lbl, title)=>{ + const r = document.createElement("div"); + r.className = "mp-row"; r.title = title; + const l = document.createElement("span"); + l.className = "mp-lbl"; l.textContent = lbl; + r.appendChild(l); + pop.appendChild(r); + return r; + }; + /* node colour */ + const r1 = row("⬤", T("nodeColor")); + PALETTE.concat(["#26344a"]).forEach(c=>{ + const b = document.createElement("button"); + b.className = "mp-sw"; b.dataset.v = c; b.style.background = c; + b.addEventListener("click", ()=>{ n.c = (n.c===c ? null : c); restyle(); }); + r1.appendChild(b); + }); + const xBtn = document.createElement("button"); + xBtn.className = "mp-x"; xBtn.textContent = "✕"; + xBtn.addEventListener("click", closeNodePop); + r1.appendChild(xBtn); + /* font size */ + const r2 = row("A", T("nodeFont")); + [["A",13],["A",16],["A",20]].forEach(([txt,fs],i)=>{ + const b = document.createElement("button"); + b.className = "mp-fs"; b.dataset.v = fs; + b.textContent = txt; b.style.fontSize = (11+i*3)+"px"; + b.addEventListener("click", ()=>{ n.fs = fs; restyle(); }); + r2.appendChild(b); + }); + if(n.parent!=null){ + /* line colour */ + const r3 = row("╱", T("lineColor")); + PALETTE.concat(["#26344a"]).forEach(c=>{ + const b = document.createElement("button"); + b.className = "mp-sw"; b.dataset.v = "l"+c; b.style.background = c; + b.addEventListener("click", ()=>{ n.lc = (n.lc===c ? null : c); restyle(); }); + r3.appendChild(b); + }); + /* line style */ + const r4 = row("┄", T("lineStyle")); + [["solid",""],["dash","dash"],["dot","dot"]].forEach(([id,val])=>{ + const b = document.createElement("button"); + b.className = "mp-ls"; b.dataset.ls = id; b.dataset.v = "s"+id; + b.appendChild(document.createElement("i")); + b.addEventListener("click", ()=>{ n.ls = val || null; restyle(); }); + r4.appendChild(b); + }); + } + function refreshPop(){ + pop.querySelectorAll(".mp-sw").forEach(b=>{ + const v = b.dataset.v; + b.classList.toggle("on", v.startsWith("l") ? n.lc===v.slice(1) : n.c===v); + }); + pop.querySelectorAll(".mp-fs").forEach(b=> + b.classList.toggle("on", +b.dataset.v === (n.fs || (n.parent==null?17:15)))); + pop.querySelectorAll(".mp-ls").forEach(b=> + b.classList.toggle("on", (n.ls||"") === (b.dataset.ls==="solid" ? "" : b.dataset.ls))); + } + refreshPop(); + stage.appendChild(pop); + } + document.addEventListener("pointerdown", e=>{ + if(!stage.querySelector(".mm-pop")) return; + if(!e.target.closest(".mm-pop") && !e.target.closest(".mm-cog")) closeNodePop(); + }); + + function render(){ + mmEl.className = "mm "+S.layout; + root.querySelectorAll(".mm-seg button").forEach(b=>{ + b.textContent = T(b.dataset.m==="spider" ? "mmSpider" : b.dataset.m==="tree" ? "mmTree" : "mmWeb"); + b.classList.toggle("on", b.dataset.m===S.layout); + }); + root.querySelector(".mm-hint").textContent = T("mmHint"); + closeNodePop(); + stage.querySelectorAll(".mm-node").forEach(x=>x.remove()); + S.nodes.forEach(n=>stage.appendChild(buildNode(n))); + drawLines(); + } + root.querySelectorAll(".mm-seg button").forEach(b=>{ + b.addEventListener("click", ()=>{ S.layout = b.dataset.m; render(); }); + }); + new ResizeObserver(drawLines).observe(stage); + document.addEventListener("langchange", render); + render(); + return { + getState: ()=>({ layout:S.layout, nodes:S.nodes, nextId:S.nextId }), + setState: st=>{ if(st && st.nodes){ S.layout=st.layout||"spider"; S.nodes=st.nodes; S.nextId=st.nextId||S.nextId; render(); } } + }; +} + diff --git a/public/js/widgets/names.js b/public/js/widgets/names.js new file mode 100644 index 0000000..88ee88d --- /dev/null +++ b/public/js/widgets/names.js @@ -0,0 +1,74 @@ +/* widget: Namenkiezer */ +/* ========================================================= + Name picker widget +==========================================================*/ +function mountNames(root, initState){ + let used = new Set(initState?.used||[]), spinning = false; + root.innerHTML = ` +
+
🎯
+
+ +
+ + + +
+
`; + const ta = root.querySelector(".np-list"), + result = root.querySelector(".np-result"), + pickBtn = root.querySelector(".np-pick"), + nr = root.querySelector(".np-nr"); + if(initState){ + ta.value = initState.names || ""; + nr.checked = !!initState.nr; + } + ta.addEventListener("input", ()=>{used.clear();updateProgress();}); + ta.addEventListener("keydown", e=>e.stopPropagation()); + function names(){ return ta.value.split("\n").map(s=>s.trim()).filter(Boolean); } + function updateProgress(){const all=names();used=new Set([...used].filter(name=>all.includes(name)));root.querySelector('.np-progress').textContent=T('namesProgress').replace('{used}',used.size).replace('{total}',all.length)} + pickBtn.addEventListener("click", ()=>{ + if(spinning) return; + const all = names(); + if(!all.length){ ta.focus(); return; } + let pool = nr.checked ? all.filter(n=>!used.has(n)) : all; + if(!pool.length){ + result.textContent = T("namesDone"); + updateProgress(); + return; + } + spinning = true; + result.classList.remove("winner"); + result.classList.add("spin"); + const FUN = ["🌟","🎉","🦄","🚀","🐸","🎈","🍀","⚡"]; + let n = 0; + const iv = setInterval(()=>{ + result.textContent = all[Math.floor(Math.random()*all.length)]; + tone(400+Math.random()*300, 0.03, 0, "square", 0.04); + if(++n >= 16){ + clearInterval(iv); + const winner = pool[Math.floor(Math.random()*pool.length)]; + const em = FUN[Math.floor(Math.random()*8)]; + result.textContent = `${em} ${winner} ${em}`; + result.classList.remove("spin"); + result.classList.add("winner"); + confetti(result.parentElement); + if(nr.checked) used.add(winner); + updateProgress(); + sndWin(); + spinning = false; + } + }, 80); + }); + root.querySelector('.np-reset').addEventListener('click',()=>{used.clear();result.textContent='🎯';result.classList.remove('winner','spin');updateProgress()}); + function labels(){ + ta.placeholder = T("namesPh"); + pickBtn.textContent = T("namesPick"); + root.querySelector('.np-reset').textContent=T('namesReset'); + root.querySelector(".np-nrl").textContent = T("namesNoRep"); + updateProgress(); + } + document.addEventListener("langchange", labels); + labels(); + return { getState: ()=>({names:ta.value, nr:nr.checked, used:[...used]}) }; +} diff --git a/public/js/widgets/noise.js b/public/js/widgets/noise.js new file mode 100644 index 0000000..aac7794 --- /dev/null +++ b/public/js/widgets/noise.js @@ -0,0 +1,124 @@ +/* widget: Geluidsmeter */ +/* ========================================================= + Live noise meter — mic input stays local, never recorded or sent anywhere +==========================================================*/ +function mountNoise(root, initState){ + let zoneGreen = (initState && initState.zoneGreen) || 40; + let zoneYellow = (initState && initState.zoneYellow) || 70; + let running = false, audioCtx = null, analyser = null, stream = null, rafId = null, warnTimer = null; + let level = 0, peak = 0, timeData = null; + + root.innerHTML = ` +
+

+
+
+
+
+
+
+
+
+ + +
+ + +
+

+
`; + const gauge = root.querySelector(".nm-gauge"), + fill = root.querySelector(".nm-fill"), + markGreen = root.querySelector(".nm-mark.green"), + markYellow = root.querySelector(".nm-mark.yellow"), + face = root.querySelector(".nm-face"), + status = root.querySelector(".nm-status"), + toggle = root.querySelector(".nm-toggle"), + z1 = root.querySelector(".nm-z1"), + z2 = root.querySelector(".nm-z2"); + z1.value = zoneGreen; + z2.value = zoneYellow; + + function currentZone(){ return level < zoneGreen ? "green" : level < zoneYellow ? "yellow" : "red"; } + function draw(){ + fill.style.height = Math.round(level) + "%"; + fill.className = "nm-fill " + currentZone(); + markGreen.style.bottom = zoneGreen + "%"; + markYellow.style.bottom = zoneYellow + "%"; + face.textContent = !running ? "🎙️" : currentZone() === "green" ? "🙂" : currentZone() === "yellow" ? "😐" : "😬"; + status.textContent = !running ? T("noiseOff") + : T("noiseStatus_" + currentZone()); + gauge.classList.toggle("warn", running && currentZone() === "red"); + } + function loop(){ + if(!running || !document.body.contains(root)){ stop(); return; } + analyser.getByteTimeDomainData(timeData); + let sum = 0; + for(let i = 0; i < timeData.length; i++){ const v = (timeData[i] - 128) / 128; sum += v * v; } + const rms = Math.sqrt(sum / timeData.length); + const pct = Math.min(100, rms * 100 * 4.2); + level = level * 0.75 + pct * 0.25; + peak = Math.max(peak * 0.995, level); + draw(); + rafId = requestAnimationFrame(loop); + } + async function start(){ + try{ + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + }catch(e){ + status.textContent = T("noiseDenied"); + return; + } + audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)(); + if(audioCtx.state === "suspended") await audioCtx.resume().catch(()=>{}); + const source = audioCtx.createMediaStreamSource(stream); + analyser = audioCtx.createAnalyser(); + analyser.fftSize = 1024; + timeData = new Uint8Array(analyser.fftSize); + source.connect(analyser); + running = true; level = 0; peak = 0; + labels(); + loop(); + } + function stop(){ + running = false; + if(rafId) cancelAnimationFrame(rafId); + rafId = null; + if(stream){ stream.getTracks().forEach(t => t.stop()); stream = null; } + analyser = null; + level = 0; + labels(); + draw(); + } + toggle.addEventListener("click", ()=>{ if(running) stop(); else start(); }); + z1.addEventListener("input", ()=>{ + zoneGreen = Math.min(+z1.value, +z2.value - 5); + z1.value = zoneGreen; + draw(); + }); + z2.addEventListener("input", ()=>{ + zoneYellow = Math.max(+z2.value, zoneGreen + 5); + z2.value = zoneYellow; + draw(); + }); + /* geluid over rood laten lopen is een stoorzender op zich - een korte, + zachte waarschuwing volstaat, geen herhaald alarm */ + const watchWarn = ()=>{ + clearInterval(warnTimer); + warnTimer = setInterval(()=>{ + if(running && currentZone() === "red") sndBad(); + }, 6000); + }; + watchWarn(); + function labels(){ + root.querySelector(".nm-mission").textContent = T("wg_noise_d"); + toggle.textContent = running ? "⏸ " + T("noiseStop") : "🎙️ " + T("noiseStart"); + root.querySelectorAll(".nm-zone-lbl")[0].textContent = T("noiseZoneGreen"); + root.querySelectorAll(".nm-zone-lbl")[1].textContent = T("noiseZoneYellow"); + root.querySelector(".nm-privacy").textContent = T("noisePrivacy"); + draw(); + } + document.addEventListener("langchange", labels); + labels(); + return { getState: ()=>({ zoneGreen, zoneYellow }) }; +} diff --git a/public/js/widgets/notes.js b/public/js/widgets/notes.js new file mode 100644 index 0000000..043a9b0 --- /dev/null +++ b/public/js/widgets/notes.js @@ -0,0 +1,19 @@ +/* widget: Notities */ +/* ========================================================= + Notes widget +==========================================================*/ +function mountNotes(root, initState){ + const S={theme:initState?.theme||'sun',large:!!initState?.large}; + root.innerHTML='
'; + const ta = document.createElement("textarea"); + ta.className = "notes"; + ta.placeholder = T("notesPh"); + if(initState && typeof initState.text==="string") ta.value = initState.text; + const app=root.querySelector('.notes-app'),swatches=root.querySelector('.notes-swatches'); + ['sun','mint','sky','lilac'].forEach(theme=>{const b=document.createElement('button');b.type='button';b.dataset.theme=theme;b.setAttribute('aria-label',theme);b.onclick=()=>{S.theme=theme;apply()};swatches.append(b)}); + app.appendChild(ta); + function apply(){app.dataset.theme=S.theme;ta.classList.toggle('large',S.large);root.querySelectorAll('.notes-swatches button').forEach(b=>{const on=b.dataset.theme===S.theme;b.classList.toggle('on',on);b.setAttribute('aria-pressed',String(on))});root.querySelector('.notes-size').classList.toggle('on',S.large);root.querySelector('.notes-size').setAttribute('aria-pressed',String(S.large));root.querySelector('.notes-count').textContent=T('notesCount').replace('{count}',ta.value.length)} + function labels(){ta.placeholder=T('notesPh');root.querySelector('.notes-color-label').textContent=T('notesColor');root.querySelector('.notes-size').title=T('notesLarge');apply()} + ta.addEventListener('input',apply);root.querySelector('.notes-size').onclick=()=>{S.large=!S.large;apply()};document.addEventListener("langchange",labels);labels(); + return { getState: ()=>({text:ta.value,theme:S.theme,large:S.large}) }; +} diff --git a/public/js/widgets/numberline.js b/public/js/widgets/numberline.js new file mode 100644 index 0000000..5107887 --- /dev/null +++ b/public/js/widgets/numberline.js @@ -0,0 +1,72 @@ +/* widget: interactieve getallenlijn */ +function mountNumberline(root, initState){ + const S=Object.assign({level:1,yearGroup:null,managedLearningLevel:false,min:0,max:20,step:1,value:0,target:10,score:0,streak:0,solved:false},initState||{}); + const rules=mathTaskRules(S.yearGroup,S.level); + if(S.managedLearningLevel&&rules){S.min=rules.numberlineMin;S.max=rules.numberlineMax;S.step=rules.numberlineStep;S.value=S.min;S.target=S.min+S.step;} + root.innerHTML=` +
+
+ + + + +
+
+
+
+ + + + +
+
+
`; + const q=s=>root.querySelector(s), minEl=q('.nlw-min'), maxEl=q('.nlw-max'), stepEl=q('.nlw-step'), + line=q('.nlw-line'), valueEl=q('.nlw-value'), task=q('.nlw-task'), feedback=q('.nlw-feedback'),score=q('.nlw-score'); + if(![...stepEl.options].some(o=>+o.value===S.step))stepEl.add(new Option(S.step,S.step)); + minEl.value=S.min; maxEl.value=S.max; stepEl.value=S.step; + [minEl,maxEl,stepEl].forEach(el=>el.disabled=!!S.managedLearningLevel); + function normalize(){ + let min=Math.max(-1000000,Math.min(999999,+minEl.value||0)); let max=Math.max(-999999,Math.min(1000000,+maxEl.value||20)); + if(max<=min) max=min+1; const step=+stepEl.value||1; + if((max-min)/step>60) max=min+60*step; + S.min=min; S.max=max; S.step=step; + if(S.valuemax||(S.value-min)%step!==0) S.value=min; + if(S.targetmax||(S.target-min)%step!==0) S.target=min; + minEl.value=min; maxEl.value=max; render(); + } + function render(){ + line.innerHTML=""; + for(let v=S.min;v<=S.max;v+=S.step){ + const b=document.createElement('button'); b.type='button'; b.className='nlw-tick'; b.dataset.v=v; + const mark=document.createElement('i'), label=document.createElement('span'); label.textContent=v; + b.append(mark,label); b.classList.toggle('selected',v===S.value); + b.addEventListener('click',()=>{S.value=v;if(!S.solved)feedback.textContent="";render();}); line.appendChild(b); + } + valueEl.textContent=S.value; task.textContent=T('numberlineTask').replace('{number}',S.target); + score.textContent=T('numberlineScore').replace('{score}',S.score).replace('{streak}',S.streak); + } + function newTarget(){ + const count=Math.floor((S.max-S.min)/S.step);let target=S.target; + for(let tries=0;tries<8&&target===S.target;tries++)target=S.min+Math.floor(Math.random()*(count+1))*S.step; + S.target=target;S.solved=false; + feedback.textContent="";feedback.className='nlw-feedback';render(); + } + [minEl,maxEl,stepEl].forEach(el=>el.addEventListener('change',normalize)); + q('.nlw-minus').addEventListener('click',()=>{S.value=Math.max(S.min,S.value-S.step);render();}); + q('.nlw-plus').addEventListener('click',()=>{S.value=Math.min(S.max,S.value+S.step);render();}); + q('.nlw-new').addEventListener('click',newTarget); + q('.nlw-check').addEventListener('click',()=>{ + const ok=S.value===S.target; feedback.textContent=T(ok?'numberlineGood':'numberlineTry'); + feedback.className='nlw-feedback '+(ok?'ok':'bad'); + if(ok){if(!S.solved){S.score++;S.streak++;S.solved=true;}sndGood();confetti(root);}else{S.streak=0;S.solved=false;sndBad();}render(); + }); + function labels(){ + q('.nlw-from-label').textContent=T('numberlineFrom'); q('.nlw-to-label').textContent=T('numberlineTo'); + q('.nlw-step-label').textContent=T('numberlineStep'); q('.nlw-new').textContent=T('numberlineNew'); + q('.nlw-check').textContent=T('numberlineCheck'); render(); + } + root.classList.toggle("managed-learning-level",!!S.managedLearningLevel); + document.addEventListener('langchange',labels); labels(); normalize(); + return {getState:()=>({min:S.min,max:S.max,step:S.step,value:S.value,target:S.target,score:S.score,streak:S.streak,solved:S.solved})}; +} diff --git a/public/js/widgets/sentence.js b/public/js/widgets/sentence.js new file mode 100644 index 0000000..b266e31 --- /dev/null +++ b/public/js/widgets/sentence.js @@ -0,0 +1,73 @@ +/* widget: zinsbouwer met aanpasbare woordbanken */ +function mountSentence(root, initState){ + const year=learningYearGroup(initState?.yearGroup); + const early=year&&year<=3,advanced=year&&year>=7; + const defaults=LANG==='nl' ? (early ? { + who:['De hond','Ik','Sam','De juf'],does:['leest','speelt','maakt','ziet'],what:['een boek','buiten','een huis','de bal'], + } : advanced ? { + who:['Hoewel de bron','De schrijver','Onze klas','Volgens de onderzoeker'],does:['vergelijkt','beargumenteert','verklaart','beoordeelt'],what:['de informatie kritisch','twee standpunten','de uitkomst zorgvuldig','het bewijs in de tekst'], + } : { + who:['De hond','Mijn vriend','De juf','Sara'], does:['lees','speelt','maakt','ziet'], + what:['een boek','op het plein','een mooie tekening','de rode bal'], + }) : (early ? { + who:['The dog','I','Sam','The teacher'],does:['reads','plays','makes','sees'],what:['a book','outside','a house','the ball'], + } : advanced ? { + who:['Although the source','The writer','Our class','According to the researcher'],does:['compares','argues','explains','assesses'],what:['the information critically','two viewpoints','the result carefully','the evidence in the text'], + } : { + who:['The dog','My friend','The teacher','Sarah'], does:['reads','plays','makes','sees'], + what:['a book','in the playground','a beautiful drawing','the red ball'], + }); + const legacyNl={who:['De hond','Mijn vriend','De juf','Sara'],does:['lees','speelt','maakt','ziet'],what:['een boek','op het plein','een mooie tekening','de rode bal']}; + const legacyEn={who:['The dog','My friend','The teacher','Sarah'],does:['reads','plays','makes','sees'],what:['a book','in the playground','a beautiful drawing','the red ball']}; + const savedBanks=initState?.banks,isLegacyDefault=savedBanks&&[legacyNl,legacyEn].some(bank=>JSON.stringify(bank)===JSON.stringify(savedBanks)); + const chosenBanks=initState?.managedLearningLevel&&isLegacyDefault?defaults:(savedBanks||defaults); + const S={banks:JSON.parse(JSON.stringify(chosenBanks)),sentence:[...(initState?.sentence||[])]}; + root.innerHTML=` +
+
+
+
+ + + +
+
+
`; + const q=s=>root.querySelector(s), sentenceEl=q('.sb-sentence'), banksEl=q('.sb-banks'), kind=q('.sb-kind'), word=q('.sb-word'); + function sentenceText(){ return S.sentence.map(x=>x.word).join(' ').replace(/\s+([,.!?])/g,'$1'); } + function renderSentence(){ + sentenceEl.innerHTML=""; + if(!S.sentence.length){ sentenceEl.textContent=T('sentenceHint'); sentenceEl.classList.add('empty'); return; } + sentenceEl.classList.remove('empty'); + S.sentence.forEach(part=>{const span=document.createElement('span');span.className='sb-part '+part.kind;span.textContent=part.word;sentenceEl.appendChild(span);}); + } + function renderBanks(){ + banksEl.innerHTML=""; + ['who','does','what'].forEach(k=>{ + const group=document.createElement('section'); group.className='sb-bank '+k; + const h=document.createElement('h3'); h.textContent=T('sentence_'+k); group.appendChild(h); + const chips=document.createElement('div'); chips.className='sb-chips'; + S.banks[k].forEach((w,i)=>{ + const wrap=document.createElement('span'); wrap.className='sb-chip-wrap'; + const chip=document.createElement('button'); chip.type='button'; chip.className='sb-chip'; chip.textContent=w; + chip.addEventListener('click',()=>{S.sentence.push({kind:k,word:w});renderSentence();}); + const remove=document.createElement('button'); remove.type='button'; remove.className='sb-chip-remove'; remove.textContent='×'; remove.title=T('sentenceRemove'); + remove.addEventListener('click',()=>{S.banks[k].splice(i,1);renderBanks();}); wrap.append(chip,remove); chips.appendChild(wrap); + }); + group.appendChild(chips); banksEl.appendChild(group); + }); + } + function addWord(){const w=word.value.trim();if(!w)return;S.banks[kind.value].push(w);word.value='';renderBanks();} + q('.sb-add-btn').addEventListener('click',addWord); word.addEventListener('keydown',e=>{e.stopPropagation();if(e.key==='Enter')addWord();}); + q('.sb-back').addEventListener('click',()=>{S.sentence.pop();renderSentence();}); + q('.sb-clear').addEventListener('click',()=>{S.sentence=[];renderSentence();}); + q('.sb-surprise').addEventListener('click',()=>{S.sentence=['who','does','what'].filter(k=>S.banks[k].length).map(kind=>({kind,word:S.banks[kind][Math.floor(Math.random()*S.banks[kind].length)]}));renderSentence();sndGood();confetti(sentenceEl)}); + q('.sb-speak').addEventListener('click',()=>{const text=sentenceText();if(text&&'speechSynthesis'in window){speechSynthesis.cancel();const u=new SpeechSynthesisUtterance(text);u.lang=LANG==='nl'?'nl-NL':'en-GB';speechSynthesis.speak(u);}}); + function labels(){ + [...kind.options].forEach(o=>o.textContent=T('sentence_'+o.value)); word.placeholder=T('sentenceAdd'); + q('.sb-surprise').textContent=T('sentenceSurprise');q('.sb-clear').textContent=T('sentenceClear'); q('.sb-speak').textContent=T('sentenceSpeak'); renderSentence();renderBanks(); + } + root.classList.toggle("managed-learning-level",!!initState?.managedLearningLevel); + document.addEventListener('langchange',labels);labels(); + return {getState:()=>({banks:S.banks,sentence:S.sentence})}; +} diff --git a/public/js/widgets/timer.js b/public/js/widgets/timer.js new file mode 100644 index 0000000..78b7ad7 --- /dev/null +++ b/public/js/widgets/timer.js @@ -0,0 +1,151 @@ +/* widget: Timer */ +/* ========================================================= + Timer widget — digits, visual "kijkklok" disc, or both +==========================================================*/ +function mountTimer(root, initState){ + const DIAL = 3600; // the disc is a 60-minute dial, like a real kijkklok + let total = 300, left = 300, iv = null, + mode = "both", // dig | pie | both + snd = true; + if(initState){ + total = initState.total || 300; left = total; + mode = initState.mode || "both"; + snd = initState.snd !== false; + } + root.innerHTML = ` +
+
+ +
+ + + +
+
+
+ + + + + + + +
05:00
+
+
+
+ + +
+
+
`; + const box = root.querySelector(".timer"), + svg = root.querySelector(".tpie"), + disp = root.querySelector(".tdisp"), + arc = root.querySelector(".parc"), + knob = root.querySelector(".knob"), + bstart = root.querySelector(".bstart"), + breset = root.querySelector(".breset"), + bsnd = root.querySelector(".bsnd"), + presets = root.querySelector(".presets"), + hint = root.querySelector(".thint"); + const fmt = s => String(Math.floor(s/60)).padStart(2,"0")+":"+String(s%60).padStart(2,"0"); + + /* minute tick marks (every 5 min) */ + const ticks = root.querySelector(".ticks"); + for(let i=0;i<12;i++){ + const a = i*30*Math.PI/180; + const l = document.createElementNS("http://www.w3.org/2000/svg","line"); + l.setAttribute("x1", 75+62*Math.sin(a)); l.setAttribute("y1", 75-62*Math.cos(a)); + l.setAttribute("x2", 75+69*Math.sin(a)); l.setAttribute("y2", 75-69*Math.cos(a)); + l.setAttribute("stroke", "#b9c7d9"); l.setAttribute("stroke-width", i%3===0?2.5:1.5); + ticks.appendChild(l); + } + + function drawPie(){ + const a = Math.min(360, left/DIAL*360); + const rad = a*Math.PI/180; + const kx = 75 + 63*Math.sin(rad), ky = 75 - 63*Math.cos(rad); + knob.setAttribute("cx", kx.toFixed(2)); knob.setAttribute("cy", ky.toFixed(2)); + if(a<=0){ arc.setAttribute("d",""); return; } + if(a>=359.99){ arc.setAttribute("d","M75,5 A70,70 0 1 1 74.99,5 Z"); return; } + const x = 75 + 70*Math.sin(rad), y = 75 - 70*Math.cos(rad); + arc.setAttribute("d",`M75,75 L75,5 A70,70 0 ${a>180?1:0} 1 ${x.toFixed(2)},${y.toFixed(2)} Z`); + } + function draw(){ + disp.textContent = fmt(left); + disp.classList.toggle("warn", left<=10 && left>0 && iv!==null); + drawPie(); + } + function labels(){ + bstart.textContent = (iv ? "⏸ "+T("pause") : "▶ "+T("start")); + breset.textContent = "↺ "+T("reset"); + presets.querySelectorAll("button").forEach(b=>b.textContent = b.dataset.m+" "+T("min")); + root.querySelectorAll(".tseg button").forEach(b=>{ + const key = b.dataset.m==="dig" ? "tmDigits" : b.dataset.m==="pie" ? "tmVisual" : "tmBoth"; + b.title = T(key); + b.classList.toggle("on", b.dataset.m===mode); + }); + bsnd.textContent = snd ? "🔔" : "🔕"; + bsnd.title = T("tmSound"); + bsnd.classList.toggle("on", snd); + box.className = "timer m-"+mode + (box.classList.contains("flash")?" flash":""); + hint.textContent = mode==="dig" ? "" : T("tmHint"); + } + [1,5,10,15,30].forEach(m=>{ + const b = document.createElement("button"); + b.dataset.m = m; + b.addEventListener("click", ()=>{ stop(); total=left=m*60; draw(); labels(); }); + presets.appendChild(b); + }); + root.querySelectorAll(".tseg button").forEach(b=>{ + b.addEventListener("click", ()=>{ mode = b.dataset.m; labels(); }); + }); + bsnd.addEventListener("click", ()=>{ snd=!snd; labels(); }); + + /* drag on the disc to set the time */ + function angleFromEvent(ev){ + const r = svg.getBoundingClientRect(); + const px = (ev.clientX - r.left)*150/r.width - 75; + const py = (ev.clientY - r.top)*150/r.height - 75; + return (Math.atan2(px, -py)*180/Math.PI + 360) % 360; + } + svg.addEventListener("pointerdown", e=>{ + e.preventDefault(); + svg.setPointerCapture(e.pointerId); + const setFrom = ev=>{ + const mins = Math.min(60, Math.max(1, Math.round(angleFromEvent(ev)/6))); + total = left = mins*60; + draw(); + }; + setFrom(e); + const move = ev=>setFrom(ev); + const up = ()=>{ svg.removeEventListener("pointermove",move); svg.removeEventListener("pointerup",up); }; + svg.addEventListener("pointermove",move); + svg.addEventListener("pointerup",up); + }); + + function stop(){ if(iv){ clearInterval(iv); iv=null; } } + bstart.addEventListener("click", ()=>{ + if(iv){ stop(); } + else if(left>0){ + iv = setInterval(()=>{ + left--; + if(left<=0){ + left=0; stop(); + if(snd) sndWin(); + box.classList.add("flash"); + setTimeout(()=>box.classList.remove("flash"), 2200); + labels(); + } + draw(); + },1000); + } + labels(); draw(); + }); + breset.addEventListener("click", ()=>{ stop(); left=total; draw(); labels(); }); + document.addEventListener("langchange", labels); + labels(); draw(); + return { getState: ()=>({total, mode, snd}) }; +} + diff --git a/public/js/widgets/world.js b/public/js/widgets/world.js new file mode 100644 index 0000000..07b6d56 --- /dev/null +++ b/public/js/widgets/world.js @@ -0,0 +1,1094 @@ +/* Wereldoriëntatie: echte soortfoto's en geografisch correcte topografie. */ +"use strict"; + +const WORLD_MEDIA_ROOT="img/world/"; +const worldMedia=(file,author,license,source)=>Object.freeze({file,author,license,source}); +const WORLD_MEDIA=Object.freeze({ + oak:{ + tree:worldMedia("trees/oak.webp","AnemoneProjectors","CC BY-SA 2.0","https://commons.wikimedia.org/wiki/File:1._English_Oak_(Quercus_robur)_(3607652442).jpg"), + leaf:worldMedia("leaves/oak.webp","Rasbak","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Zomereik_blad_Quercus_robur.jpg"), + }, + birch:{ + tree:worldMedia("trees/birch.webp","Maplan","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Betula_pendula_winter.jpg"), + leaf:worldMedia("leaves/birch.webp","Joanna Boisse","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Atlas_roslin_pl_Brzoza_brodawkowata_1393_6470.jpg"), + }, + horse_chestnut:{ + tree:worldMedia("trees/horse-chestnut.webp","Toubib","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Paardenkastanje2.jpg"), + leaf:worldMedia("leaves/horse-chestnut.webp","Tournasol7","CC BY 4.0","https://commons.wikimedia.org/wiki/File:Aesculus_hippocastanum_in_botanical_garden_of_UKW_Bydgoszcz_(2).jpg"), + }, + pine:{ + tree:worldMedia("trees/pine.webp","Hello, I am Bruce","CC BY-SA 2.0","https://commons.wikimedia.org/wiki/File:Pinus_sylvestris_Glenmuick.jpg"), + leaf:worldMedia("leaves/pine.webp","AnRo0002","CC0 1.0","https://commons.wikimedia.org/wiki/File:20151215Pinus_sylvestris2.jpg"), + }, + beech:{ + tree:worldMedia("trees/beech.webp","Emőke Dénes","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Fagales_-_Fagus_sylvatica.jpg"), + leaf:worldMedia("leaves/beech.webp","AnRo0002","CC0 1.0","https://commons.wikimedia.org/wiki/File:20130530Hain_Rotbuche.jpg"), + }, + maple:{ + tree:worldMedia("trees/maple.webp","MPF","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Acer-pseudoplatanus.jpg"), + leaf:worldMedia("leaves/maple.webp","Willow","CC BY 2.5","https://commons.wikimedia.org/wiki/File:Acer_pseudoplatanus_003.jpg"), + }, + willow:{ + tree:worldMedia("trees/willow.webp","Burkhard Mücke","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Salix_alba_an_der_Isar.jpg"), + leaf:worldMedia("leaves/willow.webp","AnRo0002","CC0 1.0","https://commons.wikimedia.org/wiki/File:20160721Salix_alba1.jpg"), + }, + alder:{ + tree:worldMedia("trees/alder.webp","Willow","CC BY 2.5","https://commons.wikimedia.org/wiki/File:Alnus_glutinosa_011.jpg"), + leaf:worldMedia("leaves/alder.webp","AnRo0002","CC0 1.0","https://commons.wikimedia.org/wiki/File:20200531Alnus_glutinosa2.jpg"), + }, + lime:{ + tree:worldMedia("trees/lime.webp","Friedrich Haag","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:012_2015_05_06_Winterlinde_(Wiki_Loves_Earth_2015).jpg"), + leaf:worldMedia("leaves/lime.webp","Dmitry Makeev","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:2020_year._Herbarium._Tilia_cordata._img-007.jpg"), + }, + ash:{ + tree:worldMedia("trees/ash.webp","Willow","CC BY 2.5","https://commons.wikimedia.org/wiki/File:Fraxinus_excelsior_001.jpg"), + leaf:worldMedia("leaves/ash.webp","Dmitry Makeev","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:2020_year._Herbarium._Fraxinus._img-015.jpg"), + }, + sweet_chestnut:{ + tree:worldMedia("trees/sweet-chestnut.webp","Zeynel Cebeci","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Castanea_sativa_-_Anatolian_sweet_chestnut_04.jpg"), + leaf:worldMedia("leaves/sweet-chestnut.webp","Ekir","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Castanea_sativa_leaves_and_immature_fruit.jpg"), + }, + rowan:{ + tree:worldMedia("trees/rowan.webp","Eeno11","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Rowan_tree_20081002b.jpg"), + leaf:worldMedia("leaves/rowan.webp","Dmitry Makeev","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:2020_year._Herbarium._Sorbus_aucuparia._img-005.jpg"), + }, + hawthorn:{ + tree:worldMedia("trees/hawthorn.webp","Jean-Pol GRANDMONT","CC BY 2.5","https://commons.wikimedia.org/wiki/File:Crataegus_monogyna_in_Joncret_AR1aJPG.jpg"), + leaf:worldMedia("leaves/hawthorn.webp","Didier Descouens","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:(MHNT)_Crataegus_monogyna_-_Leaves_and_stipules.jpg"), + }, + aspen:{ + tree:worldMedia("trees/aspen.webp","Willow","CC BY-SA 2.5","https://commons.wikimedia.org/wiki/File:Populus_tremula_004.jpg"), + leaf:worldMedia("leaves/aspen.webp","Dmitry Makeev","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:2020_year._Herbarium._Populus_tremula._img-017.jpg"), + }, + spruce:{ + tree:worldMedia("trees/spruce.webp","Ivar Leidus","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Kuusk_Keila-Paldiski_rdt_%C3%A4%C3%A4res.jpg"), + leaf:worldMedia("leaves/spruce.webp","Achird","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Picea_abies_needles_in_V%C3%A4smestorp_2024-10-29_(0261).jpg"), + }, + larch:{ + tree:worldMedia("trees/larch.webp","MrPanyGoff","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:European_larch_-_Larix_decidua.jpg"), + leaf:worldMedia("leaves/larch.webp","Rosser1954 / Roger Griffith","Public domain","https://commons.wikimedia.org/wiki/File:Larix_decidua_needles_and_male_cones.JPG"), + }, + yew:{ + tree:worldMedia("trees/yew.webp","Blarney Castle Estate","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:2020_Witch%E2%80%99s_Yew_Tree_IRL.jpg"), + leaf:worldMedia("leaves/yew.webp","hedera.baltica","CC BY-SA 2.0","https://commons.wikimedia.org/wiki/File:Yew_berries_(50578142398).jpg"), + }, + elm:{ + tree:worldMedia("trees/elm.webp","Ptelea","Public domain","https://commons.wikimedia.org/wiki/File:Ulmus_minor_tree.jpg"), + leaf:worldMedia("leaves/elm.webp","Daniel Capilla","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Elm_leaves_(Ulmus_minor).jpg"), + }, + robin:{bird:worldMedia("birds/robin.webp","Rasbak","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Erithacus_rubecula,_Roodborstje.jpg")}, + great_tit:{bird:worldMedia("birds/great-tit.webp","Sławek Staszczuk","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Parus_major_m.jpg")}, + blackbird:{bird:worldMedia("birds/blackbird.webp","Malene Thyssen","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Solsort.jpg")}, + mallard:{bird:worldMedia("birds/mallard.webp","Mbdortmund","CC BY 3.0","https://commons.wikimedia.org/wiki/File:Duisburg_10401IMG_0401.JPG")}, + blue_tit:{bird:worldMedia("birds/blue-tit.webp","Luc Viatour","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Cyanistes_caeruleus_3_Luc_Viatour.jpg")}, + house_sparrow:{bird:worldMedia("birds/house-sparrow.webp","JrPol","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Huismus,_man.jpg")}, + magpie:{bird:worldMedia("birds/magpie.webp","Dion Art","CC BY-SA 4.0","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")}, + woodpecker:{bird:worldMedia("birds/woodpecker.webp","Usitea","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Grote_bonte_specht.JPG")}, + chaffinch:{bird:worldMedia("birds/chaffinch.webp","MichaelMaggs / Arad","CC BY-SA 2.5","https://commons.wikimedia.org/wiki/File:Fringilla_coelebs_chaffinch_male_edit2.jpg")}, + starling:{bird:worldMedia("birds/starling.webp","Mark Robinson","CC BY 2.0","https://commons.wikimedia.org/wiki/File:Sturnus_vulgaris_-England_-standing-8.jpg")}, + grey_heron:{bird:worldMedia("birds/grey-heron.webp","Alexis Lours","CC BY 4.0","https://commons.wikimedia.org/wiki/File:Grey_heron_2022_04_03_03.jpg")}, + mute_swan:{bird:worldMedia("birds/mute-swan.webp","Giles Laurent","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:093_Wild_Mute_swan_at_Lake_Geneva_during_sunset_Photo_by_Giles_Laurent.jpg")}, + coot:{bird:worldMedia("birds/coot.webp","Zeynel Cebeci","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Fulica_atra_-_Common_coot,_Adana_2016-11-12_01-1.jpg")}, + greylag_goose:{bird:worldMedia("birds/greylag-goose.webp","H005 / Pro2","Public domain","https://commons.wikimedia.org/wiki/File:Graugans_Anser_Anser.jpg")}, + collared_dove:{bird:worldMedia("birds/collared-dove.webp","Zeynel Cebeci","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Streptopelia_decaocto_-_Eurasian_Collared_Dove_05.jpg")}, + wren:{bird:worldMedia("birds/wren.webp","Alexis Lours","CC BY 4.0","https://commons.wikimedia.org/wiki/File:Eurasian_wren_(Troglodytes_troglodytes).jpg")}, + buzzard:{bird:worldMedia("birds/buzzard.webp","Yerpo","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Buteo_buteo_juv_Log_(2).jpg")}, + barn_owl:{bird:worldMedia("birds/barn-owl.webp","Luc Viatour","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Tyto_alba_1_Luc_Viatour.jpg")}, + dandelion:{plant:worldMedia("plants/dandelion.webp","Dominicus Johannes Bergsma","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Paardenbloem_(Taraxacum_officinale)_06.JPG")}, + nettle:{plant:worldMedia("plants/nettle.webp","Dominicus Johannes Bergsma","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Brandnetel,_Urtica_dioica,_Locatie,_Famberhorst.jpg")}, + daisy:{plant:worldMedia("plants/daisy.webp","Grand-Duc","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Bellis_perennis_full_plant.jpg")}, + red_clover:{plant:worldMedia("plants/red-clover.webp","Robert Flogaus-Faust","CC BY 4.0","https://commons.wikimedia.org/wiki/File:Trifolium_pratense_RF.jpg")}, + white_deadnettle:{plant:worldMedia("plants/white-deadnettle.webp","Robert Flogaus-Faust","CC BY 4.0","https://commons.wikimedia.org/wiki/File:Lamium_album_RF.jpg")}, + cow_parsley:{plant:worldMedia("plants/cow-parsley.webp","Agnes Monkelbaan","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Fluitenkruid_(Anthriscus_sylvestris)_26-04-2020._(actm.)_02.jpg")}, + yarrow:{plant:worldMedia("plants/yarrow.webp","Rhododendrites","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Achillea_millefolium_(52354).jpg")}, + ribwort_plantain:{plant:worldMedia("plants/ribwort-plantain.webp","Krzysztof Ziarnek, Kenraiz","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Plantago_lanceolata_kz09.jpg")}, + common_poppy:{plant:worldMedia("plants/common-poppy.webp","Jörg Hempel","CC BY-SA 2.5","https://commons.wikimedia.org/wiki/File:Papaver_rhoeas_LC0050.jpg")}, + chicory:{plant:worldMedia("plants/chicory.webp","T. Kebert","CC BY-SA 4.0","https://commons.wikimedia.org/wiki/File:Cichorium_intybus_TK_2021-07-12_1.jpg")}, + foxglove:{plant:worldMedia("plants/foxglove.webp","Jörg Hempel","CC BY-SA 3.0","https://commons.wikimedia.org/wiki/File:Digitalis_purpurea_LC0101.jpg")}, + common_sorrel:{plant:worldMedia("plants/common-sorrel.webp","Tournasol7","CC BY 4.0","https://commons.wikimedia.org/wiki/File:Rumex_acetosa_in_Aveyron_(7).jpg")}, +}); + +const WORLD_TREES=Object.freeze([ + {id:"oak",nl:"Zomereik",en:"Pedunculate oak",latin:"Quercus robur",tier:1,hintNl:"Brede kroon, diep gelobde bladeren en eikels aan een steeltje.",hintEn:"Broad crown, deeply lobed leaves and acorns on a stalk."}, + {id:"birch",nl:"Ruwe berk",en:"Silver birch",latin:"Betula pendula",tier:1,hintNl:"Witte bast met donkere strepen en driehoekige, gezaagde blaadjes.",hintEn:"White bark with dark markings and triangular, serrated leaves."}, + {id:"horse_chestnut",nl:"Witte paardenkastanje",en:"Horse chestnut",latin:"Aesculus hippocastanum",tier:1,hintNl:"Vijf tot zeven grote blaadjes vormen samen één handvormig blad.",hintEn:"Five to seven large leaflets form one hand-shaped leaf."}, + {id:"pine",nl:"Grove den",en:"Scots pine",latin:"Pinus sylvestris",tier:1,hintNl:"Oranjebruine schors bovenin; de blauwgroene naalden staan per twee.",hintEn:"Orange-brown upper bark; blue-green needles grow in pairs."}, + {id:"beech",nl:"Beuk",en:"European beech",latin:"Fagus sylvatica",tier:2,hintNl:"Gladde grijze stam en glanzende ovale bladeren met een golvende rand.",hintEn:"Smooth grey trunk and glossy oval leaves with a wavy edge."}, + {id:"maple",nl:"Gewone esdoorn",en:"Sycamore maple",latin:"Acer pseudoplatanus",tier:2,hintNl:"Groot handvormig blad met vijf puntige lobben en gevleugelde zaden.",hintEn:"Large hand-shaped leaf with five pointed lobes and winged seeds."}, + {id:"willow",nl:"Schietwilg",en:"White willow",latin:"Salix alba",tier:3,hintNl:"Brede, losse kroon en lange smalle bladeren met een zilverige onderkant.",hintEn:"Broad open crown and long narrow leaves with a silvery underside."}, + {id:"alder",nl:"Zwarte els",en:"Black alder",latin:"Alnus glutinosa",tier:3,hintNl:"Ronde bladeren met een ingedeukte top en kleine houtige elzenproppen.",hintEn:"Rounded leaves with a notched tip and small woody alder cones."}, + {id:"lime",nl:"Winterlinde",en:"Small-leaved lime",latin:"Tilia cordata",tier:1,hintNl:"Hartvormige bladeren en kleine nootjes aan een bleek, vleugelachtig schutblad.",hintEn:"Heart-shaped leaves and small nutlets attached to a pale, wing-like bract."}, + {id:"ash",nl:"Gewone es",en:"European ash",latin:"Fraxinus excelsior",tier:1,hintNl:"Zwarte knoppen en geveerde bladeren met zeven tot dertien deelblaadjes.",hintEn:"Black buds and pinnate leaves with seven to thirteen leaflets."}, + {id:"sweet_chestnut",nl:"Tamme kastanje",en:"Sweet chestnut",latin:"Castanea sativa",tier:1,hintNl:"Lange, scherp gezaagde bladeren en kastanjes in een dicht stekelige bolster.",hintEn:"Long, sharply toothed leaves and chestnuts in a densely spiny husk."}, + {id:"rowan",nl:"Wilde lijsterbes",en:"Rowan",latin:"Sorbus aucuparia",tier:2,hintNl:"Geveerde bladeren, witte bloemschermen en trossen oranjerode bessen.",hintEn:"Pinnate leaves, clusters of white flowers and bunches of orange-red berries."}, + {id:"hawthorn",nl:"Eenstijlige meidoorn",en:"Common hawthorn",latin:"Crataegus monogyna",tier:2,hintNl:"Doornige takken, diep gelobde bladeren, witte bloemen en rode vruchten.",hintEn:"Thorny branches, deeply lobed leaves, white flowers and red fruits."}, + {id:"aspen",nl:"Ratelpopulier",en:"Aspen",latin:"Populus tremula",tier:2,hintNl:"Bijna ronde bladeren aan een afgeplatte steel die al bij weinig wind trillen.",hintEn:"Almost round leaves on a flattened stalk that tremble in the slightest breeze."}, + {id:"spruce",nl:"Fijnspar",en:"Norway spruce",latin:"Picea abies",tier:2,hintNl:"Korte scherpe naalden rondom de twijg en lange kegels die omlaag hangen.",hintEn:"Short sharp needles all around the twig and long cones that hang down."}, + {id:"larch",nl:"Europese lariks",en:"European larch",latin:"Larix decidua",tier:3,hintNl:"Zachte naalden in bundeltjes; als enige gewone naaldboom laat hij ze in de herfst vallen.",hintEn:"Soft needles in clusters; unlike most conifers it sheds them in autumn."}, + {id:"yew",nl:"Taxus",en:"European yew",latin:"Taxus baccata",tier:3,hintNl:"Donkergroene platte naalden en rode besachtige zaadmantels; de plant is giftig.",hintEn:"Dark green flat needles and red berry-like arils; the plant is poisonous."}, + {id:"elm",nl:"Gladde iep",en:"Field elm",latin:"Ulmus minor",tier:3,hintNl:"Ruwe gezaagde bladeren met een opvallend scheve bladvoet.",hintEn:"Rough, toothed leaves with a noticeably uneven base."}, +]); + +const WORLD_BIRDS=Object.freeze([ + {id:"robin",nl:"Roodborst",en:"European robin",latin:"Erithacus rubecula",tier:1,hintNl:"Oranjerode borst en gezicht, met een olijfbruine rug.",hintEn:"Orange-red breast and face with an olive-brown back."}, + {id:"great_tit",nl:"Koolmees",en:"Great tit",latin:"Parus major",tier:1,hintNl:"Zwarte kop, witte wangen en een zwarte streep over de gele buik.",hintEn:"Black head, white cheeks and a black stripe down the yellow belly."}, + {id:"blackbird",nl:"Merel",en:"Common blackbird",latin:"Turdus merula",tier:1,hintNl:"Het mannetje is helemaal zwart met een gele snavel en gele oogring.",hintEn:"The male is all black with a yellow bill and yellow eye-ring."}, + {id:"mallard",nl:"Wilde eend",en:"Mallard",latin:"Anas platyrhynchos",tier:1,hintNl:"Het mannetje heeft een groene kop, witte halsring en kastanjebruine borst.",hintEn:"The male has a green head, white neck ring and chestnut breast."}, + {id:"blue_tit",nl:"Pimpelmees",en:"Blue tit",latin:"Cyanistes caeruleus",tier:2,hintNl:"Blauw petje en vleugels, witte wangen en een gele buik.",hintEn:"Blue cap and wings, white cheeks and a yellow belly."}, + {id:"house_sparrow",nl:"Huismus",en:"House sparrow",latin:"Passer domesticus",tier:2,hintNl:"Bruin gestreepte rug; het mannetje heeft een grijze kruin en zwarte bef.",hintEn:"Brown streaked back; the male has a grey crown and black bib."}, + {id:"magpie",nl:"Ekster",en:"Eurasian magpie",latin:"Pica pica",tier:3,hintNl:"Zwart-wit verenkleed met blauwgroene glans en een opvallend lange staart.",hintEn:"Black-and-white plumage with a blue-green sheen and a very long tail."}, + {id:"woodpecker",nl:"Grote bonte specht",en:"Great spotted woodpecker",latin:"Dendrocopos major",tier:3,hintNl:"Zwart-wit met rode veren onder de staart; het mannetje heeft rood in de nek.",hintEn:"Black and white with red beneath the tail; the male has red on the nape."}, + {id:"chaffinch",nl:"Vink",en:"Common chaffinch",latin:"Fringilla coelebs",tier:1,hintNl:"Het mannetje heeft een blauwgrijze kop, roestbruine borst en twee witte vleugelstrepen.",hintEn:"The male has a blue-grey head, rusty breast and two white wing bars."}, + {id:"starling",nl:"Spreeuw",en:"Common starling",latin:"Sturnus vulgaris",tier:1,hintNl:"Donker verenkleed met metaalglans en lichte spikkels, plus een korte staart.",hintEn:"Dark, iridescent plumage with pale speckles and a short tail."}, + {id:"grey_heron",nl:"Blauwe reiger",en:"Grey heron",latin:"Ardea cinerea",tier:1,hintNl:"Grote grijze watervogel met lange poten, een lange hals en dolkvormige snavel.",hintEn:"Large grey waterbird with long legs, a long neck and dagger-shaped bill."}, + {id:"mute_swan",nl:"Knobbelzwaan",en:"Mute swan",latin:"Cygnus olor",tier:1,hintNl:"Grote witte zwaan met een oranje snavel en zwarte knobbel aan de basis.",hintEn:"Large white swan with an orange bill and a black knob at its base."}, + {id:"coot",nl:"Meerkoet",en:"Eurasian coot",latin:"Fulica atra",tier:2,hintNl:"Zwarte watervogel met een opvallend witte snavel en witte voorhoofdsplaat.",hintEn:"Black waterbird with a striking white bill and white forehead shield."}, + {id:"greylag_goose",nl:"Grauwe gans",en:"Greylag goose",latin:"Anser anser",tier:2,hintNl:"Grote grijsbruine gans met een stevige oranje of roze snavel en roze poten.",hintEn:"Large grey-brown goose with a stout orange or pink bill and pink legs."}, + {id:"collared_dove",nl:"Turkse tortel",en:"Eurasian collared dove",latin:"Streptopelia decaocto",tier:2,hintNl:"Licht beigegrijs met een smalle zwarte halsband en een lange, rechte staart.",hintEn:"Pale beige-grey with a narrow black neck collar and a long, square-ended tail."}, + {id:"wren",nl:"Winterkoning",en:"Eurasian wren",latin:"Troglodytes troglodytes",tier:2,hintNl:"Heel klein bruin vogeltje met fijne bandjes en een kort staartje dat vaak rechtop staat.",hintEn:"Very small brown bird with fine barring and a short tail often held upright."}, + {id:"buzzard",nl:"Buizerd",en:"Common buzzard",latin:"Buteo buteo",tier:3,hintNl:"Brede vleugels, korte staart en een verenkleed dat van donkerbruin tot bijna wit varieert.",hintEn:"Broad wings, a short tail and plumage varying from dark brown to nearly white."}, + {id:"barn_owl",nl:"Kerkuil",en:"Barn owl",latin:"Tyto alba",tier:3,hintNl:"Lichte uil met een goudbruine rug en een wit, hartvormig gezicht.",hintEn:"Pale owl with a golden-brown back and a white, heart-shaped face."}, +]); + +const WORLD_PLANTS=Object.freeze([ + {id:"dandelion",nl:"Paardenbloem",en:"Dandelion",latin:"Taraxacum officinale",tier:1,hintNl:"Gele bloemhoofdjes, een holle stengel met melksap en later een ronde pluizenbol.",hintEn:"Yellow flower heads, a hollow stem with milky sap and later a round seed clock."}, + {id:"nettle",nl:"Grote brandnetel",en:"Stinging nettle",latin:"Urtica dioica",tier:1,hintNl:"Tegenoverstaande, scherp gezaagde bladeren met brandharen op stengel en blad.",hintEn:"Opposite, sharply toothed leaves with stinging hairs on stems and leaves."}, + {id:"daisy",nl:"Madeliefje",en:"Common daisy",latin:"Bellis perennis",tier:1,hintNl:"Laag rozet met lepelvormige blaadjes en witte bloemen rond een geel hart.",hintEn:"Low rosette of spoon-shaped leaves and white flowers around a yellow centre."}, + {id:"red_clover",nl:"Rode klaver",en:"Red clover",latin:"Trifolium pratense",tier:1,hintNl:"Ronde roze-paarse bloemhoofdjes en bladeren van drie vaak licht gevlekte deelblaadjes.",hintEn:"Round pink-purple flower heads and leaves of three often pale-marked leaflets."}, + {id:"white_deadnettle",nl:"Witte dovenetel",en:"White dead-nettle",latin:"Lamium album",tier:2,hintNl:"Lijkt op brandnetel maar prikt niet; witte lipbloemen staan in kransen rond de stengel.",hintEn:"Looks like a nettle but does not sting; white lipped flowers circle the stem."}, + {id:"cow_parsley",nl:"Fluitenkruid",en:"Cow parsley",latin:"Anthriscus sylvestris",tier:2,hintNl:"Fijne varenachtige bladeren en luchtige schermen met heel veel kleine witte bloemen.",hintEn:"Delicate fern-like leaves and airy umbels of many tiny white flowers."}, + {id:"yarrow",nl:"Duizendblad",en:"Yarrow",latin:"Achillea millefolium",tier:2,hintNl:"Heel fijn verdeelde bladeren en platte schermen met kleine witte bloemhoofdjes.",hintEn:"Very finely divided leaves and flat clusters of small white flower heads."}, + {id:"ribwort_plantain",nl:"Smalle weegbree",en:"Ribwort plantain",latin:"Plantago lanceolata",tier:2,hintNl:"Smalle bladeren met sterke evenwijdige nerven en bruine aren op geribde stelen.",hintEn:"Narrow leaves with strong parallel veins and brown spikes on ribbed stems."}, + {id:"common_poppy",nl:"Grote klaproos",en:"Common poppy",latin:"Papaver rhoeas",tier:2,hintNl:"Vier papierdunne rode kroonbladen, vaak met een donkere vlek rond het zwarte hart.",hintEn:"Four papery red petals, often with a dark blotch around the black centre."}, + {id:"chicory",nl:"Wilde cichorei",en:"Common chicory",latin:"Cichorium intybus",tier:3,hintNl:"Helderblauwe lintbloemen staan vrijwel direct langs een stijve vertakte stengel.",hintEn:"Bright blue ray flowers grow almost directly along a stiff branching stem."}, + {id:"foxglove",nl:"Vingerhoedskruid",en:"Foxglove",latin:"Digitalis purpurea",tier:3,hintNl:"Hoge aar met roze-paarse klokvormige bloemen die van binnen gevlekt zijn; giftig.",hintEn:"Tall spike of pink-purple bell-shaped flowers, spotted inside; poisonous."}, + {id:"common_sorrel",nl:"Veldzuring",en:"Common sorrel",latin:"Rumex acetosa",tier:3,hintNl:"Pijlvormige bladeren en een losse roodgroene pluim met heel kleine bloemetjes.",hintEn:"Arrow-shaped leaves and a loose red-green panicle of tiny flowers."}, +]); + +const NL_PROVINCES=Object.freeze([ + {id:"friesland",shape:"friesland",nl:"Friesland",en:"Friesland",capital:"leeuwarden",tier:2,color:"#93c5fd",x:518,y:166}, + {id:"groningen",shape:"groningen",nl:"Groningen",en:"Groningen",capital:"groningen_city",tier:2,color:"#fca5a5",x:615,y:72}, + {id:"drenthe",shape:"drenthe",nl:"Drenthe",en:"Drenthe",capital:"assen",tier:3,color:"#fde68a",x:611,y:222}, + {id:"overijssel",shape:"overijssel",nl:"Overijssel",en:"Overijssel",capital:"zwolle",tier:2,color:"#c4b5fd",x:598,y:351}, + {id:"flevoland",shape:"flevoland",nl:"Flevoland",en:"Flevoland",capital:"lelystad",tier:3,color:"#fdba74",x:465,y:272}, + {id:"noord_holland",shape:"noord-holland",nl:"Noord-Holland",en:"North Holland",capital:"haarlem",tier:1,color:"#f9a8d4",x:308,y:244}, + {id:"utrecht",shape:"utrecht",nl:"Utrecht",en:"Utrecht",capital:"utrecht_city",tier:1,color:"#d8b4fe",x:393,y:471}, + {id:"gelderland",shape:"gelderland",nl:"Gelderland",en:"Gelderland",capital:"arnhem",tier:1,color:"#86efac",x:493,y:452}, + {id:"zuid_holland",shape:"zuid-holland",nl:"Zuid-Holland",en:"South Holland",capital:"den_haag",tier:1,color:"#67e8f9",x:245,y:451}, + {id:"zeeland",shape:"zeeland",nl:"Zeeland",en:"Zeeland",capital:"middelburg",tier:3,color:"#5eead4",x:132,y:698}, + {id:"noord_brabant",shape:"noord-brabant",nl:"Noord-Brabant",en:"North Brabant",capital:"den_bosch",tier:1,color:"#fcd34d",x:367,y:599}, + {id:"limburg",shape:"limburg",nl:"Limburg",en:"Limburg",capital:"maastricht",tier:1,color:"#a7f3d0",x:489,y:714}, +]); + +const NL_CITIES=Object.freeze([ + {id:"leeuwarden",nl:"Leeuwarden",en:"Leeuwarden",province:"friesland",capital:true,tier:2,x:467,y:119}, + {id:"groningen_city",nl:"Groningen",en:"Groningen",province:"groningen",capital:true,tier:2,x:612,y:112}, + {id:"assen",nl:"Assen",en:"Assen",province:"drenthe",capital:true,tier:3,x:612,y:175}, + {id:"zwolle",nl:"Zwolle",en:"Zwolle",province:"overijssel",capital:true,tier:2,x:538,y:332}, + {id:"lelystad",nl:"Lelystad",en:"Lelystad",province:"flevoland",capital:true,tier:3,x:417,y:327}, + {id:"haarlem",nl:"Haarlem",en:"Haarlem",province:"noord_holland",capital:true,tier:1,x:261,y:371}, + {id:"utrecht_city",nl:"Utrecht",en:"Utrecht",province:"utrecht",capital:true,tier:1,x:355,y:460}, + {id:"arnhem",nl:"Arnhem",en:"Arnhem",province:"gelderland",capital:true,tier:1,x:500,y:486}, + {id:"den_haag",nl:"Den Haag",en:"The Hague",province:"zuid_holland",capital:true,tier:1,x:192,y:460,side:"left"}, + {id:"middelburg",nl:"Middelburg",en:"Middelburg",province:"zeeland",capital:true,tier:3,x:65,y:635}, + {id:"den_bosch",nl:"'s-Hertogenbosch",en:"'s-Hertogenbosch",province:"noord_brabant",capital:true,tier:1,x:388,y:577}, + {id:"maastricht",nl:"Maastricht",en:"Maastricht",province:"limburg",capital:true,tier:1,x:463,y:836}, + {id:"amsterdam",nl:"Amsterdam",en:"Amsterdam",province:"noord_holland",capital:false,tier:1,x:311,y:373}, + {id:"rotterdam",nl:"Rotterdam",en:"Rotterdam",province:"zuid_holland",capital:false,tier:1,x:234,y:508}, + {id:"eindhoven",nl:"Eindhoven",en:"Eindhoven",province:"noord_brabant",capital:false,tier:1,x:424,y:653}, + {id:"nijmegen",nl:"Nijmegen",en:"Nijmegen",province:"gelderland",capital:false,tier:1,x:497,y:536}, + {id:"enschede",nl:"Enschede",en:"Enschede",province:"overijssel",capital:false,tier:2,x:688,y:409,side:"left"}, + {id:"alkmaar",nl:"Alkmaar",en:"Alkmaar",province:"noord_holland",capital:false,tier:2,x:283,y:296}, + {id:"breda",nl:"Breda",en:"Breda",province:"noord_brabant",capital:false,tier:3,x:286,y:611,side:"left"}, + {id:"tilburg",nl:"Tilburg",en:"Tilburg",province:"noord_brabant",capital:false,tier:3,x:347,y:625}, +]); + +const NL_REGIONS=Object.freeze([ + {id:"north",nl:"Noord-Nederland",en:"Northern Netherlands",tier:1,color:"#60a5fa",provinces:["friesland","groningen","drenthe"],x:565,y:172}, + {id:"east",nl:"Oost-Nederland",en:"Eastern Netherlands",tier:1,color:"#a78bfa",provinces:["overijssel","gelderland","flevoland"],x:548,y:406}, + {id:"west",nl:"West-Nederland",en:"Western Netherlands",tier:1,color:"#22d3ee",provinces:["noord_holland","zuid_holland","utrecht","zeeland"],x:252,y:454}, + {id:"south",nl:"Zuid-Nederland",en:"Southern Netherlands",tier:1,color:"#fbbf24",provinces:["noord_brabant","limburg"],x:417,y:648}, +]); + +const NL_RIVERS=Object.freeze([ + {id:"rijn",nl:"Rijn",en:"Rhine",tier:1,color:"#2563eb",labelX:399,labelY:477,path:"M 567 510 C 535 508 515 493 489 491 C 452 488 429 472 394 474 C 353 476 322 494 280 502 C 257 507 238 508 218 514"}, + {id:"maas",nl:"Maas",en:"Meuse",tier:1,color:"#0891b2",labelX:401,labelY:581,path:"M 463 850 C 445 817 469 773 490 730 C 511 688 536 639 519 596 C 504 565 472 556 439 570 C 398 588 362 574 320 580 C 280 586 247 578 211 566"}, + {id:"ijssel",nl:"IJssel",en:"IJssel",tier:2,color:"#0ea5e9",labelX:536,labelY:389,path:"M 497 489 C 516 459 525 426 526 390 C 527 356 520 329 502 306 C 484 282 463 270 447 250"}, + {id:"waal",nl:"Waal",en:"Waal",tier:2,color:"#1d4ed8",labelX:421,labelY:548,path:"M 557 519 C 527 530 503 539 470 548 C 435 558 406 562 373 553 C 340 544 310 548 280 558 C 253 566 230 560 210 550"}, + {id:"schelde",nl:"Schelde",en:"Scheldt",tier:3,color:"#0284c7",labelX:139,labelY:641,path:"M 41 660 C 82 654 119 649 151 642 C 176 637 191 624 207 609"}, +]); + +const WORLD_CONTINENTS=Object.freeze([ + {id:"europe",shape:"europe",nl:"Europa",en:"Europe",tier:1,color:"#fbbf24",x:382,y:96}, + {id:"africa",shape:"africa",nl:"Afrika",en:"Africa",tier:1,color:"#fb923c",x:383,y:190}, + {id:"asia",shape:"asia",nl:"Azië",en:"Asia",tier:1,color:"#f87171",x:505,y:112}, + {id:"north_america",shape:"north-america",nl:"Noord-Amerika",en:"North America",tier:2,color:"#60a5fa",x:154,y:112}, + {id:"south_america",shape:"south-america",nl:"Zuid-Amerika",en:"South America",tier:2,color:"#34d399",x:245,y:252}, + {id:"oceania",shape:"oceania",nl:"Oceanië",en:"Oceania",tier:2,color:"#c084fc",x:593,y:248}, + {id:"antarctica",shape:"antarctica",nl:"Antarctica",en:"Antarctica",tier:3,color:"#cbd5e1",x:360,y:337}, +]); + +const worldName=item=>LANG==="nl"?item.nl:item.en; +const worldHint=item=>LANG==="nl"?item.hintNl:item.hintEn; +const worldShuffle=items=>{ + const copy=[...items]; + for(let i=copy.length-1;i>0;i--){ + const j=Math.floor(Math.random()*(i+1)); + [copy[i],copy[j]]=[copy[j],copy[i]]; + } + return copy; +}; +const worldSelect=(value,items)=>{ + const select=document.createElement("select"); + items.forEach(([id,key])=>select.add(new Option(T(key),id))); + select.value=value; + return select; +}; +const worldLevelPool=(items,level)=>items.filter(item=>item.tier<=level); +const worldMediaKind=(type,view)=>type==="birds"?"bird":type==="plants"?"plant":view; +const worldPhotoLabel=kind=>T(kind==="bird"?"worldPhotoBird":kind==="plant"?"worldPhotoPlant":kind==="leaf"?"worldPhotoLeaf":"worldPhotoTree"); + +function worldPhoto(item,type,view){ + const kind=worldMediaKind(type,view); + const media=WORLD_MEDIA[item.id]?.[kind]; + const figure=document.createElement("figure"); + figure.className="world-photo world-photo-"+kind; + const image=document.createElement("img"); + image.className="world-picture"; + image.alt=worldPhotoLabel(kind); + image.draggable=false; + image.loading="lazy"; + image.decoding="async"; + image.src=WORLD_MEDIA_ROOT+media.file; + const badge=document.createElement("span"); + badge.className="world-photo-badge"; + badge.textContent=worldPhotoLabel(kind); + const credit=document.createElement("a"); + credit.className="world-photo-credit"; + credit.href=media.source; + credit.target="_blank"; + credit.rel="noopener noreferrer"; + credit.textContent=T("worldPhotoCredit").replace("{author}",media.author).replace("{license}",media.license); + figure.append(image,badge,credit); + return figure; +} + +function worldVisual(item,type,view){ + const visual=document.createElement("div"); + visual.className="world-visual "+type+(view==="mixed"?" mixed":""); + if(type==="trees"&&view==="mixed")visual.append(worldPhoto(item,type,"tree"),worldPhoto(item,type,"leaf")); + else visual.append(worldPhoto(item,type,view)); + return visual; +} + +function worldPhotoOption(item,type,view,index,onChoose,status){ + const kind=worldMediaKind(type,view); + const media=WORLD_MEDIA[item.id]?.[kind]; + const figure=document.createElement("figure"); + figure.className="world-photo-choice"+(status?" "+status:""); + const button=document.createElement("button"); + button.type="button"; + button.className="world-image-button"; + button.setAttribute("aria-label",T("worldPhotoOption").replace("{number}",index+1)); + const image=document.createElement("img"); + image.className="world-picture"; + image.alt=""; + image.draggable=false; + image.loading="lazy"; + image.decoding="async"; + image.src=WORLD_MEDIA_ROOT+media.file; + const number=document.createElement("span"); + number.className="world-choice-number"; + number.textContent=String(index+1); + button.append(image,number); + button.onclick=onChoose; + const credit=document.createElement("a"); + credit.className="world-photo-credit"; + credit.href=media.source; + credit.target="_blank"; + credit.rel="noopener noreferrer"; + credit.textContent=T("worldPhotoCredit").replace("{author}",media.author).replace("{license}",media.license); + figure.append(button,credit); + return figure; +} + +function worldIdentity(item){ + const identity=document.createElement("div"); + identity.className="world-identity"; + const name=document.createElement("h3"); + name.textContent=worldName(item); + const latin=document.createElement("em"); + latin.textContent=item.latin; + identity.append(name,latin); + return identity; +} + +/* ---- flitskaarten: naam kort tonen, dan het beeld + zelftoets, zoals + flits.js - hier gevoed door de wereldoriëntatie-content i.p.v. woordkaarten. + Hergebruikt de generieke fl*-teksten (zelfde vraag/knoppen als flits.js). ---- */ +function worldFlashCard(card,name,visual,S,redraw,onNext,onAssess){ + const wrap=document.createElement("div"); + wrap.className="world-flash-card"; + wrap.append(visual); + const nameZone=document.createElement("div"); + nameZone.className="world-flash-name"+(S.flashRevealed?"":" dim"); + nameZone.textContent=S.flashRevealed?name:"• • •"; + wrap.append(nameZone); + card.append(wrap); + if(!S.flashRevealed){ + const hint=document.createElement("p"); + hint.className="world-flash-hint"; + hint.textContent=T("flMission"); + card.append(hint); + const actions=document.createElement("div"); + actions.className="world-actions"; + actions.append(btn(T("flShow"),()=>{S.flashRevealed=true;redraw()})); + actions.append(btn(T("worldNext"),onNext,"ghost")); + card.append(actions); + }else if(!S.solved){ + const reflect=document.createElement("div"); + reflect.className="world-flash-reflect"; + const q=document.createElement("p"); + q.textContent=T("flQuestion"); + reflect.append(q); + const row=document.createElement("div"); + row.className="world-actions"; + row.append(btn(T("flGotIt"),()=>onAssess(true))); + row.append(btn(T("flAgain"),()=>onAssess(false),"ghost")); + reflect.append(row); + card.append(reflect); + } +} + +/* ---- letterblokken: spel de naam met dezelfde sleep-blokken-mechaniek als + letters.js, hier gevoed door de naam van de soort/plaats. Het puntenpatroon + van het juiste antwoord staat altijd in het vak (zelfde tastbare hint als + overal elders); of de LETTER er ook gewoon bij staat hangt af van het + niveau, net als bij letters.js: niveau 1 verklapt alles, niveau 2 de + helft, niveau 3 niets - alleen de stippen blijven dan over als hint. */ +const worldIsLetter=ch=>/\p{L}/u.test(ch); +const worldSpellPool=name=>[...name.toLowerCase()].filter(worldIsLetter); +function worldSpellRevealMask(count,level){ + if(level===1)return new Array(count).fill(true); + if(level===3)return new Array(count).fill(false); + const hideCount=Math.max(1,Math.min(count-1,Math.round(count/2))); + const hidden=new Set(worldShuffle([...Array(count).keys()]).slice(0,hideCount)); + return Array.from({length:count},(_,i)=>!hidden.has(i)); +} +function worldSpellNeedsNewTray(S,name){ + const letters=worldSpellPool(name); + return !Array.isArray(S.answerLetters)||S.answerLetters.length!==letters.length||!Array.isArray(S.trayLetters)||!Array.isArray(S.revealMask)||S.revealMask.length!==letters.length; +} +function worldSpellNewTray(S,name){ + const letters=worldSpellPool(name); + const distractPool="abcdefghijklmnopqrstuvwxyz".split("").filter(c=>!letters.includes(c)); + S.trayLetters=worldShuffle([...letters,...worldShuffle(distractPool).slice(0,4)]); + S.answerLetters=letters.map(()=>null); + S.revealMask=worldSpellRevealMask(letters.length,S.level); + S.spellSolved=false; +} +function worldLetterBlock(ch,trayIndex,letters,S,redraw){ + const b=document.createElement("div"); + b.className="blok sm world-letter-block "+(VOWELS.includes(ch)?"vowel":"cons"); + b.dataset.letter=ch; + const label=document.createElement("div"); + label.className="bl"; + label.textContent=ch; + b.append(label); + const dg=dotGrid(ch); + dg.classList.add("blok-dots"); + b.append(dg); + b.setAttribute("role","button"); + b.setAttribute("aria-label",T("clockDigit").replace("{digit}",ch)); + b.tabIndex=0; + const settle=idx=>{S.answerLetters[idx]=trayIndex;sndGood();redraw()}; + b.onkeydown=e=>{ + if((e.key==="Enter"||e.key===" ")&&!S.spellSolved){ + e.preventDefault(); + const idx=letters.findIndex((need,i)=>need===ch&&S.answerLetters[i]===null); + if(idx>=0)settle(idx); + } + }; + b.onpointerdown=e=>{ + if(S.spellSolved||(e.pointerType==="mouse"&&e.button!==0))return; + e.preventDefault(); + try{b.releasePointerCapture(e.pointerId)}catch(_){} + const clone=b.cloneNode(true); + clone.id="dragClone"; + document.body.append(clone); + b.classList.add("dragging"); + const moveTo=ev=>{const zoom=VZ();clone.style.left=ev.clientX/zoom+"px";clone.style.top=ev.clientY/zoom+"px"}; + moveTo(e); + let hovered=null; + const hit=ev=>{ + let found=null; + document.querySelectorAll(".world-letter-slot:not(.filled)").forEach(slot=>{ + const r=slot.getBoundingClientRect(),pad=8; + if(ev.clientX>=r.left-pad&&ev.clientX<=r.right+pad&&ev.clientY>=r.top-pad&&ev.clientY<=r.bottom+pad)found=slot; + }); + return found; + }; + const move=ev=>{ + moveTo(ev); + const target=hit(ev); + if(hovered&&hovered!==target)hovered.classList.remove("droptarget"); + hovered=target; + if(hovered)hovered.classList.add("droptarget"); + }; + const clean=()=>{ + document.removeEventListener("pointermove",move); + document.removeEventListener("pointerup",up); + document.removeEventListener("pointercancel",cancel); + clone.remove(); + b.classList.remove("dragging"); + if(hovered)hovered.classList.remove("droptarget"); + }; + const up=ev=>{ + const target=hit(ev); + clean(); + if(target){ + const idx=+target.dataset.index; + if(letters[idx]===ch)settle(idx); + else{target.classList.add("shake");setTimeout(()=>target.classList.remove("shake"),400);sndBad()} + } + }; + const cancel=clean; + document.addEventListener("pointermove",move); + document.addEventListener("pointerup",up); + document.addEventListener("pointercancel",cancel); + }; + return b; +} +function worldSpellAnswer(name,S,redraw){ + if(worldSpellNeedsNewTray(S,name))worldSpellNewTray(S,name); + const letters=worldSpellPool(name); + const wrap=document.createElement("div"); + wrap.className="world-spell-answer"; + const wordRow=document.createElement("div"); + wordRow.className="world-spell-word"; + let slotIdx=0; + [...name.toLowerCase()].forEach(ch=>{ + if(!worldIsLetter(ch)){ + const gap=document.createElement("span"); + gap.className="world-spell-gap"; + wordRow.append(gap); + return; + } + const idx=slotIdx++; + const slot=document.createElement("div"); + slot.className="slot sm world-letter-slot"+(S.answerLetters[idx]!==null?" filled":""); + slot.dataset.index=idx; + const ghost=document.createElement("div"); + ghost.className="ghost"; + ghost.textContent=S.revealMask[idx]?letters[idx]:"?"; + const dots=dotGrid(letters[idx]); + dots.classList.add("slot-dots"); + slot.append(ghost,dots); + if(S.answerLetters[idx]!==null){ + const trayIndex=S.answerLetters[idx]; + const placed=worldLetterBlock(S.trayLetters[trayIndex],trayIndex,letters,S,redraw); + placed.classList.add("in-slot"); + placed.tabIndex=-1; + placed.onpointerdown=null; + placed.onkeydown=null; + slot.append(placed); + slot.onclick=()=>{if(!S.spellSolved){S.answerLetters[idx]=null;redraw()}}; + } + wordRow.append(slot); + }); + wrap.append(wordRow); + const tray=document.createElement("div"); + tray.className="lg-tray world-spell-tray"; + S.trayLetters.forEach((ch,trayIndex)=>{ + if(S.answerLetters.includes(trayIndex))return; + tray.append(worldLetterBlock(ch,trayIndex,letters,S,redraw)); + }); + wrap.append(tray); + return wrap; +} + +function mountWorldQuiz(root,init,config){ + const exerciseOptions=config.type==="trees" + ?[["recognize","worldExerciseRecognize"],["pair","worldExercisePair"],["clue","worldExerciseClue"],["photo","worldExercisePhoto"],["latin","worldExerciseLatin"]] + :[["recognize","worldExerciseRecognize"],["clue","worldExerciseClue"],["photo","worldExercisePhoto"],["latin","worldExerciseLatin"]]; + const S=Object.assign({ + activity:"quiz",exercise:"recognize",level:1,view:config.type==="trees"?"mixed":config.type==="birds"?"bird":"plant", + score:0,streak:0,target:"",taskView:"",options:[],picked:"",solved:false,feedback:"", + flashRevealed:false,answerLetters:null,trayLetters:null,revealMask:null,spellSolved:false, + },init||{}); + S.level=Math.max(1,Math.min(3,+S.level||1)); + if(!exerciseOptions.some(([id])=>id===S.exercise))S.exercise="recognize"; + const activity=worldSelect(S.activity,[["learn","worldLearn"],["quiz","worldQuiz"],["flash","worldFlash"],["spell","worldSpell"]]); + const exercise=worldSelect(S.exercise,exerciseOptions); + const stage=document.createElement("div"); + const exerciseSetting=field(T("worldExercise"),exercise); + const settings=[field(T("worldActivity"),activity),exerciseSetting]; + let view,viewSetting; + if(config.type==="trees"){ + view=worldSelect(S.view,[["tree","worldTreeWhole"],["leaf","worldTreeLeaf"],["mixed","worldTreeMixed"]]); + viewSetting=field(T("worldTreeView"),view); + settings.push(viewSetting); + } + const pool=()=>worldLevelPool(config.items,S.level); + const current=()=>config.items.find(item=>item.id===S.target)||pool()[0]; + const newTask=()=>{ + const items=pool(),previous=S.target; + let target=items[Math.floor(Math.random()*items.length)]; + if(items.length>1)while(target.id===previous)target=items[Math.floor(Math.random()*items.length)]; + S.target=target.id; + S.taskView=config.type==="trees" + ?S.exercise==="pair"?"mixed":S.view==="mixed"?(Math.random()<.5?"tree":"leaf"):S.view + :config.type==="birds"?"bird":"plant"; + S.options=worldShuffle([target,...worldShuffle(items.filter(item=>item.id!==target.id)).slice(0,3)]).map(item=>item.id); + S.picked=""; + S.solved=false; + S.feedback=""; + S.flashRevealed=false; + S.answerLetters=null; + S.trayLetters=null; + S.revealMask=null; + S.spellSolved=false; + draw(); + }; + const answer=id=>{ + if(S.solved)return; + S.picked=id; + if(id===S.target){ + S.solved=true;S.feedback="good";S.score++;S.streak++;sndGood(); + }else{ + S.feedback="bad";S.streak=0;sndBad(); + } + draw(); + }; + const assessFlash=ok=>{ + if(S.solved)return; + S.solved=true; + if(ok){S.feedback="good";S.score++;S.streak++;sndGood()} + else{S.feedback="bad";S.streak=0;sndBad()} + draw(); + }; + const chooseLevel=value=>{S.level=value;S.score=0;S.streak=0;newTask()}; + const worldViewFor=()=>config.type==="trees"?(S.view==="mixed"?"tree":S.view):config.type==="birds"?"bird":"plant"; + const gallery=()=>{ + const grid=document.createElement("div"); + grid.className="world-gallery"; + pool().forEach(item=>{ + const card=document.createElement("article"); + card.className="world-specimen"; + card.append(worldVisual(item,config.type,config.type==="trees"?S.view:config.type==="birds"?"bird":"plant"),worldIdentity(item)); + const hint=document.createElement("p"); + hint.textContent=worldHint(item); + card.append(hint); + grid.append(card); + }); + return grid; + }; + function draw(){ + S.activity=activity.value; + S.exercise=exercise.value; + if(view)S.view=view.value; + exerciseSetting.hidden=S.activity!=="quiz"; + if(viewSetting)viewSetting.hidden=S.activity==="quiz"&&!["recognize","photo"].includes(S.exercise); + stage.className="world-app"; + stage.innerHTML=""; + const top=document.createElement("div"); + top.className="world-top"; + const levelControl=document.createElement("div"); + levelControl.className="world-level-control"; + const levelLabel=document.createElement("span"); + levelLabel.textContent=T("worldLevel"); + const levels=document.createElement("div"); + levels.className="lg-levels world-levels"; + [1,2,3].forEach(value=>{ + const button=document.createElement("button"); + button.type="button"; + button.textContent=T("lvl"+value); + button.title=T("worldLevel"+value+"Hint"); + button.classList.toggle("active",S.level===value); + button.onclick=()=>chooseLevel(value); + levels.append(button); + }); + levelControl.append(levelLabel,levels); + top.append(levelControl); + const title=document.createElement("strong"); + title.textContent=T("wg_"+config.type); + const score=document.createElement("span"); + score.className="world-score"; + score.textContent=T("worldScore").replace("{score}",S.score).replace("{streak}",S.streak); + top.append(title,score); + stage.append(top); + if(S.activity==="learn"){ + const intro=document.createElement("p"); + intro.className="world-intro"; + intro.textContent=T("worldExploreHint"); + stage.append(intro,gallery()); + return; + } + const item=current(),card=document.createElement("section"); + card.className="world-task-card"; + if(S.activity==="flash"){ + worldFlashCard(card,worldName(item),worldVisual(item,config.type,worldViewFor()),S,draw,()=>{S.flashRevealed=true;draw()},assessFlash); + if(S.solved){ + const fact=document.createElement("div"); + fact.className="world-fact"; + fact.append(worldIdentity(item)); + const factHint=document.createElement("p"); + factHint.textContent=worldHint(item); + fact.append(factHint); + card.append(fact); + } + const flashFeedback=document.createElement("p"); + flashFeedback.className="world-feedback "+(S.feedback==="good"?"ok":S.feedback?"bad":""); + flashFeedback.setAttribute("role","status"); + if(S.feedback)flashFeedback.textContent=T(S.feedback==="good"?"worldCorrect":"worldTryAgain"); + card.append(flashFeedback); + const flashActions=document.createElement("div"); + flashActions.className="world-actions"; + flashActions.append(btn(T("worldNext"),newTask,"ghost")); + card.append(flashActions); + stage.append(card); + return; + } + if(S.activity==="spell"){ + card.append(worldVisual(item,config.type,worldViewFor())); + const spellHint=document.createElement("p"); + spellHint.className="world-spell-hint"; + spellHint.textContent=T("worldSpellHint"); + card.append(spellHint); + card.append(worldSpellAnswer(worldName(item),S,draw)); + if(!S.spellSolved&&S.answerLetters.every(v=>v!==null)){ + S.spellSolved=true;S.score++;S.streak++;sndGood(); + } + const spellFeedback=document.createElement("p"); + spellFeedback.className="world-feedback "+(S.spellSolved?"ok":""); + spellFeedback.setAttribute("role","status"); + if(S.spellSolved)spellFeedback.textContent=T("worldSpellComplete"); + if(S.spellSolved&¤tUser&¤tUser.avatar){const cheer=renderAvatarFace(currentUser.avatar,"md");cheer.classList.add("av-cheer");spellFeedback.append(cheer)} + card.append(spellFeedback); + if(S.spellSolved){ + const fact=document.createElement("div"); + fact.className="world-fact"; + fact.append(worldIdentity(item)); + const factHint=document.createElement("p"); + factHint.textContent=worldHint(item); + fact.append(factHint); + card.append(fact); + } + const spellActions=document.createElement("div"); + spellActions.className="world-actions"; + spellActions.append(btn(T("worldNext"),newTask,"ghost")); + card.append(spellActions); + stage.append(card); + return; + } + const question=document.createElement("h3"); + if(S.exercise==="photo"){ + const photoQuestion=config.type==="birds"?"worldFindBirdPhoto":config.type==="plants"?"worldFindPlantPhoto":"worldFindTreePhoto"; + question.textContent=T(photoQuestion).replace("{name}",worldName(item)); + card.append(question); + const answers=document.createElement("div"); + answers.className="world-photo-answers"; + S.options.forEach((id,index)=>{ + const option=config.items.find(entry=>entry.id===id); + if(!option)return; + const status=S.solved&&id===S.target?"correct":S.feedback==="bad"&&id===S.picked?"wrong":""; + const optionView=config.type==="trees"?(S.taskView==="mixed"?"tree":S.taskView):config.type==="birds"?"bird":"plant"; + answers.append(worldPhotoOption(option,config.type,optionView,index,()=>answer(id),status)); + }); + card.append(answers); + }else{ + const prompt=document.createElement("div"); + if(S.exercise==="clue"){ + question.textContent=T("worldQuestionClue"); + prompt.className="world-clue"; + prompt.textContent=worldHint(item); + }else if(S.exercise==="latin"){ + question.textContent=T("worldQuestionLatin"); + prompt.className="world-clue world-clue-latin"; + prompt.textContent=item.latin; + }else if(S.exercise==="pair"){ + question.textContent=T("worldQuestionPair"); + prompt.append(worldVisual(item,config.type,"mixed")); + }else{ + const questionKey=config.type==="birds"?"worldQuestionBird":config.type==="plants"?"worldQuestionPlant":S.taskView==="leaf"?"worldQuestionLeaf":"worldQuestionTree"; + question.textContent=T(questionKey); + prompt.append(worldVisual(item,config.type,S.taskView)); + } + card.append(question,prompt); + const answers=document.createElement("div"); + answers.className="world-answers"; + S.options.forEach(id=>{ + const option=config.items.find(entry=>entry.id===id); + if(!option)return; + const button=btn(worldName(option),()=>answer(id)); + if(S.solved&&id===S.target)button.classList.add("correct"); + else if(S.feedback==="bad"&&id===S.picked)button.classList.add("wrong"); + answers.append(button); + }); + card.append(answers); + } + const feedback=document.createElement("p"); + feedback.className="world-feedback "+(S.feedback==="good"?"ok":S.feedback?"bad":""); + feedback.setAttribute("role","status"); + if(S.feedback)feedback.textContent=T(S.feedback==="good"?"worldCorrect":"worldTryAgain"); + card.append(feedback); + const actions=document.createElement("div"); + actions.className="world-actions"; + actions.append(btn(T("worldNext"),newTask,"ghost")); + card.append(actions); + if(S.solved){ + const fact=document.createElement("div"); + fact.className="world-fact"; + fact.append(worldIdentity(item)); + const hint=document.createElement("p"); + hint.textContent=worldHint(item); + fact.append(hint); + card.append(fact); + } + stage.append(card); + } + const isTaskActivity=()=>["quiz","flash","spell"].includes(S.activity); + activity.onchange=()=>{S.activity=activity.value;if(isTaskActivity())newTask();else draw()}; + exercise.onchange=()=>{S.exercise=exercise.value;if(isTaskActivity())newTask();else draw()}; + if(view)view.onchange=()=>{S.view=view.value;if(isTaskActivity())newTask();else draw()}; + learningShell(root,settings,stage); + if(isTaskActivity())newTask();else draw(); + return{getState:()=>({...S,options:[...S.options]})}; +} + +function mountTrees(root,init){return mountWorldQuiz(root,init,{type:"trees",items:WORLD_TREES})} +function mountBirds(root,init){return mountWorldQuiz(root,init,{type:"birds",items:WORLD_BIRDS})} +function mountPlants(root,init){return mountWorldQuiz(root,init,{type:"plants",items:WORLD_PLANTS})} + +const SVG_NS="http://www.w3.org/2000/svg"; +const TOPO_MAP_ASSET=WORLD_MEDIA_ROOT+"netherlands-provinces.svg"; +const TOPO_MAP_SOURCE="https://commons.wikimedia.org/wiki/File:Netherlands_provinces_map_blank.svg"; +const TOPO_WORLD_ASSET=WORLD_MEDIA_ROOT+"world-continents.svg"; +const TOPO_WORLD_SOURCE="https://www.naturalearthdata.com/downloads/"; +function svgNode(name,attributes={}){ + const node=document.createElementNS(SVG_NS,name); + Object.entries(attributes).forEach(([key,value])=>node.setAttribute(key,value)); + return node; +} +function svgAction(node,label,action){ + node.setAttribute("role","button"); + node.setAttribute("tabindex","0"); + node.setAttribute("aria-label",label); + node.addEventListener("click",action); + node.addEventListener("keydown",event=>{ + if(event.key==="Enter"||event.key===" "){ + event.preventDefault(); + action(); + } + }); +} + +function makeTopoMap(mode,pool,onPick,showLabels,picked,target){ + const wrap=document.createElement("div"); + wrap.className="topo-map-wrap"; + const svg=svgNode("svg",{ + class:"topo-map",viewBox:"0 0 743 881", + role:onPick?"group":"img","aria-label":T("topoMapLabel"), + }); + svg.append(svgNode("rect",{x:0,y:0,width:743,height:881,rx:34,class:"topo-water"})); + const active=new Set(pool.map(item=>item.id)); + if(mode==="regions"){ + NL_REGIONS.forEach(region=>{ + const group=svgNode("g",{ + class:"topo-region"+(region.id===target?" target":"")+(region.id===picked?" picked":""), + "data-id":region.id, + }); + region.provinces.forEach(provinceId=>{ + const province=NL_PROVINCES.find(item=>item.id===provinceId); + if(province)group.append(svgNode("use",{href:TOPO_MAP_ASSET+"#"+province.shape,fill:region.color,class:"topo-province"})); + }); + const title=svgNode("title"); + title.textContent=worldName(region); + group.append(title); + if(onPick&&active.has(region.id))svgAction(group,worldName(region),()=>onPick(region.id)); + svg.append(group); + if(showLabels&&active.has(region.id)){ + const label=svgNode("text",{x:region.x,y:region.y,class:"topo-label topo-region-label"}); + label.textContent=worldName(region); + svg.append(label); + } + }); + }else{ + NL_PROVINCES.forEach(province=>{ + const selectable=mode==="provinces"&&active.has(province.id); + const shape=svgNode("use",{ + href:TOPO_MAP_ASSET+"#"+province.shape, + fill:mode==="rivers"?"#dce8df":province.color, + class:"topo-province"+(province.id===target?" target":"")+(province.id===picked?" picked":"")+(mode==="provinces"&&!selectable?" inactive":""), + "data-id":province.id, + }); + const title=svgNode("title"); + title.textContent=worldName(province); + shape.append(title); + if(onPick&&selectable)svgAction(shape,worldName(province),()=>onPick(province.id)); + svg.append(shape); + if(showLabels&&mode==="provinces"&&selectable){ + const label=svgNode("text",{x:province.x,y:province.y,class:"topo-label"}); + label.textContent=worldName(province); + svg.append(label); + } + }); + } + if(mode==="rivers")NL_RIVERS.forEach(river=>{ + const selectable=active.has(river.id); + const group=svgNode("g",{ + class:"topo-river"+(river.id===target?" target":"")+(river.id===picked?" picked":"")+(!selectable?" inactive":""), + "data-id":river.id,style:"--river-color:"+river.color, + }); + group.append( + svgNode("path",{d:river.path,class:"topo-river-hit"}), + svgNode("path",{d:river.path,class:"topo-river-line"}), + ); + const title=svgNode("title"); + title.textContent=worldName(river); + group.append(title); + if(onPick&&selectable)svgAction(group,worldName(river),()=>onPick(river.id)); + svg.append(group); + if(showLabels&&selectable){ + const label=svgNode("text",{x:river.labelX,y:river.labelY,class:"topo-river-label"}); + label.textContent=worldName(river); + svg.append(label); + } + }); + if(mode==="capitals"||mode==="cities")pool.forEach(city=>{ + const group=svgNode("g",{ + class:"topo-marker"+(city.id===target?" target":"")+(city.id===picked?" picked":""), + }); + const hit=svgNode("circle",{cx:city.x,cy:city.y,r:24,class:"topo-hit"}); + const dot=svgNode("circle",{cx:city.x,cy:city.y,r:10,class:"topo-dot"}); + group.append(hit,dot); + if(showLabels){ + const left=city.side==="left"; + const label=svgNode("text",{ + x:city.x+(left?-16:16),y:city.y-13, + class:"topo-city-label"+(left?" left":""), + }); + label.textContent=worldName(city); + group.append(label); + } + if(onPick)svgAction(group,worldName(city),()=>onPick(city.id)); + svg.append(group); + }); + const source=document.createElement("a"); + source.className="topo-source"; + source.href=TOPO_MAP_SOURCE; + source.target="_blank"; + source.rel="noopener noreferrer"; + source.textContent=T("topoMapSource"); + wrap.append(svg,source); + return wrap; +} + +function makeWorldMap(pool,onPick,showLabels,picked,target){ + const wrap=document.createElement("div"); + wrap.className="topo-map-wrap world-map-wrap"; + const svg=svgNode("svg",{ + class:"topo-map world-map",viewBox:"0 0 720 360", + role:onPick?"group":"img","aria-label":T("topoWorldMapLabel"), + }); + svg.append(svgNode("rect",{x:0,y:0,width:720,height:360,rx:24,class:"topo-water"})); + const active=new Set(pool.map(item=>item.id)); + WORLD_CONTINENTS.forEach(continent=>{ + const selectable=active.has(continent.id); + const shape=svgNode("use",{ + href:TOPO_WORLD_ASSET+"#"+continent.shape, + fill:continent.color, + class:"world-continent"+(continent.id===target?" target":"")+(continent.id===picked?" picked":"")+(!selectable?" inactive":""), + "data-id":continent.id, + }); + const title=svgNode("title"); + title.textContent=worldName(continent); + shape.append(title); + if(onPick&&selectable)svgAction(shape,worldName(continent),()=>onPick(continent.id)); + svg.append(shape); + if(showLabels&&selectable){ + const label=svgNode("text",{x:continent.x,y:continent.y,class:"world-continent-label"}); + label.textContent=worldName(continent); + svg.append(label); + } + }); + const source=document.createElement("a"); + source.className="topo-source"; + source.href=TOPO_WORLD_SOURCE; + source.target="_blank"; + source.rel="noopener noreferrer"; + source.textContent=T("topoWorldMapSource"); + wrap.append(svg); + if(showLabels){ + const legend=document.createElement("div"); + legend.className="topo-world-legend"; + pool.forEach(continent=>{ + const entry=document.createElement("span"); + const swatch=document.createElement("i"); + swatch.style.background=continent.color; + entry.append(swatch,worldName(continent)); + legend.append(entry); + }); + wrap.append(legend); + } + wrap.append(source); + return wrap; +} + +function mountTopography(root,init){ + const modeOptions=[ + ["provinces","topoProvinces"],["capitals","topoCapitals"],["cities","topoCities"], + ["regions","topoRegions"],["rivers","topoRivers"],["continents","topoContinents"], + ]; + const S=Object.assign({ + activity:"quiz",mode:"provinces",level:1,score:0,streak:0, + target:"",picked:"",solved:false,feedback:"", + flashRevealed:false,answerLetters:null,trayLetters:null,revealMask:null,spellSolved:false, + },init||{}); + S.level=Math.max(1,Math.min(3,+S.level||1)); + if(!modeOptions.some(([id])=>id===S.mode))S.mode="provinces"; + const activity=worldSelect(S.activity,[["learn","worldLearn"],["quiz","worldQuiz"],["flash","worldFlash"],["spell","worldSpell"]]); + const mode=worldSelect(S.mode,modeOptions); + const stage=document.createElement("div"); + const pool=()=>{ + const items=S.mode==="provinces"?NL_PROVINCES + :S.mode==="capitals"?NL_CITIES.filter(item=>item.capital) + :S.mode==="cities"?NL_CITIES.filter(item=>!item.capital) + :S.mode==="regions"?NL_REGIONS + :S.mode==="rivers"?NL_RIVERS + :WORLD_CONTINENTS; + return items.filter(item=>item.tier<=S.level); + }; + const current=()=>pool().find(item=>item.id===S.target)||pool()[0]; + const newTask=()=>{ + const items=pool(),previous=S.target; + let target=items[Math.floor(Math.random()*items.length)]; + if(items.length>1)while(target.id===previous)target=items[Math.floor(Math.random()*items.length)]; + S.target=target.id;S.picked="";S.solved=false;S.feedback=""; + S.flashRevealed=false;S.answerLetters=null;S.trayLetters=null;S.revealMask=null;S.spellSolved=false; + draw(); + }; + const answer=id=>{ + if(S.solved)return; + S.picked=id; + if(id===S.target){ + S.solved=true;S.feedback="good";S.score++;S.streak++;sndGood(); + }else{ + S.feedback="bad";S.streak=0;sndBad(); + } + draw(); + }; + const assessFlash=ok=>{ + if(S.solved)return; + S.solved=true; + if(ok){S.feedback="good";S.score++;S.streak++;sndGood()} + else{S.feedback="bad";S.streak=0;sndBad()} + draw(); + }; + const chooseLevel=value=>{S.level=value;S.score=0;S.streak=0;newTask()}; + function draw(){ + S.activity=activity.value;S.mode=mode.value; + stage.className="world-app topo-app"; + stage.innerHTML=""; + const top=document.createElement("div"); + top.className="world-top"; + if(S.mode!=="regions"){ + const levelControl=document.createElement("div"); + levelControl.className="world-level-control"; + const levelLabel=document.createElement("span"); + levelLabel.textContent=T("worldLevel"); + const levels=document.createElement("div"); + levels.className="lg-levels world-levels"; + [1,2,3].forEach(value=>{ + const button=document.createElement("button"); + button.type="button"; + button.textContent=T("lvl"+value); + button.title=T("worldLevel"+value+"Hint"); + button.classList.toggle("active",S.level===value); + button.onclick=()=>chooseLevel(value); + levels.append(button); + }); + levelControl.append(levelLabel,levels); + top.append(levelControl); + } + const title=document.createElement("strong"); + title.textContent=T("wg_topography"); + const score=document.createElement("span"); + score.className="world-score"; + score.textContent=T("worldScore").replace("{score}",S.score).replace("{streak}",S.streak); + top.append(title,score); + stage.append(top); + const card=document.createElement("section"); + card.className="world-task-card topo-card"; + const renderMap=(interactive,labels,pickedId,targetId)=>S.mode==="continents" + ?makeWorldMap(pool(),interactive,labels,pickedId,targetId) + :makeTopoMap(S.mode,pool(),interactive,labels,pickedId,targetId); + if(S.activity==="learn"){ + const intro=document.createElement("p"); + intro.className="world-intro"; + intro.textContent=T(S.mode==="regions"?"topoExploreRegions":S.mode==="rivers"?"topoExploreRivers":S.mode==="continents"?"topoExploreContinents":"topoExploreHint"); + card.append(intro,renderMap(null,true,"","")); + }else if(S.activity==="flash"){ + const item=current(); + worldFlashCard(card,worldName(item),renderMap(null,S.flashRevealed,"",S.flashRevealed?S.target:""),S,draw,()=>{S.flashRevealed=true;draw()},assessFlash); + const flashFeedback=document.createElement("p"); + flashFeedback.className="world-feedback "+(S.feedback==="good"?"ok":S.feedback?"bad":""); + flashFeedback.setAttribute("role","status"); + if(S.feedback)flashFeedback.textContent=T(S.feedback==="good"?"worldCorrect":"worldTryAgain"); + card.append(flashFeedback); + const flashActions=document.createElement("div"); + flashActions.className="world-actions"; + flashActions.append(btn(T("worldNext"),newTask,"ghost")); + card.append(flashActions); + }else if(S.activity==="spell"){ + const item=current(); + card.append(renderMap(null,true,"",S.target)); + const spellHint=document.createElement("p"); + spellHint.className="world-spell-hint"; + spellHint.textContent=T("worldSpellHint"); + card.append(spellHint); + card.append(worldSpellAnswer(worldName(item),S,draw)); + if(!S.spellSolved&&S.answerLetters.every(v=>v!==null)){ + S.spellSolved=true;S.score++;S.streak++;sndGood(); + } + const spellFeedback=document.createElement("p"); + spellFeedback.className="world-feedback "+(S.spellSolved?"ok":""); + spellFeedback.setAttribute("role","status"); + if(S.spellSolved)spellFeedback.textContent=T("worldSpellComplete"); + if(S.spellSolved&¤tUser&¤tUser.avatar){const cheer=renderAvatarFace(currentUser.avatar,"md");cheer.classList.add("av-cheer");spellFeedback.append(cheer)} + card.append(spellFeedback); + const spellActions=document.createElement("div"); + spellActions.className="world-actions"; + spellActions.append(btn(T("worldNext"),newTask,"ghost")); + card.append(spellActions); + }else{ + const item=current(),question=document.createElement("h3"); + const questionKey=S.mode==="provinces"?"topoFindProvince" + :S.mode==="capitals"?"topoFindCapital" + :S.mode==="cities"?"topoFindCity" + :S.mode==="regions"?"topoFindRegion" + :S.mode==="rivers"?"topoFindRiver" + :"topoFindContinent"; + question.textContent=T(questionKey).replace("{name}",worldName(item)); + card.append(question,renderMap(answer,S.solved,S.picked,S.solved?S.target:"")); + const feedback=document.createElement("p"); + feedback.className="world-feedback "+(S.feedback==="good"?"ok":S.feedback?"bad":""); + feedback.setAttribute("role","status"); + if(S.feedback)feedback.textContent=T(S.feedback==="good"?"worldCorrect":"worldTryAgain"); + const actions=document.createElement("div"); + actions.className="world-actions"; + actions.append(btn(T("worldNext"),newTask,"ghost")); + card.append(feedback,actions); + } + stage.append(card); + } + const isTaskActivity=()=>["quiz","flash","spell"].includes(S.activity); + activity.onchange=()=>{S.activity=activity.value;if(isTaskActivity())newTask();else draw()}; + mode.onchange=()=>{S.mode=mode.value;if(isTaskActivity())newTask();else draw()}; + learningShell(root,[ + field(T("worldActivity"),activity), + field(T("topoMode"),mode), + ],stage); + if(isTaskActivity())newTask();else draw(); + return{getState:()=>({...S})}; +} diff --git a/public/js/widgets/write.js b/public/js/widgets/write.js new file mode 100644 index 0000000..58b1016 --- /dev/null +++ b/public/js/widgets/write.js @@ -0,0 +1,205 @@ +/* widget: Schrijfblad */ +/* ========================================================= + Writing pad — pen on paper with lines or maths squares +==========================================================*/ +function mountWrite(root, initState){ + const COLORS = ["#26344a","#3b7dd8","#e0554d","#3fae6a","#f7c948"]; + const SIZES = [3,6,10]; + let color = COLORS[0], size = SIZES[1], tool = "pen", // pen | mark | text | erase + bg = (initState && initState.bg) || "write"; + + root.innerHTML = ` +
+
+
+
+
+
+ +
+
+
`; + + const stage = root.querySelector(".wp-stage"), + canvas = root.querySelector("canvas"), + ctx = canvas.getContext("2d"), + toolsEl = root.querySelector(".wp-tools"), + bgsEl = root.querySelector(".wp-bgs"), + colorsEl = root.querySelector(".wp-colors"), + sizesEl = root.querySelector(".wp-sizes"), + clearBtn = root.querySelector(".wp-clear"); + + const TOOLS = [["pen","✏️","toolPen"],["vulp","🖋️","toolFountain"],["mark","🖍️","toolMark"],["text","🔤","toolText"],["erase","🧽","eraser"]]; + TOOLS.forEach(([id,icon,key])=>{ + const b = document.createElement("button"); + b.dataset.tool = id; b.textContent = icon; + b.addEventListener("click", ()=>{ tool = id; refresh(); }); + toolsEl.appendChild(b); + }); + + /* background choices */ + const BGS = [["blank","bgBlank"],["lines","bgLines"],["write","bgWrite"],["grid-s","bgGridS"],["grid-b","bgGridB"]]; + BGS.forEach(([id,key])=>{ + const b = document.createElement("button"); + b.className = "wp-bg"; b.dataset.bg = id; + b.appendChild(document.createElement("i")); + b.addEventListener("click", ()=>{ bg = id; refresh(); }); + bgsEl.appendChild(b); + }); + COLORS.forEach(c=>{ + const b = document.createElement("button"); + b.className = "wp-color"; b.dataset.c = c; + const i = document.createElement("i"); i.style.background = c; + b.appendChild(i); + b.addEventListener("click", ()=>{ color = c; if(tool==="erase") tool = "pen"; refresh(); }); + colorsEl.appendChild(b); + }); + SIZES.forEach(s=>{ + const b = document.createElement("button"); + b.className = "wp-size"; b.dataset.s = s; + const i = document.createElement("i"); + i.style.width = i.style.height = (s+4)+"px"; + b.appendChild(i); + b.addEventListener("click", ()=>{ size = s; refresh(); }); + sizesEl.appendChild(b); + }); + clearBtn.addEventListener("click", ()=>{ ctx.clearRect(0,0,canvas.width,canvas.height); }); + + function refresh(){ + stage.className = "wp-stage" + (bg==="blank" ? "" : " bg-"+bg) + (tool==="text" ? " t-text" : ""); + toolsEl.querySelectorAll("button").forEach(b=>{ + b.classList.toggle("on", b.dataset.tool===tool); + b.title = T(TOOLS.find(x=>x[0]===b.dataset.tool)[2]); + }); + bgsEl.querySelectorAll("button").forEach(b=>{ + b.classList.toggle("on", b.dataset.bg===bg); + b.title = T(BGS.find(x=>x[0]===b.dataset.bg)[1]); + }); + colorsEl.querySelectorAll("button").forEach(b=>b.classList.toggle("on", b.dataset.c===color && tool!=="erase")); + sizesEl.querySelectorAll("button").forEach(b=>{ b.classList.toggle("on", +b.dataset.s===size); b.title = T("penSize"); }); + clearBtn.title = T("clearAll"); + } + document.addEventListener("langchange", refresh); + + /* canvas sizing (preserves ink on resize) */ + const dpr = window.devicePixelRatio || 1; + function resize(){ + const w = stage.clientWidth, h = stage.clientHeight; + if(w<2 || h<2) return; + const keep = document.createElement("canvas"); + keep.width = canvas.width; keep.height = canvas.height; + if(canvas.width) keep.getContext("2d").drawImage(canvas,0,0); + canvas.width = Math.round(w*dpr); canvas.height = Math.round(h*dpr); + canvas.style.width = w+"px"; canvas.style.height = h+"px"; + if(keep.width) ctx.drawImage(keep,0,0); + ctx.lineCap = "round"; ctx.lineJoin = "round"; + } + new ResizeObserver(resize).observe(stage); + resize(); + + /* restore saved ink */ + if(initState && initState.ink){ + const im = new Image(); + im.onload = ()=>{ resize(); ctx.drawImage(im,0,0); }; + im.src = initState.ink; + } + + /* drawing */ + let drawing = false, lx=0, ly=0, nibW = size; + const pos = ev=>{ + const r = canvas.getBoundingClientRect(); + /* ratio-based: correct under app zoom and widget zoom */ + return [(ev.clientX-r.left)*(canvas.width/r.width), (ev.clientY-r.top)*(canvas.height/r.height)]; + }; + canvas.addEventListener("pointerdown", e=>{ + if(e.pointerType==="mouse" && e.button!==0) return; + e.preventDefault(); + if(tool==="text"){ startText(e); return; } + canvas.setPointerCapture(e.pointerId); + drawing = true; + nibW = size*0.8; + [lx,ly] = pos(e); + strokeTo(lx+0.01, ly+0.01, e.pressure, e.pointerType); + }); + canvas.addEventListener("pointermove", e=>{ + if(!drawing) return; + const evs = e.getCoalescedEvents ? e.getCoalescedEvents() : [e]; + evs.forEach(ev=>{ const [x,y] = pos(ev); strokeTo(x, y, ev.pressure, ev.pointerType); }); + }); + const stopDraw = ()=>{ drawing = false; ctx.globalAlpha = 1; }; + canvas.addEventListener("pointerup", stopDraw); + canvas.addEventListener("pointercancel", stopDraw); + function strokeTo(x,y,pressure,ptype){ + ctx.globalCompositeOperation = tool==="erase" ? "destination-out" : "source-over"; + ctx.globalAlpha = tool==="mark" ? 0.32 : 1; + ctx.strokeStyle = color; + let w = size; + if(tool==="erase") w = size*5; + else if(tool==="mark") w = size*3.5; + else if(tool==="vulp"){ + /* nib: real pen pressure when available, otherwise slower = thicker */ + let target; + if(ptype==="pen" && pressure > 0){ + target = size*(0.45 + pressure*1.9); + }else{ + const speed = Math.hypot(x-lx, y-ly)/dpr; + target = Math.max(size*0.5, Math.min(size*2.3, size*2.3 - speed*0.12)); + } + nibW += (target - nibW)*0.3; /* smooth transitions between thin and thick */ + w = nibW; + } + ctx.lineWidth = w*dpr; + ctx.beginPath(); + ctx.moveTo(lx,ly); + ctx.lineTo(x,y); + ctx.stroke(); + lx = x; ly = y; + } + + /* text tool: click the sheet, type, Enter (or click away) to place */ + function startText(e){ + if(stage.querySelector(".wp-text-input")) return; + const r = stage.getBoundingClientRect(); + const sc = r.width / stage.offsetWidth; /* effective zoom on this widget */ + const x = (e.clientX - r.left)/sc, y = (e.clientY - r.top)/sc; + const fontPx = size*6 + 10; + const inp = document.createElement("input"); + inp.type = "text"; inp.className = "wp-text-input"; + inp.style.left = Math.min(x, r.width-140)+"px"; + inp.style.top = Math.min(y, r.height-34)+"px"; + inp.style.color = color; + inp.style.fontSize = fontPx+"px"; + stage.appendChild(inp); + setTimeout(()=>inp.focus(), 0); + let done = false; + const commit = ()=>{ + if(done) return; done = true; + const v = inp.value.trim(); + if(v){ + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 1; + ctx.fillStyle = color; + ctx.font = `700 ${fontPx*dpr}px "Segoe UI Rounded","Segoe UI",Verdana,sans-serif`; + ctx.textBaseline = "top"; + ctx.fillText(v, x*dpr, y*dpr); + } + inp.remove(); + }; + inp.addEventListener("keydown", ev=>{ + ev.stopPropagation(); + if(ev.key==="Enter") commit(); + if(ev.key==="Escape"){ done = true; inp.remove(); } + }); + inp.addEventListener("blur", commit); + } + + refresh(); + return { + getState: ()=>{ + let ink = null; + try{ ink = canvas.toDataURL("image/png"); }catch(e){} + return { bg, ink }; + } + }; +} + diff --git a/public/js/wiki.js b/public/js/wiki.js new file mode 100644 index 0000000..7c08648 --- /dev/null +++ b/public/js/wiki.js @@ -0,0 +1,225 @@ +/* Curriculumwiki: doorzoekbare volwassenweergave van CURRICULUM_PO_2026. */ +"use strict"; + +(function(){ + const root=document.getElementById("curriculumWiki"); + let selectedArea="all"; + let query=""; + + const node=(tag,cls,text)=>{ + const el=document.createElement(tag); + if(cls)el.className=cls; + if(text!=null)el.textContent=text; + return el; + }; + const normal=value=>String(value||"").toLocaleLowerCase("nl-NL"); + const coverageKey=value=>value==="partial"?"wikiCoveragePartial":value==="support"?"wikiCoverageSupport":"wikiCoverageOpen"; + const widgetName=id=>{ + const translated=T("wg_"+id); + return translated==="wg_"+id?id:translated; + }; + const goalHaystack=(area,goal)=>normal([ + area.title,area.description,goal.id,goal.domain,goal.summary, + ...goal.parts,...goal.widgets, ...goal.widgets.map(widgetName) + ].join(" ")); + + function wikiLink(label,href){ + const link=node("a","wiki-link",label); + link.href=href; + link.target="_blank"; + link.rel="noopener noreferrer"; + return link; + } + + function drawGoals(holder,filters){ + holder.replaceChildren(); + const needle=normal(query.trim()); + let shown=0; + CURRICULUM_PO_2026.areas.forEach(area=>{ + if(selectedArea!=="all"&&selectedArea!==area.id)return; + const areaHit=needle&&normal(area.title+" "+area.description).includes(needle); + const goals=area.goals.filter(goal=>!needle||areaHit||goalHaystack(area,goal).includes(needle)); + if(!goals.length)return; + shown+=goals.length; + + const section=node("section","wiki-area"); + section.id="wiki-area-"+area.id; + const areaHead=node("div","wiki-area-head"); + const title=node("h3"); + title.appendChild(node("span","wiki-area-icon",area.icon)); + title.appendChild(document.createTextNode(area.title)); + areaHead.appendChild(title); + areaHead.appendChild(node("span","wiki-range",T("wikiGoalsRange").replace("{range}",area.goalsRange))); + section.appendChild(areaHead); + section.appendChild(node("p","wiki-area-intro",area.description)); + + const grid=node("div","wiki-grid"); + goals.forEach(goal=>{ + const card=node("article","wiki-goal"); + card.id="wiki-goal-"+goal.id; + const head=node("div","wiki-goal-head"); + head.appendChild(node("span","wiki-goal-id",T("wikiGoal")+" "+goal.id)); + head.appendChild(node("span","wiki-domain",goal.domain)); + card.appendChild(head); + card.appendChild(node("p","wiki-summary",goal.summary)); + const partTitle=node("h4",null,T("wikiParts")); + card.appendChild(partTitle); + const parts=node("ul","wiki-parts"); + goal.parts.forEach((part,index)=>{ + const code=String(goal.id)+String.fromCharCode(65+index); + const item=node("li"); + item.appendChild(node("strong",null,code)); + item.appendChild(document.createTextNode(" · "+part)); + parts.appendChild(item); + }); + card.appendChild(parts); + const coverage=node("div","wiki-coverage-row"); + coverage.appendChild(node("span","wiki-coverage wiki-coverage-"+goal.coverage,T(coverageKey(goal.coverage)))); + if(goal.widgets.length){ + const widgets=node("div","wiki-widgets"); + widgets.appendChild(node("span","wiki-widgets-label",T("wikiWidgets")+":")); + goal.widgets.forEach(id=>widgets.appendChild(node("span","wiki-widget",widgetName(id)))); + coverage.appendChild(widgets); + } + card.appendChild(coverage); + grid.appendChild(card); + }); + section.appendChild(grid); + const pageUrl=CURRICULUM_PO_2026.source+"#page="+area.page; + section.appendChild(wikiLink(T("wikiOfficialArea").replace("{area}",area.title),pageUrl)); + holder.appendChild(section); + }); + if(!shown)holder.appendChild(node("div","wiki-empty",T("wikiNoResults"))); + filters.querySelectorAll("button").forEach(button=>button.setAttribute("aria-pressed",String(button.dataset.area===selectedArea))); + } + + function renderCurriculumWiki(payload){ + if(payload&&payload.area){selectedArea=payload.area;query="";} + root.replaceChildren(); + const header=node("div","wiki-hero"); + const heroText=node("div","wiki-hero-text"); + heroText.appendChild(node("div","wiki-kicker",T("wikiKicker"))); + heroText.appendChild(node("h2",null,T("wikiTitle"))); + heroText.appendChild(node("p",null,T("wikiIntro"))); + header.appendChild(heroText); + const facts=node("div","wiki-facts"); + facts.appendChild(node("strong",null,"9")); + facts.appendChild(node("span",null,T("wikiAreas"))); + facts.appendChild(node("strong",null,"40")); + facts.appendChild(node("span",null,T("wikiGoals"))); + header.appendChild(facts); + root.appendChild(header); + + const status=node("aside","wiki-status"); + status.appendChild(node("strong",null,T("wikiStatusTitle"))); + status.appendChild(node("span",null,T("wikiStatusText"))); + status.appendChild(wikiLink(T("wikiOfficialSource"),CURRICULUM_PO_2026.source)); + root.appendChild(status); + + const tule=node("details","wiki-tule"); + const tuleSummary=node("summary",null,T("wikiTuleTitle")); + tule.appendChild(tuleSummary); + tule.appendChild(node("p",null,T("wikiTuleText"))); + const sourceLinks=node("div","wiki-source-links"); + sourceLinks.appendChild(wikiLink(T("wikiTuleSource"),CURRICULUM_PO_2026.tule)); + sourceLinks.appendChild(wikiLink(T("wikiProgressionsSource"),CURRICULUM_PO_2026.progressions)); + tule.appendChild(sourceLinks); + root.appendChild(tule); + + const profiles=node("details","wiki-tule wiki-year-profiles"); + profiles.appendChild(node("summary",null,T("wikiYearProfiles"))); + profiles.appendChild(node("p",null,T("wikiYearProfilesText"))); + const profileGrid=node("div","wiki-profile-grid"); + [["taal","📖"],["rekenen","🔢"],["creatief","🎨"]].forEach(([subject,icon])=>{ + const column=node("section","wiki-profile-column"); + const subjectKey={taal:"parentLevelTaal",rekenen:"parentLevelRekenen",creatief:"parentLevelCreative"}[subject]; + column.appendChild(node("h3",null,icon+" "+T(subjectKey))); + LEARNING_YEAR_PROFILES[subject].slice(1).forEach((profile,index)=>{ + const row=node("article","wiki-profile-row"); + row.appendChild(node("strong",null,T("wikiYear").replace("{n}",index+1))); + row.appendChild(node("span","wiki-profile-title",profile.title)); + row.appendChild(node("small",null,T("wikiProfileGoals")+": "+profile.goals.join(" · "))); + column.appendChild(row); + }); + profileGrid.appendChild(column); + }); + profiles.appendChild(profileGrid); + root.appendChild(profiles); + + if(LANG==="en")root.appendChild(node("p","wiki-language-note",T("wikiLanguageNote"))); + + const searchWrap=node("div","wiki-search"); + const label=node("label",null,T("wikiSearchLabel")); + label.htmlFor="wikiGoalSearch"; + const input=node("input"); + input.type="search"; + input.id="wikiGoalSearch"; + input.placeholder=T("wikiSearchPlaceholder"); + input.autocomplete="off"; + input.value=query; + searchWrap.appendChild(label); + searchWrap.appendChild(input); + root.appendChild(searchWrap); + + const filters=node("div","wiki-filters"); + filters.setAttribute("aria-label",T("wikiAreaFilter")); + const all=node("button",null,T("wikiAllAreas")); + all.type="button"; + all.dataset.area="all"; + filters.appendChild(all); + CURRICULUM_PO_2026.areas.forEach(area=>{ + const button=node("button",null,area.icon+" "+area.title); + button.type="button"; + button.dataset.area=area.id; + filters.appendChild(button); + }); + root.appendChild(filters); + + const holder=node("div","wiki-results"); + root.appendChild(holder); + filters.addEventListener("click",event=>{ + const button=event.target.closest("button[data-area]"); + if(!button)return; + selectedArea=button.dataset.area; + drawGoals(holder,filters); + holder.scrollIntoView({block:"nearest"}); + }); + input.addEventListener("input",()=>{ + query=input.value; + drawGoals(holder,filters); + }); + drawGoals(holder,filters); + if(payload&&payload.goal)requestAnimationFrame(()=>{ + const target=document.getElementById("wiki-goal-"+payload.goal); + if(target){target.classList.add("wiki-goal-focus");target.scrollIntoView({block:"center"});} + }); + + const disclaimer=node("footer","wiki-disclaimer"); + disclaimer.appendChild(node("strong",null,T("wikiCarefulTitle"))); + disclaimer.appendChild(document.createTextNode(" "+T("wikiCarefulText"))); + root.appendChild(disclaimer); + } + window.renderCurriculumWiki=renderCurriculumWiki; + + registerSettingsSection({ + id:"curriculum",icon:"📚",group:"personal",order:3, + label:()=>T("wikiTitle"),sub:()=>T("wikiSub"), + visible:()=>!!currentUser&&can("curriculum.view"), + activate(payload){showSettingsDom("curriculum");renderCurriculumWiki(payload);}, + keywords:()=>[ + T("wikiTitle"),T("wikiSub"),T("wikiYearProfiles"),"SLO","kerndoelen","curriculum","leerlijnen","TULE", + ...CURRICULUM_PO_2026.areas.flatMap(area=>[area.title,area.description]) + ], + searchItems:needle=>{ + const q=normal(needle); + return CURRICULUM_PO_2026.areas.flatMap(area=>{ + const areaMatch=normal(area.title+" "+area.description).includes(q); + const goalMatch=area.goals.some(goal=>goalHaystack(area,goal).includes(q)); + if(!areaMatch&&!goalMatch)return[]; + return[{icon:area.icon,label:area.title,sub:T("wikiGoal")+" "+area.goalsRange, + go:()=>goSettingsSection("curriculum",{area:area.id})}]; + }); + } + }); + +})(); diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..3340f8d --- /dev/null +++ b/src/api.js @@ -0,0 +1,1898 @@ +// teach API: inloggen, rollen en gebruikersbeheer. +// +// Rollen en rechten: +// super - systeemmanager: alles, over alle scholen heen, +// kan andere systeemmanagers aanwijzen. +// admin - schoolbeheerder: alle gebruikers en klassen van de eigen school. +// teacher - groepsleiding: leerlingen aanmaken in eigen klas, leerlingen +// verplaatsen naar andere klassen (uitwisselen), leerling- +// wachtwoorden inzien en aanpassen. +// pupil - leerling: inloggen en eigen borden; kan het eigen wachtwoord +// NIET inzien of wijzigen. +// parent - ouder (school_id NULL): eigen registratie, beheert gekoppelde +// kind-accounts en ziet hun voortgang; geen beheer, geen borden. +import { + hashPassword, verifyPassword, createSession, userFromRequest, + newLinkCode, pupilPassword, staffPassword, checkPassword, publicUser, + SESSION_COOKIE, hashSessionToken, sessionTokenFromRequest, sessionDaysForRole, +} from './auth.js'; +import { PERMISSIONS, CREATABLE_ROLES, can } from './permissions.js'; +import registerLiveSessions from './live-classroom.js'; + +export default async function api(app) { + const pool = app.pg; + // Voorkomt meetbaar snellere antwoorden voor niet-bestaande gebruikers. + const dummyPasswordHash = await hashPassword(newLinkCode()); + const cookieSecure = process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production'; + const cookieOptions = { path: '/', httpOnly: true, secure: cookieSecure, sameSite: 'strict' }; + const setSessionCookie = (reply, token, role) => reply.setCookie(SESSION_COOKIE, token, { + ...cookieOptions, maxAge: sessionDaysForRole(role) * 24 * 60 * 60, + }); + const clearSessionCookie = (reply) => reply.clearCookie(SESSION_COOKIE, { path: '/' }); + + // ---- helpers -------------------------------------------------------------- + const fail = (reply, code, msg) => { reply.code(code); return { error: msg }; }; + + app.decorateRequest('user', null); + app.addHook('preHandler', async (req, reply) => { + if (!['GET','HEAD','OPTIONS'].includes(req.method) && req.headers.origin) { + const expectedOrigin = `${req.protocol}://${req.headers.host}`; + if (req.headers.origin !== expectedOrigin) return fail(reply, 403, 'ongeldige origin'); + } + req.user = await userFromRequest(pool, req); + }); + app.addHook('onResponse', async (req, reply) => { + if ([401, 403, 429].includes(reply.statusCode)) { + req.log.warn({ securityEvent: true, status: reply.statusCode, method: req.method, + url: req.url, ip: req.ip, userId: req.user?.id ?? null }, 'security-relevant response'); + } + }); + const need = (req, reply, roles) => { + if (!req.user) { reply.code(401); throw new Error('niet ingelogd'); } + if (roles && !roles.some((r) => req.user.allRoles.includes(r))) { reply.code(403); throw new Error('geen rechten'); } + }; + // groepsleiding-only regels gelden alleen als iemand *uitsluitend* teacher is; + // wie er (via een extra rol) ook admin of super bij heeft, valt onder de + // ruimere regels van die rol i.p.v. de leerling-only-beperking van teacher. + const teacherOnly = (req) => + req.user.allRoles.includes('teacher') + && !req.user.allRoles.includes('admin') + && !req.user.allRoles.includes('super'); + // welke rollen iemand mag aanmaken: unie over al hun rollen (hoofd + extra) + const creatableRoles = (req) => + [...new Set(req.user.allRoles.flatMap((r) => CREATABLE_ROLES[r] || []))]; + const sameSchool = (req, u) => + req.user.allRoles.includes('super') || Number(u.school_id) === Number(req.user.school_id); + + app.setErrorHandler((err, req, reply) => { + if (reply.statusCode >= 400 && reply.statusCode < 500) return reply.send({ error: err.message }); + req.log.error({ err }, 'onverwachte serverfout'); + // Altijd de echte oorzaak meegeven i.p.v. een kale "serverfout": dit is een + // intern beheertool (geen publieke site), en een onherleidbare "serverfout" + // was al twee keer een doodlopende weg bij het diagnosticeren van een bug - + // eerder was dit alleen voor ingelogde gebruikers (req.user), maar bleek + // ook dát soms nog niet gezet als de fout vóór/tijdens het inloggen zit. + reply.code(500).send({ error: `serverfout: ${err.message}` }); + }); + + // ---- version -------------------------------------------------------------- + app.get('/version', async () => ({ + name: 'teach', + version: process.env.APP_VERSION ?? 'dev', + sha: process.env.GIT_SHA || undefined, + })); + + // ---- publiek: scholenlijst voor het inlogscherm ---------------------------- + app.get('/schools', async () => { + const r = await pool.query('SELECT id, name FROM schools ORDER BY name'); + return r.rows.map((s) => ({ id: Number(s.id), name: s.name })); + }); + + // ---- site-inhoud (CMS van de systeemmanager) -------------------------------- + // Kleine sleutel/waarde-opslag: welkomstwoord (inlogscherm), update-melding + // (banner voor alle gebruikers) en aanvullende widget-uitleg (galerij). + // Lezen is publiek (het inlogscherm toont het welkomstwoord al vóór het + // inloggen); schrijven kan alleen de systeemmanager, per toegestane sleutel + // en met een maat-limiet zodat dit nooit een opslagplaats voor blobs wordt. + const CONTENT_KEYS = { welcome: 8 * 1024, announcement: 8 * 1024, widgetInfo: 64 * 1024 }; + app.get('/content', async () => { + const r = await pool.query('SELECT key, value FROM site_content'); + const out = {}; + r.rows.forEach((row) => { out[row.key] = row.value; }); + return out; + }); + app.put('/admin/content/:key', async (req, reply) => { + need(req, reply, PERMISSIONS['content.manage']); + const key = req.params.key; + if (!(key in CONTENT_KEYS)) return fail(reply, 404, 'onbekende inhoud'); + const value = req.body ?? {}; + if (!value || typeof value !== 'object' || Array.isArray(value)) return fail(reply, 400, 'ongeldige inhoud'); + if (Buffer.byteLength(JSON.stringify(value), 'utf8') > CONTENT_KEYS[key]) return fail(reply, 413, 'inhoud te groot'); + await pool.query( + `INSERT INTO site_content (key, value, updated_at, updated_by) VALUES ($1, $2, now(), $3) + ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now(), updated_by = $3`, + [key, value, req.user.id]); + return { ok: true }; + }); + + // ---- auth ------------------------------------------------------------------- + app.post('/auth/login', { + config: { rateLimit: { max: 8, timeWindow: '10 minutes', ban: 3 } }, + }, async (req, reply) => { + const { schoolId, username, password } = req.body ?? {}; + if (typeof username !== 'string' || typeof password !== 'string' + || !username.trim() || username.length > 30 || password.length > 128) + return fail(reply, 400, 'ongeldige naam of wachtwoord'); + const r = schoolId + ? await pool.query('SELECT * FROM users WHERE school_id = $1 AND lower(username) = lower($2)', [schoolId, username.trim()]) + : await pool.query('SELECT * FROM users WHERE school_id IS NULL AND lower(username) = lower($1)', [username.trim()]); + const u = r.rows[0]; + const passwordOk = await verifyPassword(password, u?.password_hash || dummyPasswordHash); + if (!u || !passwordOk) return fail(reply, 401, 'onbekende naam of verkeerd wachtwoord'); + // account is nu "in gebruik": leerling-wachtwoord niet langer inzichtelijk + await pool.query('UPDATE users SET last_login_at = now() WHERE id = $1', [u.id]); + const token = await createSession(pool, u.id, u.role); + setSessionCookie(reply, token, u.role); + return { user: publicUser(u) }; + }); + + // Account activeren met een koppelcode (groepsleiding/beheerders) + app.post('/auth/link', { + config: { rateLimit: { max: 5, timeWindow: '15 minutes', ban: 3 } }, + }, async (req, reply) => { + const { code, password } = req.body ?? {}; + if (typeof code !== 'string' || code.length > 20 || typeof password !== 'string' || password.length > 128) + return fail(reply, 400, 'ongeldige koppelcode of wachtwoord'); + const r = await pool.query('SELECT * FROM users WHERE link_code = $1', [code.trim().toUpperCase()]); + const u = r.rows[0]; + if (!u) return fail(reply, 404, 'koppelcode onbekend of al gebruikt'); + const err = checkPassword(password, u.role); + if (err) return fail(reply, 400, err); + const hash = await hashPassword(password); + await pool.query('UPDATE users SET password_hash = $1, link_code = NULL, last_login_at = now() WHERE id = $2', [hash, u.id]); + const token = await createSession(pool, u.id, u.role); + setSessionCookie(reply, token, u.role); + return { user: publicUser({ ...u, password_hash: hash, link_code: null }) }; + }); + + app.post('/auth/logout', async (req, reply) => { + const token = sessionTokenFromRequest(req); + if (token) await pool.query('DELETE FROM sessions WHERE token = $1', [hashSessionToken(token)]); + clearSessionCookie(reply); + return { ok: true }; + }); + + app.get('/auth/me', async (req, reply) => { + if (!req.user) return fail(reply, 401, 'niet ingelogd'); + return { user: publicUser(req.user) }; + }); + + // Eigen wachtwoord wijzigen - niet voor leerlingen + app.patch('/auth/password', { + config: { rateLimit: { max: 5, timeWindow: '15 minutes' } }, + }, async (req, reply) => { + need(req, reply, PERMISSIONS['password.own.change']); + const { currentPassword, password } = req.body ?? {}; + if (typeof currentPassword !== 'string' || typeof password !== 'string') return fail(reply, 400, 'ongeldig wachtwoord'); + if (!(await verifyPassword(currentPassword, req.user.password_hash))) return fail(reply, 401, 'huidig wachtwoord onjuist'); + const err = checkPassword(password, req.user.role); + if (err) return fail(reply, 400, err); + await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', + [await hashPassword(password), req.user.id]); + const token = sessionTokenFromRequest(req); + await pool.query('DELETE FROM sessions WHERE user_id = $1 AND token <> $2', + [req.user.id, token ? hashSessionToken(token) : '']); + return { ok: true }; + }); + + // ---- eigen app-data (borden, thema's, woorden) ------------------------------ + app.get('/me/data', async (req, reply) => { + need(req, reply); + const r = await pool.query('SELECT data, data_rev FROM users WHERE id = $1', [req.user.id]); + return { data: r.rows[0]?.data ?? {}, rev: Number(r.rows[0]?.data_rev ?? 0) }; + }); + + // ---- eigen personage (avatar) ----------------------------------------------- + // Open voor iedere ingelogde rol (ook leerling/ouder) - net als /me/data hierboven, + // geen rol-beperkte PERMISSIONS-sleutel nodig, altijd de eigen rij. + const AVATAR_SPECIES = ['dino', 'monster', 'animal']; + const AVATAR_VARIANTS = { + dino: ['rex', 'triceratops', 'stegosaurus', 'raptor'], + monster: ['horned', 'cyclops', 'slime', 'alien'], + animal: ['cat', 'dog', 'rabbit', 'bear'], + }; + const AVATAR_HAIR = ['none', 'tuft', 'spiky', 'curly', 'wavy', 'bob', 'ponytail', 'braids', 'mohawk']; + const AVATAR_HEAD_ACC = ['none', 'cap', 'beanie', 'crown', 'wizardhat', 'glasses', 'sunglasses', 'eyepatch', 'bow', 'flower', 'headphones', 'partyhat', 'star']; + const AVATAR_BODY_ACC = ['none', 'scarf', 'cape', 'badge', 'necklace', 'bowtie', 'belt', 'backpack', 'satchel', 'wings', 'medal']; + const AVATAR_REWARD_CATALOG = [ + { key: 'head:star', type: 'head', item: 'star', points: 1 }, + { key: 'body:medal', type: 'body', item: 'medal', points: 3 }, + { key: 'dance:robot', type: 'dance', item: 'robot', points: 6 }, + ]; + const avatarRewardKey = (type, item) => + AVATAR_REWARD_CATALOG.find((reward) => reward.type === type && reward.item === item)?.key || null; + const isHexColor = (v) => typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v); + app.get('/me/avatar-rewards', async (req, reply) => { + need(req, reply); + const points = Number((await pool.query( + 'SELECT reward_points FROM users WHERE id = $1', [req.user.id])).rows[0]?.reward_points || 0); + const rewards = (await pool.query( + 'SELECT reward_key FROM user_avatar_rewards WHERE user_id = $1 ORDER BY unlocked_at', + [req.user.id])).rows.map((row) => row.reward_key); + return { points, rewards, catalog: AVATAR_REWARD_CATALOG }; + }); + app.put('/me/avatar', async (req, reply) => { + need(req, reply); + const b = req.body ?? {}; + if (!AVATAR_SPECIES.includes(b.species)) return fail(reply, 400, 'ongeldige soort'); + const variantOptions = AVATAR_VARIANTS[b.species]; + const speciesVariant = b.speciesVariant ?? variantOptions[0]; + if (!variantOptions.includes(speciesVariant)) return fail(reply, 400, 'ongeldige soortvariant'); + if (!isHexColor(b.bodyColor)) return fail(reply, 400, 'ongeldige lichaamskleur'); + if (!AVATAR_HAIR.includes(b.hairStyle)) return fail(reply, 400, 'ongeldige haarstijl'); + if (!isHexColor(b.hairColor)) return fail(reply, 400, 'ongeldige haarkleur'); + if (!AVATAR_HEAD_ACC.includes(b.accessoryHead)) return fail(reply, 400, 'ongeldig hoofdaccessoire'); + if (!AVATAR_BODY_ACC.includes(b.accessoryBody)) return fail(reply, 400, 'ongeldig lichaamsaccessoire'); + const requiredRewards = [ + avatarRewardKey('head', b.accessoryHead), + avatarRewardKey('body', b.accessoryBody), + ].filter(Boolean); + if (requiredRewards.length) { + const owned = (await pool.query( + 'SELECT reward_key FROM user_avatar_rewards WHERE user_id = $1 AND reward_key = ANY($2::text[])', + [req.user.id, requiredRewards])).rows.map((row) => row.reward_key); + if (requiredRewards.some((key) => !owned.includes(key))) return fail(reply, 403, 'avatarbeloning nog niet vrijgespeeld'); + } + const avatar = { + species: b.species, speciesVariant, bodyColor: b.bodyColor, hairStyle: b.hairStyle, hairColor: b.hairColor, + accessoryHead: b.accessoryHead, accessoryBody: b.accessoryBody, + }; + await pool.query('UPDATE users SET avatar_data = $1 WHERE id = $2', [avatar, req.user.id]); + return { avatar }; + }); + // Optimistische concurrency: de client stuurt de rev mee die hij geladen + // heeft (x-data-rev); de update slaagt alleen als die nog actueel is, anders + // 409 (een ander tabblad/digibord schreef eerder). Zonder header geen check + // (oude clients blijven werken, laatste schrijver wint dan zoals voorheen). + const revFromRequest = (req) => { + const h = req.headers['x-data-rev']; + if (h === undefined) return null; + const n = Number(h); + return Number.isInteger(n) && n >= 0 ? n : null; + }; + // Versiegeschiedenis: vóór een overschrijving de oude data bewaren, maar + // hooguit één snapshot per 10 minuten (autosave draait elke 2,5s) en + // maximaal 10 versies per gebruiker. + const SNAPSHOT_MIN_AGE = "10 minutes"; + const SNAPSHOT_KEEP = 10; + const snapshotUserData = async (userId) => { + const fresh = (await pool.query( + `SELECT 1 FROM user_data_versions WHERE user_id = $1 AND created_at > now() - $2::interval LIMIT 1`, + [userId, SNAPSHOT_MIN_AGE])).rows[0]; + if (fresh) return; + await pool.query( + `INSERT INTO user_data_versions (user_id, data) + SELECT id, data FROM users WHERE id = $1 AND data IS NOT NULL AND data <> '{}'::jsonb`, + [userId]); + await pool.query( + `DELETE FROM user_data_versions WHERE user_id = $1 AND id NOT IN ( + SELECT id FROM user_data_versions WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 + )`, [userId, SNAPSHOT_KEEP]); + }; + const writeUserData = async (req, reply, data) => { + await snapshotUserData(req.user.id); + const rev = revFromRequest(req); + const r = rev === null + ? await pool.query( + 'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 RETURNING data_rev', + [data, req.user.id]) + : await pool.query( + 'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 AND data_rev = $3 RETURNING data_rev', + [data, req.user.id, rev]); + if (!r.rows[0]) return fail(reply, 409, 'in een ander tabblad bewerkt'); + return { ok: true, rev: Number(r.rows[0].data_rev) }; + }; + // schoolleerlingen hebben geen eigen borden-data; thuiskinderen (pupil + // zonder school, ouderportaal) wél - die mogen ook hun reservekopieën zien + const schoolPupil = (req) => req.user.role === 'pupil' && req.user.school_id != null; + app.get('/me/data/versions', async (req, reply) => { + need(req, reply); + if (schoolPupil(req)) return fail(reply, 403, 'geen rechten'); + const rows = (await pool.query( + 'SELECT id, created_at FROM user_data_versions WHERE user_id = $1 ORDER BY created_at DESC', + [req.user.id])).rows; + return { versions: rows.map((v) => ({ id: Number(v.id), createdAt: v.created_at })) }; + }); + app.post('/me/data/restore', async (req, reply) => { + need(req, reply); + if (schoolPupil(req)) return fail(reply, 403, 'geen rechten'); + const { versionId } = req.body ?? {}; + if (!Number.isInteger(versionId)) return fail(reply, 400, 'ongeldige versie'); + const v = (await pool.query( + 'SELECT data FROM user_data_versions WHERE id = $1 AND user_id = $2', + [versionId, req.user.id])).rows[0]; + if (!v) return fail(reply, 404, 'versie onbekend'); + // de huidige data eerst zelf als versie bewaren, zodat een herstel ook + // weer ongedaan gemaakt kan worden (zonder 10-minuten-drempel) + await pool.query( + `INSERT INTO user_data_versions (user_id, data) + SELECT id, data FROM users WHERE id = $1 AND data IS NOT NULL AND data <> '{}'::jsonb`, + [req.user.id]); + const r = await pool.query( + 'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 RETURNING data_rev', + [v.data, req.user.id]); + return { data: v.data, rev: Number(r.rows[0].data_rev) }; + }); + app.put('/me/data', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => { + need(req, reply); + const data = req.body ?? {}; + if (!data || typeof data !== 'object' || Array.isArray(data)) return fail(reply, 400, 'ongeldige borddata'); + if (Buffer.byteLength(JSON.stringify(data), 'utf8') > 20 * 1024 * 1024) return fail(reply, 413, 'borddata te groot'); + return writeUserData(req, reply, data); + }); + // Gesectioneerd opslaan: alleen de gewijzigde sub-key vervangen (jsonb_set) + // i.p.v. de hele blob versturen. Zelfde rev/409- en snapshot-gedrag als de + // volledige PUT hierboven, die blijft bestaan voor restore/eerste save. + const DATA_SECTIONS = { boards: 20, themes: 2, generalExtra: 2, anchors: 5 }; // cap in MB + app.put('/me/data/:section', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => { + need(req, reply); + const section = req.params.section; + if (!(section in DATA_SECTIONS)) return fail(reply, 404, 'onbekende sectie'); + const part = req.body ?? {}; + // anchors is een lijst, de overige secties zijn objecten + if (!part || typeof part !== 'object') return fail(reply, 400, 'ongeldige data'); + if (Array.isArray(part) !== (section === 'anchors')) return fail(reply, 400, 'ongeldige data'); + if (Buffer.byteLength(JSON.stringify(part), 'utf8') > DATA_SECTIONS[section] * 1024 * 1024) + return fail(reply, 413, 'data te groot'); + await snapshotUserData(req.user.id); + const rev = revFromRequest(req); + const set = `data = jsonb_set(coalesce(data, '{}'::jsonb), $1, $2::jsonb), data_rev = data_rev + 1, data_updated_at = now()`; + const r = rev === null + ? await pool.query(`UPDATE users SET ${set} WHERE id = $3 RETURNING data_rev`, + [`{${section}}`, JSON.stringify(part), req.user.id]) + : await pool.query(`UPDATE users SET ${set} WHERE id = $3 AND data_rev = $4 RETURNING data_rev`, + [`{${section}}`, JSON.stringify(part), req.user.id, rev]); + if (!r.rows[0]) return fail(reply, 409, 'in een ander tabblad bewerkt'); + return { ok: true, rev: Number(r.rows[0].data_rev) }; + }); + + // ---- scholen (super) --------------------------------------------------------- + app.post('/admin/schools', async (req, reply) => { + need(req, reply, PERMISSIONS['schools.manage']); + const { name } = req.body ?? {}; + if (!name || typeof name !== 'string' || name.trim().length > 80) return fail(reply, 400, 'ongeldige naam'); + const r = await pool.query('INSERT INTO schools (name) VALUES ($1) RETURNING id, name', [name.trim()]); + return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } }; + }); + app.patch('/admin/schools/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['schools.manage']); + const { name } = req.body ?? {}; + if (!name || typeof name !== 'string' || !name.trim() || name.trim().length > 80) return fail(reply, 400, 'ongeldige naam'); + const r = await pool.query('UPDATE schools SET name = $1 WHERE id = $2 RETURNING id, name', [name.trim(), req.params.id]); + if (!r.rows[0]) return fail(reply, 404, 'school onbekend'); + return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } }; + }); + app.delete('/admin/schools/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['schools.manage']); + await pool.query('DELETE FROM schools WHERE id = $1', [req.params.id]); + return { ok: true }; + }); + + // Multireset: alle leerlingwachtwoorden van een hele school in één keer + // vernieuwen. Alleen schoolbeheerder (eigen school) en systeemmanager. + app.post('/admin/schools/:id/reset-passwords', async (req, reply) => { + need(req, reply, PERMISSIONS['schools.resetPasswords']); + const schoolId = req.params.id; + if (!req.user.allRoles.includes('super') && Number(schoolId) !== Number(req.user.school_id)) + return fail(reply, 403, 'geen rechten'); + const pupils = (await pool.query( + "SELECT * FROM users WHERE school_id = $1 AND role = 'pupil' ORDER BY lower(username)", [schoolId])).rows; + const users = []; + for (const p of pupils) { + const pw = pupilPassword(); + await pool.query('UPDATE users SET password_hash = $1, last_login_at = NULL WHERE id = $2', + [await hashPassword(pw), p.id]); + await pool.query('DELETE FROM sessions WHERE user_id = $1', [p.id]); + users.push({ id: Number(p.id), username: p.username, displayName: p.display_name || p.username, password: pw }); + } + return { users }; + }); + + // ---- klassen ----------------------------------------------------------------- + app.get('/admin/classes', async (req, reply) => { + need(req, reply, PERMISSIONS['classes.view']); + const schoolId = req.user.allRoles.includes('super') ? req.query.school : req.user.school_id; + if (!schoolId) return { classes: [] }; + const r = await pool.query( + `SELECT c.id, c.name, + COALESCE(json_agg(ct.user_id) FILTER (WHERE ct.user_id IS NOT NULL), '[]') AS teacher_ids + FROM classes c LEFT JOIN class_teachers ct ON ct.class_id = c.id + WHERE c.school_id = $1 GROUP BY c.id ORDER BY c.name`, [schoolId]); + return { classes: r.rows.map((c) => ({ id: Number(c.id), name: c.name, teacherIds: c.teacher_ids.map(Number) })) }; + }); + // een klas naar een ANDERE school verplaatsen - alleen systeemmanager. + // Een klas hoort altijd bij een school (classes.school_id is NOT NULL); + // losmaken kan dus niet - er moet een geldige doelschool zijn. + app.patch('/admin/classes/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['schools.manage']); + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + const raw = req.body?.schoolId; + if (raw == null) return fail(reply, 400, 'school verplicht'); // een klas hoort altijd bij een school + const target = Number(raw); + if (!Number.isInteger(target) || target <= 0) return fail(reply, 400, 'ongeldige school'); + const school = (await pool.query('SELECT 1 FROM schools WHERE id = $1', [target])).rows[0]; + if (!school) return fail(reply, 404, 'school onbekend'); + await pool.query('UPDATE classes SET school_id = $1 WHERE id = $2', [target, c.id]); + return { ok: true }; + }); + app.post('/admin/classes', async (req, reply) => { + need(req, reply, PERMISSIONS['classes.manage']); + const { name, school } = req.body ?? {}; + const schoolId = req.user.allRoles.includes('super') ? school : req.user.school_id; + if (!name || typeof name !== 'string' || name.trim().length > 80 || !schoolId) return fail(reply, 400, 'ongeldige naam of school'); + try { + const r = await pool.query('INSERT INTO classes (school_id, name) VALUES ($1,$2) RETURNING id, name', [schoolId, name.trim()]); + return { class: { id: Number(r.rows[0].id), name: r.rows[0].name } }; + } catch (e) { + if (e.code === '23505') return fail(reply, 409, 'klasnaam bestaat al binnen deze school'); + throw e; + } + }); + app.delete('/admin/classes/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['classes.manage']); + // Zelfde reden als bij het verwijderen van een gebruiker: alles in één + // try/catch, zodat een mislukking de echte oorzaak teruggeeft i.p.v. terug + // te vallen op de kale "serverfout" van de generieke handler. + try { + const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]); + if (!r.rows[0]) return fail(reply, 404, 'klas onbekend'); + if (!req.user.allRoles.includes('super') && Number(r.rows[0].school_id) !== Number(req.user.school_id)) + return fail(reply, 403, 'geen rechten'); + await pool.query('DELETE FROM classes WHERE id = $1', [req.params.id]); + return { ok: true }; + } catch (e) { + req.log.error({ err: e }, 'verwijderen klas mislukt'); + return fail(reply, 409, `verwijderen mislukt: ${e.detail || e.message}`); + } + }); + // groepsleiding aan klas koppelen/loskoppelen + // Elke staf-rol (groepsleiding, schoolbeheerder, systeemmanager) kan aan + // een klas gekoppeld worden; alleen leerlingen niet (die horen al bij één + // klas via users.class_id). Een systeemmanager is school-onafhankelijk en + // mag dus aan klassen van elke school gekoppeld worden. + app.post('/admin/classes/:id/teachers', async (req, reply) => { + need(req, reply, PERMISSIONS['classes.manage']); + const { userId, remove } = req.body ?? {}; + if (!userId) return fail(reply, 400, 'gebruiker verplicht'); + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [userId])).rows[0]; + if (!c || !u) return fail(reply, 404, 'klas of gebruiker onbekend'); + if (!req.user.allRoles.includes('super') && Number(c.school_id) !== Number(req.user.school_id)) + return fail(reply, 403, 'geen rechten'); + if (!['teacher', 'admin', 'super'].includes(u.role)) return fail(reply, 400, 'alleen staf kan aan een klas gekoppeld worden'); + if (u.role !== 'super' && Number(c.school_id) !== Number(u.school_id)) + return fail(reply, 400, 'klas en gebruiker moeten bij dezelfde school horen'); + if (remove) await pool.query('DELETE FROM class_teachers WHERE class_id = $1 AND user_id = $2', [c.id, u.id]); + else await pool.query('INSERT INTO class_teachers (class_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [c.id, u.id]); + return { ok: true }; + }); + + // Multireset: alle leerlingwachtwoorden van één klas in één keer vernieuwen. + // Alleen schoolbeheerder (eigen school) en systeemmanager - niet + // de groepsleiding, om per ongeluk een hele klas resetten te voorkomen. + app.post('/admin/classes/:id/reset-passwords', async (req, reply) => { + need(req, reply, PERMISSIONS['classes.manage']); + const r = await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id]); + const c = r.rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!req.user.allRoles.includes('super') && Number(c.school_id) !== Number(req.user.school_id)) + return fail(reply, 403, 'geen rechten'); + const pupils = (await pool.query( + "SELECT * FROM users WHERE class_id = $1 AND role = 'pupil' ORDER BY lower(username)", [c.id])).rows; + const users = []; + for (const p of pupils) { + const pw = pupilPassword(); + await pool.query('UPDATE users SET password_hash = $1, last_login_at = NULL WHERE id = $2', + [await hashPassword(pw), p.id]); + await pool.query('DELETE FROM sessions WHERE user_id = $1', [p.id]); + users.push({ id: Number(p.id), username: p.username, displayName: p.display_name || p.username, password: pw }); + } + return { users }; + }); + + // Genereer een voorstel-wachtwoord (niet opgeslagen) voor in het aanmaakformulier. + app.get('/admin/genpw', async (req, reply) => { + need(req, reply, PERMISSIONS['users.genpw']); + const role = req.query.role === 'pupil' ? 'pupil' : 'staff'; + return { password: role === 'pupil' ? pupilPassword() : staffPassword() }; + }); + + // ---- gebruikers --------------------------------------------------------------- + // Lijst: super ziet alles (optioneel per school), admin de eigen school, + // teacher de eigen school maar leerlingen alleen uit de eigen klas(sen). + app.get('/admin/users', async (req, reply) => { + need(req, reply, PERMISSIONS['users.manage']); + // Ouders hebben school_id NULL en vallen daardoor buiten elke schoollijst; + // een school ziet wél de ouders die aan haar eigen leerlingen gekoppeld zijn. + // Postgres eist bij SELECT DISTINCT dat elke ORDER BY-expressie letterlijk + // in de select-lijst staat - lower(p.username) alleen via p.* volstond niet + // (gaf "for SELECT DISTINCT, ORDER BY expressions must appear in select + // list"), dus expliciet als extra kolom toevoegen. + const linkedParents = async (schoolId, extra = '', params = []) => (await pool.query( + `SELECT DISTINCT p.*, lower(p.username) AS sort_key FROM users p + JOIN parent_children pc ON pc.parent_id = p.id + JOIN users k ON k.id = pc.pupil_id + WHERE k.school_id = $1 ${extra} ORDER BY sort_key`, + [schoolId, ...params])).rows; + let rows; + if (req.user.allRoles.includes('super')) { + rows = (req.query.school + ? await pool.query('SELECT * FROM users WHERE school_id = $1 ORDER BY role, lower(username)', [req.query.school]) + : await pool.query('SELECT * FROM users ORDER BY school_id NULLS FIRST, role, lower(username)')).rows; + if (req.query.school) rows = rows.concat(await linkedParents(req.query.school)); + } else if (teacherOnly(req)) { + rows = (await pool.query( + `SELECT * FROM users + WHERE school_id = $1 + AND (role != 'pupil' OR class_id IN (SELECT class_id FROM class_teachers WHERE user_id = $2)) + ORDER BY role, lower(username)`, + [req.user.school_id, req.user.id])).rows; + rows = rows.concat(await linkedParents(req.user.school_id, + 'AND k.class_id IN (SELECT class_id FROM class_teachers WHERE user_id = $2)', [req.user.id])); + } else { + rows = (await pool.query('SELECT * FROM users WHERE school_id = $1 ORDER BY role, lower(username)', [req.user.school_id])).rows; + rows = rows.concat(await linkedParents(req.user.school_id)); + } + // extra stafrollen per gebruiker erbij ophalen (in één keer, niet per rij) + const roleRows = rows.length + ? (await pool.query('SELECT user_id, role FROM user_roles WHERE user_id = ANY($1)', [rows.map((u) => u.id)])).rows + : []; + const extraByUser = new Map(); + roleRows.forEach((r) => { + const key = String(r.user_id); + extraByUser.set(key, [...(extraByUser.get(key) || []), r.role]); + }); + // klas-koppelingen van groepsleiding (class_teachers), ook in één keer + const ctRows = rows.length + ? (await pool.query('SELECT user_id, class_id FROM class_teachers WHERE user_id = ANY($1)', [rows.map((u) => u.id)])).rows + : []; + const classesByUser = new Map(); + ctRows.forEach((r) => { + const key = String(r.user_id); + classesByUser.set(key, [...(classesByUser.get(key) || []), Number(r.class_id)]); + }); + // kindernamen bij ouder-rijen, zodat de school ziet wie bij wie hoort + const parentIds = rows.filter((u) => u.role === 'parent').map((u) => u.id); + const kidRows = parentIds.length + ? (await pool.query( + `SELECT pc.parent_id, k.display_name, k.username FROM parent_children pc + JOIN users k ON k.id = pc.pupil_id WHERE pc.parent_id = ANY($1)`, [parentIds])).rows + : []; + const kidsByParent = new Map(); + kidRows.forEach((r) => { + const key = String(r.parent_id); + kidsByParent.set(key, [...(kidsByParent.get(key) || []), r.display_name || r.username]); + }); + return { users: rows.map((u) => ({ + ...publicUser({ ...u, extra_roles: extraByUser.get(String(u.id)) || [] }), + linkCode: u.link_code || null, + everLoggedIn: !!u.last_login_at, + classIds: classesByUser.get(String(u.id)) || [], + ...(u.role === 'parent' ? { childNames: kidsByParent.get(String(u.id)) || [] } : {}), + })) }; + }); + + // Aanmaken. super: alles (ook supers). admin: admin/teacher/pupil in eigen school. + // teacher: alleen leerlingen in de eigen school. + app.post('/admin/users', async (req, reply) => { + need(req, reply, PERMISSIONS['users.manage']); + const b = req.body ?? {}; + const role = b.role; + if (!creatableRoles(req).includes(role)) return fail(reply, 403, 'geen rechten voor deze rol'); + // Extra veiligheidsstap voor de meest ingrijpende actie in dit beheer: een + // nieuwe systeemmanager aanmaken vereist het eigen wachtwoord van de + // aanmaker, ter bevestiging - los van (en aanvullend op) creatableRoles(), + // die al bepaalt dát alleen een systeemmanager dit mag proberen. + if (role === 'super') { + const ok = await verifyPassword(b.confirmPassword, req.user.password_hash); + if (!ok) return fail(reply, 403, 'eigen wachtwoord onjuist'); + } + const schoolId = req.user.allRoles.includes('super') ? (role === 'super' ? null : b.school) : req.user.school_id; + if (role !== 'super' && !schoolId) return fail(reply, 400, 'school verplicht'); + const username = (b.username || '').trim(); + if (!/^[a-zA-Z0-9_.-]{2,30}$/.test(username)) return fail(reply, 400, 'ongeldige gebruikersnaam'); + try { + if (role === 'pupil') { + if (b.classId != null) { + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [b.classId])).rows[0]; + if (!c || Number(c.school_id) !== Number(schoolId)) return fail(reply, 400, 'klas hoort niet bij deze school'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten voor deze klas'); + } + const pw = b.password || pupilPassword(); + const err = checkPassword(pw, 'pupil'); + if (err) return fail(reply, 400, err); + const r = await pool.query( + `INSERT INTO users (username, role, school_id, class_id, display_name, password_hash) + VALUES ($1,'pupil',$2,$3,$4,$5) RETURNING *`, + [username, schoolId, b.classId ?? null, b.displayName || username, await hashPassword(pw)]); + return { user: { ...publicUser(r.rows[0]), password: pw } }; + } + // groepsleiding/beheer: ofwel meteen een wachtwoord (zelf gekozen of + // gegenereerd), ofwel de klassieke koppelcode om zelf te activeren. + if (b.password || b.generatePassword) { + const pw = b.password || staffPassword(); + const err = checkPassword(pw, role); + if (err) return fail(reply, 400, err); + const r = await pool.query( + `INSERT INTO users (username, role, school_id, display_name, password_hash) + VALUES ($1,$2,$3,$4,$5) RETURNING *`, + [username, role, schoolId, b.displayName || username, await hashPassword(pw)]); + return { user: { ...publicUser(r.rows[0]), password: pw } }; + } + const code = newLinkCode(); + const r = await pool.query( + `INSERT INTO users (username, role, school_id, display_name, link_code) + VALUES ($1,$2,$3,$4,$5) RETURNING *`, + [username, role, schoolId, b.displayName || username, code]); + return { user: { ...publicUser(r.rows[0]), linkCode: code } }; + } catch (e) { + if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al'); + throw e; + } + }); + + // Aanpassen: naam, klas (uitwisselen), leerlingwachtwoord, nieuwe koppelcode, rol (super). + app.patch('/admin/users/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['users.manage']); + const r = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]); + const u = r.rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && u.role !== 'pupil') return fail(reply, 403, 'groepsleiding beheert alleen leerlingen'); + if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id)))) + return fail(reply, 403, 'groepsleiding beheert alleen leerlingen uit eigen klassen'); + if (u.role === 'super' && !req.user.allRoles.includes('super')) return fail(reply, 403, 'geen rechten'); + const b = req.body ?? {}; + if (b.displayName) { + if (typeof b.displayName !== 'string' || b.displayName.trim().length > 80) return fail(reply, 400, 'ongeldige weergavenaam'); + await pool.query('UPDATE users SET display_name = $1 WHERE id = $2', [b.displayName.trim(), u.id]); + } + if (b.classId !== undefined && u.role === 'pupil') { + if (b.classId !== null) { + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [b.classId])).rows[0]; + if (!c || Number(c.school_id) !== Number(u.school_id)) return fail(reply, 400, 'klas hoort niet bij deze school'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten voor deze klas'); + } + await pool.query('UPDATE users SET class_id = $1 WHERE id = $2', [b.classId, u.id]); + } + if (b.password && u.role === 'pupil') { + const err = checkPassword(b.password, 'pupil'); + if (err) return fail(reply, 400, err); + await pool.query('UPDATE users SET password_hash = $1, last_login_at = NULL WHERE id = $2', + [await hashPassword(b.password), u.id]); + await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]); + } + // groepsleiding/beheer: direct een nieuw wachtwoord zetten (zelf gekozen + // of gegenereerd) zonder de koppelcode-activatie opnieuw te doorlopen. + if ((b.password || b.generatePassword) && u.role !== 'pupil' && can(req.user.allRoles, 'users.staffCredentials')) { + const pw = b.password || staffPassword(); + const err = checkPassword(pw, u.role); + if (err) return fail(reply, 400, err); + await pool.query('UPDATE users SET password_hash = $1, link_code = NULL WHERE id = $2', [await hashPassword(pw), u.id]); + await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]); + return { ok: true, password: pw }; + } + if (b.newLinkCode && u.role !== 'pupil' && can(req.user.allRoles, 'users.staffCredentials')) { + const code = newLinkCode(); + await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]); + await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]); + return { ok: true, linkCode: code }; + } + // school koppelen/losmaken (alleen systeemmanager): losse accounts kunnen + // teruggezet worden naar een school en bewust losgemaakt. Super en parent + // zijn per definitie schoolloos - daarvoor is dit een stille no-op. + if (b.schoolId !== undefined && req.user.allRoles.includes('super') + && ['teacher', 'admin', 'pupil'].includes(u.role)) { + const target = b.schoolId === null ? null : Number(b.schoolId); + if (target !== null) { + const school = (await pool.query('SELECT 1 FROM schools WHERE id = $1', [target])).rows[0]; + if (!school) return fail(reply, 404, 'school onbekend'); + } + if (target !== (u.school_id == null ? null : Number(u.school_id))) { + // klasindeling behouden als de huidige klas al bij de doelschool hoort + // (herstelvolgorde: eerst de klas terugkoppelen, dan de leerlingen) + let keepClass = null; + if (u.class_id != null && target !== null) { + const cls = (await pool.query('SELECT school_id FROM classes WHERE id = $1', [u.class_id])).rows[0]; + if (cls && Number(cls.school_id) === target) keepClass = u.class_id; + } + try { + await pool.query('UPDATE users SET school_id = $1, class_id = $2 WHERE id = $3', [target, keepClass, u.id]); + } catch (e) { + // naam kan botsen in de doelschool óf (bij losmaken) in de + // school-loze naamruimte (systeemmanagers/ouders/thuiskinderen) + if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al'); + throw e; + } + await pool.query('DELETE FROM class_teachers WHERE user_id = $1', [u.id]); + // een oude individuele toewijzing verwijst naar een bord van de vorige school + if (u.role === 'pupil') await pool.query('DELETE FROM assignments WHERE pupil_id = $1', [u.id]); + } + } + // rolwijziging alleen tussen staf-rollen onderling: leerlingen blijven + // leerling en ouder-accounts kunnen nooit naar een beheerrol promoveren. + // 'super' is hier bewust GEEN geldig doel: een systeemmanager aanmaken kan + // alleen via POST /admin/users, met het eigen-wachtwoord-bevestigingsstap - + // niet door een bestaande schoolbeheerder/groepsleiding via deze + // eenvoudige rolwijziging te promoveren. Demoteren van een bestaande + // systeemmanager naar admin/teacher blijft wel mogelijk. + if (b.role && can(req.user.allRoles, 'users.role.change') + && ['admin', 'teacher'].includes(b.role) + && ['super', 'admin', 'teacher'].includes(u.role)) + await pool.query('UPDATE users SET role = $1 WHERE id = $2', [b.role, u.id]); + // extra functies naast de hoofdrol (bv. een teacher die ook admin-rechten krijgt) - + // alleen voor staf, zelfde recht als hoofdrol-wijziging; hoofdrol zelf telt niet + // ook nog als "extra". Ook hier is 'super' bewust geen toegestane extra rol, + // om dezelfde reden als hierboven. + if (b.extraRoles !== undefined && can(req.user.allRoles, 'users.role.change') && u.role !== 'pupil') { + const extra = [...new Set(b.extraRoles)].filter((r) => ['admin', 'teacher'].includes(r) && r !== u.role); + await pool.query('DELETE FROM user_roles WHERE user_id = $1', [u.id]); + for (const role of extra) { + await pool.query('INSERT INTO user_roles (user_id, role) VALUES ($1, $2) ON CONFLICT DO NOTHING', [u.id, role]); + } + return { ok: true, extraRoles: extra }; + } + return { ok: true }; + }); + + // Een gebruiker en al z'n afhankelijke gegevens in één transactie verwijderen. + // We rekenen NIET op ON DELETE CASCADE: sommige (oudere) databases hebben een + // foreign key die dat mist, waardoor DELETE FROM users een 500 "serverfout" + // geeft. Door de afhankelijke rijen eerst zelf te verwijderen werkt het + // ongeacht de exacte FK-instelling. Migratie 015 herstelt de FK's daarnaast + // structureel; dit is het vangnet dat sowieso werkt. + const deleteUserFully = async (userId) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + // bewuste SET NULL: wie iets in het CMS wijzigde blijft die inhoud houden + await client.query('UPDATE site_content SET updated_by = NULL WHERE updated_by = $1', [userId]); + // eerst de klas/school van de gebruiker zelf lossnijden zodat triggers op + // eventuele resten niet in de weg zitten + const dependents = [ + 'DELETE FROM sessions WHERE user_id = $1', + 'DELETE FROM user_roles WHERE user_id = $1', + 'DELETE FROM class_teachers WHERE user_id = $1', + 'DELETE FROM assignments WHERE pupil_id = $1 OR teacher_id = $1', + 'DELETE FROM progress_events WHERE pupil_id = $1 OR teacher_id = $1', + 'DELETE FROM parent_children WHERE parent_id = $1 OR pupil_id = $1', + 'DELETE FROM school_link_requests WHERE parent_id = $1 OR pupil_id = $1', + 'DELETE FROM user_data_versions WHERE user_id = $1', + 'DELETE FROM shared_library WHERE owner_id = $1', + 'DELETE FROM image_assets WHERE owner_id = $1', + 'DELETE FROM image_folders WHERE owner_id = $1', + 'DELETE FROM image_themes WHERE owner_id = $1', + 'DELETE FROM boards WHERE owner_id = $1', + 'DELETE FROM folders WHERE owner_id = $1', + ]; + for (const sql of dependents) await client.query(sql, [userId]); + await client.query('DELETE FROM users WHERE id = $1', [userId]); + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK').catch(() => {}); + throw e; + } finally { + client.release(); + } + }; + + app.delete('/admin/users/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['users.manage']); + // Alles vanaf hier (ook de opzoek- en rechtencontroles, niet alleen de + // verwijdering zelf) in één try/catch: anders valt een fout die vóór + // deleteUserFully() optreedt (bv. een kapotte query) terug op de kale + // "serverfout" van de generieke handler i.p.v. een zichtbare reden. + try { + const r = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]); + const u = r.rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (Number(u.id) === Number(req.user.id)) return fail(reply, 400, 'je kunt jezelf niet verwijderen'); + if (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && u.role !== 'pupil') return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id)))) + return fail(reply, 403, 'groepsleiding beheert alleen leerlingen uit eigen klassen'); + if (u.role === 'super' && !req.user.allRoles.includes('super')) return fail(reply, 403, 'geen rechten'); + await deleteUserFully(u.id); + return { ok: true }; + } catch (e) { + // Zou niet meer moeten voorkomen (alle afhankelijke rijen worden in + // deleteUserFully al weggehaald), maar als het tóch faalt geven we de + // echte reden mee i.p.v. een kale "serverfout", zodat de oorzaak zichtbaar is. + req.log.error({ err: e }, 'verwijderen gebruiker mislukt'); + return fail(reply, 409, `verwijderen mislukt: ${e.detail || e.message}`); + } + }); + + // ---- toewijzingen (leerling-omgeving) ------------------------------------------ + // Een leerkracht koppelt een eigen bord (via het stabiele board-id) aan een klas + // (standaard voor iedereen erin) of een individuele leerling (uitzondering, voor + // wie op een ander niveau werkt). Leerling-eigen koppeling wint van de klas- + // koppeling. Geen momentopname: het bord wordt live opgezocht in de data van de + // leerkracht, dus nieuwe woorden die de leerkracht toevoegt komen vanzelf door. + const classOwnedByTeacher = async (classId, userId) => + !!(await pool.query('SELECT 1 FROM class_teachers WHERE class_id = $1 AND user_id = $2', [classId, userId])).rows[0]; + const findBoardById = (data, boardId) => { + const folders = data?.boards?.folders || []; + for (const fo of folders) { + const bo = (fo.boards || []).find((b) => b.id === boardId); + if (bo) return bo; + } + return null; + }; + const describeAssignment = async (a) => { + const t = (await pool.query('SELECT display_name, username, data FROM users WHERE id = $1', [a.teacher_id])).rows[0]; + const board = t ? findBoardById(t.data, a.board_id) : null; + const widget = a.widget_id ? (board?.data?.widgets || []).find((w) => w.wid === a.widget_id) : null; + const sequence = Array.isArray(a.sequence) ? a.sequence.map((step) => { + if (step.source === 'library' && step.widgetType) { + return { + ...step, + boardName: null, + themeName: step.theme?.name || null, + }; + } + const stepBoard = t ? findBoardById(t.data, step.boardId) : null; + const stepWidget = (stepBoard?.data?.widgets || []).find((w) => w.wid === step.widgetId); + return { ...step, boardName: stepBoard?.name || null, widgetType: stepWidget?.id || null }; + }) : []; + return { + teacherId: Number(a.teacher_id), + teacherName: t ? (t.display_name || t.username) : null, + boardId: a.board_id, + boardName: board ? board.name : null, + widgetId: a.widget_id || null, + widgetType: widget ? widget.id : null, + mode: a.mode || 'werken', + title: a.title || '', + sequence, + updatedAt: a.updated_at, + }; + }; + // Gedeelde validatie voor beide PUT-routes: heel bord (widgetId null, bestaand + // gedrag) of één widget van dat bord, met modus kijken/werken. De widget moet + // echt in het opgegeven bord van de leerkracht zelf staan. + const parseShareBody = (req, reply) => { + const { boardId, widgetId, mode } = req.body ?? {}; + if (!boardId || typeof boardId !== 'string' || boardId.length > 100) return fail(reply, 400, 'ongeldige boardId'); + const board = findBoardById(req.user.data, boardId); + if (!board) return fail(reply, 400, 'bord onbekend of niet van deze gebruiker'); + const m = mode ?? 'werken'; + if (m !== 'kijken' && m !== 'werken') return fail(reply, 400, 'ongeldige modus'); + if (widgetId != null) { + if (typeof widgetId !== 'string' || widgetId.length > 100) return fail(reply, 400, 'ongeldige widgetId'); + if (!(board.data?.widgets || []).some((w) => w.wid === widgetId)) return fail(reply, 400, 'widget onbekend op dit bord'); + } + return { boardId, widgetId: widgetId ?? null, mode: m }; + }; + const PUPIL_ASSIGNMENT_WIDGETS = new Set([ + 'anchor', 'letters', 'flits', 'hak', 'ball', 'sentence', 'wordtypes', + 'madd', 'msub', 'mmul', 'mdiv', 'mpct', 'numberline', 'fractions', 'clock', 'placevalue', 'money', + ]); + const ASSIGNMENT_LIBRARY_BOARD = 'assignment-library'; + const THEMED_ASSIGNMENT_WIDGETS = new Set(['letters', 'flits', 'hak', 'ball']); + // Widgets die een echt afgerond moment loggen; alleen dan kan een stap in een + // reeks op bewijs wachten. flits/hak kwamen erbij toen ze een zelfcheck + // ("lukte het zelf?") kregen - daarvoor completeerden die stappen meteen, + // omdat er geen enkel voortgangsevent kón bestaan. + const PROGRESS_ASSIGNMENT_WIDGETS = new Set(['letters', 'ball', 'flits', 'hak', 'madd', 'msub', 'mmul', 'mdiv', 'mpct']); + const parseAssignmentTheme = (rawTheme, widgetType, index, reply) => { + if (rawTheme == null) return { theme: null }; + if (!THEMED_ASSIGNMENT_WIDGETS.has(widgetType)) + return fail(reply, 400, `thema is niet beschikbaar voor stap ${index + 1}`); + if (!rawTheme || typeof rawTheme !== 'object' || Array.isArray(rawTheme)) + return fail(reply, 400, `ongeldig thema in stap ${index + 1}`); + const id = typeof rawTheme.id === 'string' ? rawTheme.id.trim() : ''; + const name = typeof rawTheme.name === 'string' ? rawTheme.name.trim() : ''; + if (!id || id.length > 80 || !name || name.length > 80 || !Array.isArray(rawTheme.words) + || !rawTheme.words.length || rawTheme.words.length > 120) + return fail(reply, 400, `ongeldig thema in stap ${index + 1}`); + const words = []; + for (const rawWord of rawTheme.words) { + if (!Array.isArray(rawWord) || typeof rawWord[0] !== 'string') { + return fail(reply, 400, `ongeldig themawoord in stap ${index + 1}`); + } + const word = rawWord[0].trim(); + const picture = rawWord[1] == null ? '' : String(rawWord[1]); + const yearGroup = rawWord[2] == null ? null : Number(rawWord[2]); + const anchor = rawWord[3] == null ? null : String(rawWord[3]).trim(); + if (!word || word.length > 80 || picture.length > 20_000 + || (yearGroup != null && (!Number.isInteger(yearGroup) || yearGroup < 1 || yearGroup > 8)) + || (anchor != null && anchor.length > 80)) { + return fail(reply, 400, `ongeldig themawoord in stap ${index + 1}`); + } + words.push([word, picture, yearGroup, anchor]); + } + const theme = { id, name, words }; + if (JSON.stringify(theme).length > 160_000) + return fail(reply, 400, `thema in stap ${index + 1} is te groot`); + return { theme }; + }; + const parseAssignmentBody = (req, reply) => { + const raw = req.body ?? {}; + if (!Array.isArray(raw.sequence) || raw.sequence.length === 0) { + const share = parseShareBody(req, reply); + if (share.error) return share; + return { ...share, title: '', sequence: [] }; + } + if (raw.sequence.length > 12) return fail(reply, 400, 'een opdrachtenreeks heeft maximaal 12 stappen'); + const title = typeof raw.title === 'string' ? raw.title.trim() : ''; + if (!title || title.length > 80) return fail(reply, 400, 'geef de opdrachtenreeks een titel van maximaal 80 tekens'); + const sequence = []; + for (let index = 0; index < raw.sequence.length; index += 1) { + const item = raw.sequence[index] ?? {}; + const mode = item.mode ?? 'werken'; + if (mode !== 'kijken' && mode !== 'werken') return fail(reply, 400, `ongeldige modus in stap ${index + 1}`); + if (item.widgetType != null) { + if (typeof item.widgetType !== 'string' || !PUPIL_ASSIGNMENT_WIDGETS.has(item.widgetType)) + return fail(reply, 400, `widget van stap ${index + 1} is niet geschikt voor de leerlingomgeving`); + const parsedTheme = parseAssignmentTheme(item.theme, item.widgetType, index, reply); + if (parsedTheme.error) return parsedTheme; + sequence.push({ + id: `step-${index + 1}`, + source: 'library', + boardId: ASSIGNMENT_LIBRARY_BOARD, + widgetId: `assignment-step-${index + 1}`, + widgetType: item.widgetType, + mode, + requiresProgress: PROGRESS_ASSIGNMENT_WIDGETS.has(item.widgetType), + theme: parsedTheme.theme, + }); + continue; + } + const boardId = item.boardId || raw.boardId; + const board = typeof boardId === 'string' ? findBoardById(req.user.data, boardId) : null; + if (!board) return fail(reply, 400, `bord van stap ${index + 1} onbekend`); + if (!item.widgetId || typeof item.widgetId !== 'string' || item.widgetId.length > 100) + return fail(reply, 400, `ongeldige widget in stap ${index + 1}`); + const widget = (board.data?.widgets || []).find((candidate) => candidate.wid === item.widgetId); + if (!widget) return fail(reply, 400, `widget van stap ${index + 1} staat niet op dit bord`); + if (!PUPIL_ASSIGNMENT_WIDGETS.has(widget.id)) + return fail(reply, 400, `widget van stap ${index + 1} is niet geschikt voor de leerlingomgeving`); + sequence.push({ id: `step-${index + 1}`, boardId, widgetId: item.widgetId, mode }); + } + if (JSON.stringify(sequence).length > 512_000) + return fail(reply, 400, 'opdrachtenreeks is te groot'); + const first = sequence[0]; + return { boardId: first.boardId, widgetId: first.widgetId, mode: first.mode, title, sequence }; + }; + const saveAssignment = async (targetColumn, targetId, teacherId, share) => { + const row = (await pool.query( + `INSERT INTO assignments (${targetColumn}, teacher_id, board_id, widget_id, mode, title, sequence) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (${targetColumn}) WHERE ${targetColumn} IS NOT NULL + DO UPDATE SET teacher_id = $2, board_id = $3, widget_id = $4, mode = $5, + title = $6, sequence = $7, updated_at = now() + RETURNING id`, + [targetId, teacherId, share.boardId, share.widgetId, share.mode, share.title, JSON.stringify(share.sequence)])).rows[0]; + if (row?.id) await pool.query('DELETE FROM assignment_step_progress WHERE assignment_id = $1', [row.id]); + }; + + app.get('/assignments/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + const a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [c.id])).rows[0]; + return { assignment: a ? await describeAssignment(a) : null }; + }); + app.put('/assignments/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const share = parseAssignmentBody(req, reply); + if (share.error) return share; + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + await saveAssignment('class_id', c.id, req.user.id, share); + return { ok: true }; + }); + app.delete('/assignments/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + await pool.query('DELETE FROM assignments WHERE class_id = $1', [c.id]); + return { ok: true }; + }); + + const pupilAccessible = async (req, u) => { + if (!u || u.role !== 'pupil' || !sameSchool(req, u)) return false; + if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id)))) return false; + return true; + }; + app.get('/assignments/pupil/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + const a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [u.id])).rows[0]; + return { assignment: a ? await describeAssignment(a) : null }; + }); + app.put('/assignments/pupil/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const share = parseAssignmentBody(req, reply); + if (share.error) return share; + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + await saveAssignment('pupil_id', u.id, req.user.id, share); + return { ok: true }; + }); + app.delete('/assignments/pupil/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + await pool.query('DELETE FROM assignments WHERE pupil_id = $1', [u.id]); + return { ok: true }; + }); + + // Leerling-kant: de effectieve toewijzing (eigen override, anders de klas). + // Bij een reeks wordt uitsluitend de eerste nog niet afgeronde stap live uit + // het bord van de leerkracht geladen; losse toewijzingen blijven ongewijzigd. + const effectiveAssignment = async (user) => { + let assignment = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [user.id])).rows[0]; + if (!assignment && user.class_id) { + assignment = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [user.class_id])).rows[0]; + } + return assignment || null; + }; + const assignmentWidgets = async (assignment, boardId, widgetId, step = null) => { + if (step?.source === 'library' && step.widgetType) { + const state = step.theme ? { + assignmentThemeId: step.theme.id, + assignmentThemeName: step.theme.name, + assignmentWords: step.theme.words, + } : {}; + return [{ id: step.widgetType, wid: step.widgetId, state }]; + } + const teacher = (await pool.query('SELECT data FROM users WHERE id = $1', [assignment.teacher_id])).rows[0]; + const board = teacher ? findBoardById(teacher.data, boardId) : null; + let widgets = (board?.data?.widgets || []).map((widget) => ({ id: widget.id, wid: widget.wid, state: widget.state })); + if (widgetId) widgets = widgets.filter((widget) => widget.wid === widgetId); + return widgets; + }; + const liveSessions = registerLiveSessions(app, { + pool, need, fail, progressRoles: PERMISSIONS['progress.view'], + sameSchool, teacherOnly, classOwnedByTeacher, effectiveAssignment, assignmentWidgets, + }); + + app.get('/my/assignment', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + const assignment = await effectiveAssignment(req.user); + if (!assignment) return { widgets: [], mode: 'werken', updatedAt: null, sequence: null }; + const steps = Array.isArray(assignment.sequence) ? assignment.sequence : []; + if (!steps.length) { + return { + widgets: await assignmentWidgets(assignment, assignment.board_id, assignment.widget_id), + mode: assignment.mode || 'werken', updatedAt: assignment.updated_at, sequence: null, + }; + } + const completedRows = (await pool.query( + 'SELECT step_id, completed_at FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2', + [assignment.id, req.user.id])).rows; + const completedIds = new Set(completedRows.map((row) => row.step_id)); + const currentIndex = steps.findIndex((step) => !completedIds.has(step.id)); + const current = currentIndex >= 0 ? steps[currentIndex] : null; + const previous = currentIndex > 0 ? steps[currentIndex - 1] : null; + const readySince = previous + ? (completedRows.find((row) => row.step_id === previous.id)?.completed_at || assignment.updated_at) + : assignment.updated_at; + const ready = !current || current.mode === 'kijken' || current.requiresProgress === false || !!(await pool.query( + `SELECT 1 FROM progress_events + WHERE pupil_id = $1 AND widget_id = $2 AND created_at >= $3 LIMIT 1`, + [req.user.id, current.widgetId, readySince])).rows[0]; + return { + widgets: current ? await assignmentWidgets(assignment, current.boardId, current.widgetId, current) : [], + mode: current?.mode || 'werken', + updatedAt: assignment.updated_at, + sequence: { + title: assignment.title || '', + steps: steps.map((step) => { + const described = { id: step.id, mode: step.mode, completed: completedIds.has(step.id) }; + if (step.source === 'library' && step.widgetType) { + described.widgetType = step.widgetType; + described.themeName = step.theme?.name || null; + } + return described; + }), + currentIndex: currentIndex < 0 ? steps.length : currentIndex, + completed: completedIds.size, + total: steps.length, + done: currentIndex < 0, + ready, + }, + }; + }); + + app.post('/my/assignment/steps/:stepId/complete', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + const assignment = await effectiveAssignment(req.user); + const steps = Array.isArray(assignment?.sequence) ? assignment.sequence : []; + if (!assignment || !steps.length) return fail(reply, 400, 'geen actieve opdrachtenreeks'); + const completedRows = (await pool.query( + 'SELECT step_id, completed_at FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2', + [assignment.id, req.user.id])).rows; + const completedIds = new Set(completedRows.map((row) => row.step_id)); + const currentIndex = steps.findIndex((step) => !completedIds.has(step.id)); + const current = currentIndex >= 0 ? steps[currentIndex] : null; + if (!current || current.id !== req.params.stepId) return fail(reply, 409, 'deze stap is niet de huidige opdracht'); + if (current.mode !== 'kijken' && current.requiresProgress !== false) { + const previous = currentIndex > 0 ? steps[currentIndex - 1] : null; + const readySince = previous + ? (completedRows.find((row) => row.step_id === previous.id)?.completed_at || assignment.updated_at) + : assignment.updated_at; + const evidence = (await pool.query( + `SELECT 1 FROM progress_events + WHERE pupil_id = $1 AND widget_id = $2 AND created_at >= $3 LIMIT 1`, + [req.user.id, current.widgetId, readySince])).rows[0]; + if (!evidence) return fail(reply, 409, 'rond eerst een opdracht in de widget af'); + } + const inserted = (await pool.query( + `INSERT INTO assignment_step_progress (assignment_id, pupil_id, step_id) + VALUES ($1,$2,$3) ON CONFLICT DO NOTHING RETURNING step_id`, + [assignment.id, req.user.id, current.id])).rows[0]; + let points = Number(req.user.reward_points || 0); + let unlocked = []; + if (inserted) { + points = Number((await pool.query( + 'UPDATE users SET reward_points = reward_points + 1 WHERE id = $1 RETURNING reward_points', + [req.user.id])).rows[0]?.reward_points || points + 1); + unlocked = (await pool.query( + `INSERT INTO user_avatar_rewards (user_id, reward_key) + SELECT $1, reward_key FROM (VALUES + ('head:star', 1), ('body:medal', 3), ('dance:robot', 6) + ) AS rewards(reward_key, required_points) + WHERE required_points <= $2 + ON CONFLICT DO NOTHING RETURNING reward_key`, + [req.user.id, points])).rows.map((row) => row.reward_key); + } + return { ok: true, points, unlocked, done: completedIds.size + (inserted ? 1 : 0) >= steps.length }; + }); + + // ---- klassenmanagement: niveaugroep + niveau per leerling per vakgebied ------- + // Zelfde klas-standaard-met-leerling-override-vorm als assignments hierboven, + // nu met vakgebied als extra dimensie (subject: taal/rekenen/world/creatief - dezelfde + // waarden als REGISTRY's cat in board.js). "groep" (year_group, 1-8) is de + // niveaugroep gebaseerd op de leerjaren van de basisschool waaraan de + // leerdoelen gekoppeld zijn; "level" (1-3, niet geklemd in het datamodel) is + // de fijnmazige verfijning daarbinnen die widgets nu al kennen. + const LEVEL_SUBJECTS = ['taal', 'rekenen', 'world', 'creatief']; + const describePupilLevel = (row) => ({ yearGroup: Number(row.year_group), level: Number(row.level) }); + const parseLevelBody = (req, reply) => { + const { subject, yearGroup, level } = req.body ?? {}; + if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied'); + const yg = Number(yearGroup); + if (!Number.isInteger(yg) || yg < 1 || yg > 8) return fail(reply, 400, 'ongeldige groep'); + const lvl = Number(level); + if (!Number.isInteger(lvl) || lvl < 1 || lvl > 3) return fail(reply, 400, 'ongeldig niveau'); + return { subject, yearGroup: yg, level: lvl }; + }; + + app.get('/levels/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const subject = req.query.subject; + if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied'); + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + const cls = (await pool.query( + 'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2', [c.id, subject])).rows[0]; + const overrides = (await pool.query( + `SELECT pl.* FROM pupil_levels pl JOIN users u ON u.id = pl.pupil_id + WHERE u.class_id = $1 AND pl.subject = $2`, [c.id, subject])).rows; + return { + classDefault: cls ? describePupilLevel(cls) : null, + pupilOverrides: overrides.map((r) => ({ pupilId: Number(r.pupil_id), ...describePupilLevel(r) })), + }; + }); + app.put('/levels/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const body = parseLevelBody(req, reply); + if (body.error) return body; + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + await pool.query( + `INSERT INTO pupil_levels (class_id, subject, year_group, level) VALUES ($1,$2,$3,$4) + ON CONFLICT (class_id, subject) WHERE class_id IS NOT NULL + DO UPDATE SET year_group = $3, level = $4, updated_at = now()`, + [c.id, body.subject, body.yearGroup, body.level]); + return { ok: true }; + }); + app.delete('/levels/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const subject = req.query.subject; + if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied'); + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + await pool.query('DELETE FROM pupil_levels WHERE class_id = $1 AND subject = $2', [c.id, subject]); + return { ok: true }; + }); + + app.get('/levels/pupil/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const subject = req.query.subject; + if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied'); + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + const row = (await pool.query( + 'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2', [u.id, subject])).rows[0]; + return { level: row ? describePupilLevel(row) : null }; + }); + app.put('/levels/pupil/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const body = parseLevelBody(req, reply); + if (body.error) return body; + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + await pool.query( + `INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4) + ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL + DO UPDATE SET year_group = $3, level = $4, updated_at = now()`, + [u.id, body.subject, body.yearGroup, body.level]); + return { ok: true }; + }); + app.delete('/levels/pupil/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const subject = req.query.subject; + if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied'); + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + await pool.query('DELETE FROM pupil_levels WHERE pupil_id = $1 AND subject = $2', [u.id, subject]); + return { ok: true }; + }); + + // Leerling-kant: effectieve groep/niveau (eigen override wint van de klas), + // zelfde resolutievorm als /my/assignment hierboven. Geen instelling -> + // {yearGroup:null, level:1}, gelijk aan de bestaande widget-standaardwaarde. + app.get('/my/level', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + const subject = req.query.subject; + if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied'); + let row = (await pool.query( + 'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2', [req.user.id, subject])).rows[0]; + if (!row && req.user.class_id) { + row = (await pool.query( + 'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2', [req.user.class_id, subject])).rows[0]; + } + return row ? describePupilLevel(row) : { yearGroup: null, level: 1 }; + }); + + // Alleen een leerling zonder school beheert het eigen niveau. Zodra een + // school het account adopteert, neemt de klas/leerlinginstelling van de + // leerkracht het automatisch over en wordt zelf wijzigen geweigerd. + app.put('/my/level', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + if (req.user.school_id != null) return fail(reply, 403, 'het leerniveau wordt door de school beheerd'); + const body = parseLevelBody(req, reply); + if (body.error) return body; + if (!['taal', 'rekenen', 'creatief'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied'); + await pool.query( + `INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4) + ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL + DO UPDATE SET year_group = $3, level = $4, updated_at = now()`, + [req.user.id, body.subject, body.yearGroup, body.level]); + return { ok: true, level: { yearGroup: body.yearGroup, level: body.level } }; + }); + + // ---- voortgang (leerling-omgeving) --------------------------------------------- + // Leerlingen loggen elk afgerond "moment" in een widget (ronde sommen, voltooid + // woord, geknapte ballon). teacher_id/board_id komen server-side uit de effectieve + // toewijzing (net als /my/assignment), nooit van de client, zodat een leerling geen + // voortgang onder een andere leerkracht kan wegschrijven. + const PROGRESS_LIMIT = 200; + const clampCount = (n) => Math.max(0, Math.min(1000, Number(n) || 0)); + // Optioneel detail (welke opdracht - som/woord - en het gegeven/juiste + // antwoord): decoratief, nooit een reden om de hele log-poging te laten + // falen. Alleen bewaard als het een plain object is en niet absurd groot; + // anders stilzwijgend genegeerd (detail = null), de rest van de melding + // (attempts/correct/stars) wordt gewoon gelogd. + const sanitizeProgressDetail = (detail) => { + if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return null; + try { + if (JSON.stringify(detail).length > 1000) return null; + } catch { return null; } + return detail; + }; + app.post('/my/progress', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + const { widgetId, widgetType, attempts, correct, stars, detail, liveSessionId } = req.body ?? {}; + if (!widgetId || typeof widgetId !== 'string' || widgetId.length > 100) return fail(reply, 400, 'ongeldige widgetId'); + if (!widgetType || typeof widgetType !== 'string' || widgetType.length > 40) return fail(reply, 400, 'ongeldige widgetType'); + let a = await effectiveAssignment(req.user); + if (a && Array.isArray(a.sequence) && a.sequence.length) { + const completed = new Set((await pool.query( + 'SELECT step_id FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2', + [a.id, req.user.id])).rows.map((row) => row.step_id)); + const current = a.sequence.find((step) => !completed.has(step.id)); + if (!current || current.widgetId !== widgetId) return fail(reply, 409, 'deze widget is niet de huidige stap'); + } + // thuiskind (leerling zonder school, ouderportaal): geen toewijzing nodig, + // de voortgang wordt gelogd met de (eerste) gekoppelde ouder als + // "leerkracht" zodat het ouderportaal de tijdlijn direct kan tonen + if (!a && !req.user.school_id) { + const p = (await pool.query( + 'SELECT parent_id FROM parent_children WHERE pupil_id = $1 ORDER BY parent_id LIMIT 1', + [req.user.id])).rows[0]; + if (p) a = { teacher_id: p.parent_id, board_id: 'thuis' }; + } + if (!a) return fail(reply, 400, 'geen toewijzing'); + const cleanAttempts = clampCount(attempts); + const cleanCorrect = clampCount(correct); + if (await liveSessions.isPaused(req.user.id, liveSessionId)) + return fail(reply, 409, 'de leerkracht heeft deze opdracht tijdelijk gepauzeerd'); + await pool.query( + `INSERT INTO progress_events (pupil_id, teacher_id, board_id, widget_id, widget_type, attempts, correct, stars, detail) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [req.user.id, a.teacher_id, a.board_id, widgetId, widgetType, cleanAttempts, cleanCorrect, clampCount(stars), sanitizeProgressDetail(detail)]); + await liveSessions.recordProgress(req.user.id, liveSessionId, cleanAttempts, cleanCorrect); + return { ok: true }; + }); + const describeProgressEvent = (e) => ({ + widgetId: e.widget_id, + widgetType: e.widget_type, + attempts: e.attempts, + correct: e.correct, + stars: e.stars, + detail: e.detail || null, + createdAt: e.created_at, + }); + // Een gekoppelde ouder mag de voortgang van het eigen kind zien; verder + // gelden de normale staf-regels (progress.view + pupilAccessible). + const parentLinked = async (parentId, pupilId) => !!(await pool.query( + 'SELECT 1 FROM parent_children WHERE parent_id = $1 AND pupil_id = $2', + [parentId, pupilId])).rows[0]; + app.get('/progress/pupil/:id', async (req, reply) => { + need(req, reply); + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (req.user.role === 'parent') { + if (!(await parentLinked(req.user.id, u.id))) return fail(reply, 403, 'geen rechten'); + } else { + if (!can(req.user.allRoles, 'progress.view')) return fail(reply, 403, 'geen rechten'); + if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + } + const rows = (await pool.query( + 'SELECT * FROM progress_events WHERE pupil_id = $1 ORDER BY created_at DESC LIMIT $2', + [u.id, PROGRESS_LIMIT])).rows; + return { events: rows.map(describeProgressEvent) }; + }); + app.get('/progress/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['progress.view']); + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + /* standaard blijft de korte activiteitenweergave (200) - de resultaten- + export vraagt expliciet een hogere limiet op om niet stilzwijgend af + te kappen bij een drukke klas; begrensd op 5000 tegen misbruik. */ + const limit = Math.max(1, Math.min(5000, Number(req.query.limit) || PROGRESS_LIMIT)); + const rows = (await pool.query( + `SELECT pe.*, u.display_name, u.username FROM progress_events pe + JOIN users u ON u.id = pe.pupil_id + WHERE u.class_id = $1 ORDER BY pe.created_at DESC LIMIT $2`, + [c.id, limit])).rows; + return { events: rows.map((e) => ({ ...describeProgressEvent(e), pupilId: Number(e.pupil_id), pupilName: e.display_name || e.username })) }; + }); + + app.get('/progress/dashboard/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['progress.view']); + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + const pupils = (await pool.query( + `SELECT u.id, u.display_name, u.username, + COALESCE(SUM(pe.attempts), 0) AS attempts, + COALESCE(SUM(pe.correct), 0) AS correct, + MAX(pe.created_at) AS last_activity + FROM users u LEFT JOIN progress_events pe ON pe.pupil_id = u.id + WHERE u.class_id = $1 AND u.role = 'pupil' + GROUP BY u.id, u.display_name, u.username + ORDER BY COALESCE(u.display_name, u.username)`, [c.id])).rows; + const assignments = (await pool.query( + `SELECT * FROM assignments + WHERE class_id = $1 OR pupil_id IN (SELECT id FROM users WHERE class_id = $1 AND role = 'pupil')`, [c.id])).rows; + const completed = (await pool.query( + `SELECT asp.assignment_id, asp.pupil_id, COUNT(*) AS completed + FROM assignment_step_progress asp JOIN users u ON u.id = asp.pupil_id + WHERE u.class_id = $1 GROUP BY asp.assignment_id, asp.pupil_id`, [c.id])).rows; + const classAssignment = assignments.find((assignment) => Number(assignment.class_id) === Number(c.id)); + const completedMap = new Map(completed.map((row) => [`${row.assignment_id}:${row.pupil_id}`, Number(row.completed)])); + const overview = pupils.map((pupil) => { + const assignment = assignments.find((item) => Number(item.pupil_id) === Number(pupil.id)) || classAssignment; + const total = Array.isArray(assignment?.sequence) ? assignment.sequence.length : 0; + const done = assignment ? (completedMap.get(`${assignment.id}:${pupil.id}`) || 0) : 0; + const attempts = Number(pupil.attempts || 0); + const correct = Number(pupil.correct || 0); + const accuracy = attempts ? Math.round((correct / attempts) * 100) : 0; + const started = !!assignment && (!!done || (pupil.last_activity + && new Date(pupil.last_activity) >= new Date(assignment.updated_at))); + const assignmentComplete = !!assignment && (total ? done >= total : started); + let status = 'notStarted'; + if (total && done >= total) status = 'complete'; + else if (attempts >= 5 && accuracy < 60) status = 'support'; + else if (attempts || done) status = 'active'; + return { + pupilId: Number(pupil.id), pupilName: pupil.display_name || pupil.username, + attempts, correct, accuracy, lastActivity: pupil.last_activity, + sequence: assignment && total ? { title: assignment.title || '', completed: done, total } : null, + assigned: !!assignment, started, assignmentComplete, status, + }; + }); + const summary = overview.reduce((counts, pupil) => { + counts[pupil.status] += 1; + return counts; + }, { complete: 0, active: 0, support: 0, notStarted: 0 }); + const funnel = { + total: overview.length, + assigned: overview.filter((pupil) => pupil.assigned).length, + started: overview.filter((pupil) => pupil.started).length, + completed: overview.filter((pupil) => pupil.assignmentComplete).length, + }; + return { pupils: overview, summary, funnel }; + }); + + // ---- ouderportaal ----------------------------------------------------------- + // Ouders (role 'parent', school_id NULL) registreren zichzelf zonder school, + // beheren gekoppelde kind-accounts en melden thuiskinderen aan bij een school. + // De school houdt de regie over aanmeldverzoeken (adopteren/koppelen/afwijzen). + const needParent = (req, reply) => { + need(req, reply); + if (req.user.role !== 'parent') { reply.code(403); throw new Error('alleen voor ouders'); } + }; + const USERNAME_RE = /^[a-zA-Z0-9_.-]{2,30}$/; + + app.post('/auth/register-parent', { + config: { rateLimit: { max: 8, timeWindow: '10 minutes', ban: 3 } }, + }, async (req, reply) => { + const { username, password } = req.body ?? {}; + const name = typeof username === 'string' ? username.trim() : ''; + if (!USERNAME_RE.test(name)) return fail(reply, 400, 'ongeldige gebruikersnaam'); + const err = checkPassword(password, 'parent'); + if (err) return fail(reply, 400, err); + try { + const r = await pool.query( + `INSERT INTO users (username, role, school_id, display_name, password_hash, last_login_at) + VALUES ($1,'parent',NULL,$1,$2,now()) RETURNING *`, + [name, await hashPassword(password)]); + const u = r.rows[0]; + const token = await createSession(pool, u.id, u.role); + setSessionCookie(reply, token, u.role); + return { user: publicUser(u) }; + } catch (e) { + if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al'); + throw e; + } + }); + + // Gekoppelde kinderen, met school-/klasnaam en eventuele aanmeldstatus. + app.get('/parent/children', async (req, reply) => { + needParent(req, reply); + const rows = (await pool.query( + `SELECT u.id, u.username, u.display_name, u.school_id, u.class_id, + s.name AS school_name, c.name AS class_name + FROM parent_children pc JOIN users u ON u.id = pc.pupil_id + LEFT JOIN schools s ON s.id = u.school_id + LEFT JOIN classes c ON c.id = u.class_id + WHERE pc.parent_id = $1 ORDER BY lower(u.username)`, [req.user.id])).rows; + const reqs = (await pool.query( + `SELECT r.pupil_id, s.name AS school_name + FROM school_link_requests r JOIN schools s ON s.id = r.school_id + WHERE r.parent_id = $1`, [req.user.id])).rows; + const pendingByPupil = new Map(reqs.map((r) => [String(r.pupil_id), r.school_name])); + return { children: rows.map((u) => ({ + id: Number(u.id), + username: u.username, + displayName: u.display_name || u.username, + schoolId: u.school_id == null ? null : Number(u.school_id), + schoolName: u.school_name || null, + className: u.class_name || null, + appliedTo: pendingByPupil.get(String(u.id)) || null, + })) }; + }); + + app.get('/parent/children/:id/levels', async (req, reply) => { + needParent(req, reply); + if (!(await parentLinked(req.user.id, req.params.id))) return fail(reply, 403, 'geen rechten'); + const child = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!child || child.role !== 'pupil') return fail(reply, 404, 'kind onbekend'); + if (child.school_id != null) return { schoolManaged: true, levels: {} }; + const rows = (await pool.query( + "SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject IN ('taal','rekenen','creatief')", + [child.id])).rows; + return { + schoolManaged: false, + levels: Object.fromEntries(rows.map((row) => [row.subject, describePupilLevel(row)])), + }; + }); + + app.put('/parent/children/:id/level', async (req, reply) => { + needParent(req, reply); + if (!(await parentLinked(req.user.id, req.params.id))) return fail(reply, 403, 'geen rechten'); + const child = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!child || child.role !== 'pupil') return fail(reply, 404, 'kind onbekend'); + if (child.school_id != null) return fail(reply, 403, 'het leerniveau wordt door de school beheerd'); + const body = parseLevelBody(req, reply); + if (body.error) return body; + if (!['taal', 'rekenen', 'creatief'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied'); + await pool.query( + `INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4) + ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL + DO UPDATE SET year_group = $3, level = $4, updated_at = now()`, + [child.id, body.subject, body.yearGroup, body.level]); + return { ok: true }; + }); + + // Kind aanmaken: pupil zonder school (thuiskind), meteen gekoppeld. + // Het gegenereerde wachtwoord wordt eenmalig teruggegeven. + app.post('/parent/children', async (req, reply) => { + needParent(req, reply); + const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + if (!USERNAME_RE.test(name)) return fail(reply, 400, 'ongeldige naam (2-30 tekens, letters/cijfers)'); + const pw = pupilPassword(); + try { + const r = await pool.query( + `INSERT INTO users (username, role, school_id, display_name, password_hash) + VALUES ($1,'pupil',NULL,$1,$2) RETURNING *`, + [name, await hashPassword(pw)]); + const u = r.rows[0]; + await pool.query('INSERT INTO parent_children (parent_id, pupil_id) VALUES ($1,$2)', [req.user.id, u.id]); + return { child: { ...publicUser(u), password: pw } }; + } catch (e) { + if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al'); + throw e; + } + }); + + // Schoolkind koppelen met de eenmalige ouder-code die de school uitgeeft. + app.post('/parent/children/link', { + config: { rateLimit: { max: 5, timeWindow: '15 minutes', ban: 3 } }, + }, async (req, reply) => { + needParent(req, reply); + const { code } = req.body ?? {}; + if (typeof code !== 'string' || !code.trim() || code.length > 20) return fail(reply, 400, 'ongeldige code'); + const u = (await pool.query( + "SELECT * FROM users WHERE parent_code = $1 AND role = 'pupil'", + [code.trim().toUpperCase()])).rows[0]; + if (!u) return fail(reply, 404, 'code onbekend of al gebruikt'); + await pool.query('INSERT INTO parent_children (parent_id, pupil_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', + [req.user.id, u.id]); + await pool.query('UPDATE users SET parent_code = NULL WHERE id = $1', [u.id]); + return { child: publicUser(u) }; + }); + + // Thuiskind aanmelden bij een school: er ontstaat een aanmeldverzoek dat de + // school afhandelt; dubbel aanmelden bij dezelfde school geeft een 409. + app.post('/parent/children/:id/apply', async (req, reply) => { + needParent(req, reply); + const { schoolId } = req.body ?? {}; + if (!schoolId) return fail(reply, 400, 'school verplicht'); + if (!(await parentLinked(req.user.id, req.params.id))) return fail(reply, 403, 'geen rechten'); + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'kind onbekend'); + if (u.school_id != null) return fail(reply, 400, 'dit kind zit al op een school'); + const s = (await pool.query('SELECT * FROM schools WHERE id = $1', [schoolId])).rows[0]; + if (!s) return fail(reply, 404, 'school onbekend'); + try { + await pool.query('INSERT INTO school_link_requests (school_id, parent_id, pupil_id) VALUES ($1,$2,$3)', + [s.id, req.user.id, u.id]); + } catch (e) { + if (e.code === '23505') return fail(reply, 409, 'al aangemeld bij deze school'); + throw e; + } + return { ok: true }; + }); + + // Alleen de koppeling verwijderen; het kind-account zelf blijft bestaan. + app.delete('/parent/children/:id', async (req, reply) => { + needParent(req, reply); + await pool.query('DELETE FROM parent_children WHERE parent_id = $1 AND pupil_id = $2', + [req.user.id, req.params.id]); + return { ok: true }; + }); + + // Staf genereert per leerling een eenmalige ouder-code; de respons toont + // hem één keer, daarna is hij alleen nog te vernieuwen (oude vervalt). + app.post('/admin/users/:id/parent-code', async (req, reply) => { + need(req, reply, PERMISSIONS['users.manage']); + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + const code = newLinkCode(); + await pool.query('UPDATE users SET parent_code = $1 WHERE id = $2', [code, u.id]); + return { ok: true, parentCode: code }; + }); + + // Openstaande aanmeldverzoeken voor de eigen school (super: gekozen school). + const requestSchoolId = (req) => + req.user.allRoles.includes('super') ? req.query?.school : req.user.school_id; + app.get('/admin/link-requests', async (req, reply) => { + need(req, reply, PERMISSIONS['users.manage']); + const schoolId = requestSchoolId(req); + if (!schoolId) return { requests: [] }; + const rows = (await pool.query( + `SELECT r.id, r.created_at, + k.id AS pupil_id, k.username AS pupil_username, k.display_name AS pupil_name, + o.username AS parent_username, o.display_name AS parent_name + FROM school_link_requests r + JOIN users k ON k.id = r.pupil_id + JOIN users o ON o.id = r.parent_id + WHERE r.school_id = $1 ORDER BY r.created_at`, [schoolId])).rows; + return { requests: rows.map((r) => ({ + id: Number(r.id), + createdAt: r.created_at, + pupil: { id: Number(r.pupil_id), username: r.pupil_username, displayName: r.pupil_name || r.pupil_username }, + parent: { username: r.parent_username, displayName: r.parent_name || r.parent_username }, + })) }; + }); + + // Afhandelen van een aanmeldverzoek: + // * adopt - het thuisaccount wordt de schoolleerling (school + klas op + // hetzelfde account; borden/voortgang/ouderkoppeling blijven). + // Bij een naamconflict in de school kan staf direct een andere + // gebruikersnaam meegeven. + // * match - de ouderkoppeling verhuist naar een bestaande schoolleerling; + // het thuisaccount wordt alleen opgeruimd als het leeg is (geen + // borddata), zodat er nooit werk verloren gaat. + // * reject - verzoek verdwijnt, verder verandert er niets. + app.post('/admin/link-requests/:id/resolve', async (req, reply) => { + need(req, reply, PERMISSIONS['users.manage']); + const r = (await pool.query('SELECT * FROM school_link_requests WHERE id = $1', [req.params.id])).rows[0]; + if (!r) return fail(reply, 404, 'aanmelding onbekend'); + if (!req.user.allRoles.includes('super') && Number(r.school_id) !== Number(req.user.school_id)) + return fail(reply, 403, 'geen rechten'); + const b = req.body ?? {}; + if (b.action === 'reject') { + await pool.query('DELETE FROM school_link_requests WHERE id = $1', [r.id]); + return { ok: true }; + } + if (b.action === 'adopt') { + let classId = null; + if (b.classId != null) { + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [b.classId])).rows[0]; + if (!c || Number(c.school_id) !== Number(r.school_id)) return fail(reply, 400, 'klas hoort niet bij deze school'); + classId = c.id; + } + let username = null; + if (b.username != null) { + username = String(b.username).trim(); + if (!USERNAME_RE.test(username)) return fail(reply, 400, 'ongeldige gebruikersnaam'); + } + try { + await pool.query( + `UPDATE users SET school_id = $1, class_id = $2, username = COALESCE($3, username) + WHERE id = $4 AND role = 'pupil'`, + [r.school_id, classId, username, r.pupil_id]); + } catch (e) { + if (e.code === '23505') return fail(reply, 409, 'gebruikersnaam bestaat al binnen deze school'); + throw e; + } + // De thuiskeuze mag na adoptie niet als leerling-override boven de + // klasinstelling blijven hangen; vanaf nu bepaalt de leerkracht dit. + await pool.query('DELETE FROM pupil_levels WHERE pupil_id = $1', [r.pupil_id]); + // het kind hoort nu bij deze school: ook aanmeldingen bij andere + // scholen zijn daarmee vervallen + await pool.query('DELETE FROM school_link_requests WHERE pupil_id = $1', [r.pupil_id]); + return { ok: true }; + } + if (b.action === 'match') { + if (!b.pupilId) return fail(reply, 400, 'leerling verplicht'); + const target = (await pool.query('SELECT * FROM users WHERE id = $1', [b.pupilId])).rows[0]; + if (!target || target.role !== 'pupil' || Number(target.school_id) !== Number(r.school_id)) + return fail(reply, 400, 'leerling hoort niet bij deze school'); + await pool.query( + `INSERT INTO parent_children (parent_id, pupil_id) + SELECT parent_id, $1 FROM parent_children WHERE pupil_id = $2 + ON CONFLICT DO NOTHING`, [target.id, r.pupil_id]); + const home = (await pool.query( + `SELECT (data IS NULL OR data = '{}'::jsonb) AS empty FROM users WHERE id = $1`, + [r.pupil_id])).rows[0]; + if (home?.empty) { + // leeg thuisaccount opruimen (cascade ruimt koppelingen en verzoeken op) + await pool.query('DELETE FROM users WHERE id = $1', [r.pupil_id]); + } else { + // thuisaccount met werk erin blijft bestaan; alleen de verzoeken weg + await pool.query('DELETE FROM school_link_requests WHERE pupil_id = $1', [r.pupil_id]); + } + return { ok: true }; + } + return fail(reply, 400, 'onbekende actie'); + }); + + // ---- gedeelde bibliotheek -------------------------------------------------- + // Systeembreed (school_id NULL, gepubliceerd door de systeemmanager, voor + // iedereen zichtbaar) of per school (alleen zichtbaar binnen die school). + // Andere gebruikers KOPIËREN een item naar hun eigen collectie; er wordt + // niet live in gedeelde items gewerkt. Beheren (hernoemen/verplaatsen/ + // verwijderen) mag de systeemmanager altijd, de eigenaar altijd, en de + // schoolbeheerder voor items van de eigen school. + const SHARED_KINDS = ['board', 'anchor']; + const sharedStaff = (req, reply) => { + need(req, reply); + if (req.user.role === 'pupil') { reply.code(403); throw new Error('geen rechten'); } + }; + // Vergrendeld (readonly) school-item: ook de eigenaar kan er niet meer bij, + // alleen de schoolbeheerder en de systeemmanager nog. + const canManageShared = (req, item) => + req.user.allRoles.includes('super') + || (item.school_id != null && req.user.allRoles.includes('admin') + && Number(item.school_id) === Number(req.user.school_id)) + || (Number(item.owner_id) === Number(req.user.id) && !item.readonly); + // Wie mag de alleen-lezen-vlag zetten/weghalen: de systeemmanager altijd, + // de schoolbeheerder voor items van de eigen school (niet de leerkracht). + const canLockShared = (req, item) => + req.user.allRoles.includes('super') + || (item.school_id != null && req.user.allRoles.includes('admin') + && Number(item.school_id) === Number(req.user.school_id)); + const sharedItemVisible = (req, item) => + req.user.allRoles.includes('super') + || item.school_id == null + || Number(item.school_id) === Number(req.user.school_id); + const validSharedName = (n) => typeof n === 'string' && n.trim() && n.trim().length <= 40; + const validSharedFolder = (f) => typeof f === 'string' && f.length <= 200 && !f.includes('//') && !/[\x00-\x1f]/.test(f); + const describeShared = (req, it) => ({ + id: Number(it.id), + name: it.name, + folder: it.folder || '', + scope: it.school_id == null ? 'global' : 'school', + ownerName: it.owner_name || null, + readonly: !!it.readonly, + canManage: canManageShared(req, it), + canLock: canLockShared(req, it), + }); + + app.get('/shared/:kind', async (req, reply) => { + sharedStaff(req, reply); + const { kind } = req.params; + if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort'); + const rows = req.user.allRoles.includes('super') + ? (await pool.query( + `SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, s.readonly, u.display_name AS owner_name + FROM shared_library s LEFT JOIN users u ON u.id = s.owner_id + WHERE s.kind = $1 ORDER BY s.folder, s.name`, [kind])).rows + : (await pool.query( + `SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, s.readonly, u.display_name AS owner_name + FROM shared_library s LEFT JOIN users u ON u.id = s.owner_id + WHERE s.kind = $1 AND (s.school_id IS NULL OR s.school_id = $2) + ORDER BY s.folder, s.name`, [kind, req.user.school_id])).rows; + return { items: rows.map((it) => describeShared(req, it)) }; + }); + app.get('/shared/:kind/:id', async (req, reply) => { + sharedStaff(req, reply); + const { kind } = req.params; + if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort'); + const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0]; + if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend'); + return { item: { ...describeShared(req, it), data: it.data } }; + }); + app.post('/shared/:kind', { bodyLimit: 6 * 1024 * 1024 }, async (req, reply) => { + sharedStaff(req, reply); + const { kind } = req.params; + if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort'); + const { name, folder, data, scope } = req.body ?? {}; + if (!validSharedName(name)) return fail(reply, 400, 'ongeldige naam'); + if (folder !== undefined && !validSharedFolder(folder)) return fail(reply, 400, 'ongeldige map'); + if (!data || typeof data !== 'object' || Array.isArray(data)) return fail(reply, 400, 'ongeldige data'); + if (Buffer.byteLength(JSON.stringify(data), 'utf8') > 5 * 1024 * 1024) return fail(reply, 413, 'data te groot'); + let schoolId = null; + if (scope === 'global') { + need(req, reply, PERMISSIONS['shared.global.manage']); + } else if (scope === 'school') { + need(req, reply, PERMISSIONS['shared.school.manage']); + if (req.user.school_id == null) return fail(reply, 400, 'geen school gekoppeld aan dit account'); + schoolId = req.user.school_id; + } else return fail(reply, 400, 'ongeldige scope'); + const r = await pool.query( + `INSERT INTO shared_library (school_id, owner_id, kind, folder, name, data) + VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, + [schoolId, req.user.id, kind, folder || '', name.trim(), data]); + return { ok: true, id: Number(r.rows[0].id) }; + }); + // map-prefix-rewrite (map hernoemen/verplaatsen: {from, to}) of de hele map + // op alleen-lezen zetten ({from, readonly}); rewrite mag alleen als de + // gebruiker ALLE items onder het pad mag beheren, vergrendelen alleen door + // wie de vlag op alle items mag zetten (schoolbeheerder/systeemmanager) + app.patch('/shared/:kind/folder', async (req, reply) => { + sharedStaff(req, reply); + const { kind } = req.params; + if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort'); + const { scope, from, to, readonly } = req.body ?? {}; + if (scope !== 'global' && scope !== 'school') return fail(reply, 400, 'ongeldige scope'); + if (!validSharedFolder(from) || !from) return fail(reply, 400, 'ongeldige map'); + if (to === undefined && typeof readonly !== 'boolean') return fail(reply, 400, 'niets te doen'); + if (to !== undefined && (!validSharedFolder(to) || !to)) return fail(reply, 400, 'ongeldige map'); + const rows = scope === 'global' + ? (await pool.query( + `SELECT * FROM shared_library WHERE kind = $1 AND school_id IS NULL AND (folder = $2 OR folder LIKE $3)`, + [kind, from, from + '/%'])).rows + : (await pool.query( + `SELECT * FROM shared_library WHERE kind = $1 AND school_id = $2 AND (folder = $3 OR folder LIKE $4)`, + [kind, req.user.school_id, from, from + '/%'])).rows; + if (to !== undefined){ + if (rows.some((it) => !canManageShared(req, it))) return fail(reply, 403, 'geen rechten op alle items in deze map'); + await pool.query( + `UPDATE shared_library SET folder = $1 || substr(folder, $2) + WHERE id = ANY($3)`, + [to, from.length + 1, rows.map((it) => it.id)]); + } + if (typeof readonly === 'boolean'){ + if (rows.some((it) => !canLockShared(req, it))) return fail(reply, 403, 'alleen de schoolbeheerder of systeemmanager kan dit vergrendelen'); + await pool.query('UPDATE shared_library SET readonly = $1 WHERE id = ANY($2)', [readonly, rows.map((it) => it.id)]); + } + return { ok: true }; + }); + app.patch('/shared/:kind/:id', async (req, reply) => { + sharedStaff(req, reply); + const { kind } = req.params; + if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort'); + const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0]; + if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend'); + const { name, folder, readonly } = req.body ?? {}; + if (name !== undefined || folder !== undefined){ + if (!canManageShared(req, it)) return fail(reply, 403, 'geen rechten'); + if (name !== undefined && !validSharedName(name)) return fail(reply, 400, 'ongeldige naam'); + if (folder !== undefined && !validSharedFolder(folder)) return fail(reply, 400, 'ongeldige map'); + if (name !== undefined) await pool.query('UPDATE shared_library SET name = $1 WHERE id = $2', [name.trim(), it.id]); + if (folder !== undefined) await pool.query('UPDATE shared_library SET folder = $1 WHERE id = $2', [folder, it.id]); + } + if (readonly !== undefined){ + if (typeof readonly !== 'boolean') return fail(reply, 400, 'ongeldige waarde'); + if (!canLockShared(req, it)) return fail(reply, 403, 'alleen de schoolbeheerder of systeemmanager kan dit vergrendelen'); + await pool.query('UPDATE shared_library SET readonly = $1 WHERE id = $2', [readonly, it.id]); + } + return { ok: true }; + }); + app.delete('/shared/:kind/:id', async (req, reply) => { + sharedStaff(req, reply); + const { kind } = req.params; + if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort'); + const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0]; + if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend'); + if (!canManageShared(req, it)) return fail(reply, 403, 'geen rechten'); + await pool.query('DELETE FROM shared_library WHERE id = $1', [it.id]); + return { ok: true }; + }); +} + +// Eerste super-beheerder aanmaken als die nog niet bestaat. +export async function bootstrapSuper(pool, log) { + const r = await pool.query("SELECT count(*)::int AS n FROM users WHERE role = 'super'"); + if (r.rows[0].n > 0) return; + const username = process.env.SUPER_USER || 'beheerder'; + const password = process.env.SUPER_PASS; + if (!password) throw new Error('SUPER_PASS is verplicht bij het aanmaken van de eerste super-beheerder'); + const passwordError = checkPassword(password, 'super'); + if (passwordError) throw new Error(`ongeldige SUPER_PASS: ${passwordError}`); + await pool.query( + `INSERT INTO users (username, role, display_name, password_hash) + VALUES ($1, 'super', $1, $2)`, + [username, await hashPassword(password)]); + log.warn(`Eerste super-beheerder aangemaakt: ${username}. Het wachtwoord wordt niet gelogd.`); +} diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..1e20c35 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,133 @@ +// Auth-helpers: wachtwoord-hashing (scrypt, ingebouwd in Node), sessietokens +// en koppelcodes. Geen externe dependencies nodig. +import { scrypt, randomBytes, timingSafeEqual, createHash } from 'node:crypto'; + +export const SESSION_COOKIE = 'teach_session'; + +export function hashPassword(password) { + return new Promise((resolve, reject) => { + const salt = randomBytes(16).toString('hex'); + scrypt(password, salt, 32, (err, buf) => { + if (err) return reject(err); + resolve(`${salt}:${buf.toString('hex')}`); + }); + }); +} + +export function verifyPassword(password, stored) { + return new Promise((resolve) => { + if (typeof password !== 'string' || password.length > 128 || !stored || !stored.includes(':')) return resolve(false); + const [salt, hex] = stored.split(':'); + scrypt(password, salt, 32, (err, buf) => { + if (err) return resolve(false); + const a = Buffer.from(hex, 'hex'); + resolve(a.length === buf.length && timingSafeEqual(a, buf)); + }); + }); +} + +export function newToken() { + return randomBytes(32).toString('hex'); +} + +export function hashSessionToken(token) { + return createHash('sha256').update(token).digest('hex'); +} + +export function sessionTokenFromRequest(req) { + return req.cookies?.[SESSION_COOKIE] || null; +} + +// Leesbare eenmalige koppelcode, bv. "K7FP-3RZM" +export function newLinkCode() { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + const pick = (n) => Array.from(randomBytes(n)).map((b) => chars[b % chars.length]).join(''); + return `${pick(4)}-${pick(4)}`; +} + +// Eenvoudig leerlingwachtwoord dat een juf kan voorlezen, bv. "vis73" +export function pupilPassword() { + const words = ['vis','kat','zon','maan','boot','ster','boom','bal','kip','roos','beer','muis','peer','koek','wolf','haan']; + const pickWord = () => words[randomBytes(1)[0] % words.length]; + const n = 100 + (randomBytes(2).readUInt16BE() % 900); + return `${pickWord()}-${pickWord()}-${pickWord()}-${n}`; +} + +// Willekeurig wachtwoord voor groepsleiding/beheerders, altijd met minstens +// 1 letter en 1 cijfer zodat het meteen aan checkPassword() voldoet. +export function staffPassword() { + const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + const digits = '23456789'; + const all = letters + digits; + const pick = (set, n) => Array.from(randomBytes(n)).map((b) => set[b % set.length]).join(''); + return `${pick(letters, 1)}${pick(digits, 1)}${pick(all, 12)}`; +} + +// Wachtwoordeisen: leerlingen kort en eenvoudig (worden voorgelezen door +// groepsleiding), groepsleiding/beheer strenger (eigen, onthouden wachtwoord). +export function checkPassword(password, role) { + if (typeof password !== 'string' || !password) return 'wachtwoord verplicht'; + if (password.length > 128) return 'wachtwoord: maximaal 128 tekens'; + if (role === 'pupil') { + if (password.length < 4) return 'wachtwoord: minimaal 4 tekens'; + return null; + } + if (password.length < 8) return 'wachtwoord: minimaal 8 tekens'; + if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) + return 'wachtwoord: minimaal 1 letter en 1 cijfer'; + return null; +} + +export function sessionDaysForRole(role) { + if (role === 'super' || role === 'admin') return 1; + if (role === 'teacher') return 7; + return 30; +} + +export async function createSession(pool, userId, role) { + const token = newToken(); + await pool.query('DELETE FROM sessions WHERE expires_at <= now()'); + await pool.query(`DELETE FROM sessions WHERE token IN ( + SELECT token FROM sessions WHERE user_id = $1 ORDER BY created_at DESC OFFSET 9 + )`, [userId]); + await pool.query( + `INSERT INTO sessions (token, user_id, expires_at) + VALUES ($1, $2, now() + ($3 * interval '1 day'))`, + [hashSessionToken(token), userId, sessionDaysForRole(role)], + ); + return token; +} + +export async function userFromRequest(pool, req) { + const token = sessionTokenFromRequest(req); + if (!token) return null; + const r = await pool.query( + `SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.token = $1 AND s.expires_at > now()`, + [hashSessionToken(token)], + ); + const u = r.rows[0]; + if (!u) return null; + // extra stafrollen naast de hoofdrol (role) - alleen relevant voor rechten-checks, + // zie src/permissions.js + const er = await pool.query('SELECT role FROM user_roles WHERE user_id = $1', [u.id]); + u.extra_roles = er.rows.map((row) => row.role); + u.allRoles = [u.role, ...u.extra_roles]; + return u; +} + +// Publiek profiel (zonder wachtwoordvelden) +export function publicUser(u) { + if (!u) return null; + return { + id: Number(u.id), + username: u.username, + displayName: u.display_name || u.username, + role: u.role, + extraRoles: u.extra_roles || [], + schoolId: u.school_id == null ? null : Number(u.school_id), + classId: u.class_id == null ? null : Number(u.class_id), + pending: !u.password_hash && !!u.link_code, + avatar: u.avatar_data || null, + }; +} diff --git a/src/body-parser.js b/src/body-parser.js new file mode 100644 index 0000000..3b07161 --- /dev/null +++ b/src/body-parser.js @@ -0,0 +1,18 @@ +// Sta een leeg lichaam toe bij Content-Type: application/json (bv. DELETE- +// verzoeken zonder body, zoals de client die stuurt). Fastify's +// standaardparser weigert dit anders met "Body cannot be empty when +// content-type is set to 'application/json'" - vóórdat er ook maar één hook +// of route-handler heeft kunnen draaien, dus zonder gebruiker en zonder +// route-eigen foutafhandeling: elke poging eindigde in een onherleidbare +// kale "serverfout". +export function registerEmptyJsonBodyParser(app) { + app.addContentTypeParser('application/json', { parseAs: 'string' }, (req, body, done) => { + if (!body) return done(null, undefined); + try { + done(null, JSON.parse(body)); + } catch (err) { + err.statusCode = 400; + done(err); + } + }); +} diff --git a/src/frontend.js b/src/frontend.js new file mode 100644 index 0000000..6260365 --- /dev/null +++ b/src/frontend.js @@ -0,0 +1,44 @@ +// teach - statische frontend met cache-beleid en cache-busting. +// +// index.html wordt bij boot één keer gelezen; elke __V__-placeholder in de +// asset-URL's (?v=__V__) wordt vervangen door de app-versie en de pagina wordt +// daarna uit het geheugen geserveerd met "no-cache" (altijd hervalideren). +// De js/css-bestanden zelf dragen daardoor een geversioneerde URL en mogen +// een jaar lang "immutable" gecachet worden: een nieuwe release geeft nieuwe +// URL's, herhaalbezoek binnen dezelfde release kost vrijwel geen bytes. +// Fonts en afbeeldingen worden zonder versie geladen (CSS url()/JS-paden) en +// krijgen dus een kortere maxAge mét de zwakke etag als 304-vangnet. +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import fastifyStatic from '@fastify/static'; + +export function resolveAppVersion(env = process.env) { + const v = env.APP_VERSION; + // zonder echte versie (lokaal ontwikkelen) nooit een stale cache riskeren + return v && v !== 'dev' ? v : `dev-${Date.now()}`; +} + +export async function registerFrontend(app, { root, version }) { + const html = (await readFile(join(root, 'index.html'), 'utf8')) + .replaceAll('__V__', encodeURIComponent(version)); + const sendIndex = (_req, reply) => reply + .type('text/html; charset=utf-8') + .header('cache-control', 'no-cache') + .send(html); + app.get('/', sendIndex); + app.get('/index.html', sendIndex); + + await app.register(fastifyStatic, { + root, + index: false, // index.html gaat via de in-memory route hierboven + setHeaders(reply, path) { + if (/[\\/](js|css)[\\/]/.test(path)) { + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } else if (/[\\/]fonts[\\/]/.test(path)) { + reply.header('cache-control', 'public, max-age=604800'); + } else { + reply.header('cache-control', 'public, max-age=86400'); + } + }, + }); +} diff --git a/src/images.js b/src/images.js new file mode 100644 index 0000000..9e9b986 --- /dev/null +++ b/src/images.js @@ -0,0 +1,577 @@ +// Beveiligde afbeeldingscatalogus: bestanden op disk, metadata in PostgreSQL. +// Afbeeldingen kunnen in meerdere thema's staan en gebruiken dezelfde +// slash-paden als de bestaande mappenverkenner. +import { createReadStream } from 'node:fs'; +import { access, mkdir, unlink, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { userFromRequest } from './auth.js'; + +export const IMAGE_MB = 1024 * 1024; +export const MAX_IMAGE_FILE_BYTES = 50 * IMAGE_MB; +export const DEFAULT_IMAGE_QUOTAS = Object.freeze({ + super: null, + admin: 400 * IMAGE_MB, + default: 200 * IMAGE_MB, +}); + +const SUPPORTED_IMAGE_TYPES = [ + 'image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif', +]; + +const rolesOf = (user) => user?.allRoles || [user?.role].filter(Boolean); +export const defaultImageQuota = (userOrRole) => { + const roles = typeof userOrRole === 'string' ? [userOrRole] : rolesOf(userOrRole); + if (roles.includes('super')) return null; + if (roles.includes('admin')) return DEFAULT_IMAGE_QUOTAS.admin; + return DEFAULT_IMAGE_QUOTAS.default; +}; +export const imageScopeForUser = (user) => { + const roles = rolesOf(user); + if (roles.includes('super')) return 'global'; + if (roles.includes('admin')) return 'school'; + return 'personal'; +}; +export const canSeeImage = (user, item) => { + if (!user || !item) return false; + if (rolesOf(user).includes('super') || item.scope === 'global') return true; + if (item.scope === 'school') + return item.school_id != null && Number(item.school_id) === Number(user.school_id); + return item.scope === 'personal' && Number(item.owner_id) === Number(user.id); +}; +export const canManageImage = (user, item) => { + if (!user || !item) return false; + const roles = rolesOf(user); + if (roles.includes('super')) return true; + if (item.scope === 'school') + return roles.includes('admin') && Number(item.school_id) === Number(user.school_id); + return item.scope === 'personal' && Number(item.owner_id) === Number(user.id); +}; + +export function normaliseImageFolder(value, allowEmpty = true) { + if (value == null || value === '') return allowEmpty ? '' : null; + if (typeof value !== 'string' || value.length > 200 || value.includes('\\') + || value.includes('//') || /[\x00-\x1f]/.test(value)) return null; + const path = value.trim().replace(/^\/+|\/+$/g, ''); + if ((!path && !allowEmpty) || path.split('/').some((part) => + !part || part === '.' || part === '..' || part.length > 40)) return null; + return path; +} +const validName = (value) => + typeof value === 'string' && !!value.trim() && value.trim().length <= 80; +const numericIds = (value) => Array.isArray(value) + ? [...new Set(value.map(Number).filter((id) => Number.isInteger(id) && id > 0))].slice(0, 30) + : []; +const quotaOverride = (value, fallback) => { + if (value == null) return fallback; + const n = Number(value); + return n === -1 ? null : n; +}; + +export function detectImageType(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null; + if (buffer.subarray(0, 8).equals(Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]))) + return { mime: 'image/png', ext: 'png' }; + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) + return { mime: 'image/jpeg', ext: 'jpg' }; + const head = buffer.subarray(0, 12).toString('ascii'); + if (head.startsWith('GIF87a') || head.startsWith('GIF89a')) + return { mime: 'image/gif', ext: 'gif' }; + if (head.startsWith('RIFF') && head.slice(8, 12) === 'WEBP') + return { mime: 'image/webp', ext: 'webp' }; + if (buffer.subarray(4, 12).toString('ascii').startsWith('ftypavi')) + return { mime: 'image/avif', ext: 'avif' }; + return null; +} + +const visibleSql = (user, alias = '') => { + const p = alias ? alias + '.' : ''; + if (rolesOf(user).includes('super')) return { sql: 'TRUE', params: [] }; + if (user.school_id != null) return { + sql: `(${p}scope = 'global' OR (${p}scope = 'school' AND ${p}school_id = $1) OR (${p}scope = 'personal' AND ${p}owner_id = $2))`, + params: [user.school_id, user.id], + }; + return { + sql: `(${p}scope = 'global' OR (${p}scope = 'personal' AND ${p}owner_id = $1))`, + params: [user.id], + }; +}; +const contextSql = (item, column = 'folder') => { + if (item.scope === 'global') return { sql: `scope = 'global'`, params: [] }; + if (item.scope === 'school') return { + sql: `scope = 'school' AND school_id = $1`, params: [item.school_id], + }; + return { + sql: `scope = 'personal' AND owner_id = $1`, params: [item.owner_id], + }; +}; +const compatibleTheme = (asset, theme) => { + if (theme.scope === 'global') return true; + if (asset.scope === 'school') + return theme.scope === 'school' && Number(theme.school_id) === Number(asset.school_id); + if (asset.scope === 'personal') + return theme.scope === 'personal' && Number(theme.owner_id) === Number(asset.owner_id); + return false; +}; +const safeStorageKey = (value) => + typeof value === 'string' && /^[0-9a-f-]{36}\.(png|jpg|gif|webp|avif)$/.test(value); + +async function quotaStatus(db, user, lock = false) { + const userRow = (await db.query( + `SELECT id, role, school_id, image_quota_bytes FROM users WHERE id = $1${lock ? ' FOR UPDATE' : ''}`, + [user.id])).rows[0]; + const used = Number((await db.query( + 'SELECT COALESCE(sum(size_bytes),0)::bigint AS used FROM image_assets WHERE owner_id = $1', + [user.id])).rows[0]?.used || 0); + const fallback = defaultImageQuota(user); + const userLimit = quotaOverride(userRow?.image_quota_bytes, fallback); + let schoolUsed = 0, schoolLimit = null; + if (userRow?.school_id != null) { + const school = (await db.query( + `SELECT id, image_quota_bytes FROM schools WHERE id = $1${lock ? ' FOR UPDATE' : ''}`, + [userRow.school_id])).rows[0]; + schoolUsed = Number((await db.query( + 'SELECT COALESCE(sum(size_bytes),0)::bigint AS used FROM image_assets WHERE school_id = $1', + [userRow.school_id])).rows[0]?.used || 0); + schoolLimit = quotaOverride(school?.image_quota_bytes, null); + } + return { + usedBytes: used, + limitBytes: userLimit, + schoolUsedBytes: schoolUsed, + schoolLimitBytes: schoolLimit, + }; +} + +const describeAsset = (user, row) => ({ + id: Number(row.id), + name: row.name, + folder: row.folder || '', + scope: row.scope, + ownerName: row.owner_name || null, + sizeBytes: Number(row.size_bytes || 0), + mimeType: row.mime_type, + themes: (row.theme_ids || []).map(Number), + tags: row.tags || [], + src: row.public_path || `/api/images/${row.id}/content`, + builtin: !!row.public_path, + canManage: canManageImage(user, row), + canDelete: canManageImage(user, row) && !row.public_path, +}); +const describeTheme = (user, row) => ({ + id: Number(row.id), + nameNl: row.name_nl, + nameEn: row.name_en, + folder: row.folder || '', + scope: row.scope, + builtin: !!row.key, + canManage: canManageImage(user, row) && !row.key, +}); +const describeFolder = (user, row) => ({ + id: Number(row.id), + path: row.path, + scope: row.scope, + canManage: canManageImage(user, row), +}); + +export default async function imageApi(app, options = {}) { + const pool = app.pg; + const storageDir = options.storageDir || process.env.IMAGE_STORAGE_DIR + || join(process.cwd(), 'storage', 'images'); + const resolveUser = options.resolveUser || ((req) => userFromRequest(pool, req)); + await mkdir(storageDir, { recursive: true }); + + app.decorateRequest('imageUser', null); + app.addContentTypeParser(SUPPORTED_IMAGE_TYPES, { + parseAs: 'buffer', bodyLimit: MAX_IMAGE_FILE_BYTES, + }, (_req, body, done) => done(null, body)); + app.addHook('preHandler', async (req, reply) => { + if (!['GET','HEAD','OPTIONS'].includes(req.method) && req.headers.origin) { + const expectedOrigin = `${req.protocol}://${req.headers.host}`; + if (req.headers.origin !== expectedOrigin) + return reply.code(403).send({ error: 'ongeldige origin' }); + } + req.imageUser = await resolveUser(req); + }); + app.setErrorHandler((err, req, reply) => { + if (reply.sent) return; + if (reply.statusCode >= 400 && reply.statusCode < 500) + return reply.send({ error: err.message }); + req.log.error(err); + return reply.code(500).send({ error: 'serverfout' }); + }); + const fail = (reply, code, message) => reply.code(code).send({ error: message }); + const needUser = (req, reply) => { + if (!req.imageUser) { fail(reply, 401, 'niet ingelogd'); return false; } + return true; + }; + + app.get('/images', async (req, reply) => { + if (!needUser(req, reply)) return; + const user = req.imageUser; + const av = visibleSql(user, 'a'); + const tv = visibleSql(user, 't'); + const fv = visibleSql(user, 'f'); + const images = (await pool.query( + `SELECT a.*, u.display_name AS owner_name, + COALESCE(array_agg(at.theme_id) FILTER (WHERE at.theme_id IS NOT NULL), '{}') AS theme_ids + FROM image_assets a + LEFT JOIN users u ON u.id = a.owner_id + LEFT JOIN image_asset_themes at ON at.asset_id = a.id + WHERE ${av.sql} + GROUP BY a.id, u.display_name + ORDER BY a.folder, lower(a.name)`, av.params)).rows; + const themes = (await pool.query( + `SELECT t.* FROM image_themes t WHERE ${tv.sql} ORDER BY lower(t.name_nl)`, + tv.params)).rows; + const folders = (await pool.query( + `SELECT f.* FROM image_folders f WHERE ${fv.sql} ORDER BY f.path`, + fv.params)).rows; + return { + images: images.map((row) => describeAsset(user, row)), + themes: themes.map((row) => describeTheme(user, row)), + folders: folders.map((row) => describeFolder(user, row)), + quota: await quotaStatus(pool, user), + uploadScope: imageScopeForUser(user), + }; + }); + + app.get('/images/:id/content', async (req, reply) => { + if (!needUser(req, reply)) return; + const row = (await pool.query('SELECT * FROM image_assets WHERE id = $1', [req.params.id])).rows[0]; + if (!row || !canSeeImage(req.imageUser, row)) return fail(reply, 404, 'afbeelding onbekend'); + if (row.public_path) return reply.redirect(row.public_path); + if (!safeStorageKey(row.storage_key)) return fail(reply, 404, 'bestand onbekend'); + const path = join(storageDir, row.storage_key); + try { await access(path); } catch { return fail(reply, 404, 'bestand ontbreekt'); } + reply.type(row.mime_type); + reply.header('Cache-Control', 'private, max-age=3600'); + return reply.send(createReadStream(path)); + }); + + app.post('/images', { bodyLimit: MAX_IMAGE_FILE_BYTES }, async (req, reply) => { + if (!needUser(req, reply)) return; + if (!Buffer.isBuffer(req.body) || !req.body.length) return fail(reply, 400, 'afbeelding verplicht'); + const type = detectImageType(req.body); + if (!type) return fail(reply, 415, 'alleen PNG, JPEG, GIF, WebP of AVIF'); + const name = typeof req.query?.name === 'string' ? req.query.name : ''; + const folder = normaliseImageFolder(req.query?.folder || ''); + if (!validName(name) || folder == null) return fail(reply, 400, 'ongeldige naam of map'); + const themeIds = String(req.query?.themes || '').split(',').filter(Boolean).map(Number); + if (themeIds.some((id) => !Number.isInteger(id) || id <= 0) || themeIds.length > 30) + return fail(reply, 400, 'ongeldige thema’s'); + let originalName = ''; + try { originalName = decodeURIComponent(req.headers['x-file-name'] || ''); } catch {} + originalName = originalName.slice(0, 160); + const user = req.imageUser; + const scope = imageScopeForUser(user); + if (scope === 'school' && user.school_id == null) return fail(reply, 400, 'geen school gekoppeld'); + const schoolId = scope === 'school' || scope === 'personal' ? user.school_id : null; + const storageKey = `${randomUUID()}.${type.ext}`; + const filePath = join(storageDir, storageKey); + const client = await pool.connect(); + let fileWritten = false; + try { + await client.query('BEGIN'); + const quota = await quotaStatus(client, user, true); + if (quota.limitBytes != null && quota.usedBytes + req.body.length > quota.limitBytes) { + await client.query('ROLLBACK'); + return fail(reply, 413, 'persoonlijke opslaglimiet bereikt'); + } + if (schoolId != null && quota.schoolLimitBytes != null + && quota.schoolUsedBytes + req.body.length > quota.schoolLimitBytes) { + await client.query('ROLLBACK'); + return fail(reply, 413, 'opslaglimiet van de school bereikt'); + } + let themes = []; + if (themeIds.length) { + themes = (await client.query('SELECT * FROM image_themes WHERE id = ANY($1)', [themeIds])).rows; + const probe = { scope, school_id: schoolId, owner_id: user.id }; + if (themes.length !== themeIds.length + || themes.some((theme) => !canSeeImage(user, theme) || !compatibleTheme(probe, theme))) { + await client.query('ROLLBACK'); + return fail(reply, 400, 'thema niet beschikbaar voor deze afbeelding'); + } + } + await writeFile(filePath, req.body, { flag: 'wx' }); + fileWritten = true; + const inserted = (await client.query( + `INSERT INTO image_assets + (key, scope, school_id, owner_id, folder, name, original_name, mime_type, size_bytes, storage_key) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`, + [randomUUID(), scope, schoolId, user.id, folder, name.trim(), originalName, + type.mime, req.body.length, storageKey])).rows[0]; + if (folder) await client.query( + `INSERT INTO image_folders (scope, school_id, owner_id, path) + VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`, + [scope, schoolId, user.id, folder]); + for (const themeId of themeIds) + await client.query( + 'INSERT INTO image_asset_themes (asset_id, theme_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', + [inserted.id, themeId]); + await client.query('COMMIT'); + return { image: describeAsset(user, { ...inserted, theme_ids: themeIds }) }; + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + if (fileWritten) await unlink(filePath).catch(() => {}); + throw err; + } finally { + client.release(); + } + }); + + app.patch('/images/:id', async (req, reply) => { + if (!needUser(req, reply)) return; + const item = (await pool.query('SELECT * FROM image_assets WHERE id = $1', [req.params.id])).rows[0]; + if (!item || !canSeeImage(req.imageUser, item)) return fail(reply, 404, 'afbeelding onbekend'); + if (!canManageImage(req.imageUser, item)) return fail(reply, 403, 'geen rechten'); + const body = req.body || {}; + if (body.name === undefined && body.folder === undefined && body.themeIds === undefined) + return fail(reply, 400, 'niets te wijzigen'); + if (body.name !== undefined && !validName(body.name)) return fail(reply, 400, 'ongeldige naam'); + const folder = body.folder === undefined ? undefined : normaliseImageFolder(body.folder); + if (folder === null) return fail(reply, 400, 'ongeldige map'); + const themeIds = body.themeIds === undefined ? null : numericIds(body.themeIds); + if (body.themeIds !== undefined && themeIds.length !== new Set(body.themeIds.map(Number)).size) + return fail(reply, 400, 'ongeldige thema’s'); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + if (body.name !== undefined) + await client.query('UPDATE image_assets SET name = $1 WHERE id = $2', [body.name.trim(), item.id]); + if (folder !== undefined) { + await client.query('UPDATE image_assets SET folder = $1 WHERE id = $2', [folder, item.id]); + if (folder) await client.query( + `INSERT INTO image_folders (scope, school_id, owner_id, path) + VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`, + [item.scope, item.school_id, item.owner_id, folder]); + } + if (themeIds) { + const themes = themeIds.length + ? (await client.query('SELECT * FROM image_themes WHERE id = ANY($1)', [themeIds])).rows : []; + if (themes.length !== themeIds.length + || themes.some((theme) => !canSeeImage(req.imageUser, theme) || !compatibleTheme(item, theme))) { + await client.query('ROLLBACK'); + return fail(reply, 400, 'thema niet beschikbaar voor deze afbeelding'); + } + await client.query('DELETE FROM image_asset_themes WHERE asset_id = $1', [item.id]); + for (const themeId of themeIds) + await client.query('INSERT INTO image_asset_themes (asset_id, theme_id) VALUES ($1,$2)', [item.id, themeId]); + } + await client.query('COMMIT'); + return { ok: true }; + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + throw err; + } finally { + client.release(); + } + }); + + app.delete('/images/:id', async (req, reply) => { + if (!needUser(req, reply)) return; + const item = (await pool.query('SELECT * FROM image_assets WHERE id = $1', [req.params.id])).rows[0]; + if (!item || !canSeeImage(req.imageUser, item)) return fail(reply, 404, 'afbeelding onbekend'); + if (!canManageImage(req.imageUser, item)) return fail(reply, 403, 'geen rechten'); + if (item.public_path) return fail(reply, 403, 'ingebouwde afbeelding kan niet worden verwijderd'); + await pool.query('DELETE FROM image_assets WHERE id = $1', [item.id]); + if (safeStorageKey(item.storage_key)) + await unlink(join(storageDir, item.storage_key)).catch(() => {}); + return { ok: true }; + }); + + app.post('/images/themes', async (req, reply) => { + if (!needUser(req, reply)) return; + const { name, folder: rawFolder } = req.body || {}; + const folder = normaliseImageFolder(rawFolder || ''); + if (!validName(name) || folder == null) return fail(reply, 400, 'ongeldig thema'); + const user = req.imageUser, scope = imageScopeForUser(user); + if (scope === 'school' && user.school_id == null) return fail(reply, 400, 'geen school gekoppeld'); + const schoolId = scope === 'global' ? null : user.school_id; + try { + const row = (await pool.query( + `INSERT INTO image_themes (scope, school_id, owner_id, folder, name_nl, name_en) + VALUES ($1,$2,$3,$4,$5,$5) RETURNING *`, + [scope, schoolId, user.id, folder, name.trim()])).rows[0]; + return { theme: describeTheme(user, row) }; + } catch (err) { + if (err.code === '23505') return fail(reply, 409, 'thema bestaat al'); + throw err; + } + }); + + app.patch('/images/themes/:id', async (req, reply) => { + if (!needUser(req, reply)) return; + const item = (await pool.query('SELECT * FROM image_themes WHERE id = $1', [req.params.id])).rows[0]; + if (!item || !canSeeImage(req.imageUser, item)) return fail(reply, 404, 'thema onbekend'); + if (item.key || !canManageImage(req.imageUser, item)) return fail(reply, 403, 'geen rechten'); + const { name, folder: rawFolder } = req.body || {}; + const folder = rawFolder === undefined ? undefined : normaliseImageFolder(rawFolder); + if (name !== undefined && !validName(name)) return fail(reply, 400, 'ongeldige naam'); + if (folder === null) return fail(reply, 400, 'ongeldige map'); + if (name === undefined && folder === undefined) return fail(reply, 400, 'niets te wijzigen'); + try { + if (name !== undefined) + await pool.query('UPDATE image_themes SET name_nl = $1, name_en = $1 WHERE id = $2', [name.trim(), item.id]); + if (folder !== undefined) + await pool.query('UPDATE image_themes SET folder = $1 WHERE id = $2', [folder, item.id]); + return { ok: true }; + } catch (err) { + if (err.code === '23505') return fail(reply, 409, 'thema bestaat al'); + throw err; + } + }); + + app.delete('/images/themes/:id', async (req, reply) => { + if (!needUser(req, reply)) return; + const item = (await pool.query('SELECT * FROM image_themes WHERE id = $1', [req.params.id])).rows[0]; + if (!item || !canSeeImage(req.imageUser, item)) return fail(reply, 404, 'thema onbekend'); + if (item.key || !canManageImage(req.imageUser, item)) return fail(reply, 403, 'geen rechten'); + await pool.query('DELETE FROM image_themes WHERE id = $1', [item.id]); + return { ok: true }; + }); + + app.post('/images/folders', async (req, reply) => { + if (!needUser(req, reply)) return; + const path = normaliseImageFolder(req.body?.path, false); + if (!path) return fail(reply, 400, 'ongeldige map'); + const user = req.imageUser, scope = imageScopeForUser(user); + if (scope === 'school' && user.school_id == null) return fail(reply, 400, 'geen school gekoppeld'); + const schoolId = scope === 'global' ? null : user.school_id; + try { + const row = (await pool.query( + `INSERT INTO image_folders (scope, school_id, owner_id, path) + VALUES ($1,$2,$3,$4) RETURNING *`, + [scope, schoolId, user.id, path])).rows[0]; + return { folder: describeFolder(user, row) }; + } catch (err) { + if (err.code === '23505') return fail(reply, 409, 'map bestaat al'); + throw err; + } + }); + + app.patch('/images/folders/:id', async (req, reply) => { + if (!needUser(req, reply)) return; + const item = (await pool.query('SELECT * FROM image_folders WHERE id = $1', [req.params.id])).rows[0]; + if (!item || !canSeeImage(req.imageUser, item)) return fail(reply, 404, 'map onbekend'); + if (!canManageImage(req.imageUser, item)) return fail(reply, 403, 'geen rechten'); + const to = normaliseImageFolder(req.body?.path, false); + if (!to) return fail(reply, 400, 'ongeldige map'); + const from = item.path; + if (to === from || to.startsWith(from + '/')) return fail(reply, 400, 'map kan niet in zichzelf'); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const ctx = contextSql(item); + const descendants = (await client.query( + `SELECT id, path FROM image_folders WHERE ${ctx.sql} + AND (path = $${ctx.params.length + 1} OR path LIKE $${ctx.params.length + 2})`, + [...ctx.params, from, from + '/%'])).rows; + const assets = (await client.query( + `SELECT id, folder AS path FROM image_assets WHERE ${ctx.sql} + AND (folder = $${ctx.params.length + 1} OR folder LIKE $${ctx.params.length + 2})`, + [...ctx.params, from, from + '/%'])).rows; + const themes = (await client.query( + `SELECT id, folder AS path FROM image_themes WHERE ${ctx.sql} + AND (folder = $${ctx.params.length + 1} OR folder LIKE $${ctx.params.length + 2})`, + [...ctx.params, from, from + '/%'])).rows; + for (const row of descendants) + await client.query('UPDATE image_folders SET path = $1 WHERE id = $2', [to + row.path.slice(from.length), row.id]); + for (const row of assets) + await client.query('UPDATE image_assets SET folder = $1 WHERE id = $2', [to + row.path.slice(from.length), row.id]); + for (const row of themes) + await client.query('UPDATE image_themes SET folder = $1 WHERE id = $2', [to + row.path.slice(from.length), row.id]); + await client.query('COMMIT'); + return { ok: true }; + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + if (err.code === '23505') return fail(reply, 409, 'map bestaat al'); + throw err; + } finally { + client.release(); + } + }); + + app.delete('/images/folders/:id', async (req, reply) => { + if (!needUser(req, reply)) return; + const item = (await pool.query('SELECT * FROM image_folders WHERE id = $1', [req.params.id])).rows[0]; + if (!item || !canSeeImage(req.imageUser, item)) return fail(reply, 404, 'map onbekend'); + if (!canManageImage(req.imageUser, item)) return fail(reply, 403, 'geen rechten'); + const ctx = contextSql(item); + const count = Number((await pool.query( + `SELECT + (SELECT count(*) FROM image_folders WHERE ${ctx.sql} + AND path LIKE $${ctx.params.length + 1}) + + (SELECT count(*) FROM image_assets WHERE ${ctx.sql} + AND (folder = $${ctx.params.length + 2} OR folder LIKE $${ctx.params.length + 1})) AS n`, + [...ctx.params, item.path + '/%', item.path])).rows[0]?.n || 0); + if (count) return fail(reply, 409, 'map is niet leeg'); + await pool.query('DELETE FROM image_folders WHERE id = $1', [item.id]); + return { ok: true }; + }); + + app.get('/images/admin/quotas', async (req, reply) => { + if (!needUser(req, reply)) return; + if (!rolesOf(req.imageUser).includes('super')) return fail(reply, 403, 'geen rechten'); + const schools = (await pool.query( + `SELECT s.id, s.name, s.image_quota_bytes, + COALESCE(x.used,0)::bigint AS used + FROM schools s + LEFT JOIN (SELECT school_id, sum(size_bytes) AS used FROM image_assets + WHERE school_id IS NOT NULL GROUP BY school_id) x ON x.school_id = s.id + ORDER BY lower(s.name)`)).rows; + const users = (await pool.query( + `SELECT u.id, u.username, u.display_name, u.role, u.school_id, u.image_quota_bytes, + COALESCE(x.used,0)::bigint AS used, + COALESCE(r.roles, '{}') AS extra_roles + FROM users u + LEFT JOIN (SELECT owner_id, sum(size_bytes) AS used FROM image_assets + WHERE owner_id IS NOT NULL GROUP BY owner_id) x ON x.owner_id = u.id + LEFT JOIN (SELECT user_id, array_agg(role) AS roles FROM user_roles GROUP BY user_id) r ON r.user_id = u.id + ORDER BY u.school_id NULLS FIRST, lower(u.display_name)`)).rows; + return { + schools: schools.map((row) => ({ + id: Number(row.id), name: row.name, usedBytes: Number(row.used), + overrideBytes: row.image_quota_bytes == null ? null : Number(row.image_quota_bytes), + limitBytes: quotaOverride(row.image_quota_bytes, null), + })), + users: users.map((row) => { + const user = { role: row.role, allRoles: [row.role, ...(row.extra_roles || [])] }; + return { + id: Number(row.id), username: row.username, + displayName: row.display_name || row.username, role: row.role, + schoolId: row.school_id == null ? null : Number(row.school_id), + usedBytes: Number(row.used), + overrideBytes: row.image_quota_bytes == null ? null : Number(row.image_quota_bytes), + defaultBytes: defaultImageQuota(user), + limitBytes: quotaOverride(row.image_quota_bytes, defaultImageQuota(user)), + }; + }), + }; + }); + + const validQuota = (value) => value === null || value === -1 + || (Number.isInteger(value) && value >= 0 && value <= 10 * 1024 * IMAGE_MB); + app.patch('/images/admin/users/:id/quota', async (req, reply) => { + if (!needUser(req, reply)) return; + if (!rolesOf(req.imageUser).includes('super')) return fail(reply, 403, 'geen rechten'); + const value = req.body?.limitBytes; + if (!validQuota(value)) return fail(reply, 400, 'ongeldige limiet'); + const row = (await pool.query( + 'UPDATE users SET image_quota_bytes = $1 WHERE id = $2 RETURNING id', + [value, req.params.id])).rows[0]; + if (!row) return fail(reply, 404, 'gebruiker onbekend'); + return { ok: true }; + }); + app.patch('/images/admin/schools/:id/quota', async (req, reply) => { + if (!needUser(req, reply)) return; + if (!rolesOf(req.imageUser).includes('super')) return fail(reply, 403, 'geen rechten'); + const value = req.body?.limitBytes; + if (!validQuota(value)) return fail(reply, 400, 'ongeldige limiet'); + const row = (await pool.query( + 'UPDATE schools SET image_quota_bytes = $1 WHERE id = $2 RETURNING id', + [value, req.params.id])).rows[0]; + if (!row) return fail(reply, 404, 'school onbekend'); + return { ok: true }; + }); +} diff --git a/src/live-classroom.js b/src/live-classroom.js new file mode 100644 index 0000000..8fe777d --- /dev/null +++ b/src/live-classroom.js @@ -0,0 +1,443 @@ +// Bidirectionele live klasinteractie. +// PostgreSQL blijft de bron van waarheid; SSE meldt alleen dat een client de +// actuele snapshot opnieuw moet ophalen. Daardoor blijven reconnects veilig. + +const LIVE_WINDOW_MS = 45_000; +const HELP_IDLE_MS = 3 * 60_000; +const ACTION_KINDS = new Set(['hint', 'encourage', 'pause', 'resume', 'easier']); +const LEVEL_SUBJECTS = new Set(['taal', 'rekenen', 'world', 'creatief']); + +export default function registerLiveClassroom(app, options) { + const { + pool, need, fail, progressRoles, sameSchool, teacherOnly, + classOwnedByTeacher, effectiveAssignment, assignmentWidgets, + } = options; + const classStreams = new Map(); + const pupilStreams = new Map(); + + const publish = (streamMap, key, event) => { + const streams = streamMap.get(String(key)); + if (!streams) return; + for (const stream of [...streams]) { + try { stream.write(`event: ${event}\ndata: {}\n\n`); } + catch { streams.delete(stream); } + } + if (!streams.size) streamMap.delete(String(key)); + }; + const publishClass = (classId) => publish(classStreams, classId, 'sessions'); + const publishPupil = (pupilId) => publish(pupilStreams, pupilId, 'actions'); + + const describeSession = (row) => { + const lastSeenAt = row.last_seen_at || row.started_at; + const live = !row.ended_at && Date.now() - new Date(lastSeenAt).getTime() <= LIVE_WINDOW_MS; + const attempts = Number(row.attempts || 0); + const correct = Number(row.correct || 0); + const startedMs = new Date(row.started_at).getTime(); + const progressMs = row.last_progress_at ? new Date(row.last_progress_at).getTime() : 0; + let helpSignal = null; + if (live && !row.paused_at && attempts >= 5 && correct / attempts < 0.6) { + helpSignal = 'errors'; + } else if (live && !row.paused_at && Date.now() - startedMs >= HELP_IDLE_MS + && (!progressMs || Date.now() - progressMs >= HELP_IDLE_MS)) { + helpSignal = 'idle'; + } + return { + id: Number(row.id), + pupilId: Number(row.pupil_id), + pupilName: row.display_name || row.username, + widgetId: row.widget_id, + widgetType: row.widget_type, + mode: row.mode, + startedAt: row.started_at, + lastSeenAt, + endedAt: row.ended_at || null, + progressCount: Number(row.progress_count || 0), + attempts, + correct, + lastProgressAt: row.last_progress_at || null, + paused: !!row.paused_at, + pausedAt: row.paused_at || null, + helpSignal, + status: live ? 'live' : 'recent', + }; + }; + + const describeAction = (row) => ({ + id: Number(row.id), + kind: row.kind, + message: row.message || '', + payload: row.payload || {}, + createdAt: row.created_at, + }); + + const accessibleClass = async (req, reply) => { + const row = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!row) return { response: fail(reply, 404, 'klas onbekend') }; + if (!sameSchool(req, row)) return { response: fail(reply, 403, 'geen rechten') }; + if (teacherOnly(req) && !(await classOwnedByTeacher(row.id, req.user.id))) { + return { response: fail(reply, 403, 'geen rechten') }; + } + return { row }; + }; + + const accessiblePupil = async (req, reply, classRow) => { + if (!/^\d+$/.test(String(req.params.pupilId))) { + return { response: fail(reply, 400, 'ongeldige leerling') }; + } + const pupil = (await pool.query( + 'SELECT * FROM users WHERE id = $1 AND role = $2', + [req.params.pupilId, 'pupil'])).rows[0]; + if (!pupil) return { response: fail(reply, 404, 'leerling onbekend') }; + if (Number(pupil.class_id) !== Number(classRow.id)) { + return { response: fail(reply, 403, 'leerling hoort niet bij deze klas') }; + } + return { pupil }; + }; + + const openStream = (req, reply, streamMap, key) => { + reply.hijack(); + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + reply.raw.write('event: connected\ndata: {}\n\n'); + const streamKey = String(key); + if (!streamMap.has(streamKey)) streamMap.set(streamKey, new Set()); + streamMap.get(streamKey).add(reply.raw); + const heartbeat = setInterval(() => { + try { reply.raw.write(': heartbeat\n\n'); } catch { /* close ruimt op */ } + }, 15_000); + heartbeat.unref?.(); + const cleanup = () => { + clearInterval(heartbeat); + const streams = streamMap.get(streamKey); + if (streams) { + streams.delete(reply.raw); + if (!streams.size) streamMap.delete(streamKey); + } + }; + reply.raw.once('close', cleanup); + }; + + const trendDays = () => Array.from({ length: 7 }, (_, index) => { + const date = new Date(); + date.setHours(12, 0, 0, 0); + date.setDate(date.getDate() - (6 - index)); + return date.toISOString().slice(0, 10); + }); + + const buildTrends = (activityRows, completionRows) => { + const days = trendDays(); + const classMap = new Map(days.map((day) => [day, { + date: day, attempts: 0, correct: 0, stepCompleted: 0, updates: 0, + }])); + const pupilMaps = new Map(); + const pupilDay = (pupilId, day) => { + const key = String(pupilId); + if (!pupilMaps.has(key)) { + pupilMaps.set(key, new Map(days.map((date) => [date, { + date, attempts: 0, correct: 0, stepCompleted: 0, updates: 0, + }]))); + } + return pupilMaps.get(key).get(day); + }; + for (const row of activityRows) { + const day = String(row.day).slice(0, 10); + const classPoint = classMap.get(day); + const pupilPoint = pupilDay(row.pupil_id, day); + if (!classPoint || !pupilPoint) continue; + for (const point of [classPoint, pupilPoint]) { + point.attempts += Number(row.attempts || 0); + point.correct += Number(row.correct || 0); + point.updates += Number(row.updates || 0); + } + } + for (const row of completionRows) { + const day = String(row.day).slice(0, 10); + const classPoint = classMap.get(day); + const pupilPoint = pupilDay(row.pupil_id, day); + if (!classPoint || !pupilPoint) continue; + classPoint.stepCompleted += Number(row.completed || 0); + pupilPoint.stepCompleted += Number(row.completed || 0); + } + const finish = (points) => points.map((point) => { + const { stepCompleted, ...values } = point; + return { + ...values, + completed: Math.max(point.updates, stepCompleted), + accuracy: point.attempts ? Math.round((point.correct / point.attempts) * 100) : null, + }; + }); + return { + classTrend: finish([...classMap.values()]), + pupilTrends: Object.fromEntries( + [...pupilMaps.entries()].map(([pupilId, points]) => [pupilId, finish([...points.values()])])), + }; + }; + + const snapshot = async (classId) => { + const [sessionRows, activityRows, completionRows] = await Promise.all([ + pool.query( + `SELECT ls.*, u.display_name, u.username + FROM live_learning_sessions ls JOIN users u ON u.id = ls.pupil_id + WHERE ls.class_id = $1 + AND COALESCE(ls.last_seen_at, ls.started_at) >= now() - interval '24 hours' + ORDER BY + CASE WHEN ls.ended_at IS NULL AND ls.last_seen_at >= now() - interval '45 seconds' THEN 0 ELSE 1 END, + COALESCE(ls.last_seen_at, ls.started_at) DESC + LIMIT 80`, [classId]), + pool.query( + `SELECT pe.pupil_id, to_char(pe.created_at, 'YYYY-MM-DD') AS day, + SUM(pe.attempts) AS attempts, SUM(pe.correct) AS correct, COUNT(*) AS updates + FROM progress_events pe JOIN users u ON u.id = pe.pupil_id + WHERE u.class_id = $1 AND pe.created_at >= current_date - interval '6 days' + GROUP BY pe.pupil_id, to_char(pe.created_at, 'YYYY-MM-DD')`, [classId]), + pool.query( + `SELECT asp.pupil_id, to_char(asp.completed_at, 'YYYY-MM-DD') AS day, + COUNT(*) AS completed + FROM assignment_step_progress asp JOIN users u ON u.id = asp.pupil_id + WHERE u.class_id = $1 AND asp.completed_at >= current_date - interval '6 days' + GROUP BY asp.pupil_id, to_char(asp.completed_at, 'YYYY-MM-DD')`, [classId]), + ]); + const sessions = sessionRows.rows.map(describeSession); + const trends = buildTrends(activityRows.rows, completionRows.rows); + return { + sessions, + ...trends, + liveCount: sessions.filter((session) => session.status === 'live').length, + helpCount: sessions.filter((session) => session.helpSignal).length, + serverTime: new Date().toISOString(), + }; + }; + + app.post('/my/live-session/start', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + if (!req.user.class_id) return fail(reply, 400, 'leerling heeft geen klas'); + const { widgetId, widgetType, mode } = req.body ?? {}; + if (!widgetId || typeof widgetId !== 'string' || widgetId.length > 100) { + return fail(reply, 400, 'ongeldige widgetId'); + } + if (!widgetType || typeof widgetType !== 'string' || widgetType.length > 40) { + return fail(reply, 400, 'ongeldige widgetType'); + } + if (mode !== 'werken' && mode !== 'kijken') return fail(reply, 400, 'ongeldige modus'); + + const assignment = await effectiveAssignment(req.user); + if (!assignment) return fail(reply, 400, 'geen toewijzing'); + const steps = Array.isArray(assignment.sequence) ? assignment.sequence : []; + let boardId = assignment.board_id; + let assignedWidgetId = assignment.widget_id; + let assignedMode = assignment.mode || 'werken'; + let assignedStep = null; + if (steps.length) { + const completed = new Set((await pool.query( + 'SELECT step_id FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2', + [assignment.id, req.user.id])).rows.map((row) => row.step_id)); + const current = steps.find((step) => !completed.has(step.id)); + if (!current) return fail(reply, 409, 'opdrachtenreeks is al afgerond'); + assignedStep = current; + boardId = current.boardId || assignment.board_id; + assignedWidgetId = current.widgetId; + assignedMode = current.mode; + } + if (assignedWidgetId && assignedWidgetId !== widgetId) { + return fail(reply, 409, 'deze widget is niet de huidige opdracht'); + } + if (assignedMode !== mode) return fail(reply, 409, 'de opdrachtmodus is gewijzigd'); + + const widgets = await assignmentWidgets(assignment, boardId, assignedWidgetId, assignedStep); + const known = widgets.some((widget) => + widget.id === widgetType && (widget.wid ? widget.wid === widgetId : true)); + if (!known) return fail(reply, 409, 'widget staat niet meer in de toewijzing'); + + await pool.query( + `UPDATE live_learning_sessions + SET ended_at = now(), last_seen_at = now() + WHERE pupil_id = $1 AND ended_at IS NULL`, [req.user.id]); + await pool.query( + `DELETE FROM live_learning_sessions + WHERE pupil_id = $1 AND started_at < now() - interval '30 days'`, [req.user.id]); + const row = (await pool.query( + `INSERT INTO live_learning_sessions + (pupil_id, teacher_id, class_id, assignment_id, board_id, widget_id, widget_type, mode) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, + [req.user.id, assignment.teacher_id, req.user.class_id, assignment.id, + boardId, widgetId, widgetType, mode])).rows[0]; + publishClass(req.user.class_id); + return { + session: describeSession({ + ...row, display_name: req.user.display_name, username: req.user.username, + }), + }; + }); + + app.post('/my/live-session/:id/heartbeat', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + if (!/^\d+$/.test(String(req.params.id))) return fail(reply, 400, 'ongeldige sessie'); + const row = (await pool.query( + `UPDATE live_learning_sessions SET last_seen_at = now() + WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL + RETURNING id, paused_at`, + [req.params.id, req.user.id])).rows[0]; + if (!row) return fail(reply, 404, 'sessie niet actief'); + return { ok: true, paused: !!row.paused_at }; + }); + + app.post('/my/live-session/:id/end', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + if (!/^\d+$/.test(String(req.params.id))) return fail(reply, 400, 'ongeldige sessie'); + const row = (await pool.query( + `UPDATE live_learning_sessions SET ended_at = now(), last_seen_at = now() + WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL RETURNING class_id`, + [req.params.id, req.user.id])).rows[0]; + if (row) publishClass(row.class_id); + return { ok: true }; + }); + + app.get('/my/live-actions', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + const [actions, session] = await Promise.all([ + pool.query( + `SELECT * FROM teacher_pupil_actions + WHERE pupil_id = $1 AND acknowledged_at IS NULL + AND created_at >= now() - interval '24 hours' + ORDER BY created_at, id LIMIT 20`, [req.user.id]), + pool.query( + `SELECT id, paused_at FROM live_learning_sessions + WHERE pupil_id = $1 AND ended_at IS NULL + ORDER BY started_at DESC LIMIT 1`, [req.user.id]), + ]); + return { + actions: actions.rows.map(describeAction), + paused: !!session.rows[0]?.paused_at, + }; + }); + + app.post('/my/live-actions/:id/ack', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + if (!/^\d+$/.test(String(req.params.id))) return fail(reply, 400, 'ongeldige actie'); + await pool.query( + `UPDATE teacher_pupil_actions SET acknowledged_at = now() + WHERE id = $1 AND pupil_id = $2 AND acknowledged_at IS NULL`, + [req.params.id, req.user.id]); + return { ok: true }; + }); + + app.get('/my/live-actions/stream', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + openStream(req, reply, pupilStreams, req.user.id); + }); + + app.get('/progress/live/class/:id', async (req, reply) => { + need(req, reply, progressRoles); + const access = await accessibleClass(req, reply); + if (!access.row) return access.response; + return snapshot(access.row.id); + }); + + app.get('/progress/live/class/:id/stream', async (req, reply) => { + need(req, reply, progressRoles); + const access = await accessibleClass(req, reply); + if (!access.row) return access.response; + openStream(req, reply, classStreams, access.row.id); + }); + + app.post('/progress/live/class/:id/pupil/:pupilId/action', async (req, reply) => { + need(req, reply, progressRoles); + const access = await accessibleClass(req, reply); + if (!access.row) return access.response; + const pupilAccess = await accessiblePupil(req, reply, access.row); + if (!pupilAccess.pupil) return pupilAccess.response; + const pupil = pupilAccess.pupil; + const { kind, subject } = req.body ?? {}; + if (!ACTION_KINDS.has(kind)) return fail(reply, 400, 'ongeldige live-actie'); + const rawMessage = typeof req.body?.message === 'string' ? req.body.message.trim() : ''; + if (rawMessage.length > 160) return fail(reply, 400, 'bericht is te lang'); + if ((kind === 'hint' || kind === 'encourage') && !rawMessage) { + return fail(reply, 400, 'bericht ontbreekt'); + } + + let payload = {}; + if (kind === 'pause' || kind === 'resume') { + const pausedAt = kind === 'pause' ? 'now()' : 'NULL'; + const session = (await pool.query( + `UPDATE live_learning_sessions SET paused_at = ${pausedAt}, last_seen_at = now() + WHERE id = ( + SELECT id FROM live_learning_sessions + WHERE pupil_id = $1 AND class_id = $2 AND ended_at IS NULL + AND last_seen_at >= now() - interval '45 seconds' + ORDER BY started_at DESC LIMIT 1 + ) RETURNING id`, + [pupil.id, access.row.id])).rows[0]; + if (!session) return fail(reply, 409, 'leerling heeft geen actieve sessie'); + } + + if (kind === 'easier') { + if (!LEVEL_SUBJECTS.has(subject)) return fail(reply, 400, 'ongeldig vakgebied'); + let level = (await pool.query( + 'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2', + [pupil.id, subject])).rows[0]; + if (!level) { + level = (await pool.query( + 'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2', + [access.row.id, subject])).rows[0]; + } + if (!level) return fail(reply, 409, 'stel eerst een leerjaar voor dit vakgebied in'); + const oldYearGroup = Number(level.year_group); + const oldLevel = Number(level.level); + const yearGroup = oldLevel > 1 ? oldYearGroup : Math.max(1, oldYearGroup - 1); + const easierLevel = oldLevel > 1 ? oldLevel - 1 : 1; + await pool.query( + `INSERT INTO pupil_levels (pupil_id, subject, year_group, level) + VALUES ($1,$2,$3,$4) + ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL + DO UPDATE SET year_group = $3, level = $4, updated_at = now()`, + [pupil.id, subject, yearGroup, easierLevel]); + payload = { subject, yearGroup, level: easierLevel }; + } + + await pool.query( + `DELETE FROM teacher_pupil_actions + WHERE pupil_id = $1 AND created_at < now() - interval '30 days'`, [pupil.id]); + const action = (await pool.query( + `INSERT INTO teacher_pupil_actions + (class_id, pupil_id, teacher_id, kind, message, payload) + VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, + [access.row.id, pupil.id, req.user.id, kind, rawMessage || null, + JSON.stringify(payload)])).rows[0]; + publishPupil(pupil.id); + publishClass(access.row.id); + return { ok: true, action: describeAction(action) }; + }); + + return { + async isPaused(pupilId, sessionId) { + if (!Number.isSafeInteger(Number(sessionId)) || Number(sessionId) <= 0) return false; + const row = (await pool.query( + `SELECT paused_at FROM live_learning_sessions + WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL`, + [sessionId, pupilId])).rows[0]; + return !!row?.paused_at; + }, + async recordProgress(pupilId, sessionId, attempts, correct) { + if (!Number.isSafeInteger(Number(sessionId)) || Number(sessionId) <= 0) return; + const row = (await pool.query( + `UPDATE live_learning_sessions + SET last_seen_at = now(), last_progress_at = now(), + progress_count = progress_count + 1, + attempts = attempts + $3, correct = correct + $4 + WHERE id = $1 AND pupil_id = $2 AND ended_at IS NULL AND paused_at IS NULL + RETURNING class_id`, + [sessionId, pupilId, attempts, correct])).rows[0]; + if (row) publishClass(row.class_id); + }, + }; +} diff --git a/src/migrate.js b/src/migrate.js new file mode 100644 index 0000000..a9b3a65 --- /dev/null +++ b/src/migrate.js @@ -0,0 +1,48 @@ +// Automatische, veilige migraties bij het opstarten. +// +// Veiligheid: +// - alleen vooruit: elk .sql-bestand in db/ wordt precies één keer uitgevoerd +// en daarna geregistreerd in schema_migrations; +// - elk bestand draait in zijn eigen transactie: mislukt er iets, dan wordt +// alles van dat bestand teruggedraaid en start de app NIET (bestaande data +// blijft onaangeroerd); +// - een advisory lock voorkomt dat twee instanties tegelijk migreren; +// - de migratiebestanden zelf zijn idempotent (IF NOT EXISTS), dus ook een +// database waarop ze al handmatig zijn uitgevoerd blijft gewoon werken. +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const LOCK_KEY = 727271; + +export async function runMigrations(pool, log, dir) { + const client = await pool.connect(); + try { + await client.query('SELECT pg_advisory_lock($1)', [LOCK_KEY]); + await client.query(`CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`); + const done = new Set( + (await client.query('SELECT filename FROM schema_migrations')).rows.map((r) => r.filename), + ); + const files = (await readdir(dir)).filter((f) => f.endsWith('.sql')).sort(); + for (const f of files) { + if (done.has(f)) continue; + const sql = await readFile(join(dir, f), 'utf8'); + log.info(`migratie uitvoeren: ${f}`); + try { + await client.query('BEGIN'); + await client.query(sql); + await client.query('INSERT INTO schema_migrations (filename) VALUES ($1)', [f]); + await client.query('COMMIT'); + log.info(`migratie klaar: ${f}`); + } catch (e) { + await client.query('ROLLBACK'); + throw new Error(`migratie ${f} mislukt (teruggedraaid, data ongewijzigd): ${e.message}`); + } + } + } finally { + await client.query('SELECT pg_advisory_unlock($1)', [LOCK_KEY]).catch(() => {}); + client.release(); + } +} diff --git a/src/permissions.js b/src/permissions.js new file mode 100644 index 0000000..5f8d313 --- /dev/null +++ b/src/permissions.js @@ -0,0 +1,37 @@ +// teach: centrale permissiematrix voor de vaste rollen, inclusief ouder. +// Gespiegeld in public/js/permissions.js (geen bundler in dit project, dus twee +// bestanden die dezelfde waarden hand-in-hand bijhouden). Dit is geen database- +// gestuurd/rechten-per-gebruiker-configureerbaar systeem, maar één benoemde bron +// i.p.v. dezelfde rol-arrays los verspreid over de routes. +export const PERMISSIONS = { + 'admin.access': ['super', 'admin', 'teacher'], + 'curriculum.view': ['super', 'admin', 'teacher', 'parent'], + 'password.own.change': ['super', 'admin', 'teacher'], + 'schools.manage': ['super'], + 'schools.resetPasswords': ['super', 'admin'], + 'classes.view': ['super', 'admin', 'teacher'], + 'classes.manage': ['super', 'admin'], + 'users.manage': ['super', 'admin', 'teacher'], + 'users.staffCredentials': ['super', 'admin'], + 'users.genpw': ['super', 'admin', 'teacher'], + 'users.role.change': ['super'], + 'assignments.manage': ['super', 'admin', 'teacher'], + 'progress.view': ['super', 'admin', 'teacher'], + 'shared.global.manage': ['super'], + 'content.manage': ['super'], + 'shared.school.manage': ['super', 'admin', 'teacher'], +}; + +// Welke rol een account met welke rol mag aanmaken. +export const CREATABLE_ROLES = { + super: ['super', 'admin', 'teacher', 'pupil'], + admin: ['admin', 'teacher', 'pupil'], + teacher: ['pupil'], +}; + +// roles mag een losse rol-string zijn (bestaande aanroepen) of een array van alle +// rollen die een gebruiker heeft (hoofdrol + eventuele extra stafrollen). +export const can = (roles, action) => { + const list = Array.isArray(roles) ? roles : [roles]; + return (PERMISSIONS[action] || []).some(r => list.includes(r)); +}; diff --git a/src/server.js b/src/server.js index 14afce0..b5e3478 100644 --- a/src/server.js +++ b/src/server.js @@ -1,17 +1,32 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import zlib from 'node:zlib'; import Fastify from 'fastify'; -import fastifyStatic from '@fastify/static'; +import fastifyCookie from '@fastify/cookie'; +import fastifyCompress from '@fastify/compress'; +import fastifyHelmet from '@fastify/helmet'; +import fastifyRateLimit from '@fastify/rate-limit'; import pg from 'pg'; +import api, { bootstrapSuper } from './api.js'; +import imageApi from './images.js'; +import { registerFrontend, resolveAppVersion } from './frontend.js'; +import { runMigrations } from './migrate.js'; +import { registerEmptyJsonBodyParser } from './body-parser.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT ?? 3000); const HOST = process.env.HOST ?? '0.0.0.0'; +const TRUST_PROXY_HOPS = Math.max(0, Number(process.env.TRUST_PROXY_HOPS ?? 2)); +const IS_PRODUCTION = process.env.NODE_ENV === 'production'; const app = Fastify({ logger: true, - trustProxy: true, // achter nginx reverse proxy + // Pangolin/Traefik en nginx vormen normaal twee vertrouwde proxy-hops. + // Vertrouw nooit willekeurige X-Forwarded-* headers van directe clients. + trustProxy: TRUST_PROXY_HOPS, + // Alleen de borddata-route krijgt expliciet een ruimere limiet. + bodyLimit: 1024 * 1024, }); // --- Database pool ----------------------------------------------------------- @@ -24,6 +39,8 @@ const pool = new pg.Pool({ // Maak de pool bereikbaar in routes via app.pg app.decorate('pg', pool); +registerEmptyJsonBodyParser(app); + // --- Health checks (gebruikt door Docker + load balancer) -------------------- app.get('/healthz', async () => ({ status: 'ok' })); @@ -38,29 +55,65 @@ app.get('/readyz', async (req, reply) => { } }); -// --- API --------------------------------------------------------------------- -// Voorbeeld-endpoint. Hier komt straks de board/user-API die nu nog in -// localStorage zit. Prefix /api houdt het netjes gescheiden van de static app. -app.register( - async (api) => { - api.get('/version', async () => ({ - name: 'teach', - version: process.env.APP_VERSION ?? 'dev', - })); +// --- HTTP-beveiliging -------------------------------------------------------- +await app.register(fastifyCookie); +await app.register(fastifyRateLimit, { global: false }); +await app.register(fastifyHelmet, { + global: true, + hsts: IS_PRODUCTION ? { maxAge: 31536000, includeSubDomains: true } : false, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'blob:', 'https:'], + mediaSrc: ["'self'", 'blob:', 'https:'], + connectSrc: ["'self'", 'https://api.arasaac.org'], + frameSrc: ['https://www.youtube.com', 'https://player.vimeo.com'], + objectSrc: ["'none'"], + baseUri: ["'self'"], + formAction: ["'self'"], + frameAncestors: ["'none'"], + upgradeInsecureRequests: IS_PRODUCTION ? [] : null, + }, }, - { prefix: '/api' }, -); + referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, +}); +app.addHook('onSend', async (_req, reply) => { + reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)'); +}); + +// --- Compressie --------------------------------------------------------------- +// Vóór de API- en frontend-registraties: de onSend-hook van compress geldt +// alleen voor routes die daarna worden toegevoegd. Brotli op quality 4: +// vergelijkbare ratio als gzip-9 tegen een fractie van de CPU (belangrijk voor +// grote dynamische antwoorden zoals /me/data). +await app.register(fastifyCompress, { + global: true, + encodings: ['br', 'gzip'], + threshold: 1024, + brotliOptions: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } }, + zlibOptions: { level: 6 }, +}); + +// --- API --------------------------------------------------------------------- +// Inloggen, rollen, gebruikersbeheer en per-gebruiker data. Zie src/api.js. +app.register(api, { prefix: '/api' }); +app.register(imageApi, { prefix: '/api' }); // --- Static frontend --------------------------------------------------------- -// Serveert public/index.html (de digibord-app) op / -app.register(fastifyStatic, { +// index.html met versie-injectie (cache-busting) + cache-headers per prefix; +// zie src/frontend.js voor het beleid. +await registerFrontend(app, { root: join(__dirname, '..', 'public'), - index: ['index.html'], + version: resolveAppVersion(), }); // --- Start ------------------------------------------------------------------- const start = async () => { try { + await runMigrations(pool, app.log, join(__dirname, '..', 'db')); + await bootstrapSuper(pool, app.log); await app.listen({ port: PORT, host: HOST }); } catch (err) { app.log.error(err); diff --git a/test/api-security.test.js b/test/api-security.test.js new file mode 100644 index 0000000..c5be7d0 --- /dev/null +++ b/test/api-security.test.js @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import cookie from '@fastify/cookie'; +import rateLimit from '@fastify/rate-limit'; +import api from '../src/api.js'; +import { hashPassword } from '../src/auth.js'; + +async function makeApp() { + const passwordHash = await hashPassword('Veilig123'); + const calls = []; + const pool = { + async query(sql, params=[]) { + calls.push({sql, params}); + if (sql.includes('FROM users WHERE school_id = $1')) return {rows:[{ + id: 7, username: 'docent', display_name: 'Docent', role: 'teacher', + school_id: 2, class_id: null, password_hash: passwordHash, data: {}, + }]}; + return {rows:[]}; + }, + }; + const app = Fastify({trustProxy: 2}); + await app.register(cookie); + await app.register(rateLimit, {global:false}); + app.decorate('pg', pool); + await app.register(api, {prefix:'/api'}); + await app.ready(); + return {app, calls}; +} + +test('login zet HttpOnly-cookie en bewaart alleen een tokenhash', async () => { + const oldNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + const {app, calls} = await makeApp(); + const res = await app.inject({method:'POST', url:'/api/auth/login', payload:{ + schoolId:2, username:'docent', password:'Veilig123', + }}); + assert.equal(res.statusCode, 200, res.body); + assert.equal('token' in res.json(), false); + const setCookie = res.headers['set-cookie']; + assert.match(setCookie, /teach_session=/); + assert.match(setCookie, /HttpOnly/); + assert.match(setCookie, /Secure/); + assert.match(setCookie, /SameSite=Strict/); + const rawToken = /teach_session=([^;]+)/.exec(setCookie)[1]; + const insert = calls.find(c=>c.sql.includes('INSERT INTO sessions')); + assert.ok(insert); + assert.notEqual(insert.params[0], rawToken); + assert.match(insert.params[0], /^[a-f0-9]{64}$/); + await app.close(); + if (oldNodeEnv === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = oldNodeEnv; +}); + +test('state-changing request met vreemde Origin wordt geweigerd', async () => { + const {app} = await makeApp(); + const res = await app.inject({method:'POST', url:'/api/auth/logout', headers:{ + origin:'https://aanvaller.example', host:'teach.example', + }, payload:{}}); + assert.equal(res.statusCode, 403); + await app.close(); +}); diff --git a/test/assignment-composer.test.js b/test/assignment-composer.test.js new file mode 100644 index 0000000..9742ec7 --- /dev/null +++ b/test/assignment-composer.test.js @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +test('leerkracht bouwt visueel een reeks en geeft die in één actie aan meerdere doelgroepen', async () => { + const [cast, admin, css, core, ...wordWidgets] = await Promise.all([ + readFile('public/js/cast.js', 'utf8'), + readFile('public/js/admin.js', 'utf8'), + readFile('public/css/teach.css', 'utf8'), + readFile('public/js/core.js', 'utf8'), + readFile('public/js/widgets/letters.js', 'utf8'), + readFile('public/js/widgets/flits.js', 'utf8'), + readFile('public/js/widgets/hak.js', 'utf8'), + readFile('public/js/widgets/ball.js', 'utf8'), + ]); + + assert.match(cast, /castShareKind\s*=\s*"path"/); + assert.match(cast, /castSelectedTargets\s*=\s*new Set/); + assert.match(cast, /assignmentLibraryWidgets\(\)[\s\S]*REGISTRY\.filter/); + assert.match(cast, /castThemeCatalog[\s\S]*languageThemeList[\s\S]*THEMES\[LANG\]/); + assert.match(cast, /cast-widget-card/); + assert.match(cast, /castPathSteps\.push/); + assert.match(cast, /slice\(0,12-castPathSteps\.length\)/); + assert.match(cast, /widgetType:step\.widgetType[\s\S]*theme:castThemePayload/); + assert.doesNotMatch(cast, /currentBoardWidgets/); + assert.match(cast, /castPupilCoveredByClass/); + assert.match(cast, /Promise\.allSettled\(targets\.map/); + assert.match(cast, /castSendSelected/); + + assert.match(admin, /assignment-composer-cta[\s\S]*btnCast/); + assert.match(css, /cast-widget-grid[\s\S]*cast-path-step[\s\S]*cast-target-grid/); + assert.match(css, /cast-sendbar\{position:sticky/); + assert.match(css, /@media \(max-width:700px\)[\s\S]*cast-composer-sheet/); + for (const widget of wordWidgets) { + assert.match(widget, /assignmentWords/); + assert.match(widget, /assignmentThemeName/); + assert.match(widget, /srcSel\.disabled=true/); + } + + for (const key of [ + 'castIntro', 'castLibrarySubtitle', 'castKindPath', 'castLiveStateHint', + 'castWidgetLibrary', 'castSequenceOrder', 'castTheme', 'castThemeNone', + 'castBuiltInThemes', 'castOwnThemes', 'castIndependentStep', + 'castAudienceTitle', 'castSelectVisible', 'castSend', 'castSent', + 'castNeedPath', 'castNeedTarget', 'castOpenComposer', + ]) { + assert.equal(core.split(`${key}:`).length - 1, 2, `${key} moet tweetalig zijn`); + } +}); diff --git a/test/auth.test.js b/test/auth.test.js new file mode 100644 index 0000000..3b77fdc --- /dev/null +++ b/test/auth.test.js @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + hashPassword, verifyPassword, newToken, hashSessionToken, + pupilPassword, staffPassword, checkPassword, sessionDaysForRole, +} from '../src/auth.js'; + +test('wachtwoordhash verifieert alleen het juiste wachtwoord', async () => { + const hash = await hashPassword('Veilig123'); + assert.equal(await verifyPassword('Veilig123', hash), true); + assert.equal(await verifyPassword('verkeerd', hash), false); + assert.equal(hash.includes('Veilig123'), false); +}); + +test('sessietokens zijn sterk en worden gehasht opgeslagen', () => { + const token = newToken(); + assert.match(token, /^[a-f0-9]{64}$/); + assert.match(hashSessionToken(token), /^[a-f0-9]{64}$/); + assert.notEqual(hashSessionToken(token), token); +}); + +test('gegenereerde wachtwoorden voldoen aan het beleid', () => { + const pupil = pupilPassword(); + const staff = staffPassword(); + assert.equal(checkPassword(pupil, 'pupil'), null); + assert.equal(checkPassword(staff, 'teacher'), null); + assert.match(pupil, /^[a-z]+-[a-z]+-[a-z]+-\d{3}$/); + assert.ok(staff.length >= 14); +}); + +test('sessieduur wordt korter bij meer privileges', () => { + assert.equal(sessionDaysForRole('super'), 1); + assert.equal(sessionDaysForRole('admin'), 1); + assert.equal(sessionDaysForRole('teacher'), 7); + assert.equal(sessionDaysForRole('pupil'), 30); +}); diff --git a/test/avatar.test.js b/test/avatar.test.js new file mode 100644 index 0000000..129243f --- /dev/null +++ b/test/avatar.test.js @@ -0,0 +1,303 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {runInNewContext} from 'node:vm'; + +function fakeAvatarElement(){ + const classes=new Set(); + return { + attributes:{}, dataset:{}, children:[], innerHTML:"", removed:false, + classList:{add(...ids){ids.forEach(id=>classes.add(id));},remove(...ids){ids.forEach(id=>classes.delete(id));},contains(id){return classes.has(id);}}, + setAttribute(name,value){this.attributes[name]=String(value);}, + appendChild(child){this.children.push(child);return child;}, + remove(){this.removed=true;}, + }; +} + +async function loadAvatarRenderer(){ + const source=await readFile('public/js/avatar.js','utf8'); + const cutoff=source.indexOf('/* bouwt de interactieve kiezer'); + assert.ok(cutoff>0,'rendergedeelte van avatar.js ontbreekt'); + const timers=[]; + const context={ + T:key=>key, + currentUser:{avatar:{species:"animal",speciesVariant:"cat",bodyColor:"#3fae6a"}}, + window:{matchMedia:()=>({matches:false})}, + setTimeout(fn,delay){timers.push({fn,delay});}, + document:{createElement:fakeAvatarElement,createElementNS:fakeAvatarElement}, + }; + runInNewContext(source.slice(0,cutoff)+ + ';globalThis.__avatar={renderAvatarFace,avatarBodyMarkup,sanitize:avatarSanitize,variants:AVATAR_VARIANTS,hair:AVATAR_HAIR_MARKUP,bodyAccessories:AVATAR_BODY_ACC_MARKUP,headAccessories:AVATAR_HEAD_ACC_MARKUP,hairIds:AVATAR_HAIR,headIds:AVATAR_HEAD_ACC,bodyIds:AVATAR_BODY_ACC,dances:AVATAR_DANCES,nextDance:avatarDanceChoice,celebrate:celebrateAvatar,unlock:avatarAcceptRewards};', + context); + context.__avatar.timers=timers; + return context.__avatar; +} + +const base={ + species:'dino', + speciesVariant:'rex', + bodyColor:'#3fae6a', + hairStyle:'none', + hairColor:'#5b3a29', + accessoryHead:'none', + accessoryBody:'none', +}; + +test("iedere categorie heeft vier anatomisch herkenbare avatarvarianten",async()=>{ + const api=await loadAvatarRenderer(); + const variants={ + dino:["rex","triceratops","stegosaurus","raptor"], + monster:["horned","cyclops","slime","alien"], + animal:["cat","dog","rabbit","bear"], + }; + for(const [species,ids] of Object.entries(variants)){ + assert.deepEqual(Array.from(api.variants[species]),ids); + const bodies=[]; + for(const id of ids){ + const markup=api.avatarBodyMarkup(species,base.bodyColor,id); + bodies.push(markup); + assert.ok(markup.includes("av-creature-"+id),species+"/"+id+" mist variantklasse"); + assert.equal((markup.match(/av-arm av-arm-/g)||[]).length,2,species+"/"+id+" mist twee armen"); + assert.equal((markup.match(/av-leg av-leg-/g)||[]).length,2,species+"/"+id+" mist twee benen"); + for(const part of ["av-neck","av-torso-"+species,"av-head-large","av-eye"]){ + assert.ok(markup.includes(part),species+"/"+id+" mist "+part); + } + assert.ok(markup.indexOf("av-arm")api.avatarBodyMarkup(species,base.bodyColor,id); + assert.ok(markup("dino","rex").includes("av-tail-rex")); + assert.ok(markup("dino","triceratops").includes("av-frill-triceratops")); + assert.ok(markup("dino","triceratops").includes("av-nose-horn")); + assert.ok(markup("dino","stegosaurus").includes("av-dino-plates")); + assert.ok(markup("dino","raptor").includes("av-feather-crest-raptor")); + assert.ok(markup("dino","raptor").includes("av-feather-chest")); + + assert.ok(markup("monster","horned").includes("av-horn-left")); + assert.equal((markup("monster","cyclops").match(/class="av-part av-eye/g)||[]).length,1); + assert.ok(markup("monster","cyclops").includes("av-cyclops-eye")); + assert.ok(markup("monster","slime").includes("av-slime-drop")); + assert.equal((markup("monster","alien").match(/av-alien-eye/g)||[]).length,3); + assert.ok(markup("monster","alien").includes("av-antenna-tip")); + + assert.ok(markup("animal","cat").includes("av-whiskers")); + assert.ok(markup("animal","dog").includes("av-ear-dog")); + assert.ok(markup("animal","dog").includes("av-tongue")); + assert.ok(markup("animal","rabbit").includes("av-ear-rabbit")); + assert.ok(markup("animal","rabbit").includes("av-rabbit-teeth")); + assert.ok(markup("animal","bear").includes("av-ear-bear")); + assert.ok(markup("animal","bear").includes("av-muzzle-bear")); + + assert.equal(api.sanitize({species:"dino"}).speciesVariant,"rex"); + assert.equal(api.sanitize({species:"monster"}).speciesVariant,"horned"); + assert.equal(api.sanitize({species:"animal"}).speciesVariant,"cat"); + assert.equal(api.sanitize({species:"animal",speciesVariant:"rabbit"}).speciesVariant,"rabbit"); + assert.equal(api.sanitize({species:"monster",speciesVariant:"rabbit"}).speciesVariant,"horned"); +}); + +test("voltooide opdrachten rouleren door vijf basisdansen en een vrijspeelbare robotdans",async()=>{ + const api=await loadAvatarRenderer(); + const basic=["bounce","twist","spin","shuffle","wiggle"]; + const dances=[...basic,"robot"]; + assert.deepEqual(Array.from(api.dances),dances); + assert.deepEqual(basic.map(()=>api.nextDance()),basic); + assert.equal(api.nextDance("robot"),"bounce","robotdans blijft eerst vergrendeld"); + api.unlock(["dance:robot"]); + assert.equal(api.nextDance("robot"),"robot"); + assert.equal(api.nextDance("spin"),"spin"); + + const hostClasses=new Set(),stages=[]; + const host={ + classList:{add:id=>hostClasses.add(id),remove:id=>hostClasses.delete(id)}, + querySelectorAll:()=>stages.filter(stage=>!stage.removed), + querySelector:()=>stages.find(stage=>!stage.removed)||null, + appendChild(stage){stages.push(stage);}, + }; + const stage=api.celebrate(host,{dance:"shuffle"}); + assert.equal(stage.dataset.dance,"shuffle"); + assert.equal(stage.attributes["aria-hidden"],"true"); + assert.equal(stage.children.length,1); + assert.ok(stage.children[0].classList.contains("av-dancer")); + assert.ok(hostClasses.has("avatar-dance-host")); + assert.equal(api.timers[0].delay,3400); + api.timers[0].fn(); + assert.equal(stage.removed,true); + assert.equal(hostClasses.has("avatar-dance-host"),false); + + const [css,letters,math,flits,ball]=await Promise.all([ + readFile("public/css/teach.css","utf8"), + readFile("public/js/widgets/letters.js","utf8"), + readFile("public/js/widgets/math.js","utf8"), + readFile("public/js/widgets/flits.js","utf8"), + readFile("public/js/widgets/ball.js","utf8"), + ]); + for(const dance of dances){ + assert.ok(css.includes(".av-dance-"+dance),dance+" mist een dansklasse"); + assert.ok(css.includes("@keyframes avDance"+dance[0].toUpperCase()+dance.slice(1)),dance+" mist keyframes"); + } + assert.ok(css.includes(".av-dance-reduced"),"verminderde beweging mist een statische terugval"); + assert.match(letters,/function showCardDone\(\)[\s\S]*celebrateAvatar\(root\)/); + assert.match(math,/function endRound\(\)[\s\S]*celebrateAvatar\(root\)/); + assert.match(flits,/S\.idx >= S\.order\.length\)\{ celebrateAvatar\(root\); newRound\(\); \}/); + assert.match(ball,/S\.idx >= S\.order\.length\) celebrateAvatar\(root\)/); +}); + +test("haar volgt de kop met natuurlijke details en correcte lagen",async()=>{ + const api=await loadAvatarRenderer(); + assert.deepEqual(Array.from(api.hairIds),["none","tuft","spiky","curly","wavy","bob","ponytail","braids","mohawk"]); + for(const id of api.hairIds){ + assert.equal(typeof api.hair[id].back,"function",id+" mist haar-achterlaag"); + assert.equal(typeof api.hair[id].front,"function",id+" mist haar-voorlaag"); + } + + for(const id of api.hairIds.filter(id=>id!=="none")){ + const front=api.hair[id].front("#123456"); + const back=api.hair[id].back("#123456"); + const combined=back+front; + assert.ok(front.includes("av-hair-"+id),id+" mist een herkenbare voorlaag"); + assert.ok(combined.includes("av-hair-texture"),id+" mist natuurlijke haartextuur"); + assert.doesNotMatch(combined,/undefined|NaN/,id+" bevat ongeldige SVG-waarden"); + } + for(const id of ["curly","wavy","bob","ponytail","braids"]){ + assert.notEqual(api.hair[id].back("#123456"),"",id+" mist volume achter de kop"); + } + for(const id of ["tuft","spiky","mohawk"]){ + assert.equal(api.hair[id].back("#123456"),"",id+" hoort geen onzichtbare achterlaag te hebben"); + } + for(const id of ["wavy","bob","ponytail","braids"]){ + assert.ok(api.hair[id].front("#123456").includes("av-hair-part"),id+" mist een natuurlijke scheiding"); + } + assert.ok(api.hair.wavy.front("#123456").includes("av-side-lock")); + assert.ok(api.hair.bob.front("#123456").includes("av-side-lock")); + assert.ok(api.hair.ponytail.back("#123456").includes("av-hair-tie")); + assert.ok(api.hair.braids.back("#123456").includes("av-hair-tie")); + + const transforms={dino:"translate(1 0)",monster:"translate(0 -1)",animal:"translate(0 0)"}; + for(const [species,transform] of Object.entries(transforms)){ + const markup=api.renderAvatarFace({...base,species,hairStyle:"wavy",hairColor:"#123456"},"lg").innerHTML; + assert.equal((markup.match(new RegExp("av-hair-fit-"+species,"g"))||[]).length,2,species+" mist voor- of achterpasvorm"); + assert.ok(markup.includes(`transform="${transform}"`),species+" gebruikt een verkeerde kruincorrectie"); + assert.ok(markup.indexOf("av-hair-back-layer")markup.indexOf("av-head-large")); + } + + const bob=api.renderAvatarFace({...base,hairStyle:"bob"},"lg").innerHTML; + assert.ok(bob.indexOf("av-hair-bob")bob.indexOf("av-head-large")); + const braids=api.renderAvatarFace({...base,hairStyle:"braids"},"lg").innerHTML; + assert.ok(braids.indexOf("av-hair-braids")braids.indexOf("av-head-large")); + + const cap=api.renderAvatarFace({...base,hairStyle:"spiky",hairColor:"#123456",accessoryHead:"cap"},"lg").innerHTML; + const beanie=api.renderAvatarFace({...base,hairStyle:"spiky",hairColor:"#123456",accessoryHead:"beanie"},"lg").innerHTML; + const crown=api.renderAvatarFace({...base,hairStyle:"spiky",hairColor:"#123456",accessoryHead:"crown"},"lg").innerHTML; + const cappedBraids=api.renderAvatarFace({...base,hairStyle:"braids",hairColor:"#123456",accessoryHead:"cap"},"lg").innerHTML; + assert.ok(!cap.includes("av-hair-front-layer"),"pet hoort kort haar af te dekken"); + assert.ok(!beanie.includes("av-hair-front-layer"),"muts hoort kort haar af te dekken"); + assert.ok(crown.includes("av-hair-front-layer"),"kroon hoort op het haar te rusten"); + assert.ok(cappedBraids.includes("av-hair-back-layer"),"vlechten horen naast een pet zichtbaar te blijven"); + assert.ok(!cappedBraids.includes("av-hair-front-layer"),"pet hoort de bovenkant van vlechten af te dekken"); +}); + +test('accessoires gebruiken vaste ankers en correcte voor- en achterlagen',async()=>{ + const api=await loadAvatarRenderer(); + assert.deepEqual(Array.from(api.headIds),['none','cap','beanie','crown','wizardhat','glasses','sunglasses','eyepatch','bow','flower','headphones','partyhat','star']); + assert.deepEqual(Array.from(api.bodyIds),['none','scarf','cape','badge','necklace','bowtie','belt','backpack','satchel','wings','medal']); + + for(const id of api.bodyIds){ + assert.equal(typeof api.bodyAccessories[id].back,'function',id+' mist achterlaag'); + assert.equal(typeof api.bodyAccessories[id].front,'function',id+' mist voorlaag'); + } + for(const [id,part] of Object.entries({beanie:'av-beanie',wizardhat:'av-wizardhat',eyepatch:'av-eyepatch',flower:'av-flower'})){ + const markup=api.renderAvatarFace({...base,accessoryHead:id},'lg').innerHTML; + assert.ok(markup.indexOf(part)>markup.indexOf('av-head-large'),id+' hoort op de kop'); + } + const cyclopsGlasses=api.renderAvatarFace({...base,species:"monster",speciesVariant:"cyclops",accessoryHead:"glasses"},"lg").innerHTML; + const cyclopsPatch=api.renderAvatarFace({...base,species:"monster",speciesVariant:"cyclops",accessoryHead:"eyepatch"},"lg").innerHTML; + const alienGlasses=api.renderAvatarFace({...base,species:"monster",speciesVariant:"alien",accessoryHead:"glasses"},"lg").innerHTML; + const alienShades=api.renderAvatarFace({...base,species:"monster",speciesVariant:"alien",accessoryHead:"sunglasses"},"lg").innerHTML; + assert.ok(cyclopsGlasses.includes("av-glasses-cyclops"),"cycloopbril hoort rond het ene oog"); + assert.ok(cyclopsPatch.includes("av-eyepatch-cyclops"),"cycloopooglap hoort voor het ene oog"); + assert.ok(alienGlasses.includes("av-glasses-alien"),"alienbril hoort rond drie ogen"); + assert.ok(alienShades.includes("av-sunglasses-alien"),"alienzonnebril hoort alle drie ogen af te dekken"); + + const cape=api.renderAvatarFace({...base,accessoryBody:'cape'},'lg').innerHTML; + assert.ok(cape.indexOf('av-cape"')cape.indexOf('av-head-large')); + + const backpack=api.renderAvatarFace({...base,accessoryBody:'backpack'},'lg').innerHTML; + assert.ok(backpack.indexOf('av-backpack"')backpack.indexOf('av-head-large')); + + const satchel=api.renderAvatarFace({...base,accessoryBody:'satchel'},'lg').innerHTML; + assert.ok(satchel.indexOf('av-satchel"')satchel.indexOf('av-head-large')); + + const wings=api.renderAvatarFace({...base,accessoryBody:'wings'},'lg').innerHTML; + assert.ok(wings.indexOf('av-wings')wings.indexOf('av-head-large')); + + for(const [id,part] of [['scarf','av-scarf'],['badge','av-badge'],['necklace','av-necklace'],['bowtie','av-bowtie'],['belt','av-belt'],['medal','av-reward-medal']]){ + const markup=api.renderAvatarFace({...base,accessoryBody:id},'lg').innerHTML; + assert.ok(markup.indexOf(part)>markup.indexOf('av-head-large'),id+' hoort voor op het lijf'); + } +}); + +test("alle 15444 variant-, haar- en accessoirecombinaties renderen volledig",async()=>{ + const api=await loadAvatarRenderer(); + let combinations=0; + for(const speciesId of ["dino","monster","animal"]){ + for(const variantId of api.variants[speciesId]){ + for(const hairId of api.hairIds){ + for(const headId of api.headIds){ + for(const bodyId of api.bodyIds){ + const svg=api.renderAvatarFace({ + ...base, + species:speciesId, + speciesVariant:variantId, + hairStyle:hairId, + accessoryHead:headId, + accessoryBody:bodyId, + },"sm"); + assert.equal(svg.attributes.viewBox,"-8 -21 116 132"); + assert.equal(svg.attributes.preserveAspectRatio,"xMidYMid meet"); + assert.doesNotMatch(svg.innerHTML,/undefined|NaN/); + assert.ok(svg.innerHTML.includes("av-head-large")); + assert.ok(svg.innerHTML.includes("av-creature-"+variantId)); + combinations++; + } + } + } + } + } + assert.equal(combinations,15444); +}); +test("client, API en beide talen bevatten dezelfde avataropties",async()=>{ + const [apiSource,core]=await Promise.all([ + readFile("src/api.js","utf8"), + readFile("public/js/core.js","utf8"), + ]); + const hair=["tuft","wavy","bob","braids"]; + const accessories=["beanie","wizardhat","eyepatch","flower","bowtie","belt","satchel","wings","star","medal"]; + const variants=["rex","triceratops","stegosaurus","raptor","horned","cyclops","slime","alien","cat","dog","rabbit","bear"]; + for(const id of hair){ + assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-validatie"); + assert.equal(core.split("avatarHair_"+id+":").length-1,2,id+" mist een vertaling"); + } + for(const id of accessories){ + assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-validatie"); + assert.equal(core.split("avatarAcc_"+id+":").length-1,2,id+" mist een vertaling"); + } + for(const id of variants){ + assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-variantvalidatie"); + assert.equal(core.split("avatarVariant_"+id+":").length-1,2,id+" mist een variantvertaling"); + } + assert.ok(apiSource.includes("speciesVariant"),"API slaat de soortvariant niet op"); + assert.equal(core.split("avatarVariant:").length-1,2,"variantveld mist een label"); +}); diff --git a/test/body-parser.test.js b/test/body-parser.test.js new file mode 100644 index 0000000..96450f2 --- /dev/null +++ b/test/body-parser.test.js @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import { registerEmptyJsonBodyParser } from '../src/body-parser.js'; + +// Dit is precies wat public/js/core.js's api() vóór de fix altijd deed voor +// een DELETE zonder body: Content-Type: application/json meesturen zonder +// werkelijke inhoud. Fastify's ingebouwde JSON-parser weigert dat met "Body +// cannot be empty when content-type is set to 'application/json'" - vóórdat +// er ook maar één hook of route-handler heeft kunnen draaien. Geen enkele +// gemockte-pool-test kon dit vangen (die simuleren geen echte HTTP-requests), +// vandaar deze losse test tegen een echte Fastify-request-cyclus. +test('leeg lichaam met Content-Type: application/json wordt niet geweigerd', async () => { + const app = Fastify(); + registerEmptyJsonBodyParser(app); + app.delete('/thing/:id', async () => ({ ok: true })); + await app.ready(); + const res = await app.inject({ + method: 'DELETE', url: '/thing/1', headers: { 'content-type': 'application/json' }, + }); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(res.json(), { ok: true }); + await app.close(); +}); + +test('ongeldige JSON in het lichaam geeft nog steeds een nette 400', async () => { + const app = Fastify(); + registerEmptyJsonBodyParser(app); + app.post('/thing', async (req) => ({ received: req.body })); + await app.ready(); + const res = await app.inject({ + method: 'POST', url: '/thing', headers: { 'content-type': 'application/json' }, payload: '{niet geldig', + }); + assert.equal(res.statusCode, 400); + await app.close(); +}); + +test('een echte JSON-body werkt gewoon zoals voorheen', async () => { + const app = Fastify(); + registerEmptyJsonBodyParser(app); + app.post('/thing', async (req) => ({ received: req.body })); + await app.ready(); + const res = await app.inject({ + method: 'POST', url: '/thing', headers: { 'content-type': 'application/json' }, payload: { a: 1 }, + }); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(res.json(), { received: { a: 1 } }); + await app.close(); +}); diff --git a/test/cms.test.js b/test/cms.test.js new file mode 100644 index 0000000..b6e4a22 --- /dev/null +++ b/test/cms.test.js @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import cookie from '@fastify/cookie'; +import rateLimit from '@fastify/rate-limit'; +import api from '../src/api.js'; + +// Gemockte pool zoals in schools.test.js: sessie-join levert `user`, +// site_content-queries worden per test beantwoord/vastgelegd. +function makeApp(user, rows = []) { + const calls = []; + const pool = { + async query(sql, params = []) { + calls.push({ sql, params }); + if (sql.includes('FROM sessions s JOIN users u')) return { rows: user ? [user] : [] }; + if (sql.includes('FROM user_roles')) return { rows: [] }; + if (sql.includes('FROM site_content')) return { rows }; + return { rows: [] }; + }, + }; + return (async () => { + const app = Fastify({ trustProxy: 2 }); + await app.register(cookie); + await app.register(rateLimit, { global: false }); + app.decorate('pg', pool); + await app.register(api, { prefix: '/api' }); + await app.ready(); + return { app, calls }; + })(); +} + +const cookies = { teach_session: 'x'.repeat(64) }; +const superUser = { id: 1, username: 'sm', role: 'super', school_id: null, class_id: null, data: {}, data_rev: 0 }; +const admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 }; + +test('site-inhoud is publiek leesbaar als sleutel/waarde-object', async () => { + const { app } = await makeApp(null, [ + { key: 'welcome', value: { nl: 'Welkom!', en: 'Welcome!' } }, + { key: 'announcement', value: { active: true, nl: 'Update', en: 'Update', id: 7 } }, + ]); + const res = await app.inject({ url: '/api/content' }); + assert.equal(res.statusCode, 200, res.body); + const body = res.json(); + assert.equal(body.welcome.nl, 'Welkom!'); + assert.equal(body.announcement.active, true); + await app.close(); +}); + +test('systeemmanager slaat inhoud op via upsert', async () => { + const { app, calls } = await makeApp(superUser); + const res = await app.inject({ method: 'PUT', url: '/api/admin/content/welcome', cookies, + payload: { nl: 'Hallo school!', en: 'Hello school!' } }); + assert.equal(res.statusCode, 200, res.body); + const upsert = calls.find((c) => c.sql.includes('INSERT INTO site_content')); + assert.ok(upsert.sql.includes('ON CONFLICT (key) DO UPDATE')); + assert.equal(upsert.params[0], 'welcome'); + assert.equal(upsert.params[1].nl, 'Hallo school!'); + assert.equal(upsert.params[2], 1); + await app.close(); +}); + +test('schoolbeheerder en gasten mogen geen inhoud schrijven', async () => { + const { app } = await makeApp(admin); + const res = await app.inject({ method: 'PUT', url: '/api/admin/content/welcome', cookies, + payload: { nl: 'x' } }); + assert.equal(res.statusCode, 403); + const { app: app2 } = await makeApp(null); + const res2 = await app2.inject({ method: 'PUT', url: '/api/admin/content/welcome', + payload: { nl: 'x' } }); + assert.equal(res2.statusCode, 401); + await app.close(); await app2.close(); +}); + +test('alleen bekende sleutels, objecten en begrensde omvang worden geaccepteerd', async () => { + const { app } = await makeApp(superUser); + const unknown = await app.inject({ method: 'PUT', url: '/api/admin/content/hack', cookies, payload: { a: 1 } }); + assert.equal(unknown.statusCode, 404); + const arr = await app.inject({ method: 'PUT', url: '/api/admin/content/welcome', cookies, payload: [1, 2] }); + assert.equal(arr.statusCode, 400); + const huge = await app.inject({ method: 'PUT', url: '/api/admin/content/welcome', cookies, + payload: { nl: 'x'.repeat(9 * 1024) } }); + assert.equal(huge.statusCode, 413); + await app.close(); +}); diff --git a/test/coloring.test.js b/test/coloring.test.js new file mode 100644 index 0000000..da657d5 --- /dev/null +++ b/test/coloring.test.js @@ -0,0 +1,126 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {runInNewContext} from 'node:vm'; + +async function coloringContext(){ + const [extra,widget]=await Promise.all([ + readFile('public/js/widgets/coloring-scenes-extra.js','utf8'),readFile('public/js/widgets/coloring.js','utf8') + ]),source=extra+'\n'+widget; + const context={}; + runInNewContext(source+`; + globalThis.__coloring={ + pages:COLORING_PAGES, + themes:COLORING_THEME_KEYS, + pagesFor:coloringPagesFor, + pencilWidth:coloringPencilWidth, + safeColor:coloringSafeColor + }`,context); + return{source,api:context.__coloring}; +} + +test('kleurplatenwidget laadt vóór het bord en is als creatieve leerlingwidget geregistreerd',async()=>{ + const [html,board,pupil,cast,core,css]=await Promise.all([ + readFile('public/index.html','utf8'),readFile('public/js/board.js','utf8'), + readFile('public/js/pupil.js','utf8'),readFile('public/js/cast.js','utf8'), + readFile('public/js/core.js','utf8'),readFile('public/css/coloring.css','utf8') + ]); + assert.ok(html.indexOf('js/widgets/coloring-scenes-extra.js')>0); + assert.ok(html.indexOf('js/widgets/coloring-scenes-extra.js')0); + assert.ok(html.indexOf('js/widgets/coloring.js'){ + const {api}=await coloringContext(); + const pages=JSON.parse(JSON.stringify(api.pages)); + assert.equal(pages.length,28); + assert.equal(new Set(pages.map(page=>page.id)).size,pages.length); + const added=[ + 'farmyard','rainbow-weather','construction-site','forest-picnic', + 'dinosaur-valley','spring-nest','fire-station','bee-meadow', + 'coral-reef','mountain-camp','robot-workshop','canal-street', + 'solar-observatory','savanna-web','japanese-garden' + ]; + assert.equal(added.filter(id=>pages.some(page=>page.id===id)).length,15); + for(let year=1;year<=8;year++){ + const suitable=JSON.parse(JSON.stringify(api.pagesFor(year))); + assert.equal(suitable.length,7,`groep ${year} heeft zeven kleurplaten`); + assert.ok(suitable.every(page=>year>=page.years[0]&&year<=page.years[1])); + assert.equal(new Set(suitable.map(page=>page.theme)).size,5,`groep ${year} heeft alle vijf thema's`); + } + for(const page of pages){ + assert.ok(api.themes[page.theme],page.id+' heeft een bekend thema'); + assert.ok(page.title.nl&&page.title.en,page.id+' heeft een tweetalige titel'); + const parts=[...page.svg.matchAll(/data-part="([^"]+)"/g)].map(match=>match[1]); + const details=(page.svg.match(/class="coloring-detail"/g)||[]).length; + const openFillablePaths=[...page.svg.matchAll(/]* d="([^"]+)"/g)] + .filter(match=>!/[zZ]\s*$/.test(match[2])).map(match=>match[1]); + assert.ok(parts.length>=14,page.id+' heeft voldoende afzonderlijke kleurvakken'); + assert.equal(new Set(parts).size,parts.length,page.id+' heeft unieke kleurvakken'); + assert.deepEqual(openFillablePaths,[],page.id+' heeft alleen gesloten aanklikbare paden'); + assert.ok(details>=4,page.id+' heeft voldoende herkenbaar binnenlijnwerk'); + assert.match(page.svg,new RegExp(`data-scene="${page.id}"`)); + assert.match(page.svg,/data-level="2"/); + assert.match(page.svg,/data-level="3"/); + assert.doesNotMatch(page.svg,/item.id===id); + names.forEach(name=>assert.ok(page.svg.includes(`data-part="${name}"`),`${id} mist ${name}`)); + } + assert.equal(api.pagesFor(5,'science').length,2); + assert.equal(api.pagesFor(3,'science').length,1); +}); + +test('potlood reageert op echte pendruk en bouwt kleur transparant op',async()=>{ + const {source,api}=await coloringContext(); + assert.ok(api.pencilWidth(7,.9,'pen')>api.pencilWidth(7,.2,'pen')); + assert.equal(api.pencilWidth(7,0,'mouse'),7); + assert.equal(api.safeColor('#AABBCC'),'#aabbcc'); + assert.equal(api.safeColor('red'),'#e0554d'); + for(const feature of ['getCoalescedEvents','event.pressure','erase?1:.27','quadraticCurveTo','Math.random()'])assert.ok(source.includes(feature),feature); + const css=await readFile('public/css/coloring.css','utf8'); + assert.match(css,/mix-blend-mode:multiply/); + assert.match(css,/\.coloring-detail\{stroke-width:4\.5;pointer-events:none\}/); +}); + +test('verfemmer vult een SVG-vak in één keer en state bewaart potlood én vlakkleuren',async()=>{ + const {source}=await coloringContext(); + assert.match(source,/event\.target\.closest\("\[data-fillable\]\[data-part\]\"\)/); + assert.match(source,/part\.setAttribute\("fill",color\)/); + assert.match(source,/fills\[part\.dataset\.part\]=color/); + assert.match(source,/canvas\.toDataURL\("image\/png"\)/); + assert.match(source,/return\{theme,pageId,fills:\{\.\.\.fills\},ink,tool,color,size,completed,yearGroup,level/); + assert.match(source,/opts\.onProgress/); +}); + +test('creatief niveau is beschikbaar per klas, leerling en thuiskind',async()=>{ + const [api,admin,progressions,parent,wiki,migration,live]=await Promise.all([ + readFile('src/api.js','utf8'),readFile('public/js/admin.js','utf8'), + readFile('public/js/learning-progressions.js','utf8'),readFile('public/js/parent.js','utf8'), + readFile('public/js/wiki.js','utf8'), + readFile('db/024_creative_pupil_levels.sql','utf8'),readFile('src/live-classroom.js','utf8') + ]); + assert.match(api,/LEVEL_SUBJECTS = \[[^\]]*'creatief'/); + assert.match(admin,/KM_SUBJECTS = \[[^\]]*"creatief"/); + assert.match(progressions,/LEARNING_SUBJECTS=\[[^\]]*"creatief"/); + assert.match(parent,/\["taal","rekenen","creatief"\]\.forEach/); + assert.match(wiki,/creatief:"parentLevelCreative"/); + assert.match(migration,/CHECK \(subject IN \('taal','rekenen','world','creatief'\)\)/); + assert.match(live,/LEVEL_SUBJECTS = new Set\(\[[^\]]*'creatief'/); +}); diff --git a/test/curriculum-wiki.test.js b/test/curriculum-wiki.test.js new file mode 100644 index 0000000..d387350 --- /dev/null +++ b/test/curriculum-wiki.test.js @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {runInNewContext} from 'node:vm'; +import {PERMISSIONS} from '../src/permissions.js'; + +test('curriculumdataset bevat negen leergebieden en alle veertig kerndoelen',async()=>{ + const source=await readFile('public/js/curriculum.js','utf8'); + const context={}; + runInNewContext(source+';globalThis.__curriculum=CURRICULUM_PO_2026',context); + const data=JSON.parse(JSON.stringify(context.__curriculum)); + assert.equal(data.id,'slo-po-2026'); + assert.equal(data.edition,'2026-07'); + assert.match(data.tule,/slo\.nl/); + assert.match(data.progressions,/slo\.nl/); + assert.equal(data.areas.length,9); + const goals=data.areas.flatMap(area=>area.goals); + assert.deepEqual(goals.map(goal=>goal.id),Array.from({length:40},(_,index)=>index+1)); + for(const area of data.areas){ + assert.ok(area.title&&area.description&&area.page,area.id); + for(const goal of area.goals){ + assert.ok(goal.domain&&goal.summary,goal.id); + assert.ok(goal.parts.length>=1,goal.id); + assert.ok(['partial','support','open'].includes(goal.coverage),goal.id); + assert.ok(Array.isArray(goal.widgets),goal.id); + } + } +}); + +test('iedere kerndoelkoppeling verwijst naar een bestaande widget en de informatieknop linkt diep naar de wiki',async()=>{ + const [curriculumSource,board,settings,wiki,css]=await Promise.all([ + readFile('public/js/curriculum.js','utf8'), + readFile('public/js/board.js','utf8'), + readFile('public/js/settings.js','utf8'), + readFile('public/js/wiki.js','utf8'), + readFile('public/css/teach.css','utf8') + ]); + const context={}; + runInNewContext(curriculumSource+';globalThis.__curriculum=CURRICULUM_PO_2026',context); + const data=JSON.parse(JSON.stringify(context.__curriculum)); + const registered=new Set([...board.matchAll(/\{\s*id:"([^"]+)"\s*,\s*cat:/g)].map(match=>match[1])); + const linked=new Set(data.areas.flatMap(area=>area.goals.flatMap(goal=>goal.widgets))); + for(const widget of linked)assert.ok(registered.has(widget),widget+' is gekoppeld maar niet geregistreerd'); + for(const widget of ['letters','sentence','madd','fractions','dice','timer','schedule','trees','topography','noise','birthday','avatar']){ + assert.ok(linked.has(widget),widget+' mist een inhoudelijke kerndoelkoppeling'); + } + assert.match(board,/function curriculumGoalsForWidget/); + assert.match(board,/link\.href="#wiki-goal-"\+goal\.id/); + assert.match(board,/openSettings\("curriculum",\{area:area\.id,goal:goal\.id\}\)/); + assert.match(settings,/window\.openSettings = function\(section, payload\)/); + assert.match(wiki,/card\.id="wiki-goal-"\+goal\.id/); + assert.match(wiki,/target\.scrollIntoView\(\{block:"center"\}\)/); + assert.match(css,/\.winfo\[hidden\]\{display:none\}/); + assert.match(css,/\.widget-curriculum-modal/); +}); + +test('curriculumwiki en widgetinformatie zijn alleen zichtbaar voor volwassen rollen',async()=>{ + assert.deepEqual(PERMISSIONS['curriculum.view'],['super','admin','teacher','parent']); + assert.ok(!PERMISSIONS['curriculum.view'].includes('pupil')); + const [client,wiki,board,html]=await Promise.all([ + readFile('public/js/permissions.js','utf8'), + readFile('public/js/wiki.js','utf8'), + readFile('public/js/board.js','utf8'), + readFile('public/index.html','utf8') + ]); + assert.match(client,/'curriculum\.view':\s*\['super', 'admin', 'teacher', 'parent'\]/); + assert.match(wiki,/visible:\(\)=>!!currentUser&&can\("curriculum\.view"\)/); + assert.match(board,/infoBtn\.hidden=!\(currentUser&&can\("curriculum\.view"\)\)/); + assert.match(board,/if\(!currentUser\|\|!can\("curriculum\.view"\)\)return/); + assert.doesNotMatch(html,/id="btnWiki"/); + assert.doesNotMatch(wiki,/wikiButton/); + assert.doesNotMatch(wiki,/innerHTML\s*=/); +}); + +test('curriculumwiki is geladen, doorzoekbaar, responsief en tweetalig bediend',async()=>{ + const [html,wiki,core,css,version]=await Promise.all([ + readFile('public/index.html','utf8'), + readFile('public/js/wiki.js','utf8'), + readFile('public/js/core.js','utf8'), + readFile('public/css/teach.css','utf8'), + readFile('VERSION','utf8') + ]); + assert.ok(html.indexOf('js/settings.js') new RegExp(`(^|;|\\s)${prop}\\s*:`).test(decls); + +/* het bloktype in een selector: .money-digit-block, .blok.sm, .blok, ... */ +function contextTokens(selector) { + return selector.split(',') + .map((part) => part.trim()) + .filter((part) => /\.(blok|slot)\b/.test(part) && !/\s\.(bl|ghost|dots|blok-dots|slot-dots)\b/.test(part)) + .map((part) => (part.match(/\.[a-z0-9-]*(?:block|slot|blok)\b/g) || []).join('')); +} + +test('elk bloktype dat een eigen hoogte krijgt, zet ook zijn eigen stipmaat', () => { + const withDot = new Set(); + RULES.forEach(([selector, decls]) => { + if (declares(decls, '--dot')) contextTokens(selector).forEach((t) => withDot.add(t)); + }); + const missing = []; + RULES.forEach(([selector, decls]) => { + if (!declares(decls, 'height')) return; + contextTokens(selector).forEach((token) => { + if (!token) return; + // een variant erft de maat van zijn basis (.blok/.slot) als die het zet + const base = token.includes('slot') ? '.slot' : '.blok'; + if (!withDot.has(token) && !withDot.has(base)) missing.push(`${selector} (${token})`); + }); + }); + assert.deepEqual(missing, [], 'deze regels zetten een blokhoogte zonder dat er ergens een --dot voor dat type bestaat'); + assert.ok(withDot.has('.blok') && withDot.has('.slot'), 'basisblok en -slot zetten --dot'); +}); + +test('de stippen zijn nooit kleiner dan 6px: het patroon moet leesbaar blijven', () => { + const values = [...CSS.matchAll(/--dot\s*:\s*([\d.]+)px/g)].map((m) => Number(m[1])); + assert.ok(values.length >= 5, `verwacht meerdere stipmaten, gevonden: ${values.length}`); + values.forEach((v) => assert.ok(v >= 6, `stipmaat ${v}px is onder de leesbaarheidsgrens`)); +}); + +test('geen enkel bloktype heeft nog een eigen hardgecodeerd stippenraster', () => { + const hardcoded = RULES.filter(([selector, decls]) => + /\.dots\b/.test(selector) + && /(grid-template-(columns|rows)\s*:[^;]*\d+px|(?:^|;|\s)(width|height)\s*:\s*\d+px)/.test(decls)); + assert.deepEqual(hardcoded.map(([s]) => s), [], + 'stipmaten horen via var(--dot) te lopen, anders loopt een nieuw blokformaat weer uit de pas'); +}); + +/* Narekening van de verticale begroting per bloktype. De blokrand telt mee + (border-box), dus: rand + padding-top + labelhoogte + rasterhoogte + + ondermarge moet binnen de blokhoogte passen. Rasterhoogte = 3 stippen + 2 + tussenruimtes van --dot/3. */ +const CONTEXTS = [ + { naam: 'letterblok', maat: '.blok', label: '.blok .bl', marge: '.blok .blok-dots', dot: '.blok' }, + { naam: 'letterslot', maat: '.slot', label: '.slot .ghost', marge: '.slot .slot-dots', dot: '.slot' }, + { naam: 'rekenblok', maat: '.blok.sm', label: '.blok.sm .bl', marge: '.blok.sm .blok-dots', dot: '.blok.sm' }, + { naam: 'rekenslot', maat: '.slot.sm', label: '.slot.sm .ghost', marge: '.slot.sm .slot-dots', dot: '.slot.sm' }, +]; +function value(selectorPart, prop) { + let found = null; + RULES.forEach(([selector, decls]) => { + if (!selector.split(',').some((part) => part.trim() === selectorPart)) return; + const m = decls.match(new RegExp(`(?:^|;|\\s)${prop}\\s*:\\s*([\\d.]+)px`)); + if (m) found = Number(m[1]); + }); + return found; +} + +test('label, stippenraster en marges passen samen binnen elk basisblok', () => { + CONTEXTS.forEach((ctx) => { + const hoogte = value(ctx.maat, 'height'); + const padding = value(ctx.maat, 'padding-top') ?? 0; + const rand = (value(ctx.maat, 'border') ?? 2.5) * 2; + const label = value(ctx.label, 'height'); + const marge = value(ctx.marge, 'margin-bottom'); + const dot = value(ctx.dot, '--dot'); + assert.ok(hoogte && label && marge && dot, `${ctx.naam}: maten niet gevonden`); + const raster = 3 * dot + 2 * (dot / 3); + const nodig = padding + label + raster + marge + rand; + assert.ok(nodig <= hoogte, + `${ctx.naam}: ${nodig.toFixed(1)}px nodig in een blok van ${hoogte}px - de stippen steken eruit`); + }); +}); diff --git a/test/frontend.test.js b/test/frontend.test.js new file mode 100644 index 0000000..eecb012 --- /dev/null +++ b/test/frontend.test.js @@ -0,0 +1,127 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import zlib from 'node:zlib'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import Fastify from 'fastify'; +import fastifyCompress from '@fastify/compress'; +import { registerFrontend, resolveAppVersion } from '../src/frontend.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const publicRoot = join(__dirname, '..', 'public'); + +// Zelfde volgorde als src/server.js: compress vóór de frontend-registratie. +async function makeApp(version = '1.2.3-test') { + const app = Fastify(); + await app.register(fastifyCompress, { + global: true, + encodings: ['br', 'gzip'], + threshold: 1024, + brotliOptions: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } }, + zlibOptions: { level: 6 }, + }); + await registerFrontend(app, { root: publicRoot, version }); + await app.ready(); + return app; +} + +test('index.html wordt geserveerd met versie-injectie en no-cache', async () => { + const app = await makeApp(); + const res = await app.inject({ url: '/' }); + assert.equal(res.statusCode, 200); + assert.match(res.headers['content-type'], /text\/html/); + assert.equal(res.headers['cache-control'], 'no-cache'); + assert.ok(res.payload.includes('?v=1.2.3-test'), 'versie hoort geïnjecteerd te zijn'); + assert.ok(!res.payload.includes('__V__'), 'geen placeholder meer in de uitvoer'); + const alias = await app.inject({ url: '/index.html' }); + assert.equal(alias.headers['cache-control'], 'no-cache'); + await app.close(); +}); + +test('js en css zijn een jaar immutable; fonts en img korter zonder immutable', async () => { + const app = await makeApp(); + for (const url of ['/js/core.js', '/css/teach.css']) { + const res = await app.inject({ url }); + assert.equal(res.statusCode, 200, url); + assert.equal(res.headers['cache-control'], 'public, max-age=31536000, immutable', url); + } + const font = await app.inject({ url: '/fonts/quicksand.woff2' }); + assert.equal(font.headers['cache-control'], 'public, max-age=604800'); + assert.ok(font.headers.etag, 'etag als 304-vangnet voor ongeversioneerde assets'); + const img = await app.inject({ url: '/img/logo.svg' }); + assert.equal(img.headers['cache-control'], 'public, max-age=86400'); + await app.close(); +}); + +test('assets gaan gecomprimeerd over de lijn (gzip én brotli)', async () => { + const app = await makeApp(); + const gz = await app.inject({ url: '/css/teach.css', headers: { 'accept-encoding': 'gzip' } }); + assert.equal(gz.headers['content-encoding'], 'gzip'); + assert.match(gz.headers.vary ?? '', /accept-encoding/i); + const cssRaw = zlib.gunzipSync(gz.rawPayload).toString('utf8'); + assert.ok(cssRaw.includes('toolbar'), 'gzip-inhoud is geldige css'); + const br = await app.inject({ url: '/js/core.js', headers: { 'accept-encoding': 'br' } }); + assert.equal(br.headers['content-encoding'], 'br'); + const jsRaw = zlib.brotliDecompressSync(br.rawPayload).toString('utf8'); + assert.ok(jsRaw.includes('const VERSION'), 'brotli-inhoud is geldige js'); + assert.ok(br.rawPayload.length < jsRaw.length / 2, 'compressie levert echt winst op'); + await app.close(); +}); + +test('conditional GET levert 304 zonder body', async () => { + const app = await makeApp(); + const first = await app.inject({ url: '/fonts/quicksand.woff2' }); + const res = await app.inject({ url: '/fonts/quicksand.woff2', headers: { 'if-none-match': first.headers.etag } }); + assert.equal(res.statusCode, 304); + assert.equal(res.rawPayload.length, 0); + await app.close(); +}); + +test('resolveAppVersion geeft de echte versie door en valt lokaal nooit stale terug', () => { + assert.equal(resolveAppVersion({ APP_VERSION: '0.4.0-beta' }), '0.4.0-beta'); + const a = resolveAppVersion({}); + const b = resolveAppVersion({ APP_VERSION: 'dev' }); + assert.match(a, /^dev-\d+$/); + assert.match(b, /^dev-\d+$/); +}); + +test('index.html: elk script heeft defer + cache-busting, font-preload blijft ongeversioneerd', async () => { + const [html, server] = await Promise.all([ + readFile(join(publicRoot, 'index.html'), 'utf8'), + readFile(join(__dirname, '..', 'src', 'server.js'), 'utf8'), + ]); + const scripts = [...html.matchAll(/