From ab16f160dfe1638b61d01589a7f1c63af6ea52d4 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:35:43 +0200 Subject: [PATCH 001/192] Test pipeline dev->test -- 2.45.2 From 1db182f4405951beb81ad5d8a5b52f92fc5b1075 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:45:25 +0200 Subject: [PATCH 002/192] runs-on: self-hosted runner --- .forgejo/workflows/dev.yaml | 2 +- .forgejo/workflows/release.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/dev.yaml b/.forgejo/workflows/dev.yaml index 0486c07..1030f4a 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: self-hosted # label van jouw Forgejo-runner env: # 10.0.20.22:3000 - host van je Forgejo (= container registry host) REGISTRY: ${{ vars.REGISTRY }} diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml index b2585a3..aa79da7 100644 --- a/.forgejo/workflows/release.yaml +++ b/.forgejo/workflows/release.yaml @@ -12,7 +12,7 @@ concurrency: jobs: build-and-deploy: - runs-on: ubuntu-latest # pas aan naar het label van jouw Forgejo-runner + runs-on: self-hosted # label van jouw Forgejo-runner env: REGISTRY: ${{ vars.REGISTRY }} IMAGE_NAME: ${{ github.repository }} # bes-r/teach -- 2.45.2 From b7979dbbcf5cbfe589e3384dff65af6937ef7ce6 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:14:48 +0200 Subject: [PATCH 003/192] Deploy via gewone ssh i.p.v. externe action --- .forgejo/workflows/dev.yaml | 24 +++++++++--------------- .forgejo/workflows/release.yaml | 23 +++++++++-------------- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/.forgejo/workflows/dev.yaml b/.forgejo/workflows/dev.yaml index 1030f4a..508d56f 100644 --- a/.forgejo/workflows/dev.yaml +++ b/.forgejo/workflows/dev.yaml @@ -11,7 +11,7 @@ concurrency: jobs: build-and-deploy: - runs-on: self-hosted # 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 }} @@ -40,18 +40,12 @@ jobs: 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 + ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -i deploy_key ${{ secrets.TEST_USER }}@${{ secrets.TEST_HOST }} \ + "set -e && cd ${{ secrets.TEST_DEPLOY_PATH }} && 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 && docker compose -f compose.deploy.yaml up -d && docker image prune -f" + rm -f deploy_key diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml index aa79da7..a0576af 100644 --- a/.forgejo/workflows/release.yaml +++ b/.forgejo/workflows/release.yaml @@ -12,7 +12,7 @@ concurrency: jobs: build-and-deploy: - runs-on: self-hosted # label van jouw Forgejo-runner + runs-on: prod # jouw prod-runner (productieserver) env: REGISTRY: ${{ vars.REGISTRY }} IMAGE_NAME: ${{ github.repository }} # bes-r/teach @@ -40,17 +40,12 @@ jobs: 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 + ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -i deploy_key ${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }} \ + "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" + rm -f deploy_key -- 2.45.2 From 910f7953ad0dee9db35338eface2ee9b95a661f4 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:49:02 +0200 Subject: [PATCH 004/192] Nieuwe testrun -- 2.45.2 From f2cb88f5a68f48b74a5e683d6ac2cd15b2118558 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:48:01 +0200 Subject: [PATCH 005/192] Schone pipelinerun -- 2.45.2 From 2b567fe6244af957193d513c63c138bc31e99a60 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:52:25 +0200 Subject: [PATCH 006/192] Deploy: alleen app-image pullen, niet Docker Hub --- .forgejo/workflows/dev.yaml | 2 +- .forgejo/workflows/release.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/dev.yaml b/.forgejo/workflows/dev.yaml index 508d56f..13ff843 100644 --- a/.forgejo/workflows/dev.yaml +++ b/.forgejo/workflows/dev.yaml @@ -47,5 +47,5 @@ jobs: chmod 600 deploy_key ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -i deploy_key ${{ secrets.TEST_USER }}@${{ secrets.TEST_HOST }} \ - "set -e && cd ${{ secrets.TEST_DEPLOY_PATH }} && 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 && docker compose -f compose.deploy.yaml up -d && docker image prune -f" + "set -e && cd ${{ secrets.TEST_DEPLOY_PATH }} && 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 && docker image prune -f" rm -f deploy_key diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml index a0576af..55e7351 100644 --- a/.forgejo/workflows/release.yaml +++ b/.forgejo/workflows/release.yaml @@ -47,5 +47,5 @@ jobs: chmod 600 deploy_key ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -i deploy_key ${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }} \ - "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" + "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 app && docker compose -f compose.deploy.yaml up -d && docker image prune -f" rm -f deploy_key -- 2.45.2 From 92a7e6e2377527d7f364b76b26d4ac4c272639ad Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:01:23 +0200 Subject: [PATCH 007/192] ssh deploy: fail-fast (BatchMode + ConnectTimeout) --- .forgejo/workflows/dev.yaml | 2 +- .forgejo/workflows/release.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/dev.yaml b/.forgejo/workflows/dev.yaml index 13ff843..cea8df5 100644 --- a/.forgejo/workflows/dev.yaml +++ b/.forgejo/workflows/dev.yaml @@ -45,7 +45,7 @@ jobs: run: | echo "${{ secrets.TEST_SSH_KEY }}" > deploy_key chmod 600 deploy_key - ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + 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 }} && 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 && docker image prune -f" rm -f deploy_key diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml index 55e7351..e161a39 100644 --- a/.forgejo/workflows/release.yaml +++ b/.forgejo/workflows/release.yaml @@ -45,7 +45,7 @@ jobs: run: | echo "${{ secrets.PROD_SSH_KEY }}" > deploy_key chmod 600 deploy_key - ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + 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 }} && 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 && docker image prune -f" rm -f deploy_key -- 2.45.2 From 01e7f7726459f284fe235ee13ea8f55d8514c22a Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:11:32 +0200 Subject: [PATCH 008/192] ssh deploy werkend + trigger -- 2.45.2 From 3a77442abc6b443e684984b056983d3f4afce732 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:17:28 +0200 Subject: [PATCH 009/192] Nginx reverse proxy op poort 8081 --- deploy/compose.deploy.yaml | 11 +++++++++++ deploy/nginx.conf | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 deploy/nginx.conf diff --git a/deploy/compose.deploy.yaml b/deploy/compose.deploy.yaml index 3682422..5520b4e 100644 --- a/deploy/compose.deploy.yaml +++ b/deploy/compose.deploy.yaml @@ -25,6 +25,17 @@ services: - "127.0.0.1:${APP_PORT:-3000}:3000" restart: unless-stopped + web: + image: nginx:alpine + depends_on: + - app + ports: + # Bereikbaar in de browser op http://:8081 + - "8081:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + restart: unless-stopped + db: image: postgres:17-alpine environment: diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..777e745 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + server_name _; + + # Wat groter zodat grote borden/afbeeldingen niet geweigerd worden + client_max_body_size 25m; + + 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 $scheme; + } +} -- 2.45.2 From 334b0f8e14fa33a33e48a97896e9f193725e8083 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:47:23 +0200 Subject: [PATCH 010/192] Versienummering en Widget Ankerwoorden toegevoegd --- public/index.html | 279 +++++++++++++++++++++++++++++++++------------- 1 file changed, 202 insertions(+), 77 deletions(-) diff --git a/public/index.html b/public/index.html index 7a70d8b..ecc074b 100644 --- a/public/index.html +++ b/public/index.html @@ -4,6 +4,7 @@ teach + + @@ -1027,3811 +89,23 @@ - + + + + + + + + + + + + + + + + + + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..09cd209 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,55 @@ +/* teach - start: beeldgrootte, contrast, sessie-herstel */ +/* ========================================================= + init +==========================================================*/ +/* display size: scale the whole app up for pupils who need it (device setting) */ +let ZOOM = 100; +const btnZoomIn = document.getElementById("btnZoomIn"); +const btnZoomOut = document.getElementById("btnZoomOut"); +const zoomPct = document.getElementById("zoomPct"); +function setZoom(z){ + ZOOM = Math.max(100, Math.min(200, Math.round(z/10)*10)); + document.body.style.zoom = ZOOM/100; + zoomPct.textContent = ZOOM+"%"; + zoomPct.classList.toggle("on", ZOOM!==100); + btnZoomIn.title = btnZoomOut.title = zoomPct.title = T("zoomUI"); + btnZoomIn.disabled = ZOOM>=200; + btnZoomOut.disabled = ZOOM<=100; + try{ localStorage.setItem("teach.zoom", ZOOM); }catch(e){} +} +btnZoomIn.addEventListener("click", ()=>setZoom(ZOOM+10)); +btnZoomOut.addEventListener("click", ()=>setZoom(ZOOM-10)); +zoomPct.addEventListener("click", ()=>setZoom(100)); /* tap the % to reset */ +document.addEventListener("langchange", ()=>{ btnZoomIn.title = btnZoomOut.title = zoomPct.title = T("zoomUI"); }); +try{ const z = +localStorage.getItem("teach.zoom"); if(z>100) setZoom(z); else setZoom(100); }catch(e){ setZoom(100); } + +/* high-contrast accessibility mode (device setting, persists) */ +const btnHC = document.getElementById("btnHC"); +function setHC(on){ + document.body.classList.toggle("hc", on); + btnHC.classList.toggle("on", on); + btnHC.title = T("a11y"); + try{ localStorage.setItem("teach.hc", on ? "1" : ""); }catch(e){} +} +btnHC.addEventListener("click", ()=>setHC(!document.body.classList.contains("hc"))); +document.addEventListener("langchange", ()=>{ btnHC.title = T("a11y"); }); +try{ if(localStorage.getItem("teach.hc")==="1") setHC(true); }catch(e){} + +applyI18n(); +updateEmptyHint(); +/* restore session (same browser tab session only) */ +(function(){ + let name = null; + try{ name = sessionStorage.getItem("teach.session"); }catch(e){} + const users = loadUsers(); + if(name && users[name]){ + currentUser = { name, data: users[name].data }; + THEMES = themesFromData(currentUser.data); + GENERAL_EXTRA = generalExtraFromData(currentUser.data); + updateUserUI(); + BS = boardsFromData(currentUser.data); + loadCurrentSlot(); + }else{ + updateBoardsUI(); + } +})(); \ No newline at end of file diff --git a/public/js/board.js b/public/js/board.js new file mode 100644 index 0000000..f657bc4 --- /dev/null +++ b/public/js/board.js @@ -0,0 +1,1064 @@ +/* teach - bord: widgets, tekenen, gereedschappen, borden & mappen */ +/* ========================================================= + Widget framework +==========================================================*/ +const board = document.getElementById("board"); +const dock = document.getElementById("dock"); +let zTop = 10, widgetCount = 0; +const instances = new Set(); + +const REGISTRY = [ + { id:"letters", cat:"taal", icon:"🔤", w:760, h:640, minW:640, minH:540, mount:mountLetterGame }, + { id:"flits", cat:"taal", icon:"⚡", w:520, h:430, minW:400, minH:330, mount:mountFlits }, + { id:"hak", cat:"taal", icon:"✂️", w:540, h:440, minW:420, minH:340, mount:mountHak }, + { id:"ball", cat:"taal", icon:"🎈", w:620, h:560, minW:480, minH:440, mount:mountBall }, + { id:"anchor", cat:"taal", icon:"⚓", w:680, h:500, minW:440, minH:340, mount:mountAnchor }, + { id:"madd", cat:"rekenen", icon:"➕", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"add") }, + { id:"msub", cat:"rekenen", icon:"➖", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"sub") }, + { id:"mmul", cat:"rekenen", icon:"✖️", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"mul") }, + { id:"mdiv", cat:"rekenen", icon:"➗", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"div") }, + { id:"mpct", cat:"rekenen", icon:"💯", w:480, h:620, minW:400, minH:520, mount:(r,s)=>mountMath(r,s,"pct") }, + { id:"write", cat:"tools", icon:"✏️", w:560, h:440, minW:360, minH:280, mount:mountWrite }, + { id:"mind", cat:"tools", icon:"🕸️", w:640, h:480, minW:420, minH:320, mount:mountMind }, + { id:"notes", cat:"tools", icon:"📝", w:340, h:300, minW:240, minH:180, mount:mountNotes }, + { id:"timer", cat:"tools", icon:"⏱️", w:350, h:470, minW:300, minH:420, mount:mountTimer }, + { id:"dice", cat:"tools", icon:"🎲", w:400, h:340, minW:300, minH:270, mount:mountDice }, + { id:"names", cat:"tools", icon:"🎯", w:420, h:430, minW:320, minH:330, mount:mountNames }, + { id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia } +]; +const WIDGET_CATS = [["taal","catTaal"],["rekenen","catRekenen"],["tools","catTools"],["media","catMedia"]]; + +function makeWidget(def, saved){ + const win = document.createElement("div"); + win.className = "widget"; + win.style.width = (saved && saved.w ? saved.w : def.w)+"px"; + win.style.height = (saved && saved.h ? saved.h : def.h)+"px"; + if(def.minW) win.style.minWidth = def.minW+"px"; + if(def.minH) win.style.minHeight = def.minH+"px"; + win.style.left = (saved && saved.x!=null ? saved.x : 40 + (widgetCount*36)%240) + "px"; + win.style.top = (saved && saved.y!=null ? saved.y : 30 + (widgetCount*30)%160) + "px"; + win.style.zIndex = ++zTop; + widgetCount++; + if(saved && saved.bare){ win.classList.add("bare"); win.dataset.bare = "1"; } + + const head = document.createElement("div"); + head.className = "widget-head"; + head.innerHTML = `${def.icon}`; + const zBtn = document.createElement("button"); + zBtn.className = "wmin wz"; zBtn.textContent = "A+"; + head.appendChild(zBtn); + const minBtn = document.createElement("button"); + minBtn.className = "wmin"; minBtn.textContent = "–"; + head.appendChild(minBtn); + const maxBtn = document.createElement("button"); + maxBtn.className = "wmin"; maxBtn.textContent = "▢"; + head.appendChild(maxBtn); + const close = document.createElement("button"); + close.className = "wclose"; close.textContent = "✕"; + head.appendChild(close); + + const body = document.createElement("div"); + body.className = "widget-body"; + win.appendChild(head); win.appendChild(body); + + let pill = null; + function setMin(m){ + if(m && !pill){ + win.dataset.prevW = win.offsetWidth; + win.dataset.prevH = win.offsetHeight; + win.dataset.min = "1"; + win.style.display = "none"; + pill = document.createElement("button"); + pill.className = "dock-item"; + pill.innerHTML = `${def.icon}`; + pill.querySelector(".dl").textContent = T("wg_"+def.id); + pill.title = T("wMax"); + pill.addEventListener("click", ()=>setMin(false)); + dock.appendChild(pill); + }else if(!m && pill){ + pill.remove(); pill = null; + win.dataset.min = ""; + win.style.display = "flex"; + win.style.zIndex = ++zTop; + } + } + minBtn.addEventListener("click", ()=>setMin(true)); + /* per-widget content zoom for low-vision pupils */ + let wz = (saved && saved.wz) || 1; + function applyWZ(){ + body.style.zoom = wz; + win.dataset.wz = wz; + zBtn.textContent = wz===1 ? "A+" : Math.round(wz*100)+"%"; + zBtn.classList.toggle("on", wz!==1); + zBtn.title = T("wZoom"); + } + zBtn.addEventListener("click", ()=>{ + wz = wz===1 ? 1.25 : wz===1.25 ? 1.5 : 1; + applyWZ(); + }); + applyWZ(); + function setMax(m){ + if(m && win.dataset.max!=="1"){ + win.dataset.pmx = win.offsetLeft; win.dataset.pmy = win.offsetTop; + win.dataset.pmw = win.offsetWidth; win.dataset.pmh = win.offsetHeight; + win.dataset.max = "1"; + win.style.left = "0px"; win.style.top = "0px"; + win.style.width = board.clientWidth+"px"; + win.style.height = board.clientHeight+"px"; + win.style.zIndex = ++zTop; + maxBtn.textContent = "❐"; + setHudMin(true); /* keep the tools bar out of the way */ + }else if(!m && win.dataset.max==="1"){ + win.dataset.max = ""; + win.style.left = win.dataset.pmx+"px"; win.style.top = win.dataset.pmy+"px"; + win.style.width = win.dataset.pmw+"px"; win.style.height = win.dataset.pmh+"px"; + maxBtn.textContent = "▢"; + } + maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull"); + } + maxBtn.addEventListener("click", ()=>setMax(win.dataset.max!=="1")); + head.addEventListener("dblclick", e=>{ + if(e.target===close || e.target===minBtn || e.target===maxBtn) return; + setMin(true); + }); + + const setTitle = ()=>{ + head.querySelector(".wtitle").textContent = T("wg_"+def.id); + minBtn.title = T("wMin"); + maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull"); + zBtn.title = T("wZoom"); + if(pill){ pill.querySelector(".dl").textContent = T("wg_"+def.id); pill.title = T("wMax"); } + }; + setTitle(); + document.addEventListener("langchange", setTitle); + + win.addEventListener("pointerdown", ()=>{ win.style.zIndex = ++zTop; }); + head.addEventListener("pointerdown", e=>{ + if(e.target===close) return; + e.preventDefault(); + const zf = VZ(); + const sx = e.clientX/zf - win.offsetLeft, sy = e.clientY/zf - win.offsetTop; + const move = ev=>{ + win.style.left = Math.max(0, Math.min(board.clientWidth - 60, ev.clientX/zf - sx)) + "px"; + win.style.top = Math.max(0, Math.min(board.clientHeight - 40, ev.clientY/zf - sy)) + "px"; + }; + const up = ()=>{ document.removeEventListener("pointermove",move); document.removeEventListener("pointerup",up); }; + document.addEventListener("pointermove",move); document.addEventListener("pointerup",up); + }); + + board.appendChild(win); + const api = def.mount(body, saved ? saved.state : null) || {}; + if(saved && saved.min) setMin(true); + if(saved && saved.max) setMax(true); + const inst = { def, win, api }; + instances.add(inst); + close.addEventListener("click", ()=>{ + if(pill){ pill.remove(); pill = null; } + instances.delete(inst); win.remove(); updateEmptyHint(); + }); + updateEmptyHint(); + return inst; +} +function updateEmptyHint(){ + document.body.classList.toggle("board-empty", !board.querySelector(".widget")); +} + +function serializeBoard(){ + return { + ink: boardInkURL(), + bg: BOARD.bg, + widgets: [...instances].map(inst=>{ + const w = inst.win; + const min = w.dataset.min === "1"; + const max = w.dataset.max === "1"; + return { + id: inst.def.id, + x: max ? +w.dataset.pmx : w.offsetLeft, + y: max ? +w.dataset.pmy : w.offsetTop, + w: min ? (+w.dataset.prevW || inst.def.w) : max ? +w.dataset.pmw : w.offsetWidth, + h: min ? (+w.dataset.prevH || inst.def.h) : max ? +w.dataset.pmh : w.offsetHeight, + min, max, + wz: +w.dataset.wz || 1, + bare: w.dataset.bare === "1", + state: inst.api.getState ? inst.api.getState() : null + }; + }), + imgs: [...board.querySelectorAll(".bimg")].map(b=>({ + src: b.querySelector("img").src, + x: b.offsetLeft, y: b.offsetTop, w: b.offsetWidth + })) + }; +} +function restoreBoard(b){ + [...instances].forEach(inst=>inst.win.remove()); + instances.clear(); + dock.innerHTML = ""; + widgetCount = 0; + (b.widgets||[]).forEach(wd=>{ + const def = REGISTRY.find(d=>d.id===wd.id); + if(def) makeWidget(def, wd); + }); + setBoardInk(b.ink); + setBoardBg(b.bg || "dots"); + board.querySelectorAll(".bimg").forEach(x=>x.remove()); + (b.imgs||[]).forEach(im=>addBoardImage(im.src, im.x, im.y, im.w)); + updateEmptyHint(); +} + +/* ---------- board drawing layer (pen mode) ---------- */ +const bInk = document.createElement("canvas"); +bInk.id = "boardInk"; +board.insertBefore(bInk, board.firstChild); +const bCtx = bInk.getContext("2d"); +const bDpr = window.devicePixelRatio || 1; +function bResize(){ + const w = board.clientWidth, h = board.clientHeight; + if(w<2 || h<2) return; + const keep = document.createElement("canvas"); + keep.width = bInk.width; keep.height = bInk.height; + if(bInk.width) keep.getContext("2d").drawImage(bInk,0,0); + bInk.width = Math.round(w*bDpr); bInk.height = Math.round(h*bDpr); + if(keep.width) bCtx.drawImage(keep,0,0); + bCtx.lineCap = "round"; bCtx.lineJoin = "round"; +} +new ResizeObserver(bResize).observe(board); +bResize(); + +const PEN = { tool:"pen", color:"#26344a", size:4, nib:4 }; +const BOARD = { bg:"blank" }; +const penbar = document.getElementById("penbar"); +function setPen(on){ + document.body.classList.toggle("pen", on); + document.body.classList.toggle("pen-text", on && PEN.tool==="text"); + refreshPenbar(); +} +function setBoardBg(bg){ + BOARD.bg = bg; + board.className = (bg && bg!=="blank") ? "bg-"+bg : ""; + refreshPenbar(); +} +const PEN_TOOLS = [["hand","✋","toolHand"],["pen","✏️","toolPen"],["vulp","🖋️","toolFountain"],["mark","🖍️","toolMark"],["text","🔤","toolText"],["erase","🧽","eraser"]]; +PEN_TOOLS.forEach(([id,icon])=>{ + const b = document.createElement("button"); + b.dataset.tool = id; b.textContent = icon; + b.addEventListener("click", ()=>{ PEN.tool = id; setPen(true); refreshPenbar(); }); + penbar.querySelector(".pen-tools").appendChild(b); +}); +["#26344a","#3b7dd8","#e0554d","#3fae6a","#9b59b6","#f7c948"].forEach(c=>{ + const b = document.createElement("button"); + b.className = "wp-color"; b.dataset.c = c; + const dot = document.createElement("i"); dot.style.background = c; + b.appendChild(dot); + b.addEventListener("click", ()=>{ PEN.color = c; if(PEN.tool==="erase") PEN.tool = "pen"; refreshPenbar(); }); + penbar.querySelector(".pen-colors").appendChild(b); +}); +[3,5,8].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", ()=>{ PEN.size = s; refreshPenbar(); }); + penbar.querySelector(".pen-sizes").appendChild(b); +}); +const PEN_BGS = [["blank","bgBlank"],["dots","bgDots"],["lines","bgLines"],["write","bgWrite"],["grid-s","bgGridS"],["grid-b","bgGridB"]]; +PEN_BGS.forEach(([id])=>{ + const b = document.createElement("button"); + b.className = "wp-bg"; b.dataset.bg = id; + b.appendChild(document.createElement("i")); + b.addEventListener("click", ()=>setBoardBg(id)); + penbar.querySelector(".pen-bgs").appendChild(b); +}); +const PEN_EXTRAS = [["ruler","📏","toolRuler"],["geo","📐","toolGeo"],["lens","🔍","toolLens"],["light","🚦","wg_light"]]; +PEN_EXTRAS.forEach(([id,icon])=>{ + const b = document.createElement("button"); + b.dataset.extra = id; b.textContent = icon; + b.addEventListener("click", ()=>toggleDTool(id)); + penbar.querySelector(".pen-extras").appendChild(b); +}); +penbar.querySelector(".pen-clear").addEventListener("click", ()=>bCtx.clearRect(0,0,bInk.width,bInk.height)); +/* the ✍-button toggles drawing mode on/off (palette itself stays visible) */ +penbar.querySelector(".pen-close").addEventListener("click", ()=>setPen(!document.body.classList.contains("pen"))); +/* orientation + collapse of the whole bar */ +document.querySelector(".hud-orient").addEventListener("click", ()=>{ + document.getElementById("hud").classList.toggle("vert"); +}); +document.querySelector(".hud-min").addEventListener("click", function(){ + setHudMin(!document.getElementById("hud").classList.contains("min")); +}); +/* unified draggable bar (pen palette + dock) */ +const hud = document.getElementById("hud"); +hud.querySelector(".pen-grip").addEventListener("pointerdown", e=>{ + e.preventDefault(); + const zf = VZ(); + const r = hud.getBoundingClientRect(); + hud.style.transform = "none"; + hud.style.bottom = "auto"; + hud.style.left = (r.left/zf)+"px"; + hud.style.top = (r.top/zf)+"px"; + const ox = (e.clientX - r.left)/zf, oy = (e.clientY - r.top)/zf; + const mv = ev=>{ + hud.style.left = Math.max(4, Math.min(window.innerWidth/zf - r.width/zf - 4, ev.clientX/zf - ox))+"px"; + hud.style.top = Math.max(4, Math.min(window.innerHeight/zf - 44, ev.clientY/zf - oy))+"px"; + }; + const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; + document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); +}); +function setHudMin(m){ + hud.classList.toggle("min", m); + const b = document.querySelector(".hud-min"); + if(b) b.textContent = m ? "+" : "–"; +} +function updateHud(){ hud.classList.toggle("has-dock", dock.children.length > 0); } +new MutationObserver(updateHud).observe(dock, {childList:true}); +updateHud(); +function refreshPenbar(){ + penbar.querySelectorAll(".pen-tools button").forEach(b=>{ + b.classList.toggle("on", b.dataset.tool===PEN.tool); + b.title = T(PEN_TOOLS.find(x=>x[0]===b.dataset.tool)[2]); + }); + penbar.querySelectorAll(".wp-color").forEach(b=> + b.classList.toggle("on", b.dataset.c===PEN.color && PEN.tool!=="erase")); + penbar.querySelectorAll(".wp-size").forEach(b=>b.classList.toggle("on", +b.dataset.s===PEN.size)); + penbar.querySelectorAll(".wp-bg").forEach(b=>{ + b.classList.toggle("on", b.dataset.bg===BOARD.bg); + b.title = T(PEN_BGS.find(x=>x[0]===b.dataset.bg)[1]); + }); + penbar.querySelectorAll(".pen-extras button").forEach(b=>{ + b.classList.toggle("on", !!DTOOLS[b.dataset.extra]); + b.title = T(PEN_EXTRAS.find(x=>x[0]===b.dataset.extra)[2]); + }); + penbar.querySelector(".pen-clear").title = T("clearAll"); + const power = penbar.querySelector(".pen-close"); + const penOn = document.body.classList.contains("pen"); + power.textContent = "✍"; + power.title = T("penMode"); + power.classList.toggle("on", penOn); + document.querySelector(".hud-orient").title = T("hudOrient"); + document.querySelector(".hud-min").title = T("hudMin"); + document.body.classList.toggle("pen-text", penOn && PEN.tool==="text"); + document.body.classList.toggle("pen-hand", PEN.tool==="hand"); +} +document.addEventListener("langchange", refreshPenbar); + +/* ---------- physical tools: ruler, set square, magnifier ---------- */ +const DTOOLS = {}; +function makeMovable(t){ + let ang = 0; + t.addEventListener("pointerdown", e=>{ + if(e.target.classList.contains("dtool-x") || e.target.closest(".dtool-opts")) return; + e.preventDefault(); e.stopPropagation(); + if(e.target.classList.contains("dtool-rot")){ + const r = t.getBoundingClientRect(); + const cx = r.left+r.width/2, cy = r.top+r.height/2; + const start = Math.atan2(e.clientY-cy, e.clientX-cx)*180/Math.PI - ang; + const mv = ev=>{ + ang = Math.atan2(ev.clientY-cy, ev.clientX-cx)*180/Math.PI - start; + t.style.transform = `rotate(${ang}deg)`; + }; + const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; + document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); + return; + } + const zf = VZ(); + const sx = e.clientX/zf - t.offsetLeft, sy = e.clientY/zf - t.offsetTop; + const mv = ev=>{ + t.style.left = (ev.clientX/zf-sx)+"px"; + t.style.top = (ev.clientY/zf-sy)+"px"; + if(t.lensDraw) t.lensDraw(); + }; + const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; + document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); + }); +} +function toggleDTool(kind){ + if(DTOOLS[kind]){ + if(DTOOLS[kind]._iv) clearInterval(DTOOLS[kind]._iv); + DTOOLS[kind].remove(); delete DTOOLS[kind]; refreshPenbar(); return; + } + const t = document.createElement("div"); + t.className = "dtool dtool-"+kind; + if(kind==="ruler"){ + t._len = 600; t._hc = false; + const drawRuler = ()=>{ + const L = t._len, hc = t._hc; + const ink = hc ? "#000" : "#5a4a20"; + const bg = hc ? "rgba(255,214,0,.95)" : "rgba(255,243,205,.82)"; + let ticks = ""; + for(let i=5; i`; + if(cm) ticks += `${i/50}`; + } + t.style.width = L+"px"; + t.querySelector(".dtool-svg").innerHTML = ` + + + + + + + teach · cm + ${ticks}`; + }; + t.innerHTML = `
+
+ + +
`; + t.querySelector(".do-len").addEventListener("click", ()=>{ + t._len = t._len===600 ? 800 : t._len===800 ? 400 : 600; drawRuler(); + }); + t.querySelector(".do-hc").addEventListener("click", ()=>{ t._hc = !t._hc; drawRuler(); }); + t.style.height = "76px"; + drawRuler(); + }else if(kind==="geo"){ + t._sz = 340; t._hc = false; + const drawGeo = ()=>{ + const W = t._sz, H = W/2 + 22, hc = t._hc; + const ink = hc ? "#000" : "#1e4f74"; + const bg = hc ? "rgba(255,214,0,.9)" : "rgba(185,222,248,.6)"; + const halo = `stroke="${hc?"#ffd400":"#ffffff"}" stroke-width="3.5" paint-order="stroke" stroke-linejoin="round"`; + const ax = W/2, ay = 16, by = H-18; /* apex + baseline */ + const half = W/2 - 14; + let cmTicks = ""; + for(let i=25; i<=half-16; i+=25){ + const cm = i%50===0; + [ax-i, ax+i].forEach(x=>{ + cmTicks += ``; + }); + if(cm){ + [ax-i, ax+i].forEach(x=>{ + cmTicks += `${i/50}`; + }); + } + } + /* degree arc with labels INSIDE the arc so they stay readable */ + let degs = ""; + const R = half*0.60; + for(let d=10; d<180; d+=10){ + const rad = d*Math.PI/180; + const x1 = ax - Math.cos(rad)*(R-7), y1 = by - Math.sin(rad)*(R-7); + const x2 = ax - Math.cos(rad)*R, y2 = by - Math.sin(rad)*R; + degs += ``; + if(d%30===0 && d>=30 && d<=150){ + const xt = ax - Math.cos(rad)*(R-22), yt = by - Math.sin(rad)*(R-22); + degs += `${d}°`; + } + } + t.style.width = W+"px"; t.style.height = H+"px"; + t.querySelector(".dtool-svg").innerHTML = ` + + + + 0 + ${cmTicks}${degs}`; + }; + t.innerHTML = `
+
+ + +
`; + t.querySelector(".do-len").addEventListener("click", ()=>{ + t._sz = t._sz===340 ? 440 : t._sz===440 ? 280 : 340; drawGeo(); + }); + t.querySelector(".do-hc").addEventListener("click", ()=>{ t._hc = !t._hc; drawGeo(); }); + drawGeo(); + }else if(kind==="light"){ /* compact traffic light directly on the board */ + let on = "green"; + t.innerHTML = `
+ + + +
`; + const apply = ()=>t.querySelectorAll(".tl-lamp").forEach(l=>l.classList.toggle("on", l.dataset.c===on)); + t.querySelectorAll(".tl-lamp").forEach(l=>{ + l.addEventListener("pointerdown", e=>e.stopPropagation()); + l.addEventListener("click", ()=>{ on = l.dataset.c; apply(); }); + }); + apply(); + t.style.left = (board.clientWidth-140)+"px"; t.style.top = "20px"; + }else{ /* lens: magnifies background, pictures and ink (2x); resizable, round or square */ + const Z = 2, LMIN = 120, LMAX = 340; + t._d = 184; t._sq = false; + const c = document.createElement("canvas"); + t.appendChild(c); + t.lensDraw = ()=>{ + const d = t._d, S = Z*2; /* canvas is 2x for sharpness */ + const ctx2 = c.getContext("2d"); + const cx = t.offsetLeft + d/2 + 2, cy = t.offsetTop + d/2 + 2; /* board css px */ + const sw = d/Z; + const sx = cx - sw/2, sy = cy - sw/2; + ctx2.fillStyle = "#eef3f8"; + ctx2.fillRect(0, 0, d*2, d*2); + if(BOARD.bg === "dots"){ + ctx2.fillStyle = "#c9d6e5"; + const g = 26; + for(let gx = Math.floor(sx/g)*g; gx < sx+sw; gx += g){ + for(let gy = Math.floor(sy/g)*g; gy < sy+sw; gy += g){ + ctx2.beginPath(); + ctx2.arc((gx-sx)*S, (gy-sy)*S, 1.4*S, 0, 7); + ctx2.fill(); + } + } + } + board.querySelectorAll(".bimg").forEach(b=>{ + const im = b.querySelector("img"); + if(!im.complete || !im.naturalWidth) return; + try{ + ctx2.drawImage(im, (b.offsetLeft-sx)*S, (b.offsetTop-sy)*S, b.offsetWidth*S, b.offsetHeight*S); + }catch(e){} + }); + try{ + ctx2.drawImage(bInk, sx*bDpr, sy*bDpr, sw*bDpr, sw*bDpr, 0, 0, d*2, d*2); + }catch(e){} + }; + const lensSetup = ()=>{ + const d = t._d; + c.width = d*2; c.height = d*2; + c.style.width = d+"px"; c.style.height = d+"px"; + c.style.borderRadius = t._sq ? "16px" : "50%"; + t.style.width = (d+4)+"px"; t.style.height = (d+4)+"px"; + t.lensDraw(); + }; + const opts = document.createElement("div"); + opts.className = "dtool-opts"; + [["−","lensSmall",()=>{ t._d = Math.max(LMIN, t._d-40); lensSetup(); }], + ["+","lensBig", ()=>{ t._d = Math.min(LMAX, t._d+40); lensSetup(); }], + ["▢","lensShape",()=>{ t._sq = !t._sq; lensSetup(); }]].forEach(([txt,key,fn])=>{ + const b = document.createElement("button"); + b.textContent = txt; b.title = T(key); + b.addEventListener("click", fn); + opts.appendChild(b); + }); + t.appendChild(opts); + lensSetup(); + t._iv = setInterval(()=>t.lensDraw(), 350); + } + const x = document.createElement("button"); + x.className = "dtool-x"; x.textContent = "✕"; + x.addEventListener("click", ()=>{ if(t._iv) clearInterval(t._iv); t.remove(); delete DTOOLS[kind]; refreshPenbar(); }); + t.appendChild(x); + if(kind==="ruler" || kind==="geo"){ + const rot = document.createElement("button"); + rot.className = "dtool-rot"; rot.textContent = "↻"; + t.appendChild(rot); + } + makeMovable(t); + board.appendChild(t); + DTOOLS[kind] = t; + if(t.lensDraw) t.lensDraw(); + refreshPenbar(); +} + +/* board text tool */ +function startBoardText(e){ + if(board.querySelector(".wp-text-input")) return; + const zf = VZ(); + const r = board.getBoundingClientRect(); + const x = (e.clientX - r.left)/zf, y = (e.clientY - r.top)/zf; + const fontPx = PEN.size*6 + 12; + const inp = document.createElement("input"); + inp.type = "text"; inp.className = "wp-text-input"; + inp.style.left = Math.min(x, board.clientWidth-150)+"px"; + inp.style.top = Math.min(y, board.clientHeight-40)+"px"; + inp.style.color = PEN.color; + inp.style.fontSize = fontPx+"px"; + inp.style.zIndex = 4700; + board.appendChild(inp); + setTimeout(()=>inp.focus(), 0); + let done = false; + const commit = ()=>{ + if(done) return; done = true; + const v = inp.value.trim(); + if(v){ + bCtx.globalCompositeOperation = "source-over"; + bCtx.globalAlpha = 1; + bCtx.fillStyle = PEN.color; + bCtx.font = `700 ${fontPx*bDpr}px "Segoe UI Rounded","Segoe UI",Verdana,sans-serif`; + bCtx.textBaseline = "top"; + bCtx.fillText(v, x*bDpr, y*bDpr); + if(DTOOLS.lens) DTOOLS.lens.lensDraw(); + } + 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); +} + +let bDraw = false, bx = 0, by = 0; +const bPos = ev=>{ + const r = bInk.getBoundingClientRect(); + /* ratio-based: correct under any display zoom */ + return [(ev.clientX-r.left)*(bInk.width/r.width), (ev.clientY-r.top)*(bInk.height/r.height)]; +}; +bInk.addEventListener("pointerdown", e=>{ + if(e.pointerType==="mouse" && e.button!==0) return; + e.preventDefault(); + if(PEN.tool==="text"){ startBoardText(e); return; } + bInk.setPointerCapture(e.pointerId); + bDraw = true; + PEN.nib = PEN.size*0.8; + [bx,by] = bPos(e); + bStroke(bx+0.01, by+0.01, e.pressure, e.pointerType); +}); +bInk.addEventListener("pointermove", e=>{ + if(!bDraw) return; + (e.getCoalescedEvents ? e.getCoalescedEvents() : [e]).forEach(ev=>{ + const [x,y] = bPos(ev); bStroke(x, y, ev.pressure, ev.pointerType); + }); +}); +const bStop = ()=>{ + bDraw = false; + bCtx.globalAlpha = 1; + if(DTOOLS.lens) DTOOLS.lens.lensDraw(); +}; +bInk.addEventListener("pointerup", bStop); +bInk.addEventListener("pointercancel", bStop); +function bStroke(x,y,pressure,ptype){ + bCtx.globalCompositeOperation = PEN.tool==="erase" ? "destination-out" : "source-over"; + bCtx.globalAlpha = PEN.tool==="mark" ? 0.32 : 1; + bCtx.strokeStyle = PEN.color; + let w = PEN.size; + if(PEN.tool==="erase") w = PEN.size*6; + else if(PEN.tool==="mark") w = PEN.size*3.5; + else if(PEN.tool==="vulp"){ + let target; + if(ptype==="pen" && pressure > 0){ + target = PEN.size*(0.45 + pressure*1.9); + }else{ + const speed = Math.hypot(x-bx, y-by)/bDpr; + target = Math.max(PEN.size*0.5, Math.min(PEN.size*2.3, PEN.size*2.3 - speed*0.12)); + } + PEN.nib += (target - PEN.nib)*0.3; + w = PEN.nib; + } + bCtx.lineWidth = w*bDpr; + bCtx.beginPath(); bCtx.moveTo(bx,by); bCtx.lineTo(x,y); bCtx.stroke(); + bx = x; by = y; +} +function boardInkURL(){ try{ return bInk.toDataURL("image/png"); }catch(e){ return null; } } +function setBoardInk(url){ + bCtx.clearRect(0,0,bInk.width,bInk.height); + if(!url) return; + const im = new Image(); + im.onload = ()=>bCtx.drawImage(im,0,0); + im.src = url; +} +refreshPenbar(); + +/* gallery */ +const galleryWrap = document.getElementById("galleryWrap"); +const gallery = document.getElementById("gallery"); +function renderGallery(){ + gallery.innerHTML = ""; + WIDGET_CATS.forEach(([cat,key])=>{ + const defs = REGISTRY.filter(d=>d.cat===cat); + if(!defs.length) return; + const h = document.createElement("div"); + h.className = "gcat-title"; + h.textContent = T(key); + gallery.appendChild(h); + const grid = document.createElement("div"); + grid.className = "gallery"; + defs.forEach(def=>{ + const c = document.createElement("div"); + c.className = "gcard"; + c.innerHTML = `
${def.icon}
${T("wg_"+def.id)}
${T("wg_"+def.id+"_d")}
`; + c.addEventListener("click", ()=>{ galleryWrap.classList.remove("open"); makeWidget(def); }); + grid.appendChild(c); + }); + gallery.appendChild(grid); + }); +} +galleryWrap.addEventListener("click", e=>{ if(e.target===galleryWrap) galleryWrap.classList.remove("open"); }); + +/* ---------- floating add-button (FAB) ---------- */ +const fab = document.getElementById("fab"); +const fabMenu = document.getElementById("fabmenu"); +function refreshFab(){ + fabMenu.querySelector('[data-f="widgets"] .fl').textContent = T("fabWidgets"); + fabMenu.querySelector('[data-f="draw"] .fl').textContent = T("fabDraw"); + fabMenu.querySelector('[data-f="mind"] .fl').textContent = T("fabMind"); + fabMenu.querySelector('[data-f="img"] .fl').textContent = T("fabImg"); + fabMenu.querySelector('[data-f="draw"]').classList.toggle("on", document.body.classList.contains("pen")); +} +fab.addEventListener("click", ()=>{ + refreshFab(); + fabMenu.classList.toggle("open"); + const open = fabMenu.classList.contains("open"); + fab.classList.toggle("open", open); + if(open) document.getElementById("boardStrip").classList.remove("open"); +}); +document.addEventListener("pointerdown", e=>{ + if(!fabMenu.classList.contains("open")) return; + if(e.target.closest("#fab") || e.target.closest("#fabmenu")) return; + fabMenu.classList.remove("open"); + fab.classList.remove("open"); +}); +fabMenu.querySelector('[data-f="widgets"]').addEventListener("click", ()=>{ + fabMenu.classList.remove("open"); fab.classList.remove("open"); + renderGallery(); galleryWrap.classList.add("open"); +}); +fabMenu.querySelector('[data-f="draw"]').addEventListener("click", ()=>{ + fabMenu.classList.remove("open"); fab.classList.remove("open"); + setPen(!document.body.classList.contains("pen")); +}); +fabMenu.querySelector('[data-f="mind"]').addEventListener("click", ()=>{ + fabMenu.classList.remove("open"); fab.classList.remove("open"); + const def = REGISTRY.find(d=>d.id==="mind"); + makeWidget(def, { x:0, y:0, w:board.clientWidth, h:board.clientHeight, bare:true }); +}); +/* image upload onto the board */ +const imgInput = document.createElement("input"); +imgInput.type = "file"; imgInput.accept = "image/*"; imgInput.style.display = "none"; +document.body.appendChild(imgInput); +fabMenu.querySelector('[data-f="img"]').addEventListener("click", ()=>{ + fabMenu.classList.remove("open"); fab.classList.remove("open"); + imgInput.click(); +}); +function loadImgFile(f, x, y){ + const keepPng = /png|gif|svg/.test(f.type); + const img = new Image(); + img.onload = ()=>{ + const c = document.createElement("canvas"); + const s = Math.min(1, 900/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); + addBoardImage(keepPng ? c.toDataURL("image/png") : c.toDataURL("image/jpeg", 0.85), x, y, Math.min(360, c.width)); + URL.revokeObjectURL(img.src); + }; + img.src = URL.createObjectURL(f); +} +imgInput.addEventListener("change", ()=>{ + const f = imgInput.files[0]; if(!f) return; + loadImgFile(f, 80, 90); + imgInput.value = ""; +}); +/* drag & drop images from other websites or the computer onto the board */ +board.addEventListener("dragover", e=>{ e.preventDefault(); }); +board.addEventListener("drop", e=>{ + e.preventDefault(); + const zf = VZ(); + const r = board.getBoundingClientRect(); + const x = Math.max(4, (e.clientX - r.left)/zf - 100), y = Math.max(4, (e.clientY - r.top)/zf - 70); + const dt = e.dataTransfer; + if(dt.files && dt.files.length){ + [...dt.files].forEach((f,i)=>{ if(f.type.startsWith("image/")) loadImgFile(f, x+i*24, y+i*24); }); + return; + } + let url = null; + const html = dt.getData("text/html"); + if(html){ + const m = html.match(/]+src\s*=\s*["']([^"']+)["']/i); + if(m) url = m[1]; + } + if(!url){ + const uri = (dt.getData("text/uri-list") || dt.getData("text/plain") || "").trim().split("\n")[0]; + if(/^https?:\/\//i.test(uri) && (/\.(jpe?g|png|gif|webp|svg|avif)([?#]|$)/i.test(uri) || uri.includes("image"))) url = uri; + else if(/^data:image\//.test(uri)) url = uri; + else if(/^https?:\/\//i.test(uri)) url = uri; /* try anyway; broken links can be removed */ + } + if(url) addBoardImage(url, x, y, 320); +}); +function addBoardImage(src, x, y, w){ + const d = document.createElement("div"); + d.className = "bimg"; + d.style.left = x+"px"; d.style.top = y+"px"; d.style.width = w+"px"; + const im = document.createElement("img"); + im.src = src; im.draggable = false; + d.appendChild(im); + const xb = document.createElement("button"); + xb.className = "dtool-x"; xb.textContent = "✕"; + xb.addEventListener("click", ()=>d.remove()); + d.appendChild(xb); + const rs = document.createElement("div"); + rs.className = "bimg-rs"; + rs.addEventListener("pointerdown", e=>{ + e.preventDefault(); e.stopPropagation(); + const zf = VZ(); + const sw = d.offsetWidth, sx = e.clientX; + const mv = ev=>{ d.style.width = Math.max(80, sw + (ev.clientX - sx)/zf)+"px"; }; + const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; + document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); + }); + d.appendChild(rs); + d.addEventListener("pointerdown", e=>{ + if(e.target===xb || e.target===rs) return; + if(e.pointerType==="mouse" && e.button!==0) return; + e.preventDefault(); + const zf = VZ(); + const ox = e.clientX/zf - d.offsetLeft, oy = e.clientY/zf - d.offsetTop; + const mv = ev=>{ d.style.left = (ev.clientX/zf-ox)+"px"; d.style.top = (ev.clientY/zf-oy)+"px"; }; + const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; + document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); + }); + board.appendChild(d); + return d; +} +document.addEventListener("langchange", refreshFab); + +/* ========================================================= + Multiple boards, organised in folders (quick nav next to +) +==========================================================*/ +function freshBS(){ + return { folders:[{ name: LANG==="nl" ? "Map 1" : "Folder 1", + boards:[{ name: LANG==="nl" ? "Bord 1" : "Board 1", data:null }] }], + f:0, b:0 }; +} +let BS = freshBS(); +function boardsFromData(data){ + if(data && data.boards && data.boards.folders && data.boards.folders.length) return data.boards; + if(data && data.board){ + const bs = freshBS(); + bs.folders[0].boards[0].data = data.board; /* migrate the old single board */ + return bs; + } + return freshBS(); +} +function curSlot(){ + if(BS.f >= BS.folders.length) BS.f = 0; + const fo = BS.folders[BS.f]; + if(BS.b >= fo.boards.length) BS.b = 0; + return fo.boards[BS.b]; +} +function emptyBoardData(){ return { widgets:[], ink:null, bg:"blank", imgs:[] }; } +function stashCurrent(){ curSlot().data = serializeBoard(); } +function loadCurrentSlot(){ + restoreBoard(curSlot().data || emptyBoardData()); + updateBoardsUI(); +} +function switchTo(f, b){ + stashCurrent(); + BS.f = f; BS.b = b; + loadCurrentSlot(); +} +const boardsPanel = document.getElementById("boardsPanel"); +const btnFolders = document.getElementById("btnFolders"); +btnFolders.addEventListener("click", ()=>{ + renderBoardsPanel(); + boardsPanel.classList.toggle("open"); +}); +document.addEventListener("pointerdown", e=>{ + if(!boardsPanel.classList.contains("open")) return; + if(e.target.closest("#btnFolders") || e.target.closest("#boardsPanel")) return; + boardsPanel.classList.remove("open"); +}); +function updateBoardsUI(){ + btnFolders.title = `${T("boardsNav")} · ${BS.folders[BS.f].name} / ${curSlot().name}`; + fabBoardsBtn.title = `${BS.folders[BS.f].name} / ${curSlot().name}`; + if(boardsPanel.classList.contains("open")) renderBoardsPanel(); + if(boardStrip.classList.contains("open")) renderStrip(); +} +function flatBoards(){ + const out = []; + BS.folders.forEach((fo,fi)=>fo.boards.forEach((bo,bi)=>out.push({fi,bi,bo,fo}))); + return out; +} + +/* ---- quick switch: hover the 🗂 button → previews side by side ---- */ +const fabBoardsBtn = document.getElementById("fabBoardsBtn"); +const boardStrip = document.getElementById("boardStrip"); +let stripTimer = null; +function openStrip(){ clearTimeout(stripTimer); renderStrip(); boardStrip.classList.add("open"); } +function closeStrip(){ boardStrip.classList.remove("open"); } +fabBoardsBtn.addEventListener("click", ()=>{ + boardStrip.classList.contains("open") ? closeStrip() : openStrip(); +}); +fabBoardsBtn.addEventListener("mouseenter", openStrip); +fabBoardsBtn.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); }); +boardStrip.addEventListener("mouseenter", ()=>clearTimeout(stripTimer)); +boardStrip.addEventListener("mouseleave", ()=>{ stripTimer = setTimeout(closeStrip, 400); }); +document.addEventListener("pointerdown", e=>{ + if(!boardStrip.classList.contains("open")) return; + if(e.target.closest("#boardStrip") || e.target.closest("#fabBoardsBtn")) return; + closeStrip(); +}); +boardStrip.addEventListener("wheel", e=>{ e.preventDefault(); boardStrip.scrollLeft += e.deltaY; }); + +/* mini preview of a board's contents */ +function boardThumb(data){ + const w = 168, h = 105; + const c = document.createElement("canvas"); + c.width = w; c.height = h; + const g = c.getContext("2d"); + g.fillStyle = "#fff"; g.fillRect(0, 0, w, h); + if(!data) return c; + const bw = Math.max(board.clientWidth, 1), bh = Math.max(board.clientHeight, 1); + (data.imgs||[]).forEach(im=>{ + g.fillStyle = "#d8e2ef"; + g.fillRect(im.x*w/bw, im.y*h/bh, Math.max(6, im.w*w/bw), Math.max(5, im.w*0.7*h/bh)); + }); + (data.widgets||[]).forEach(wd=>{ + const x = wd.x*w/bw, y = wd.y*h/bh, + ww = Math.max(12, wd.w*w/bw), wh = Math.max(9, wd.h*h/bh); + g.fillStyle = "rgba(59,125,216,.14)"; + g.fillRect(x, y, ww, wh); + g.strokeStyle = "rgba(59,125,216,.55)"; + g.strokeRect(x, y, ww, wh); + const def = REGISTRY.find(d=>d.id===wd.id); + if(def){ g.font = "11px sans-serif"; g.fillText(def.icon, x+2, y+12); } + }); + if(data.ink){ + const img = new Image(); + img.onload = ()=>{ try{ g.drawImage(img, 0, 0, w, h); }catch(e){} }; + img.src = data.ink; + } + return c; +} +function renderStrip(){ + boardStrip.innerHTML = ""; + const items = flatBoards(); + items.forEach(it=>{ + const isCur = it.fi===BS.f && it.bi===BS.b; + const card = document.createElement("div"); + card.className = "bs-card" + (isCur ? " active" : ""); + card.title = `${it.fo.name} / ${it.bo.name}`; + card.appendChild(boardThumb(isCur ? serializeBoard() : it.bo.data)); + const row = document.createElement("div"); + row.className = "bs-name"; + const nm = document.createElement("span"); + nm.textContent = it.bo.name; + row.appendChild(nm); + if(items.length > 1){ + const x = document.createElement("button"); + x.className = "bs-x"; x.textContent = "✕"; + x.title = T("delBoard"); + x.addEventListener("pointerdown", ev=>ev.stopPropagation()); + x.addEventListener("click", ev=>{ + ev.stopPropagation(); + if(!x.dataset.arm){ /* first click arms, second click deletes */ + clearTimeout(stripTimer); /* keep the strip open while confirming */ + x.dataset.arm = "1"; x.classList.add("arm"); x.textContent = "✓?"; + setTimeout(()=>{ x.dataset.arm=""; x.classList.remove("arm"); x.textContent="✕"; }, 3000); + return; + } + const fo = BS.folders[it.fi]; + fo.boards.splice(it.bi, 1); + if(!fo.boards.length) fo.boards.push({ name:T("boardName")+" 1", data:null }); + if(isCur){ BS.f = it.fi; BS.b = 0; loadCurrentSlot(); } + else if(it.fi===BS.f && it.bi < BS.b) BS.b--; + renderStrip(); + }); + row.appendChild(x); + } + card.appendChild(row); + card.addEventListener("click", ()=>{ + if(!isCur){ switchTo(it.fi, it.bi); renderStrip(); } + }); + boardStrip.appendChild(card); + }); + const add = document.createElement("div"); + add.className = "bs-card bs-new"; + add.textContent = "+"; + add.title = T("newBoard"); + add.addEventListener("click", ()=>{ + const fo = BS.folders[BS.f]; + fo.boards.push({ name:`${T("boardName")} ${fo.boards.length+1}`, data:null }); + switchTo(BS.f, fo.boards.length-1); + renderStrip(); + }); + boardStrip.appendChild(add); +} +function renderBoardsPanel(){ + boardsPanel.innerHTML = ""; + BS.folders.forEach((fo, fi)=>{ + const fr = document.createElement("div"); + fr.className = "bp-folder"; + const nm = document.createElement("span"); + nm.className = "bpf-name"; nm.textContent = "📁 " + fo.name; + fr.appendChild(nm); + const add = document.createElement("button"); + add.textContent = T("newBoard"); + add.addEventListener("click", ()=>{ + fo.boards.push({ name:`${T("boardName")} ${fo.boards.length+1}`, data:null }); + switchTo(fi, fo.boards.length-1); + renderBoardsPanel(); + }); + fr.appendChild(add); + if(BS.folders.length > 1){ + const del = document.createElement("button"); + del.textContent = "🗑"; + del.addEventListener("click", ()=>{ + if(fi === BS.f){ BS.folders.splice(fi,1); BS.f = 0; BS.b = 0; loadCurrentSlot(); } + else{ BS.folders.splice(fi,1); if(BS.f > fi) BS.f--; } + renderBoardsPanel(); + }); + fr.appendChild(del); + } + boardsPanel.appendChild(fr); + const row = document.createElement("div"); + row.className = "bp-boards"; + fo.boards.forEach((bo, bi)=>{ + const chip = document.createElement("div"); + chip.className = "bp-board" + (fi===BS.f && bi===BS.b ? " active" : ""); + const lbl = document.createElement("span"); + lbl.textContent = bo.name; + chip.appendChild(lbl); + chip.addEventListener("click", e=>{ + if(e.target.classList.contains("bpb-x") || e.target.tagName==="INPUT") return; + if(fi===BS.f && bi===BS.b) return; + switchTo(fi, bi); + renderBoardsPanel(); + }); + chip.addEventListener("dblclick", ()=>{ + if(chip.querySelector("input")) return; + const inp = document.createElement("input"); + inp.value = bo.name; inp.maxLength = 20; + chip.replaceChild(inp, lbl); + inp.focus(); inp.select(); + let done = false; + const commit = ()=>{ + if(done) return; done = true; + bo.name = inp.value.trim() || bo.name; + renderBoardsPanel(); updateBoardsUI(); + }; + inp.addEventListener("keydown", ev=>{ ev.stopPropagation(); if(ev.key==="Enter") commit(); }); + inp.addEventListener("blur", commit); + inp.addEventListener("pointerdown", ev=>ev.stopPropagation()); + }); + if(fo.boards.length > 1 || BS.folders.length > 1){ + const x = document.createElement("button"); + x.className = "bpb-x"; x.textContent = "✕"; + x.addEventListener("click", ev=>{ + ev.stopPropagation(); + const wasCur = (fi===BS.f && bi===BS.b); + fo.boards.splice(bi,1); + if(!fo.boards.length) fo.boards.push({ name:T("boardName")+" 1", data:null }); + if(wasCur){ BS.b = 0; loadCurrentSlot(); } + else if(fi===BS.f && bi < BS.b) BS.b--; + renderBoardsPanel(); + }); + chip.appendChild(x); + } + row.appendChild(chip); + }); + boardsPanel.appendChild(row); + }); + const nf = document.createElement("div"); + nf.className = "bp-new"; + const inp = document.createElement("input"); + inp.placeholder = T("folderPh"); inp.maxLength = 20; + const addFolder = ()=>{ + const name = inp.value.trim() || `${T("folderName")} ${BS.folders.length+1}`; + BS.folders.push({ name, boards:[{ name:T("boardName")+" 1", data:null }] }); + inp.value = ""; + renderBoardsPanel(); + }; + inp.addEventListener("keydown", e=>{ e.stopPropagation(); if(e.key==="Enter") addFolder(); }); + const nb = document.createElement("button"); + nb.className = "tbtn"; nb.textContent = T("newFolder"); + nb.addEventListener("click", addFolder); + nf.appendChild(inp); nf.appendChild(nb); + boardsPanel.appendChild(nf); + const hint = document.createElement("div"); + hint.className = "bp-hint"; hint.textContent = T("renameTip"); + boardsPanel.appendChild(hint); +} +document.addEventListener("langchange", updateBoardsUI); + diff --git a/public/js/core.js b/public/js/core.js new file mode 100644 index 0000000..1d3be71 --- /dev/null +++ b/public/js/core.js @@ -0,0 +1,392 @@ +/* teach - kern: versie, i18n, accounts, opslag, geluid */ +"use strict"; +/* version — bump on every change (form 0.0.00) */ +const VERSION = "0.1.02"; +(function(){ + const tag = document.getElementById("verTag"); + tag.textContent = "v"+VERSION; + /* dev branch deploys report a dev-… build via /api/version → mark as beta; + main/release builds show only the version number */ + fetch("/api/version") + .then(r=>r.ok ? r.json() : null) + .then(v=>{ + if(v && typeof v.version==="string" && v.version.startsWith("dev")){ + tag.textContent = "v"+VERSION+" · beta"; + tag.classList.add("beta"); + } + }) + .catch(()=>{}); +})(); +/* ========================================================= + i18n +==========================================================*/ +const I18N = { + nl:{ + addWidget:"Widget", chooseWidget:"Kies een widget", saveBoard:"Bord opslaan", + emptyBoard:"Het bord is leeg. Klik op “Widget” om te beginnen.", + wg_letters:"Letterblokken", wg_letters_d:"Leer lezen en spellen met letterblokken – 3 niveaus, zoals ‘Ik leer lezen’.", + wg_notes:"Notities", wg_notes_d:"Een vel om op te schrijven.", + wg_timer:"Timer", wg_timer_d:"Afteltimer met kijkklok voor de klas.", + wg_write:"Schrijfblad", wg_write_d:"Schrijven en rekenen: pen, gum en papier met schrijflijnen of rekenblokjes.", + bgBlank:"Leeg blad", bgLines:"Lijntjes", bgWrite:"Schrijflijnen", + bgGridS:"Kleine rekenblokjes", bgGridB:"Grote rekenblokjes", + penSize:"Dikte", eraser:"Gum", clearAll:"Alles wissen", + toolPen:"Pen", toolFountain:"Vulpen (dun en dik, reageert op druk of snelheid)", + toolMark:"Markeerstift", toolText:"Tekst typen (klik op het blad)", + wMin:"Minimaliseren", wMax:"Herstellen", + lvl1:"Niveau 1", lvl2:"Niveau 2", lvl3:"Niveau 3", + instr1:"Leg de letterblokken op de goede plek. Kijk goed naar de letters!", + instr2_begin:"Zoek de beginletter van het woord.", + instr2_mid:"Zoek de middelste letter van het woord.", + instr2_end:"Zoek de eindletter van het woord.", + instr3:"Spel het woord bij het plaatje. De nopjes helpen je!", + card:"Kaart", cardGeneral:"Algemeen", word:"Woord", welldone:"Goed zo!", next:"Volgende woord ▸", + cardDone:"Kaart klaar! 🎉", pickCard:"Kies een nieuwe kaart of een ander niveau.", + myWords:"Mijn woorden", manage:"Thema's en woorden beheren", addWord:"+ Toevoegen", + newTheme:"Nieuw thema", themeNamePh:"naam van het thema…", delTheme:"Thema verwijderen", + numWords:"Woorden per spel", all:"Alle", noThemes:"— nog geen thema's —", + muteAll:"Alle geluiden aan/uit", sndWidget:"Geluid van deze widget aan/uit", + catTaal:"Taal", catRekenen:"Rekenen", catTools:"Hulpmiddelen", catMedia:"Media", + wg_madd:"Plussommen", wg_madd_d:"Optellen tot 10, 20 of 100.", + wg_msub:"Minsommen", wg_msub_d:"Aftrekken tot 10, 20 of 100.", + wg_mmul:"Keersommen", wg_mmul_d:"De tafels van 1 t/m 12 oefenen.", + wg_mdiv:"Deelsommen", wg_mdiv_d:"Delen met de tafels van 1 t/m 12.", + wg_mpct:"Procenten", wg_mpct_d:"Procenten uitrekenen met mooie getallen.", + mGood:"Goed zo!", mAnsWas:"Het goede antwoord is", mAgain:"Nog een keer ▸", + mGoed:"goed", mOf:"van", mDoneT:"Klaar!", + amode:"Blokjes slepen of typen", penMode:"Tekenen op het bord", + fabWidgets:"Widget toevoegen", fabDraw:"Tekenen & schrijven", fabMind:"Mindmap op het bord", + fabImg:"Afbeelding op het bord", + nodeColor:"Kleur", nodeFont:"Lettergrootte", lineColor:"Lijnkleur", lineStyle:"Lijnstijl", + a11y:"Hoog contrast (voor slechtzienden)", + zoomUI:"Beeldgrootte: alles groter of kleiner tonen", + lensBig:"Loep groter", lensSmall:"Loep kleiner", lensShape:"Rond of vierkant", + toolHand:"Handje: verplaatsen en bedienen", + boardsNav:"Borden en mappen", newBoard:"+ Bord", newFolder:"+ Map", + folderPh:"naam nieuwe map…", boardName:"Bord", folderName:"Map", + delBoard:"Bord verwijderen (2× tikken)", + renameTip:"Dubbelklik op een bord om het te hernoemen", + hudMin:"Menu inklappen/uitklappen", hudOrient:"Horizontaal of verticaal", + wFull:"Maximaliseren", wUnfull:"Terugzetten", + wZoom:"Inhoud groter of kleiner (voor slechtzienden)", + wg_flits:"Flitswoorden", wg_flits_d:"Een woord flitst kort in beeld – lezen voordat het verdwijnt!", + flFlash:"⚡ Flits", flShow:"👁 Toon", flNext:"Volgende ▸", flSec:"sec", + wg_dice:"Dobbelstenen", wg_dice_d:"Gooi 1, 2 of 3 grote dobbelstenen.", + diceRoll:"🎲 Gooi!", diceSum:"Samen", + wg_light:"Stoplicht", wg_light_d:"Rood, oranje of groen – bijvoorbeeld voor het werkrumoer.", + wg_names:"Namenkiezer", wg_names_d:"Kies willekeurig een naam uit de klas.", + namesPh:"Eén naam per regel…", namesPick:"🎯 Kies!", namesNoRep:"Zonder herhaling", + namesDone:"Iedereen is geweest! 🎉", + wg_hak:"Hakken & plakken", wg_hak_d:"Woorden in klanken hakken en weer plakken, met voorlezen.", + hakChop:"✂️ Hak", hakGlue:"🔗 Plak", hakSay:"🔊 Lees voor", + wg_ball:"Ballonnenwoord", wg_ball_d:"Raad het woord letter voor letter voordat de ballonnen knappen.", + wg_anchor:"Ankerwoorden", wg_anchor_d:"Onbeperkt woordraster rond een thema: elk woord hoort bij de woorden eromheen.", + anchorPh:"thema…", anchorHint:"Tik op een vak en typ · sleep de achtergrond om te schuiven", + anchorColor:"Kleur: klik om te wisselen (verbind woorden met dezelfde kleur)", + ballWin:"Geraden! 🎉", ballLose:"Helaas! Het woord was:", + bgDots:"Stippen", toolRuler:"Liniaal", toolGeo:"Geodriehoek", toolLens:"Vergrootglas", + wg_mind:"Mindmap", wg_mind_d:"Woordweb, spin of boomdiagram om samen ideeën te ordenen.", + mmSpider:"Spin", mmTree:"Boom", mmWeb:"Woordweb", mmRoot:"Onderwerp", + mmHint:"Klik op tekst om te typen · sleep aan de rand", + wg_media:"Foto & video", wg_media_d:"Toon foto's en video's van internet, zoals YouTube of Vimeo.", + mediaPh:"Plak een link (YouTube, Vimeo, foto of video)…", mediaShow:"Tonen", + mediaEmpty:"Plak hierboven een link en klik op Tonen.", + mediaErr:"Deze link kan niet worden getoond. Probeer een YouTube-, Vimeo-, foto- of videolink, of open hem met ↗ in een nieuw tabblad.", + mediaOpen:"Open in nieuw tabblad", + wordPh:"woord (2–5 letters)", close:"Sluiten", + badWord:"Gebruik 2 t/m 5 letters (a-z).", needPic:"Kies eerst een plaatje.", + noWords:"Nog geen eigen woorden. Voeg er hierboven een toe!", + tabEmoji:"😀 Emoji", tabPicto:"🖼️ Picto's", tabUpload:"📷 Upload", + pictoSearch:"Zoeken", pictoPh:"zoekwoord…", pictoNone:"Niets gevonden. Probeer een ander woord.", + pictoLoad:"Zoeken…", pictoErr:"Kon picto's niet laden (internet nodig).", + pictoCredit:"Picto's: ARASAAC (CC BY-NC-SA, Gobierno de Aragón). Ook leuk: sclera.be (gratis download, dan via Upload toevoegen).", + upload:"📷 Kies een afbeelding…", + upHint:"Tip: vierkant plaatje, witte of doorzichtige achtergrond, één duidelijk voorwerp, géén tekst in het plaatje.", + cats:["Dieren","Eten","Natuur","Vervoer","Spullen","Lichaam"], + notesPh:"Schrijf hier…", + start:"Start", pause:"Pauze", reset:"Reset", min:"min", + tmDigits:"Cijfers", tmVisual:"Kijkklok", tmBoth:"Allebei", tmSound:"Geluid aan/uit", + tmHint:"Sleep aan de rode schijf om de tijd in te stellen.", + login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen", + namePh:"naam", passPh:"wachtwoord", + guestNote:"Zonder inloggen wordt niets bewaard: eigen woorden en het bord zijn weg als je de pagina sluit. Log in om alles op dit apparaat te bewaren.", + storedNote:"Je eigen woorden worden automatisch bewaard. Met “Bord opslaan” bewaar je ook de widgets op het bord.", + errLogin:"Onbekende naam of verkeerd wachtwoord.", + errExists:"Deze naam bestaat al.", + errName:"Naam: 2–20 letters of cijfers.", + errPass:"Wachtwoord: minimaal 4 tekens.", + errCrypto:"Inloggen werkt niet in deze browser.", + hello:"Hoi", boardSaved:"Opgeslagen ✓", loginFirst:"Eerst inloggen" + }, + en:{ + addWidget:"Widget", chooseWidget:"Choose a widget", saveBoard:"Save board", + emptyBoard:"The board is empty. Click “Widget” to get started.", + wg_letters:"Letter blocks", wg_letters_d:"Learn to read and spell with letter blocks – 3 levels, like ‘Ik leer lezen’.", + wg_notes:"Notes", wg_notes_d:"A sheet to write on.", + wg_timer:"Timer", wg_timer_d:"Countdown timer with a visual clock for class.", + wg_write:"Writing pad", wg_write_d:"Writing and maths: pen, eraser and paper with handwriting lines or maths squares.", + bgBlank:"Blank sheet", bgLines:"Lines", bgWrite:"Handwriting lines", + bgGridS:"Small maths squares", bgGridB:"Big maths squares", + penSize:"Thickness", eraser:"Eraser", clearAll:"Clear all", + toolPen:"Pen", toolFountain:"Fountain pen (thin and thick, follows pressure or speed)", + toolMark:"Highlighter", toolText:"Type text (click the sheet)", + wMin:"Minimize", wMax:"Restore", + lvl1:"Level 1", lvl2:"Level 2", lvl3:"Level 3", + instr1:"Place the letter blocks in the right spot. Look closely at the letters!", + instr2_begin:"Find the beginning letter of the word.", + instr2_mid:"Find the middle letter of the word.", + instr2_end:"Find the ending letter of the word.", + instr3:"Spell the word for the picture. The little studs will help you!", + card:"Card", cardGeneral:"General", word:"Word", welldone:"Well done!", next:"Next word ▸", + cardDone:"Card complete! 🎉", pickCard:"Pick a new card or another level.", + myWords:"My words", manage:"Manage themes and words", addWord:"+ Add", + newTheme:"New theme", themeNamePh:"name of the theme…", delTheme:"Delete theme", + numWords:"Words per game", all:"All", noThemes:"— no themes yet —", + muteAll:"All sounds on/off", sndWidget:"This widget's sound on/off", + catTaal:"Language", catRekenen:"Maths", catTools:"Tools", catMedia:"Media", + wg_madd:"Addition", wg_madd_d:"Adding up to 10, 20 or 100.", + wg_msub:"Subtraction", wg_msub_d:"Subtracting up to 10, 20 or 100.", + wg_mmul:"Multiplication", wg_mmul_d:"Practise the times tables 1–12.", + wg_mdiv:"Division", wg_mdiv_d:"Dividing with the tables 1–12.", + wg_mpct:"Percentages", wg_mpct_d:"Working out percentages with friendly numbers.", + mGood:"Well done!", mAnsWas:"The right answer is", mAgain:"Again ▸", + mGoed:"correct", mOf:"of", mDoneT:"Done!", + amode:"Drag blocks or type", penMode:"Draw on the board", + fabWidgets:"Add widget", fabDraw:"Draw & write", fabMind:"Mindmap on the board", + fabImg:"Picture on the board", + nodeColor:"Colour", nodeFont:"Font size", lineColor:"Line colour", lineStyle:"Line style", + a11y:"High contrast (for low vision)", + zoomUI:"Display size: show everything bigger or smaller", + lensBig:"Larger magnifier", lensSmall:"Smaller magnifier", lensShape:"Round or square", + toolHand:"Hand: move and interact", + boardsNav:"Boards and folders", newBoard:"+ Board", newFolder:"+ Folder", + folderPh:"name of new folder…", boardName:"Board", folderName:"Folder", + delBoard:"Delete board (tap twice)", + renameTip:"Double-click a board to rename it", + hudMin:"Collapse/expand menu", hudOrient:"Horizontal or vertical", + wFull:"Maximize", wUnfull:"Restore", + wZoom:"Content bigger or smaller (low vision)", + wg_flits:"Flash words", wg_flits_d:"A word flashes briefly – read it before it disappears!", + flFlash:"⚡ Flash", flShow:"👁 Show", flNext:"Next ▸", flSec:"sec", + wg_dice:"Dice", wg_dice_d:"Roll 1, 2 or 3 big dice.", + diceRoll:"🎲 Roll!", diceSum:"Total", + wg_light:"Traffic light", wg_light_d:"Red, orange or green – e.g. for classroom noise.", + wg_names:"Name picker", wg_names_d:"Pick a random name from the class.", + namesPh:"One name per line…", namesPick:"🎯 Pick!", namesNoRep:"No repeats", + namesDone:"Everyone has had a turn! 🎉", + wg_hak:"Chop & blend", wg_hak_d:"Chop words into sounds and blend them back, with read-aloud.", + hakChop:"✂️ Chop", hakGlue:"🔗 Blend", hakSay:"🔊 Read aloud", + wg_ball:"Balloon word", wg_ball_d:"Guess the word letter by letter before the balloons pop.", + wg_anchor:"Anchor words", wg_anchor_d:"Unlimited word grid around a theme: every word relates to its neighbours.", + anchorPh:"theme…", anchorHint:"Tap a cell and type · drag the background to pan", + anchorColor:"Colour: click to cycle (connect words with the same colour)", + ballWin:"You got it! 🎉", ballLose:"Oh no! The word was:", + bgDots:"Dots", toolRuler:"Ruler", toolGeo:"Set square", toolLens:"Magnifier", + wg_mind:"Mind map", wg_mind_d:"Word web, spider or tree diagram to organise ideas together.", + mmSpider:"Spider", mmTree:"Tree", mmWeb:"Word web", mmRoot:"Topic", + mmHint:"Click text to type · drag by the edge", + wg_media:"Photo & video", wg_media_d:"Show photos and videos from the internet, such as YouTube or Vimeo.", + mediaPh:"Paste a link (YouTube, Vimeo, photo or video)…", mediaShow:"Show", + mediaEmpty:"Paste a link above and click Show.", + mediaErr:"This link cannot be shown. Try a YouTube, Vimeo, photo or video link, or open it with ↗ in a new tab.", + mediaOpen:"Open in new tab", + wordPh:"word (2–5 letters)", close:"Close", + badWord:"Use 2 to 5 letters (a-z).", needPic:"Pick a picture first.", + noWords:"No custom words yet. Add one above!", + tabEmoji:"😀 Emoji", tabPicto:"🖼️ Pictos", tabUpload:"📷 Upload", + pictoSearch:"Search", pictoPh:"search word…", pictoNone:"Nothing found. Try another word.", + pictoLoad:"Searching…", pictoErr:"Could not load pictos (internet required).", + pictoCredit:"Pictos: ARASAAC (CC BY-NC-SA, Gobierno de Aragón). Also nice: sclera.be (free download, then add via Upload).", + upload:"📷 Choose an image…", + upHint:"Tip: square picture, white or transparent background, one clear object, no text in the picture.", + cats:["Animals","Food","Nature","Transport","Things","Body"], + notesPh:"Write here…", + start:"Start", pause:"Pause", reset:"Reset", min:"min", + tmDigits:"Digits", tmVisual:"Visual clock", tmBoth:"Both", tmSound:"Sound on/off", + tmHint:"Drag the red disc to set the time.", + login:"Log in", loginBtn:"Log in", registerBtn:"Create account", logout:"Log out", + namePh:"name", passPh:"password", + guestNote:"Without logging in nothing is saved: custom words and the board are gone when you close the page. Log in to keep everything on this device.", + storedNote:"Your custom words are saved automatically. “Save board” also stores the widgets on the board.", + errLogin:"Unknown name or wrong password.", + errExists:"This name already exists.", + errName:"Name: 2–20 letters or digits.", + errPass:"Password: at least 4 characters.", + errCrypto:"Login does not work in this browser.", + hello:"Hi", boardSaved:"Saved ✓", loginFirst:"Log in first" + } +}; +let LANG = "nl"; +const T = k => I18N[LANG][k]; +/* effective CSS zoom of the app (display-size setting); pointer coordinates + are visual pixels, absolute positions are layout pixels → divide by VZ() */ +const VZ = ()=>{ const z = parseFloat(document.body.style.zoom); return z>0 ? z : 1; }; + +function applyI18n(){ + document.querySelectorAll("[data-i18n]").forEach(el=>{ el.textContent = T(el.dataset.i18n); }); + document.querySelectorAll("#langToggle button").forEach(b=>b.classList.toggle("active", b.dataset.lang===LANG)); + document.getElementById("liName").placeholder = T("namePh"); + document.getElementById("liPass").placeholder = T("passPh"); + document.getElementById("btnMute").title = T("muteAll"); + updateUserUI(); + document.dispatchEvent(new CustomEvent("langchange")); +} +document.querySelectorAll("#langToggle button").forEach(b=>{ + b.addEventListener("click", ()=>{ LANG=b.dataset.lang; applyI18n(); }); +}); +document.getElementById("btnFs").addEventListener("click", ()=>{ + if(document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen(); +}); + +/* ========================================================= + Accounts & storage + - Guest: everything in memory only, gone on close. + - Logged in: PBKDF2-SHA256 hashed password (Web Crypto), + per-user data (custom words + saved board) in localStorage. + Note: this protects the password itself; anyone with physical + access to this device/browser profile can still clear or read + stored data. For real multi-device security a server is needed. +==========================================================*/ +const USERS_KEY = "teach.users"; +let currentUser = null; // {name, data} + +function loadUsers(){ try{ return JSON.parse(localStorage.getItem(USERS_KEY)) || {}; }catch(e){ return {}; } } +function saveUsers(u){ try{ localStorage.setItem(USERS_KEY, JSON.stringify(u)); }catch(e){} } + +const bufToHex = b => [...new Uint8Array(b)].map(x=>x.toString(16).padStart(2,"0")).join(""); +const hexToBuf = h => new Uint8Array(h.match(/.{2}/g).map(x=>parseInt(x,16))); +async function hashPw(pw, saltHex, iterations){ + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(pw), "PBKDF2", false, ["deriveBits"]); + const bits = await crypto.subtle.deriveBits( + {name:"PBKDF2", hash:"SHA-256", salt:hexToBuf(saltHex), iterations}, key, 256); + return bufToHex(bits); +} +const cryptoOK = !!(window.crypto && crypto.subtle && crypto.getRandomValues); + +function persistUser(){ + if(!currentUser) return; + const users = loadUsers(); + if(users[currentUser.name]){ users[currentUser.name].data = currentUser.data; saveUsers(users); } +} +function persistWords(){ + if(currentUser){ + currentUser.data.themes = THEMES; + currentUser.data.generalExtra = GENERAL_EXTRA; + persistUser(); + } +} +function generalExtraFromData(data){ + return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]}; +} + +/* login UI */ +const loginWrap = document.getElementById("loginWrap"); +const loginMsg = document.getElementById("loginMsg"); +function updateUserUI(){ + document.getElementById("userLbl").textContent = currentUser ? currentUser.name : T("login"); + document.getElementById("btnUser").classList.toggle("on", !!currentUser); + document.getElementById("loginBox").style.display = currentUser ? "none" : "block"; + document.getElementById("loggedBox").style.display = currentUser ? "block" : "none"; + if(currentUser) document.getElementById("helloLbl").textContent = `${T("hello")} ${currentUser.name}! 👋`; +} +document.getElementById("btnUser").addEventListener("click", ()=>{ + loginMsg.textContent = ""; loginMsg.classList.remove("ok"); + loginWrap.classList.add("open"); +}); +loginWrap.addEventListener("click", e=>{ if(e.target===loginWrap) loginWrap.classList.remove("open"); }); + +async function doLogin(register){ + const name = document.getElementById("liName").value.trim(); + const pass = document.getElementById("liPass").value; + loginMsg.classList.remove("ok"); + if(!cryptoOK){ loginMsg.textContent = T("errCrypto"); return; } + if(!/^[a-zA-Z0-9_-]{2,20}$/.test(name)){ loginMsg.textContent = T("errName"); return; } + if(pass.length < 4){ loginMsg.textContent = T("errPass"); return; } + const users = loadUsers(); + try{ + if(register){ + if(users[name]){ loginMsg.textContent = T("errExists"); return; } + const salt = bufToHex(crypto.getRandomValues(new Uint8Array(16))); + const iterations = 150000; + const hash = await hashPw(pass, salt, iterations); + users[name] = { salt, iterations, hash, data:{ themes:{nl:[],en:[]}, board:null } }; + saveUsers(users); + }else{ + const u = users[name]; + if(!u){ loginMsg.textContent = T("errLogin"); return; } + const hash = await hashPw(pass, u.salt, u.iterations); + if(hash !== u.hash){ loginMsg.textContent = T("errLogin"); return; } + } + }catch(e){ loginMsg.textContent = T("errCrypto"); return; } + currentUser = { name, data: loadUsers()[name].data }; + THEMES = themesFromData(currentUser.data); + GENERAL_EXTRA = generalExtraFromData(currentUser.data); + document.getElementById("liPass").value = ""; + try{ sessionStorage.setItem("teach.session", name); }catch(e){} + updateUserUI(); + loginWrap.classList.remove("open"); + document.dispatchEvent(new CustomEvent("wordschange")); + BS = boardsFromData(currentUser.data); + loadCurrentSlot(); +} +document.getElementById("btnLogin").addEventListener("click", ()=>doLogin(false)); +document.getElementById("btnRegister").addEventListener("click", ()=>doLogin(true)); +document.getElementById("liPass").addEventListener("keydown", e=>{ if(e.key==="Enter") doLogin(false); }); +document.getElementById("btnLogout").addEventListener("click", ()=>{ + persistUser(); + currentUser = null; + THEMES = {nl:[],en:[]}; + GENERAL_EXTRA = {nl:[],en:[]}; + try{ sessionStorage.removeItem("teach.session"); }catch(e){} + updateUserUI(); + loginWrap.classList.remove("open"); + document.dispatchEvent(new CustomEvent("wordschange")); + BS = freshBS(); + loadCurrentSlot(); +}); + +/* save board */ +document.getElementById("btnSave").addEventListener("click", ()=>{ + const btn = document.getElementById("btnSave"); + const lbl = btn.querySelector("span"); + if(!currentUser){ + lbl.textContent = T("loginFirst"); + setTimeout(()=>{ lbl.textContent = T("saveBoard"); }, 2200); + loginWrap.classList.add("open"); + return; + } + stashCurrent(); + currentUser.data.boards = BS; + delete currentUser.data.board; + persistUser(); + lbl.textContent = T("boardSaved"); + btn.classList.add("ok"); + setTimeout(()=>{ lbl.textContent = T("saveBoard"); btn.classList.remove("ok"); }, 1800); +}); + +/* ========================================================= + Sounds +==========================================================*/ +let AC=null, MUTED=false; +const btnMute = document.getElementById("btnMute"); +btnMute.addEventListener("click", ()=>{ + MUTED = !MUTED; + btnMute.textContent = MUTED ? "🔇" : "🔊"; + btnMute.title = T("muteAll"); +}); +function tone(freq, dur=0.12, delay=0, type="sine", vol=0.18){ + if(MUTED) return; + try{ + AC = AC || new (window.AudioContext||window.webkitAudioContext)(); + const o=AC.createOscillator(), g=AC.createGain(); + o.type=type; o.frequency.value=freq; + g.gain.setValueAtTime(vol, AC.currentTime+delay); + g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime+delay+dur); + o.connect(g); g.connect(AC.destination); + o.start(AC.currentTime+delay); o.stop(AC.currentTime+delay+dur+0.02); + }catch(e){} +} +const sndGood = ()=> tone(880,0.1); +const sndBad = ()=> tone(180,0.18,0,"square",0.10); +const sndWin = ()=>{ [523,659,784,1047].forEach((f,i)=>tone(f,0.16,i*0.12,"triangle",0.2)); }; + diff --git a/public/js/data.js b/public/js/data.js new file mode 100644 index 0000000..a3aa7d6 --- /dev/null +++ b/public/js/data.js @@ -0,0 +1,132 @@ +/* teach - woorddata: algemene kaart, thema's, gedeelde woordbronnen */ +/* ========================================================= + Letter game data +==========================================================*/ +const DOTCODE = { + a:[1], b:[1,2], c:[1,4], d:[1,4,5], e:[1,5], f:[1,2,4], g:[1,2,4,5], h:[1,2,5], + i:[2,4], j:[2,4,5], k:[1,3], l:[1,2,3], m:[1,3,4], n:[1,3,4,5], o:[1,3,5], + p:[1,2,3,4], q:[1,2,3,4,5], r:[1,2,3,5], s:[2,3,4], t:[2,3,4,5], u:[1,3,6], + v:[1,2,3,6], w:[2,4,5,6], x:[1,3,4,6], y:[1,3,4,5,6], z:[1,3,5,6], + /* digits use the braille number patterns (a–j) */ + "1":[1], "2":[1,2], "3":[1,4], "4":[1,4,5], "5":[1,5], + "6":[1,2,4], "7":[1,2,4,5], "8":[1,2,5], "9":[2,4], "0":[2,4,5] +}; +const VOWELS = "aeiou"; +function dotGrid(letter){ + const code = DOTCODE[letter] || []; + const grid = document.createElement("div"); + grid.className = "dots"; + [1,4,2,5,3,6].forEach(p=>{ + const d = document.createElement("i"); + d.className = code.includes(p) ? "on" : "off"; + grid.appendChild(d); + }); + return grid; +} + +/* built-in general words: ONE card per language (extendable via the editor) */ +const WORDS = { + nl:[ + ["aap","🐵"],["vis","🐟"],["zon","☀️"],["kat","🐈"],["bal","⚽"],["ei","🥚"], + ["boom","🌳"],["huis","🏠"],["maan","🌙"],["boot","⛵"],["kip","🐔"],["muis","🐭"], + ["boek","📕"],["ster","⭐"],["roos","🌹"],["vuur","🔥"],["fiets","🚲"],["taart","🎂"], + ["jas","🧥"],["bril","👓"],["klok","⏰"],["peer","🍐"],["kaas","🧀"],["eend","🦆"], + ["hand","✋"],["voet","🦶"],["neus","👃"],["oog","👁️"],["oor","👂"],["mond","👄"] + ], + en:[ + ["cat","🐈"],["dog","🐕"],["sun","☀️"],["egg","🥚"],["ball","⚽"],["fish","🐟"], + ["tree","🌳"],["moon","🌙"],["boat","⛵"],["star","⭐"],["book","📕"],["cake","🎂"], + ["house","🏠"],["mouse","🐭"],["apple","🍎"],["train","🚂"],["fire","🔥"],["rose","🌹"], + ["bear","🐻"],["duck","🦆"],["frog","🐸"],["bee","🐝"],["cow","🐄"],["pig","🐷"], + ["hand","✋"],["foot","🦶"],["nose","👃"],["eye","👁️"],["ear","👂"],["milk","🥛"] + ] +}; +/* teacher's own additions to the general card (persisted per user) */ +let GENERAL_EXTRA = {nl:[], en:[]}; +function generalList(){ return WORDS[LANG].concat(GENERAL_EXTRA[LANG]); } +const shuffle = a => { a=a.slice(); for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]];} return a; }; + +/* custom themes: folders of own words. + {nl:[{name:"Herfst", words:[[word,pic],...]}], en:[...]} */ +let THEMES = {nl:[], en:[]}; +function themesFromData(data){ + if(!data) return {nl:[],en:[]}; + if(data.themes) return data.themes; + /* migrate old flat word lists into one theme */ + const t = {nl:[],en:[]}; + if(data.words) ["nl","en"].forEach(l=>{ + if(Array.isArray(data.words[l]) && data.words[l].length) + t[l] = [{name: l==="nl" ? "Mijn woorden" : "My words", words: data.words[l]}]; + }); + return t; +} +const isImg = p => typeof p==="string" && (p.startsWith("data:") || p.startsWith("http")); +function setPic(el, pic){ + if(isImg(pic)){ el.innerHTML = ""; const im = document.createElement("img"); im.src = pic; im.alt=""; el.appendChild(im); } + else el.textContent = pic; +} + +/* ---- shared word source (general card + themes + anchor grids) ---- */ +/* anchor-word widgets register their words here so every language widget + can use them as a theme automatically */ +let ANCHORS = []; /* [{el, name, words:[[w,pic],...]}] */ +function anchorSources(){ + ANCHORS = ANCHORS.filter(a=>a.el && document.contains(a.el)); + return ANCHORS.filter(a=>a.words.length); +} +function normWordSource(card){ + if(typeof card==="string"){ + if(card.startsWith("t")){ + const th = THEMES[LANG][+card.slice(1)]; + if(th && th.words.length) return card; + }else if(card.startsWith("a")){ + if(anchorSources()[+card.slice(1)]) return card; + } + } + return "g"; +} +function wordSourceList(card){ + card = normWordSource(card); + if(card.startsWith("t")) return THEMES[LANG][+card.slice(1)].words; + if(card.startsWith("a")) return anchorSources()[+card.slice(1)].words; + return generalList(); +} +function fillWordSourceSel(sel, card){ + sel.innerHTML = ""; + const og = document.createElement("option"); + og.value = "g"; og.textContent = T("cardGeneral"); + sel.appendChild(og); + THEMES[LANG].forEach((th,i)=>{ + if(!th.words.length) return; + const o = document.createElement("option"); + o.value = "t"+i; o.textContent = "★ "+th.name; + sel.appendChild(o); + }); + anchorSources().forEach((a,i)=>{ + const o = document.createElement("option"); + o.value = "a"+i; o.textContent = "⚓ "+a.name; + sel.appendChild(o); + }); + sel.value = normWordSource(card); + if(sel.selectedIndex < 0) sel.selectedIndex = 0; +} + +/* emoji palette (grouped) */ +const splitEmoji = s => { + const out = []; + for(const ch of s){ + if(out.length && (ch==="️" || ch==="‍")) out[out.length-1]+=ch; + else if(out.length && out[out.length-1].endsWith("‍")) out[out.length-1]+=ch; + else out.push(ch); + } + return out; +}; +const EMOJI_CATS = [ + splitEmoji("🐵🐶🐱🐭🐹🐰🦊🐻🐼🐨🐯🦁🐮🐷🐸🐔🐧🐦🦆🦉🐴🦄🐝🐛🦋🐌🐞🐠🐟🐬🐳🐢🐍🦎🐘🦒"), + splitEmoji("🍎🍐🍊🍋🍌🍉🍇🍓🍒🍑🥝🍅🥕🌽🥔🧀🥚🍞🥐🥞🍕🍟🍦🍪🎂🍫🍬🥛🧃🍯"), + splitEmoji("🌳🌲🌴🌵🌷🌹🌻🌸💐🍄⭐🌙☀️⛅🌈❄️🔥💧🌊🪨"), + splitEmoji("🚗🚕🚌🚑🚒🚜🚲🛴🏍️🚂✈️🚁⛵🚀🛶🚢"), + splitEmoji("⚽🏀🎈🎁🧸📕✏️🖍️✂️🎨🥁🎸👓⌚⏰💡🔑🧤🧢👟👗🧥🛏️🪑🚪🏠"), + splitEmoji("✋👂👃👁️👄🦶😀😢😴🤧👶👦👧👵👴") +]; + diff --git a/public/js/widgets/anchor.js b/public/js/widgets/anchor.js new file mode 100644 index 0000000..7b6ce14 --- /dev/null +++ b/public/js/widgets/anchor.js @@ -0,0 +1,159 @@ +/* widget: Ankerwoorden */ +/* ========================================================= + Anchor words — unlimited word grid around a theme +==========================================================*/ +function mountAnchor(root, initState){ + 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} }; + 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}; + } + root.innerHTML = ` +
+
+
+
`; + const stage = root.querySelector(".an-stage"), + layer = root.querySelector(".an-layer"), + hint = root.querySelector(".an-hint"); + 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); + 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,12}$/.test(w) && !seen.has(w)){ + seen.add(w); + reg.words.push([w, "⚓"]); + } + }); + 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 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"; + } + 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("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"), 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} = pd; + pd = null; + if(moved){ render(); return; } + if(target) 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(); + }; + 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()); + } + 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 }) }; +} + diff --git a/public/js/widgets/ball.js b/public/js/widgets/ball.js new file mode 100644 index 0000000..8c4cf0e --- /dev/null +++ b/public/js/widgets/ball.js @@ -0,0 +1,109 @@ +/* widget: Ballonnenwoord */ +/* ========================================================= + Balloon word widget — guess before the balloons pop +==========================================================*/ +function mountBall(root, initState){ + const S = { card:0, snd:true, lives:6, guessed:new Set(), over:false, order:[], idx:0, stars:0 }; + if(initState){ S.card = initState.card ?? 0; S.snd = initState.snd !== false; } + const play = f=>{ if(S.snd) f(); }; + + root.innerHTML = ` +
+
+ + +
+
+
+
+
+
+ +
+
+
`; + const el = s => root.querySelector(s); + const srcSel = el(".bl-src"), balloonsEl = el(".bl-balloons"), + 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(){ S.card = normWordSource(S.card); return wordSourceList(S.card); } + function buildSrc(){ fillWordSourceSel(srcSel, S.card); } + "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(6-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 = 6; S.guessed = new Set(); S.over = false; + msgEl.textContent = ""; msgEl.className = "bl-msg"; + nextBtn.classList.remove("show"); + kbdEl.querySelectorAll("button").forEach(b=>{ b.disabled = false; b.style.opacity = 1; }); + 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+" ★"; + } + }else{ + S.lives--; + 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"); + } + } + } + nextBtn.addEventListener("click", ()=>{ S.idx++; newWord(); }); + srcSel.addEventListener("change", ()=>{ + S.card = srcSel.value.startsWith("t") ? srcSel.value : +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", ()=>{ S.card=0; S.order=[]; labels(); newWord(); }); + document.addEventListener("wordschange", ()=>{ buildSrc(); S.order=[]; newWord(); }); + labels(); newWord(); + return { getState: ()=>({card:S.card, snd:S.snd}) }; +} + diff --git a/public/js/widgets/dice.js b/public/js/widgets/dice.js new file mode 100644 index 0000000..f563868 --- /dev/null +++ b/public/js/widgets/dice.js @@ -0,0 +1,74 @@ +/* widget: Dobbelstenen */ +/* ========================================================= + Dice widget +==========================================================*/ +function mountDice(root, initState){ + let count = (initState && initState.count) || 2, rolling = false; + 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}`; + rolling = false; + } + }, 90); + } + 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.querySelectorAll(".dc-seg button").forEach(x=>x.classList.toggle("on", +x.dataset.n===count)); + } + document.addEventListener("langchange", labels); + labels(); build(); + return { getState: ()=>({count}) }; +} + diff --git a/public/js/widgets/flits.js b/public/js/widgets/flits.js new file mode 100644 index 0000000..9eb66a7 --- /dev/null +++ b/public/js/widgets/flits.js @@ -0,0 +1,90 @@ +/* widget: Flitswoorden */ +/* ========================================================= + Flash words widget +==========================================================*/ +function mountFlits(root, initState){ + const S = { card:0, dur:2, idx:0, order:[] }; + if(initState){ S.card = initState.card ?? 0; S.dur = initState.dur || 2; } + let flashTimer = null; + + 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"); + + function list(){ S.card = normWordSource(S.card); return wordSourceList(S.card); } + function buildSrc(){ + fillWordSourceSel(srcSel, S.card); + 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 hide(){ + wordEl.textContent = "• • •"; + wordEl.classList.add("dim"); + picEl.textContent = ""; + } + function flash(){ + clearTimeout(flashTimer); + wordEl.textContent = cur()[0]; + wordEl.classList.remove("dim"); + picEl.textContent = ""; + cntEl.textContent = `${T("word")} ${S.idx+1}/${S.order.length}`; + flashTimer = setTimeout(hide, S.dur*1000); + } + function show(){ + clearTimeout(flashTimer); + wordEl.textContent = cur()[0]; + wordEl.classList.remove("dim"); + setPic(picEl, cur()[1]); + } + el(".fl-flash").addEventListener("click", flash); + el(".fl-show").addEventListener("click", show); + el(".fl-next").addEventListener("click", ()=>{ + S.idx++; + if(S.idx >= S.order.length) newRound(); + flash(); + }); + srcSel.addEventListener("change", ()=>{ + S.card = srcSel.value.startsWith("t") ? srcSel.value : +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"); + buildSrc(); + } + document.addEventListener("langchange", ()=>{ S.card=0; labels(); newRound(); hide(); }); + document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); hide(); }); + labels(); newRound(); + return { getState: ()=>({card:S.card, dur:S.dur}) }; +} + diff --git a/public/js/widgets/hak.js b/public/js/widgets/hak.js new file mode 100644 index 0000000..6b8e88a --- /dev/null +++ b/public/js/widgets/hak.js @@ -0,0 +1,94 @@ +/* widget: Hakken & plakken */ +/* ========================================================= + Chop & blend widget — words in sounds, with read-aloud +==========================================================*/ +function mountHak(root, initState){ + const S = { card:0, idx:0, order:[], chopped:false }; + if(initState) S.card = initState.card ?? 0; + 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"); + + function list(){ S.card = normWordSource(S.card); return wordSourceList(S.card); } + function buildSrc(){ fillWordSourceSel(srcSel, S.card); } + 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] = cur(); + wordEl.innerHTML = ""; + if(S.chopped){ + w.split("").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 " + (VOWELS.includes(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); + 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", ()=>{ + if(S.chopped) speak(cur()[0].split("").join(", "), 0.7); + else speak(cur()[0], 0.85); + }); + el(".hk-chop").addEventListener("click", ()=>{ S.chopped = true; display(); play(sndGood); }); + el(".hk-glue").addEventListener("click", ()=>{ S.chopped = false; display(); play(sndGood); }); + el(".hk-next").addEventListener("click", ()=>{ + S.idx++; if(S.idx >= S.order.length) newRound(); + S.chopped = false; display(); + }); + const play = f=>f(); + srcSel.addEventListener("change", ()=>{ + S.card = srcSel.value.startsWith("t") ? srcSel.value : +srcSel.value; + newRound(); S.chopped = false; display(); + }); + function labels(){ + el(".hk-say").textContent = T("hakSay"); + el(".hk-chop").textContent = T("hakChop"); + el(".hk-glue").textContent = T("hakGlue"); + buildSrc(); + } + document.addEventListener("langchange", ()=>{ S.card=0; labels(); newRound(); S.chopped=false; display(); }); + document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); S.chopped=false; display(); }); + labels(); newRound(); display(); + return { getState: ()=>({card:S.card}) }; +} + diff --git a/public/js/widgets/letters.js b/public/js/widgets/letters.js new file mode 100644 index 0000000..1f01130 --- /dev/null +++ b/public/js/widgets/letters.js @@ -0,0 +1,615 @@ +/* widget: Letterblokken (Ik leer lezen) */ +function mountLetterGame(root, initState){ + const S = { level:1, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks" }; + if(initState){ + S.level = initState.level||1; + S.card = initState.card ?? "g"; + S.count = initState.count||6; + S.snd = initState.snd !== false; + S.amode = initState.amode||"blocks"; + } + 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{ + 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"; + fillWordSourceSel(srcSel, S.card); + srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; newRound(); render(); }); + cardsEl.appendChild(srcSel); + 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(); }); + }); + nextBtn.addEventListener("click", ()=>{ + S.wordIdx++; + if(S.wordIdx >= S.order.length){ showCardDone(); } else render(); + }); + document.addEventListener("langchange", ()=>{ + S.lang=LANG; S.card="g"; buildCardButtons(); buildNumSel(); newRound(); render(); + }); + document.addEventListener("wordschange", ()=>{ + buildCardButtons(); newRound(); render(); + if(edEl.classList.contains("open")){ renderThemeSel(); renderEdList(); } + }); + + function fullList(){ + S.card = normWordSource(S.card); + return wordSourceList(S.card); + } + 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); + }); + cardsEl.querySelector(".lg-edit").title = T("manage"); + numSel.title = T("numWords"); + const allOpt = numSel.querySelector('option[value="all"]'); + if(allOpt) allOpt.textContent = T("all"); + + const [word, pic] = currentWord(); + 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(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"); + } + } + function showCardDone(){ + doneEl.querySelector(".dt").textContent = T("cardDone"); + doneEl.querySelector(".dp").textContent = T("pickCard"); + doneEl.classList.add("show"); + nextBtn.classList.remove("show"); + 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"); + 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("cardGeneral")} (${generalList().length})`; + edThemeSel.appendChild(og); + list.forEach((th,i)=>{ + const o = document.createElement("option"); + o.value = i; o.textContent = `★ ${th.name} (${th.words.length})`; + edThemeSel.appendChild(o); + }); + edThemeSel.value = edTheme; + edThemeAdd.title = T("newTheme"); + edThemeDel.title = T("delTheme"); + 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")); + }); + + 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"; + 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 resp = await fetch(`https://api.arasaac.org/api/pictograms/${S.lang}/search/${encodeURIComponent(term)}`); + if(!resp.ok) throw new Error("http "+resp.status); + const arr = await resp.json(); + if(!arr.length){ edStatus.textContent = T("pictoNone"); return; } + edStatus.textContent = ""; + arr.slice(0,12).forEach(p=>{ + const url = `https://static.arasaac.org/pictograms/${p._id}/${p._id}_300.png`; + 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(); }); + /* typing a word auto-fills picto search */ + edWord.addEventListener("input", ()=>{ edPSearch.value = edWord.value.trim(); }); + + /* upload tab */ + edFile.addEventListener("change", ()=>{ + const f = edFile.files[0]; if(!f) 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); + edFile.value = ""; + }); + + 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-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{ + 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(); + 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.card=st.card??0; S.count=st.count||6; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; 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..e54310a --- /dev/null +++ b/public/js/widgets/light.js @@ -0,0 +1,24 @@ +/* stoplicht (bordgereedschap-variant zit in board.js) */ +/* ========================================================= + Traffic light widget +==========================================================*/ +function mountLight(root, initState){ + let on = (initState && initState.on) || "green"; + root.innerHTML = ` +
+
+ + + +
+
`; + function apply(){ + root.querySelectorAll(".tl-lamp").forEach(l=>l.classList.toggle("on", l.dataset.c===on)); + } + root.querySelectorAll(".tl-lamp").forEach(l=>{ + l.addEventListener("click", ()=>{ on = l.dataset.c; apply(); }); + }); + apply(); + return { getState: ()=>({on}) }; +} + diff --git a/public/js/widgets/math.js b/public/js/widgets/math.js new file mode 100644 index 0000000..db114b4 --- /dev/null +++ b/public/js/widgets/math.js @@ -0,0 +1,292 @@ +/* widgets: Rekenen (+ - x : %) */ +/* ========================================================= + Math practice engine — add / sub / mul / div / percentages +==========================================================*/ +function mountMath(root, initState, mode){ + const ROUND = 10; + const S = { level:1, snd:true, amode:"blocks", q:null, tries:0, done:0, good:0, input:"", busy:false }; + if(initState){ + S.level = initState.level||1; + 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); + 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++; + fbEl.textContent = T("mGood"); + play(sndWin); + confetti(el(".mt-card")); + setTimeout(next, 1000); + } + + function gen(){ + const L = S.level; + let a, b, ans, txt; + if(mode==="add"){ + const max = 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 = 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 = L===1 ? 5 : L===2 ? 10 : 12; + a = 1+rnd(tmax); b = 1+rnd(L===3 ? 12 : 10); ans = a*b; + txt = `${a} × ${b} =`; + }else if(mode==="div"){ + const tmax = L===1 ? 5 : L===2 ? 10 : 12; + const t = 1+rnd(tmax), q = 1+rnd(L===3 ? 12 : 10); + a = t*q; ans = q; + txt = `${a} ${S.lang==="nl" || LANG==="nl" ? ":" : "÷"} ${t} =`; + }else{ /* pct */ + const sets = 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,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); + } + 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"); + fbEl.textContent = T("mGood"); + play(sndGood); + confetti(el(".mt-card")); + setTimeout(next, 900); + }else{ + S.tries++; + aEl.classList.add("shake"); + setTimeout(()=>aEl.classList.remove("shake"), 400); + play(sndBad); + S.input = ""; aEl.textContent = ""; + if(S.tries >= 3){ + S.busy = true; + fbEl.textContent = `${T("mAnsWas")} ${S.q.ans}`; + fbEl.classList.add("ans"); + 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<4){ 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.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(); + return { + getState: ()=>({ level:S.level, snd:S.snd, amode:S.amode }), + setState: st=>{ if(st){ S.level=st.level||1; 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..5652263 --- /dev/null +++ b/public/js/widgets/names.js @@ -0,0 +1,68 @@ +/* widget: Namenkiezer */ +/* ========================================================= + Name picker widget +==========================================================*/ +function mountNames(root, initState){ + let used = new Set(), 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()); + ta.addEventListener("keydown", e=>e.stopPropagation()); + function names(){ return ta.value.split("\n").map(s=>s.trim()).filter(Boolean); } + 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"); + used.clear(); + 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); + sndWin(); + spinning = false; + } + }, 80); + }); + function labels(){ + ta.placeholder = T("namesPh"); + pickBtn.textContent = T("namesPick"); + root.querySelector(".np-nrl").textContent = T("namesNoRep"); + } + document.addEventListener("langchange", labels); + labels(); + return { getState: ()=>({names:ta.value, nr:nr.checked}) }; +} + diff --git a/public/js/widgets/notes.js b/public/js/widgets/notes.js new file mode 100644 index 0000000..4f1db0a --- /dev/null +++ b/public/js/widgets/notes.js @@ -0,0 +1,14 @@ +/* widget: Notities */ +/* ========================================================= + Notes widget +==========================================================*/ +function mountNotes(root, initState){ + const ta = document.createElement("textarea"); + ta.className = "notes"; + ta.placeholder = T("notesPh"); + if(initState && typeof initState.text==="string") ta.value = initState.text; + document.addEventListener("langchange", ()=>{ ta.placeholder = T("notesPh"); }); + root.appendChild(ta); + return { getState: ()=>({text: ta.value}) }; +} + 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/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 }; + } + }; +} + -- 2.45.2 From 352e22a64b3f6ee73c81134254a3b89b88cd78c4 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:26:01 +0200 Subject: [PATCH 012/192] v0.2.00: schoolsysteem met rollen (super/admin/groepsleiding/leerling), koppelcodes, server-API en beheerpaneel --- .env.example | 5 + compose.yaml | 2 + db/002_schools_auth.sql | 52 +++++++ deploy/compose.deploy.yaml | 2 + public/css/teach.css | 56 ++++++++ public/index.html | 20 ++- public/js/admin.js | 223 ++++++++++++++++++++++++++++++ public/js/app.js | 23 ++-- public/js/core.js | 232 +++++++++++++++++++------------ src/api.js | 271 +++++++++++++++++++++++++++++++++++++ src/auth.js | 82 +++++++++++ src/server.js | 15 +- 12 files changed, 874 insertions(+), 109 deletions(-) create mode 100644 db/002_schools_auth.sql create mode 100644 public/js/admin.js create mode 100644 src/api.js create mode 100644 src/auth.js 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/compose.yaml b/compose.yaml index f1f33d9..4881ba6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -16,6 +16,8 @@ services: PORT: 3000 DATABASE_URL: ${DATABASE_URL} APP_VERSION: dev + SUPER_USER: ${SUPER_USER:-beheerder} + SUPER_PASS: ${SUPER_PASS:-} ports: - "3000:3000" restart: unless-stopped 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/deploy/compose.deploy.yaml b/deploy/compose.deploy.yaml index 5520b4e..c92f55a 100644 --- a/deploy/compose.deploy.yaml +++ b/deploy/compose.deploy.yaml @@ -20,6 +20,8 @@ services: PORT: 3000 DATABASE_URL: ${DATABASE_URL} APP_VERSION: ${IMAGE} + SUPER_USER: ${SUPER_USER:-beheerder} + SUPER_PASS: ${SUPER_PASS:-} ports: # Alleen op localhost van de VM; nginx zit ervoor als reverse proxy - "127.0.0.1:${APP_PORT:-3000}:3000" diff --git a/public/css/teach.css b/public/css/teach.css index f54678c..575f636 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -944,3 +944,59 @@ .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:2px solid #cdd9e8; border-radius:12px; padding:11px 12px; + font:inherit; font-size:15px; outline:none; margin-bottom:10px; background:#fff; +} +.lidiv{height:1.5px; background:#e3eaf3; margin:14px 0;} +.lisub{font-size:13px; font-weight:700; color:var(--muted); margin-bottom:8px;} +#ownPwRow{margin-bottom:10px;} +#ownPwRow input{ + flex:1; min-width:0; border:2px solid #cdd9e8; border-radius:12px; + padding:10px 12px; font:inherit; font-size:15px; outline:none; +} +/* ---------- beheerpaneel ---------- */ +#adminModal{width:min(760px,94vw); max-height:86vh; overflow:auto;} +.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;} +.am-msg{min-height:20px; font-size:13.5px; font-weight:800; color:var(--green); margin:4px 0;} +.am-add{display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:8px 0;} +.am-inp,.am-sel{ + border:2px solid #cdd9e8; border-radius:10px; padding:8px 10px; font:inherit; + font-size:14px; outline:none; background:#fff; min-width:0; +} +.am-inp{flex:1; min-width:140px;} +.am-list{display:flex; flex-direction:column; gap:5px;} +.am-group{font-weight:900; font-size:13px; margin-top:10px; color:var(--ink);} +.am-row{ + display:flex; gap:8px; align-items:center; background:#f7fafd; + border-radius:10px; padding:6px 10px; flex-wrap:wrap; +} +.am-name{font-weight:800; font-size:14px; flex:1; min-width:120px;} +.am-role{font-size:12px; font-weight:700; color:var(--muted);} +.am-pw{ + border:2px solid #cdd9e8; border-radius:8px; padding:5px 8px; font:inherit; + font-size:13px; width:110px; outline:none; +} +.am-code{font-size:12.5px; font-weight:800; color:var(--orange);} +.am-btn{ + border:none; background:#e8eef6; border-radius:8px; padding:6px 10px; + font:inherit; font-weight:800; font-size:12px; cursor:pointer; color:var(--ink); +} +.am-btn:hover{background:#dbe5f1;} +.am-del{ + border:none; background:#fbe3e1; border-radius:8px; width:28px; height:28px; + cursor:pointer; font-size:13px; +} +.am-del:hover{background:var(--red);} +.am-classes{display:flex; gap:6px; flex-wrap:wrap;} +.am-chip{ + display:inline-flex; gap:6px; align-items:center; background:#fff; + border:2px solid #e3eaf3; border-radius:999px; padding:5px 12px; + font-weight:800; font-size:13px; +} +.am-chipx{border:none; background:transparent; color:var(--red); font-weight:900; cursor:pointer; padding:0; font-size:11px;} +body.hc .am-row,body.hc .am-chip{border:1.5px solid #000; background:#fff;} diff --git a/public/index.html b/public/index.html index 54028b4..773456a 100644 --- a/public/index.html +++ b/public/index.html @@ -24,6 +24,7 @@ +
@@ -70,17 +71,28 @@
+
+ +
@@ -106,6 +121,7 @@ + diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..89c94b0 --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,223 @@ +/* teach - beheerpaneel (👥): gebruikers, klassen en scholen. + Zichtbaar voor super (alles), admin (eigen school) en teacher (leerlingen). */ +"use strict"; +(function(){ + const wrap = document.getElementById("adminWrap"); + const modal = document.getElementById("adminModal"); + const btn = document.getElementById("btnAdmin"); + let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null; + + 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; + }; + const msg = t => { const m = modal.querySelector(".am-msg"); if(m) m.textContent = t; }; + + btn.addEventListener("click", async ()=>{ + if(!currentUser || currentUser.role==="pupil") return; + selSchool = currentUser.role==="super" ? (selSchool ?? "") : currentUser.schoolId; + await reload(); + wrap.classList.add("open"); + }); + wrap.addEventListener("click", e=>{ if(e.target===wrap) wrap.classList.remove("open"); }); + document.addEventListener("userchange", ()=>{ if(!currentUser) wrap.classList.remove("open"); }); + + async function reload(){ + try{ + if(currentUser.role==="super") SCHOOLS = await api("/schools"); + const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : ""; + USERS = (await api("/admin/users"+q)).users; + const cq = currentUser.role==="super" ? (selSchool ? "?school="+selSchool : "?school=0") : ""; + CLASSES = (await api("/admin/classes"+cq)).classes || []; + }catch(e){ USERS = []; CLASSES = []; } + render(); + } + + 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 userRow(u){ + const row = h("div","am-row"); + row.appendChild(h("span","am-name", u.displayName + (u.displayName!==u.username ? ` (${u.username})` : ""))); + row.appendChild(h("span","am-role", T(roleKey(u.role)))); + 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}}); } + catch(e){ msg(e.message); } + }); + row.appendChild(cs); + /* wachtwoord inzien en aanpassen */ + const pw = h("input","am-pw"); + pw.value = u.password || ""; + pw.title = T("amPw"); + pw.addEventListener("keydown", ev=>ev.stopPropagation()); + pw.addEventListener("change", async ()=>{ + if(pw.value.length < 3) return; + try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{password: pw.value}}); } + catch(e){ msg(e.message); } + }); + row.appendChild(pw); + }else{ + if(u.pending && u.linkCode){ + row.appendChild(h("span","am-code", T("amCode")+": "+u.linkCode)); + }else if(u.pending){ + row.appendChild(h("span","am-code", T("amPending"))); + } + if(["super","admin"].includes(currentUser.role) && u.role!=="pupil" && u.id!==currentUser.id){ + const nc = h("button","am-btn", T("amNewCode")); + nc.addEventListener("click", async ()=>{ + try{ + const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{newLinkCode:true}}); + msg(`${T("amCreated")} ${r.linkCode}`); + reload(); + }catch(e){ msg(e.message); } + }); + row.appendChild(nc); + } + } + if(u.id !== currentUser.id){ + const del = h("button","am-del","🗑"); + del.title = T("amDelete") || ""; + del.addEventListener("click", async ()=>{ + if(!confirm(T("amConfirmDel"))) return; + try{ await api("/admin/users/"+u.id, {method:"DELETE"}); reload(); } + catch(e){ msg(e.message); } + }); + row.appendChild(del); + } + return row; + } + + function addForm(role){ + const f = h("div","am-add"); + const name = h("input","am-inp"); + name.placeholder = T("amUserPh"); + name.addEventListener("keydown", ev=>ev.stopPropagation()); + f.appendChild(name); + let cs = null; + if(role==="pupil"){ cs = classSel(""); f.appendChild(cs); } + const go = h("button","tbtn", T(role==="pupil" ? "amNewPupil" : role==="teacher" ? "amNewTeacher" : role==="admin" ? "amNewAdmin" : "amNewSuper")); + go.addEventListener("click", async ()=>{ + const body = { role, username: name.value.trim(), displayName: name.value.trim() }; + if(role==="pupil" && cs && cs.value) body.classId = +cs.value; + if(currentUser.role==="super" && role!=="super") body.school = +selSchool; + try{ + const r = await api("/admin/users", { body }); + msg(role==="pupil" + ? `${T("amPupilMade")} ${r.user.password}` + : `${T("amCreated")} ${r.user.linkCode}`); + name.value = ""; + reload(); + }catch(e){ msg(e.message); } + }); + f.appendChild(go); + return f; + } + + function render(){ + modal.innerHTML = ""; + const head = h("div","am-head"); + head.appendChild(h("h2", null, T("beheer"))); + const close = h("button","wclose","✕"); + close.addEventListener("click", ()=>wrap.classList.remove("open")); + head.appendChild(close); + modal.appendChild(head); + + /* super: schoolkiezer + nieuwe school */ + if(currentUser.role==="super"){ + const bar = h("div","am-add"); + const sel = h("select","am-sel"); + sel.appendChild(new Option(T("amAllSchools"), "")); + SCHOOLS.forEach(s=>sel.appendChild(new Option(s.name, s.id))); + sel.value = selSchool ?? ""; + sel.addEventListener("change", ()=>{ selSchool = sel.value; reload(); }); + bar.appendChild(sel); + const inp = h("input","am-inp"); + inp.placeholder = T("amNamePh"); + inp.addEventListener("keydown", ev=>ev.stopPropagation()); + bar.appendChild(inp); + const add = h("button","tbtn", T("amNewSchool")); + add.addEventListener("click", async ()=>{ + if(!inp.value.trim()) return; + try{ + const r = await api("/admin/schools", { body:{ name: inp.value.trim() } }); + selSchool = r.school.id; inp.value = ""; + reload(); + }catch(e){ msg(e.message); } + }); + bar.appendChild(add); + modal.appendChild(bar); + } + + modal.appendChild(h("div","am-msg")); + + /* klassen (admin/super binnen een school) */ + const schoolChosen = currentUser.role!=="super" || !!selSchool; + if(schoolChosen){ + modal.appendChild(h("h3","am-h3", T("amClasses"))); + const cwrapEl = h("div","am-classes"); + CLASSES.forEach(c=>{ + const chip = h("span","am-chip", c.name); + if(["super","admin"].includes(currentUser.role)){ + const x = h("button","am-chipx","✕"); + x.addEventListener("click", async ()=>{ + try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); } + catch(e){ msg(e.message); } + }); + chip.appendChild(x); + } + cwrapEl.appendChild(chip); + }); + modal.appendChild(cwrapEl); + if(["super","admin"].includes(currentUser.role)){ + const f = h("div","am-add"); + const inp = h("input","am-inp"); + inp.placeholder = T("amNamePh"); + inp.addEventListener("keydown", ev=>ev.stopPropagation()); + f.appendChild(inp); + const go = h("button","tbtn", T("amNewClass")); + go.addEventListener("click", async ()=>{ + if(!inp.value.trim()) return; + try{ + await api("/admin/classes", { body:{ name: inp.value.trim(), school: selSchool ? +selSchool : undefined } }); + reload(); + }catch(e){ msg(e.message); } + }); + f.appendChild(go); + modal.appendChild(f); + } + } + + /* gebruikers */ + modal.appendChild(h("h3","am-h3", T("amUsers"))); + const list = h("div","am-list"); + const groups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")], ["pupil", T("rolePupil")]]; + groups.forEach(([role, label])=>{ + const us = USERS.filter(u=>u.role===role); + if(!us.length) return; + list.appendChild(h("div","am-group", label)); + us.forEach(u=>list.appendChild(userRow(u))); + }); + modal.appendChild(list); + + /* toevoegen */ + if(schoolChosen){ + if(["super","admin","teacher"].includes(currentUser.role)) modal.appendChild(addForm("pupil")); + if(["super","admin"].includes(currentUser.role)){ + modal.appendChild(addForm("teacher")); + modal.appendChild(addForm("admin")); + } + } + if(currentUser.role==="super") modal.appendChild(addForm("super")); + } +})(); diff --git a/public/js/app.js b/public/js/app.js index 09cd209..3f3ec04 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -37,19 +37,16 @@ try{ if(localStorage.getItem("teach.hc")==="1") setHC(true); }catch(e){} applyI18n(); updateEmptyHint(); -/* restore session (same browser tab session only) */ -(function(){ - let name = null; - try{ name = sessionStorage.getItem("teach.session"); }catch(e){} - const users = loadUsers(); - if(name && users[name]){ - currentUser = { name, data: users[name].data }; - THEMES = themesFromData(currentUser.data); - GENERAL_EXTRA = generalExtraFromData(currentUser.data); +/* sessie herstellen: geldig servertoken? dan opnieuw inloggen zonder wachtwoord */ +(async function(){ + updateBoardsUI(); + if(!TOKEN) return; + try{ + const me = await api("/auth/me"); + currentUser = me.user; updateUserUI(); - BS = boardsFromData(currentUser.data); - loadCurrentSlot(); - }else{ - updateBoardsUI(); + await hydrateFromServer(); + }catch(e){ + resetToGuest(); } })(); \ No newline at end of file diff --git a/public/js/core.js b/public/js/core.js index 1d3be71..4f6d6da 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -1,7 +1,7 @@ /* teach - kern: versie, i18n, accounts, opslag, geluid */ "use strict"; /* version — bump on every change (form 0.0.00) */ -const VERSION = "0.1.02"; +const VERSION = "0.2.00"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -109,9 +109,25 @@ const I18N = { tmHint:"Sleep aan de rode schijf om de tijd in te stellen.", login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen", namePh:"naam", passPh:"wachtwoord", - guestNote:"Zonder inloggen wordt niets bewaard: eigen woorden en het bord zijn weg als je de pagina sluit. Log in om alles op dit apparaat te bewaren.", - storedNote:"Je eigen woorden worden automatisch bewaard. Met “Bord opslaan” bewaar je ook de widgets op het bord.", + guestNote:"Zonder inloggen wordt niets bewaard. Log in met je schoolaccount om alles op de server te bewaren.", + storedNote:"Je werk staat op de school-server en is op elk digibord beschikbaar.", errLogin:"Onbekende naam of verkeerd wachtwoord.", + schoolSel:"— kies je school (leeg = overkoepelend) —", + linkIntro:"Nieuw account? Activeer het met je koppelcode:", + linkBtn:"Account activeren", pwChange:"Wijzig", pwNewPh:"nieuw wachtwoord (min. 6)", + codePh:"koppelcode", beheer:"Beheer", + roleSuper:"Overkoepelend beheerder", roleAdmin:"Schoolbeheerder", + roleTeacher:"Groepsleiding", rolePupil:"Leerling", + amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", + amNewSchool:"+ School", amNewClass:"+ Klas", amNewTeacher:"+ Groepsleiding", + amNewAdmin:"+ Schoolbeheerder", amNewSuper:"+ Overkoepelend beheerder", + amNewPupil:"+ Leerling", amUserPh:"gebruikersnaam…", amNamePh:"naam…", + amCode:"koppelcode", amNewCode:"nieuwe code", amPw:"wachtwoord", + amNoClass:"— geen klas —", amPending:"nog niet geactiveerd", + amCouple:"koppel leerkracht", amAllSchools:"— alle scholen —", amSuperGroup:"Overkoepelend", + amCreated:"Aangemaakt! Geef deze koppelcode door:", + amPupilMade:"Leerling aangemaakt. Wachtwoord:", + amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?", errExists:"Deze naam bestaat al.", errName:"Naam: 2–20 letters of cijfers.", errPass:"Wachtwoord: minimaal 4 tekens.", @@ -206,9 +222,25 @@ const I18N = { tmHint:"Drag the red disc to set the time.", login:"Log in", loginBtn:"Log in", registerBtn:"Create account", logout:"Log out", namePh:"name", passPh:"password", - guestNote:"Without logging in nothing is saved: custom words and the board are gone when you close the page. Log in to keep everything on this device.", - storedNote:"Your custom words are saved automatically. “Save board” also stores the widgets on the board.", + guestNote:"Without logging in nothing is saved. Log in with your school account to keep everything on the server.", + storedNote:"Your work is stored on the school server and available on any board.", errLogin:"Unknown name or wrong password.", + schoolSel:"— choose your school (empty = overarching) —", + linkIntro:"New account? Activate it with your link code:", + linkBtn:"Activate account", pwChange:"Change", pwNewPh:"new password (min. 6)", + codePh:"link code", beheer:"Management", + roleSuper:"Overarching administrator", roleAdmin:"School administrator", + roleTeacher:"Group leader", rolePupil:"Pupil", + amUsers:"Users", amClasses:"Classes", amSchools:"Schools", + amNewSchool:"+ School", amNewClass:"+ Class", amNewTeacher:"+ Group leader", + amNewAdmin:"+ School admin", amNewSuper:"+ Overarching admin", + amNewPupil:"+ Pupil", amUserPh:"username…", amNamePh:"name…", + amCode:"link code", amNewCode:"new code", amPw:"password", + amNoClass:"— no class —", amPending:"not yet activated", + amCouple:"link teacher", amAllSchools:"— all schools —", amSuperGroup:"Overarching", + amCreated:"Created! Pass on this link code:", + amPupilMade:"Pupil created. Password:", + amConfirmDel:"Are you sure you want to delete this user?", errExists:"This name already exists.", errName:"Name: 2–20 letters or digits.", errPass:"Password: at least 4 characters.", @@ -227,6 +259,9 @@ function applyI18n(){ document.querySelectorAll("#langToggle button").forEach(b=>b.classList.toggle("active", b.dataset.lang===LANG)); document.getElementById("liName").placeholder = T("namePh"); document.getElementById("liPass").placeholder = T("passPh"); + document.getElementById("liCode").placeholder = T("codePh"); + document.getElementById("liPassNew").placeholder = T("pwNewPh"); + document.getElementById("ownPw").placeholder = T("pwNewPh"); document.getElementById("btnMute").title = T("muteAll"); updateUserUI(); document.dispatchEvent(new CustomEvent("langchange")); @@ -239,126 +274,157 @@ document.getElementById("btnFs").addEventListener("click", ()=>{ }); /* ========================================================= - Accounts & storage - - Guest: everything in memory only, gone on close. - - Logged in: PBKDF2-SHA256 hashed password (Web Crypto), - per-user data (custom words + saved board) in localStorage. - Note: this protects the password itself; anyone with physical - access to this device/browser profile can still clear or read - stored data. For real multi-device security a server is needed. + Accounts & sessies — via de server-API (src/api.js) + Rollen: super (overkoepelend), admin (school), teacher (groepsleiding), + pupil (leerling). Zonder inloggen wordt niets bewaard. ==========================================================*/ -const USERS_KEY = "teach.users"; -let currentUser = null; // {name, data} +let TOKEN = null; +try{ TOKEN = localStorage.getItem("teach.token"); }catch(e){} +let currentUser = null; /* {id, username, displayName, role, schoolId, classId} */ -function loadUsers(){ try{ return JSON.parse(localStorage.getItem(USERS_KEY)) || {}; }catch(e){ return {}; } } -function saveUsers(u){ try{ localStorage.setItem(USERS_KEY, JSON.stringify(u)); }catch(e){} } - -const bufToHex = b => [...new Uint8Array(b)].map(x=>x.toString(16).padStart(2,"0")).join(""); -const hexToBuf = h => new Uint8Array(h.match(/.{2}/g).map(x=>parseInt(x,16))); -async function hashPw(pw, saltHex, iterations){ - const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(pw), "PBKDF2", false, ["deriveBits"]); - const bits = await crypto.subtle.deriveBits( - {name:"PBKDF2", hash:"SHA-256", salt:hexToBuf(saltHex), iterations}, key, 256); - return bufToHex(bits); +async function api(path, opts={}){ + const headers = { "Content-Type": "application/json" }; + if(TOKEN) headers.Authorization = "Bearer " + TOKEN; + const r = await fetch("/api" + path, { + method: opts.method || (opts.body ? "POST" : "GET"), + headers, + body: opts.body ? JSON.stringify(opts.body) : undefined + }); + const j = await r.json().catch(()=>({})); + if(!r.ok) throw new Error(j.error || ("fout " + r.status)); + return j; } -const cryptoOK = !!(window.crypto && crypto.subtle && crypto.getRandomValues); +/* alles van de gebruiker (borden, thema's, eigen woorden) staat op de server */ +let saveTimer = null; function persistUser(){ - if(!currentUser) return; - const users = loadUsers(); - if(users[currentUser.name]){ users[currentUser.name].data = currentUser.data; saveUsers(users); } + if(!currentUser) return Promise.resolve(); + return api("/me/data", { method:"PUT", body:{ + themes: THEMES, generalExtra: GENERAL_EXTRA, boards: BS + }}).catch(()=>{}); } function persistWords(){ - if(currentUser){ - currentUser.data.themes = THEMES; - currentUser.data.generalExtra = GENERAL_EXTRA; - persistUser(); - } + if(!currentUser) return; + clearTimeout(saveTimer); + saveTimer = setTimeout(persistUser, 800); } function generalExtraFromData(data){ return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]}; } +async function hydrateFromServer(){ + const d = (await api("/me/data")).data || {}; + THEMES = themesFromData(d); + GENERAL_EXTRA = generalExtraFromData(d); + BS = boardsFromData(d); + document.dispatchEvent(new CustomEvent("wordschange")); + loadCurrentSlot(); + document.dispatchEvent(new CustomEvent("userchange")); +} +function resetToGuest(){ + currentUser = null; + TOKEN = null; + try{ localStorage.removeItem("teach.token"); }catch(e){} + THEMES = {nl:[],en:[]}; + GENERAL_EXTRA = {nl:[],en:[]}; + BS = freshBS(); + loadCurrentSlot(); + updateUserUI(); + document.dispatchEvent(new CustomEvent("wordschange")); + document.dispatchEvent(new CustomEvent("userchange")); +} /* login UI */ const loginWrap = document.getElementById("loginWrap"); const loginMsg = document.getElementById("loginMsg"); +const roleKey = r => r==="super" ? "roleSuper" : r==="admin" ? "roleAdmin" : r==="teacher" ? "roleTeacher" : "rolePupil"; function updateUserUI(){ - document.getElementById("userLbl").textContent = currentUser ? currentUser.name : T("login"); + document.getElementById("userLbl").textContent = currentUser ? currentUser.displayName : T("login"); document.getElementById("btnUser").classList.toggle("on", !!currentUser); document.getElementById("loginBox").style.display = currentUser ? "none" : "block"; document.getElementById("loggedBox").style.display = currentUser ? "block" : "none"; - if(currentUser) document.getElementById("helloLbl").textContent = `${T("hello")} ${currentUser.name}! 👋`; + const adminBtn = document.getElementById("btnAdmin"); + adminBtn.style.display = currentUser && currentUser.role !== "pupil" ? "" : "none"; + adminBtn.title = T("beheer"); + if(currentUser){ + document.getElementById("helloLbl").textContent = + `${T("hello")} ${currentUser.displayName}! 👋 · ${T(roleKey(currentUser.role))}`; + document.getElementById("ownPwRow").style.display = currentUser.role==="pupil" ? "none" : "flex"; + } +} +async function loadSchools(){ + const sel = document.getElementById("liSchool"); + try{ + const list = await api("/schools"); + sel.innerHTML = `` + + list.map(x=>``; } } document.getElementById("btnUser").addEventListener("click", ()=>{ loginMsg.textContent = ""; loginMsg.classList.remove("ok"); + loadSchools(); loginWrap.classList.add("open"); }); loginWrap.addEventListener("click", e=>{ if(e.target===loginWrap) loginWrap.classList.remove("open"); }); -async function doLogin(register){ - const name = document.getElementById("liName").value.trim(); - const pass = document.getElementById("liPass").value; - loginMsg.classList.remove("ok"); - if(!cryptoOK){ loginMsg.textContent = T("errCrypto"); return; } - if(!/^[a-zA-Z0-9_-]{2,20}$/.test(name)){ loginMsg.textContent = T("errName"); return; } - if(pass.length < 4){ loginMsg.textContent = T("errPass"); return; } - const users = loadUsers(); - try{ - if(register){ - if(users[name]){ loginMsg.textContent = T("errExists"); return; } - const salt = bufToHex(crypto.getRandomValues(new Uint8Array(16))); - const iterations = 150000; - const hash = await hashPw(pass, salt, iterations); - users[name] = { salt, iterations, hash, data:{ themes:{nl:[],en:[]}, board:null } }; - saveUsers(users); - }else{ - const u = users[name]; - if(!u){ loginMsg.textContent = T("errLogin"); return; } - const hash = await hashPw(pass, u.salt, u.iterations); - if(hash !== u.hash){ loginMsg.textContent = T("errLogin"); return; } - } - }catch(e){ loginMsg.textContent = T("errCrypto"); return; } - currentUser = { name, data: loadUsers()[name].data }; - THEMES = themesFromData(currentUser.data); - GENERAL_EXTRA = generalExtraFromData(currentUser.data); - document.getElementById("liPass").value = ""; - try{ sessionStorage.setItem("teach.session", name); }catch(e){} +async function afterLogin(res){ + TOKEN = res.token; + try{ localStorage.setItem("teach.token", TOKEN); }catch(e){} + currentUser = res.user; updateUserUI(); loginWrap.classList.remove("open"); - document.dispatchEvent(new CustomEvent("wordschange")); - BS = boardsFromData(currentUser.data); - loadCurrentSlot(); + await hydrateFromServer(); } -document.getElementById("btnLogin").addEventListener("click", ()=>doLogin(false)); -document.getElementById("btnRegister").addEventListener("click", ()=>doLogin(true)); -document.getElementById("liPass").addEventListener("keydown", e=>{ if(e.key==="Enter") doLogin(false); }); -document.getElementById("btnLogout").addEventListener("click", ()=>{ - persistUser(); - currentUser = null; - THEMES = {nl:[],en:[]}; - GENERAL_EXTRA = {nl:[],en:[]}; - try{ sessionStorage.removeItem("teach.session"); }catch(e){} - updateUserUI(); +document.getElementById("btnLogin").addEventListener("click", async ()=>{ + const schoolId = document.getElementById("liSchool").value || null; + const username = document.getElementById("liName").value.trim(); + const password = document.getElementById("liPass").value; + loginMsg.classList.remove("ok"); + try{ + await afterLogin(await api("/auth/login", { body:{ schoolId: schoolId ? +schoolId : null, username, password } })); + document.getElementById("liPass").value = ""; + }catch(e){ loginMsg.textContent = e.message || T("errLogin"); } +}); +document.getElementById("liPass").addEventListener("keydown", e=>{ + if(e.key==="Enter") document.getElementById("btnLogin").click(); +}); +/* account activeren met een koppelcode */ +document.getElementById("btnLink").addEventListener("click", async ()=>{ + const code = document.getElementById("liCode").value.trim().toUpperCase(); + const password = document.getElementById("liPassNew").value; + loginMsg.classList.remove("ok"); + try{ + await afterLogin(await api("/auth/link", { body:{ code, password } })); + document.getElementById("liCode").value = ""; + document.getElementById("liPassNew").value = ""; + }catch(e){ loginMsg.textContent = e.message; } +}); +document.getElementById("btnLogout").addEventListener("click", async ()=>{ + try{ await api("/auth/logout", { method:"POST", body:{} }); }catch(e){} + resetToGuest(); loginWrap.classList.remove("open"); - document.dispatchEvent(new CustomEvent("wordschange")); - BS = freshBS(); - loadCurrentSlot(); +}); +/* eigen wachtwoord wijzigen (niet voor leerlingen) */ +document.getElementById("btnOwnPw").addEventListener("click", async ()=>{ + const pw = document.getElementById("ownPw").value; + try{ + await api("/auth/password", { method:"PATCH", body:{ password: pw } }); + document.getElementById("ownPw").value = ""; + }catch(e){ alert(e.message); } }); /* save board */ -document.getElementById("btnSave").addEventListener("click", ()=>{ +document.getElementById("btnSave").addEventListener("click", async ()=>{ const btn = document.getElementById("btnSave"); const lbl = btn.querySelector("span"); if(!currentUser){ lbl.textContent = T("loginFirst"); setTimeout(()=>{ lbl.textContent = T("saveBoard"); }, 2200); + loadSchools(); loginWrap.classList.add("open"); return; } stashCurrent(); - currentUser.data.boards = BS; - delete currentUser.data.board; - persistUser(); + await persistUser(); lbl.textContent = T("boardSaved"); btn.classList.add("ok"); setTimeout(()=>{ lbl.textContent = T("saveBoard"); btn.classList.remove("ok"); }, 1800); diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..3077c2d --- /dev/null +++ b/src/api.js @@ -0,0 +1,271 @@ +// teach API: inloggen, rollen en gebruikersbeheer. +// +// Rollen en rechten: +// super - overkoepelend beheerder: alles, over alle scholen heen, +// kan andere supers 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. +import { + hashPassword, verifyPassword, createSession, userFromRequest, + newLinkCode, pupilPassword, publicUser, +} from './auth.js'; + +export default async function api(app) { + const pool = app.pg; + + // ---- helpers -------------------------------------------------------------- + const fail = (reply, code, msg) => { reply.code(code); return { error: msg }; }; + + app.decorateRequest('user', null); + app.addHook('preHandler', async (req) => { + req.user = await userFromRequest(pool, req); + }); + const need = (req, reply, roles) => { + if (!req.user) { reply.code(401); throw new Error('niet ingelogd'); } + if (roles && !roles.includes(req.user.role)) { reply.code(403); throw new Error('geen rechten'); } + }; + const sameSchool = (req, u) => + req.user.role === '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); + reply.code(500).send({ error: 'serverfout' }); + }); + + // ---- version -------------------------------------------------------------- + app.get('/version', async () => ({ + name: 'teach', + version: process.env.APP_VERSION ?? 'dev', + })); + + // ---- 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 })); + }); + + // ---- auth ------------------------------------------------------------------- + app.post('/auth/login', async (req, reply) => { + const { schoolId, username, password } = req.body ?? {}; + if (!username || !password) return fail(reply, 400, 'naam en wachtwoord verplicht'); + const r = schoolId + ? await pool.query('SELECT * FROM users WHERE school_id = $1 AND lower(username) = lower($2)', [schoolId, username]) + : await pool.query('SELECT * FROM users WHERE school_id IS NULL AND lower(username) = lower($1)', [username]); + const u = r.rows[0]; + if (!u || !(await verifyPassword(password, u.password_hash))) + return fail(reply, 401, 'onbekende naam of verkeerd wachtwoord'); + const token = await createSession(pool, u.id); + return { token, user: publicUser(u) }; + }); + + // Account activeren met een koppelcode (groepsleiding/beheerders) + app.post('/auth/link', async (req, reply) => { + const { code, password } = req.body ?? {}; + if (!code || !password || password.length < 6) + return fail(reply, 400, 'koppelcode en wachtwoord (min. 6 tekens) verplicht'); + 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 hash = await hashPassword(password); + await pool.query('UPDATE users SET password_hash = $1, link_code = NULL WHERE id = $2', [hash, u.id]); + const token = await createSession(pool, u.id); + return { token, user: publicUser({ ...u, password_hash: hash, link_code: null }) }; + }); + + app.post('/auth/logout', async (req) => { + const h = req.headers.authorization || ''; + if (h.startsWith('Bearer ')) await pool.query('DELETE FROM sessions WHERE token = $1', [h.slice(7)]); + 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', async (req, reply) => { + need(req, reply, ['super', 'admin', 'teacher']); + const { password } = req.body ?? {}; + if (!password || password.length < 6) return fail(reply, 400, 'minimaal 6 tekens'); + await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', + [await hashPassword(password), req.user.id]); + 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 FROM users WHERE id = $1', [req.user.id]); + return { data: r.rows[0]?.data ?? {} }; + }); + app.put('/me/data', async (req, reply) => { + need(req, reply); + await pool.query('UPDATE users SET data = $1 WHERE id = $2', [req.body ?? {}, req.user.id]); + return { ok: true }; + }); + + // ---- scholen (super) --------------------------------------------------------- + app.post('/admin/schools', async (req, reply) => { + need(req, reply, ['super']); + const { name } = req.body ?? {}; + if (!name) return fail(reply, 400, 'naam verplicht'); + 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.delete('/admin/schools/:id', async (req, reply) => { + need(req, reply, ['super']); + await pool.query('DELETE FROM schools WHERE id = $1', [req.params.id]); + return { ok: true }; + }); + + // ---- klassen ----------------------------------------------------------------- + app.get('/admin/classes', async (req, reply) => { + need(req, reply, ['super', 'admin', 'teacher']); + const schoolId = req.user.role === '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) })) }; + }); + app.post('/admin/classes', async (req, reply) => { + need(req, reply, ['super', 'admin']); + const { name, school } = req.body ?? {}; + const schoolId = req.user.role === 'super' ? school : req.user.school_id; + if (!name || !schoolId) return fail(reply, 400, 'naam en school verplicht'); + 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 } }; + }); + app.delete('/admin/classes/:id', async (req, reply) => { + need(req, reply, ['super', 'admin']); + 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.role !== '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 }; + }); + // groepsleiding aan klas koppelen/loskoppelen + app.post('/admin/classes/:id/teachers', async (req, reply) => { + need(req, reply, ['super', 'admin']); + const { userId, remove } = req.body ?? {}; + if (remove) await pool.query('DELETE FROM class_teachers WHERE class_id = $1 AND user_id = $2', [req.params.id, userId]); + else await pool.query('INSERT INTO class_teachers (class_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [req.params.id, userId]); + return { ok: true }; + }); + + // ---- gebruikers --------------------------------------------------------------- + // Lijst: super ziet alles (optioneel per school), admin/teacher de eigen school. + app.get('/admin/users', async (req, reply) => { + need(req, reply, ['super', 'admin', 'teacher']); + let rows; + if (req.user.role === '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; + } else { + rows = (await pool.query('SELECT * FROM users WHERE school_id = $1 ORDER BY role, lower(username)', [req.user.school_id])).rows; + } + const canSeePw = ['super', 'admin', 'teacher'].includes(req.user.role); + return { users: rows.map((u) => ({ + ...publicUser(u), + linkCode: u.link_code || null, + // leerlingwachtwoorden zijn inzichtelijk voor groepsleiding en beheer + password: canSeePw && u.role === 'pupil' ? u.password_plain : undefined, + })) }; + }); + + // 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, ['super', 'admin', 'teacher']); + const b = req.body ?? {}; + const role = b.role; + const allowed = { super: ['super', 'admin', 'teacher', 'pupil'], admin: ['admin', 'teacher', 'pupil'], teacher: ['pupil'] }; + if (!allowed[req.user.role].includes(role)) return fail(reply, 403, 'geen rechten voor deze rol'); + const schoolId = req.user.role === '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') { + const pw = b.password || pupilPassword(); + const r = await pool.query( + `INSERT INTO users (username, role, school_id, class_id, display_name, password_hash, password_plain) + VALUES ($1,'pupil',$2,$3,$4,$5,$6) RETURNING *`, + [username, schoolId, b.classId ?? null, b.displayName || username, await hashPassword(pw), 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, ['super', 'admin', 'teacher']); + 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 (req.user.role === 'teacher' && u.role !== 'pupil') return fail(reply, 403, 'groepsleiding beheert alleen leerlingen'); + if (u.role === 'super' && req.user.role !== 'super') return fail(reply, 403, 'geen rechten'); + const b = req.body ?? {}; + if (b.displayName) await pool.query('UPDATE users SET display_name = $1 WHERE id = $2', [b.displayName, u.id]); + if (b.classId !== undefined && u.role === 'pupil') + await pool.query('UPDATE users SET class_id = $1 WHERE id = $2', [b.classId, u.id]); + if (b.password && u.role === 'pupil') + await pool.query('UPDATE users SET password_plain = $1, password_hash = $2 WHERE id = $3', + [b.password, await hashPassword(b.password), u.id]); + if (b.newLinkCode && u.role !== 'pupil' && ['super', 'admin'].includes(req.user.role)) { + const code = newLinkCode(); + await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]); + return { ok: true, linkCode: code }; + } + if (b.role && req.user.role === 'super' && ['super', 'admin', 'teacher'].includes(b.role) && u.role !== 'pupil') + await pool.query('UPDATE users SET role = $1, school_id = $2 WHERE id = $3', + [b.role, b.role === 'super' ? null : u.school_id, u.id]); + return { ok: true }; + }); + + app.delete('/admin/users/:id', async (req, reply) => { + need(req, reply, ['super', 'admin', 'teacher']); + 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 (req.user.role === 'teacher' && u.role !== 'pupil') return fail(reply, 403, 'geen rechten'); + if (u.role === 'super' && req.user.role !== 'super') return fail(reply, 403, 'geen rechten'); + await pool.query('DELETE FROM users WHERE id = $1', [u.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 || newLinkCode(); + 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} / ${password} - wijzig dit wachtwoord direct!`); +} diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..af93090 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,82 @@ +// Auth-helpers: wachtwoord-hashing (scrypt, ingebouwd in Node), sessietokens +// en koppelcodes. Geen externe dependencies nodig. +import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto'; + +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 (!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'); +} + +// 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']; + const w = words[randomBytes(1)[0] % words.length]; + const n = 10 + (randomBytes(1)[0] % 90); + return `${w}${n}`; +} + +const SESSION_DAYS = 30; + +export async function createSession(pool, userId) { + const token = newToken(); + await pool.query( + `INSERT INTO sessions (token, user_id, expires_at) + VALUES ($1, $2, now() + interval '${SESSION_DAYS} days')`, + [token, userId], + ); + return token; +} + +export async function userFromRequest(pool, req) { + const h = req.headers.authorization || ''; + const token = h.startsWith('Bearer ') ? h.slice(7) : null; + 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()`, + [token], + ); + return r.rows[0] ?? null; +} + +// 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, + 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, + }; +} diff --git a/src/server.js b/src/server.js index 14afce0..233f13d 100644 --- a/src/server.js +++ b/src/server.js @@ -3,6 +3,7 @@ import { dirname, join } from 'node:path'; import Fastify from 'fastify'; import fastifyStatic from '@fastify/static'; import pg from 'pg'; +import api, { bootstrapSuper } from './api.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -39,17 +40,8 @@ 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', - })); - }, - { prefix: '/api' }, -); +// Inloggen, rollen, gebruikersbeheer en per-gebruiker data. Zie src/api.js. +app.register(api, { prefix: '/api' }); // --- Static frontend --------------------------------------------------------- // Serveert public/index.html (de digibord-app) op / @@ -61,6 +53,7 @@ app.register(fastifyStatic, { // --- Start ------------------------------------------------------------------- const start = async () => { try { + await bootstrapSuper(pool, app.log); await app.listen({ port: PORT, host: HOST }); } catch (err) { app.log.error(err); -- 2.45.2 From 08fd9e11e34d53b994f2319d736479dda3becdd7 Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:34:36 +0200 Subject: [PATCH 013/192] v0.2.01: automatische migraties bij opstarten (transactie per bestand, geen dataverlies) --- Dockerfile | 1 + public/js/core.js | 2 +- src/migrate.js | 48 +++++++++++++++++++++++++++++++++++++++++++++++ src/server.js | 2 ++ 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/migrate.js diff --git a/Dockerfile b/Dockerfile index 196fea0..311376e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,7 @@ 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 ARG APP_VERSION=dev diff --git a/public/js/core.js b/public/js/core.js index 4f6d6da..2f3ecfd 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -1,7 +1,7 @@ /* teach - kern: versie, i18n, accounts, opslag, geluid */ "use strict"; /* version — bump on every change (form 0.0.00) */ -const VERSION = "0.2.00"; +const VERSION = "0.2.01"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; 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/server.js b/src/server.js index 233f13d..e2314ef 100644 --- a/src/server.js +++ b/src/server.js @@ -4,6 +4,7 @@ import Fastify from 'fastify'; import fastifyStatic from '@fastify/static'; import pg from 'pg'; import api, { bootstrapSuper } from './api.js'; +import { runMigrations } from './migrate.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -53,6 +54,7 @@ app.register(fastifyStatic, { // --- 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) { -- 2.45.2 From 99fadaaedcdff8c6246137f6c64ae4db32625f1f Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:04:24 +0200 Subject: [PATCH 014/192] v0.2.01: schoolsysteem + automatische migraties; deploy: APP_VERSION uit image --- deploy/compose.deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/compose.deploy.yaml b/deploy/compose.deploy.yaml index c92f55a..75bb555 100644 --- a/deploy/compose.deploy.yaml +++ b/deploy/compose.deploy.yaml @@ -19,7 +19,7 @@ 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:-} ports: -- 2.45.2 From bd7c7e07e7df78e45234f06eb134901ab3cd4e89 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 12 Jul 2026 22:32:35 +0200 Subject: [PATCH 015/192] v0.2.02: uitgebreid wachtwoordbeheer voor gebruikers - Beheerders kunnen bij groepsleiding/schoolbeheer/super meteen zelf een wachtwoord instellen of genereren, als alternatief voor de koppelcode. - Nieuwe directe wachtwoord-reset voor deze rollen, los van koppelcode-reset. - Leerling-wachtwoord is na de eerste keer inloggen niet langer inzichtelijk voor groepsleiding/beheer; alleen opnieuw genereren kan dan nog. - Wachtwoordeisen aangescherpt en overal consistent gecontroleerd (leerling min. 4 tekens, groepsleiding/beheer min. 8 met letter en cijfer). - Bevestigingsveld en toon/verberg-knop bij accountactivatie en eigen wachtwoord wijzigen, om typefouten te voorkomen. Co-Authored-By: Claude Sonnet 5 --- db/003_password_state.sql | 5 ++++ public/css/teach.css | 2 ++ public/index.html | 10 ++++++- public/js/admin.js | 59 ++++++++++++++++++++++++++++++++++++--- public/js/core.js | 37 ++++++++++++++++++++++-- src/api.js | 55 ++++++++++++++++++++++++++++++------ src/auth.js | 24 ++++++++++++++++ 7 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 db/003_password_state.sql 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/public/css/teach.css b/public/css/teach.css index 575f636..1abe25f 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -407,6 +407,8 @@ #loginModal input:focus{border-color:var(--blue);} .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;} #loginMsg{min-height:20px; font-size:13.5px; font-weight:700; color:var(--red); margin:6px 0;} #loginMsg.ok{color:var(--green);} .guestnote{font-size:13px; color:var(--muted); margin-top:12px; line-height:1.45;} diff --git a/public/index.html b/public/index.html index 773456a..5d8233c 100644 --- a/public/index.html +++ b/public/index.html @@ -81,7 +81,11 @@
- +
+ + +
+
@@ -91,6 +95,10 @@
+ +
+
+
diff --git a/public/js/admin.js b/public/js/admin.js index 89c94b0..23f3d7a 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -56,17 +56,32 @@ catch(e){ msg(e.message); } }); row.appendChild(cs); - /* wachtwoord inzien en aanpassen */ + /* wachtwoord inzien en aanpassen - alleen zichtbaar zolang de leerling + nog niet is ingelogd; daarna kan alleen een nieuw wachtwoord gezet worden */ const pw = h("input","am-pw"); pw.value = u.password || ""; + pw.placeholder = u.everLoggedIn ? T("amPwHidden") : ""; pw.title = T("amPw"); pw.addEventListener("keydown", ev=>ev.stopPropagation()); pw.addEventListener("change", async ()=>{ - if(pw.value.length < 3) return; - try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{password: pw.value}}); } + if(pw.value.length < 4) return; + try{ await api("/admin/users/"+u.id, {method:"PATCH", body:{password: pw.value}}); reload(); } catch(e){ msg(e.message); } }); row.appendChild(pw); + if(u.everLoggedIn){ + const gen = h("button","am-btn","🎲"); + gen.title = T("amGenerate"); + gen.addEventListener("click", async ()=>{ + try{ + const { password } = await api("/admin/genpw?role=pupil"); + await api("/admin/users/"+u.id, {method:"PATCH", body:{password}}); + msg(`${T("amPwSet")} ${password}`); + reload(); + }catch(e){ msg(e.message); } + }); + row.appendChild(gen); + } }else{ if(u.pending && u.linkCode){ row.appendChild(h("span","am-code", T("amCode")+": "+u.linkCode)); @@ -83,6 +98,16 @@ }catch(e){ msg(e.message); } }); row.appendChild(nc); + /* direct een nieuw wachtwoord zetten, zonder de koppelcode-activatie */ + const np = h("button","am-btn", T("amNewPw")); + np.addEventListener("click", async ()=>{ + try{ + const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}}); + msg(`${T("amPwSet")} ${r.password}`); + reload(); + }catch(e){ msg(e.message); } + }); + row.appendChild(np); } } if(u.id !== currentUser.id){ @@ -106,17 +131,43 @@ f.appendChild(name); let cs = null; if(role==="pupil"){ cs = classSel(""); f.appendChild(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) */ + pwInp = h("input","am-inp am-pwinp"); + pwInp.type = "password"; + pwInp.autocomplete = "new-password"; + pwInp.placeholder = T("amPwOptional"); + pwInp.addEventListener("keydown", ev=>ev.stopPropagation()); + f.appendChild(pwInp); + const gen = h("button","am-btn","🎲"); + gen.type = "button"; + gen.title = T("amGenerate"); + gen.addEventListener("click", async ()=>{ + try{ + const { password } = await api("/admin/genpw"); + pwInp.type = "text"; + pwInp.value = password; + }catch(e){ msg(e.message); } + }); + f.appendChild(gen); + } const go = h("button","tbtn", T(role==="pupil" ? "amNewPupil" : role==="teacher" ? "amNewTeacher" : role==="admin" ? "amNewAdmin" : "amNewSuper")); go.addEventListener("click", async ()=>{ 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; try{ const r = await api("/admin/users", { body }); msg(role==="pupil" ? `${T("amPupilMade")} ${r.user.password}` - : `${T("amCreated")} ${r.user.linkCode}`); + : r.user.password + ? `${T("amPwSet")} ${r.user.password}` + : `${T("amCreated")} ${r.user.linkCode}`); name.value = ""; + if(pwInp) pwInp.value = ""; reload(); }catch(e){ msg(e.message); } }); diff --git a/public/js/core.js b/public/js/core.js index 2f3ecfd..106398f 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -114,7 +114,9 @@ const I18N = { errLogin:"Onbekende naam of verkeerd wachtwoord.", schoolSel:"— kies je school (leeg = overkoepelend) —", linkIntro:"Nieuw account? Activeer het met je koppelcode:", - linkBtn:"Account activeren", pwChange:"Wijzig", pwNewPh:"nieuw wachtwoord (min. 6)", + linkBtn:"Account activeren", pwChange:"Wijzig", pwNewPh:"nieuw wachtwoord (min. 8, met cijfer)", + pwConfirmPh:"herhaal wachtwoord", pwShow:"👁 Toon", pwHide:"🙈 Verberg", + errPwMatch:"Wachtwoorden komen niet overeen.", codePh:"koppelcode", beheer:"Beheer", roleSuper:"Overkoepelend beheerder", roleAdmin:"Schoolbeheerder", roleTeacher:"Groepsleiding", rolePupil:"Leerling", @@ -127,6 +129,9 @@ const I18N = { amCouple:"koppel leerkracht", amAllSchools:"— alle scholen —", amSuperGroup:"Overkoepelend", amCreated:"Aangemaakt! Geef deze koppelcode door:", amPupilMade:"Leerling aangemaakt. Wachtwoord:", + amPwHidden:"al gebruikt", amGenerate:"genereer wachtwoord", + amNewPw:"nieuw wachtwoord", amPwSet:"Wachtwoord ingesteld:", + amPwOptional:"wachtwoord (leeg = koppelcode)", amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?", errExists:"Deze naam bestaat al.", errName:"Naam: 2–20 letters of cijfers.", @@ -227,7 +232,9 @@ const I18N = { errLogin:"Unknown name or wrong password.", schoolSel:"— choose your school (empty = overarching) —", linkIntro:"New account? Activate it with your link code:", - linkBtn:"Activate account", pwChange:"Change", pwNewPh:"new password (min. 6)", + linkBtn:"Activate account", pwChange:"Change", pwNewPh:"new password (min. 8, incl. number)", + pwConfirmPh:"repeat password", pwShow:"👁 Show", pwHide:"🙈 Hide", + errPwMatch:"Passwords don't match.", codePh:"link code", beheer:"Management", roleSuper:"Overarching administrator", roleAdmin:"School administrator", roleTeacher:"Group leader", rolePupil:"Pupil", @@ -240,6 +247,9 @@ const I18N = { amCouple:"link teacher", amAllSchools:"— all schools —", amSuperGroup:"Overarching", amCreated:"Created! Pass on this link code:", amPupilMade:"Pupil created. Password:", + amPwHidden:"already used", amGenerate:"generate password", + amNewPw:"new password", amPwSet:"Password set:", + amPwOptional:"password (empty = link code)", amConfirmDel:"Are you sure you want to delete this user?", errExists:"This name already exists.", errName:"Name: 2–20 letters or digits.", @@ -261,7 +271,11 @@ function applyI18n(){ document.getElementById("liPass").placeholder = T("passPh"); document.getElementById("liCode").placeholder = T("codePh"); document.getElementById("liPassNew").placeholder = T("pwNewPh"); + document.getElementById("liPassNew2").placeholder = T("pwConfirmPh"); document.getElementById("ownPw").placeholder = T("pwNewPh"); + document.getElementById("ownPw2").placeholder = T("pwConfirmPh"); + document.getElementById("btnLiPassToggle").textContent = T("pwShow"); + document.getElementById("btnOwnPwToggle").textContent = T("pwShow"); document.getElementById("btnMute").title = T("muteAll"); updateUserUI(); document.dispatchEvent(new CustomEvent("langchange")); @@ -387,15 +401,31 @@ document.getElementById("btnLogin").addEventListener("click", async ()=>{ document.getElementById("liPass").addEventListener("keydown", e=>{ if(e.key==="Enter") document.getElementById("btnLogin").click(); }); +/* wachtwoord tonen/verbergen bij activeren en eigen wachtwoord wijzigen */ +function pwToggle(btnId, ...fieldIds){ + const btn = document.getElementById(btnId); + let shown = false; + btn.addEventListener("click", ()=>{ + shown = !shown; + fieldIds.forEach(id=>{ document.getElementById(id).type = shown ? "text" : "password"; }); + btn.textContent = T(shown ? "pwHide" : "pwShow"); + }); +} +pwToggle("btnLiPassToggle", "liPassNew", "liPassNew2"); +pwToggle("btnOwnPwToggle", "ownPw", "ownPw2"); + /* account activeren met een koppelcode */ document.getElementById("btnLink").addEventListener("click", async ()=>{ const code = document.getElementById("liCode").value.trim().toUpperCase(); const password = document.getElementById("liPassNew").value; + const password2 = document.getElementById("liPassNew2").value; loginMsg.classList.remove("ok"); + if(password !== password2){ loginMsg.textContent = T("errPwMatch"); return; } try{ await afterLogin(await api("/auth/link", { body:{ code, password } })); document.getElementById("liCode").value = ""; document.getElementById("liPassNew").value = ""; + document.getElementById("liPassNew2").value = ""; }catch(e){ loginMsg.textContent = e.message; } }); document.getElementById("btnLogout").addEventListener("click", async ()=>{ @@ -406,9 +436,12 @@ document.getElementById("btnLogout").addEventListener("click", async ()=>{ /* eigen wachtwoord wijzigen (niet voor leerlingen) */ document.getElementById("btnOwnPw").addEventListener("click", async ()=>{ const pw = document.getElementById("ownPw").value; + const pw2 = document.getElementById("ownPw2").value; + if(pw !== pw2){ alert(T("errPwMatch")); return; } try{ await api("/auth/password", { method:"PATCH", body:{ password: pw } }); document.getElementById("ownPw").value = ""; + document.getElementById("ownPw2").value = ""; }catch(e){ alert(e.message); } }); diff --git a/src/api.js b/src/api.js index 3077c2d..4c693d0 100644 --- a/src/api.js +++ b/src/api.js @@ -11,7 +11,7 @@ // NIET inzien of wijzigen. import { hashPassword, verifyPassword, createSession, userFromRequest, - newLinkCode, pupilPassword, publicUser, + newLinkCode, pupilPassword, staffPassword, checkPassword, publicUser, } from './auth.js'; export default async function api(app) { @@ -59,6 +59,8 @@ export default async function api(app) { const u = r.rows[0]; if (!u || !(await verifyPassword(password, u.password_hash))) 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(), password_plain = NULL WHERE id = $1', [u.id]); const token = await createSession(pool, u.id); return { token, user: publicUser(u) }; }); @@ -66,13 +68,14 @@ export default async function api(app) { // Account activeren met een koppelcode (groepsleiding/beheerders) app.post('/auth/link', async (req, reply) => { const { code, password } = req.body ?? {}; - if (!code || !password || password.length < 6) - return fail(reply, 400, 'koppelcode en wachtwoord (min. 6 tekens) verplicht'); + if (!code) return fail(reply, 400, 'koppelcode verplicht'); 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 WHERE id = $2', [hash, u.id]); + 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); return { token, user: publicUser({ ...u, password_hash: hash, link_code: null }) }; }); @@ -92,7 +95,8 @@ export default async function api(app) { app.patch('/auth/password', async (req, reply) => { need(req, reply, ['super', 'admin', 'teacher']); const { password } = req.body ?? {}; - if (!password || password.length < 6) return fail(reply, 400, 'minimaal 6 tekens'); + 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]); return { ok: true }; @@ -162,6 +166,13 @@ export default async function api(app) { return { ok: true }; }); + // Genereer een voorstel-wachtwoord (niet opgeslagen) voor in het aanmaakformulier. + app.get('/admin/genpw', async (req, reply) => { + need(req, reply, ['super', 'admin', 'teacher']); + const role = req.query.role === 'pupil' ? 'pupil' : 'staff'; + return { password: role === 'pupil' ? pupilPassword() : staffPassword() }; + }); + // ---- gebruikers --------------------------------------------------------------- // Lijst: super ziet alles (optioneel per school), admin/teacher de eigen school. app.get('/admin/users', async (req, reply) => { @@ -178,8 +189,10 @@ export default async function api(app) { return { users: rows.map((u) => ({ ...publicUser(u), linkCode: u.link_code || null, - // leerlingwachtwoorden zijn inzichtelijk voor groepsleiding en beheer + // leerlingwachtwoord is alleen inzichtelijk zolang het account nog niet + // gebruikt is (na de eerste keer inloggen wordt password_plain gewist) password: canSeePw && u.role === 'pupil' ? u.password_plain : undefined, + everLoggedIn: !!u.last_login_at, })) }; }); @@ -198,12 +211,26 @@ export default async function api(app) { try { if (role === 'pupil') { 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, password_plain) VALUES ($1,'pupil',$2,$3,$4,$5,$6) RETURNING *`, [username, schoolId, b.classId ?? null, b.displayName || username, await hashPassword(pw), 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) @@ -229,9 +256,21 @@ export default async function api(app) { if (b.displayName) await pool.query('UPDATE users SET display_name = $1 WHERE id = $2', [b.displayName, u.id]); if (b.classId !== undefined && u.role === 'pupil') await pool.query('UPDATE users SET class_id = $1 WHERE id = $2', [b.classId, u.id]); - if (b.password && u.role === 'pupil') - await pool.query('UPDATE users SET password_plain = $1, password_hash = $2 WHERE id = $3', + 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_plain = $1, password_hash = $2, last_login_at = NULL WHERE id = $3', [b.password, await hashPassword(b.password), 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' && ['super', 'admin'].includes(req.user.role)) { + 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]); + return { ok: true, password: pw }; + } if (b.newLinkCode && u.role !== 'pupil' && ['super', 'admin'].includes(req.user.role)) { const code = newLinkCode(); await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]); diff --git a/src/auth.js b/src/auth.js index af93090..07c05ae 100644 --- a/src/auth.js +++ b/src/auth.js @@ -43,6 +43,30 @@ export function pupilPassword() { return `${w}${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, 6)}`; +} + +// Wachtwoordeisen: leerlingen kort en eenvoudig (worden voorgelezen door +// groepsleiding), groepsleiding/beheer strenger (eigen, onthouden wachtwoord). +export function checkPassword(password, role) { + if (!password) return 'wachtwoord verplicht'; + 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; +} + const SESSION_DAYS = 30; export async function createSession(pool, userId) { -- 2.45.2 From 64acb84f501f75aabe64c47191fdbc51069940d0 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 12 Jul 2026 22:53:08 +0200 Subject: [PATCH 016/192] v0.2.03-beta: kopieerknop voor wachtwoorden + multireset klas/school MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kopieerknop (📋) bij elk getoond wachtwoord/koppelcode, zodat het niet meer verdwijnt voordat het overgetypt kan worden - Bugfix: getoond wachtwoord werd bijna direct overschreven doordat de lijst opnieuw werd opgebouwd vlak nadat het bericht getoond werd (race condition tussen msg() en reload()) - Nieuw: alle leerlingwachtwoorden van een klas of hele school in één keer resetten, alleen voor schoolbeheerder en overkoepelend beheerder - Resultatenpaneel na een multireset met "kopieer alles"-knop (blijft zichtbaar tot de beheerder het zelf sluit) Co-Authored-By: Claude Sonnet 5 --- public/css/teach.css | 22 +++++++- public/js/admin.js | 131 ++++++++++++++++++++++++++++++++++++++----- public/js/core.js | 10 ++++ src/api.js | 41 ++++++++++++++ 4 files changed, 189 insertions(+), 15 deletions(-) diff --git a/public/css/teach.css b/public/css/teach.css index 1abe25f..bbdf9e1 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -964,7 +964,10 @@ .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;} -.am-msg{min-height:20px; font-size:13.5px; font-weight:800; color:var(--green); margin:4px 0;} +.am-msg{ + min-height:20px; font-size:13.5px; font-weight:800; color:var(--green); margin:4px 0; + display:flex; align-items:center; gap:8px; flex-wrap:wrap; +} .am-add{display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:8px 0;} .am-inp,.am-sel{ border:2px solid #cdd9e8; border-radius:10px; padding:8px 10px; font:inherit; @@ -1001,4 +1004,21 @@ font-weight:800; font-size:13px; } .am-chipx{border:none; background:transparent; color:var(--red); font-weight:900; cursor:pointer; padding:0; font-size:11px;} +.am-copy{ + border:none; background:#e8eef6; border-radius:8px; padding:5px 8px; + font:inherit; font-size:13px; cursor:pointer; color:var(--ink); line-height:1; +} +.am-copy:hover{background:#dbe5f1;} +.am-reset{ + background:#f7fafd; border:2px solid #cdd9e8; 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:#fff; + 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:#fff;} +body.hc .am-reset,body.hc .am-reset-row{border:1.5px solid #000; background:#fff;} diff --git a/public/js/admin.js b/public/js/admin.js index 23f3d7a..a253058 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -6,6 +6,10 @@ const modal = document.getElementById("adminModal"); const btn = document.getElementById("btnAdmin"); let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null; + /* 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; const esc = s => String(s ?? "").replace(/&/g,"&").replace(/{ @@ -14,7 +18,36 @@ if(text != null) el.textContent = text; return el; }; - const msg = t => { const m = modal.querySelector(".am-msg"); if(m) m.textContent = t; }; + /* 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-copy", "📋"); + b.type = "button"; + b.title = T("amCopy"); + 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)); + }; btn.addEventListener("click", async ()=>{ if(!currentUser || currentUser.role==="pupil") return; @@ -23,7 +56,7 @@ wrap.classList.add("open"); }); wrap.addEventListener("click", e=>{ if(e.target===wrap) wrap.classList.remove("open"); }); - document.addEventListener("userchange", ()=>{ if(!currentUser) wrap.classList.remove("open"); }); + document.addEventListener("userchange", ()=>{ if(!currentUser){ wrap.classList.remove("open"); resetResults = null; } }); async function reload(){ try{ @@ -69,6 +102,11 @@ catch(e){ msg(e.message); } }); row.appendChild(pw); + const pwCopy = h("button","am-copy","📋"); + pwCopy.type = "button"; + pwCopy.title = T("amCopy"); + pwCopy.addEventListener("click", e=>{ e.preventDefault(); if(pw.value) copyText(pwCopy, pw.value); }); + row.appendChild(pwCopy); if(u.everLoggedIn){ const gen = h("button","am-btn","🎲"); gen.title = T("amGenerate"); @@ -76,8 +114,8 @@ try{ const { password } = await api("/admin/genpw?role=pupil"); await api("/admin/users/"+u.id, {method:"PATCH", body:{password}}); - msg(`${T("amPwSet")} ${password}`); - reload(); + await reload(); + msg(`${T("amPwSet")} ${password}`, password); }catch(e){ msg(e.message); } }); row.appendChild(gen); @@ -93,8 +131,8 @@ nc.addEventListener("click", async ()=>{ try{ const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{newLinkCode:true}}); - msg(`${T("amCreated")} ${r.linkCode}`); - reload(); + await reload(); + msg(`${T("amCreated")} ${r.linkCode}`, r.linkCode); }catch(e){ msg(e.message); } }); row.appendChild(nc); @@ -103,8 +141,8 @@ np.addEventListener("click", async ()=>{ try{ const r = await api("/admin/users/"+u.id, {method:"PATCH", body:{generatePassword:true}}); - msg(`${T("amPwSet")} ${r.password}`); - reload(); + await reload(); + msg(`${T("amPwSet")} ${r.password}`, r.password); }catch(e){ msg(e.message); } }); row.appendChild(np); @@ -161,20 +199,54 @@ if(currentUser.role==="super" && role!=="super") body.school = +selSchool; try{ const r = await api("/admin/users", { body }); + name.value = ""; + if(pwInp) pwInp.value = ""; + await reload(); msg(role==="pupil" ? `${T("amPupilMade")} ${r.user.password}` : r.user.password ? `${T("amPwSet")} ${r.user.password}` - : `${T("amCreated")} ${r.user.linkCode}`); - name.value = ""; - if(pwInp) pwInp.value = ""; - reload(); + : `${T("amCreated")} ${r.user.linkCode}`, + r.user.password || r.user.linkCode); }catch(e){ msg(e.message); } }); f.appendChild(go); return f; } + /* 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); + 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; + } + function render(){ modal.innerHTML = ""; const head = h("div","am-head"); @@ -211,15 +283,30 @@ } modal.appendChild(h("div","am-msg")); + const rp = resetPanel(); + if(rp) modal.appendChild(rp); /* klassen (admin/super binnen een school) */ const schoolChosen = currentUser.role!=="super" || !!selSchool; + const canResetPw = ["super","admin"].includes(currentUser.role); if(schoolChosen){ modal.appendChild(h("h3","am-h3", T("amClasses"))); const cwrapEl = h("div","am-classes"); CLASSES.forEach(c=>{ const chip = h("span","am-chip", c.name); - if(["super","admin"].includes(currentUser.role)){ + if(canResetPw){ + const rs = h("button","am-chipx","🔄"); + rs.title = T("amResetClass"); + rs.addEventListener("click", async ()=>{ + if(!confirm(`${T("amConfirmReset")} ${c.name}?`)) return; + try{ + const r = await api("/admin/classes/"+c.id+"/reset-passwords", {method:"POST"}); + await reload(); + resetResults = { title: `${T("amResetClass")}: ${c.name}`, entries: r.users }; + render(); + }catch(e){ msg(e.message); } + }); + chip.appendChild(rs); const x = h("button","am-chipx","✕"); x.addEventListener("click", async ()=>{ try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); } @@ -230,7 +317,7 @@ cwrapEl.appendChild(chip); }); modal.appendChild(cwrapEl); - if(["super","admin"].includes(currentUser.role)){ + if(canResetPw){ const f = h("div","am-add"); const inp = h("input","am-inp"); inp.placeholder = T("amNamePh"); @@ -246,6 +333,22 @@ }); f.appendChild(go); modal.appendChild(f); + + /* multireset: alle leerlingwachtwoorden van de hele school in één keer */ + const rsAll = h("button","am-btn","🔄 "+T("amResetSchool")); + rsAll.type = "button"; + rsAll.addEventListener("click", async ()=>{ + const schoolId = currentUser.role==="super" ? selSchool : currentUser.schoolId; + if(!schoolId) return; + if(!confirm(T("amConfirmResetSchool"))) return; + try{ + const r = await api("/admin/schools/"+schoolId+"/reset-passwords", {method:"POST"}); + await reload(); + resetResults = { title: T("amResetSchool"), entries: r.users }; + render(); + }catch(e){ msg(e.message); } + }); + modal.appendChild(rsAll); } } diff --git a/public/js/core.js b/public/js/core.js index 106398f..1d16fc4 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -133,6 +133,11 @@ const I18N = { amNewPw:"nieuw wachtwoord", amPwSet:"Wachtwoord ingesteld:", amPwOptional:"wachtwoord (leeg = koppelcode)", amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?", + amCopy:"kopieer", amCopyAll:"kopieer alles", + amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden", + amConfirmReset:"Alle leerlingwachtwoorden van deze klas vernieuwen?", + amConfirmResetSchool:"Alle leerlingwachtwoorden van deze hele school vernieuwen? Dit kan niet ongedaan worden gemaakt.", + amResetNone:"Geen leerlingen om te resetten.", errExists:"Deze naam bestaat al.", errName:"Naam: 2–20 letters of cijfers.", errPass:"Wachtwoord: minimaal 4 tekens.", @@ -251,6 +256,11 @@ const I18N = { amNewPw:"new password", amPwSet:"Password set:", amPwOptional:"password (empty = link code)", amConfirmDel:"Are you sure you want to delete this user?", + amCopy:"copy", amCopyAll:"copy all", + amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords", + amConfirmReset:"Renew all pupil passwords for this class?", + amConfirmResetSchool:"Renew all pupil passwords for this whole school? This cannot be undone.", + amResetNone:"No pupils to reset.", errExists:"This name already exists.", errName:"Name: 2–20 letters or digits.", errPass:"Password: at least 4 characters.", diff --git a/src/api.js b/src/api.js index 4c693d0..bb35391 100644 --- a/src/api.js +++ b/src/api.js @@ -128,6 +128,25 @@ export default async function api(app) { return { ok: true }; }); + // Multireset: alle leerlingwachtwoorden van een hele school in één keer + // vernieuwen. Alleen schoolbeheerder (eigen school) en overkoepelend beheerder. + app.post('/admin/schools/:id/reset-passwords', async (req, reply) => { + need(req, reply, ['super', 'admin']); + const schoolId = req.params.id; + if (req.user.role !== '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_plain = $1, password_hash = $2, last_login_at = NULL WHERE id = $3', + [pw, await hashPassword(pw), 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, ['super', 'admin', 'teacher']); @@ -166,6 +185,28 @@ export default async function api(app) { return { ok: true }; }); + // Multireset: alle leerlingwachtwoorden van één klas in één keer vernieuwen. + // Alleen schoolbeheerder (eigen school) en overkoepelend beheerder - 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, ['super', 'admin']); + 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.role !== '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_plain = $1, password_hash = $2, last_login_at = NULL WHERE id = $3', + [pw, await hashPassword(pw), 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, ['super', 'admin', 'teacher']); -- 2.45.2 From 05d4fc7c8e42dc960bf784bd69f06aa6139615b7 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 13 Jul 2026 07:53:52 +0200 Subject: [PATCH 017/192] v0.2.04-beta: mobiel- en tablet-vriendelijk maken van bord, widgets en beheer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Responsive breakpoints toegevoegd (tablet ≤1024px, telefoon ≤600px) en grotere tikzones op aanraakschermen (pointer:coarse) - er waren nog geen @media-regels in de hele stylesheet - Bugfix: #boardsPanel had een vaste 300px-breedte en overflowde op een telefoon; nu net zo responsive als #boardStrip - Widgets (letters, rekenen, schrijfblok, mindmap, ...) krijgen niet langer harde desktop-pixelmaten die van het scherm afvallen - grootte en positie worden nu geclampt aan de zichtbare bordgrootte - Eigen, groter aanraakbare resize-greep voor widgets i.p.v. de piepkleine native browser-grip (resize:both) - Beheerpaneel: dichte rijen (wachtwoordveld + knoppen) stapelen nu netjes op smalle schermen i.p.v. verdrukt te wrappen Co-Authored-By: Claude Sonnet 5 --- public/css/teach.css | 62 +++++++++++++++++++++++++++++++++++++++++++- public/js/admin.js | 15 +++++++---- public/js/board.js | 40 +++++++++++++++++++++++++--- 3 files changed, 107 insertions(+), 10 deletions(-) diff --git a/public/css/teach.css b/public/css/teach.css index bbdf9e1..3d3d1c5 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -5,6 +5,8 @@ --yellow:#f7c948; --purple:#9b59b6; --orange:#f28c38; --wood:#f6e7c9; --wood-d:#e8d2a4; --radius:16px; --shadow:0 10px 30px rgba(30,50,90,.18); + /* minimale tikzones voor touch (WCAG/iOS-richtlijn) */ + --tap:44px; --tap-sm:36px; } *{box-sizing:border-box; -webkit-tap-highlight-color:transparent;} *::-webkit-scrollbar{width:8px; height:8px;} @@ -310,7 +312,7 @@ 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:both; overflow:hidden; + min-width:280px; min-height:200px; resize:none; overflow:hidden; transition:background .2s, box-shadow .2s, border-color .2s; } .widget:hover,.widget:focus-within{ @@ -321,6 +323,17 @@ .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; @@ -758,6 +771,7 @@ body.hc .widget{background:#fff; 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:#fff;} 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:#fff;} body.hc .slot .ghost{color:#666;} @@ -987,6 +1001,7 @@ font-size:13px; width:110px; outline:none; } .am-code{font-size:12.5px; font-weight:800; color:var(--orange);} +.am-actions{display:flex; align-items:center; gap:8px; flex-wrap:wrap;} .am-btn{ border:none; background:#e8eef6; border-radius:8px; padding:6px 10px; font:inherit; font-weight:800; font-size:12px; cursor:pointer; color:var(--ink); @@ -1022,3 +1037,48 @@ .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:#fff;} body.hc .am-reset,body.hc .am-reset-row{border:1.5px solid #000; background:#fff;} + +/* ============================================================ + Mobiel & tablet: responsieve aanpassingen. Alles hieronder is + additief - de bovenstaande desktopstijl blijft de standaard. + ============================================================ */ + +/* ---------- tablet (≤1024px) ---------- */ +@media (max-width:1024px){ + /* #boardsPanel had een vaste 300px-breedte die op smallere + schermen overflowt; #boardStrip deed dit al goed. */ + #boardsPanel{width:min(300px, calc(100vw - 32px));} +} + +/* ---------- telefoon (≤600px) ---------- */ +@media (max-width:600px){ + #toolbar{padding:0 8px; gap:6px;} + .logo{font-size:22px;} + .logo .crayon{font-size:18px; margin-left:4px;} + #verTag{display:none;} + .tbtn{padding:8px 10px; font-size:13px;} + #boardsPanel{top:60px; max-height:min(66vh,480px);} + + /* 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){ + .tbtn{min-height:var(--tap-sm);} + #langToggle button{min-height:var(--tap-sm);} + .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-btn,.am-copy{min-height:var(--tap-sm); padding:8px 12px;} + .am-del{width:var(--tap-sm); height:var(--tap-sm);} + .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;} +} diff --git a/public/js/admin.js b/public/js/admin.js index a253058..5c56965 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -79,6 +79,10 @@ function userRow(u){ const row = h("div","am-row"); + /* actieknoppen (kopieer/genereer/verwijder) samen in één groep, zodat + ze op smalle schermen als blok rechts blijven staan i.p.v. los + tussen de andere rij-elementen te wrappen */ + const actions = h("div","am-actions"); row.appendChild(h("span","am-name", u.displayName + (u.displayName!==u.username ? ` (${u.username})` : ""))); row.appendChild(h("span","am-role", T(roleKey(u.role)))); if(u.role==="pupil"){ @@ -106,7 +110,7 @@ pwCopy.type = "button"; pwCopy.title = T("amCopy"); pwCopy.addEventListener("click", e=>{ e.preventDefault(); if(pw.value) copyText(pwCopy, pw.value); }); - row.appendChild(pwCopy); + actions.appendChild(pwCopy); if(u.everLoggedIn){ const gen = h("button","am-btn","🎲"); gen.title = T("amGenerate"); @@ -118,7 +122,7 @@ msg(`${T("amPwSet")} ${password}`, password); }catch(e){ msg(e.message); } }); - row.appendChild(gen); + actions.appendChild(gen); } }else{ if(u.pending && u.linkCode){ @@ -135,7 +139,7 @@ msg(`${T("amCreated")} ${r.linkCode}`, r.linkCode); }catch(e){ msg(e.message); } }); - row.appendChild(nc); + actions.appendChild(nc); /* direct een nieuw wachtwoord zetten, zonder de koppelcode-activatie */ const np = h("button","am-btn", T("amNewPw")); np.addEventListener("click", async ()=>{ @@ -145,7 +149,7 @@ msg(`${T("amPwSet")} ${r.password}`, r.password); }catch(e){ msg(e.message); } }); - row.appendChild(np); + actions.appendChild(np); } } if(u.id !== currentUser.id){ @@ -156,8 +160,9 @@ try{ await api("/admin/users/"+u.id, {method:"DELETE"}); reload(); } catch(e){ msg(e.message); } }); - row.appendChild(del); + actions.appendChild(del); } + if(actions.childNodes.length) row.appendChild(actions); return row; } diff --git a/public/js/board.js b/public/js/board.js index f657bc4..c3faa7c 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -28,15 +28,26 @@ const REGISTRY = [ ]; const WIDGET_CATS = [["taal","catTaal"],["rekenen","catRekenen"],["tools","catTools"],["media","catMedia"]]; +/* houdt een (opgeslagen of standaard) widgetgrootte binnen het bord - zonder + dit passen desktop-formaten (tot 760px breed) niet op een telefoon/tablet */ +function clampWidgetSize(w, h, def){ + const fw = Math.max(def.minW || 240, Math.min(w, board.clientWidth)); + const fh = Math.max(def.minH || 180, Math.min(h, board.clientHeight)); + return { w: fw, h: fh }; +} + function makeWidget(def, saved){ const win = document.createElement("div"); win.className = "widget"; - win.style.width = (saved && saved.w ? saved.w : def.w)+"px"; - win.style.height = (saved && saved.h ? saved.h : def.h)+"px"; + const size = clampWidgetSize(saved && saved.w ? saved.w : def.w, saved && saved.h ? saved.h : def.h, def); + win.style.width = size.w+"px"; + win.style.height = size.h+"px"; if(def.minW) win.style.minWidth = def.minW+"px"; if(def.minH) win.style.minHeight = def.minH+"px"; - win.style.left = (saved && saved.x!=null ? saved.x : 40 + (widgetCount*36)%240) + "px"; - win.style.top = (saved && saved.y!=null ? saved.y : 30 + (widgetCount*30)%160) + "px"; + const cx = saved && saved.x!=null ? saved.x : 40 + (widgetCount*36)%240; + const cy = saved && saved.y!=null ? saved.y : 30 + (widgetCount*30)%160; + win.style.left = Math.max(0, Math.min(cx, board.clientWidth - size.w - 8)) + "px"; + win.style.top = Math.max(0, Math.min(cy, board.clientHeight - size.h - 8)) + "px"; win.style.zIndex = ++zTop; widgetCount++; if(saved && saved.bare){ win.classList.add("bare"); win.dataset.bare = "1"; } @@ -61,6 +72,27 @@ function makeWidget(def, saved){ body.className = "widget-body"; win.appendChild(head); win.appendChild(body); + /* eigen resize-greep (touch-vriendelijk, vervangt de native browser-grip) */ + const rs = document.createElement("div"); + rs.className = "widget-rs"; + rs.addEventListener("pointerdown", e=>{ + e.preventDefault(); e.stopPropagation(); + win.style.zIndex = ++zTop; + const zf = VZ(); + const sw = win.offsetWidth, sh = win.offsetHeight, sx = e.clientX, sy = e.clientY; + const mv = ev=>{ + const maxW = board.clientWidth - win.offsetLeft - 8; + const maxH = board.clientHeight - win.offsetTop - 8; + let nw = sw + (ev.clientX - sx)/zf, nh = sh + (ev.clientY - sy)/zf; + nw = Math.max(def.minW || 240, Math.min(nw, maxW)); + nh = Math.max(def.minH || 180, Math.min(nh, maxH)); + win.style.width = nw+"px"; win.style.height = nh+"px"; + }; + const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; + document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); + }); + win.appendChild(rs); + let pill = null; function setMin(m){ if(m && !pill){ -- 2.45.2 From 5cd1c8007301f837c61566cf0cec41c6b98549a9 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 13 Jul 2026 08:04:50 +0200 Subject: [PATCH 018/192] v0.2.05-beta: bronwaarde widgets als string bewaren + projectafspraken toegevoegd - ball.js/flits.js/hak.js: S.card bewaart de geselecteerde bronwaarde nu altijd als string i.p.v. te casten naar number, zodat de bronselectie correct blijft werken - AGENTS.md en CLAUDE.md toegevoegd met projectafspraken Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 11 +++++++++++ CLAUDE.md | 11 +++++++++++ public/js/widgets/ball.js | 2 +- public/js/widgets/flits.js | 2 +- public/js/widgets/hak.js | 2 +- 5 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md 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/public/js/widgets/ball.js b/public/js/widgets/ball.js index 8c4cf0e..96395c8 100644 --- a/public/js/widgets/ball.js +++ b/public/js/widgets/ball.js @@ -86,7 +86,7 @@ function mountBall(root, initState){ } nextBtn.addEventListener("click", ()=>{ S.idx++; newWord(); }); srcSel.addEventListener("change", ()=>{ - S.card = srcSel.value.startsWith("t") ? srcSel.value : +srcSel.value; + S.card = srcSel.value; S.order = []; newWord(); }); sndBtn.addEventListener("click", ()=>{ diff --git a/public/js/widgets/flits.js b/public/js/widgets/flits.js index 9eb66a7..11c7654 100644 --- a/public/js/widgets/flits.js +++ b/public/js/widgets/flits.js @@ -71,7 +71,7 @@ function mountFlits(root, initState){ flash(); }); srcSel.addEventListener("change", ()=>{ - S.card = srcSel.value.startsWith("t") ? srcSel.value : +srcSel.value; + S.card = srcSel.value; newRound(); hide(); cntEl.textContent = ""; }); diff --git a/public/js/widgets/hak.js b/public/js/widgets/hak.js index 6b8e88a..c211f1f 100644 --- a/public/js/widgets/hak.js +++ b/public/js/widgets/hak.js @@ -77,7 +77,7 @@ function mountHak(root, initState){ }); const play = f=>f(); srcSel.addEventListener("change", ()=>{ - S.card = srcSel.value.startsWith("t") ? srcSel.value : +srcSel.value; + S.card = srcSel.value; newRound(); S.chopped = false; display(); }); function labels(){ -- 2.45.2 From d05b12bb86aaddfccb98b6962a0548dd1fa3c8fb Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 13 Jul 2026 08:55:43 +0200 Subject: [PATCH 019/192] v0.2.06-beta: automatische plaatjes per woord in Ankerwoorden - Ankerwoorden-widget: elk woord krijgt nu automatisch een passend plaatje via de ARASAAC-pictogrammendienst, in plaats van steeds hetzelfde ankersymbool - Plaatjes worden per woord gecachet en bewaard in de bordstatus, zodat ze niet steeds opnieuw opgehaald worden - Bij geen resultaat of een netwerkfout valt de widget terug op het ankersymbool - ARASAAC-opzoekfunctie hergebruikt vanuit de bestaande woordkaart-editor (geen dubbele code meer) Co-Authored-By: Claude Sonnet 5 --- public/js/data.js | 8 ++++++++ public/js/widgets/anchor.js | 23 ++++++++++++++++++++--- public/js/widgets/letters.js | 6 ++---- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/public/js/data.js b/public/js/data.js index a3aa7d6..1051b31 100644 --- a/public/js/data.js +++ b/public/js/data.js @@ -66,6 +66,14 @@ function setPic(el, pic){ else el.textContent = pic; } +/* ---- ARASAAC pictogram lookup (shared by the word editor + anchor grid) ---- */ +async function arasaacSearch(term, lang){ + const resp = await fetch(`https://api.arasaac.org/api/pictograms/${lang}/search/${encodeURIComponent(term)}`); + if(!resp.ok) throw new Error("http "+resp.status); + return resp.json(); +} +const arasaacUrl = id => `https://static.arasaac.org/pictograms/${id}/${id}_300.png`; + /* ---- shared word source (general card + themes + anchor grids) ---- */ /* anchor-word widgets register their words here so every language widget can use them as a theme automatically */ diff --git a/public/js/widgets/anchor.js b/public/js/widgets/anchor.js index 7b6ce14..2a1da84 100644 --- a/public/js/widgets/anchor.js +++ b/public/js/widgets/anchor.js @@ -6,7 +6,7 @@ function mountAnchor(root, initState){ 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} }; + const S = { cells:{}, pan:{x:0, y:0}, pics:{} }; if(initState){ const src = initState.cells || {}; Object.keys(src).forEach(k=>{ @@ -15,6 +15,22 @@ function mountAnchor(root, initState){ 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 -> auto-fetched pictogram (or fallback emoji) */ + } + const FALLBACK_PIC = "⚓"; + const fetching = new Set(); + /* auto-fetch a matching picture per word instead of using the anchor emoji for everything */ + 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) : FALLBACK_PIC; + }).catch(()=>{ + S.pics[word] = FALLBACK_PIC; + }).finally(()=>{ + fetching.delete(word); + syncSource(); + }); } root.innerHTML = `
@@ -38,7 +54,8 @@ function mountAnchor(root, initState){ const w = (S.cells[k].t||"").trim().toLowerCase(); if(/^[a-zà-ÿ]{2,12}$/.test(w) && !seen.has(w)){ seen.add(w); - reg.words.push([w, "⚓"]); + if(S.pics[w] === undefined) fetchPic(w); + reg.words.push([w, S.pics[w] || FALLBACK_PIC]); } }); if(!quiet) document.dispatchEvent(new CustomEvent("wordschange")); @@ -154,6 +171,6 @@ function mountAnchor(root, initState){ syncSource(true); render(); if(reg.words.length) document.dispatchEvent(new CustomEvent("wordschange")); - return { getState: ()=>({ cells:S.cells, pan:S.pan }) }; + return { getState: ()=>({ cells:S.cells, pan:S.pan, pics:S.pics }) }; } diff --git a/public/js/widgets/letters.js b/public/js/widgets/letters.js index 1f01130..1e7a163 100644 --- a/public/js/widgets/letters.js +++ b/public/js/widgets/letters.js @@ -404,13 +404,11 @@ function mountLetterGame(root, initState){ edStatus.textContent = T("pictoLoad"); edPGrid.innerHTML = ""; try{ - const resp = await fetch(`https://api.arasaac.org/api/pictograms/${S.lang}/search/${encodeURIComponent(term)}`); - if(!resp.ok) throw new Error("http "+resp.status); - const arr = await resp.json(); + 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 = `https://static.arasaac.org/pictograms/${p._id}/${p._id}_300.png`; + const url = arasaacUrl(p._id); const b = document.createElement("button"); b.type="button"; const im = document.createElement("img"); -- 2.45.2 From b80da89ab64291d21095333d86bb6abc1d19c0e9 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 13 Jul 2026 09:31:52 +0200 Subject: [PATCH 020/192] v0.2.07-beta: ankerwoorden automatisch opslaan - Ankerwoorden-widget: elk toegevoegd, gewijzigd of verwijderd woord wordt nu automatisch (na korte debounce) opgeslagen naar de server, net als bij de woordkaart-editor - persistWords(): ververst eerst de bordstatus (stashCurrent) voordat er opgeslagen wordt, zodat ook de bestaande woordkaart-autosave geen verouderde bordstatus meer verstuurt - Zonder deze fix gingen getypte ankerwoorden verloren bij herladen/opnieuw inloggen zonder expliciet op "Bord opslaan" te klikken, en verschenen ze daardoor ook niet in andere widgets na een reload Co-Authored-By: Claude Sonnet 5 --- public/js/core.js | 2 +- public/js/widgets/anchor.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/public/js/core.js b/public/js/core.js index 1d16fc4..59e059d 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -330,7 +330,7 @@ function persistUser(){ function persistWords(){ if(!currentUser) return; clearTimeout(saveTimer); - saveTimer = setTimeout(persistUser, 800); + saveTimer = setTimeout(()=>{ stashCurrent(); persistUser(); }, 800); } function generalExtraFromData(data){ return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]}; diff --git a/public/js/widgets/anchor.js b/public/js/widgets/anchor.js index 2a1da84..d6b176d 100644 --- a/public/js/widgets/anchor.js +++ b/public/js/widgets/anchor.js @@ -157,6 +157,7 @@ function mountAnchor(root, initState){ else delete S.cells[k]; syncSource(); render(); + persistWords(); }; inp.addEventListener("keydown", ev=>{ ev.stopPropagation(); -- 2.45.2 From b52c45329cc04482e53770e981a0a8aed6643899 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 13 Jul 2026 10:39:50 +0200 Subject: [PATCH 021/192] v0.2.08-beta: instellingen-pagina, vernieuwd inlogscherm, centrale permissiematrix + rolwijziging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Inlogscherm herzien: aparte tabs voor "Inloggen" en "Koppelcode activeren" i.p.v. één doorlopende vorm - Nieuwe Instellingen-pagina: account-info, wachtwoord wijzigen en uitloggen zijn verplaatst uit het inlogscherm naar een eigen scherm (👤-knop opent nu Instellingen i.p.v. het inlogscherm zodra je bent ingelogd) - Laadstatus op de inlog-, koppelcode- en wachtwoordknoppen tijdens het versturen; consistente foutmeldingen i.p.v. een losse alert() bij het wijzigen van je eigen wachtwoord - Centrale, benoemde permissiematrix (nieuw: src/permissions.js + public/js/permissions.js) vervangt de verspreide rol-arrays in src/api.js en de losse rol-checks in admin.js/core.js - Nieuw: een overkoepelend beheerder (super) kan nu de rol van een stafaccount (groepsleiding/schoolbeheerder/overkoepelend) rechtstreeks in het beheerpaneel wijzigen; leerlingaccounts blijven altijd leerling Co-Authored-By: Claude Sonnet 5 --- public/css/teach.css | 21 +++++------ public/index.html | 34 +++++++++++------ public/js/admin.js | 31 ++++++++++------ public/js/core.js | 79 ++++++++++++++++++++++++++++++---------- public/js/permissions.js | 26 +++++++++++++ src/api.js | 40 ++++++++++---------- src/permissions.js | 26 +++++++++++++ 7 files changed, 184 insertions(+), 73 deletions(-) create mode 100644 public/js/permissions.js create mode 100644 src/permissions.js diff --git a/public/css/teach.css b/public/css/teach.css index 3d3d1c5..9abf3c0 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -51,6 +51,7 @@ .tbtn:hover{background:var(--blue-d);} .tbtn.ghost{background:#e8eef6; color:var(--ink);} .tbtn.ghost:hover{background:#dbe5f1;} + .tbtn:disabled{opacity:.55; cursor:default;} #btnSave{min-width:158px; text-align:center;} #btnSave.ok{background:var(--green); color:#fff;} #btnUser.on{background:var(--green); color:#fff;} @@ -411,22 +412,21 @@ .gcard .gi{font-size:38px;} .gcard .gn{font-weight:800; margin-top:8px; font-size:15px;} .gcard .gd{font-size:12.5px; color:var(--muted); margin-top:4px; line-height:1.35;} - /* login modal */ - #loginModal{width:min(400px,92vw);} - #loginModal input{ + /* login + instellingen modals */ + #loginModal,#settingsModal{width:min(400px,92vw);} + #loginModal input,#settingsModal input{ width:100%; border:2px solid #cdd9e8; border-radius:12px; padding:11px 12px; font:inherit; font-size:16px; outline:none; margin-bottom:10px; } - #loginModal input:focus{border-color:var(--blue);} + #loginModal input:focus,#settingsModal input:focus{border-color:var(--blue);} .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;} - #loginMsg{min-height:20px; font-size:13.5px; font-weight:700; color:var(--red); margin:6px 0;} - #loginMsg.ok{color:var(--green);} + .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;} - #loggedBox{display:none;} - #loggedBox .hello{font-size:18px; font-weight:800; margin-bottom:14px;} + #settingsModal .hello{font-size:18px; font-weight:800; margin-bottom:14px;} /* ========================================================= Letter game widget ==========================================================*/ @@ -966,10 +966,9 @@ width:100%; border:2px solid #cdd9e8; border-radius:12px; padding:11px 12px; font:inherit; font-size:15px; outline:none; margin-bottom:10px; background:#fff; } -.lidiv{height:1.5px; background:#e3eaf3; margin:14px 0;} .lisub{font-size:13px; font-weight:700; color:var(--muted); margin-bottom:8px;} -#ownPwRow{margin-bottom:10px;} -#ownPwRow input{ +#ownPwBlock .lrow{margin-bottom:10px;} +#ownPwBlock input{ flex:1; min-width:0; border:2px solid #cdd9e8; border-radius:12px; padding:10px 12px; font:inherit; font-size:15px; outline:none; } diff --git a/public/index.html b/public/index.html index 5d8233c..b480547 100644 --- a/public/index.html +++ b/public/index.html @@ -69,16 +69,21 @@
`; document.body.appendChild(wrap); @@ -66,6 +71,8 @@ function ensurePicPicker(){ 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(); } @@ -77,6 +84,7 @@ function ensurePicPicker(){ 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(); }); @@ -125,9 +133,22 @@ function ensurePicPicker(){ } 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", e=>{ + 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"); @@ -138,7 +159,6 @@ function ensurePicPicker(){ URL.revokeObjectURL(img.src); }; img.src = URL.createObjectURL(f); - e.target.value = ""; }); wrap.__applyLabels = applyLabels; @@ -154,9 +174,9 @@ function openPicPicker(word, onSelect){ wrap.__applyLabels(); wrap.__renderCats(); wrap.querySelector(".ed-psearch").value = word || ""; - /* met een woord starten we meteen op het picto-zoekresultaat i.p.v. het emoji-tabblad, - anders lijkt de vooraf ingevulde zoekopdracht nergens naartoe te leiden */ - wrap.__setTab(word ? "picto" : "emoji"); + /* 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) wrap.__searchPictos(); + if(word && !currentUser) wrap.__searchPictos(); } diff --git a/public/js/widgets/letters.js b/public/js/widgets/letters.js index 3c83032..a44e168 100644 --- a/public/js/widgets/letters.js +++ b/public/js/widgets/letters.js @@ -49,7 +49,7 @@ function mountLetterGame(root, initState, opts){
- +
@@ -69,6 +69,10 @@ function mountLetterGame(root, initState, opts){
+
@@ -312,7 +316,7 @@ function mountLetterGame(root, initState, opts){ edThemeSel.innerHTML = ""; const og = document.createElement("option"); og.value = "g"; - og.textContent = `${T("cardGeneral")} (${generalList().length})`; + 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 */ @@ -519,6 +523,7 @@ function mountLetterGame(root, initState, opts){ 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(); } @@ -568,12 +573,25 @@ function mountLetterGame(root, initState, opts){ } 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", ()=>{ + 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"); @@ -584,7 +602,6 @@ function mountLetterGame(root, initState, opts){ URL.revokeObjectURL(img.src); }; img.src = URL.createObjectURL(f); - edFile.value = ""; }); el(".ed-add").addEventListener("click", ()=>{ @@ -615,6 +632,8 @@ function mountLetterGame(root, initState, opts){ 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"); @@ -757,4 +776,3 @@ function confetti(container){ } } - diff --git a/test/language-library.test.js b/test/language-library.test.js new file mode 100644 index 0000000..ae3046d --- /dev/null +++ b/test/language-library.test.js @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {runInNewContext} from 'node:vm'; + +async function languageData(extra=''){ + const source=await readFile('public/js/data.js','utf8'); + const context={LANG:'nl',T:key=>key}; + runInNewContext(source+`; ${extra} + globalThis.__language={ + concepts:LANGUAGE_CONCEPTS, + themesNl:languageThemeList("nl"), + themesEn:languageThemeList("en"), + words:WORDS, + catalogNl:languagePictureCatalog("nl") + };`,context); + return JSON.parse(JSON.stringify(context.__language)); +} + +test('taalwidgets delen een ruime tweetalige woordenschat in acht duidelijke thema’s',async()=>{ + const data=await languageData(); + assert.ok(data.concepts.length>=140,'verwacht minimaal 140 vaste woordplaatjes'); + assert.equal(data.themesNl.length,8); + assert.equal(data.themesEn.length,8); + assert.equal(new Set(data.concepts.map(item=>item.id)).size,data.concepts.length); + for(const concept of data.concepts){ + assert.ok(concept.nl&&concept.en&&concept.pic,concept.id); + assert.ok(data.themesNl.some(theme=>theme.id===concept.theme),concept.theme); + } + for(const themes of [data.themesNl,data.themesEn]){ + for(const theme of themes)assert.ok(theme.words.length>=10,theme.id); + assert.equal(themes.reduce((sum,theme)=>sum+theme.words.length,0),data.concepts.length); + } +}); + +test('alle oude startwoorden blijven staan en krijgen ook een bibliotheekplaatje',async()=>{ + const data=await languageData(); + const oldNl=['aap','vis','zon','kat','bal','ei','boom','huis','maan','boot','kip','muis','boek','ster','roos','vuur','fiets','taart','jas','bril','klok','peer','kaas','eend','hand','voet','neus','oog','oor','mond']; + const oldEn=['cat','dog','sun','egg','ball','fish','tree','moon','boat','star','book','cake','house','mouse','apple','train','fire','rose','bear','duck','frog','bee','cow','pig','hand','foot','nose','eye','ear','milk']; + assert.deepEqual(data.words.nl.map(([word])=>word),oldNl); + assert.deepEqual(data.words.en.map(([word])=>word),oldEn); + const names=new Set(data.catalogNl.images.map(image=>image.name)); + for(const word of oldNl)assert.ok(names.has(word),word); +}); + +test('vaste en eigen woordplaatjes worden als herbruikbare catalogusitems aangeboden',async()=>{ + const data=await languageData('GENERAL_EXTRA.nl=[["test","🧪"]]; THEMES.nl=[{name:"Boerderij",folder:"School",words:[["stal","🏚️"]]}];'); + const builtins=data.catalogNl.images.filter(image=>image.builtin); + assert.equal(builtins.length,data.concepts.length); + assert.equal(data.catalogNl.themes.filter(theme=>theme.languageBuiltin).length,8); + for(const image of builtins){ + assert.ok(image.id<0); + assert.ok(image.src.startsWith('data:image/svg+xml;charset=utf-8,')); + assert.ok(image.value&&image.themes.length===1); + assert.equal(image.canManage,false); + } + const testPic=data.catalogNl.images.find(image=>image.name==='test'); + const stablePic=data.catalogNl.images.find(image=>image.name==='stal'); + assert.equal(testPic.value,'🧪'); + assert.equal(stablePic.value,'🏚️'); + assert.equal(stablePic.folder,'Taal/Mijn thema’s/School/Boerderij'); +}); + +test('alle vier taalspellen gebruiken themabronnen en kunnen de bibliotheek openen',async()=>{ + const [data,catalog,picker,letters,css,catalogCss]=await Promise.all([ + readFile('public/js/data.js','utf8'), + readFile('public/js/image-catalog.js','utf8'), + readFile('public/js/picker.js','utf8'), + readFile('public/js/widgets/letters.js','utf8'), + readFile('public/css/teach.css','utf8'), + readFile('public/css/image-catalog.css','utf8'), + ]); + assert.match(data,/card\.startsWith\("b:"\)/); + assert.match(data,/builtinGroup\.label = "📚 "/); + for(const widget of ['letters','flits','hak','ball']){ + const source=await readFile(`public/js/widgets/${widget}.js`,'utf8'); + assert.match(source,/wordSourceList\(/,widget); + assert.match(source,/fillWordSourceSel\(/,widget); + } + assert.match(catalog,/refreshWordPictureCatalog/); + assert.match(catalog,/languagePictureCatalog\(LANG\)/); + assert.match(catalog,/ic-word-themes/); + assert.match(picker,/data-t="library"/); + assert.match(picker,/uploadImageToCatalog\(f,/); + assert.match(letters,/data-t="library"/); + assert.match(letters,/openImageCatalog\(\(src,image\)/); + assert.match(letters,/uploadImageToCatalog\(f,/); + assert.match(css,/\.ed-tabs\{[^}]*overflow-x:auto/); + assert.match(catalogCss,/\.ic-word-themes\{[^}]*overflow-x:auto/); + assert.match(catalogCss,/@media\(max-width:760px\)/); +}); -- 2.45.2 From 456d6647185063ca37321fe9399c73d424006dff Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 16 Jul 2026 19:01:25 +0200 Subject: [PATCH 119/192] perf: compressie, cache-headers, cache-busting en parallel ladende scripts (v0.3.81-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @fastify/compress (brotli quality 4 + gzip, global): alle statische assets én API-JSON (zoals /me/data) gaan gecomprimeerd over de lijn - eerste lading van ~715 KB naar grofweg een derde - Nieuw src/frontend.js: index.html wordt bij boot één keer gelezen, ?v=__V__ wordt vervangen door de app-versie en de pagina wordt uit het geheugen geserveerd met no-cache - Cache-beleid per prefix: js/css een jaar immutable (veilig dankzij de geversioneerde URL's), fonts 7 dagen, afbeeldingen 1 dag met zwakke etag als 304-vangnet - herhaalbezoek kost vrijwel geen bytes meer - Alle 31 scripts staan nu met defer in de head: parallel downloaden, uitvoeren in dezelfde dwingende volgorde (core → widgets → board → app); font-preload bewust ongeversioneerd (moet byte-gelijk blijven aan de url() in teach.css) - Compress geregistreerd vóór de api/frontend-registraties (onSend-hook geldt alleen voor latere routes) - geborgd met een teksttest - 6 nieuwe tests (test/frontend.test.js): versie-injectie, cache-headers per prefix, gzip+brotli round-trip, 304 op etag, defer/?v= op elk script Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF --- VERSION | 2 +- package-lock.json | 400 ++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + public/index.html | 73 ++++---- public/js/core.js | 2 +- src/frontend.js | 44 +++++ src/server.js | 24 ++- test/frontend.test.js | 109 ++++++++++++ 8 files changed, 615 insertions(+), 40 deletions(-) create mode 100644 src/frontend.js create mode 100644 test/frontend.test.js diff --git a/VERSION b/VERSION index cc81902..b58b3df 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.80-beta +0.3.81-beta diff --git a/package-lock.json b/package-lock.json index 9424e41..8b5a7b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "teach", "version": "0.1.0", "dependencies": { + "@fastify/compress": "^8.3.1", "@fastify/cookie": "^11.1.1", "@fastify/helmet": "^13.1.0", "@fastify/rate-limit": "^11.1.0", @@ -56,6 +57,48 @@ "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", @@ -283,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", @@ -360,6 +415,26 @@ "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", @@ -372,6 +447,36 @@ "node": "18 || 20 || >=22" } }, + "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": { + "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", @@ -398,6 +503,12 @@ "url": "https://opencollective.com/express" } }, + "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", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -416,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", @@ -604,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", @@ -619,6 +819,12 @@ "node": ">= 10" } }, + "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", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", @@ -702,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", @@ -735,6 +950,15 @@ "node": ">=14.0.0" } }, + "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -751,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", @@ -916,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", @@ -932,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", @@ -981,6 +1294,26 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safe-regex2": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", @@ -1079,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", @@ -1097,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", @@ -1115,6 +1503,18 @@ "node": ">=0.6" } }, + "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", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 4bfb185..7b984ea 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "node --test" }, "dependencies": { + "@fastify/compress": "^8.3.1", "@fastify/cookie": "^11.1.1", "@fastify/helmet": "^13.1.0", "@fastify/rate-limit": "^11.1.0", diff --git a/public/index.html b/public/index.html index b62e2f3..5b3aae4 100644 --- a/public/index.html +++ b/public/index.html @@ -4,10 +4,46 @@ teachus.today - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -260,36 +296,5 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/js/core.js b/public/js/core.js index 432c1ce..565ba61 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.80-beta"; +const VERSION = "0.3.81-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; 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/server.js b/src/server.js index 67be936..5eda97f 100644 --- a/src/server.js +++ b/src/server.js @@ -1,13 +1,15 @@ 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'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -78,16 +80,30 @@ 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 ------------------------------------------------------------------- diff --git a/test/frontend.test.js b/test/frontend.test.js new file mode 100644 index 0000000..a9ca698 --- /dev/null +++ b/test/frontend.test.js @@ -0,0 +1,109 @@ +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(/ + diff --git a/public/js/board.js b/public/js/board.js index f4086dc..2afe24b 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -1127,7 +1127,8 @@ function renderGallery(){ defs.forEach(def=>{const c=document.createElement("div");c.className="gcard";c.tabIndex=0; const icon=document.createElement("div");icon.className="gi";icon.textContent=def.icon; const name=document.createElement("div");name.className="gn";name.textContent=T("wg_"+def.id); - const desc=document.createElement("div");desc.className="gd";desc.textContent=T("wg_"+def.id+"_d"); + /* uitleg: eigen tekst van de systeemmanager (CMS) gaat vóór de standaard */ + const desc=document.createElement("div");desc.className="gd";desc.textContent=(typeof CMS!=="undefined"&&CMS.widgetInfo&&CMS.widgetInfo[def.id]&&(LANG==="en"?CMS.widgetInfo[def.id].en:CMS.widgetInfo[def.id].nl))||T("wg_"+def.id+"_d"); const star=document.createElement("button");star.className="gstar";star.type="button";star.textContent=favorites.includes(def.id)?"★":"☆";star.title=T("galleryFavorite"); star.onclick=e=>{e.stopPropagation();favorites=favorites.includes(def.id)?favorites.filter(x=>x!==def.id):[...favorites,def.id];localStorage.setItem("teachWidgetFavorites",JSON.stringify(favorites));renderGallery()}; const add=()=>{galleryWrap.classList.remove("open");makeWidget(def)};c.onclick=add;c.onkeydown=e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();add()}};c.append(star,icon,name,desc);grid.appendChild(c)});gallery.appendChild(grid); diff --git a/public/js/cms.js b/public/js/cms.js new file mode 100644 index 0000000..47bea0d --- /dev/null +++ b/public/js/cms.js @@ -0,0 +1,221 @@ +/* teach - CMS van de systeemmanager: site-inhoud die zonder release aanpasbaar + is. Drie sleutels (server: site_content, routes in api.js): + welcome - welkomstwoord op het inlogscherm, per taal; + announcement - melding/banner voor alle gebruikers (aan/uit, info of + belangrijk, per taal; elke wijziging krijgt een nieuw id + zodat weggeklikte meldingen opnieuw verschijnen); + widgetInfo - per widget een eigen uitleg die de standaardtekst in de + widgetgalerij vervangt (per taal, leeg = standaard). + Ankerdiagrammen/borden deelt de systeemmanager via de bestaande gedeelde + bibliotheek (map 🌐 Systeem in de verkenner) en afbeeldingen via de + catalogus - de Inhoud-sectie linkt daar rechtstreeks naartoe. */ +"use strict"; +let CMS = {}; +(function(){ + const DISMISS_KEY = "teach.dismissedAnnouncement"; + const cmsEl = (tag, cls, text)=>{ + const e = document.createElement(tag); + if(cls) e.className = cls; + if(text != null) e.textContent = text; + return e; + }; + const inLang = v => v ? ((LANG==="en" ? v.en : v.nl) || v.nl || v.en || "") : ""; + + async function loadContent(){ + try{ CMS = await api("/content") || {}; }catch(e){ CMS = {}; } + renderAnnouncement(); + renderWelcome(); + document.dispatchEvent(new CustomEvent("cmschange")); + } + + /* ---- banner (update-melding) voor iedereen -------------------------------- */ + let banner = null; + function renderAnnouncement(){ + if(banner){ banner.remove(); banner = null; } + const a = CMS.announcement; + const text = a && a.active ? inLang(a) : ""; + if(!text) return; + let dismissed = null; + try{ dismissed = localStorage.getItem(DISMISS_KEY); }catch(e){} + if(dismissed === String(a.id)) return; + banner = cmsEl("div", "cms-banner" + (a.level==="warn" ? " warn" : "")); + banner.appendChild(cmsEl("span", null, text)); + const x = cmsEl("button", "cms-banner-x", "✕"); + x.type = "button"; + x.title = T("close"); + x.addEventListener("click", ()=>{ + try{ localStorage.setItem(DISMISS_KEY, String(a.id)); }catch(e){} + banner.remove(); banner = null; + }); + banner.appendChild(x); + document.body.appendChild(banner); + } + + /* ---- welkomstwoord op het inlogscherm -------------------------------------- */ + function renderWelcome(){ + let box = document.getElementById("cmsWelcome"); + const text = inLang(CMS.welcome); + if(!text){ if(box) box.remove(); return; } + if(!box){ + box = cmsEl("div"); + box.id = "cmsWelcome"; + document.querySelector("#loginModal .login-logo").after(box); + } + box.textContent = text; + } + document.addEventListener("langchange", ()=>{ renderAnnouncement(); renderWelcome(); }); + + /* ---- Inhoud-sectie in Instellingen (alleen systeemmanager) ----------------- */ + const host = cmsEl("div", "st-section"); + host.dataset.section = "inhoud"; + host.style.display = "none"; + document.getElementById("stContent").appendChild(host); + + async function save(key, value, msgEl){ + try{ + await api("/admin/content/"+key, { method:"PUT", body:value }); + CMS[key] = value; + msgEl.textContent = T("cmsSaved"); + msgEl.classList.add("ok"); + renderAnnouncement(); + renderWelcome(); + document.dispatchEvent(new CustomEvent("cmschange")); + }catch(e){ + msgEl.textContent = e.message; + msgEl.classList.remove("ok"); + } + } + const langArea = (labelKey, value)=>{ + const wrap = cmsEl("label", "cms-field"); + wrap.appendChild(cmsEl("span", "am-field-lbl", T(labelKey))); + const ta = document.createElement("textarea"); + ta.className = "am-inp cms-ta"; + ta.maxLength = 2000; + ta.value = value || ""; + ta.addEventListener("keydown", ev=>ev.stopPropagation()); + wrap.appendChild(ta); + wrap._ta = ta; + return wrap; + }; + + function renderPanel(){ + host.innerHTML = ""; + + /* welkomstwoord */ + host.appendChild(cmsEl("div", "am-h3", T("cmsWelcomeH"))); + const wMsg = cmsEl("div", "formMsg"); + const wNl = langArea("cmsNl", CMS.welcome && CMS.welcome.nl); + const wEn = langArea("cmsEn", CMS.welcome && CMS.welcome.en); + const wSave = cmsEl("button", "am-btn", T("save")); + wSave.type = "button"; + wSave.addEventListener("click", ()=>save("welcome", { nl: wNl._ta.value.trim(), en: wEn._ta.value.trim() }, wMsg)); + const wBlock = cmsEl("div", "am-reset cms-block"); + [wNl, wEn, wSave, wMsg].forEach(n=>wBlock.appendChild(n)); + host.appendChild(wBlock); + + /* update-melding */ + host.appendChild(cmsEl("div", "am-h3", T("cmsAnnounceH"))); + const a = CMS.announcement || {}; + const aMsg = cmsEl("div", "formMsg"); + const activeLbl = cmsEl("label", "cms-check"); + const active = document.createElement("input"); + active.type = "checkbox"; + active.checked = !!a.active; + activeLbl.appendChild(active); + activeLbl.appendChild(cmsEl("span", null, " " + T("cmsActive"))); + const level = document.createElement("select"); + level.className = "am-sel"; + level.appendChild(new Option(T("cmsLevelInfo"), "info")); + level.appendChild(new Option(T("cmsLevelWarn"), "warn")); + level.value = a.level === "warn" ? "warn" : "info"; + const aNl = langArea("cmsNl", a.nl); + const aEn = langArea("cmsEn", a.en); + const aSave = cmsEl("button", "am-btn", T("save")); + aSave.type = "button"; + aSave.addEventListener("click", ()=>{ + /* nieuw id per wijziging: weggeklikte meldingen verschijnen dan opnieuw */ + save("announcement", { active: active.checked, level: level.value, + nl: aNl._ta.value.trim(), en: aEn._ta.value.trim(), id: Date.now() }, aMsg); + }); + const aBlock = cmsEl("div", "am-reset cms-block"); + const aRow = cmsEl("div", "cms-row"); + aRow.appendChild(activeLbl); + aRow.appendChild(level); + [aRow, aNl, aEn, aSave, aMsg].forEach(n=>aBlock.appendChild(n)); + host.appendChild(aBlock); + + /* widget-uitleg */ + host.appendChild(cmsEl("div", "am-h3", T("cmsWidgetH"))); + const gBlock = cmsEl("div", "am-reset cms-block"); + const gMsg = cmsEl("div", "formMsg"); + const pick = document.createElement("select"); + pick.className = "am-sel"; + pick.appendChild(new Option(T("cmsWidgetPick"), "")); + REGISTRY.forEach(def=>pick.appendChild(new Option(def.icon + " " + T("wg_"+def.id), def.id))); + const editor = cmsEl("div"); + editor.style.display = "none"; + pick.addEventListener("change", ()=>{ + editor.innerHTML = ""; + gMsg.textContent = ""; + if(!pick.value){ editor.style.display = "none"; return; } + const id = pick.value; + const cur = (CMS.widgetInfo || {})[id] || {}; + const gNl = langArea("cmsNl", cur.nl); + const gEn = langArea("cmsEn", cur.en); + gNl._ta.placeholder = I18N.nl["wg_"+id+"_d"] || ""; + gEn._ta.placeholder = I18N.en["wg_"+id+"_d"] || ""; + const hint = cmsEl("div", "guestnote", T("cmsWidgetHint")); + const gSave = cmsEl("button", "am-btn", T("save")); + gSave.type = "button"; + gSave.addEventListener("click", ()=>{ + const info = { ...(CMS.widgetInfo || {}) }; + const nl = gNl._ta.value.trim(), en = gEn._ta.value.trim(); + if(nl || en) info[id] = { nl, en }; + else delete info[id]; + save("widgetInfo", info, gMsg); + }); + [gNl, gEn, hint, gSave].forEach(n=>editor.appendChild(n)); + editor.style.display = ""; + }); + gBlock.appendChild(pick); + gBlock.appendChild(editor); + gBlock.appendChild(gMsg); + host.appendChild(gBlock); + + /* systeembrede inhoud: ankerdiagrammen/borden en afbeeldingen */ + host.appendChild(cmsEl("div", "am-h3", T("cmsShortcutsH"))); + const sBlock = cmsEl("div", "am-reset cms-block"); + sBlock.appendChild(cmsEl("div", "guestnote", T("cmsShortcutsHint"))); + const shared = cmsEl("button", "tbtn ghost", T("cmsOpenShared")); + shared.type = "button"; + shared.addEventListener("click", ()=>{ + document.getElementById("settingsWrap").classList.remove("open"); + document.getElementById("btnFolders").click(); + }); + const imgs = cmsEl("button", "tbtn ghost", T("cmsOpenImages")); + imgs.type = "button"; + imgs.addEventListener("click", ()=>{ + document.getElementById("settingsWrap").classList.remove("open"); + openImageCatalog(); + }); + const sRow = cmsEl("div", "cms-row"); + sRow.appendChild(shared); + sRow.appendChild(imgs); + sBlock.appendChild(sRow); + host.appendChild(sBlock); + } + + registerSettingsSection({ + id:"inhoud", icon:"📢", group:"system", order:32, + label:()=>T("stInhoud"), sub:()=>T("stInhoudSub"), + visible:()=>!!currentUser && can("content.manage"), + activate(){ + renderPanel(); + showSettingsDom("inhoud"); + }, + keywords:()=>[T("cmsWelcomeH"), T("cmsAnnounceH"), T("cmsWidgetH"), + "welkom","welcome","melding","announcement","banner","uitleg","cms","tekst","text"], + }); + + loadContent(); +})(); diff --git a/public/js/core.js b/public/js/core.js index 4e7e6a9..4a8a1da 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.83-beta"; +const VERSION = "0.3.84-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -211,6 +211,17 @@ const I18N = { stVoortgangSub:"Volg per leerling de resultaten in de tijd.", stScholenSub:"Scholen en systeemmanagers, over alle scholen heen.", stOpslag:"Opslag", stOpslagSub:"Afbeeldingencatalogus en opslagruimte per school.", + stInhoud:"Inhoud", stInhoudSub:"Welkomstwoord, meldingen en widget-uitleg - direct aanpasbaar, zonder release.", + cmsWelcomeH:"Welkomstwoord (inlogscherm)", + cmsAnnounceH:"Melding voor alle gebruikers", + cmsWidgetH:"Widget-uitleg (bibliotheek)", + cmsActive:"Melding tonen", cmsLevelInfo:"informatie", cmsLevelWarn:"belangrijk", + cmsSaved:"Opgeslagen ✓", cmsNl:"Nederlands", cmsEn:"English", + cmsWidgetPick:"— kies een widget… —", + cmsWidgetHint:"Leeg laten = de standaarduitleg.", + cmsShortcutsH:"Systeembrede inhoud", + cmsShortcutsHint:"Ankerdiagrammen en borden deel je via de gedeelde bibliotheek (map 🌐 Systeem in de verkenner); afbeeldingen via de catalogus. Iedereen kan ze daar kopiëren.", + cmsOpenShared:"🌐 Borden & ankerdiagrammen", cmsOpenImages:"🖼 Afbeeldingencatalogus", stBackups:"Reservekopieën", backupsNote:"Automatisch bewaarde eerdere versies van je borden en woorden.", backupsEmpty:"Nog geen reservekopieën.", backupRestore:"herstel", backupRestoreConfirm:"zeker?", backupRestored:"Versie hersteld ✓", @@ -489,6 +500,17 @@ const I18N = { stVoortgangSub:"Follow each pupil's results over time.", stScholenSub:"Schools and system managers, across all schools.", stOpslag:"Storage", stOpslagSub:"Image catalogue and storage per school.", + stInhoud:"Content", stInhoudSub:"Welcome text, announcements and widget descriptions - editable without a release.", + cmsWelcomeH:"Welcome text (login screen)", + cmsAnnounceH:"Announcement for all users", + cmsWidgetH:"Widget descriptions (library)", + cmsActive:"Show announcement", cmsLevelInfo:"information", cmsLevelWarn:"important", + cmsSaved:"Saved ✓", cmsNl:"Nederlands", cmsEn:"English", + cmsWidgetPick:"— pick a widget… —", + cmsWidgetHint:"Leave empty for the default description.", + cmsShortcutsH:"System-wide content", + cmsShortcutsHint:"Share anchor diagrams and boards through the shared library (🌐 System folder in the explorer); images through the catalogue. Everyone can copy them from there.", + cmsOpenShared:"🌐 Boards & anchor diagrams", cmsOpenImages:"🖼 Image catalogue", stBackups:"Backups", backupsNote:"Automatically kept earlier versions of your boards and words.", backupsEmpty:"No backups yet.", backupRestore:"restore", backupRestoreConfirm:"sure?", backupRestored:"Version restored ✓", diff --git a/public/js/settings.js b/public/js/settings.js index 4116caf..e6a5e4b 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -58,6 +58,8 @@ function registerSettingsSection(sec){ ST_SECTIONS.push(sec); } }); } 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(){ -- 2.45.2 From 8b4bd47407da2a15f64ca0f7b54e1b57effe0ac4 Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 16 Jul 2026 20:42:53 +0200 Subject: [PATCH 123/192] style: zakelijk ontwerpsysteem voor Instellingen (v0.3.85-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Volledig gescoped op #settingsModal zodat het bord speels blijft: systeemfont i.p.v. Quicksand, controls 32px (40px op aanraakschermen), vlakke 1px-randen i.p.v. schaduwen en pastel-pills - Rijen worden tabelregels met scheidingslijnen en hover; groepskoppen zijn subtiele sticky sectierijen; navigatie-actief is neutraal grijs met accent-inzetlijn; iconen gedempt in grijstinten - Knoppen één systeem: subtiel omrand, danger als rode tekst die pas gevuld rood wordt na wapenen; chips/resultaten/segmentknoppen omrand vlak - Hoog contrast expliciet teruggebracht binnen de nieuwe scope; sectiekop-icoon in eigen span zodat CSS hem kan dempen Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF --- VERSION | 2 +- public/css/teach.css | 81 +++++++++++++++++++++++++++++++++++++++++++ public/js/core.js | 2 +- public/js/settings.js | 5 ++- 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index 7306e75..c6e4500 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.84-beta +0.3.85-beta diff --git a/public/css/teach.css b/public/css/teach.css index 07bf16a..15f31e6 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1863,3 +1863,84 @@ body.dark .world-fact{background:#1d3b2a;color:#a9e5b7}body.dark .world-fact .wo .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; + border-bottom:1px solid var(--line); margin:0; padding:7px 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); + font-size:12.5px; font-weight:550; box-shadow:none; padding:0 12px; +} +#settingsModal .am-btn:hover, #settingsModal .tbtn:hover{background:var(--surface-2);} +#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; color:var(--muted); font-weight:550;} +#settingsModal .am-add-wrap > .tbtn:hover{color:var(--ink);} +/* 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;} diff --git a/public/js/core.js b/public/js/core.js index 4a8a1da..f92ec3f 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.84-beta"; +const VERSION = "0.3.85-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; diff --git a/public/js/settings.js b/public/js/settings.js index e6a5e4b..b0b3315 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -90,7 +90,10 @@ function registerSettingsSection(sec){ ST_SECTIONS.push(sec); } searchInp.value = ""; nav.querySelectorAll(".st-nav-btn").forEach(b=>b.classList.toggle("active", b.dataset.section===id)); secHead.innerHTML = ""; - secHead.appendChild(el("div","st-sec-title", s.icon + " " + s.label())); + 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); -- 2.45.2 From af86ff5c475f56315f65cc511f59d0f97663ec79 Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 17 Jul 2026 07:06:18 +0200 Subject: [PATCH 124/192] feat: gebruikersbeheer als master-detail met vast bewerkpaneel (v0.3.86-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gebruikers staan in een echte tabel (initiaal-avatar + naam | functie | klas | status) met kolomkoppen en uitgelijnde kolommen; klikken op een rij selecteert (accent-markering) zonder her-render, dus het zoekveld houdt focus - Rechts een vast detailpaneel: bewerken (functie, klaskoppeling, klas, wachtwoord, acties), aanmeldverzoeken afhandelen én nieuwe gebruikers aanmaken gebeuren allemaal dáár - één plek, één patroon - Eenmalige wachtwoorden/codes verschijnen als badge in het paneel; na aanmaken is de nieuwe gebruiker direct geselecteerd; selectie overleeft herladen en wordt gevalideerd tegen de data - Aanmeldverzoeken zijn gewone tabelrijen met een "nieuw"-chip; de losse toevoeg-formulieren per klas zijn vervangen door één "Nieuwe gebruiker"-knop in de werkbalk (met rolkeuze) - Smal scherm: het detailpaneel wordt een overlay met sluitknop (onder de tabel stapelen is onbruikbaar bij grote scholen) - Menu-items herzien: de sectie heet "Scholen" (geen "Systeem > Systeem"-verdubbeling meer) en de Systeem-groep is geordend als Scholen / Inhoud / Opslag - Eerste emoji-sweep in de omgebouwde delen (actieknoppen zijn tekstknoppen); zoekfilter en inklapbare klasgroepen werken ongewijzigd - Geverifieerd in headless Chromium (19 checks, incl. focusbehoud, overlay-gedrag en selectie-na-reload) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF --- VERSION | 2 +- public/css/teach.css | 60 +++++++ public/js/admin.js | 392 ++++++++++++++++++++++++++++++++----------- public/js/cms.js | 2 +- public/js/core.js | 8 +- 5 files changed, 360 insertions(+), 104 deletions(-) diff --git a/VERSION b/VERSION index c6e4500..3336225 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.85-beta +0.3.86-beta diff --git a/public/css/teach.css b/public/css/teach.css index 15f31e6..b5adbbe 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1944,3 +1944,63 @@ 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); + } +} diff --git a/public/js/admin.js b/public/js/admin.js index ed7399c..2f396f0 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -145,13 +145,13 @@ { tab:"voortgang", icon:"📈", group:"school", order:13, labelKey:"amProgress", subKey:"stVoortgangSub", show:()=>can("progress.view"), keywords:()=>["voortgang","progress","score","resultaten","results","tijdlijn","timeline"] }, - { tab:"scholen", icon:"🏫", group:"system", order:30, labelKey:"amOverarching", subKey:"stScholenSub", + { tab:"scholen", icon:"🏫", group:"system", order:30, labelKey:"amSchools", subKey:"stScholenSub", show:()=>currentUser.role==="super", keywords:()=>[T("amNewSchool"), T("amNewSuper"), "school","systeemmanager","system manager"], search:q=>SCHOOLS .filter(s=>s.name.toLowerCase().includes(q)) .map(s=>({ icon:"🏫", label:s.name, sub:T("amSchools"), go:()=>goSettingsSection("scholen") })) }, - { tab:"opslag", icon:"🖼", group:"system", order:31, labelKey:"stOpslag", subKey:"stOpslagSub", + { tab:"opslag", icon:"🖼", group:"system", order:32, labelKey:"stOpslag", subKey:"stOpslagSub", show:()=>currentUser.role==="super", keywords:()=>["opslag","storage","afbeeldingen","images","quota"] }, ]; @@ -194,6 +194,7 @@ document.addEventListener("userchange", ()=>{ if(!currentUser) resetResults = null; loadPromise = null; + selectedUserKey = null; }); async function reload(){ @@ -232,29 +233,64 @@ return sel; } - function userRow(u, opts){ - const row = h("div","am-row" + (opts && opts.sub ? " am-row-sub" : "")); - /* rustig standaardbeeld: alleen naam + rol/klas; alle knoppen en velden - zitten achter het ✏-knopje in een uitklapbaar gereedschapsblok */ - const tools = h("div","am-row-tools"); - const actions = h("div","am-actions"); - const icon = opts && opts.icon ? opts.icon + " " : ""; - row.appendChild(h("span","am-name", icon + u.displayName + (u.displayName!==u.username ? ` (${u.username})` : ""))); + /* ---- gebruikers: master-detail --------------------------------------------- + Links een compacte tabel (naam | functie | klas | status), rechts een vast + detailpaneel waarin de geselecteerde gebruiker bewerkt wordt. De selectie + overleeft reload(); credential-acties selecteren de gebruiker zodat het + eenmalige wachtwoord/de code direct in het paneel zichtbaar is. */ + let selectedUserKey = null; /* "u:" | "req:" | "new" | null */ + let masterHost = null, detailHost = null; + + 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); + } + function userKlasText(u){ if(u.role==="pupil"){ const cls = CLASSES.find(c=>Number(c.id)===Number(u.classId)); - row.appendChild(h("span","am-role", T(roleKey(u.role)) + (cls ? ` · ${cls.name}` : ""))); - }else if(u.role==="parent"){ - /* de school ziet bij elke ouder direct om welke kinderen het gaat */ - const kids = (u.childNames||[]).join(", "); - row.appendChild(h("span","am-role", T(roleKey(u.role)) + (kids ? ` · 🧒 ${kids}` : ""))); - }else{ - const names = (u.classIds||[]) - .map(cid=>CLASSES.find(c=>Number(c.id)===Number(cid))?.name) - .filter(Boolean); - row.appendChild(h("span","am-role", T(roleKey(u.role)) - + (names.length ? ` · ${names.join(", ")}` : "") - + (u.pending ? ` · ${T("amPending")}` : ""))); + return cls ? cls.name : ""; } + if(u.role==="parent") return (u.childNames||[]).join(", "); + return (u.classIds||[]) + .map(cid=>CLASSES.find(c=>Number(c.id)===Number(cid))?.name) + .filter(Boolean).join(", "); + } + function userStatus(u){ + if(u.pending) return T("amPending"); + if(u.role==="pupil" && !u.everLoggedIn) return T("amNeverLoggedIn"); + return ""; + } + function nameCellFor(display, username){ + const cell = h("span","am-name"); + cell.appendChild(avatar(display)); + const nm = h("span","am-name-txt", display); + if(username && display!==username) nm.appendChild(h("small","am-username"," · "+username)); + cell.appendChild(nm); + return cell; + } + /* tabelrij in de gebruikerslijst: klik selecteert (geen her-render, zodat + het zoekveld focus houdt); bewerken gebeurt in het detailpaneel */ + function userCells(u){ + const row = h("div","am-row am-tr"); + row.dataset.selkey = "u:"+u.id; + row.appendChild(nameCellFor(u.displayName, u.username)); + row.appendChild(h("span","am-cell", T(roleKey(u.role)))); + row.appendChild(h("span","am-cell", userKlasText(u) || "—")); + const stCell = h("span","am-cell am-status"); + const st = userStatus(u); + if(st) stCell.appendChild(h("span","am-chip", st)); + else stCell.textContent = "—"; + row.appendChild(stCell); + row.addEventListener("click", ()=>{ selectedUserKey = "u:"+u.id; syncSelection(); }); + return row; + } + + /* het bewerkformulier van één gebruiker; wordt gebruikt door het + detailpaneel (gebruikers) én inline door het Systeem-tabblad (subrijen) */ + function userTools(u){ + const tools = h("div","am-tools"); + const actions = h("div","am-actions"); const isStaff = ["teacher","admin","super"].includes(u.role); if(isStaff && can("users.role.change") && u.id!==currentUser.id){ /* één functie per gebruiker: alleen de hoofdrol is te wijzigen (super); @@ -322,30 +358,29 @@ catch(e){ msg(e.message); } }); pwWrap.appendChild(pw); - const pwCopy = h("button","am-copy","📋"); + const pwCopy = h("button","am-btn", T("amCopy")); pwCopy.type = "button"; - pwCopy.title = T("amCopy"); 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 ()=>{ + 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 }); - openRows.add(u.id); + selectedUserKey = "u:"+u.id; reload(); }catch(e){ msg(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 ()=>{ + 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 }); - openRows.add(u.id); + selectedUserKey = "u:"+u.id; reload(); }catch(e){ msg(e.message); } })); @@ -354,55 +389,115 @@ 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 ()=>{ + 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 }); - openRows.add(u.id); + selectedUserKey = "u:"+u.id; reload(); }catch(e){ msg(e.message); } })); /* direct een nieuw wachtwoord zetten, zonder de koppelcode-activatie */ - actions.appendChild(dangerBtn("🎲 " + T("amNewPw"), "amNewPwWarn", async ()=>{ + 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 }); - openRows.add(u.id); + selectedUserKey = "u:"+u.id; reload(); }catch(e){ msg(e.message); } })); } } if(u.id !== currentUser.id){ - const del = dangerBtn("🗑 " + T("amDelete"), "amDeleteWarn", async ()=>{ - try{ await api("/admin/users/"+u.id, {method:"DELETE"}); reload(); } + const del = dangerBtn(T("amDelete"), "amDeleteWarn", async ()=>{ + try{ await api("/admin/users/"+u.id, {method:"DELETE"}); selectedUserKey = null; reload(); } catch(e){ msg(e.message); } }); actions.appendChild(del); } + if(actions.childNodes.length) tools.appendChild(field("amFieldActions", actions)); + return tools; + } + + /* inline-variant voor het Systeem-tabblad (schoolbeheerder-subrijen): + naam + rol met een "Bewerken"-tekstknop die het formulier uitklapt */ + function userRow(u, opts){ + const row = h("div","am-row" + (opts && opts.sub ? " am-row-sub" : "")); + row.appendChild(h("span","am-name", u.displayName + (u.displayName!==u.username ? ` (${u.username})` : ""))); + row.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : ""))); + const tools = h("div","am-row-tools"); const badge = resultBadge(u); if(badge) tools.appendChild(badge); - if(actions.childNodes.length) tools.appendChild(field("amFieldActions", actions)); - if(tools.childNodes.length){ - const edit = h("button","am-edit","✏"); - edit.type = "button"; - edit.title = T("amEdit"); - const sync = ()=>{ - const open = openRows.has(u.id); - tools.style.display = open ? "" : "none"; - edit.classList.toggle("on", open); - }; - edit.addEventListener("click", ()=>{ - if(openRows.has(u.id)) openRows.delete(u.id); else openRows.add(u.id); - sync(); - }); + tools.appendChild(userTools(u)); + const edit = h("button","am-edit", T("amEdit")); + edit.type = "button"; + const sync = ()=>{ + const open = openRows.has(u.id); + tools.style.display = open ? "" : "none"; + edit.classList.toggle("on", open); + }; + edit.addEventListener("click", ()=>{ + if(openRows.has(u.id)) openRows.delete(u.id); else openRows.add(u.id); sync(); - row.appendChild(edit); - row.appendChild(tools); - } + }); + sync(); + row.appendChild(edit); + row.appendChild(tools); return row; } + /* het detailpaneel rechts: leeg advies, gebruiker bewerken, aanmelding + afhandelen of een nieuwe gebruiker aanmaken - afhankelijk van de selectie */ + function syncSelection(){ + if(!masterHost || !detailHost) return; + /* selectie valideren tegen de actuele data (na reload kan hij weg zijn) */ + if(selectedUserKey && selectedUserKey.startsWith("u:") + && !USERS.some(u=>"u:"+u.id===selectedUserKey)) selectedUserKey = null; + if(selectedUserKey && selectedUserKey.startsWith("req:") + && !LINK_REQUESTS.some(r=>"req:"+r.id===selectedUserKey)) selectedUserKey = null; + masterHost.querySelectorAll(".am-tr").forEach(r=>r.classList.toggle("sel", r.dataset.selkey===selectedUserKey)); + const split = masterHost.closest(".am-split"); + if(split) split.classList.toggle("has-sel", !!selectedUserKey); + renderDetail(); + } + function detailHead(title, subtitle){ + const head = h("div","am-detail-head"); + head.appendChild(avatar(title, true)); + const txt = h("div","am-detail-title"); + txt.appendChild(h("h4", null, title)); + if(subtitle) txt.appendChild(h("span","am-role", subtitle)); + head.appendChild(txt); + const close = h("button","am-btn am-detail-close", T("close")); + close.type = "button"; + close.addEventListener("click", ()=>{ selectedUserKey = null; syncSelection(); }); + head.appendChild(close); + return head; + } + function renderDetail(){ + if(!detailHost) return; + detailHost.innerHTML = ""; + if(selectedUserKey === "new"){ + detailHost.appendChild(detailHead(T("amNewUser"), "")); + detailHost.appendChild(newUserForm()); + return; + } + if(selectedUserKey && selectedUserKey.startsWith("u:")){ + const u = USERS.find(x=>"u:"+x.id===selectedUserKey); + detailHost.appendChild(detailHead(u.displayName, T(roleKey(u.role)) + (u.displayName!==u.username ? ` · ${u.username}` : ""))); + const badge = resultBadge(u); + if(badge) detailHost.appendChild(badge); + detailHost.appendChild(userTools(u)); + return; + } + if(selectedUserKey && selectedUserKey.startsWith("req:")){ + const r = LINK_REQUESTS.find(x=>"req:"+x.id===selectedUserKey); + detailHost.appendChild(detailHead(r.pupil.displayName, `${T("amRequestOne")} · ${T("amRequestVia")} ${r.parent.displayName}`)); + detailHost.appendChild(requestTools(r)); + return; + } + detailHost.appendChild(h("div","am-detail-empty", T("amDetailEmpty"))); + } + /* 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). */ @@ -487,6 +582,84 @@ return wrap; } + /* nieuw-gebruiker-formulier in het detailpaneel: eerst de rol kiezen + (super aanmaken blijft op het Systeem-tabblad - die rol hoort niet bij + één school), daarna de velden die bij die rol horen */ + function newUserForm(){ + const wrap = h("div","am-tools"); + const roles = creatableRoles().filter(r=>r!=="super"); + const roleSel = h("select","am-sel"); + roles.forEach(r=>roleSel.appendChild(new Option(T(roleKey(r)), r))); + roleSel.value = roles.includes("pupil") ? "pupil" : roles[0]; + wrap.appendChild(field("amFieldRole", roleSel)); + const body = h("div","am-tools"); + wrap.appendChild(body); + const build = ()=>{ + body.innerHTML = ""; + const role = roleSel.value; + const name = h("input","am-inp"); + name.placeholder = T("amUserPh"); + name.addEventListener("keydown", ev=>ev.stopPropagation()); + body.appendChild(field("amFieldName", name)); + let cs = null; + if(role==="pupil"){ cs = classSel(""); body.appendChild(field("amFieldClass", cs)); } + let pwInp = null; + if(role!=="pupil"){ + 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); + body.appendChild(field("amFieldPw", pwWrap)); + } + const go = h("button","am-btn am-primary", T("amNewUser")); + go.type = "button"; + go.disabled = true; + name.addEventListener("input", ()=>{ go.disabled = name.value.trim().length < 2; }); + const errEl = h("div","am-form-err"); + go.addEventListener("click", async ()=>{ + errEl.textContent = ""; + const reqBody = { role, username: name.value.trim(), displayName: name.value.trim() }; + if(role==="pupil" && cs && cs.value) reqBody.classId = +cs.value; + if(pwInp && pwInp.value.trim()) reqBody.password = pwInp.value.trim(); + if(currentUser.role==="super") reqBody.school = +selSchool; + try{ + const r = await api("/admin/users", { body: reqBody }); + rowResults.set(r.user.id, { + label: r.user.password ? T("amResultPw") : T("amResultCode"), + value: r.user.password || r.user.linkCode, + }); + if(role==="pupil"){ + openGroups.add(r.user.classId ? "class:"+r.user.classId : "class:none"); + openGroups.add("pupils"); + } + /* de nieuwe gebruiker wordt direct geselecteerd: het eenmalige + wachtwoord of de koppelcode staat dan in het detailpaneel */ + selectedUserKey = "u:"+r.user.id; + reload(); + }catch(e){ errEl.textContent = e.message; } + }); + body.appendChild(go); + body.appendChild(errEl); + setTimeout(()=>name.focus(), 0); + }; + roleSel.addEventListener("change", build); + build(); + 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. */ @@ -548,7 +721,7 @@ }); tools.appendChild(field("amFieldName", nameInp)); const acts = h("div","am-actions"); - acts.appendChild(dangerBtn("🗑 " + T("amDelete"), "amDeleteSchoolWarn", async ()=>{ + acts.appendChild(dangerBtn(T("amDelete"), "amDeleteSchoolWarn", async ()=>{ try{ await api("/admin/schools/"+s.id, {method:"DELETE"}); if(String(selSchool)===String(s.id)) selSchool = ""; @@ -575,7 +748,7 @@ list.appendChild(row); /* de schoolbeheerders van deze school, duidelijk onderscheiden van de schoolrij (🛡-icoon, ingesprongen) maar met hetzelfde ✏-gereedschap */ - admins.forEach(a=>list.appendChild(userRow(a, { icon:"🛡", sub:true }))); + admins.forEach(a=>list.appendChild(userRow(a, { sub:true }))); }); panel.appendChild(list); @@ -645,7 +818,7 @@ row.appendChild(h("span","am-role", `${pupilCount} ${(pupilCount===1 ? T("amPupilOne") : T("amPupils")).toLowerCase()}`)); if(canResetPw){ const acts = h("div","am-actions"); - acts.appendChild(dangerBtn("🔄 " + T("amResetClass"), "amConfirmReset", async ()=>{ + acts.appendChild(dangerBtn(T("amResetClass"), "amConfirmReset", async ()=>{ try{ const r = await api("/admin/classes/"+c.id+"/reset-passwords", {method:"POST"}); await reload(); @@ -653,7 +826,7 @@ render(); }catch(e){ msg(e.message); } })); - acts.appendChild(dangerBtn("🗑 " + T("amDelete"), "amDeleteClassWarn", async ()=>{ + acts.appendChild(dangerBtn(T("amDelete"), "amDeleteClassWarn", async ()=>{ try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); } catch(e){ msg(e.message); } })); @@ -684,7 +857,7 @@ panel.appendChild(f); /* multireset: alle leerlingwachtwoorden van de hele school in één keer */ - panel.appendChild(dangerBtn("🔄 " + T("amResetSchool"), "amConfirmResetSchool", async ()=>{ + panel.appendChild(dangerBtn(T("amResetSchool"), "amConfirmResetSchool", async ()=>{ const schoolId = currentUser.role==="super" ? selSchool : currentUser.schoolId; if(!schoolId) return; try{ @@ -907,12 +1080,21 @@ adopteren (thuisaccount wordt de schoolleerling, evt. met klas en - bij naamconflict - een andere gebruikersnaam), koppelen aan een bestaande leerling, of afwijzen */ - function linkRequestRow(r){ - const row = h("div","am-row"); - row.appendChild(h("span","am-name","🧒 " + r.pupil.displayName - + (r.pupil.displayName!==r.pupil.username ? ` (${r.pupil.username})` : ""))); - row.appendChild(h("span","am-role", `${T("amRequestVia")} 👪 ${r.parent.displayName}`)); - const tools = h("div","am-row-tools"); + /* aanmeldverzoek als tabelrij; afhandelen gebeurt in het detailpaneel */ + function requestCells(r){ + const row = h("div","am-row am-tr"); + row.dataset.selkey = "req:"+r.id; + row.appendChild(nameCellFor(r.pupil.displayName, r.pupil.username)); + row.appendChild(h("span","am-cell", T("amRequestOne"))); + row.appendChild(h("span","am-cell", `${T("amRequestVia")} ${r.parent.displayName}`)); + const stCell = h("span","am-cell am-status"); + stCell.appendChild(h("span","am-chip on", T("amReqNew"))); + row.appendChild(stCell); + row.addEventListener("click", ()=>{ selectedUserKey = "req:"+r.id; syncSelection(); }); + return row; + } + function requestTools(r){ + const tools = h("div","am-tools"); const adoptWrap = h("div","am-inline"); const cs = classSel(null); @@ -925,7 +1107,7 @@ adopt.addEventListener("click", async ()=>{ const body = { action:"adopt", classId: cs.value ? +cs.value : null }; if(un.style.display!=="none" && un.value.trim()) body.username = un.value.trim(); - try{ await api("/admin/link-requests/"+r.id+"/resolve", { body }); reload(); } + try{ await api("/admin/link-requests/"+r.id+"/resolve", { body }); selectedUserKey = null; reload(); } catch(e){ /* naamconflict binnen de school: veld tonen om direct een andere gebruikersnaam mee te geven */ @@ -945,7 +1127,7 @@ mb.type = "button"; mb.addEventListener("click", async ()=>{ if(!ms.value) return; - try{ await api("/admin/link-requests/"+r.id+"/resolve", { body:{ action:"match", pupilId: +ms.value } }); reload(); } + try{ await api("/admin/link-requests/"+r.id+"/resolve", { body:{ action:"match", pupilId: +ms.value } }); selectedUserKey = null; reload(); } catch(e){ msg(e.message); } }); matchWrap.appendChild(ms); @@ -953,32 +1135,50 @@ tools.appendChild(field("amMatchBtn", matchWrap)); const actions = h("div","am-actions"); - actions.appendChild(dangerBtn("🗑 " + T("amReject"), "amRejectWarn", async ()=>{ - try{ await api("/admin/link-requests/"+r.id+"/resolve", { body:{ action:"reject" } }); reload(); } + actions.appendChild(dangerBtn(T("amReject"), "amRejectWarn", async ()=>{ + try{ await api("/admin/link-requests/"+r.id+"/resolve", { body:{ action:"reject" } }); selectedUserKey = null; reload(); } catch(e){ msg(e.message); } })); tools.appendChild(field("amFieldActions", actions)); - row.appendChild(tools); - return row; + return tools; } function renderGebruikersPanel(panel){ - /* openstaande aanmeldingen bovenaan: dit vraagt actie van de school */ - if(LINK_REQUESTS.length){ - panel.appendChild(h("div","am-group", `${T("amLinkRequests")} (${LINK_REQUESTS.length})`)); - const rl = h("div","am-reqlist am-list"); - LINK_REQUESTS.forEach(r=>rl.appendChild(linkRequestRow(r))); - panel.appendChild(rl); - } - const filterBar = h("div","am-add"); + const schoolChosenTop = currentUser.role!=="super" || !!selSchool; + /* werkbalk: zoeken links, nieuwe gebruiker rechts */ + const bar = h("div","am-toolbar"); const filterInp = h("input","am-inp am-filter"); filterInp.type = "search"; filterInp.placeholder = T("amSearchPh"); filterInp.value = userFilter; filterInp.addEventListener("keydown", ev=>ev.stopPropagation()); filterInp.addEventListener("input", ()=>{ userFilter = filterInp.value; applyUserFilter(panel); }); - filterBar.appendChild(filterInp); - panel.appendChild(filterBar); + bar.appendChild(filterInp); + if(schoolChosenTop && creatableRoles().some(r=>r!=="super")){ + const add = h("button","am-btn am-primary", T("amNewUser")); + add.type = "button"; + add.addEventListener("click", ()=>{ selectedUserKey = "new"; syncSelection(); }); + bar.appendChild(add); + } + panel.appendChild(bar); + + /* master (tabel) + detail (bewerkpaneel) */ + const split = h("div","am-split"); + const master = h("div","am-master"); + const detail = h("div","am-detail"); + masterHost = master; detailHost = detail; + + /* openstaande aanmeldingen bovenaan: dit vraagt actie van de school */ + if(LINK_REQUESTS.length){ + master.appendChild(h("div","am-group", `${T("amLinkRequests")} (${LINK_REQUESTS.length})`)); + const rl = h("div","am-reqlist am-list"); + LINK_REQUESTS.forEach(r=>rl.appendChild(requestCells(r))); + master.appendChild(rl); + } + /* kolomkoppen boven de tabel */ + const thead = h("div","am-thead"); + ["amFieldName","amFieldRole","amFieldClass","amColStatus"].forEach(k=>thead.appendChild(h("span",null,T(k)))); + master.appendChild(thead); const list = h("div","am-list am-userlist"); const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")], ["parent", T("amParents")]]; @@ -986,18 +1186,16 @@ const us = USERS.filter(u=>u.role===role); if(!us.length) return; list.appendChild(h("div","am-group", `${label} (${us.length})`)); - us.forEach(u=>list.appendChild(userRow(u))); + us.forEach(u=>list.appendChild(userCells(u))); }); const pupils = USERS.filter(u=>u.role==="pupil"); - const schoolChosen = currentUser.role!=="super" || !!selSchool; - const creatable = creatableRoles(); - const canAddPupil = schoolChosen && creatable.includes("pupil"); + const schoolChosen = schoolChosenTop; /* leerlingen staan onder inklapbare groepskoppen (standaard dicht): zo blijft de lijst overzichtelijk bij grote scholen; zoeken kijkt altijd door alle groepen heen (zie applyUserFilter) */ - const pupilGroup = (key, label, us, formEl)=>{ + const pupilGroup = (key, label, us)=>{ const g = h("div","am-group am-group-toggle"); g.dataset.collapsible = "1"; g.dataset.gkey = key; @@ -1009,8 +1207,7 @@ applyUserFilter(panel); }); list.appendChild(g); - us.forEach(u=>list.appendChild(userRow(u))); - if(formEl) list.appendChild(formEl); + us.forEach(u=>list.appendChild(userCells(u))); }; /* per-klas indeling heeft alleen zin met één gekozen school (CLASSES is dan @@ -1018,29 +1215,22 @@ één groep - anders vallen leerlingen met een classId die niet in de (dan lege) CLASSES-lijst voorkomt onterecht helemaal weg */ if(currentUser.role==="teacher" || !schoolChosen){ - if(pupils.length || canAddPupil){ - pupilGroup("pupils", T("amPupils"), pupils, canAddPupil ? addForm("pupil") : null); - } + if(pupils.length) pupilGroup("pupils", T("amPupils"), pupils); }else{ CLASSES.forEach(c=>{ const us = pupils.filter(u=>u.classId===c.id); - if(!us.length && !canAddPupil) return; - pupilGroup("class:"+c.id, c.name, us, canAddPupil ? addForm("pupil", c.id) : null); + if(us.length) pupilGroup("class:"+c.id, c.name, us); }); const noClass = pupils.filter(u=>!u.classId); - if(noClass.length || canAddPupil){ - pupilGroup("class:none", T("amNoClass"), noClass, canAddPupil ? addForm("pupil") : null); - } + if(noClass.length) pupilGroup("class:none", T("amNoClass"), noClass); } - panel.appendChild(list); + master.appendChild(list); + split.appendChild(master); + split.appendChild(detail); + panel.appendChild(split); - /* staf-aanmaakformulieren - welke rollen deze gebruiker mag aanmaken (CREATABLE_ROLES). - "super" aanmaken staat in het Systeem-tabblad, niet hier (die rol hoort niet - bij één school). */ - if(schoolChosen){ - ["teacher","admin"].forEach(r=>{ if(creatable.includes(r)) panel.appendChild(addForm(r)); }); - } applyUserFilter(panel); + syncSelection(); /* selectie valideren + detailpaneel vullen */ } function switchAdminTab(tab){ @@ -1063,7 +1253,7 @@ SCHOOLS.forEach(s=>sel.appendChild(new Option(s.name, s.id))); sel.value = selSchool ?? ""; sel.setAttribute("aria-label", T("amPickSchool")); - sel.addEventListener("change", ()=>{ selSchool = sel.value; reload(); }); + sel.addEventListener("change", ()=>{ selSchool = sel.value; selectedUserKey = null; reload(); }); bar.appendChild(sel); return bar; } diff --git a/public/js/cms.js b/public/js/cms.js index 47bea0d..555f052 100644 --- a/public/js/cms.js +++ b/public/js/cms.js @@ -206,7 +206,7 @@ let CMS = {}; } registerSettingsSection({ - id:"inhoud", icon:"📢", group:"system", order:32, + id:"inhoud", icon:"📢", group:"system", order:31, label:()=>T("stInhoud"), sub:()=>T("stInhoudSub"), visible:()=>!!currentUser && can("content.manage"), activate(){ diff --git a/public/js/core.js b/public/js/core.js index f92ec3f..d097612 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.85-beta"; +const VERSION = "0.3.86-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -276,6 +276,9 @@ const I18N = { amNewCodeWarn:"Maakt een nieuwe koppelcode aan. Het huidige wachtwoord vervalt direct; de gebruiker moet opnieuw activeren.", amDeleteWarn:"Verwijdert dit account definitief, inclusief borden en woorden.", amResultPw:"Nieuw wachtwoord", amResultCode:"Nieuwe koppelcode", + amColStatus:"Status", amNeverLoggedIn:"nog niet ingelogd", + amDetailEmpty:"Selecteer links een gebruiker om te bewerken, of maak een nieuwe aan.", + amNewUser:"Nieuwe gebruiker", amRequestOne:"Aanmelding", amReqNew:"nieuw", amParents:"Ouders", amParentCode:"Ouder-code", amResultParentCode:"Ouder-code", amParentCodeWarn:"Maakt een eenmalige ouder-code aan; een eerdere code vervalt.", @@ -565,6 +568,9 @@ const I18N = { amNewCodeWarn:"Generates a new link code. The current password stops working immediately; the user must re-activate.", amDeleteWarn:"Deletes this account permanently, including boards and words.", amResultPw:"New password", amResultCode:"New link code", + amColStatus:"Status", amNeverLoggedIn:"not signed in yet", + amDetailEmpty:"Select a user on the left to edit, or create a new one.", + amNewUser:"New user", amRequestOne:"Enrolment", amReqNew:"new", amParents:"Parents", amParentCode:"Parent code", amResultParentCode:"Parent code", amParentCodeWarn:"Generates a one-time parent code; a previous code stops working.", -- 2.45.2 From ebe622588f87bf1b7bd2e8e7ba519c244a16d978 Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 17 Jul 2026 07:08:17 +0200 Subject: [PATCH 125/192] style: tekstknoppen en consistente secties in het beheer - emoji-sweep afgerond (v0.3.87-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Alle resterende emoji uit het beheer: kopieerknoppen zijn tekstknoppen ("kopieer"), wachtwoord-genereerknop heet "genereer", "alles kopiëren" zonder klembord-icoon, schoolrijen zonder 🏫, contextbalk-label is het woord "School", bevestiging is "zeker?" (de rode armed-stijl is het signaal), kopieer-feedback een net vinkje - Scholen-tabblad: "Bewerken"-tekstknoppen i.p.v. het ✏-potlood; zoek-placeholders zonder vergrootglas-emoji - CMS-widgetkeuze zonder emoji-prefix in de opties - Playwright-emoji-scan over alle vijf beheersecties (knoppen/labels/groepen schoon, ▸▾✕✓+ toegestaan) + volledige master-detail-regressie (19 checks) groen Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF --- VERSION | 2 +- public/js/admin.js | 21 +++++++++------------ public/js/cms.js | 2 +- public/js/core.js | 18 +++++++++--------- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/VERSION b/VERSION index 3336225..0b28cd5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.86-beta +0.3.87-beta diff --git a/public/js/admin.js b/public/js/admin.js index 2f396f0..7bee177 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -49,13 +49,12 @@ document.body.removeChild(ta); } const orig = btn.textContent; - btn.textContent = "✅"; + btn.textContent = "✓"; setTimeout(()=>{ btn.textContent = orig; }, 1200); } function copyBtn(text, cls){ - const b = h("button", cls || "am-copy", "📋"); + const b = h("button", cls || "am-btn", T("amCopy")); b.type = "button"; - b.title = T("amCopy"); b.addEventListener("click", e=>{ e.preventDefault(); if(text) copyText(b, text); }); return b; } @@ -92,7 +91,7 @@ b.addEventListener("click", async ()=>{ if(!timer){ b.classList.add("armed"); - b.textContent = "⚠ " + T("amSure"); + b.textContent = T("amSure"); timer = setTimeout(()=>{ timer = null; b.classList.remove("armed"); b.textContent = label; }, 4000); return; } @@ -527,9 +526,8 @@ pwInp.placeholder = T("amPwOptional"); pwInp.addEventListener("keydown", ev=>ev.stopPropagation()); pwWrap.appendChild(pwInp); - const gen = h("button","am-btn","🎲"); + const gen = h("button","am-btn", T("amGenerate")); gen.type = "button"; - gen.title = T("amGenerate"); gen.addEventListener("click", async ()=>{ try{ const { password } = await api("/admin/genpw"); @@ -677,7 +675,7 @@ return box; } const all = resetResults.entries.map(en=>`${en.displayName}\t${en.password}`).join("\n"); - const copyAll = h("button","am-btn","📋 "+T("amCopyAll")); + const copyAll = h("button","am-btn", T("amCopyAll")); copyAll.type = "button"; copyAll.addEventListener("click", e=>{ e.preventDefault(); copyText(copyAll, all); }); box.appendChild(copyAll); @@ -703,7 +701,7 @@ SCHOOLS.forEach(s=>{ const admins = SYS_ADMINS.filter(a=>Number(a.schoolId)===Number(s.id)); const row = h("div","am-row am-row-school"); - row.appendChild(h("span","am-name", "🏫 " + s.name)); + row.appendChild(h("span","am-name", s.name)); row.appendChild(h("span","am-role", `${admins.length} ${(admins.length===1 ? T("amAdminOne") : T("amAdmins")).toLowerCase()}`)); /* zelfde ✏-systeem als bij gebruikers: hernoemen en verwijderen @@ -729,9 +727,8 @@ }catch(e){ msg(e.message); } })); tools.appendChild(field("amFieldActions", acts)); - const edit = h("button","am-edit","✏"); + const edit = h("button","am-edit", T("amEdit")); edit.type = "button"; - edit.title = T("amEdit"); const key = "school:"+s.id; const sync = ()=>{ const open = openRows.has(key); @@ -747,7 +744,7 @@ row.appendChild(tools); list.appendChild(row); /* de schoolbeheerders van deze school, duidelijk onderscheiden van de - schoolrij (🛡-icoon, ingesprongen) maar met hetzelfde ✏-gereedschap */ + schoolrij (ingesprongen) maar met hetzelfde bewerk-gereedschap */ admins.forEach(a=>list.appendChild(userRow(a, { sub:true }))); }); panel.appendChild(list); @@ -1247,7 +1244,7 @@ function schoolContextBar(){ if(currentUser.role !== "super") return null; const bar = h("div","am-schoolbar"); - bar.appendChild(h("span","am-schoolbar-lbl","🏫")); + bar.appendChild(h("span","am-schoolbar-lbl", T("amFieldSchool"))); const sel = h("select","am-sel"); sel.appendChild(new Option(T("amAllSchools"), "")); SCHOOLS.forEach(s=>sel.appendChild(new Option(s.name, s.id))); diff --git a/public/js/cms.js b/public/js/cms.js index 555f052..b6023b2 100644 --- a/public/js/cms.js +++ b/public/js/cms.js @@ -151,7 +151,7 @@ let CMS = {}; const pick = document.createElement("select"); pick.className = "am-sel"; pick.appendChild(new Option(T("cmsWidgetPick"), "")); - REGISTRY.forEach(def=>pick.appendChild(new Option(def.icon + " " + T("wg_"+def.id), def.id))); + REGISTRY.forEach(def=>pick.appendChild(new Option(T("wg_"+def.id), def.id))); const editor = cmsEl("div"); editor.style.display = "none"; pick.addEventListener("change", ()=>{ diff --git a/public/js/core.js b/public/js/core.js index d097612..adf04fc 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.86-beta"; +const VERSION = "0.3.87-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -200,7 +200,7 @@ const I18N = { login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen", settingsTitle:"Instellingen", tabLogin:"Inloggen", tabLinkCode:"Koppelcode", busy:"Bezig…", stAccount:"Account", stPassword:"Wachtwoord", stSession:"Sessie", - stSearchPh:"🔎 Zoeken in instellingen…", stNoResults:"Niets gevonden.", + stSearchPh:"Zoeken in instellingen…", stNoResults:"Niets gevonden.", stOpenSection:"open dit onderdeel", stGroupPersonal:"Persoonlijk", stGroupSchool:"School", stGroupSystem:"Systeem", stAccountSub:"Je gegevens, wachtwoord en reservekopieën.", @@ -265,7 +265,7 @@ const I18N = { pupilEmpty:"Je leerkracht heeft nog niets voor je klaargezet.", pupilWatching:"meekijken", amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Systeem", - amPickSchoolHint:"Kies hierboven eerst een school (🏫).", amPickSchool:"Kies een school", + amPickSchoolHint:"Kies hierboven eerst een school.", amPickSchool:"Kies een school", amPupils:"Leerlingen", amPupilOne:"Leerling", amEdit:"Bewerken", amAdmins:"Schoolbeheerders", amAdminOne:"Schoolbeheerder", amClassAdd:"— voeg toe aan klas… —", amClassRemove:"Uit deze klas halen", @@ -276,7 +276,7 @@ const I18N = { amNewCodeWarn:"Maakt een nieuwe koppelcode aan. Het huidige wachtwoord vervalt direct; de gebruiker moet opnieuw activeren.", amDeleteWarn:"Verwijdert dit account definitief, inclusief borden en woorden.", amResultPw:"Nieuw wachtwoord", amResultCode:"Nieuwe koppelcode", - amColStatus:"Status", amNeverLoggedIn:"nog niet ingelogd", + amColStatus:"Status", amNeverLoggedIn:"nog niet ingelogd", amFieldSchool:"School", amDetailEmpty:"Selecteer links een gebruiker om te bewerken, of maak een nieuwe aan.", amNewUser:"Nieuwe gebruiker", amRequestOne:"Aanmelding", amReqNew:"nieuw", amParents:"Ouders", @@ -293,7 +293,7 @@ const I18N = { amNewSchool:"+ School", amNewClass:"+ Klas", amNewTeacher:"+ Groepsleiding", amNewAdmin:"+ Schoolbeheerder", amNewSuper:"+ Systeemmanager", amNewPupil:"+ Leerling", amUserPh:"gebruikersnaam…", amNamePh:"naam…", - amSearchPh:"🔎 zoek op naam…", + amSearchPh:"zoek op naam…", amCode:"koppelcode", amNewCode:"nieuwe code", amPw:"wachtwoord", amNoClass:"— geen klas —", amPending:"nog niet geactiveerd", amCouple:"koppel leerkracht", amAllSchools:"— alle scholen —", amSuperGroup:"Systeem", @@ -492,7 +492,7 @@ const I18N = { login:"Log in", loginBtn:"Log in", registerBtn:"Create account", logout:"Log out", settingsTitle:"Settings", tabLogin:"Log in", tabLinkCode:"Link code", busy:"Please wait…", stAccount:"Account", stPassword:"Password", stSession:"Session", - stSearchPh:"🔎 Search settings…", stNoResults:"Nothing found.", + stSearchPh:"Search settings…", stNoResults:"Nothing found.", stOpenSection:"open this section", stGroupPersonal:"Personal", stGroupSchool:"School", stGroupSystem:"System", stAccountSub:"Your details, password and backups.", @@ -557,7 +557,7 @@ const I18N = { pupilEmpty:"Your teacher hasn't set up anything for you yet.", pupilWatching:"watching", amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"System", - amPickSchoolHint:"First pick a school above (🏫).", amPickSchool:"Pick a school", + amPickSchoolHint:"First pick a school above.", amPickSchool:"Pick a school", amPupils:"Pupils", amPupilOne:"Pupil", amEdit:"Edit", amAdmins:"School admins", amAdminOne:"School admin", amClassAdd:"— add to class… —", amClassRemove:"Remove from this class", @@ -568,7 +568,7 @@ const I18N = { amNewCodeWarn:"Generates a new link code. The current password stops working immediately; the user must re-activate.", amDeleteWarn:"Deletes this account permanently, including boards and words.", amResultPw:"New password", amResultCode:"New link code", - amColStatus:"Status", amNeverLoggedIn:"not signed in yet", + amColStatus:"Status", amNeverLoggedIn:"not signed in yet", amFieldSchool:"School", amDetailEmpty:"Select a user on the left to edit, or create a new one.", amNewUser:"New user", amRequestOne:"Enrolment", amReqNew:"new", amParents:"Parents", @@ -585,7 +585,7 @@ const I18N = { amNewSchool:"+ School", amNewClass:"+ Class", amNewTeacher:"+ Group leader", amNewAdmin:"+ School admin", amNewSuper:"+ System manager", amNewPupil:"+ Pupil", amUserPh:"username…", amNamePh:"name…", - amSearchPh:"🔎 search by name…", + amSearchPh:"search by name…", amCode:"link code", amNewCode:"new code", amPw:"password", amNoClass:"— no class —", amPending:"not yet activated", amCouple:"link teacher", amAllSchools:"— all schools —", amSuperGroup:"System", -- 2.45.2 From c3492ab2ca943e209998e215d12f4c6c88514f83 Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 17 Jul 2026 14:20:41 +0200 Subject: [PATCH 126/192] feat: Opslag per school met groepskaart-component (v0.3.88-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nieuw herbruikbaar groepskaart-component (amGroupCard): één kader om items die bij elkaar horen - koprij met grijs icoon, titel, samenvatting-als-uitklaptoggle en Bewerken; compacte subrijen; open-status overleeft re-renders (injecteerbare Set zodat ook andere modules hem gebruiken) - Opslag-tab volledig herbouwd: eerst één school kiezen (of "Systeem (zonder school)" voor systeemmanagers/ouders/losse accounts) i.p.v. alle scholen én alle gebruikers plat met altijd-open editors - Na keuze één kaart: schoolnaam + quota-samenvatting in de kop (schoolquota bewerken klapt uit), daaronder alleen de gebruikers van díe school als compacte regels; bewerkvelden pas na "Bewerken" per rij; zoeken binnen de school - "Standaard"-optie benoemt nu de echte rolstandaard ("Standaard (200 MB)") via defaultBytes - Mobiel: beheer-rijen stapelen niet meer verticaal (kaartkop en subrijen wrappen netjes; subrij ± twee compacte regels i.p.v. >100px) - Geverifieerd in headless Chromium (13 checks, o.a. dat schoolkeuze en open bewerkrij een her-render overleven - module-state) + screenshots 390px/desktop Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF --- VERSION | 2 +- public/css/teach.css | 39 ++++++++++++++++ public/js/admin.js | 57 +++++++++++++++++++++++ public/js/core.js | 6 ++- public/js/image-catalog.js | 92 +++++++++++++++++++++++++++++++------- 5 files changed, 178 insertions(+), 18 deletions(-) diff --git a/VERSION b/VERSION index 0b28cd5..e510274 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.87-beta +0.3.88-beta diff --git a/public/css/teach.css b/public/css/teach.css index b5adbbe..530daea 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -2004,3 +2004,42 @@ body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000 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:none; font-size:12px; color:var(--muted);} +#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;} +#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;} +} diff --git a/public/js/admin.js b/public/js/admin.js index 7bee177..c859c1a 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -116,6 +116,63 @@ return badge; } + /* ---- groepskaart: één kader om wat bij elkaar hoort ------------------------ + Kop (grijs icoon + titel + samenvatting-als-toggle + Bewerken) met daaronder + optioneel uitklapbare compacte subrijen en/of een uitklapbaar bewerkblok. + De open-status leeft in een Set die re-renders overleeft; andere modules + (bv. de Opslag-tab in image-catalog.js) geven hun eigen Set mee. */ + function groupCard(opts){ + const openSet = opts.openSet || openGroups; + const editSet = opts.editSet || openRows; + 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. Per rol alleen diff --git a/public/js/core.js b/public/js/core.js index adf04fc..df43290 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.87-beta"; +const VERSION = "0.3.88-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -190,6 +190,8 @@ const I18N = { imgLoading:"Afbeeldingen laden…", imgSaved:"Afbeelding opgeslagen ✓", imgDeleted:"Afbeelding verwijderd ✓", imgThemeAdded:"Thema toegevoegd ✓", imgStorageAdmin:"Opslaglimieten afbeeldingen", imgQuotaUsage:"{used} gebruikt · {limit}", imgQuotaDefault:"Standaard", imgQuotaCustom:"Aangepast", + imgPickSchool:"— kies een school —", imgSystemScope:"Systeem (zonder school)", + imgQuotaDefaultOf:"Standaard ({limit})", imgQuotaMb:"limiet in MB", imgQuotaInvalid:"Vul een geldige limiet in.", imgSchools:"Scholen", imgUsers:"Gebruikers", imgSearchUsers:"Zoek gebruiker…", cats:["Dieren","Eten","Natuur","Vervoer","Spullen","Lichaam"], @@ -482,6 +484,8 @@ const I18N = { imgLoading:"Loading images…", imgSaved:"Image saved ✓", imgDeleted:"Image deleted ✓", imgThemeAdded:"Theme added ✓", imgStorageAdmin:"Image storage limits", imgQuotaUsage:"{used} used · {limit}", imgQuotaDefault:"Default", imgQuotaCustom:"Custom", + imgPickSchool:"— pick a school —", imgSystemScope:"System (no school)", + imgQuotaDefaultOf:"Default ({limit})", imgQuotaMb:"limit in MB", imgQuotaInvalid:"Enter a valid limit.", imgSchools:"Schools", imgUsers:"Users", imgSearchUsers:"Search user…", cats:["Animals","Food","Nature","Transport","Things","Body"], diff --git a/public/js/image-catalog.js b/public/js/image-catalog.js index 099311a..f5ca6d6 100644 --- a/public/js/image-catalog.js +++ b/public/js/image-catalog.js @@ -344,19 +344,28 @@ async function openImageCatalog(onSelect){ /* 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 heading=document.createElement("div");heading.className="am-h3";heading.textContent=T("imgStorageAdmin"); - const status=document.createElement("div");status.className="am-msg";panel.append(heading,status); + 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); - const editor=(item,type)=>{ - const row=document.createElement("div");row.className="am-row img-quota-row"; - const info=document.createElement("div");info.className="am-name";info.textContent=item.name||item.displayName; - const use=document.createElement("span");use.className="am-role";use.textContent=T("imgQuotaUsage").replace("{used}",imageBytes(item.usedBytes)).replace("{limit}",fmt(item.limitBytes)); - info.append(document.createElement("br"),use); + /* 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"; - [["default","imgQuotaDefault"],["custom","imgQuotaCustom"],["unlimited","imgUnlimited"]].forEach(([v,k])=>mode.add(new Option(T(k),v))); + 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)):""; @@ -368,18 +377,69 @@ function renderImageStoragePanel(panel){ try{await api("/images/admin/"+type+"/"+item.id+"/quota",{method:"PATCH",body:{limitBytes}});renderImageStoragePanel(panel)} catch(err){status.textContent=err.message} }; - row.append(info,mode,amount,save);return row; + 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=""; - const schoolTitle=document.createElement("div");schoolTitle.className="am-group";schoolTitle.textContent=T("imgSchools"); - host.append(schoolTitle,...data.schools.map(s=>editor({...s,name:s.name},"schools"))); - const userTitle=document.createElement("div");userTitle.className="am-group";userTitle.textContent=T("imgUsers"); - const filter=document.createElement("input");filter.className="am-inp";filter.type="search";filter.placeholder=T("imgSearchUsers"); - const users=document.createElement("div");users.className="img-quota-users"; - const draw=()=>{const term=filter.value.trim().toLowerCase();users.innerHTML="";data.users.filter(u=>(u.displayName+" "+u.username).toLowerCase().includes(term)).forEach(u=>users.append(editor(u,"users")))}; - filter.oninput=draw;host.append(userTitle,filter,users);draw(); + /* 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} })(); } -- 2.45.2 From 08618ba0b3f0b745d528d4c2266d4f9f76f9ff7a Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 17 Jul 2026 14:27:19 +0200 Subject: [PATCH 127/192] feat: Scholen, Klassen en Toewijzingen als groepskaarten (v0.3.89-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scholen: één gezamenlijk kader per school - getinte kop met grijs schoolicoon, naam en de beheerder-teller als uitklaptoggle; beheerders standaard verborgen en als compacte subrijen (36px) met status-glyph (● actief / ○ nog niet geactiveerd, uitleg via title); hernoemen/verwijderen klapt uit binnen hetzelfde kader - geen verwarring meer tussen scholen en beheerders - Klassen: kaart per klas met leerlingtal in de kop; de reset-/verwijderacties verborgen tot Bewerken - Toewijzingen: kaart per klas met de huidige stand compact in de kop; bord-/widget-/moduskeuze klapt uit via Bewerken - Het status-glyph en de kaartkop-wrap zijn onderdeel van het gedeelde groepskaart-component; bewust klein grijs schoolicoon terug (functioneel onderscheid, emoji-scan-uitzondering) - Geverifieerd in headless Chromium (14 checks) + screenshots 390px/desktop Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF --- VERSION | 2 +- public/css/teach.css | 2 +- public/js/admin.js | 91 ++++++++++++++++++++++---------------------- public/js/core.js | 6 +-- 4 files changed, 51 insertions(+), 50 deletions(-) diff --git a/VERSION b/VERSION index e510274..76418e2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.88-beta +0.3.89-beta diff --git a/public/css/teach.css b/public/css/teach.css index 530daea..57ded36 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -2017,7 +2017,7 @@ body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000 #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:none; font-size:12px; color:var(--muted);} +#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;} diff --git a/public/js/admin.js b/public/js/admin.js index c859c1a..42a2660 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -478,9 +478,18 @@ /* inline-variant voor het Systeem-tabblad (schoolbeheerder-subrijen): naam + rol met een "Bewerken"-tekstknop die het formulier uitklapt */ function userRow(u, opts){ - const row = h("div","am-row" + (opts && opts.sub ? " am-row-sub" : "")); + const compact = !!(opts && opts.compact); + const row = h("div","am-row" + (opts && opts.sub ? " am-row-sub" : "") + (compact ? " am-card-sub" : "")); row.appendChild(h("span","am-name", u.displayName + (u.displayName!==u.username ? ` (${u.username})` : ""))); - row.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : ""))); + if(compact){ + /* compacte kaart-subrij: status als glyph met uitleg-title i.p.v. tekst */ + row.appendChild(h("span","am-role", T(roleKey(u.role)))); + const glyph = h("span","am-glyph" + (u.pending ? "" : " ok"), u.pending ? "○" : "●"); + glyph.title = u.pending ? T("amPending") : T("amStatusActive"); + row.appendChild(glyph); + }else{ + row.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : ""))); + } const tools = h("div","am-row-tools"); const badge = resultBadge(u); if(badge) tools.appendChild(badge); @@ -754,16 +763,10 @@ achter een bewuste extra stap (+-knop klapt het formulier uit, zoals bij gebruikers) en bevestigt daarna nog met de naam erin. */ function renderScholenPanel(panel){ - const list = h("div","am-list"); SCHOOLS.forEach(s=>{ const admins = SYS_ADMINS.filter(a=>Number(a.schoolId)===Number(s.id)); - const row = h("div","am-row am-row-school"); - row.appendChild(h("span","am-name", s.name)); - row.appendChild(h("span","am-role", - `${admins.length} ${(admins.length===1 ? T("amAdminOne") : T("amAdmins")).toLowerCase()}`)); - /* zelfde ✏-systeem als bij gebruikers: hernoemen en verwijderen - zitten achter het gereedschapsblok, de rij zelf blijft rustig */ - const tools = h("div","am-row-tools"); + /* bewerk-gereedschap van de school zelf (hernoemen/verwijderen) */ + const tools = h("div","am-tools"); const nameInp = h("input","am-inp"); nameInp.value = s.name; nameInp.maxLength = 80; @@ -784,27 +787,17 @@ }catch(e){ msg(e.message); } })); tools.appendChild(field("amFieldActions", acts)); - const edit = h("button","am-edit", T("amEdit")); - edit.type = "button"; - const key = "school:"+s.id; - const sync = ()=>{ - const open = openRows.has(key); - tools.style.display = open ? "" : "none"; - edit.classList.toggle("on", open); - }; - edit.addEventListener("click", ()=>{ - if(openRows.has(key)) openRows.delete(key); else openRows.add(key); - sync(); - }); - sync(); - row.appendChild(edit); - row.appendChild(tools); - list.appendChild(row); - /* de schoolbeheerders van deze school, duidelijk onderscheiden van de - schoolrij (ingesprongen) maar met hetzelfde bewerk-gereedschap */ - admins.forEach(a=>list.appendChild(userRow(a, { sub:true }))); + /* één kader om de school en haar beheerders; beheerders standaard + ingeklapt achter de teller, als compacte subrijen */ + panel.appendChild(groupCard({ + icon:"🏫", + title:s.name, + summary:`${admins.length} ${(admins.length===1 ? T("amAdminOne") : T("amAdmins")).toLowerCase()}`, + key:"schooladmins:"+s.id, + edit:{ key:"school:"+s.id, tools }, + subs: admins.map(a=>userRow(a, { sub:true, compact:true })), + })); }); - panel.appendChild(list); const wrap = h("div","am-add-wrap"); const toggle = h("button","tbtn ghost", T("amNewSchool")); @@ -864,12 +857,9 @@ const canResetPw = can("schools.resetPasswords"); /* klassen als volwaardige rijen (zelfde maat als de gebruikersvakken), met de gevaarlijke acties als aparte, bevestigde knoppen */ - const list = h("div","am-list"); CLASSES.forEach(c=>{ - const row = h("div","am-row"); const pupilCount = USERS.filter(u=>u.role==="pupil" && Number(u.classId)===Number(c.id)).length; - row.appendChild(h("span","am-name", c.name)); - row.appendChild(h("span","am-role", `${pupilCount} ${(pupilCount===1 ? T("amPupilOne") : T("amPupils")).toLowerCase()}`)); + let edit = null; if(canResetPw){ const acts = h("div","am-actions"); acts.appendChild(dangerBtn(T("amResetClass"), "amConfirmReset", async ()=>{ @@ -884,11 +874,16 @@ try{ await api("/admin/classes/"+c.id, {method:"DELETE"}); reload(); } catch(e){ msg(e.message); } })); - row.appendChild(acts); + const tools = h("div","am-tools"); + tools.appendChild(field("amFieldActions", acts)); + edit = { key:"klas:"+c.id, tools }; } - list.appendChild(row); + panel.appendChild(groupCard({ + title:c.name, + summary:`${pupilCount} ${(pupilCount===1 ? T("amPupilOne") : T("amPupils")).toLowerCase()}`, + edit, + })); }); - panel.appendChild(list); if(canResetPw){ const f = h("div","am-add"); const inp = h("input","am-inp"); @@ -947,8 +942,6 @@ return s; } function assignmentRow(target, boardOptions, current){ - const row = h("div","am-row"); - row.appendChild(h("span","am-name", target.label)); const sel = h("select","am-sel"); sel.appendChild(new Option(T("amNoAssignment"), "")); boardOptions.forEach(b=>sel.appendChild(new Option(b.label, b.id))); @@ -971,10 +964,6 @@ sel.addEventListener("change", syncWidgetSel); widgetSel.addEventListener("change", syncModeSel); const info = h("span","am-code", describeCurrent(current)); - row.appendChild(sel); - row.appendChild(widgetSel); - row.appendChild(modeSel); - row.appendChild(info); syncWidgetSel(); const apply = h("button","am-btn", T("amAssign")); apply.type = "button"; @@ -994,8 +983,20 @@ } }catch(e){ msg(e.message); } }); - row.appendChild(apply); - return row; + /* kop toont compact de huidige stand; de keuzes klappen uit via Bewerken */ + const tools = h("div","am-tools"); + const inline = h("div","am-inline"); + inline.appendChild(sel); + inline.appendChild(widgetSel); + inline.appendChild(modeSel); + inline.appendChild(apply); + tools.appendChild(inline); + tools.appendChild(info); + return groupCard({ + title: target.label, + summary: describeCurrent(current) || T("amNoAssignment"), + edit: { key: "assign:"+target.type+":"+target.id, tools }, + }); } /* vult een + + + `; + 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/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/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/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/test/widgets.test.js b/test/widgets.test.js index 5177737..e7dcc50 100644 --- a/test/widgets.test.js +++ b/test/widgets.test.js @@ -344,6 +344,10 @@ test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px- light:'.tl-house', mood:'.mood', poll:'.poll', + noise:'.nm-gauge', + feelings:'.fz-zones', + breathe:'.bre-circle', + compliments:'.cpl-card', media:'.media-bar', }; assert.deepEqual(registered,Object.keys(coverage).sort(),'ieder geregistreerd widgettype staat in de mobiele audit'); -- 2.45.2 From 8b57922307a4d908721703646e82e3de49066f5b Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 01:06:39 +0200 Subject: [PATCH 161/192] fix: schoolbeheerder zichtbaar als groepsleiding + A+-knop verspringt niet meer (v0.4.22-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Een schoolbeheerder (of systeemmanager) die aan een klas gekoppeld werd, kwam daar niet zichtbaar te staan: de klas-detailweergave en de staf-telling op de klastegel filterden op role==="teacher", terwijl de server al sinds v0.3.38 elke stafrol (teacher/admin/super) toestaat in class_teachers. Puur een weergavebug op de client - de koppeling zelf werkte al goed. Nieuwe isClassStaff()-helper matcht nu exact de serverkant se toegestane rollenset. - De "+" in de A+-zoomknop (toolbar én het per-widget contentzoom-knopje) gebruikte de CJK-volle-breedte "+" (U+FF0B) naast de gewone letter "A" - dat teken valt vermoedelijk terug op een ander lettertype dan Quicksand, waardoor de relatieve positie t.o.v. de "A" verspringt zodra document.body.style.zoom verandert. Vervangen door de gewone ASCII "+" (net als de A−-knop al een normaal minteken gebruikt, niet de volle- breedte variant). --- VERSION | 2 +- public/index.html | 2 +- public/js/admin.js | 10 ++++++++-- public/js/board.js | 4 ++-- public/js/core.js | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/VERSION b/VERSION index d692748..1f03f53 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.21-beta +0.4.22-beta diff --git a/public/index.html b/public/index.html index b9f1d0a..fa80deb 100644 --- a/public/index.html +++ b/public/index.html @@ -61,7 +61,7 @@ - +
diff --git a/public/js/admin.js b/public/js/admin.js index 965dbe6..56e61df 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -48,6 +48,12 @@ const creatableRoles = () => [...new Set([currentUser.role, ...(currentUser.extraRoles||[])].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); @@ -1084,7 +1090,7 @@ onClick: ()=>{ navLevel = { type:"flat", kind:"requests" }; render(); } })); } CLASSES.forEach(c=>{ - const staff = USERS.filter(u=>u.role==="teacher" && (u.classIds||[]).some(id=>Number(id)===Number(c.id))).length; + const staff = USERS.filter(u=>isClassStaff(u) && (u.classIds||[]).some(id=>Number(id)===Number(c.id))).length; const cp = pupils.filter(u=>u.classId===c.id).length; grid.appendChild(tile({ label:c.name, stats:[{text:`🧑‍🏫 ${staff}`},{text:`🧒 ${cp}`}], onClick: ()=>{ navLevel = { type:"class", id:c.id }; kebabOpen = false; render(); } })); @@ -1125,7 +1131,7 @@ panel.appendChild(titleRow); if(kebabOpen && canManageClasses) panel.appendChild(classActionsPanel(c, otherSchools)); - const staff = USERS.filter(u=>u.role==="teacher" && (u.classIds||[]).some(id=>Number(id)===Number(c.id))); + const staff = USERS.filter(u=>isClassStaff(u) && (u.classIds||[]).some(id=>Number(id)===Number(c.id))); panel.appendChild(h("p","am-section-label", T("roleTeacher"))); panel.appendChild(peopleList(staff)); if(creatableRoles().includes("teacher")) panel.appendChild(addForm("teacher", c.id)); diff --git a/public/js/board.js b/public/js/board.js index f124323..9a96957 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -120,7 +120,7 @@ function makeWidget(def, saved){ const lockBtn = document.createElement("button"); lockBtn.className="wmin wlock"; lockBtn.textContent="🔓"; head.appendChild(lockBtn); const previewBtn = document.createElement("button"); previewBtn.className="wmin wpreview"; previewBtn.textContent="◉"; head.appendChild(previewBtn); const zBtn = document.createElement("button"); - zBtn.className = "wmin wz"; zBtn.textContent = "A+"; + zBtn.className = "wmin wz"; zBtn.textContent = "A+"; head.appendChild(zBtn); const minBtn = document.createElement("button"); minBtn.className = "wmin"; minBtn.textContent = "–"; @@ -194,7 +194,7 @@ function makeWidget(def, saved){ function applyWZ(){ body.style.zoom = wz; win.dataset.wz = wz; - zBtn.textContent = wz===1 ? "A+" : Math.round(wz*100)+"%"; + zBtn.textContent = wz===1 ? "A+" : Math.round(wz*100)+"%"; zBtn.classList.toggle("on", wz!==1); zBtn.title = T("wZoom"); } diff --git a/public/js/core.js b/public/js/core.js index 216ffb3..a3b7c15 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.21-beta"; +const VERSION = "0.4.22-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; -- 2.45.2 From 24d89b8bb51d14dfde19d2bdf86f89871a8d94d3 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 07:34:25 +0200 Subject: [PATCH 162/192] feat: eigen personage (avatar) voor leerlingen, ouders en staf (v0.4.23-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nieuwe migratie db/018_user_avatar.sql: kolom avatar_data (jsonb) op users. - publicUser() geeft avatar overal automatisch mee (login, /auth/me, /admin/users) - bestaande school/klas-rechten regelen vanzelf wie welke personages ziet, geen nieuwe permissie-sleutel nodig. - Nieuwe route PUT /me/avatar (open voor elke ingelogde rol, zelfde patroon als /me/data): valideert soort/kleuren/haar/accessoires, slaat de eigen rij op. - Nieuwe gedeelde module public/js/avatar.js: personage samenstellen uit soort (dino/monster/dier - elk met een vast eigen lichaamsdeel, ook de gevraagde dinostekels), lichaams- en haarkleur, haarstijl en hoofd-/ lichaamsaccessoires; gedeelde editor-UI voor zowel de instellingen-sectie "Mijn personage" als de nieuwe widget. - Instellingen-sectie "Mijn personage" (cms.js-patroon) - de leerling-toegang, want widgets van categorie "class" zijn niet toewijsbaar in het afgeschermde leerling-speelscherm. - Widget 🦖 toegevoegd aan het bord (categorie "class") voor staf en volledige-whiteboard-gebruikers. - School-navigator (admin.js): leerkracht/beheerder ziet nu het personage van elk klaslid in de personenlijst i.p.v. altijd de initialen-cirkel. - Voorbereiding gerelateerd roadmappunt: math.js toont het eigen personage kort naast "Goed!" als aanmoediging (pilot in één widget, geen nieuw feedbacktype); zonder ingesteld personage blijft het gedrag ongewijzigd. - Vertalingen (nl+en) voor de nieuwe widget, instellingen-sectie en editor. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- db/018_user_avatar.sql | 6 ++ public/css/teach.css | 56 ++++++++++ public/index.html | 1 + public/js/admin.js | 5 +- public/js/avatar.js | 216 ++++++++++++++++++++++++++++++++++++++ public/js/board.js | 1 + public/js/core.js | 20 +++- public/js/widgets/math.js | 17 ++- src/api.js | 25 +++++ src/auth.js | 1 + test/widgets.test.js | 1 + 12 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 db/018_user_avatar.sql create mode 100644 public/js/avatar.js diff --git a/VERSION b/VERSION index 1f03f53..b81f59b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.22-beta +0.4.23-beta \ No newline at end of file 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/public/css/teach.css b/public/css/teach.css index 199b49d..fb11767 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1672,6 +1672,62 @@ body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000; .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): gelaagde div's + CSS custom properties, + zelfde aanpak als de taart hierboven (--cake/--icing), geen SVG/afbeeldingen. ==== */ +.avatar-face{position:relative; display:inline-block; flex:none;} +.avatar-face.sm{width:24px; height:24px;} +.avatar-face.md{width:44px; height:44px;} +.avatar-face.lg{width:180px; height:180px;} +.av-body{position:absolute; left:20%; right:20%; bottom:2%; top:52%; border-radius:45% 45% 40% 40%; background:var(--av-body); z-index:1;} +.av-head{position:absolute; left:8%; right:8%; top:4%; height:56%; border-radius:50%; background:var(--av-body); z-index:3; box-shadow:inset 0 -6px 10px rgba(0,0,0,.12);} +.av-part-head{position:absolute; z-index:6; display:none;} +.av-species-monster .av-part-head{display:block; left:28%; right:28%; top:0; height:14%;} +.av-species-monster .av-part-head::before,.av-species-monster .av-part-head::after{content:""; position:absolute; width:16%; height:100%; background:var(--av-body); filter:brightness(.85); clip-path:polygon(50% 0,0% 100%,100% 100%);} +.av-species-monster .av-part-head::before{left:6%;} +.av-species-monster .av-part-head::after{right:6%;} +.av-species-animal .av-part-head{display:block; left:0; right:0; top:-2%; height:22%;} +.av-species-animal .av-part-head::before,.av-species-animal .av-part-head::after{content:""; position:absolute; width:22%; height:100%; border-radius:50%; background:var(--av-body);} +.av-species-animal .av-part-head::before{left:2%;} +.av-species-animal .av-part-head::after{right:2%;} +.av-part-body{position:absolute; z-index:6; display:none;} +.av-species-dino .av-part-body{display:block; left:26%; right:26%; top:42%; height:22%;} +.av-species-dino .av-part-body::before,.av-species-dino .av-part-body::after{content:""; position:absolute; width:20%; height:100%; background:var(--av-body); filter:brightness(.72); clip-path:polygon(50% 0%,0% 100%,100% 100%);} +.av-species-dino .av-part-body::before{left:8%;} +.av-species-dino .av-part-body::after{right:8%;} +.av-species-animal .av-part-body{display:block; right:2%; bottom:8%; width:16%; height:30%; border-radius:50% 50% 50% 10%; background:var(--av-body); transform:rotate(20deg); filter:brightness(.9);} +.av-hair{position:absolute; z-index:4; display:none; background:var(--av-hair);} +.av-hair-spiky{display:block; left:14%; right:14%; top:-2%; height:22%; clip-path:polygon(0% 100%,10% 20%,25% 90%,35% 10%,50% 90%,65% 10%,75% 90%,90% 20%,100% 100%);} +.av-hair-curly{display:block; left:6%; right:6%; top:-4%; height:26%; border-radius:50% 50% 40% 40%;} +.av-hair-ponytail{display:block; left:10%; right:10%; top:-2%; height:20%; border-radius:50% 50% 30% 30%;} +.av-hair-ponytail::after{content:""; position:absolute; right:-6%; top:40%; width:20%; height:45%; border-radius:0 50% 50% 50%; background:var(--av-hair);} +.av-hair-mohawk{display:block; left:42%; right:42%; top:-8%; height:30%; border-radius:40% 40% 0 0;} +.av-hair-none{display:none;} +.av-acc-head{position:absolute; z-index:5; display:none;} +.av-acc-head-cap{display:block; left:6%; right:6%; top:2%; height:20%; border-radius:50% 50% 0 0; background:var(--blue);} +.av-acc-head-crown{display:block; left:14%; right:14%; top:-4%; height:18%; background:var(--yellow); clip-path:polygon(0% 100%,0% 40%,20% 70%,35% 10%,50% 60%,65% 10%,80% 70%,100% 40%,100% 100%);} +.av-acc-head-glasses{display:block; left:14%; right:14%; top:32%; height:16%;} +.av-acc-head-glasses::before,.av-acc-head-glasses::after{content:""; position:absolute; width:38%; height:100%; border-radius:50%; border:3px solid var(--ink); background:rgba(255,255,255,.35);} +.av-acc-head-glasses::before{left:0;} +.av-acc-head-glasses::after{right:0;} +.av-acc-head-bow{display:block; right:8%; top:8%; width:18%; height:14%; background:var(--pink); clip-path:polygon(0% 0%,50% 40%,100% 0%,100% 100%,50% 60%,0% 100%);} +.av-acc-body{position:absolute; z-index:4; display:none;} +.av-acc-body-scarf{display:block; left:18%; right:18%; top:48%; height:10%; border-radius:8px; background:var(--red);} +.av-acc-body-cape{display:block; left:10%; right:10%; top:54%; bottom:0; z-index:0; border-radius:0 0 40% 40%; background:var(--purple); opacity:.9;} +.av-acc-body-badge{display:block; left:32%; width:16%; top:62%; height:12%; border-radius:50%; background:var(--yellow); border:2px solid #fff;} +.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;} diff --git a/public/index.html b/public/index.html index fa80deb..52a4f29 100644 --- a/public/index.html +++ b/public/index.html @@ -18,6 +18,7 @@ + diff --git a/public/js/admin.js b/public/js/admin.js index 56e61df..877e828 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -778,7 +778,10 @@ /* rij voor één persoon: avatar, naam, rol/status - tikken opent de sheet */ function personRow(u){ const row = h("div","am-row am-person"); - row.appendChild(avatar(u.displayName)); + /* eigen personage vervangt de initialen-cirkel zodra iemand er één heeft + ingesteld (Instellingen > "Mijn personage") - zo ziet een leerkracht de + personages van de klas via het scherm dat toch al gebruikt wordt. */ + row.appendChild(u.avatar ? renderAvatarFace(u.avatar, "sm") : avatar(u.displayName)); const info = h("div","am-person-info"); info.appendChild(h("span","am-name", u.displayName)); info.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : ""))); diff --git a/public/js/avatar.js b/public/js/avatar.js new file mode 100644 index 0000000..58cebde --- /dev/null +++ b/public/js/avatar.js @@ -0,0 +1,216 @@ +/* teach - personage (avatar): eigen figuur samenstellen (soort, kleuren, haar, + accessoires), gedeeld tussen de Instellingen-sectie "Mijn personage" en het + avatar-widget. Gelaagde div's + CSS custom properties, zelfde aanpak als de + verjaardagstaart-widget (--cake/--icing) - geen SVG/afbeeldingen. */ +"use strict"; +const AVATAR_SPECIES = ["dino", "monster", "animal"]; +const AVATAR_HAIR = ["none", "spiky", "curly", "ponytail", "mohawk"]; +const AVATAR_HEAD_ACC = ["none", "cap", "crown", "glasses", "bow"]; +const AVATAR_BODY_ACC = ["none", "scarf", "cape", "badge"]; +const AVATAR_DEFAULT = Object.freeze({ + species: "dino", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29", + accessoryHead: "none", accessoryBody: "none", +}); + +function avatarSanitize(config){ + const c = config || {}; + return { + species: AVATAR_SPECIES.includes(c.species) ? c.species : AVATAR_DEFAULT.species, + bodyColor: /^#[0-9a-fA-F]{6}$/.test(c.bodyColor||"") ? c.bodyColor : AVATAR_DEFAULT.bodyColor, + hairStyle: AVATAR_HAIR.includes(c.hairStyle) ? c.hairStyle : AVATAR_DEFAULT.hairStyle, + hairColor: /^#[0-9a-fA-F]{6}$/.test(c.hairColor||"") ? c.hairColor : AVATAR_DEFAULT.hairColor, + accessoryHead: AVATAR_HEAD_ACC.includes(c.accessoryHead) ? c.accessoryHead : AVATAR_DEFAULT.accessoryHead, + accessoryBody: AVATAR_BODY_ACC.includes(c.accessoryBody) ? c.accessoryBody : AVATAR_DEFAULT.accessoryBody, + }; +} + +/* bouwt de gelaagde weergave van een personage; config==null → neutrale + standaardweergave (nooit een lege/kapotte weergave). */ +function renderAvatarFace(config, size){ + const c = avatarSanitize(config); + const face = document.createElement("div"); + face.className = "avatar-face " + (size || "md") + " av-species-" + c.species; + face.style.setProperty("--av-body", c.bodyColor); + face.style.setProperty("--av-hair", c.hairColor); + face.setAttribute("role", "img"); + face.setAttribute("aria-label", T("avatarAlt")); + const partHead = document.createElement("div"); partHead.className = "av-part-head"; + const hair = document.createElement("div"); hair.className = "av-hair av-hair-" + c.hairStyle; + const head = document.createElement("div"); head.className = "av-head"; + const accHead = document.createElement("div"); accHead.className = "av-acc-head av-acc-head-" + c.accessoryHead; + const partBody = document.createElement("div"); partBody.className = "av-part-body"; + const body = document.createElement("div"); body.className = "av-body"; + const accBody = document.createElement("div"); accBody.className = "av-acc-body av-acc-body-" + c.accessoryBody; + face.append(partBody, body, accBody, partHead, head, accHead, hair); + return face; +} + +/* bouwt de interactieve kiezer + live voorbeeld; roept onChange(config) aan + bij elke wijziging. Hergebruikt door zowel de instellingen-sectie als het + widget - één UI, geen duplicatie. */ +function buildAvatarEditor(container, initialConfig, onChange){ + let config = avatarSanitize(initialConfig); + container.innerHTML = ""; + const editor = document.createElement("div"); + editor.className = "avatar-editor"; + const preview = document.createElement("div"); + preview.className = "avatar-preview"; + const controls = document.createElement("div"); + controls.className = "avatar-controls"; + editor.append(preview, controls); + container.appendChild(editor); + + const speciesRow = document.createElement("div"); + speciesRow.className = "avatar-species-row"; + AVATAR_SPECIES.forEach(id=>{ + const b = document.createElement("button"); + b.type = "button"; + b.dataset.species = id; + b.addEventListener("click", ()=>{ config.species = id; update(); }); + speciesRow.appendChild(b); + }); + controls.appendChild(speciesRow); + + const row1 = document.createElement("div"); row1.className = "avatar-row"; + const bodyColorField = document.createElement("label"); + const bodyColorSpan = document.createElement("span"); + const bodyColorInput = document.createElement("input"); bodyColorInput.type = "color"; + bodyColorInput.addEventListener("input", ()=>{ config.bodyColor = bodyColorInput.value; update(); }); + bodyColorField.append(bodyColorSpan, bodyColorInput); + const hairColorField = document.createElement("label"); + const hairColorSpan = document.createElement("span"); + const hairColorInput = document.createElement("input"); hairColorInput.type = "color"; + hairColorInput.addEventListener("input", ()=>{ config.hairColor = hairColorInput.value; update(); }); + hairColorField.append(hairColorSpan, hairColorInput); + row1.append(bodyColorField, hairColorField); + controls.appendChild(row1); + + const row2 = document.createElement("div"); row2.className = "avatar-row"; + const hairField = document.createElement("label"); + const hairSpan = document.createElement("span"); + const hairSel = document.createElement("select"); + AVATAR_HAIR.forEach(id=>hairSel.appendChild(new Option("", id))); + hairSel.addEventListener("change", ()=>{ config.hairStyle = hairSel.value; update(); }); + hairField.append(hairSpan, hairSel); + row2.append(hairField); + controls.appendChild(row2); + + const row3 = document.createElement("div"); row3.className = "avatar-row"; + const headAccField = document.createElement("label"); + const headAccSpan = document.createElement("span"); + const headAccSel = document.createElement("select"); + AVATAR_HEAD_ACC.forEach(id=>headAccSel.appendChild(new Option("", id))); + headAccSel.addEventListener("change", ()=>{ config.accessoryHead = headAccSel.value; update(); }); + headAccField.append(headAccSpan, headAccSel); + const bodyAccField = document.createElement("label"); + const bodyAccSpan = document.createElement("span"); + const bodyAccSel = document.createElement("select"); + AVATAR_BODY_ACC.forEach(id=>bodyAccSel.appendChild(new Option("", id))); + bodyAccSel.addEventListener("change", ()=>{ config.accessoryBody = bodyAccSel.value; update(); }); + bodyAccField.append(bodyAccSpan, bodyAccSel); + row3.append(headAccField, bodyAccField); + controls.appendChild(row3); + + function syncLabels(){ + speciesRow.querySelectorAll("button").forEach(b=>{ + b.textContent = T("avatarSpecies_" + b.dataset.species); + b.classList.toggle("active", b.dataset.species === config.species); + }); + bodyColorSpan.textContent = T("avatarBodyColor"); + hairColorSpan.textContent = T("avatarHairColor"); + hairSpan.textContent = T("avatarHairStyle"); + [...hairSel.options].forEach(o=>o.textContent = T("avatarHair_" + o.value)); + headAccSpan.textContent = T("avatarAccHead"); + [...headAccSel.options].forEach(o=>o.textContent = T("avatarAcc_" + o.value)); + bodyAccSpan.textContent = T("avatarAccBody"); + [...bodyAccSel.options].forEach(o=>o.textContent = T("avatarAcc_" + o.value)); + } + function update(){ + bodyColorInput.value = config.bodyColor; + hairColorInput.value = config.hairColor; + hairSel.value = config.hairStyle; + headAccSel.value = config.accessoryHead; + bodyAccSel.value = config.accessoryBody; + syncLabels(); + preview.innerHTML = ""; + preview.appendChild(renderAvatarFace(config, "lg")); + if(onChange) onChange({ ...config }); + } + document.addEventListener("langchange", syncLabels); + update(); + return { getConfig: ()=>({ ...config }), setConfig(c){ config = avatarSanitize(c); update(); } }; +} + +async function saveAvatar(config){ + const clean = avatarSanitize(config); + const res = await api("/me/avatar", { method: "PUT", body: clean }); + currentUser.avatar = res.avatar; + document.dispatchEvent(new CustomEvent("avatarchange")); + return res.avatar; +} + +/* ---- Instellingen-sectie "Mijn personage" (cms.js-patroon: eigen .st-section + zelf aanmaken, niet in index.html) ---- */ +(function(){ + const host = document.createElement("div"); + host.className = "st-section"; + host.dataset.section = "avatar"; + host.style.display = "none"; + document.getElementById("stContent").appendChild(host); + const msg = document.createElement("p"); + msg.className = "avatar-save-msg"; + let editorApi = null; + function build(){ + host.innerHTML = ""; + editorApi = buildAvatarEditor(host, currentUser && currentUser.avatar, ()=>{ msg.textContent = ""; }); + const saveBtn = document.createElement("button"); + saveBtn.type = "button"; + saveBtn.className = "tbtn"; + saveBtn.textContent = T("avatarSave"); + saveBtn.addEventListener("click", async ()=>{ + try{ await saveAvatar(editorApi.getConfig()); msg.textContent = T("avatarSaved"); } + catch(e){ msg.textContent = e.message; } + }); + host.appendChild(saveBtn); + host.appendChild(msg); + } + registerSettingsSection({ + id: "avatar", icon: "🦖", group: "personal", order: 3, + label: ()=>T("stAvatar"), sub: ()=>T("stAvatarSub"), + visible: ()=>!!currentUser, + activate(){ showSettingsDom("avatar"); build(); }, + keywords: ()=>[T("avatarSpecies_dino"), T("avatarSpecies_monster"), T("avatarSpecies_animal"), + "personage","avatar","dinosaurus","monster","dier","haar","hair","kleur","color"], + }); +})(); + +/* ---- widget: dunne wrapper rond dezelfde editor ---- */ +function mountAvatarWidget(root){ + root.innerHTML = `

`; + root.querySelector(".play-mission").textContent = T("wg_avatar_d"); + const host = document.createElement("div"); + host.style.cssText = "flex:1;min-height:0;display:flex;flex-direction:column;"; + root.appendChild(host); + const msg = document.createElement("p"); + msg.className = "avatar-save-msg"; + let editorApi = null; + function build(){ + host.innerHTML = ""; + editorApi = buildAvatarEditor(host, currentUser && currentUser.avatar, ()=>{ msg.textContent = ""; }); + const saveBtn = document.createElement("button"); + saveBtn.type = "button"; + saveBtn.className = "tbtn"; + saveBtn.textContent = T("avatarSave"); + saveBtn.addEventListener("click", async ()=>{ + try{ await saveAvatar(editorApi.getConfig()); msg.textContent = T("avatarSaved"); } + catch(e){ msg.textContent = e.message; } + }); + host.appendChild(saveBtn); + host.appendChild(msg); + } + build(); + document.addEventListener("langchange", ()=>{ root.querySelector(".play-mission").textContent = T("wg_avatar_d"); }); + /* currentUser.avatar is de bron van waarheid (server), niet het widget-eigen + initState - geen dubbele opslag van dezelfde data op twee plekken. */ + return { getState: ()=>({}) }; +} diff --git a/public/js/board.js b/public/js/board.js index 9a96957..b3e01c4 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -65,6 +65,7 @@ const REGISTRY = [ { id:"feelings", cat:"class", icon:"🎭", w:560, h:520, minW:380, minH:400, mount:mountFeelings }, { id:"breathe", cat:"class", icon:"🌬️", w:440, h:520, minW:320, minH:420, mount:mountBreathe }, { id:"compliments", cat:"class", icon:"💝", w:460, h:400, minW:340, minH:300, mount:mountCompliments }, + { id:"avatar", cat:"class", icon:"🦖", w:620, h:560, minW:400, minH:400, mount:mountAvatarWidget }, { id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia } ]; const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["world","catWorld"],["class","catClass"],["tools","catTools"],["media","catMedia"]]; diff --git a/public/js/core.js b/public/js/core.js index a3b7c15..8642b2a 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.22-beta"; +const VERSION = "0.4.23-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -162,6 +162,15 @@ const I18N = { "brePattern_4-4":"4 tellen in, 4 uit", "brePattern_4-7-8":"4 in, 7 vast, 8 uit", "brePattern_4-4-4-4":"4 in, 4 vast, 4 uit, 4 vast", wg_compliments:"Complimentenmachine", wg_compliments_d:"Trek een willekeurig compliment om iemand blij te maken.", complimentsDraw:"Trek een compliment", + wg_avatar:"Mijn personage", wg_avatar_d:"Stel je eigen personage samen: soort, kleuren, haar en accessoires.", + stAvatar:"Mijn personage", stAvatarSub:"Stel je eigen personage samen.", + avatarAlt:"Mijn personage", avatarSpecies_dino:"Dino", avatarSpecies_monster:"Monster", avatarSpecies_animal:"Dier", + avatarBodyColor:"Lichaamskleur", avatarHairColor:"Haarkleur", avatarHairStyle:"Haarstijl", + avatarHair_none:"Geen", avatarHair_spiky:"Piekerig", avatarHair_curly:"Krullen", avatarHair_ponytail:"Staartje", avatarHair_mohawk:"Hanenkam", + avatarAccHead:"Hoofdaccessoire", avatarAccBody:"Lichaamsaccessoire", + avatarAcc_none:"Geen", avatarAcc_cap:"Petje", avatarAcc_crown:"Kroon", avatarAcc_glasses:"Bril", avatarAcc_bow:"Strik", + avatarAcc_scarf:"Sjaal", avatarAcc_cape:"Cape", avatarAcc_badge:"Insigne", + avatarSave:"Opslaan", avatarSaved:"Personage opgeslagen ✓", clock24Hour:"24-uursklok", widgetSettings:"Instellingen", clockWelcome:"Oefenen met de klok", clockWelcomeHint:"Kies bovenaan niveau 1, 2 of 3. Kies bij Instellingen hoe de opdracht wordt getoond en start daarna.", clockSetTitle:"Zet de wijzers op de goede tijd", clockReadTitle:"Bekijk de opdracht en maak de tijd", clockChooseTitle:"Kies het juiste tijdblok", clockYourTask:"Jouw opdracht", clockLinkHands:"Grote wijzer draait mee", clockTypeHint:"Voer de tijd in als UU:MM.", clockLevel1Hint:"Niveau 1: hele en halve uren", clockLevel2Hint:"Niveau 2: stappen van vijf minuten", clockLevel3Hint:"Niveau 3: alle minuten, met optioneel 24 uur en dagdelen", clockBuildHint:"Sleep vier cijfers naar de vakken en maak UU:MM.", clockDigitGhost:"?", clockDigit:"Cijfer {digit}", clockIncomplete:"Maak eerst de volledige tijd af.", clockCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", clockTryAgain:"Bijna! Probeer het nog een keer.", clockStartTask:"Start opdracht", clockNextTask:"Volgende opdracht", clockLevel:"Niveau", clockLevelHour:"Hele uren", clockLevelQuarter:"Kwartieren", clockLevelFive:"Vijf minuten", clockLevelMinute:"Iedere minuut", clockLearningStyle:"Opdracht tonen", clockStyleAnalogue:"Analoge klok", clockStyleDigital:"Grote digitale tijd", clockStyleWords:"In woorden", clockStyleBoth:"Klok, cijfers en woorden", clockDragHint:"Sleep de wijzers om de tijd te veranderen.", clockGame:"Spel", clockGameSet:"Klok zetten", clockGameRead:"Tijd aflezen", clockGameChoose:"Kies de tijd", clockStart:"Kies ‘Nieuwe opdracht’ om te beginnen", clockRead:"Welke tijd zie je? Stel je antwoord hieronder in.", clockChoose:"Welke digitale tijd hoort bij deze klok?", clockTry:"Nog niet. Het goede antwoord is {time}.", clockScore:"Score: {score} · reeks: {streak}", clockWordsHour:"{hour} uur", clockWordsQuarterPast:"kwart over {hour}", clockWordsHalf:"half {hour}", clockWordsQuarterTo:"kwart voor {hour}", clockWordsPast:"{minutes} over {hour}", clockWordsTo:"{minutes} voor {hour}", clockMorning:"in de ochtend", clockAfternoon:"in de middag", clockEvening:"in de avond", clockNight:"in de nacht", fractionRows:"Rijen", fractionLabels:"Breuken tonen", fractionHint:"Tik in een rij tot waar je de breuk wilt kleuren.", fractionSelected:"Gekozen breuk: {fraction}", fractionClear:"Leegmaken", clockTask:"Oefenopdracht", clockSet:"Zet de klok op {time}", clockHour:"Uur", clockMinute:"Minuten", placeNumber:"Getal", placeGame:"Spel", placeGameExplore:"Verkennen", placeGameBuild:"Bouwen", placeLevel:"Niveau", placeLevel1Hint:"Niveau 1: getallen tot 100 (T en E)", placeLevel2Hint:"Niveau 2: getallen tot 1000 (H, T en E)", placeLevel3Hint:"Niveau 3: getallen tot 1.000.000 (HD, TD, D, H, T en E)", placeBuildTitle:"Bouw het getal", placeYourTask:"Jouw opdracht", placeBuildHint:"Sleep de cijfers naar de vakken en bouw het getal.", placeDigitGhost:"?", placeIncomplete:"Maak eerst het hele getal af.", placeCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", placeTryAgain:"Bijna! Probeer het nog een keer.", placeNextTask:"Volgende opdracht", placeScore:"Score: {score} · reeks: {streak}", moneyMake:"Maak € {amount}", wordtypeSentence:"Zin", wordtypeHint:"Tik: blauw zelfstandig naamwoord · groen werkwoord · geel bijvoeglijk naamwoord", scheduleActivity:"activiteit…", scheduleEmpty:"Voeg de eerste activiteit toe.", scheduleCount:"{count} momenten op de planning", pollReset:"Opnieuw", pollQuestion:"Vraag", pollOptions:"Antwoorden (één per regel)", pollTotal:"{count} stemmen", moodTotal:"{count} kinderen hebben gekozen", moodVote:"Kies {face}", galleryFavorites:"Favorieten", galleryFavorite:"Favoriet maken", galleryEmpty:"Geen widgets gevonden.", gallerySearch:"Zoek widgets…", templateMath:"Rekenles", templateLanguage:"Taalles", templateDay:"Dagstart", exportBoard:"Exporteren", importBoard:"Importeren", importError:"Dit is geen geldig Teach-bordbestand.", widgetDuplicate:"Dupliceren", widgetLock:"Vastzetten", widgetUnlock:"Losmaken", widgetPreview:"Voorbeeld zonder bediening", undo:"Ongedaan maken", redo:"Opnieuw", wg_sentence:"Zinsbouwer", wg_sentence_d:"Bouw zinnen met kleurgecodeerde zinsdelen en voeg eigen woorden toe.", @@ -485,6 +494,15 @@ const I18N = { "brePattern_4-4":"4 counts in, 4 out", "brePattern_4-7-8":"4 in, 7 hold, 8 out", "brePattern_4-4-4-4":"4 in, 4 hold, 4 out, 4 hold", wg_compliments:"Compliment machine", wg_compliments_d:"Draw a random compliment to make someone's day.", complimentsDraw:"Draw a compliment", + wg_avatar:"My character", wg_avatar_d:"Build your own character: species, colours, hair and accessories.", + stAvatar:"My character", stAvatarSub:"Build your own character.", + avatarAlt:"My character", avatarSpecies_dino:"Dino", avatarSpecies_monster:"Monster", avatarSpecies_animal:"Animal", + avatarBodyColor:"Body colour", avatarHairColor:"Hair colour", avatarHairStyle:"Hair style", + avatarHair_none:"None", avatarHair_spiky:"Spiky", avatarHair_curly:"Curly", avatarHair_ponytail:"Ponytail", avatarHair_mohawk:"Mohawk", + avatarAccHead:"Head accessory", avatarAccBody:"Body accessory", + avatarAcc_none:"None", avatarAcc_cap:"Cap", avatarAcc_crown:"Crown", avatarAcc_glasses:"Glasses", avatarAcc_bow:"Bow", + avatarAcc_scarf:"Scarf", avatarAcc_cape:"Cape", avatarAcc_badge:"Badge", + avatarSave:"Save", avatarSaved:"Character saved ✓", clock24Hour:"24-hour clock", widgetSettings:"Settings", clockWelcome:"Practise telling time", clockWelcomeHint:"Choose level 1, 2 or 3 at the top. In Settings, choose how the task is shown, then start.", clockSetTitle:"Move the hands to the correct time", clockReadTitle:"View the task and make the time", clockChooseTitle:"Choose the correct time block", clockYourTask:"Your task", clockLinkHands:"Move the minute hand along", clockTypeHint:"Enter the time as HH:MM.", clockLevel1Hint:"Level 1: whole and half hours", clockLevel2Hint:"Level 2: five-minute steps", clockLevel3Hint:"Level 3: every minute, with optional 24-hour time and day parts", clockBuildHint:"Drag four digits into the slots to make HH:MM.", clockDigitGhost:"?", clockDigit:"Digit {digit}", clockIncomplete:"Complete the full time first.", clockCorrect:"Well done! Choose the next task. 🌟", clockTryAgain:"Almost! Try again.", clockStartTask:"Start task", clockNextTask:"Next task", clockLevel:"Level", clockLevelHour:"Whole hours", clockLevelQuarter:"Quarter hours", clockLevelFive:"Five minutes", clockLevelMinute:"Every minute", clockLearningStyle:"Show task as", clockStyleAnalogue:"Analogue clock", clockStyleDigital:"Large digital time", clockStyleWords:"In words", clockStyleBoth:"Clock, digits and words", clockDragHint:"Drag the hands to change the time.", clockGame:"Game", clockGameSet:"Set the clock", clockGameRead:"Read the time", clockGameChoose:"Choose the time", clockStart:"Choose ‘New task’ to begin", clockRead:"What time is it? Set your answer below.", clockChoose:"Which digital time matches this clock?", clockTry:"Not yet. The correct answer is {time}.", clockScore:"Score: {score} · streak: {streak}", clockWordsHour:"{hour} o'clock", clockWordsQuarterPast:"quarter past {hour}", clockWordsHalf:"half past {hour}", clockWordsQuarterTo:"quarter to {hour}", clockWordsPast:"{minutes} past {hour}", clockWordsTo:"{minutes} to {hour}", clockMorning:"in the morning", clockAfternoon:"in the afternoon", clockEvening:"in the evening", clockNight:"at night", fractionRows:"Rows", fractionLabels:"Show fractions", fractionHint:"Tap a row up to the part you want to colour.", fractionSelected:"Selected fraction: {fraction}", fractionClear:"Clear", clockTask:"Practice task", clockSet:"Set the clock to {time}", clockHour:"Hour", clockMinute:"Minutes", placeNumber:"Number", placeGame:"Game", placeGameExplore:"Explore", placeGameBuild:"Build", placeLevel:"Level", placeLevel1Hint:"Level 1: numbers up to 100 (T and E)", placeLevel2Hint:"Level 2: numbers up to 1000 (H, T and E)", placeLevel3Hint:"Level 3: numbers up to 1,000,000 (HD, TD, D, H, T and E)", placeBuildTitle:"Build the number", placeYourTask:"Your task", placeBuildHint:"Drag the digits into the slots to build the number.", placeDigitGhost:"?", placeIncomplete:"Complete the whole number first.", placeCorrect:"Well done! Choose the next task. 🌟", placeTryAgain:"Almost! Try again.", placeNextTask:"Next task", placeScore:"Score: {score} · streak: {streak}", moneyMake:"Make € {amount}", wordtypeSentence:"Sentence", wordtypeHint:"Tap: blue noun · green verb · yellow adjective", scheduleActivity:"activity…", scheduleEmpty:"Add the first activity.", scheduleCount:"{count} moments in the schedule", pollReset:"Reset", pollQuestion:"Question", pollOptions:"Answers (one per line)", pollTotal:"{count} votes", moodTotal:"{count} children have chosen", moodVote:"Choose {face}", galleryFavorites:"Favourites", galleryFavorite:"Add favourite", galleryEmpty:"No widgets found.", gallerySearch:"Search widgets…", templateMath:"Maths lesson", templateLanguage:"Language lesson", templateDay:"Day start", exportBoard:"Export", importBoard:"Import", importError:"This is not a valid Teach board file.", widgetDuplicate:"Duplicate", widgetLock:"Lock", widgetUnlock:"Unlock", widgetPreview:"Preview without controls", undo:"Undo", redo:"Redo", wg_sentence:"Sentence builder", wg_sentence_d:"Build sentences with colour-coded parts and add your own words.", diff --git a/public/js/widgets/math.js b/public/js/widgets/math.js index a344a1a..d461bbe 100644 --- a/public/js/widgets/math.js +++ b/public/js/widgets/math.js @@ -39,6 +39,19 @@ function mountMath(root, initState, mode, opts){ `; 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"), @@ -140,7 +153,7 @@ function mountMath(root, initState, mode, opts){ function blocksSolved(){ S.busy = true; S.good++; - fbEl.textContent = T("mGood"); + showGoodFeedback(); play(sndWin); confetti(el(".mt-card")); setTimeout(next, 1000); @@ -213,7 +226,7 @@ function mountMath(root, initState, mode, opts){ S.busy = true; S.good++; aEl.classList.add("ok"); - fbEl.textContent = T("mGood"); + showGoodFeedback(); play(sndGood); confetti(el(".mt-card")); setTimeout(next, 900); diff --git a/src/api.js b/src/api.js index 603125a..1b95bf6 100644 --- a/src/api.js +++ b/src/api.js @@ -190,6 +190,31 @@ export default async function api(app) { 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_HAIR = ['none', 'spiky', 'curly', 'ponytail', 'mohawk']; + const AVATAR_HEAD_ACC = ['none', 'cap', 'crown', 'glasses', 'bow']; + const AVATAR_BODY_ACC = ['none', 'scarf', 'cape', 'badge']; + const isHexColor = (v) => typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v); + 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'); + 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 avatar = { + species: b.species, 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 diff --git a/src/auth.js b/src/auth.js index 6eac973..1e20c35 100644 --- a/src/auth.js +++ b/src/auth.js @@ -128,5 +128,6 @@ export function publicUser(u) { 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/test/widgets.test.js b/test/widgets.test.js index e7dcc50..86fea94 100644 --- a/test/widgets.test.js +++ b/test/widgets.test.js @@ -348,6 +348,7 @@ test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px- feelings:'.fz-zones', breathe:'.bre-circle', compliments:'.cpl-card', + avatar:'.avatar-editor', media:'.media-bar', }; assert.deepEqual(registered,Object.keys(coverage).sort(),'ieder geregistreerd widgettype staat in de mobiele audit'); -- 2.45.2 From 9bf91f0bd134d989e11876f5a3a4b58171d8dab1 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 09:50:18 +0200 Subject: [PATCH 163/192] fix: personage-tekening had een gezicht noch duidelijke dino-vorm (v0.4.24-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Elk personage kreeg ogen (was volledig gezichtsloos - de kernoorzaak van de lage beeldkwaliteit: een gekleurde vlek zonder gezicht oogt nooit als een figuur, ongeacht soort/haar/accessoires). - Dino kreeg een snuitje met neusgaten, een duidelijk zichtbaar staartje en een derde, groter/donkerder rugstekel-plaatje - hiervoor waren de rugstekels aanwezig maar onzichtbaar (z-index onder het lijf/hoofd, zie vorige commit), nu bewust vóór het silhouet getekend zodat ze altijd tonen. - Snuitje stond eerst z-index-onder de rugstekels (dus zelf ook onzichtbaar, identieke fout als hierboven) - z-index verhoogd zodat het snuitje altijd vóór de rugstekels ligt, wat ook anatomisch klopt (snuit zit vooraan). - Geverifieerd met alle vijf scenario's uit de vorige verificatie (opslaan, widget, School-navigator-badge, math.js-aanmoediging) plus een losse screenshot-galerij op alle drie weergavegroottes (24/44/180px). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- public/css/teach.css | 21 +++++++++++++++++---- public/js/avatar.js | 8 +++++++- public/js/core.js | 2 +- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/VERSION b/VERSION index b81f59b..92ab6b2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.23-beta \ No newline at end of file +0.4.24-beta \ No newline at end of file diff --git a/public/css/teach.css b/public/css/teach.css index fb11767..853053c 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1690,11 +1690,24 @@ body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000; .av-species-animal .av-part-head::before{left:2%;} .av-species-animal .av-part-head::after{right:2%;} .av-part-body{position:absolute; z-index:6; display:none;} -.av-species-dino .av-part-body{display:block; left:26%; right:26%; top:42%; height:22%;} -.av-species-dino .av-part-body::before,.av-species-dino .av-part-body::after{content:""; position:absolute; width:20%; height:100%; background:var(--av-body); filter:brightness(.72); clip-path:polygon(50% 0%,0% 100%,100% 100%);} -.av-species-dino .av-part-body::before{left:8%;} -.av-species-dino .av-part-body::after{right:8%;} +.av-species-dino .av-part-body{display:block; left:22%; right:22%; top:36%; height:26%;} +.av-species-dino .av-part-body::before,.av-species-dino .av-part-body::after{content:""; position:absolute; width:22%; height:82%; bottom:0; background:var(--av-body); filter:brightness(.68); clip-path:polygon(50% 0%,100% 65%,100% 100%,0% 100%,0% 65%); border-radius:3px 3px 0 0;} +.av-species-dino .av-part-body::before{left:4%;} +.av-species-dino .av-part-body::after{right:4%;} +.av-spike-mid{display:none; position:absolute;} +.av-species-dino .av-spike-mid{display:block; left:39%; width:22%; height:100%; bottom:0; background:var(--av-body); filter:brightness(.68); clip-path:polygon(50% 0%,100% 65%,100% 100%,0% 100%,0% 65%); border-radius:3px 3px 0 0;} .av-species-animal .av-part-body{display:block; right:2%; bottom:8%; width:16%; height:30%; border-radius:50% 50% 50% 10%; background:var(--av-body); transform:rotate(20deg); filter:brightness(.9);} +.av-tail{position:absolute; z-index:0; display:none;} +.av-species-dino .av-tail{display:block; left:-8%; bottom:8%; width:22%; height:18%; background:var(--av-body); border-radius:50% 15% 50% 50%; transform:rotate(-25deg); filter:brightness(.93);} +.av-eye{position:absolute; z-index:4; top:30%; width:16%; height:16%; border-radius:50%; background:#fff; box-shadow:inset 0 0 0 1.5px rgba(20,20,30,.15);} +.av-eye::after{content:""; position:absolute; width:46%; height:46%; left:27%; top:30%; border-radius:50%; background:#232733;} +.av-eye-l{left:25%;} +.av-eye-r{right:25%;} +.av-snout{position:absolute; z-index:7; display:none;} +.av-species-dino .av-snout{display:block; left:30%; right:30%; top:46%; height:20%; border-radius:45%; background:var(--av-body); filter:brightness(1.22);} +.av-species-dino .av-snout::before,.av-species-dino .av-snout::after{content:""; position:absolute; width:14%; height:22%; top:28%; border-radius:50%; background:#2b2b2b; opacity:.6;} +.av-species-dino .av-snout::before{left:26%;} +.av-species-dino .av-snout::after{right:26%;} .av-hair{position:absolute; z-index:4; display:none; background:var(--av-hair);} .av-hair-spiky{display:block; left:14%; right:14%; top:-2%; height:22%; clip-path:polygon(0% 100%,10% 20%,25% 90%,35% 10%,50% 90%,65% 10%,75% 90%,90% 20%,100% 100%);} .av-hair-curly{display:block; left:6%; right:6%; top:-4%; height:26%; border-radius:50% 50% 40% 40%;} diff --git a/public/js/avatar.js b/public/js/avatar.js index 58cebde..f1605e4 100644 --- a/public/js/avatar.js +++ b/public/js/avatar.js @@ -37,11 +37,17 @@ function renderAvatarFace(config, size){ const partHead = document.createElement("div"); partHead.className = "av-part-head"; const hair = document.createElement("div"); hair.className = "av-hair av-hair-" + c.hairStyle; const head = document.createElement("div"); head.className = "av-head"; + const eyeL = document.createElement("div"); eyeL.className = "av-eye av-eye-l"; + const eyeR = document.createElement("div"); eyeR.className = "av-eye av-eye-r"; + const snout = document.createElement("div"); snout.className = "av-snout"; const accHead = document.createElement("div"); accHead.className = "av-acc-head av-acc-head-" + c.accessoryHead; + const tail = document.createElement("div"); tail.className = "av-tail"; const partBody = document.createElement("div"); partBody.className = "av-part-body"; + const spikeMid = document.createElement("div"); spikeMid.className = "av-spike-mid"; + partBody.appendChild(spikeMid); const body = document.createElement("div"); body.className = "av-body"; const accBody = document.createElement("div"); accBody.className = "av-acc-body av-acc-body-" + c.accessoryBody; - face.append(partBody, body, accBody, partHead, head, accHead, hair); + face.append(tail, partBody, body, accBody, partHead, head, eyeL, eyeR, snout, accHead, hair); return face; } diff --git a/public/js/core.js b/public/js/core.js index 8642b2a..8b06dc6 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.23-beta"; +const VERSION = "0.4.24-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; -- 2.45.2 From 4438fad52b1ce9e5cd84e458d9c67d7021a7bcd2 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 09:57:48 +0200 Subject: [PATCH 164/192] fix: dino-personage krijgt een echt dinolichaam i.p.v. een blote romp (v0.4.25-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Buikvlek (lichter gekleurde ovaal) op de romp - een klassiek dino-kenmerk dat meteen de "kleurvlek zonder vorm"-indruk wegneemt. - Twee kleine pootjes onderaan en twee stompe armpjes opzij (T-rex-stijl) - het lichaam stond eerder als een rond blok zonder ledematen. - Staart herzien naar een echte taps toelopende vorm (clip-path) i.p.v. de ronde druppelvorm van hiervoor. - Alleen voor soort "dino" - monster/dier ongewijzigd, geen regressie (gecontroleerd via de screenshot-galerij van alle drie soorten samen). - Geverifieerd op alle vijf bestaande scenario's (opslaan, widget, School-navigator-badge, math.js-aanmoediging, ongeldige input) én opnieuw op alle drie weergavegroottes (24/44/180px) vóór het committen. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- public/css/teach.css | 12 +++++++++++- public/js/avatar.js | 7 ++++++- public/js/core.js | 2 +- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/VERSION b/VERSION index 92ab6b2..3e9b3af 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.24-beta \ No newline at end of file +0.4.25-beta \ No newline at end of file diff --git a/public/css/teach.css b/public/css/teach.css index 853053c..f4ec74e 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1698,7 +1698,17 @@ body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000; .av-species-dino .av-spike-mid{display:block; left:39%; width:22%; height:100%; bottom:0; background:var(--av-body); filter:brightness(.68); clip-path:polygon(50% 0%,100% 65%,100% 100%,0% 100%,0% 65%); border-radius:3px 3px 0 0;} .av-species-animal .av-part-body{display:block; right:2%; bottom:8%; width:16%; height:30%; border-radius:50% 50% 50% 10%; background:var(--av-body); transform:rotate(20deg); filter:brightness(.9);} .av-tail{position:absolute; z-index:0; display:none;} -.av-species-dino .av-tail{display:block; left:-8%; bottom:8%; width:22%; height:18%; background:var(--av-body); border-radius:50% 15% 50% 50%; transform:rotate(-25deg); filter:brightness(.93);} +.av-species-dino .av-tail{display:block; left:-14%; bottom:10%; width:30%; height:16%; background:var(--av-body); filter:brightness(.93); clip-path:polygon(100% 15%,100% 85%,35% 100%,0% 55%,30% 0%); transform:rotate(-8deg);} +.av-leg{position:absolute; z-index:0; display:none; border-radius:45% 45% 55% 55%;} +.av-species-dino .av-leg{display:block; top:82%; height:16%; width:17%; background:var(--av-body); filter:brightness(.78);} +.av-leg-l{left:16%;} +.av-leg-r{right:16%;} +.av-arm{position:absolute; z-index:2; display:none; border-radius:50%;} +.av-species-dino .av-arm{display:block; top:66%; height:15%; width:13%; background:var(--av-body); filter:brightness(.88);} +.av-arm-l{left:9%; transform:rotate(20deg);} +.av-arm-r{right:9%; transform:rotate(-20deg);} +.av-belly{position:absolute; z-index:2; display:none; border-radius:50%;} +.av-species-dino .av-belly{display:block; left:35%; right:35%; top:66%; height:28%; background:var(--av-body); filter:brightness(1.4);} .av-eye{position:absolute; z-index:4; top:30%; width:16%; height:16%; border-radius:50%; background:#fff; box-shadow:inset 0 0 0 1.5px rgba(20,20,30,.15);} .av-eye::after{content:""; position:absolute; width:46%; height:46%; left:27%; top:30%; border-radius:50%; background:#232733;} .av-eye-l{left:25%;} diff --git a/public/js/avatar.js b/public/js/avatar.js index f1605e4..9ae2185 100644 --- a/public/js/avatar.js +++ b/public/js/avatar.js @@ -42,12 +42,17 @@ function renderAvatarFace(config, size){ const snout = document.createElement("div"); snout.className = "av-snout"; const accHead = document.createElement("div"); accHead.className = "av-acc-head av-acc-head-" + c.accessoryHead; const tail = document.createElement("div"); tail.className = "av-tail"; + const legL = document.createElement("div"); legL.className = "av-leg av-leg-l"; + const legR = document.createElement("div"); legR.className = "av-leg av-leg-r"; const partBody = document.createElement("div"); partBody.className = "av-part-body"; const spikeMid = document.createElement("div"); spikeMid.className = "av-spike-mid"; partBody.appendChild(spikeMid); const body = document.createElement("div"); body.className = "av-body"; + const belly = document.createElement("div"); belly.className = "av-belly"; + const armL = document.createElement("div"); armL.className = "av-arm av-arm-l"; + const armR = document.createElement("div"); armR.className = "av-arm av-arm-r"; const accBody = document.createElement("div"); accBody.className = "av-acc-body av-acc-body-" + c.accessoryBody; - face.append(tail, partBody, body, accBody, partHead, head, eyeL, eyeR, snout, accHead, hair); + face.append(tail, legL, legR, partBody, body, belly, armL, armR, accBody, partHead, head, eyeL, eyeR, snout, accHead, hair); return face; } diff --git a/public/js/core.js b/public/js/core.js index 8b06dc6..1ec1b4f 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.24-beta"; +const VERSION = "0.4.25-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; -- 2.45.2 From 46696b0d7dc9c4fe423da16a5a17444e44965c09 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 10:19:19 +0200 Subject: [PATCH 165/192] refactor: personage-tekening volledig herbouwd als SVG (v0.4.26-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kernprobleem: de CSS-laagjesversie (losse divs met percentage-posities en z-index) had onderdelen die los van het lichaam hingen en een tekenvolgorde die niet klopte (soort-specifieke delen soms achter hoofd/lijf verstopt, soms er los voor zwevend). SVG lost dit structureel op: tekenvolgorde = laagvolgorde, geen z-index nodig, en elk onderdeel is een gebogen path/ellips die het silhouet van het vorige onderdeel ruim overlapt in plaats van ernaast te zweven. - Onderzocht hoe gangbare avatar-generators (DiceBear, Avataaars) dit aanpakken: vaste 100x100 viewBox, onderdelen als SVG-groepen in de juiste volgorde, kleur via directe fill-waarden i.p.v. CSS-variabelen in SVG. Dat patroon hier toegepast, geen code overgenomen. - Elke soort (dino/monster/dier) is nu een samenhangend lichaam: poten, armen, staart/oren, buikvlek en gezicht overlappen allemaal duidelijk met het hoofd/lijf. Dino: rugstekels, snuit met neusgaten, glimlach. Monster: hoorntjes, brede mond met slagtandjes, asymmetrische ogen. Dier: ronde oren, beer-snuit, kleine staart. - Haar (spits/krullen/paardenstaart/mohawk) en accessoires (pet/kroon/ bril/strik/sjaal/cape/badge) opnieuw getekend zodat ze ook echt op hun naam lijken, i.p.v. simpele gekleurde blokjes. - Regel toegevoegd: een pet of kroon dekt het hele bovenhoofd af, dus onderliggend haar wordt dan niet getekend (gaf anders rommelige, half verstopte plukjes rond de rand) - een paardenstaart blijft wel zichtbaar, want die hangt naar opzij. - Kleuren (lichter voor buik/snuit, donkerder voor poten/staart/ rugstekels) worden nu berekend uit de gekozen lichaamskleur zelf (avatarShade-helper), i.p.v. via CSS filter:brightness(). - CSS: alle oude .av-* laag-regels (~65 regels) verwijderd; alleen afmetingen (sm/md/lg) en de bestaande instellingen/widget/ aanmoedigings-CSS blijven staan. - Geverifieerd: alle vijf bestaande scenario's (opslaan, widget, School-navigator-badge met een echte klas vol verschillende personages, math.js-aanmoediging, ongeldige input), plus uitgebreide screenshot-rondes van losse onderdelen, alle soort/haar/accessoire- combinaties en alle drie weergavegroottes (24/44/180px) vóór het committen. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- public/css/teach.css | 61 +------------- public/js/avatar.js | 193 ++++++++++++++++++++++++++++++++++++------- public/js/core.js | 2 +- 4 files changed, 166 insertions(+), 92 deletions(-) diff --git a/VERSION b/VERSION index 3e9b3af..196f403 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.25-beta \ No newline at end of file +0.4.26-beta \ No newline at end of file diff --git a/public/css/teach.css b/public/css/teach.css index f4ec74e..f381a60 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1674,69 +1674,10 @@ body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000; .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): gelaagde div's + CSS custom properties, zelfde aanpak als de taart hierboven (--cake/--icing), geen SVG/afbeeldingen. ==== */ -.avatar-face{position:relative; display:inline-block; flex:none;} +.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;} -.av-body{position:absolute; left:20%; right:20%; bottom:2%; top:52%; border-radius:45% 45% 40% 40%; background:var(--av-body); z-index:1;} -.av-head{position:absolute; left:8%; right:8%; top:4%; height:56%; border-radius:50%; background:var(--av-body); z-index:3; box-shadow:inset 0 -6px 10px rgba(0,0,0,.12);} -.av-part-head{position:absolute; z-index:6; display:none;} -.av-species-monster .av-part-head{display:block; left:28%; right:28%; top:0; height:14%;} -.av-species-monster .av-part-head::before,.av-species-monster .av-part-head::after{content:""; position:absolute; width:16%; height:100%; background:var(--av-body); filter:brightness(.85); clip-path:polygon(50% 0,0% 100%,100% 100%);} -.av-species-monster .av-part-head::before{left:6%;} -.av-species-monster .av-part-head::after{right:6%;} -.av-species-animal .av-part-head{display:block; left:0; right:0; top:-2%; height:22%;} -.av-species-animal .av-part-head::before,.av-species-animal .av-part-head::after{content:""; position:absolute; width:22%; height:100%; border-radius:50%; background:var(--av-body);} -.av-species-animal .av-part-head::before{left:2%;} -.av-species-animal .av-part-head::after{right:2%;} -.av-part-body{position:absolute; z-index:6; display:none;} -.av-species-dino .av-part-body{display:block; left:22%; right:22%; top:36%; height:26%;} -.av-species-dino .av-part-body::before,.av-species-dino .av-part-body::after{content:""; position:absolute; width:22%; height:82%; bottom:0; background:var(--av-body); filter:brightness(.68); clip-path:polygon(50% 0%,100% 65%,100% 100%,0% 100%,0% 65%); border-radius:3px 3px 0 0;} -.av-species-dino .av-part-body::before{left:4%;} -.av-species-dino .av-part-body::after{right:4%;} -.av-spike-mid{display:none; position:absolute;} -.av-species-dino .av-spike-mid{display:block; left:39%; width:22%; height:100%; bottom:0; background:var(--av-body); filter:brightness(.68); clip-path:polygon(50% 0%,100% 65%,100% 100%,0% 100%,0% 65%); border-radius:3px 3px 0 0;} -.av-species-animal .av-part-body{display:block; right:2%; bottom:8%; width:16%; height:30%; border-radius:50% 50% 50% 10%; background:var(--av-body); transform:rotate(20deg); filter:brightness(.9);} -.av-tail{position:absolute; z-index:0; display:none;} -.av-species-dino .av-tail{display:block; left:-14%; bottom:10%; width:30%; height:16%; background:var(--av-body); filter:brightness(.93); clip-path:polygon(100% 15%,100% 85%,35% 100%,0% 55%,30% 0%); transform:rotate(-8deg);} -.av-leg{position:absolute; z-index:0; display:none; border-radius:45% 45% 55% 55%;} -.av-species-dino .av-leg{display:block; top:82%; height:16%; width:17%; background:var(--av-body); filter:brightness(.78);} -.av-leg-l{left:16%;} -.av-leg-r{right:16%;} -.av-arm{position:absolute; z-index:2; display:none; border-radius:50%;} -.av-species-dino .av-arm{display:block; top:66%; height:15%; width:13%; background:var(--av-body); filter:brightness(.88);} -.av-arm-l{left:9%; transform:rotate(20deg);} -.av-arm-r{right:9%; transform:rotate(-20deg);} -.av-belly{position:absolute; z-index:2; display:none; border-radius:50%;} -.av-species-dino .av-belly{display:block; left:35%; right:35%; top:66%; height:28%; background:var(--av-body); filter:brightness(1.4);} -.av-eye{position:absolute; z-index:4; top:30%; width:16%; height:16%; border-radius:50%; background:#fff; box-shadow:inset 0 0 0 1.5px rgba(20,20,30,.15);} -.av-eye::after{content:""; position:absolute; width:46%; height:46%; left:27%; top:30%; border-radius:50%; background:#232733;} -.av-eye-l{left:25%;} -.av-eye-r{right:25%;} -.av-snout{position:absolute; z-index:7; display:none;} -.av-species-dino .av-snout{display:block; left:30%; right:30%; top:46%; height:20%; border-radius:45%; background:var(--av-body); filter:brightness(1.22);} -.av-species-dino .av-snout::before,.av-species-dino .av-snout::after{content:""; position:absolute; width:14%; height:22%; top:28%; border-radius:50%; background:#2b2b2b; opacity:.6;} -.av-species-dino .av-snout::before{left:26%;} -.av-species-dino .av-snout::after{right:26%;} -.av-hair{position:absolute; z-index:4; display:none; background:var(--av-hair);} -.av-hair-spiky{display:block; left:14%; right:14%; top:-2%; height:22%; clip-path:polygon(0% 100%,10% 20%,25% 90%,35% 10%,50% 90%,65% 10%,75% 90%,90% 20%,100% 100%);} -.av-hair-curly{display:block; left:6%; right:6%; top:-4%; height:26%; border-radius:50% 50% 40% 40%;} -.av-hair-ponytail{display:block; left:10%; right:10%; top:-2%; height:20%; border-radius:50% 50% 30% 30%;} -.av-hair-ponytail::after{content:""; position:absolute; right:-6%; top:40%; width:20%; height:45%; border-radius:0 50% 50% 50%; background:var(--av-hair);} -.av-hair-mohawk{display:block; left:42%; right:42%; top:-8%; height:30%; border-radius:40% 40% 0 0;} -.av-hair-none{display:none;} -.av-acc-head{position:absolute; z-index:5; display:none;} -.av-acc-head-cap{display:block; left:6%; right:6%; top:2%; height:20%; border-radius:50% 50% 0 0; background:var(--blue);} -.av-acc-head-crown{display:block; left:14%; right:14%; top:-4%; height:18%; background:var(--yellow); clip-path:polygon(0% 100%,0% 40%,20% 70%,35% 10%,50% 60%,65% 10%,80% 70%,100% 40%,100% 100%);} -.av-acc-head-glasses{display:block; left:14%; right:14%; top:32%; height:16%;} -.av-acc-head-glasses::before,.av-acc-head-glasses::after{content:""; position:absolute; width:38%; height:100%; border-radius:50%; border:3px solid var(--ink); background:rgba(255,255,255,.35);} -.av-acc-head-glasses::before{left:0;} -.av-acc-head-glasses::after{right:0;} -.av-acc-head-bow{display:block; right:8%; top:8%; width:18%; height:14%; background:var(--pink); clip-path:polygon(0% 0%,50% 40%,100% 0%,100% 100%,50% 60%,0% 100%);} -.av-acc-body{position:absolute; z-index:4; display:none;} -.av-acc-body-scarf{display:block; left:18%; right:18%; top:48%; height:10%; border-radius:8px; background:var(--red);} -.av-acc-body-cape{display:block; left:10%; right:10%; top:54%; bottom:0; z-index:0; border-radius:0 0 40% 40%; background:var(--purple); opacity:.9;} -.av-acc-body-badge{display:block; left:32%; width:16%; top:62%; height:12%; border-radius:50%; background:var(--yellow); border:2px solid #fff;} .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;} diff --git a/public/js/avatar.js b/public/js/avatar.js index 9ae2185..4483955 100644 --- a/public/js/avatar.js +++ b/public/js/avatar.js @@ -1,7 +1,9 @@ /* teach - personage (avatar): eigen figuur samenstellen (soort, kleuren, haar, accessoires), gedeeld tussen de Instellingen-sectie "Mijn personage" en het - avatar-widget. Gelaagde div's + CSS custom properties, zelfde aanpak als de - verjaardagstaart-widget (--cake/--icing) - geen SVG/afbeeldingen. */ + avatar-widget. Getekend als één SVG (viewBox 0 0 100 100, tekenvolgorde = + laagvolgorde - geen z-index-gepuzzel zoals bij de eerdere CSS-laagjesversie) + zodat elk onderdeel z'n vaste plek op het silhouet overlapt in plaats van + los ernaast te zweven. */ "use strict"; const AVATAR_SPECIES = ["dino", "monster", "animal"]; const AVATAR_HAIR = ["none", "spiky", "curly", "ponytail", "mohawk"]; @@ -11,6 +13,7 @@ const AVATAR_DEFAULT = Object.freeze({ species: "dino", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29", accessoryHead: "none", accessoryBody: "none", }); +const AVATAR_SVG_NS = "http://www.w3.org/2000/svg"; function avatarSanitize(config){ const c = config || {}; @@ -24,36 +27,166 @@ function avatarSanitize(config){ }; } -/* bouwt de gelaagde weergave van een personage; config==null → neutrale - standaardweergave (nooit een lege/kapotte weergave). */ +/* mengt een hex-kleur amt (-1..1) richting zwart (negatief) of wit (positief) - + gebruikt voor buik/snuit-highlights en pootjes/staart-schaduw, zodat die + altijd bij de gekozen lichaamskleur passen zonder een aparte kleurkiezer. */ +function avatarShade(hex, amt){ + const n = parseInt(hex.slice(1), 16); + let r = (n>>16)&255, g = (n>>8)&255, b = n&255; + if(amt < 0){ r *= 1+amt; g *= 1+amt; b *= 1+amt; } + else{ r += (255-r)*amt; g += (255-g)*amt; b += (255-b)*amt; } + const clamp = v => Math.max(0, Math.min(255, Math.round(v))); + return "#" + [r,g,b].map(v=>clamp(v).toString(16).padStart(2,"0")).join(""); +} + +/* per-soort silhouet: elk onderdeel overlapt het vorige ruim (poten/armen/ + staart tot ver ín het lijf, rugstekels/hoorns pas ná de kop getekend) zodat + niets los oogt. Volgorde = tekenvolgorde = zichtbare laagvolgorde. */ +function avatarBodyMarkup(species, bodyColor){ + const dark = avatarShade(bodyColor, -0.35); + const light = avatarShade(bodyColor, 0.4); + let m = ""; + if(species === "dino"){ + m += ``; + } + if(species === "animal"){ + m += ``; + } + m += ``; + m += ``; + m += ``; + m += ``; + m += ``; + m += ``; + if(species === "animal"){ + m += ``; + m += ``; + m += ``; + m += ``; + } + if(species === "monster"){ + const darker = avatarShade(bodyColor, -0.5); + m += ``; + m += ``; + } + m += ``; + if(species === "dino"){ + m += ``; + m += ``; + m += ``; + } + if(species === "animal"){ + m += ``; + m += ``; + } + if(species === "monster"){ + m += ``; + m += ``; + m += ``; + m += ``; + }else{ + m += ``; + m += ``; + m += ``; + m += ``; + } + if(species === "dino"){ + m += ``; + const plate = avatarShade(bodyColor, -0.45); + m += ``; + m += ``; + m += ``; + } + if(species === "animal"){ + m += ``; + } + if(species === "monster"){ + m += ``; + m += ``; + m += ``; + } + return m; +} + +const AVATAR_HAIR_MARKUP = { + none: () => "", + spiky: (c) => ` + + + + + `, + curly: (c) => ` + + + + + `, + ponytail: (c) => ` + + + `, + mohawk: (c) => ``, +}; + +const AVATAR_HEAD_ACC_MARKUP = { + none: () => "", + cap: () => ` + + `, + crown: () => ` + + + + `, + glasses: () => ` + + + + + `, + bow: () => ` + + `, +}; + +const AVATAR_BODY_ACC_MARKUP = { + none: () => "", + scarf: () => ` + + `, + cape: () => ` + + `, + badge: () => ``, +}; + +/* een pet/kroon dekt het hele bovenhoofd af - loshangend haar eronder tonen + gaf rommelige, half-verstopte plukjes rond de rand (zie roadmap-memory). + Een paardenstaart mag wel blijven: die hangt naar opzij, niet over de kop. */ +function avatarHairSuppressed(c){ + return (c.accessoryHead === "cap" || c.accessoryHead === "crown") && c.hairStyle !== "ponytail"; +} + +/* bouwt de SVG-weergave van een personage; config==null → neutrale + standaardweergave (nooit een lege/kapotte weergave). Tekenvolgorde bepaalt + de laagvolgorde (SVG kent geen z-index) - vandaar cape/staart/poten eerst, + accessoires als laatste. */ function renderAvatarFace(config, size){ const c = avatarSanitize(config); - const face = document.createElement("div"); - face.className = "avatar-face " + (size || "md") + " av-species-" + c.species; - face.style.setProperty("--av-body", c.bodyColor); - face.style.setProperty("--av-hair", c.hairColor); - face.setAttribute("role", "img"); - face.setAttribute("aria-label", T("avatarAlt")); - const partHead = document.createElement("div"); partHead.className = "av-part-head"; - const hair = document.createElement("div"); hair.className = "av-hair av-hair-" + c.hairStyle; - const head = document.createElement("div"); head.className = "av-head"; - const eyeL = document.createElement("div"); eyeL.className = "av-eye av-eye-l"; - const eyeR = document.createElement("div"); eyeR.className = "av-eye av-eye-r"; - const snout = document.createElement("div"); snout.className = "av-snout"; - const accHead = document.createElement("div"); accHead.className = "av-acc-head av-acc-head-" + c.accessoryHead; - const tail = document.createElement("div"); tail.className = "av-tail"; - const legL = document.createElement("div"); legL.className = "av-leg av-leg-l"; - const legR = document.createElement("div"); legR.className = "av-leg av-leg-r"; - const partBody = document.createElement("div"); partBody.className = "av-part-body"; - const spikeMid = document.createElement("div"); spikeMid.className = "av-spike-mid"; - partBody.appendChild(spikeMid); - const body = document.createElement("div"); body.className = "av-body"; - const belly = document.createElement("div"); belly.className = "av-belly"; - const armL = document.createElement("div"); armL.className = "av-arm av-arm-l"; - const armR = document.createElement("div"); armR.className = "av-arm av-arm-r"; - const accBody = document.createElement("div"); accBody.className = "av-acc-body av-acc-body-" + c.accessoryBody; - face.append(tail, legL, legR, partBody, body, belly, armL, armR, accBody, partHead, head, eyeL, eyeR, snout, accHead, hair); - return face; + const svg = document.createElementNS(AVATAR_SVG_NS, "svg"); + svg.setAttribute("viewBox", "0 0 100 100"); + svg.setAttribute("class", "avatar-face " + (size || "md") + " av-species-" + c.species); + svg.setAttribute("role", "img"); + svg.setAttribute("aria-label", T("avatarAlt")); + let markup = ""; + if(c.accessoryBody === "cape") markup += AVATAR_BODY_ACC_MARKUP.cape(); + markup += avatarBodyMarkup(c.species, c.bodyColor); + if(!avatarHairSuppressed(c)) markup += AVATAR_HAIR_MARKUP[c.hairStyle](c.hairColor); + markup += AVATAR_HEAD_ACC_MARKUP[c.accessoryHead](); + if(c.accessoryBody !== "cape") markup += AVATAR_BODY_ACC_MARKUP[c.accessoryBody](); + svg.innerHTML = markup; + return svg; } /* bouwt de interactieve kiezer + live voorbeeld; roept onChange(config) aan diff --git a/public/js/core.js b/public/js/core.js index 1ec1b4f..533ba80 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.25-beta"; +const VERSION = "0.4.26-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; -- 2.45.2 From 817f12b70d1d00d1ad8e7b0692f73eff00ebee55 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 11:44:55 +0200 Subject: [PATCH 166/192] fix: dino-rugstekels en beer-staart oogden vreemd (v0.4.27-beta) - Dino: rugstekels stonden als een rij spitse vormen voor de hals/borst ("Ga als designer kijken" - gebruiker vond dit "wat vreemd"), wat als een rare kraag oogde. Verplaatst naar een klein randje bovenop de kop (zoals kleine hoorntjes) - dichter bij de anatomische plek (rug/kruin) en rustiger op klein formaat. - Dier (beer): had een los staartje opzij op dezelfde hoogte en met vrijwel dezelfde vorm/grootte als de armen/benen, waardoor het als een extra ledemaat oogde. Verwijderd - een beer laat van de voorkant sowieso nauwelijks iets van zijn staart zien, dus dit is ook anatomisch kloppender dan een zichtbaar zij-staartje. - Overwogen: SVG vs. PNG-lagen als basis. SVG blijft de juiste keuze hier - lichaams-/haarkleur zijn vrije kleurkiezers (elke hex-waarde), wat via fill-attributen triviaal is maar bij PNG een vast palet of canvas-hertintplugin zou vereisen; SVG schaalt bovendien scherp op alle drie weergavegroottes zonder aparte assets. - Geverifieerd: alle vijf bestaande scenario's, volledige testsuite, en een School-navigator met een echte klas (leerling met dier- personage toont nu geen vreemd zij-staartje meer). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- public/js/avatar.js | 11 +++++------ public/js/core.js | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/VERSION b/VERSION index 196f403..e85208e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.26-beta \ No newline at end of file +0.4.27-beta \ No newline at end of file diff --git a/public/js/avatar.js b/public/js/avatar.js index 4483955..cdecdae 100644 --- a/public/js/avatar.js +++ b/public/js/avatar.js @@ -49,9 +49,6 @@ function avatarBodyMarkup(species, bodyColor){ if(species === "dino"){ m += ``; } - if(species === "animal"){ - m += ``; - } m += ``; m += ``; m += ``; @@ -92,10 +89,12 @@ function avatarBodyMarkup(species, bodyColor){ } if(species === "dino"){ m += ``; + /* rugstekels als klein randje bovenop de kop i.p.v. voor de hals - een + rij spitse vormen op de borst/hals oogde als een vreemde kraag; dit + zit anatomisch dichter bij de rug en is ook stiller op klein formaat. */ const plate = avatarShade(bodyColor, -0.45); - m += ``; - m += ``; - m += ``; + m += ``; + m += ``; } if(species === "animal"){ m += ``; diff --git a/public/js/core.js b/public/js/core.js index 533ba80..a59766d 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.26-beta"; +const VERSION = "0.4.27-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; -- 2.45.2 From 09a1326c7e81009f8c9fe9d250af1c63f4bb7e0e Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 11:55:25 +0200 Subject: [PATCH 167/192] feat: meer personage-accessoires incl. zonnebril (v0.4.28-beta) - Nieuwe hoofdaccessoires: zonnebril, koptelefoon, feesthoedje. - Nieuwe lichaamsaccessoires: ketting, rugtas (bandjes over de schouders, zichtbaar van voren). - Server-validatie (src/api.js) en vertalingen (nl+en) meegewerkt met dezelfde nieuwe waarden - anders zou de client een optie tonen die de server als "ongeldig hoofdaccessoire" afwijst. - Geen wijziging aan de bestaande pet/kroon-onderdrukkingsregel nodig: zonnebril/koptelefoon/feesthoedje bedekken het bovenhoofd niet volledig, dus haar blijft er gewoon zichtbaar naast/onder. - Geverifieerd: alle select-opties komen aan in de instellingen-editor, een PUT met de nieuwe waarden slaagt (bestaande server-validatie accepteert ze nu), en alle soort/accessoire-combinaties oogden goed in screenshot (zonnebril op de dino, koptelefoon op het monster, feesthoedje + ketting samen, rugtas op het dier). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- public/js/avatar.js | 33 +++++++++++++++++++++++++++++++-- public/js/core.js | 6 +++++- src/api.js | 4 ++-- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/VERSION b/VERSION index e85208e..f63d1be 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.27-beta \ No newline at end of file +0.4.28-beta \ No newline at end of file diff --git a/public/js/avatar.js b/public/js/avatar.js index cdecdae..af23903 100644 --- a/public/js/avatar.js +++ b/public/js/avatar.js @@ -7,8 +7,8 @@ "use strict"; const AVATAR_SPECIES = ["dino", "monster", "animal"]; const AVATAR_HAIR = ["none", "spiky", "curly", "ponytail", "mohawk"]; -const AVATAR_HEAD_ACC = ["none", "cap", "crown", "glasses", "bow"]; -const AVATAR_BODY_ACC = ["none", "scarf", "cape", "badge"]; +const AVATAR_HEAD_ACC = ["none", "cap", "crown", "glasses", "sunglasses", "bow", "headphones", "partyhat"]; +const AVATAR_BODY_ACC = ["none", "scarf", "cape", "badge", "necklace", "backpack"]; const AVATAR_DEFAULT = Object.freeze({ species: "dino", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29", accessoryHead: "none", accessoryBody: "none", @@ -147,6 +147,27 @@ const AVATAR_HEAD_ACC_MARKUP = { bow: () => ` `, + sunglasses: () => ` + + + + + + + `, + headphones: () => ` + + + + + `, + partyhat: () => ` + + + + + + `, }; const AVATAR_BODY_ACC_MARKUP = { @@ -158,6 +179,14 @@ const AVATAR_BODY_ACC_MARKUP = { `, badge: () => ``, + necklace: () => ` + + `, + backpack: () => ` + + + + `, }; /* een pet/kroon dekt het hele bovenhoofd af - loshangend haar eronder tonen diff --git a/public/js/core.js b/public/js/core.js index a59766d..b18968c 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.27-beta"; +const VERSION = "0.4.28-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -169,7 +169,9 @@ const I18N = { avatarHair_none:"Geen", avatarHair_spiky:"Piekerig", avatarHair_curly:"Krullen", avatarHair_ponytail:"Staartje", avatarHair_mohawk:"Hanenkam", avatarAccHead:"Hoofdaccessoire", avatarAccBody:"Lichaamsaccessoire", avatarAcc_none:"Geen", avatarAcc_cap:"Petje", avatarAcc_crown:"Kroon", avatarAcc_glasses:"Bril", avatarAcc_bow:"Strik", + avatarAcc_sunglasses:"Zonnebril", avatarAcc_headphones:"Koptelefoon", avatarAcc_partyhat:"Feesthoedje", avatarAcc_scarf:"Sjaal", avatarAcc_cape:"Cape", avatarAcc_badge:"Insigne", + avatarAcc_necklace:"Ketting", avatarAcc_backpack:"Rugtas", avatarSave:"Opslaan", avatarSaved:"Personage opgeslagen ✓", clock24Hour:"24-uursklok", widgetSettings:"Instellingen", clockWelcome:"Oefenen met de klok", clockWelcomeHint:"Kies bovenaan niveau 1, 2 of 3. Kies bij Instellingen hoe de opdracht wordt getoond en start daarna.", clockSetTitle:"Zet de wijzers op de goede tijd", clockReadTitle:"Bekijk de opdracht en maak de tijd", clockChooseTitle:"Kies het juiste tijdblok", clockYourTask:"Jouw opdracht", clockLinkHands:"Grote wijzer draait mee", clockTypeHint:"Voer de tijd in als UU:MM.", clockLevel1Hint:"Niveau 1: hele en halve uren", clockLevel2Hint:"Niveau 2: stappen van vijf minuten", clockLevel3Hint:"Niveau 3: alle minuten, met optioneel 24 uur en dagdelen", clockBuildHint:"Sleep vier cijfers naar de vakken en maak UU:MM.", clockDigitGhost:"?", clockDigit:"Cijfer {digit}", clockIncomplete:"Maak eerst de volledige tijd af.", clockCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", clockTryAgain:"Bijna! Probeer het nog een keer.", clockStartTask:"Start opdracht", clockNextTask:"Volgende opdracht", clockLevel:"Niveau", clockLevelHour:"Hele uren", clockLevelQuarter:"Kwartieren", clockLevelFive:"Vijf minuten", clockLevelMinute:"Iedere minuut", clockLearningStyle:"Opdracht tonen", clockStyleAnalogue:"Analoge klok", clockStyleDigital:"Grote digitale tijd", clockStyleWords:"In woorden", clockStyleBoth:"Klok, cijfers en woorden", clockDragHint:"Sleep de wijzers om de tijd te veranderen.", clockGame:"Spel", clockGameSet:"Klok zetten", clockGameRead:"Tijd aflezen", clockGameChoose:"Kies de tijd", clockStart:"Kies ‘Nieuwe opdracht’ om te beginnen", clockRead:"Welke tijd zie je? Stel je antwoord hieronder in.", clockChoose:"Welke digitale tijd hoort bij deze klok?", clockTry:"Nog niet. Het goede antwoord is {time}.", clockScore:"Score: {score} · reeks: {streak}", clockWordsHour:"{hour} uur", clockWordsQuarterPast:"kwart over {hour}", clockWordsHalf:"half {hour}", clockWordsQuarterTo:"kwart voor {hour}", clockWordsPast:"{minutes} over {hour}", clockWordsTo:"{minutes} voor {hour}", clockMorning:"in de ochtend", clockAfternoon:"in de middag", clockEvening:"in de avond", clockNight:"in de nacht", fractionRows:"Rijen", fractionLabels:"Breuken tonen", fractionHint:"Tik in een rij tot waar je de breuk wilt kleuren.", fractionSelected:"Gekozen breuk: {fraction}", fractionClear:"Leegmaken", clockTask:"Oefenopdracht", clockSet:"Zet de klok op {time}", clockHour:"Uur", clockMinute:"Minuten", placeNumber:"Getal", placeGame:"Spel", placeGameExplore:"Verkennen", placeGameBuild:"Bouwen", placeLevel:"Niveau", placeLevel1Hint:"Niveau 1: getallen tot 100 (T en E)", placeLevel2Hint:"Niveau 2: getallen tot 1000 (H, T en E)", placeLevel3Hint:"Niveau 3: getallen tot 1.000.000 (HD, TD, D, H, T en E)", placeBuildTitle:"Bouw het getal", placeYourTask:"Jouw opdracht", placeBuildHint:"Sleep de cijfers naar de vakken en bouw het getal.", placeDigitGhost:"?", placeIncomplete:"Maak eerst het hele getal af.", placeCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", placeTryAgain:"Bijna! Probeer het nog een keer.", placeNextTask:"Volgende opdracht", placeScore:"Score: {score} · reeks: {streak}", moneyMake:"Maak € {amount}", wordtypeSentence:"Zin", wordtypeHint:"Tik: blauw zelfstandig naamwoord · groen werkwoord · geel bijvoeglijk naamwoord", scheduleActivity:"activiteit…", scheduleEmpty:"Voeg de eerste activiteit toe.", scheduleCount:"{count} momenten op de planning", pollReset:"Opnieuw", pollQuestion:"Vraag", pollOptions:"Antwoorden (één per regel)", pollTotal:"{count} stemmen", moodTotal:"{count} kinderen hebben gekozen", moodVote:"Kies {face}", galleryFavorites:"Favorieten", galleryFavorite:"Favoriet maken", galleryEmpty:"Geen widgets gevonden.", gallerySearch:"Zoek widgets…", templateMath:"Rekenles", templateLanguage:"Taalles", templateDay:"Dagstart", exportBoard:"Exporteren", importBoard:"Importeren", importError:"Dit is geen geldig Teach-bordbestand.", widgetDuplicate:"Dupliceren", widgetLock:"Vastzetten", widgetUnlock:"Losmaken", widgetPreview:"Voorbeeld zonder bediening", undo:"Ongedaan maken", redo:"Opnieuw", @@ -501,7 +503,9 @@ const I18N = { avatarHair_none:"None", avatarHair_spiky:"Spiky", avatarHair_curly:"Curly", avatarHair_ponytail:"Ponytail", avatarHair_mohawk:"Mohawk", avatarAccHead:"Head accessory", avatarAccBody:"Body accessory", avatarAcc_none:"None", avatarAcc_cap:"Cap", avatarAcc_crown:"Crown", avatarAcc_glasses:"Glasses", avatarAcc_bow:"Bow", + avatarAcc_sunglasses:"Sunglasses", avatarAcc_headphones:"Headphones", avatarAcc_partyhat:"Party hat", avatarAcc_scarf:"Scarf", avatarAcc_cape:"Cape", avatarAcc_badge:"Badge", + avatarAcc_necklace:"Necklace", avatarAcc_backpack:"Backpack", avatarSave:"Save", avatarSaved:"Character saved ✓", clock24Hour:"24-hour clock", widgetSettings:"Settings", clockWelcome:"Practise telling time", clockWelcomeHint:"Choose level 1, 2 or 3 at the top. In Settings, choose how the task is shown, then start.", clockSetTitle:"Move the hands to the correct time", clockReadTitle:"View the task and make the time", clockChooseTitle:"Choose the correct time block", clockYourTask:"Your task", clockLinkHands:"Move the minute hand along", clockTypeHint:"Enter the time as HH:MM.", clockLevel1Hint:"Level 1: whole and half hours", clockLevel2Hint:"Level 2: five-minute steps", clockLevel3Hint:"Level 3: every minute, with optional 24-hour time and day parts", clockBuildHint:"Drag four digits into the slots to make HH:MM.", clockDigitGhost:"?", clockDigit:"Digit {digit}", clockIncomplete:"Complete the full time first.", clockCorrect:"Well done! Choose the next task. 🌟", clockTryAgain:"Almost! Try again.", clockStartTask:"Start task", clockNextTask:"Next task", clockLevel:"Level", clockLevelHour:"Whole hours", clockLevelQuarter:"Quarter hours", clockLevelFive:"Five minutes", clockLevelMinute:"Every minute", clockLearningStyle:"Show task as", clockStyleAnalogue:"Analogue clock", clockStyleDigital:"Large digital time", clockStyleWords:"In words", clockStyleBoth:"Clock, digits and words", clockDragHint:"Drag the hands to change the time.", clockGame:"Game", clockGameSet:"Set the clock", clockGameRead:"Read the time", clockGameChoose:"Choose the time", clockStart:"Choose ‘New task’ to begin", clockRead:"What time is it? Set your answer below.", clockChoose:"Which digital time matches this clock?", clockTry:"Not yet. The correct answer is {time}.", clockScore:"Score: {score} · streak: {streak}", clockWordsHour:"{hour} o'clock", clockWordsQuarterPast:"quarter past {hour}", clockWordsHalf:"half past {hour}", clockWordsQuarterTo:"quarter to {hour}", clockWordsPast:"{minutes} past {hour}", clockWordsTo:"{minutes} to {hour}", clockMorning:"in the morning", clockAfternoon:"in the afternoon", clockEvening:"in the evening", clockNight:"at night", fractionRows:"Rows", fractionLabels:"Show fractions", fractionHint:"Tap a row up to the part you want to colour.", fractionSelected:"Selected fraction: {fraction}", fractionClear:"Clear", clockTask:"Practice task", clockSet:"Set the clock to {time}", clockHour:"Hour", clockMinute:"Minutes", placeNumber:"Number", placeGame:"Game", placeGameExplore:"Explore", placeGameBuild:"Build", placeLevel:"Level", placeLevel1Hint:"Level 1: numbers up to 100 (T and E)", placeLevel2Hint:"Level 2: numbers up to 1000 (H, T and E)", placeLevel3Hint:"Level 3: numbers up to 1,000,000 (HD, TD, D, H, T and E)", placeBuildTitle:"Build the number", placeYourTask:"Your task", placeBuildHint:"Drag the digits into the slots to build the number.", placeDigitGhost:"?", placeIncomplete:"Complete the whole number first.", placeCorrect:"Well done! Choose the next task. 🌟", placeTryAgain:"Almost! Try again.", placeNextTask:"Next task", placeScore:"Score: {score} · streak: {streak}", moneyMake:"Make € {amount}", wordtypeSentence:"Sentence", wordtypeHint:"Tap: blue noun · green verb · yellow adjective", scheduleActivity:"activity…", scheduleEmpty:"Add the first activity.", scheduleCount:"{count} moments in the schedule", pollReset:"Reset", pollQuestion:"Question", pollOptions:"Answers (one per line)", pollTotal:"{count} votes", moodTotal:"{count} children have chosen", moodVote:"Choose {face}", galleryFavorites:"Favourites", galleryFavorite:"Add favourite", galleryEmpty:"No widgets found.", gallerySearch:"Search widgets…", templateMath:"Maths lesson", templateLanguage:"Language lesson", templateDay:"Day start", exportBoard:"Export", importBoard:"Import", importError:"This is not a valid Teach board file.", widgetDuplicate:"Duplicate", widgetLock:"Lock", widgetUnlock:"Unlock", widgetPreview:"Preview without controls", undo:"Undo", redo:"Redo", diff --git a/src/api.js b/src/api.js index 1b95bf6..234dc20 100644 --- a/src/api.js +++ b/src/api.js @@ -196,8 +196,8 @@ export default async function api(app) { // geen rol-beperkte PERMISSIONS-sleutel nodig, altijd de eigen rij. const AVATAR_SPECIES = ['dino', 'monster', 'animal']; const AVATAR_HAIR = ['none', 'spiky', 'curly', 'ponytail', 'mohawk']; - const AVATAR_HEAD_ACC = ['none', 'cap', 'crown', 'glasses', 'bow']; - const AVATAR_BODY_ACC = ['none', 'scarf', 'cape', 'badge']; + const AVATAR_HEAD_ACC = ['none', 'cap', 'crown', 'glasses', 'sunglasses', 'bow', 'headphones', 'partyhat']; + const AVATAR_BODY_ACC = ['none', 'scarf', 'cape', 'badge', 'necklace', 'backpack']; const isHexColor = (v) => typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v); app.put('/me/avatar', async (req, reply) => { need(req, reply); -- 2.45.2 From 8fe6fc10ccac94897870b452219532f419e4849e Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 12:54:48 +0200 Subject: [PATCH 168/192] feat: exporteren van resultaten per klas (CSV) (v0.4.29-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nieuwe sectie "Exporteren per klas" in de bestaande Voortgang-tab: klas kiezen + "Exporteer als CSV" downloadt de volledige activiteitenlijst van die klas (leerling, widget, pogingen, goed, sterren, datum/tijd). - Bouwt voort op de bestaande route GET /progress/class/:id, die tot nu toe geen enkele frontend-consument had. - Server: nieuwe optionele ?limit=-queryparameter (begrensd op 5000) - de export vraagt expliciet meer dan de standaard activiteitenweergave (200) op, zodat een drukke klas niet stilzwijgend wordt afgekapt in het exportbestand. Zonder de parameter blijft het bestaande gedrag (200) ongewijzigd. - Eerste CSV-export in deze codebase: kleine lokale csvValue()/ downloadCsv()-helpers in admin.js (komma/quote/newline-escaping, BOM zodat Excel-NL accenten en komma-scheiding herkent) - bewust nog niet gedeeld/verplaatst naar core.js, want er is nu nog maar één gebruiker (de geplande wachtwoorden-export kan dit later hergebruiken). - Tests: test/progress-export.test.js (limit wordt doorgegeven, te hoge waarde wordt afgekapt op 5000, standaardgedrag zonder parameter blijft 200) + volledige testsuite (112/112). Ook live geverifieerd: klas kiezen zonder selectie toont een hint i.p.v. te crashen, een echte CSV-download met correcte escaping van komma's/aanhalingstekens in een leerlingnaam, en de vertaalde widget-namen in de kolommen. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- public/js/admin.js | 45 +++++++++++++++++++++++++++++ public/js/core.js | 10 ++++++- src/api.js | 6 +++- test/progress-export.test.js | 56 ++++++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 test/progress-export.test.js diff --git a/VERSION b/VERSION index f63d1be..c91d8a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.28-beta \ No newline at end of file +0.4.29-beta \ No newline at end of file diff --git a/public/js/admin.js b/public/js/admin.js index 877e828..8df4c2a 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -716,6 +716,25 @@ }); } + /* CSV-download zonder library: waarden met komma/quote/newline krijgen + quotes (Excel-conventie), een BOM vooraan zodat Excel-NL accenten en de + komma-scheiding correct herkent. Eerste CSV-export in deze codebase - + bewust hier lokaal gehouden (nog maar één gebruiker); als de geplande + wachtwoorden-export dit ook nodig heeft, dan pas verplaatsen. */ + function csvValue(v){ + const s = String(v ?? ""); + return /[",\n]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s; + } + function downloadCsv(filename, rows){ + const csv = rows.map(r=>r.map(csvValue).join(",")).join("\r\n"); + const blob = new Blob([""+csv], {type:"text/csv;charset=utf-8;"}); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; a.download = filename; + document.body.appendChild(a); a.click(); a.remove(); + URL.revokeObjectURL(url); + } + /* één regel in de voortgangstijdlijn van een leerling: widgettype, score en tijdstip */ function progressRow(ev){ const row = h("div","am-row"); @@ -766,6 +785,32 @@ moreBtn.onclick = showMore; showMore(); }); + + panel.appendChild(h("div","am-group", T("amExportClass"))); + const classSelExport = classSel(); + panel.appendChild(classSelExport); + const exportBtn = h("button","am-btn", T("amExportCsv")); + exportBtn.type = "button"; + panel.appendChild(exportBtn); + exportBtn.addEventListener("click", async ()=>{ + if(!classSelExport.value){ msg(T("amPickClassHint")); return; } + let events = []; + try{ events = (await api("/progress/class/"+classSelExport.value+"?limit=5000")).events || []; } + catch(e){ msg(e.message); return; } + if(!events.length){ msg(T("amProgressEmpty")); return; } + const cls = CLASSES.find(c=>String(c.id)===classSelExport.value); + const header = [T("amExportColPupil"), T("amExportColWidget"), T("amExportColAttempts"), + T("amExportColCorrect"), T("amExportColStars"), T("amExportColDate")]; + const rows = [header, ...events.map(ev=>[ + ev.pupilName, + T("wg_"+ev.widgetType) || ev.widgetType, + ev.attempts, ev.correct, ev.stars, + new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB"), + ])]; + const safeName = (cls ? cls.name : classSelExport.value).replace(/[^a-z0-9]+/gi, "-"); + downloadCsv(`voortgang-${safeName}.csv`, rows); + msg(T("amExportDone")); + }); } /* ========================================================================= diff --git a/public/js/core.js b/public/js/core.js index b18968c..94c4377 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.28-beta"; +const VERSION = "0.4.29-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -349,6 +349,10 @@ const I18N = { castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.", castActive:"✓ Actief", amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.", + amExportClass:"Exporteren per klas", amExportCsv:"Exporteer als CSV", amPickClassHint:"Kies eerst een klas.", + amExportColPupil:"Leerling", amExportColWidget:"Widget", amExportColAttempts:"Pogingen", + amExportColCorrect:"Goed", amExportColStars:"Sterren", amExportColDate:"Datum", + amExportDone:"Export gedownload ✓", amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?", amCopy:"kopieer", amCopyAll:"kopieer alles", amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden", @@ -683,6 +687,10 @@ const I18N = { castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.", castActive:"✓ Active", amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.", + amExportClass:"Export by class", amExportCsv:"Export as CSV", amPickClassHint:"Pick a class first.", + amExportColPupil:"Pupil", amExportColWidget:"Widget", amExportColAttempts:"Attempts", + amExportColCorrect:"Correct", amExportColStars:"Stars", amExportColDate:"Date", + amExportDone:"Export downloaded ✓", amConfirmDel:"Are you sure you want to delete this user?", amCopy:"copy", amCopyAll:"copy all", amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords", diff --git a/src/api.js b/src/api.js index 234dc20..45131d1 100644 --- a/src/api.js +++ b/src/api.js @@ -984,11 +984,15 @@ export default async function api(app) { 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, PROGRESS_LIMIT])).rows; + [c.id, limit])).rows; return { events: rows.map((e) => ({ ...describeProgressEvent(e), pupilId: Number(e.pupil_id), pupilName: e.display_name || e.username })) }; }); diff --git a/test/progress-export.test.js b/test/progress-export.test.js new file mode 100644 index 0000000..f396523 --- /dev/null +++ b/test/progress-export.test.js @@ -0,0 +1,56 @@ +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'; + +function makeApp(user) { + const calls = []; + const query = async (sql, params = []) => { + calls.push({ sql, params }); + if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] }; + if (sql.includes('FROM user_roles')) return { rows: [] }; + if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: [{ id: 100, school_id: 2, name: 'Groep 4' }] }; + if (sql.includes('FROM progress_events pe')) return { rows: [] }; + return { rows: [] }; + }; + const pool = { query, async connect() { return { query, release() {} }; } }; + 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 admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 }; + +function progressCall(calls) { + return calls.find(c => c.sql.includes('FROM progress_events pe')); +} + +test('resultaten-export: ?limit= wordt doorgegeven aan de query', async () => { + const { app, calls } = await makeApp(admin); + const res = await app.inject({ method: 'GET', url: '/api/progress/class/100?limit=3000', cookies }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(progressCall(calls).params[1], 3000); +}); + +test('resultaten-export: een te hoge limit wordt afgekapt op het veilige maximum', async () => { + const { app, calls } = await makeApp(admin); + const res = await app.inject({ method: 'GET', url: '/api/progress/class/100?limit=999999', cookies }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(progressCall(calls).params[1], 5000); +}); + +test('resultaten-export: zonder limit-param blijft het standaardgedrag (200) behouden', async () => { + const { app, calls } = await makeApp(admin); + const res = await app.inject({ method: 'GET', url: '/api/progress/class/100', cookies }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(progressCall(calls).params[1], 200); +}); -- 2.45.2 From 4e4aeb4b6d89bf48b2ed1745195913af36e3d015 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 13:31:10 +0200 Subject: [PATCH 169/192] feat: exporteren van vers-gegenereerde wachtwoorden als CSV (v0.4.30-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Exporteer als CSV"-knop toegevoegd aan het bestaande multireset- resultatenscherm (klas-wachtwoorden resetten in de School-navigator), naast de bestaande "kopieer alles"-knop. - BELANGRIJKE BEPERKING, bewust zo: dit exporteert alleen wat op dit scherm al staat (net-gegenereerde wachtwoorden). Wachtwoorden worden na het zetten nergens leesbaar bewaard (alleen gehashet in de database) - er is dus geen "haal wachtwoorden van bestaande accounts op"-mogelijkheid en die komt er ook niet. - CSV bevat gebruikersnaam (het echte inlogveld) naast de naam, ook al toont het scherm zelf alleen de naam - zonder gebruikersnaam is een uitdeelvel niet bruikbaar om mee in te loggen. - Hergebruikt de csvValue()/downloadCsv()-helpers uit de vorige resultaten-exportfeature (v0.4.29-beta) - eerste echte tweede gebruiker, dus die keuze om ze lokaal in admin.js te houden i.p.v. te verplaatsen was terecht. - Geverifieerd met de volledige testsuite (112/112) en een live browserdoorloop: klas-wachtwoorden resetten (dubbele bevestigingsklik) → CSV-download met correcte escaping van een leerlingnaam met komma én aanhalingsteken. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- public/js/admin.js | 16 ++++++++++++++++ public/js/core.js | 4 +++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index c91d8a6..96ea3ee 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.29-beta \ No newline at end of file +0.4.30-beta \ No newline at end of file diff --git a/public/js/admin.js b/public/js/admin.js index 8df4c2a..f9ef761 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -615,6 +615,22 @@ 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"); diff --git a/public/js/core.js b/public/js/core.js index 94c4377..5e72a79 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.29-beta"; +const VERSION = "0.4.30-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -352,6 +352,7 @@ const I18N = { amExportClass:"Exporteren per klas", amExportCsv:"Exporteer als CSV", amPickClassHint:"Kies eerst een klas.", amExportColPupil:"Leerling", amExportColWidget:"Widget", amExportColAttempts:"Pogingen", amExportColCorrect:"Goed", amExportColStars:"Sterren", amExportColDate:"Datum", + amExportColUsername:"Gebruikersnaam", amExportDone:"Export gedownload ✓", amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?", amCopy:"kopieer", amCopyAll:"kopieer alles", @@ -690,6 +691,7 @@ const I18N = { amExportClass:"Export by class", amExportCsv:"Export as CSV", amPickClassHint:"Pick a class first.", amExportColPupil:"Pupil", amExportColWidget:"Widget", amExportColAttempts:"Attempts", amExportColCorrect:"Correct", amExportColStars:"Stars", amExportColDate:"Date", + amExportColUsername:"Username", amExportDone:"Export downloaded ✓", amConfirmDel:"Are you sure you want to delete this user?", amCopy:"copy", amCopyAll:"copy all", -- 2.45.2 From c183c8f9310bb72a919364326bcd03c124cb378b Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 13:43:32 +0200 Subject: [PATCH 170/192] perf: opstart-fetches /auth/me en /me/data parallel i.p.v. serieel (v0.4.31-beta) - Beide verzoeken hangen alleen af van dezelfde sessie-cookie, niet van elkaars antwoord - ze werden tot nu toe na elkaar aangeroepen (hydrateFromServer() startte pas na een voltooide /auth/me), wat een hele round-trip extra kost bij elke opstart voor staf/thuisgebruikers. - app.js vraagt ze nu tegelijk op via Promise.allSettled; hydrateFromServer() krijgt het /me/data-resultaat optioneel meegegeven i.p.v. het zelf (opnieuw) op te halen. Voor een leerling/ouder wordt dat resultaat genegeerd (zij krijgen sowieso geen whiteboard-data) - nooit verspild werk, soms ongebruikt, wat de server niet meer kost dan de bestaande losse aanroep die er toch al was. - De twee andere hydrateFromServer()-aanroepen (na inloggen, na een save-conflict) geven bewust niets mee en halen gewoon vers op, zoals voorheen - alleen de allereerste opstart kan dit parallelliseren. - Geverifieerd met de volledige testsuite (112/112) en drie losse boot-scenario's in de sandbox (gast/staf/leerling): in alle drie komen /auth/me en /me/data binnen ~1ms van elkaar aan (voorheen serieel), en blijft het bestaande gedrag (gast blijft gast, staf krijgt het bord, leerling krijgt de afgeschermde weergave) exact hetzelfde. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- public/js/app.js | 13 ++++++++++--- public/js/core.js | 12 +++++++++--- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/VERSION b/VERSION index 96ea3ee..48e07bc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.30-beta \ No newline at end of file +0.4.31-beta \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js index 70e1180..18d6841 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -85,10 +85,17 @@ updateEmptyHint(); (async function(){ updateBoardsUI(); try{ - const me = await api("/auth/me"); - currentUser = me.user; + /* /auth/me en /me/data hangen allebei alleen af van dezelfde sessie-cookie, + niet van elkaars antwoord - serieel wachten (eerst wie-ben-ik, dan pas + mijn-data ophalen) kost een hele round-trip voor niets. Parallel + opvragen en het tweede antwoord meegeven aan hydrateFromServer() + (die het gewoon negeert voor een leerling/ouder, zie daar) scheelt die + round-trip in het veelvoorkomende geval (staf/thuisgebruiker). */ + const [meResult, dataResult] = await Promise.allSettled([api("/auth/me"), api("/me/data")]); + if(meResult.status !== "fulfilled") throw meResult.reason; + currentUser = meResult.value.user; updateUserUI(); - await hydrateFromServer(); + await hydrateFromServer(dataResult.status === "fulfilled" ? dataResult.value : null); }catch(e){ resetToGuest(); } diff --git a/public/js/core.js b/public/js/core.js index 5e72a79..1241f27 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.30-beta"; +const VERSION = "0.4.31-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -887,7 +887,13 @@ function persistWords(){ function generalExtraFromData(data){ return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]}; } -async function hydrateFromServer(){ +/* prefetchedData: alleen gebruikt door de opstart-boot in app.js, die /me/data + al parallel met /auth/me heeft opgehaald (zie daar) i.p.v. serieel te + wachten - een leerling/ouder gebruikt het resultaat toch niet (zie de twee + vroege returns hieronder), dus dat verzoek is nooit verspild werk, alleen + soms ongebruikt. Elke andere aanroep (na inloggen, na een save-conflict) + geeft niets mee en haalt gewoon vers op, zoals voorheen. */ +async function hydrateFromServer(prefetchedData){ /* schoolleerlingen hebben geen eigen borden - zij zien enkel wat de leerkracht heeft toegewezen (zie pupil.js), nooit het whiteboard. Thuiskinderen (leerling zonder school, ouderportaal) vallen hier expres @@ -911,7 +917,7 @@ async function hydrateFromServer(){ } stopPupilView(); stopParentView(); - const res = await api("/me/data"); + const res = prefetchedData || await api("/me/data"); const d = res.data || {}; DATA_REV = typeof res.rev === "number" ? res.rev : 0; THEMES = themesFromData(d); -- 2.45.2 From 643a00c1c3bde38271a7170ed9a8fee80018fc69 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 14:03:58 +0200 Subject: [PATCH 171/192] feat: personage-aanmoediging uitgebreid naar klok, geld, plaatswaarde en spellen (v0.4.32-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - De math.js-pilot (v0.4.23-beta: eigen personage kort naast "Goed!" bij een juist antwoord) hergebruikt in vier andere widgets, zoals destijds al voorzien in de roadmap: klok (mountClock), geld (mountMoney), plaatswaarde (mountPlacevalue) en de wereldoriëntatie-spelopdracht (mountWorldQuiz - dekt bomen/vogels/planten in één keer - en mountTopography). - Zelfde patroon overal: bij S.feedback==='good' (of S.spellSolved) en een ingesteld personage verschijnt renderAvatarFace(...,"md") met de av-cheer-animatie naast het bestaande feedbackbericht - puur visueel, geen nieuw feedbacktype. Zonder personage blijft het gedrag exact ongewijzigd. - Geverifieerd met echte oplos-interacties (niet alleen mounten): klok (typemodus + toetsenbord), geld (typemodus + toetsenbord), plaatswaarde (blokken plaatsen via toetsenbord-Enter) en de spelopdracht (letters plaatsen via toetsenbord-Enter) - in alle vier verschijnt het personage daadwerkelijk in de feedback-HTML na een echt juist antwoord. Volledige testsuite: 112/112. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- public/js/core.js | 2 +- public/js/widgets/learning.js | 6 +++--- public/js/widgets/world.js | 2 ++ 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/VERSION b/VERSION index 48e07bc..fdacb98 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.31-beta \ No newline at end of file +0.4.32-beta \ No newline at end of file diff --git a/public/js/core.js b/public/js/core.js index 1241f27..d16404b 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.31-beta"; +const VERSION = "0.4.32-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; diff --git a/public/js/widgets/learning.js b/public/js/widgets/learning.js index 9ffab62..a77f51b 100644 --- a/public/js/widgets/learning.js +++ b/public/js/widgets/learning.js @@ -710,7 +710,7 @@ function mountClock(root,init){ 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');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)}; + 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);syncSettings();newTask();return{getState:()=>({...S,choices:S.choices.map(x=>({...x})),answerDigits:[...S.answerDigits]})}; } const PLACE_COLUMNS=['HD','TD','D','H','T','E']; @@ -759,7 +759,7 @@ function mountPlacevalue(root,init){ 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');card.append(feedback); + 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()}; @@ -809,7 +809,7 @@ function mountMoney(root,init){ 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():'';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)}; + 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);syncSettings();newTask();return{getState:()=>({...S,selected:[...S.selected],given:[...S.given],answerDigits:[...S.answerDigits]})}; } diff --git a/public/js/widgets/world.js b/public/js/widgets/world.js index 872b8da..07b6d56 100644 --- a/public/js/widgets/world.js +++ b/public/js/widgets/world.js @@ -663,6 +663,7 @@ function mountWorldQuiz(root,init,config){ 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"); @@ -1054,6 +1055,7 @@ function mountTopography(root,init){ 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"; -- 2.45.2 From 36695a06f68a82ec64da06f29b220d2fc0d78c26 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 14:39:00 +0200 Subject: [PATCH 172/192] fix: leerling in werken-modus kreeg abusievelijk readonly:true (v0.4.33-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Roadmap-punt "de voortgangen werken niet" onderzocht (niet blind herbouwd - eerst gediagnosticeerd via de code en git-geschiedenis). - Gevonden: pupil.js zette sinds v0.3.15-beta (toen kijken/werken-modus werd toegevoegd) readonly:true in BEIDE takken van de modus-keuze - een kopieerfout, want de hele reden voor die commit was net dat "werken" de leerling wél moet laten werken. In "werken"-modus hoort er geen readonly-vlag te staan; "kijken" (blijft ongewijzigd) is de enige modus die dat nodig heeft. - Concreet effect: de twee widgets die opts.readonly daadwerkelijk lezen (letters.js/anchor.js) verborgen voor leerlingen in werken-modus onterecht hun eigen bewerk-gereedschap (woordenlijst- editor, opslaan/bibliotheek) - gereedschap dat een leerling in werken-modus wél hoort te zien. De kernoefening zelf (spellen, slepen) werd hierdoor niet geblokkeerd, dus dit verklaart niet de volledige klacht - overige widgets negeren opts.readonly toch al. - Tegelijk de stille `.catch(()=>{})` op het voortgang-loggen vervangen door `console.warn` bij een mislukte POST /my/progress: een mislukte log (verlopen sessie, weggehaalde toewijzing, servergolf) verdween eerder spoorloos, wat een "werkt niet"-klacht onmogelijk te onderzoeken maakte. - Nieuwe test/progress-logging.test.js dekt het serverpad dat nog helemaal geen tests had: leerling-eigen toewijzing wint van klasbrede, thuiskind-zonder-school valt terug op de gekoppelde ouder, geen-toewijzing geeft 400 zonder insert, niet-leerling geeft 403, ongeldige widgetId/widgetType geeft 400 - alle zes bevestigen dat de SQL/toewijzingslogica zelf klopt, wat het probleem verder vernauwt tot de nu twee gefixte punten hierboven of iets dat alleen in de echte omgeving van de gebruiker reproduceerbaar is. - Live geverifieerd: werken-modus toont nu het bewerk-gereedschap in letters.js (voorheen verborgen), kijken-modus blijft exact ongewijzigd (overlay + verborgen gereedschap). Volledige testsuite: 118/118. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- public/js/core.js | 2 +- public/js/pupil.js | 20 ++++++- test/progress-logging.test.js | 103 ++++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 test/progress-logging.test.js diff --git a/VERSION b/VERSION index fdacb98..429af07 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.32-beta \ No newline at end of file +0.4.33-beta \ No newline at end of file diff --git a/public/js/core.js b/public/js/core.js index d16404b..16f881f 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.32-beta"; +const VERSION = "0.4.33-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; diff --git a/public/js/pupil.js b/public/js/pupil.js index 39077dd..fd9536a 100644 --- a/public/js/pupil.js +++ b/public/js/pupil.js @@ -53,9 +53,23 @@ function renderPupilWidgets(widgets, mode){ 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); - /* in kijken-modus geen voortgang: de leerling doet zelf niets */ - const opts = watch ? { readonly:true } : { readonly:true, onProgress: (p)=>{ - api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } }).catch(()=>{}); + /* 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). */ + api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } }) + .catch(e=>console.warn("voortgang loggen mislukt:", e.message)); }}; def.mount(body, w.state || null, opts); }); diff --git a/test/progress-logging.test.js b/test/progress-logging.test.js new file mode 100644 index 0000000..9306cd5 --- /dev/null +++ b/test/progress-logging.test.js @@ -0,0 +1,103 @@ +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'; + +/* "de voortgangen werken niet" (gebruikersklacht) - dit pad had nog GEEN enkele + test (mocked-pool of anders), ondanks dat het de kern van de voortgangs- + feature is: een leerling logt een afgerond moment, en het datamodel bepaalt + zelf teacher_id/board_id uit de toewijzing (nooit van de client). Dekt de + drie manieren waarop een toewijzing gevonden kan worden (leerling-eigen, + klasbreed, thuiskind-via-ouder) plus de foutpaden. */ +function makeApp(user, { assignmentRows = {}, parentRows = [] } = {}) { + const calls = []; + const query = async (sql, params = []) => { + calls.push({ sql, params }); + if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] }; + if (sql.includes('FROM user_roles')) return { rows: [] }; + if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: assignmentRows.byPupil ? [assignmentRows.byPupil] : [] }; + if (sql === 'SELECT * FROM assignments WHERE class_id = $1') return { rows: assignmentRows.byClass ? [assignmentRows.byClass] : [] }; + if (sql.startsWith('SELECT parent_id FROM parent_children')) return { rows: parentRows }; + if (sql.startsWith('INSERT INTO progress_events')) return { rows: [] }; + return { rows: [] }; + }; + const pool = { query, async connect() { return { query, release() {} }; } }; + 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 validBody = { widgetId: 'letters:0', widgetType: 'letters', attempts: 1, correct: 1, stars: 1 }; +function insertCall(calls) { + return calls.find(c => c.sql.startsWith('INSERT INTO progress_events')); +} + +test('voortgang: leerling-eigen toewijzing wint en levert de juiste teacher_id/board_id op', async () => { + const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 }; + const { app, calls } = await makeApp(pupil, { + assignmentRows: { + byPupil: { teacher_id: 3, board_id: 'bord-a' }, + byClass: { teacher_id: 4, board_id: 'bord-b' }, // zou genegeerd moeten worden + }, + }); + const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: validBody }); + assert.equal(res.statusCode, 200, res.body); + const ins = insertCall(calls); + assert.ok(ins, 'INSERT verwacht'); + assert.equal(ins.params[1], 3); + assert.equal(ins.params[2], 'bord-a'); +}); + +test('voortgang: valt terug op de klasbrede toewijzing als er geen eigen toewijzing is', async () => { + const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 }; + const { app, calls } = await makeApp(pupil, { + assignmentRows: { byClass: { teacher_id: 4, board_id: 'bord-b' } }, + }); + const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: validBody }); + assert.equal(res.statusCode, 200, res.body); + const ins = insertCall(calls); + assert.equal(ins.params[1], 4); + assert.equal(ins.params[2], 'bord-b'); +}); + +test('voortgang: thuiskind zonder school valt terug op de gekoppelde ouder als "leerkracht"', async () => { + const homeChild = { id: 11, username: 'timo', role: 'pupil', school_id: null, class_id: null, data: {}, data_rev: 0 }; + const { app, calls } = await makeApp(homeChild, { parentRows: [{ parent_id: 7 }] }); + const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: validBody }); + assert.equal(res.statusCode, 200, res.body); + const ins = insertCall(calls); + assert.equal(ins.params[1], 7); + assert.equal(ins.params[2], 'thuis'); +}); + +test('voortgang: leerling op school zonder enige toewijzing krijgt 400, geen stille silent-fail insert', async () => { + const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 }; + const { app, calls } = await makeApp(pupil, {}); + const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: validBody }); + assert.equal(res.statusCode, 400, res.body); + assert.ok(!insertCall(calls), 'er mag geen INSERT gebeurd zijn'); +}); + +test('voortgang: niet-leerling (bv. leerkracht) krijgt 403', async () => { + const teacher = { id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0 }; + const { app } = await makeApp(teacher, {}); + const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: validBody }); + assert.equal(res.statusCode, 403, res.body); +}); + +test('voortgang: ontbrekende widgetId/widgetType levert 400 op, niet een kapotte insert', async () => { + const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 }; + const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } }); + const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { attempts: 1, correct: 1 } }); + assert.equal(res.statusCode, 400, res.body); + assert.ok(!insertCall(calls)); +}); -- 2.45.2 From e7bb76b76d5cb5dd239ab3d76e545ec151b45f2f Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 15:31:26 +0200 Subject: [PATCH 173/192] feat: klassenmanagement - niveaugroep + niveau per leerling per vakgebied (v0.4.34-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Grootste openstaande roadmap-punt. Nieuwe tabel pupil_levels (db/019_pupil_levels.sql, zelfde class_id/pupil_id-vorm als assignments) legt per vakgebied (taal/rekenen/world) een niveaugroep vast (groep 1-8, gebaseerd op de leerjaren van de basisschool waaraan de leerdoelen gekoppeld zijn) + niveau (1-3, fijnmazige verfijning BINNEN die groep). Klas-standaard met individuele leerling-overrides, net zo laagdrempelig instelbaar als de standaard - het speciaal- onderwijs-geval waar dat vanaf het begin de norm is. - Server: GET/PUT/DELETE /levels/class/:id en /levels/pupil/:id (src/api.js) hergebruiken exact het assignments-patroon (upsert via partial-unique-index, pupil-eerst-dan-klas-resolutie), plus GET /my/level voor de leerling-kant. Zelfde permissie (assignments.manage) en sameSchool/teacherOnly/pupilAccessible- guards als de bestaande toewijzingsroutes. - Nieuwe admin-tab "Klassenmanagement": klas + vakgebied kiezen, een klas-standaard instellen, en per leerling de effectieve groep/niveau zien (overerft van de klas tenzij een eigen afwijking is ingesteld) met bewerk- en terug-naar-standaard-knoppen. - ECHTE inhoudskoppeling, niet alleen een standaardwaarde: rekenen is het eerste vakgebied waarvan de SLO/TULE-leerlijn direct in getalgrenzen te vertalen is (groep 3 t/m 20, groep 4 t/m 100, groep 5 t/m 1000, groep 6 t/m 10.000). Nieuwe SLO_MATH_RANGES/mathRangeFor() in data.js; math.js's gen() gebruikt dit voor de daadwerkelijke getallen zodra een leerling een groep heeft (S.yearGroup), met het bestaande niveau 1-3 als verfijning daarbinnen. Zonder groep blijft het oude vaste 10/20/100-gedrag exact bestaan. - pupil.js geeft groep/niveau door aan taal/rekenen-widgets bij het mounten: niveau is een standaardwaarde (een al opgeslagen bord-niveau blijft leidend), groep wordt voor rekenen-widgets altijd meegegeven (er is geen "oud" groep-concept om te respecteren). Terzijde ook de stille .catch() op de nieuwe async renderPupilWidgets() opgelost. - Taal/wereldoriëntatie krijgen deze ronde nog geen eigen inhoudskoppeling (vraagt een eigen contentbron per groep, nog niet uitgezocht) - eerlijk benoemd als vervolgstap, geen loze belofte. - Tests: test/pupil-levels.test.js (10 tests) dekt de server-routes (mocked-pool-patroon) én - via dezelfde vm-techniek als elders in test/widgets.test.js voor MONEY_LEVELS - een ECHTE (niet regex-)test van mathRangeFor die bevestigt dat elke groep de juiste getalgrens oplevert en niveau nooit boven de groep-grens uitkomt. Twee bugs gevonden en gefixt tijdens het schrijven van deze tests: null-yearGroup werd per ongeluk als groep 1 behandeld i.p.v. "geen groep", en de testrol had geen klas-eigenaarschap waardoor 403 in plaats van 200 terugkwam. Volledige testsuite: 128/128. - Live geverifieerd in de sandbox: klas-standaard + leerling-override instellen in de nieuwe admin-tab, en drie scenario's in de leerling-omgeving die bevestigen dat de daadwerkelijk gegenereerde sommen binnen de juiste groep-grens blijven (geen klassenmanagement → ongewijzigd oud gedrag; groep zonder eigen bord-niveau → volledige groep-grens; groep mét een al opgeslagen bord-niveau → dat niveau blijft leidend, geschaald naar de juiste groep-grens). - public/js/data.js bevatte al niet-gerelateerde, nog niet gecommitte wijzigingen van vóór deze sessie (extra taalthema's/woorden) - alleen de SLO_MATH_RANGES/mathRangeFor-hunk is hier meegenomen (git add -p), de rest blijft bewust ongemoeid staan, net als test/language-library.test.js. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- db/019_pupil_levels.sql | 24 ++++++ public/js/admin.js | 160 +++++++++++++++++++++++++++++++++++++- public/js/core.js | 14 +++- public/js/data.js | 31 ++++++++ public/js/pupil.js | 24 +++++- public/js/widgets/math.js | 20 +++-- src/api.js | 117 ++++++++++++++++++++++++++++ test/pupil-levels.test.js | 146 ++++++++++++++++++++++++++++++++++ 9 files changed, 527 insertions(+), 11 deletions(-) create mode 100644 db/019_pupil_levels.sql create mode 100644 test/pupil-levels.test.js diff --git a/VERSION b/VERSION index 429af07..98ff426 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.33-beta \ No newline at end of file +0.4.34-beta \ No newline at end of file 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/public/js/admin.js b/public/js/admin.js index f9ef761..25ff9ea 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -21,6 +21,10 @@ 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"]; /* navigatie binnen "School": welk niveau van de boom je ziet. {type:"overview"} - tegels (scholen op "alle scholen", anders @@ -229,6 +233,10 @@ { 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:()=>can("assignments.manage"), + 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:()=>currentUser.role==="super", keywords:()=>["opslag","storage","afbeeldingen","images","quota"] }, @@ -1080,6 +1088,156 @@ return box; } + function yearGroupSel(current){ + const sel = h("select","am-sel"); + for(let g=1; g<=8; g++) sel.appendChild(new Option(T("amYearGroupN").replace("{n}", g), g)); + sel.value = current ?? 4; + return sel; + } + function levelSel(current){ + const sel = h("select","am-sel"); + [1,2,3].forEach(n=>sel.appendChild(new Option(T("lvl"+n), n))); + sel.value = current ?? 1; + return sel; + } + function describeLevel(effective, isOverride){ + if(!effective) return T("amLevelNone"); + const base = `${T("amYearGroupN").replace("{n}", effective.yearGroup)} · ${T("lvl"+effective.level)}`; + return isOverride ? base : `${base} (${T("amLevelInherited")})`; + } + + /* klas-standaard: geldt voor iedereen in de klas tenzij een leerling een + eigen afwijking heeft (zie renderPupilLevelRow hieronder). */ + function renderClassDefaultCard(classId, subject, current, onSaved){ + const box = h("div","am-card"); + box.appendChild(h("div","am-card-title", T("amLevelClassDefault"))); + const row = h("div","am-inline"); + const ygSel = yearGroupSel(current && current.yearGroup); + const lvlSel = levelSel(current && current.level); + row.append(field("amLevelYearGroup", ygSel), field("amLevelLevel", lvlSel)); + const saveBtn = h("button","am-btn", T("save")); + saveBtn.type = "button"; + saveBtn.addEventListener("click", async ()=>{ + try{ + await api(`/levels/class/${classId}`, {method:"PUT", body:{subject, yearGroup:+ygSel.value, level:+lvlSel.value}}); + msg(T("amLevelSaved")); + await onSaved(); + }catch(e){ msg(e.message); } + }); + row.appendChild(saveBtn); + box.appendChild(row); + return box; + } + + /* één leerling: toont de EFFECTIEVE groep/niveau (eigen afwijking, anders de + klas-standaard, gedempt met "erft van klas"). Instellen van een eigen + afwijking is even laagdrempelig als de klas-standaard - het + speciaal-onderwijs-geval waar dat vanaf het begin de norm is, mag geen + verstopt uitzonderingspad zijn. */ + function renderPupilLevelRow(pupil, subject, override, classDefault, onSaved){ + const effective = override || classDefault; + const wrap = h("div"); + const row = h("div","am-row am-person"); + const info = h("div","am-person-info"); + info.appendChild(h("span","am-name", pupil.displayName)); + info.appendChild(h("span","am-role", describeLevel(effective, !!override))); + row.appendChild(info); + + const editBox = h("div","am-inline"); + editBox.style.display = "none"; + const ygSel = yearGroupSel(effective && effective.yearGroup); + const lvlSel = levelSel(effective && effective.level); + editBox.append(field("amLevelYearGroup", ygSel), field("amLevelLevel", lvlSel)); + const saveBtn = h("button","am-btn", T("save")); + saveBtn.type = "button"; + saveBtn.addEventListener("click", async ()=>{ + try{ + await api(`/levels/pupil/${pupil.id}`, {method:"PUT", body:{subject, yearGroup:+ygSel.value, level:+lvlSel.value}}); + msg(T("amLevelSaved")); + await onSaved(); + }catch(e){ msg(e.message); } + }); + editBox.appendChild(saveBtn); + + const tools = h("div","am-tools"); + const editBtn = h("button","am-btn", T("amEdit")); + editBtn.type = "button"; + editBtn.addEventListener("click", ()=>{ + editBox.style.display = editBox.style.display==="none" ? "flex" : "none"; + }); + tools.appendChild(editBtn); + if(override){ + const resetBtn = h("button","am-btn", T("amLevelReset")); + resetBtn.type = "button"; + resetBtn.addEventListener("click", async ()=>{ + try{ + await api(`/levels/pupil/${pupil.id}?subject=${subject}`, {method:"DELETE"}); + msg(T("amLevelSaved")); + await onSaved(); + }catch(e){ msg(e.message); } + }); + tools.appendChild(resetBtn); + } + row.appendChild(tools); + wrap.append(row, editBox); + return wrap; + } + + /* klassenmanagement: per vakgebied een niveaugroep (leerjaar 1-8, waaraan + de leerdoelen gekoppeld zijn) + niveau (1-3) instellen, met een + klas-standaard en individuele afwijkingen - zie roadmap-memo. */ + function renderKlassenmanagementPanel(panel){ + const schoolChosen = currentUser.role!=="super" || !!selSchool; + if(!schoolChosen){ + panel.appendChild(h("div","guestnote", T("amPickSchoolHint"))); + return; + } + panel.appendChild(h("div","am-group", T("amLevels"))); + const clsSel = classSel(kmClass ?? ""); + panel.appendChild(clsSel); + + const subjectRow = h("div","am-inline"); + panel.appendChild(subjectRow); + const bodyHolder = h("div"); + panel.appendChild(bodyHolder); + + async function loadBody(){ + bodyHolder.innerHTML = ""; + if(!kmClass) return; + bodyHolder.appendChild(h("div","guestnote", "…")); + let data; + try{ data = await api(`/levels/class/${kmClass}?subject=${kmSubject}`); } + catch(e){ bodyHolder.innerHTML = ""; bodyHolder.appendChild(h("div","am-msg", e.message)); return; } + bodyHolder.innerHTML = ""; + bodyHolder.appendChild(renderClassDefaultCard(kmClass, kmSubject, data.classDefault, loadBody)); + bodyHolder.appendChild(h("div","am-group", T("amLevelPupils"))); + const list = h("div","am-list"); + const overrideMap = new Map(data.pupilOverrides.map(o=>[o.pupilId, o])); + const pupilsInClass = USERS.filter(u=>u.role==="pupil" && String(u.classId)===String(kmClass)); + if(!pupilsInClass.length) list.appendChild(h("div","guestnote", T("amNoClass"))); + pupilsInClass.forEach(u=>{ + list.appendChild(renderPupilLevelRow(u, kmSubject, overrideMap.get(u.id), data.classDefault, loadBody)); + }); + bodyHolder.appendChild(list); + } + + KM_SUBJECTS.forEach(s=>{ + const b = h("button","am-btn", T("amSubject_"+s)); + b.type = "button"; + b.classList.toggle("active", s===kmSubject); + b.addEventListener("click", ()=>{ + kmSubject = s; + subjectRow.querySelectorAll("button").forEach(x=>x.classList.remove("active")); + b.classList.add("active"); + loadBody(); + }); + subjectRow.appendChild(b); + }); + + clsSel.addEventListener("change", ()=>{ kmClass = clsSel.value || null; loadBody(); }); + if(kmClass) loadBody(); + } + /* niveau 0: "alle scholen" - alleen systeemmanager. Tegel per school + Systeemmanagers/Ouders/Zonder school + "+ School". */ function renderRootOverview(panel){ @@ -1392,7 +1550,7 @@ const tabDefs = adminTabDefs(); if(!tabDefs.some(([t])=>t===adminTab)) adminTab = tabDefs[0][0]; - const panels = { school: renderSchoolNavigator, toewijzingen: renderToewijzingenPanel, voortgang: renderVoortgangPanel, opslag: renderImageStoragePanel }; + const panels = { school: renderSchoolNavigator, toewijzingen: renderToewijzingenPanel, voortgang: renderVoortgangPanel, klassenmanagement: renderKlassenmanagementPanel, opslag: renderImageStoragePanel }; tabDefs.forEach(([t])=>{ const panel = h("div","am-tabpanel"); panel.dataset.panel = t; diff --git a/public/js/core.js b/public/js/core.js index 16f881f..92405c0 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.33-beta"; +const VERSION = "0.4.34-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -354,6 +354,12 @@ const I18N = { amExportColCorrect:"Goed", amExportColStars:"Sterren", amExportColDate:"Datum", amExportColUsername:"Gebruikersnaam", amExportDone:"Export gedownload ✓", + amLevels:"Klassenmanagement", amLevelsSub:"Stel per vakgebied een niveaugroep en niveau in.", + amLevelYearGroup:"Groep", amLevelLevel:"Niveau", amYearGroupN:"Groep {n}", + amLevelClassDefault:"Klas-standaard", amLevelPupils:"Leerlingen", + amLevelInherited:"erft van klas", amLevelNone:"nog niet ingesteld", + amLevelReset:"terug naar klas-standaard", amLevelSaved:"Opgeslagen ✓", + amSubject_taal:"Taal", amSubject_rekenen:"Rekenen", amSubject_world:"Wereldoriëntatie", amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?", amCopy:"kopieer", amCopyAll:"kopieer alles", amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden", @@ -693,6 +699,12 @@ const I18N = { amExportColCorrect:"Correct", amExportColStars:"Stars", amExportColDate:"Date", amExportColUsername:"Username", amExportDone:"Export downloaded ✓", + amLevels:"Class management", amLevelsSub:"Set a year group and level per subject.", + amLevelYearGroup:"Year group", amLevelLevel:"Level", amYearGroupN:"Year {n}", + amLevelClassDefault:"Class default", amLevelPupils:"Pupils", + amLevelInherited:"inherited from class", amLevelNone:"not set yet", + amLevelReset:"reset to class default", amLevelSaved:"Saved ✓", + amSubject_taal:"Language", amSubject_rekenen:"Maths", amSubject_world:"World orientation", amConfirmDel:"Are you sure you want to delete this user?", amCopy:"copy", amCopyAll:"copy all", amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords", diff --git a/public/js/data.js b/public/js/data.js index 8f60d37..01bca88 100644 --- a/public/js/data.js +++ b/public/js/data.js @@ -16,6 +16,37 @@ const DIGIT_DOTCODE = { "0":[], "1":[2], "2":[2,5], "3":[1,2,3], "4":[1,3,4,6], "5":[1,2,3,4,5], "6":[1,2,3,4,5,6], "7":[1,2,4,5], "8":[1,2,3,4,6], "9":[1,4,6] }; +/* Klassenmanagement (v0.4.34-beta e.v.): rekenen is het eerste vakgebied waarvan + de leerlijn direct in getalgrenzen te vertalen is (SLO/TULE: groep 3 + automatiseert optellen/aftrekken t/m 20, groep 4 t/m 100, groep 5 t/m 1000, + groep 6 t/m 10.000 met de tafels verder uitgebouwd, groep 7-8 bouwen daarop + voort). math.js gebruikt dit als de daadwerkelijke inhoud zodra een leerling + een groep heeft (zie mathRangeFor hieronder) - zonder groep blijft het oude, + vaste 10/20/100-gedrag exact bestaan. */ +const SLO_MATH_RANGES = { + 1:{addSub:10, timesTable:5}, + 2:{addSub:10, timesTable:5}, + 3:{addSub:20, timesTable:5}, + 4:{addSub:100, timesTable:10}, + 5:{addSub:1000, timesTable:10}, + 6:{addSub:10000,timesTable:12}, + 7:{addSub:10000,timesTable:12}, + 8:{addSub:10000,timesTable:12}, +}; +/* niveau (1-3) is de verfijning BINNEN een groep - een breukdeel van de + groep-grens, nooit onder de kleinste bestaande standaardwaarde (10) of + onder tafel 3. Levert bij ontbrekend yearGroup expres geen waarde (math.js + valt dan terug op zijn eigen oude vaste 10/20/100-logica). */ +function mathRangeFor(yearGroup, level){ + if(yearGroup == null) return null; + const g = SLO_MATH_RANGES[Math.max(1, Math.min(8, +yearGroup || 1))]; + if(!g) return null; + const frac = level===1 ? .25 : level===2 ? .6 : 1; + return { + addSub: Math.max(10, Math.round(g.addSub*frac/5)*5), + timesTable: Math.max(3, Math.round(g.timesTable*frac)), + }; +} const VOWELS = "aeiou"; function dotGrid(letter){ const code = (Object.prototype.hasOwnProperty.call(DIGIT_DOTCODE, letter) ? DIGIT_DOTCODE[letter] : DOTCODE[letter]) || []; diff --git a/public/js/pupil.js b/public/js/pupil.js index fd9536a..1c34223 100644 --- a/public/js/pupil.js +++ b/public/js/pupil.js @@ -18,7 +18,7 @@ function pupilAllowedDefs(){ return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat)); } -function renderPupilWidgets(widgets, mode){ +async function renderPupilWidgets(widgets, mode){ pupilView.innerHTML = ""; const defs = pupilAllowedDefs(); const watch = mode === "kijken"; @@ -32,6 +32,16 @@ function renderPupilWidgets(widgets, mode){ 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 = {}; + await Promise.all(subjects.map(async subject=>{ + try{ levels[subject] = await api("/my/level?subject="+subject); } + catch(e){ levels[subject] = null; } + })); usable.forEach(({ w, def }, idx)=>{ const card = document.createElement("div"); card.className = "widget pupil-card" + (watch ? " watch" : ""); @@ -71,7 +81,15 @@ function renderPupilWidgets(widgets, mode){ api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } }) .catch(e=>console.warn("voortgang loggen mislukt:", e.message)); }}; - def.mount(body, w.state || null, opts); + /* alleen aanvullen wat het bord zelf nog niet expliciet heeft ingesteld - + een leerkracht die zelf al een niveau op dit bord koos, blijft leidend. */ + const lvl = levels[def.cat]; + const state = w.state ? { ...w.state } : {}; + if(lvl){ + if(state.level == null) state.level = lvl.level; + if(def.cat === "rekenen" && lvl.yearGroup != null) state.yearGroup = lvl.yearGroup; + } + def.mount(body, Object.keys(state).length ? state : null, opts); }); } @@ -84,7 +102,7 @@ async function pupilPoll(){ const sig = mode + "|" + JSON.stringify(res.widgets || []); if(sig !== pupilLastSig){ pupilLastSig = sig; - renderPupilWidgets(res.widgets || [], mode); + await renderPupilWidgets(res.widgets || [], mode); } if(mode !== pupilMode){ pupilMode = mode; diff --git a/public/js/widgets/math.js b/public/js/widgets/math.js index d461bbe..022f92d 100644 --- a/public/js/widgets/math.js +++ b/public/js/widgets/math.js @@ -5,9 +5,13 @@ function mountMath(root, initState, mode, opts){ const ROUND = 10; const onProgress = opts && opts.onProgress; - const S = { level:1, snd:true, amode:"blocks", q:null, tries:0, done:0, good:0, input:"", busy:false }; + const S = { level:1, yearGroup:null, snd:true, amode:"blocks", q:null, tries:0, done:0, good:0, input:"", busy:false }; if(initState){ S.level = initState.level||1; + /* yearGroup komt van klassenmanagement (pupil.js), niet van het bord zelf + - bewust niet in getState() opgenomen, dit is een per-leerling waarde, + geen door de leerkracht op het bord ingestelde inhoud. */ + S.yearGroup = initState.yearGroup || null; S.snd = initState.snd!==false; S.amode = initState.amode||"blocks"; } @@ -161,21 +165,27 @@ function mountMath(root, initState, mode, opts){ 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); let a, b, ans, txt; if(mode==="add"){ - const max = L===1 ? 10 : L===2 ? 20 : 100; + const max = 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 = L===1 ? 10 : L===2 ? 20 : 100; + const max = 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 = L===1 ? 5 : L===2 ? 10 : 12; + const tmax = slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12); a = 1+rnd(tmax); b = 1+rnd(L===3 ? 12 : 10); ans = a*b; txt = `${a} × ${b} =`; }else if(mode==="div"){ - const tmax = L===1 ? 5 : L===2 ? 10 : 12; + const tmax = slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12); const t = 1+rnd(tmax), q = 1+rnd(L===3 ? 12 : 10); a = t*q; ans = q; txt = `${a} ${S.lang==="nl" || LANG==="nl" ? ":" : "÷"} ${t} =`; diff --git a/src/api.js b/src/api.js index 45131d1..278a693 100644 --- a/src/api.js +++ b/src/api.js @@ -917,6 +917,123 @@ export default async function api(app) { return { widgets, mode: a.mode || 'werken', updatedAt: a.updated_at }; }); + // ---- 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 - 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']; + 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) 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 }; + }); + // ---- 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 diff --git a/test/pupil-levels.test.js b/test/pupil-levels.test.js new file mode 100644 index 0000000..16aee04 --- /dev/null +++ b/test/pupil-levels.test.js @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { runInNewContext } from 'node:vm'; +import Fastify from 'fastify'; +import cookie from '@fastify/cookie'; +import rateLimit from '@fastify/rate-limit'; +import api from '../src/api.js'; + +/* Klassenmanagement: niveaugroep (groep 1-8) + niveau (1-3) per leerling per + vakgebied. Zelfde soort dekking als test/progress-logging.test.js voor het + server-pad, plus een echte (niet regex-)test van de SLO-getalgrenzen die + math.js gebruikt zodra een leerling een groep heeft. */ + +function makeApp(user, { classRow = { id: 100, school_id: 2, name: 'Groep 4' }, classDefault = null, pupilOverride = null, classPupilOverrides = [] } = {}) { + const calls = []; + const query = async (sql, params = []) => { + calls.push({ sql, params }); + if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] }; + if (sql.includes('FROM user_roles')) return { rows: [] }; + if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: classRow ? [classRow] : [] }; + if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [user] }; + if (sql === 'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2') return { rows: classDefault ? [classDefault] : [] }; + if (sql === 'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2') return { rows: pupilOverride ? [pupilOverride] : [] }; + if (sql.includes('FROM pupil_levels pl JOIN users u')) return { rows: classPupilOverrides }; + if (sql.startsWith('INSERT INTO pupil_levels')) return { rows: [] }; + if (sql.startsWith('DELETE FROM pupil_levels')) return { rows: [] }; + return { rows: [] }; + }; + const pool = { query, async connect() { return { query, release() {} }; } }; + 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 teacher = { id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0 }; +const admin = { id: 4, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 }; +const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 100, data: {}, data_rev: 0 }; + +test('klassenmanagement: klas-standaard opslaan doet de juiste upsert-query', async () => { + const { app, calls } = await makeApp(admin); + const res = await app.inject({ + method: 'PUT', url: '/api/levels/class/100', cookies, + payload: { subject: 'rekenen', yearGroup: 4, level: 2 }, + }); + assert.equal(res.statusCode, 200, res.body); + const ins = calls.find(c => c.sql.startsWith('INSERT INTO pupil_levels')); + assert.ok(ins); + assert.deepEqual(ins.params, [100, 'rekenen', 4, 2]); +}); + +test('klassenmanagement: ongeldig vakgebied geeft 400', async () => { + const { app } = await makeApp(teacher); + const res = await app.inject({ + method: 'PUT', url: '/api/levels/class/100', cookies, + payload: { subject: 'gym', yearGroup: 4, level: 2 }, + }); + assert.equal(res.statusCode, 400, res.body); +}); + +test('klassenmanagement: groep buiten 1-8 geeft 400', async () => { + const { app } = await makeApp(teacher); + const res = await app.inject({ + method: 'PUT', url: '/api/levels/class/100', cookies, + payload: { subject: 'rekenen', yearGroup: 12, level: 2 }, + }); + assert.equal(res.statusCode, 400, res.body); +}); + +test('klassenmanagement: GET class levert klas-standaard én leerling-overrides in één keer', async () => { + const { app } = await makeApp(admin, { + classDefault: { year_group: 4, level: 2 }, + classPupilOverrides: [{ pupil_id: 9, year_group: 6, level: 3 }], + }); + const res = await app.inject({ method: 'GET', url: '/api/levels/class/100?subject=rekenen', cookies }); + assert.equal(res.statusCode, 200, res.body); + const body = res.json(); + assert.deepEqual(body.classDefault, { yearGroup: 4, level: 2 }); + assert.deepEqual(body.pupilOverrides, [{ pupilId: 9, yearGroup: 6, level: 3 }]); +}); + +test('klassenmanagement: /my/level - leerling-override wint van klas-standaard', async () => { + const { app } = await makeApp(pupil, { pupilOverride: { year_group: 6, level: 3 } }); + const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies }); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(res.json(), { yearGroup: 6, level: 3 }); +}); + +test('klassenmanagement: /my/level - valt terug op de klas-standaard zonder eigen override', async () => { + const { app } = await makeApp(pupil, { classDefault: { year_group: 4, level: 2 } }); + const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies }); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(res.json(), { yearGroup: 4, level: 2 }); +}); + +test('klassenmanagement: /my/level - niets ingesteld geeft de bestaande widget-standaardwaarde', async () => { + const { app } = await makeApp(pupil); + const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies }); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(res.json(), { yearGroup: null, level: 1 }); +}); + +test('klassenmanagement: leerkracht-rol wordt geweigerd op /my/level (alleen leerlingen)', async () => { + const { app } = await makeApp(teacher); + const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies }); + assert.equal(res.statusCode, 403, res.body); +}); + +/* Echte (niet regex-)test van de SLO-getalgrenzen: data.js's SLO_MATH_RANGES/ + mathRangeFor zijn pure functies zonder DOM-afhankelijkheid, dus rechtstreeks + uitvoerbaar in een geïsoleerde VM-context - zelfde techniek als elders in + test/widgets.test.js voor MONEY_LEVELS/createMoneyTask. */ +test('SLO-rekengrenzen: groep bepaalt de daadwerkelijke getalgrens, niet meer de vaste 10/20/100-klem', async () => { + const source = await readFile('public/js/data.js', 'utf8'), context = {}; + runInNewContext(source + ';globalThis.__mathRange = mathRangeFor', context); + const mathRangeFor = context.__mathRange; + assert.equal(mathRangeFor(3, 3).addSub, 20, 'groep 3 t/m 20'); + assert.equal(mathRangeFor(4, 3).addSub, 100, 'groep 4 t/m 100'); + assert.equal(mathRangeFor(5, 3).addSub, 1000, 'groep 5 t/m 1000'); + assert.equal(mathRangeFor(6, 3).addSub, 10000, 'groep 6 t/m 10.000'); + // niveau is een verfijning BINNEN de groep: nooit hoger dan de groep-grens + for (let g = 1; g <= 8; g++) { + const r1 = mathRangeFor(g, 1), r2 = mathRangeFor(g, 2), r3 = mathRangeFor(g, 3); + assert.ok(r1.addSub <= r2.addSub && r2.addSub <= r3.addSub, `groep ${g}: niveau moet oplopen`); + assert.ok(r1.addSub >= 10, `groep ${g} niveau 1 nooit onder de kleinste bestaande standaardwaarde`); + } + // zonder groep (geen klassenmanagement ingesteld) geeft mathRangeFor niets terug - + // math.js valt dan terug op zijn eigen oude vaste logica + assert.equal(mathRangeFor(null, 2), null); + assert.equal(mathRangeFor(undefined, 2), null); +}); + +test('math.js: gen() gebruikt mathRangeFor als er een groep is, anders de oude vaste klem', async () => { + const source = await readFile('public/js/widgets/math.js', 'utf8'); + assert.match(source, /const slo = mathRangeFor\(S\.yearGroup, L\)/); + assert.match(source, /const max = slo \? slo\.addSub : \(L===1 \? 10 : L===2 \? 20 : 100\)/); + assert.match(source, /const tmax = slo \? slo\.timesTable : \(L===1 \? 5 : L===2 \? 10 : 12\)/); + assert.ok(source.includes('S.yearGroup = initState.yearGroup || null')); +}); -- 2.45.2 From 8ccfa5dc8c75b33802a3717c366b89d65d22600b Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 15:56:14 +0200 Subject: [PATCH 174/192] feat: voortgang flinke UI-upgrade + bug (1 opdracht i.p.v. groep) + grafiek (v0.4.35-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bug gevonden en bevestigd (niet blind aangenomen): math.js riep onProgress maar op ÉÉN plek aan, in endRound(), NA een heel blok van 10 sommen, met alleen het geaggregeerde totaal. Eén sessie van 10 sommen - "een groep opdrachten" - leverde dus precies 1 rij op in progress_events, niet 10, en maakte "welke som ging fout" onmogelijk te zien (alleen een totaaltelling per blok). - Fix: onProgress verplaatst naar check(), bij elke afgeronde som (goed op de eerste keer, of opgeven na 3 pogingen) - een sessie van 10 sommen levert nu 10 progress_events-rijen op, elk met een nieuw optioneel detail-veld (welke som, gegeven antwoord, juist antwoord). letters.js/ball.js blijven deze ronde ongemoeid (hun granulariteit veroorzaakte niet deze bug). - Nieuwe kolom progress_events.detail (JSONB, nullable, puur additief - db/020_progress_detail.sql). POST /my/progress accepteert het optioneel; te groot/geen plain object wordt stilzwijgend genegeerd zonder de rest van de log-poging te laten falen (detail is decoratief, geen kernfunctionaliteit). - Voortgang-tab (admin.js) herontworpen, zelfde ontwerptaal als de eerdere School-navigator-herbouw: vakgebied-tabs, een klassenmanagement-infobadge (Groep {n} · Niveau {n}, opgehaald via de bestaande /levels/pupil/:id-route) zodat resultaten meteen tegen het toegewezen niveau afgezet kunnen worden, een met de hand getekende SVG-nauwkeurigheidsring + staafdiagram (goed/fout per dag, laatste 14 dagen - geen library, zelfde aanpak als avatar.js) en een "recente fouten"-lijst die precies toont welke som fout ging. De bestaande ruwe tijdlijn blijft eronder staan, niets is verwijderd. - Terzijde gevonden en gefixt: .am-btn.active bestond nergens in de CSS, waardoor de vakgebied-tabs (hier én in Klassenmanagement, v0.4.34-beta) geen enkele visuele aanduiding hadden welke actief was. - Tests: test/progress-logging.test.js uitgebreid met 4 tests voor het detail-veld (opgeslagen, te groot genegeerd, geen plain object genegeerd, afwezig blijft null). Volledige testsuite: 132/132. - Live geverifieerd: een math.js-sessie met een mix van goede/foute sommen doorlopen en bevestigd dat er 3 aparte POST /my/progress- aanroepen gebeuren voor 3 sommen (niet 1 aggregaat), elk met het juiste detail; de herbouwde Voortgang-tab geopend met gemockte data en met screenshots bevestigd dat de nauwkeurigheidsring (38% = 3/8, rekenkundig geverifieerd), het klassenmanagement-infobadge, de staafdiagram en de "recente fouten"-lijst (met de daadwerkelijke som) correct tonen, én dat de active-tab-fix ook op de Klassenmanagement- tab zichtbaar is. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- db/020_progress_detail.sql | 7 ++ public/css/teach.css | 20 ++++ public/js/admin.js | 181 ++++++++++++++++++++++++++++++---- public/js/core.js | 8 +- public/js/widgets/math.js | 9 +- src/api.js | 21 +++- test/progress-logging.test.js | 38 +++++++ 8 files changed, 260 insertions(+), 26 deletions(-) create mode 100644 db/020_progress_detail.sql diff --git a/VERSION b/VERSION index 98ff426..c717fad 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.34-beta \ No newline at end of file +0.4.35-beta \ No newline at end of file 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/public/css/teach.css b/public/css/teach.css index f381a60..d1757bb 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1449,6 +1449,9 @@ 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; @@ -2243,6 +2246,23 @@ body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000 /* 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;} + +/* Voortgang-tab: klassenmanagement-koppeling + grafiek (v0.4.35-beta e.v.) */ +#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%;} diff --git a/public/js/admin.js b/public/js/admin.js index 25ff9ea..1c96378 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -759,6 +759,75 @@ URL.revokeObjectURL(url); } + /* eenvoudige, met de hand getekende SVG-grafiekjes voor de Voortgang-tab - + geen library nodig (zelfde aanpak als avatar.js dit sessie), puur + client-side berekend uit de al opgehaalde events. */ + const SVG_NS = "http://www.w3.org/2000/svg"; + function svgEl(tag, attrs){ + const el = document.createElementNS(SVG_NS, tag); + Object.entries(attrs).forEach(([k,v])=>el.setAttribute(k,v)); + return el; + } + function progressAccuracyRing(correct, total){ + const pct = total ? Math.round((correct/total)*100) : 0; + const r = 40, c = 2*Math.PI*r, offset = c*(1-pct/100); + const svg = svgEl("svg", {viewBox:"0 0 100 100", class:"progress-ring"}); + svg.appendChild(svgEl("circle", {cx:50, cy:50, r, fill:"none", stroke:"var(--surface-3)", "stroke-width":10})); + if(total){ + const color = pct>=80 ? "var(--green)" : pct>=50 ? "var(--yellow)" : "var(--red)"; + const arc = svgEl("circle", {cx:50, cy:50, r, fill:"none", stroke:color, + "stroke-width":10, "stroke-dasharray":c, "stroke-dashoffset":offset, "stroke-linecap":"round"}); + arc.setAttribute("transform", "rotate(-90 50 50)"); + svg.appendChild(arc); + } + const text = svgEl("text", {x:50, y:56, "text-anchor":"middle", class:"progress-ring-pct"}); + text.textContent = total ? pct+"%" : "–"; + svg.appendChild(text); + return svg; + } + /* goed/fout per dag, laatste 14 dagen - attempts/correct i.p.v. een simpele + goed/fout-vlag per event, zodat dit ook klopt voor oudere geaggregeerde + events (bv. een pre-upgrade rekenblok van 10 sommen: attempts:10, + correct:7 telt als 7 goed + 3 fout, niet als één enkel "fout"-moment). */ + function progressDailyBars(events){ + const days = []; + const byDay = new Map(); + const now = new Date(); + for(let i=13; i>=0; i--){ + const d = new Date(now); d.setDate(d.getDate()-i); + const key = d.toISOString().slice(0,10); + byDay.set(key, {correct:0, wrong:0}); + days.push(key); + } + events.forEach(ev=>{ + const key = new Date(ev.createdAt).toISOString().slice(0,10); + const bucket = byDay.get(key); + if(!bucket) return; + bucket.correct += ev.correct || 0; + bucket.wrong += Math.max(0, (ev.attempts||0) - (ev.correct||0)); + }); + const w = 280, h = 70, barW = w/days.length; + const max = Math.max(1, ...days.map(k=>{ const b = byDay.get(k); return b.correct+b.wrong; })); + const svg = svgEl("svg", {viewBox:`0 0 ${w} ${h}`, class:"progress-bars"}); + days.forEach((key,i)=>{ + const b = byDay.get(key); + const total = b.correct+b.wrong; + const totalH = (total/max)*(h-4); + const correctH = total ? (b.correct/total)*totalH : 0; + const wrongH = totalH-correctH; + const x = i*barW+barW*0.22, bw = barW*0.56; + if(wrongH>0) svg.appendChild(svgEl("rect", {x, y:h-totalH, width:bw, height:wrongH, fill:"var(--red)", rx:1})); + if(correctH>0) svg.appendChild(svgEl("rect", {x, y:h-totalH+wrongH, width:bw, height:correctH, fill:"var(--green)", rx:1})); + }); + return svg; + } + /* welk vakgebied hoort bij een widgetType - zelfde categorieën als + REGISTRY's cat in board.js, ook gebruikt door klassenmanagement. */ + function widgetSubject(widgetType){ + const def = REGISTRY.find(d=>d.id===widgetType); + return def ? def.cat : null; + } + /* één regel in de voortgangstijdlijn van een leerling: widgettype, score en tijdstip */ function progressRow(ev){ const row = h("div","am-row"); @@ -771,9 +840,11 @@ return row; } - /* voortgang: leerkracht kiest een leerling (zelfde groepering per klas als - Toewijzingen) en ziet de volledige tijdlijn van diens afgeronde - widget-momenten (ronde sommen, voltooid woord, geknapte ballon). */ + /* voortgang: leerkracht kiest een leerling en een vakgebied, ziet meteen + het toegewezen niveau (klassenmanagement-koppeling), een grafiek + (nauwkeurigheid + goed/fout per dag) en de recentste fouten mét welke + opdracht het was (waar een widget dat detail meegeeft) - de volledige + ruwe tijdlijn blijft eronder beschikbaar, niets verdwijnt. */ function renderVoortgangPanel(panel){ const schoolChosen = currentUser.role!=="super" || !!selSchool; if(!schoolChosen){ @@ -784,20 +855,56 @@ const pupilSel = h("select","am-sel"); fillPupilSelect(pupilSel, pupils); panel.appendChild(pupilSel); - const eventsHolder = h("div","am-list"); - panel.appendChild(eventsHolder); - const moreBtn = h("button","am-btn", T("amShowMore")); - moreBtn.type = "button"; - moreBtn.style.display = "none"; - panel.appendChild(moreBtn); - pupilSel.addEventListener("change", async ()=>{ - eventsHolder.innerHTML = ""; - moreBtn.style.display = "none"; - if(!pupilSel.value) return; - let events = []; - try{ events = (await api("/progress/pupil/"+pupilSel.value)).events || []; } - catch(e){ msg(e.message); return; } - if(!events.length){ eventsHolder.appendChild(h("div","guestnote", T("amProgressEmpty"))); return; } + + const subjectRow = h("div","am-inline"); + panel.appendChild(subjectRow); + const body = h("div"); + panel.appendChild(body); + + let allEvents = []; + let pvSubject = "rekenen"; + + function renderBody(){ + body.innerHTML = ""; + const events = allEvents.filter(ev=>widgetSubject(ev.widgetType)===pvSubject); + if(!events.length){ body.appendChild(h("div","guestnote", T("amProgressEmpty"))); return; } + + const totals = events.reduce((acc,ev)=>({ + correct: acc.correct + (ev.correct||0), + attempts: acc.attempts + (ev.attempts||0), + }), {correct:0, attempts:0}); + + const summary = h("div","progress-summary"); + summary.appendChild(progressAccuracyRing(totals.correct, totals.attempts)); + const summaryText = h("div","progress-summary-text"); + summaryText.appendChild(h("div","progress-summary-count", + `${totals.correct} / ${totals.attempts} ${T("mGoed")}`)); + summaryText.appendChild(h("div","progress-summary-hint", T("amProgressLast14Days"))); + summary.append(summaryText, progressDailyBars(events)); + body.appendChild(summary); + + const mistakes = events.filter(ev=>ev.detail && ev.detail.correct===false).slice(0, 10); + if(mistakes.length){ + body.appendChild(h("div","am-group", T("amProgressMistakes"))); + const list = h("div","am-list"); + mistakes.forEach(ev=>{ + const row = h("div","am-row"); + row.appendChild(h("span","am-name", ev.detail.question||"")); + row.appendChild(h("span","am-code", + T("amProgressGiven").replace("{answer}", ev.detail.answer) + " · " + + T("amProgressExpected").replace("{answer}", ev.detail.expected))); + row.appendChild(h("span","am-code", new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB"))); + list.appendChild(row); + }); + body.appendChild(list); + } + + body.appendChild(h("div","am-group", T("amProgressTimeline"))); + const eventsHolder = h("div","am-list"); + body.appendChild(eventsHolder); + const moreBtn = h("button","am-btn", T("amShowMore")); + moreBtn.type = "button"; + body.appendChild(moreBtn); /* in porties van 30: de tijdlijn kan 200 regels lang zijn */ let shown = 0; const showMore = ()=>{ @@ -806,9 +913,45 @@ moreBtn.style.display = shown < events.length ? "" : "none"; moreBtn.textContent = `${T("amShowMore")} (${events.length - shown})`; }; - moreBtn.onclick = showMore; showMore(); - }); + moreBtn.onclick = showMore; + } + + async function renderLevelBadge(){ + if(!pupilSel.value) return; + let lvl = null; + try{ lvl = (await api(`/levels/pupil/${pupilSel.value}?subject=${pvSubject}`)).level; }catch(e){} + const old = body.querySelector(".progress-level-badge"); + if(old) old.remove(); + const badge = h("div","progress-level-badge", + lvl ? T("amYearGroupN").replace("{n}", lvl.yearGroup) + " · " + T("lvl"+lvl.level) : T("amLevelNone")); + body.prepend(badge); + } + + async function loadPupil(){ + body.innerHTML = ""; + subjectRow.innerHTML = ""; + if(!pupilSel.value) return; + KM_SUBJECTS.forEach(s=>{ + const b = h("button","am-btn", T("amSubject_"+s)); + b.type = "button"; + b.classList.toggle("active", s===pvSubject); + b.addEventListener("click", ()=>{ + pvSubject = s; + subjectRow.querySelectorAll("button").forEach(x=>x.classList.remove("active")); + b.classList.add("active"); + renderBody(); + renderLevelBadge(); + }); + subjectRow.appendChild(b); + }); + body.appendChild(h("div","guestnote", "…")); + try{ allEvents = (await api("/progress/pupil/"+pupilSel.value)).events || []; } + catch(e){ body.innerHTML = ""; body.appendChild(h("div","am-msg", e.message)); return; } + renderBody(); + renderLevelBadge(); + } + pupilSel.addEventListener("change", loadPupil); panel.appendChild(h("div","am-group", T("amExportClass"))); const classSelExport = classSel(); diff --git a/public/js/core.js b/public/js/core.js index 92405c0..7bba819 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.34-beta"; +const VERSION = "0.4.35-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -349,6 +349,9 @@ const I18N = { castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.", castActive:"✓ Actief", amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.", + amProgressLast14Days:"Laatste 14 dagen", amProgressMistakes:"Recente fouten", + amProgressTimeline:"Volledige tijdlijn", + amProgressGiven:"Gegeven: {answer}", amProgressExpected:"Juist: {answer}", amExportClass:"Exporteren per klas", amExportCsv:"Exporteer als CSV", amPickClassHint:"Kies eerst een klas.", amExportColPupil:"Leerling", amExportColWidget:"Widget", amExportColAttempts:"Pogingen", amExportColCorrect:"Goed", amExportColStars:"Sterren", amExportColDate:"Datum", @@ -694,6 +697,9 @@ const I18N = { castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.", castActive:"✓ Active", amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.", + amProgressLast14Days:"Last 14 days", amProgressMistakes:"Recent mistakes", + amProgressTimeline:"Full timeline", + amProgressGiven:"Given: {answer}", amProgressExpected:"Correct: {answer}", amExportClass:"Export by class", amExportCsv:"Export as CSV", amPickClassHint:"Pick a class first.", amExportColPupil:"Pupil", amExportColWidget:"Widget", amExportColAttempts:"Attempts", amExportColCorrect:"Correct", amExportColStars:"Stars", amExportColDate:"Date", diff --git a/public/js/widgets/math.js b/public/js/widgets/math.js index 022f92d..5c6620d 100644 --- a/public/js/widgets/math.js +++ b/public/js/widgets/math.js @@ -223,7 +223,6 @@ function mountMath(root, initState, mode, opts){ doneEl.querySelector(".dg").textContent = S.good>=8 ? "🌟" : S.good>=5 ? "👍" : "💪"; doneEl.classList.add("show"); if(S.good>=8) play(sndWin); - if(onProgress) onProgress({ attempts: ROUND, correct: S.good, stars: 0 }); } function next(){ S.done++; @@ -239,17 +238,25 @@ function mountMath(root, initState, mode, opts){ 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); } } diff --git a/src/api.js b/src/api.js index 278a693..5456ff0 100644 --- a/src/api.js +++ b/src/api.js @@ -1041,10 +1041,22 @@ export default async function api(app) { // 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 } = req.body ?? {}; + const { widgetId, widgetType, attempts, correct, stars, detail } = 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 pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [req.user.id])).rows[0]; @@ -1062,9 +1074,9 @@ export default async function api(app) { } if (!a) return fail(reply, 400, 'geen toewijzing'); await pool.query( - `INSERT INTO progress_events (pupil_id, teacher_id, board_id, widget_id, widget_type, attempts, correct, stars) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, - [req.user.id, a.teacher_id, a.board_id, widgetId, widgetType, clampCount(attempts), clampCount(correct), clampCount(stars)]); + `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, clampCount(attempts), clampCount(correct), clampCount(stars), sanitizeProgressDetail(detail)]); return { ok: true }; }); const describeProgressEvent = (e) => ({ @@ -1073,6 +1085,7 @@ export default async function api(app) { 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 diff --git a/test/progress-logging.test.js b/test/progress-logging.test.js index 9306cd5..0565e7e 100644 --- a/test/progress-logging.test.js +++ b/test/progress-logging.test.js @@ -101,3 +101,41 @@ test('voortgang: ontbrekende widgetId/widgetType levert 400 op, niet een kapotte assert.equal(res.statusCode, 400, res.body); assert.ok(!insertCall(calls)); }); + +/* "1 opdracht i.p.v. een groep" - de fix zelf zit in math.js (per som loggen + i.p.v. per blok van 10), maar het datamodel/de server moeten het optionele + detail (welke som/welk antwoord) wel kunnen bewaren zonder de rest van de + voortgang-logging te breken bij rommelige/te grote input. */ +test('voortgang: detail (welke opdracht) wordt meegegeven aan de insert', async () => { + const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 }; + const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } }); + const detail = { question: '7 + 8 =', answer: 14, expected: 15, correct: false }; + const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { ...validBody, detail } }); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(insertCall(calls).params[8], detail); +}); + +test('voortgang: een te grote detail wordt genegeerd zonder de hele log-poging te laten falen', async () => { + const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 }; + const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } }); + const detail = { question: 'x'.repeat(2000) }; + const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { ...validBody, detail } }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(insertCall(calls).params[8], null); +}); + +test('voortgang: een detail dat geen plain object is (array/string) wordt genegeerd', async () => { + const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 }; + const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } }); + const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: { ...validBody, detail: ['niet', 'plain'] } }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(insertCall(calls).params[8], null); +}); + +test('voortgang: zonder detail-veld blijft alles zoals voorheen (null, geen fout)', async () => { + const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, data: {}, data_rev: 0 }; + const { app, calls } = await makeApp(pupil, { assignmentRows: { byPupil: { teacher_id: 3, board_id: 'bord-a' } } }); + const res = await app.inject({ method: 'POST', url: '/api/my/progress', cookies, payload: validBody }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(insertCall(calls).params[8], null); +}); -- 2.45.2 From 216ab8d5dd338ea879ab06ed5290458aaf31c71a Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 20:58:17 +0200 Subject: [PATCH 175/192] docs: leg SLO-kerndoelen 2026 vast (v0.4.36-beta) --- VERSION | 2 +- docs/curriculum/kerndoelen-po-2026.md | 318 ++++++++++++++++++++++++++ public/js/core.js | 2 +- 3 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 docs/curriculum/kerndoelen-po-2026.md diff --git a/VERSION b/VERSION index c717fad..8bf454f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.35-beta \ No newline at end of file +0.4.36-beta 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/public/js/core.js b/public/js/core.js index 7bba819..abdb88f 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.35-beta"; +const VERSION = "0.4.36-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; -- 2.45.2 From 322147d3d18761ed086b009ebe9afb884930f48a Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 21:13:11 +0200 Subject: [PATCH 176/192] feat: voeg curriculumwiki voor volwassenen toe (v0.4.37-beta) --- VERSION | 2 +- public/css/teach.css | 36 +++++++ public/index.html | 7 ++ public/js/core.js | 45 +++++++- public/js/curriculum.js | 109 +++++++++++++++++++ public/js/permissions.js | 3 +- public/js/wiki.js | 202 +++++++++++++++++++++++++++++++++++ src/permissions.js | 3 +- test/curriculum-wiki.test.js | 65 +++++++++++ 9 files changed, 468 insertions(+), 4 deletions(-) create mode 100644 public/js/curriculum.js create mode 100644 public/js/wiki.js create mode 100644 test/curriculum-wiki.test.js diff --git a/VERSION b/VERSION index 8bf454f..bf44e5c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.36-beta +0.4.37-beta diff --git a/public/css/teach.css b/public/css/teach.css index d1757bb..a53c8f6 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -636,6 +636,39 @@ #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 */ + #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-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{ @@ -659,6 +692,9 @@ .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} } /* ========================================================= Letter game widget diff --git a/public/index.html b/public/index.html index 52a4f29..0d4ee62 100644 --- a/public/index.html +++ b/public/index.html @@ -16,6 +16,8 @@ + + @@ -68,6 +70,7 @@ + @@ -293,6 +296,10 @@
+ + diff --git a/public/js/core.js b/public/js/core.js index abdb88f..1ce90f2 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.36-beta"; +const VERSION = "0.4.37-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -234,6 +234,27 @@ const I18N = { stGroupPersonal:"Persoonlijk", stGroupSchool:"School", stGroupSystem:"Systeem", stAccountSub:"Je gegevens, wachtwoord en reservekopieën.", stViewSub:"Weergave op dit apparaat: thema, taal en menubalk.", + wikiTitle:"Curriculumwiki", wikiShort:"Wiki", + wikiSub:"Nieuwe SLO-kerndoelen voor het primair onderwijs, per leergebied.", + wikiKicker:"Primair onderwijs · juli 2026", + wikiIntro:"Bekijk de 40 nieuwe kerndoelen, hun onderdelen en de huidige aansluiting van Teach.", + wikiAreas:"leergebieden", wikiGoals:"kerndoelen", wikiGoal:"Kerndoel", + wikiGoalsRange:"Kerndoelen {range}", wikiParts:"Onderdelen", + wikiStatusTitle:"Status van de doelen", + wikiStatusText:"Dit zijn definitieve conceptkerndoelen. De wettelijke invoering wordt gefaseerd vastgelegd; de officiële SLO-publicatie blijft leidend.", + wikiOfficialSource:"Open officiële SLO-publicatie", + wikiOfficialArea:"Bekijk {area} in de officiële publicatie", + wikiTuleTitle:"TULE en de nieuwe leerlijnen", + wikiTuleText:"TULE werkt de kerndoelen uit 2006 voorbeeldmatig uit. Nieuwe SLO-leerlijnen bij de doelen van 2026 zijn nog in ontwikkeling. Een niveau-indeling in Teach is daarom geen officiële SLO-leerlijn.", + wikiTuleSource:"Open TULE bij SLO", wikiProgressionsSource:"Volg de ontwikkeling van nieuwe leerlijnen", + wikiSearchLabel:"Doorzoek de curriculumwiki", + wikiSearchPlaceholder:"Zoek op doel, domein, onderwerp of widget…", + wikiAreaFilter:"Filter op leergebied", wikiAllAreas:"Alle leergebieden", + wikiCoveragePartial:"Deels in Teach", wikiCoverageSupport:"Lesondersteuning", wikiCoverageOpen:"Nog open", + wikiWidgets:"Huidige widgets", wikiNoResults:"Geen kerndoelen gevonden.", + wikiCarefulTitle:"Zorgvuldig gebruiken.", + wikiCarefulText:"Een widget die één onderdeel oefent, dekt niet automatisch het volledige kerndoel. School- en ervaringsdoelen vragen andere vormen van bewijs dan een goed/fout-score.", + wikiLanguageNote:"The official SLO goals and these implementation summaries are shown in Dutch.", stUsersSub:"Accounts beheren: leerlingen, staf, ouders en aanmeldingen.", stKlassenSub:"Klassen aanmaken, koppelen en beheren.", stToewijzSub:"Bepaal wat een klas of leerling te zien krijgt.", @@ -582,6 +603,27 @@ const I18N = { stGroupPersonal:"Personal", stGroupSchool:"School", stGroupSystem:"System", stAccountSub:"Your details, password and backups.", stViewSub:"Display on this device: theme, language and toolbar.", + wikiTitle:"Curriculum wiki", wikiShort:"Wiki", + wikiSub:"New SLO attainment targets for Dutch primary education, grouped by learning area.", + wikiKicker:"Primary education · July 2026", + wikiIntro:"Browse all 40 new attainment targets, their components and Teach's current coverage.", + wikiAreas:"learning areas", wikiGoals:"attainment targets", wikiGoal:"Target", + wikiGoalsRange:"Targets {range}", wikiParts:"Components", + wikiStatusTitle:"Status of these targets", + wikiStatusText:"These are final draft targets. Their legal introduction is being phased; the official SLO publication remains authoritative.", + wikiOfficialSource:"Open the official SLO publication", + wikiOfficialArea:"View {area} in the official publication", + wikiTuleTitle:"TULE and the new learning progressions", + wikiTuleText:"TULE is an illustrative elaboration of the 2006 targets. New SLO learning progressions for the 2026 targets are still being developed. Teach levels are therefore not official SLO progressions.", + wikiTuleSource:"Open TULE at SLO", wikiProgressionsSource:"Follow the development of new progressions", + wikiSearchLabel:"Search the curriculum wiki", + wikiSearchPlaceholder:"Search by target, domain, topic or widget…", + wikiAreaFilter:"Filter by learning area", wikiAllAreas:"All learning areas", + wikiCoveragePartial:"Partly in Teach", wikiCoverageSupport:"Lesson support", wikiCoverageOpen:"Not covered yet", + wikiWidgets:"Current widgets", wikiNoResults:"No attainment targets found.", + wikiCarefulTitle:"Use with care.", + wikiCarefulText:"A widget practising one component does not automatically cover the full target. School-level and experience targets require different evidence from a right/wrong score.", + wikiLanguageNote:"The official SLO goals and these implementation summaries are shown in Dutch.", stUsersSub:"Manage accounts: pupils, staff, parents and enrolments.", stKlassenSub:"Create, link and manage classes.", stToewijzSub:"Choose what a class or pupil gets to see.", @@ -969,6 +1011,7 @@ const roleKey = r => r==="super" ? "roleSuper" : r==="admin" ? "roleAdmin" : r== function updateUserUI(){ document.getElementById("userLbl").textContent = currentUser ? currentUser.displayName : T("login"); document.getElementById("btnUser").classList.toggle("on", !!currentUser); + document.getElementById("btnWiki").style.display = can("curriculum.view") ? "" : "none"; /* welke instellingen-secties zichtbaar zijn bepaalt settings.js per rol (sectieregister); hier alleen nog de accountgegevens zelf */ if(currentUser){ diff --git a/public/js/curriculum.js b/public/js/curriculum.js new file mode 100644 index 0000000..3c2d85b --- /dev/null +++ b/public/js/curriculum.js @@ -0,0 +1,109 @@ +/* Nieuwe SLO-kerndoelen primair onderwijs, versie juli 2026. + Dit is een compacte, implementatiegerichte samenvatting. De officiële + publicatie blijft leidend; componentnamen volgen de publieke doelstructuur. */ +"use strict"; + +const CURRICULUM_PO_2026=Object.freeze({ + id:"slo-po-2026", + edition:"2026-07", + status:"definitief-concept", + source:"https://www.slo.nl/publish/pages/23186/kerndoelen-primair-onderwijs-slo-juli-2026.pdf", + tule:"https://www.slo.nl/thema/meer/tule/", + progressions:"https://www.slo.nl/thema/meer/kerndoelen-onderwijs/ontwikkeling-leerlijnen/", + areas:Object.freeze([ + { + id:"nederlands",icon:"📖",title:"Nederlands",goalsRange:"1–9",page:20, + description:"Taalcompetentie ontwikkelen door betekenisvol begrijpen, spreken, schrijven, gesprekken, taalbeschouwing en literatuur.", + goals:[ + {id:1,domain:"Overkoepelend",summary:"De school stimuleert taalcompetentie met een rijke omgeving en taalontwikkeling in alle leergebieden.",parts:["Taal- en leesomgeving","Taal in de leergebieden"],coverage:"support",widgets:["anchor","letters","flits","hak","ball","sentence","media"]}, + {id:2,domain:"Communicatie",summary:"Leerlingen begrijpen en evalueren zakelijke en literaire teksten en onderzoeken de betrouwbaarheid van bronnen.",parts:["Luisteren en lezen met begrip","Luisteren en lezen met diep begrip","Bronnen verkennen"],coverage:"support",widgets:["anchor","mind","media"]}, + {id:3,domain:"Communicatie",summary:"Leerlingen produceren doelgerichte en creatieve teksten en gebruiken schrijven om te denken en leren.",parts:["Doelgericht spreken en schrijven","Creatief taal gebruiken","Schrijven om te leren"],coverage:"partial",widgets:["sentence","write","notes","mind"]}, + {id:4,domain:"Communicatie",summary:"Leerlingen voeren gesprekken om doelgericht te communiceren, samen te denken en te leren.",parts:["Doelgericht gesprekken voeren","Gesprekken voeren om te leren"],coverage:"support",widgets:["anchor","mind","poll"]}, + {id:5,domain:"Communicatie",summary:"Leerlingen reflecteren op hun taalactiviteiten, aanpak en ontwikkeling.",parts:["Reflecteren op taalactiviteiten"],coverage:"support",widgets:["letters","flits","hak","sentence"]}, + {id:6,domain:"Taal",summary:"Leerlingen onderzoeken taalvorm en betekenis en passen spelling, formulering en interpunctie functioneel toe.",parts:["Vorm en betekenis beschouwen","Spelling, formulering en interpunctie"],coverage:"partial",widgets:["letters","hak","sentence","wordtypes"]}, + {id:7,domain:"Taal",summary:"Leerlingen onderzoeken hun talige identiteit en verkennen taalvariatie en taalverandering.",parts:["Talige identiteit","Taalvariatie en taalverandering"],coverage:"support",widgets:["anchor","letters","flits","ball"]}, + {id:8,domain:"Literatuur",summary:"Leerlingen doen brede literatuurervaring op en ontwikkelen leesvoorkeur en inzicht in de waarde van literatuur.",parts:["Leesvoorkeur","Waarde van literatuur"],coverage:"open",widgets:[]}, + {id:9,domain:"Literatuur",summary:"Leerlingen onderzoeken verhalende teksten en kenmerken van verschillende literaire genres.",parts:["Verhalende teksten","Literaire genres"],coverage:"open",widgets:[]} + ] + }, + { + id:"rekenen-en-wiskunde",icon:"🔢",title:"Rekenen en wiskunde",goalsRange:"10–18",page:32, + description:"Wiskundige concepten, denk-werkwijzen, taal en gereedschap begrijpen en toepassen in de wereld.", + goals:[ + {id:10,domain:"Wiskundige concepten",summary:"Leerlingen redeneren en rekenen met getallen, breuken en verhoudingen.",parts:["Gehele en decimale getallen","Breuken","Verhoudingen"],coverage:"partial",widgets:["madd","msub","mmul","mdiv","mpct","numberline","fractions","placevalue"]}, + {id:11,domain:"Wiskundige concepten",summary:"Leerlingen meten, redeneren en rekenen met grootheden, eenheden en passende meetinstrumenten.",parts:["Grootheden en eenheden"],coverage:"partial",widgets:["clock","money"]}, + {id:12,domain:"Wiskundige concepten",summary:"Leerlingen representeren en interpreteren data en beoordelen conclusies bij gegevens kritisch.",parts:["Data"],coverage:"support",widgets:["poll"]}, + {id:13,domain:"Wiskundige concepten",summary:"Leerlingen herkennen, beschrijven en onderzoeken patronen en verbanden.",parts:["Patronen en verbanden"],coverage:"open",widgets:[]}, + {id:14,domain:"Wiskundige concepten",summary:"Leerlingen redeneren over meetkundige figuren en plaatsbepaling en voeren transformaties uit.",parts:["Meetkunde"],coverage:"open",widgets:[]}, + {id:15,domain:"Wiskundige denk-werkwijzen",summary:"Leerlingen lossen problemen op, modelleren situaties en gebruiken en beschrijven algoritmes.",parts:["Wiskundig probleemoplossen","Wiskundig modelleren","Gebruiken en beschrijven van algoritmes"],coverage:"support",widgets:["madd","msub","mmul","mdiv","mpct"]}, + {id:16,domain:"Wiskundige denk-werkwijzen",summary:"Leerlingen gebruiken wiskundetaal, representaties en instrumenten beredeneerd.",parts:["Wiskundetaal en representaties","Wiskundige instrumenten"],coverage:"partial",widgets:["numberline","fractions","clock","placevalue","money"]}, + {id:17,domain:"Wiskunde en de wereld",summary:"Leerlingen ontwikkelen een precieze, kritische, onderzoekende en volhardende wiskundige houding.",parts:["Wiskundige attitude"],coverage:"support",widgets:["madd","msub","mmul","mdiv","mpct","numberline"]}, + {id:18,domain:"Wiskunde en de wereld",summary:"Leerlingen herkennen en gebruiken wiskunde in werkelijke situaties en andere leergebieden.",parts:["Wiskunde in de werkelijkheid","Wiskunde in verschillende leergebieden"],coverage:"partial",widgets:["money","clock","topography"]} + ] + }, + { + id:"burgerschap",icon:"🤝",title:"Burgerschap",goalsRange:"19–21",page:44, + description:"Een democratische oefenplaats vormen en leren over rechtsstaat, diversiteit en maatschappelijke betrokkenheid.", + goals:[ + {id:19,domain:"Democratische oefenplaats",summary:"De school stimuleert sociale en maatschappelijke competenties en veilige, respectvolle interactie.",parts:["Sociale en maatschappelijke competenties"],coverage:"support",widgets:["poll","mood","feelings","compliments","names","light"]}, + {id:20,domain:"Democratische rechtsstaat",summary:"Leerlingen onderzoeken basiswaarden en beginselen van de democratische rechtsstaat en diversiteit in de samenleving.",parts:["Basiswaarden van de democratische rechtsstaat","Diversiteit in de samenleving"],coverage:"support",widgets:["feelings","compliments","poll"]}, + {id:21,domain:"Democratische en maatschappelijke betrokkenheid",summary:"Leerlingen doen ervaring op met democratische deelname, invloed, maatschappelijke inzet en samenwerking.",parts:["Democratische betrokkenheid","Maatschappelijke betrokkenheid"],coverage:"support",widgets:["poll","mind","anchor"]} + ] + }, + { + id:"digitale-geletterdheid",icon:"💻",title:"Digitale geletterdheid",goalsRange:"22–24",page:52, + description:"Digitale systemen en media functioneel en kritisch inzetten, producten maken en veilig deelnemen aan de digitale wereld.", + goals:[ + {id:22,domain:"Praktische kennis en vaardigheden",summary:"Leerlingen gebruiken digitale systemen, media, informatie, data en AI functioneel en kritisch.",parts:["Digitale systemen","Digitale media en informatie","Data","Artificiële intelligentie (AI)"],coverage:"support",widgets:["media","mind","write"]}, + {id:23,domain:"Ontwerpen en maken",summary:"Leerlingen ontwerpen, testen en verbeteren digitale producten en programmeerbare oplossingen.",parts:["Creëren met digitale technologie","Programmeren"],coverage:"support",widgets:["write","mind","media"]}, + {id:24,domain:"De gedigitaliseerde wereld",summary:"Leerlingen onderzoeken veiligheid, privacy en de invloed van digitale technologie op zichzelf, anderen en de samenleving.",parts:["Veiligheid en privacy","Digitale technologie, jezelf en de ander","Digitale technologie, samenleving en wereld"],coverage:"open",widgets:[]} + ] + }, + { + id:"mens-en-maatschappij",icon:"🌍",title:"Mens en maatschappij",goalsRange:"25–28",page:62, + description:"De menselijke leefwereld onderzoeken vanuit geografische, historische, economische en sociale perspectieven.", + goals:[ + {id:25,domain:"Vraagstukken en perspectieven",summary:"Leerlingen onderzoeken maatschappelijke verschijnselen, vraagstukken en de omgeving van de school vanuit meerdere invalshoeken.",parts:["Denkwijzen","Vraagstukken","De omgeving van de school"],coverage:"support",widgets:["mind","anchor","poll","media"]}, + {id:26,domain:"Mens en ruimte",summary:"Leerlingen bouwen met geografische bronnen een wereldbeeld op en onderzoeken de wisselwerking tussen mens en leefomgeving.",parts:["Wereldbeeld","De aarde als leefomgeving"],coverage:"partial",widgets:["topography"]}, + {id:27,domain:"Mens en tijd",summary:"Leerlingen onderzoeken historische ontwikkelingen, gebruiken chronologie en herkennen doorwerking van het verleden.",parts:["Historische ontwikkelingen","Tijd","Doorwerking van het verleden"],coverage:"open",widgets:[]}, + {id:28,domain:"Mens en samenleving",summary:"Leerlingen onderzoeken economie, invloed, macht, diversiteit en sociale omgeving en nemen verantwoord deel aan verkeer.",parts:["Economische keuzes","Invloed en macht","Diversiteit","Sociale omgeving","Verkeer"],coverage:"support",widgets:["money","feelings","compliments","topography"]} + ] + }, + { + id:"mens-en-natuur",icon:"🔬",title:"Mens en natuur",goalsRange:"29–32",page:74, + description:"Natuurwetenschap, technologie, organismen, gezondheid en systeem aarde onderzoekend leren begrijpen.", + goals:[ + {id:29,domain:"Natuurwetenschappen en technologie",summary:"Leerlingen onderzoeken vraagstukken met natuurwetenschappelijke en technologische denk- en werkwijzen.",parts:["Vraagstukken","Denkwijzen","Werkwijzen","Aard van natuurwetenschappen en technologie"],coverage:"partial",widgets:["trees","birds","plants"]}, + {id:30,domain:"Natuur- en scheikunde en technische systemen",summary:"Leerlingen onderzoeken technische systemen, stoffen en verschijnselen rond licht, geluid, energie en krachten.",parts:["Technische systemen","Stoffen en hun eigenschappen","Licht, geluid, energie en krachten"],coverage:"open",widgets:[]}, + {id:31,domain:"Organismen en gezondheid",summary:"Leerlingen onderzoeken organismen en hun levensprocessen en leren onderbouwde keuzes rond lichamelijke en mentale gezondheid.",parts:["Organismen","Gezond leven"],coverage:"partial",widgets:["trees","birds","plants","breathe","mood","feelings"]}, + {id:32,domain:"Systeem aarde",summary:"Leerlingen onderzoeken de aarde, weer, klimaat, water en samenhang en menselijke invloed in ecosystemen.",parts:["Aarde","Weer, klimaat en water","Ecosystemen"],coverage:"partial",widgets:["trees","birds","plants","topography"]} + ] + }, + { + id:"engels",icon:"💬",title:"Moderne vreemde talen: Engels",goalsRange:"33–34",page:84, + description:"Met plezier en vertrouwen eenvoudig communiceren in het Engels en taal- en cultuurbewustzijn ontwikkelen.", + goals:[ + {id:33,domain:"Communicatie",summary:"Leerlingen begrijpen eenvoudige Engelse teksten en communiceren mondeling en schriftelijk in vertrouwde situaties.",parts:["Kijk-, luister- en leesvaardigheid","Mondelinge communicatie","Schriftelijke communicatie"],coverage:"partial",widgets:["letters","flits","hak","ball","sentence"]}, + {id:34,domain:"Taal- en cultuurbewustzijn",summary:"Leerlingen reflecteren op taalleren en meertaligheid en verkennen interculturele communicatie.",parts:["Taalleerproces en meertaligheid","Interculturele communicatieve competentie"],coverage:"support",widgets:["anchor","letters","flits","ball"]} + ] + }, + { + id:"kunst-en-cultuur",icon:"🎨",title:"Kunst en cultuur",goalsRange:"35–37",page:92, + description:"Artistiek creatief vermogen ontwikkelen, kunstzinnige uitingen maken en kunst en cultuur beleven en duiden.", + goals:[ + {id:35,domain:"Artistiek creatief vermogen",summary:"Leerlingen gebruiken creatieve strategieën in een iteratief proces en geven betekenis aan eigen en andermans werk.",parts:["Creatieve maak- en denkstrategieën","Ontwikkelen van creatieve vermogens","Betekenis geven"],coverage:"support",widgets:["write","mind","media"]}, + {id:36,domain:"Maken en betekenis geven",summary:"Leerlingen verbeelden ideeën en gebruiken technieken, vaardigheden, materialen en vaktaal uit verschillende kunstdisciplines.",parts:["Verbeelden en creëren","Kunstzinnige technieken en vaardigheden"],coverage:"support",widgets:["write","media"]}, + {id:37,domain:"Meemaken en betekenis geven",summary:"Leerlingen beleven kunst en cultuur actief en verbinden uitingen aan maker, tijd, plaats, functie en context.",parts:["Kunst en cultuur actief meemaken","Kunst en cultuur in context"],coverage:"open",widgets:[]} + ] + }, + { + id:"bewegen-en-sport",icon:"🏃",title:"Bewegen en sport",goalsRange:"38–40",page:100, + description:"Beweegvaardigheid ontwikkelen, veilig samen bewegen en ontdekken welke beweegvormen en contexten passen.", + goals:[ + {id:38,domain:"Leren bewegen",summary:"Leerlingen nemen deel aan uiteenlopende beweegthema’s, verbeteren vaardigheden en stemmen activiteiten af op eigen mogelijkheden.",parts:["Deelnemen aan bewegen","Verbeteren beweegvaardigheid","Beweegactiviteit passend maken"],coverage:"open",widgets:[]}, + {id:39,domain:"Samen bewegen",summary:"Leerlingen nemen verantwoordelijkheid voor veilig en sociaal samen bewegen en helpen activiteiten regelen.",parts:["Bewegen met anderen","Bewegen regelen"],coverage:"open",widgets:[]}, + {id:40,domain:"Betrokken bewegen",summary:"Leerlingen verkennen beweegcontexten en onderzoeken voorkeuren, kwaliteiten, motieven en ontwikkeling.",parts:["Oriënteren op beweegcontexten","Beweegidentiteit ontwikkelen"],coverage:"open",widgets:[]} + ] + } + ]) +}); diff --git a/public/js/permissions.js b/public/js/permissions.js index f0eddeb..b5790fc 100644 --- a/public/js/permissions.js +++ b/public/js/permissions.js @@ -1,8 +1,9 @@ -/* teach - centrale permissiematrix voor de 4 vaste rollen (super/admin/teacher/pupil). +/* 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'], diff --git a/public/js/wiki.js b/public/js/wiki.js new file mode 100644 index 0000000..80b892c --- /dev/null +++ b/public/js/wiki.js @@ -0,0 +1,202 @@ +/* Curriculumwiki: doorzoekbare volwassenweergave van CURRICULUM_PO_2026. */ +"use strict"; + +(function(){ + const root=document.getElementById("curriculumWiki"); + const wikiButton=document.getElementById("btnWiki"); + 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"); + 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; + 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); + + 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); + + 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"),"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})}]; + }); + } + }); + + wikiButton.addEventListener("click",()=>openSettings("curriculum")); +})(); diff --git a/src/permissions.js b/src/permissions.js index 733f803..5f8d313 100644 --- a/src/permissions.js +++ b/src/permissions.js @@ -1,10 +1,11 @@ -// teach: centrale permissiematrix voor de 4 vaste rollen (super/admin/teacher/pupil). +// 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'], diff --git a/test/curriculum-wiki.test.js b/test/curriculum-wiki.test.js new file mode 100644 index 0000000..15827da --- /dev/null +++ b/test/curriculum-wiki.test.js @@ -0,0 +1,65 @@ +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('curriculumwiki is alleen zichtbaar voor alle volwassen rollen',async()=>{ + assert.deepEqual(PERMISSIONS['curriculum.view'],['super','admin','teacher','parent']); + assert.ok(!PERMISSIONS['curriculum.view'].includes('pupil')); + const [client,wiki]=await Promise.all([ + readFile('public/js/permissions.js','utf8'), + readFile('public/js/wiki.js','utf8') + ]); + assert.match(client,/'curriculum\.view':\s*\['super', 'admin', 'teacher', 'parent'\]/); + assert.match(wiki,/visible:\(\)=>!!currentUser&&can\("curriculum\.view"\)/); + assert.match(wiki,/wikiButton\.addEventListener\("click",\(\)=>openSettings\("curriculum"\)\)/); + 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')]*display:none/); + assert.match(html,/data-section="curriculum"/); + assert.match(wiki,/input\.type="search"/); + assert.match(wiki,/dataset\.area/); + for(const key of ['wikiTitle','wikiSub','wikiSearchPlaceholder','wikiCoveragePartial','wikiCoverageSupport','wikiCoverageOpen','wikiCarefulText']){ + assert.equal((core.match(new RegExp(key+':','g'))||[]).length,2,key); + } + assert.match(css,/\.wiki-grid\{[^}]*grid-template-columns:repeat\(2/); + assert.match(css,/@media \(max-width:720px\)[\s\S]*?\.wiki-grid\{grid-template-columns:1fr\}/); + assert.ok(core.includes('document.getElementById("btnWiki").style.display = can("curriculum.view")')); + assert.ok(core.includes('const VERSION = "'+version.trim()+'"')); +}); -- 2.45.2 From 9bab7f39c02c8a08fe92b6861e978c282b411b80 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 21:46:38 +0200 Subject: [PATCH 177/192] feat: koppel widgets aan kerndoelen (v0.4.38-beta) --- VERSION | 2 +- public/css/teach.css | 13 +++++ public/index.html | 1 - public/js/board.js | 100 ++++++++++++++++++++++++++++++++++- public/js/core.js | 15 ++++-- public/js/curriculum.js | 10 ++-- public/js/settings.js | 4 +- public/js/wiki.js | 9 ++-- test/curriculum-wiki.test.js | 44 ++++++++++++--- 9 files changed, 174 insertions(+), 24 deletions(-) diff --git a/VERSION b/VERSION index bf44e5c..e735d60 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.37-beta +0.4.38-beta diff --git a/public/css/teach.css b/public/css/teach.css index a53c8f6..5699a76 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -637,6 +637,19 @@ .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}\n .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} diff --git a/public/index.html b/public/index.html index 0d4ee62..c2266c4 100644 --- a/public/index.html +++ b/public/index.html @@ -70,7 +70,6 @@ - diff --git a/public/js/board.js b/public/js/board.js index b3e01c4..4fd06ed 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -95,6 +95,92 @@ function clampWidgetSize(w, h, def){ blijft vinden ook als er widgets vóór/na worden toegevoegd of verplaatst */ function genWidgetId(){ return "w" + Date.now().toString(36) + Math.random().toString(36).slice(2,8); } +function curriculumGoalsForWidget(widgetId){ + const matches=[]; + CURRICULUM_PO_2026.areas.forEach(area=>area.goals.forEach(goal=>{ + if(goal.widgets.includes(widgetId))matches.push({area,goal}); + })); + return matches; +} +let widgetCurriculumWrap=null; +function closeWidgetCurriculumInfo(){ + if(widgetCurriculumWrap)widgetCurriculumWrap.classList.remove("open"); +} +function openWidgetCurriculumInfo(def){ + if(!currentUser||!can("curriculum.view"))return; + if(!widgetCurriculumWrap){ + widgetCurriculumWrap=document.createElement("div"); + widgetCurriculumWrap.className="modalwrap"; + widgetCurriculumWrap.id="widgetCurriculumWrap"; + widgetCurriculumWrap.setAttribute("role","dialog"); + widgetCurriculumWrap.setAttribute("aria-modal","true"); + widgetCurriculumWrap.addEventListener("click",event=>{ + if(event.target===widgetCurriculumWrap)closeWidgetCurriculumInfo(); + }); + document.body.appendChild(widgetCurriculumWrap); + } + const panel=document.createElement("div"); + panel.className="modal widget-curriculum-modal"; + const head=document.createElement("div"); + head.className="widget-curriculum-head"; + const title=document.createElement("h2"); + title.id="widgetCurriculumTitle"; + title.textContent=def.icon+" "+T("wg_"+def.id); + 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",closeWidgetCurriculumInfo); + head.append(title,close);panel.appendChild(head); + panel.appendChild(document.createTextNode(T("widgetCurriculumIntro"))); + const goals=curriculumGoalsForWidget(def.id); + if(goals.length){ + const list=document.createElement("div"); + list.className="widget-curriculum-goals"; + goals.forEach(({area,goal})=>{ + const link=document.createElement("a"); + link.className="widget-curriculum-goal"; + link.href="#wiki-goal-"+goal.id; + const label=document.createElement("strong"); + label.textContent=area.icon+" "+T("wikiGoal")+" "+goal.id+" · "+area.title; + const summary=document.createElement("span"); + summary.textContent=goal.summary; + const status=document.createElement("small"); + status.textContent=T(goal.coverage==="partial"?"wikiCoveragePartial":goal.coverage==="support"?"wikiCoverageSupport":"wikiCoverageOpen")+" · "+T("widgetCurriculumWiki"); + link.append(label,summary,status); + link.addEventListener("click",event=>{ + event.preventDefault();closeWidgetCurriculumInfo(); + openSettings("curriculum",{area:area.id,goal:goal.id}); + }); + list.appendChild(link); + }); + panel.appendChild(list); + }else{ + const empty=document.createElement("p"); + empty.className="widget-curriculum-empty"; + empty.textContent=T("widgetCurriculumNone"); + panel.appendChild(empty); + } + const careful=document.createElement("p"); + careful.className="widget-curriculum-careful"; + careful.textContent=T("widgetCurriculumCareful"); + panel.appendChild(careful); + const all=document.createElement("a"); + all.className="tbtn widget-curriculum-all"; + all.href="#curriculum"; + all.textContent=T("widgetCurriculumAll"); + all.addEventListener("click",event=>{ + event.preventDefault();closeWidgetCurriculumInfo();openSettings("curriculum"); + }); + panel.appendChild(all); + widgetCurriculumWrap.setAttribute("aria-labelledby",title.id); + widgetCurriculumWrap.replaceChildren(panel); + widgetCurriculumWrap.classList.add("open"); + close.focus(); +} +document.addEventListener("userchange",()=>{ + if(!currentUser||!can("curriculum.view"))closeWidgetCurriculumInfo(); +}); + function makeWidget(def, saved){ const win = document.createElement("div"); win.className = "widget"; @@ -117,6 +203,10 @@ function makeWidget(def, saved){ const head = document.createElement("div"); head.className = "widget-head"; head.innerHTML = `${def.icon}`; + const infoBtn = document.createElement("button"); + infoBtn.className="wmin winfo";infoBtn.type="button";infoBtn.textContent="ⓘ";infoBtn.hidden=true; + infoBtn.addEventListener("click",event=>{event.stopPropagation();openWidgetCurriculumInfo(def);}); + head.appendChild(infoBtn); const dupBtn = document.createElement("button"); dupBtn.className="wmin wdup"; dupBtn.textContent="⧉"; head.appendChild(dupBtn); const lockBtn = document.createElement("button"); lockBtn.className="wmin wlock"; lockBtn.textContent="🔓"; head.appendChild(lockBtn); const previewBtn = document.createElement("button"); previewBtn.className="wmin wpreview"; previewBtn.textContent="◉"; head.appendChild(previewBtn); @@ -227,7 +317,7 @@ function makeWidget(def, saved){ } maxBtn.addEventListener("click", ()=>{ setMax(win.dataset.max!=="1"); markBoardChange(); }); head.addEventListener("dblclick", e=>{ - if(e.target===close || e.target===minBtn || e.target===maxBtn) return; + if(e.target.closest("button")) return; setMin(true); }); @@ -236,14 +326,18 @@ function makeWidget(def, saved){ minBtn.title = T("wMin"); maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull"); zBtn.title = T("wZoom"); + infoBtn.title = T("widgetCurriculumInfo"); + infoBtn.setAttribute("aria-label",infoBtn.title); if(pill){ pill.querySelector(".dl").textContent = T("wg_"+def.id); pill.title = T("wMax"); pill.querySelector(".dock-close").title = T("close"); } }; - setTitle(); + const setCurriculumInfoVisibility=()=>{infoBtn.hidden=!(currentUser&&can("curriculum.view"));}; + setTitle();setCurriculumInfoVisibility(); document.addEventListener("langchange", setTitle); + document.addEventListener("userchange",setCurriculumInfoVisibility); win.addEventListener("pointerdown", ()=>{ win.style.zIndex = ++zTop; }); head.addEventListener("pointerdown", e=>{ @@ -271,6 +365,8 @@ function makeWidget(def, saved){ function removeWidget(){ markBoardChange(); if(pill){ pill.remove(); pill = null; } + document.removeEventListener("langchange",setTitle); + document.removeEventListener("userchange",setCurriculumInfoVisibility); instances.delete(inst); win.remove(); updateEmptyHint(); } close.addEventListener("click", removeWidget); diff --git a/public/js/core.js b/public/js/core.js index 1ce90f2..ec01380 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.37-beta"; +const VERSION = "0.4.38-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -234,7 +234,7 @@ const I18N = { stGroupPersonal:"Persoonlijk", stGroupSchool:"School", stGroupSystem:"Systeem", stAccountSub:"Je gegevens, wachtwoord en reservekopieën.", stViewSub:"Weergave op dit apparaat: thema, taal en menubalk.", - wikiTitle:"Curriculumwiki", wikiShort:"Wiki", + wikiTitle:"Curriculumwiki", wikiSub:"Nieuwe SLO-kerndoelen voor het primair onderwijs, per leergebied.", wikiKicker:"Primair onderwijs · juli 2026", wikiIntro:"Bekijk de 40 nieuwe kerndoelen, hun onderdelen en de huidige aansluiting van Teach.", @@ -254,6 +254,10 @@ const I18N = { wikiWidgets:"Huidige widgets", wikiNoResults:"Geen kerndoelen gevonden.", wikiCarefulTitle:"Zorgvuldig gebruiken.", wikiCarefulText:"Een widget die één onderdeel oefent, dekt niet automatisch het volledige kerndoel. School- en ervaringsdoelen vragen andere vormen van bewijs dan een goed/fout-score.", + widgetCurriculumInfo:"Kerndoelinformatie", widgetCurriculumIntro:"Deze widget draagt bij aan de onderstaande kerndoelen.", + widgetCurriculumNone:"Voor deze ondersteunende widget is geen directe koppeling met een kerndoel vastgesteld.", + widgetCurriculumWiki:"Bekijk dit kerndoel in de curriculumwiki", widgetCurriculumAll:"Open de volledige curriculumwiki", + widgetCurriculumCareful:"Een widget ondersteunt onderdelen van een kerndoel en vormt op zichzelf geen bewijs dat het volledige doel is behaald.", wikiLanguageNote:"The official SLO goals and these implementation summaries are shown in Dutch.", stUsersSub:"Accounts beheren: leerlingen, staf, ouders en aanmeldingen.", stKlassenSub:"Klassen aanmaken, koppelen en beheren.", @@ -603,7 +607,7 @@ const I18N = { stGroupPersonal:"Personal", stGroupSchool:"School", stGroupSystem:"System", stAccountSub:"Your details, password and backups.", stViewSub:"Display on this device: theme, language and toolbar.", - wikiTitle:"Curriculum wiki", wikiShort:"Wiki", + wikiTitle:"Curriculum wiki", wikiSub:"New SLO attainment targets for Dutch primary education, grouped by learning area.", wikiKicker:"Primary education · July 2026", wikiIntro:"Browse all 40 new attainment targets, their components and Teach's current coverage.", @@ -623,6 +627,10 @@ const I18N = { wikiWidgets:"Current widgets", wikiNoResults:"No attainment targets found.", wikiCarefulTitle:"Use with care.", wikiCarefulText:"A widget practising one component does not automatically cover the full target. School-level and experience targets require different evidence from a right/wrong score.", + widgetCurriculumInfo:"Attainment target information", widgetCurriculumIntro:"This widget contributes to the attainment targets below.", + widgetCurriculumNone:"No direct attainment-target link has been established for this supporting widget.", + widgetCurriculumWiki:"View this target in the curriculum wiki", widgetCurriculumAll:"Open the complete curriculum wiki", + widgetCurriculumCareful:"A widget supports parts of a target and is not, by itself, evidence that the full target has been achieved.", wikiLanguageNote:"The official SLO goals and these implementation summaries are shown in Dutch.", stUsersSub:"Manage accounts: pupils, staff, parents and enrolments.", stKlassenSub:"Create, link and manage classes.", @@ -1011,7 +1019,6 @@ const roleKey = r => r==="super" ? "roleSuper" : r==="admin" ? "roleAdmin" : r== function updateUserUI(){ document.getElementById("userLbl").textContent = currentUser ? currentUser.displayName : T("login"); document.getElementById("btnUser").classList.toggle("on", !!currentUser); - document.getElementById("btnWiki").style.display = can("curriculum.view") ? "" : "none"; /* welke instellingen-secties zichtbaar zijn bepaalt settings.js per rol (sectieregister); hier alleen nog de accountgegevens zelf */ if(currentUser){ diff --git a/public/js/curriculum.js b/public/js/curriculum.js index 3c2d85b..e6b023e 100644 --- a/public/js/curriculum.js +++ b/public/js/curriculum.js @@ -30,8 +30,8 @@ const CURRICULUM_PO_2026=Object.freeze({ id:"rekenen-en-wiskunde",icon:"🔢",title:"Rekenen en wiskunde",goalsRange:"10–18",page:32, description:"Wiskundige concepten, denk-werkwijzen, taal en gereedschap begrijpen en toepassen in de wereld.", goals:[ - {id:10,domain:"Wiskundige concepten",summary:"Leerlingen redeneren en rekenen met getallen, breuken en verhoudingen.",parts:["Gehele en decimale getallen","Breuken","Verhoudingen"],coverage:"partial",widgets:["madd","msub","mmul","mdiv","mpct","numberline","fractions","placevalue"]}, - {id:11,domain:"Wiskundige concepten",summary:"Leerlingen meten, redeneren en rekenen met grootheden, eenheden en passende meetinstrumenten.",parts:["Grootheden en eenheden"],coverage:"partial",widgets:["clock","money"]}, + {id:10,domain:"Wiskundige concepten",summary:"Leerlingen redeneren en rekenen met getallen, breuken en verhoudingen.",parts:["Gehele en decimale getallen","Breuken","Verhoudingen"],coverage:"partial",widgets:["madd","msub","mmul","mdiv","mpct","numberline","fractions","placevalue","dice"]}, + {id:11,domain:"Wiskundige concepten",summary:"Leerlingen meten, redeneren en rekenen met grootheden, eenheden en passende meetinstrumenten.",parts:["Grootheden en eenheden"],coverage:"partial",widgets:["clock","money","timer","schedule"]}, {id:12,domain:"Wiskundige concepten",summary:"Leerlingen representeren en interpreteren data en beoordelen conclusies bij gegevens kritisch.",parts:["Data"],coverage:"support",widgets:["poll"]}, {id:13,domain:"Wiskundige concepten",summary:"Leerlingen herkennen, beschrijven en onderzoeken patronen en verbanden.",parts:["Patronen en verbanden"],coverage:"open",widgets:[]}, {id:14,domain:"Wiskundige concepten",summary:"Leerlingen redeneren over meetkundige figuren en plaatsbepaling en voeren transformaties uit.",parts:["Meetkunde"],coverage:"open",widgets:[]}, @@ -74,7 +74,7 @@ const CURRICULUM_PO_2026=Object.freeze({ description:"Natuurwetenschap, technologie, organismen, gezondheid en systeem aarde onderzoekend leren begrijpen.", goals:[ {id:29,domain:"Natuurwetenschappen en technologie",summary:"Leerlingen onderzoeken vraagstukken met natuurwetenschappelijke en technologische denk- en werkwijzen.",parts:["Vraagstukken","Denkwijzen","Werkwijzen","Aard van natuurwetenschappen en technologie"],coverage:"partial",widgets:["trees","birds","plants"]}, - {id:30,domain:"Natuur- en scheikunde en technische systemen",summary:"Leerlingen onderzoeken technische systemen, stoffen en verschijnselen rond licht, geluid, energie en krachten.",parts:["Technische systemen","Stoffen en hun eigenschappen","Licht, geluid, energie en krachten"],coverage:"open",widgets:[]}, + {id:30,domain:"Natuur- en scheikunde en technische systemen",summary:"Leerlingen onderzoeken technische systemen, stoffen en verschijnselen rond licht, geluid, energie en krachten.",parts:["Technische systemen","Stoffen en hun eigenschappen","Licht, geluid, energie en krachten"],coverage:"support",widgets:["noise"]}, {id:31,domain:"Organismen en gezondheid",summary:"Leerlingen onderzoeken organismen en hun levensprocessen en leren onderbouwde keuzes rond lichamelijke en mentale gezondheid.",parts:["Organismen","Gezond leven"],coverage:"partial",widgets:["trees","birds","plants","breathe","mood","feelings"]}, {id:32,domain:"Systeem aarde",summary:"Leerlingen onderzoeken de aarde, weer, klimaat, water en samenhang en menselijke invloed in ecosystemen.",parts:["Aarde","Weer, klimaat en water","Ecosystemen"],coverage:"partial",widgets:["trees","birds","plants","topography"]} ] @@ -91,8 +91,8 @@ const CURRICULUM_PO_2026=Object.freeze({ id:"kunst-en-cultuur",icon:"🎨",title:"Kunst en cultuur",goalsRange:"35–37",page:92, description:"Artistiek creatief vermogen ontwikkelen, kunstzinnige uitingen maken en kunst en cultuur beleven en duiden.", goals:[ - {id:35,domain:"Artistiek creatief vermogen",summary:"Leerlingen gebruiken creatieve strategieën in een iteratief proces en geven betekenis aan eigen en andermans werk.",parts:["Creatieve maak- en denkstrategieën","Ontwikkelen van creatieve vermogens","Betekenis geven"],coverage:"support",widgets:["write","mind","media"]}, - {id:36,domain:"Maken en betekenis geven",summary:"Leerlingen verbeelden ideeën en gebruiken technieken, vaardigheden, materialen en vaktaal uit verschillende kunstdisciplines.",parts:["Verbeelden en creëren","Kunstzinnige technieken en vaardigheden"],coverage:"support",widgets:["write","media"]}, + {id:35,domain:"Artistiek creatief vermogen",summary:"Leerlingen gebruiken creatieve strategieën in een iteratief proces en geven betekenis aan eigen en andermans werk.",parts:["Creatieve maak- en denkstrategieën","Ontwikkelen van creatieve vermogens","Betekenis geven"],coverage:"support",widgets:["write","mind","media","birthday","avatar"]}, + {id:36,domain:"Maken en betekenis geven",summary:"Leerlingen verbeelden ideeën en gebruiken technieken, vaardigheden, materialen en vaktaal uit verschillende kunstdisciplines.",parts:["Verbeelden en creëren","Kunstzinnige technieken en vaardigheden"],coverage:"support",widgets:["write","media","birthday","avatar"]}, {id:37,domain:"Meemaken en betekenis geven",summary:"Leerlingen beleven kunst en cultuur actief en verbinden uitingen aan maker, tijd, plaats, functie en context.",parts:["Kunst en cultuur actief meemaken","Kunst en cultuur in context"],coverage:"open",widgets:[]} ] }, diff --git a/public/js/settings.js b/public/js/settings.js index b0b3315..f9e4f3d 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -148,13 +148,13 @@ function registerSettingsSection(sec){ ST_SECTIONS.push(sec); } }); /* ---- openen ---------------------------------------------------------------- */ - window.openSettings = function(section){ + 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")); + 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) */ diff --git a/public/js/wiki.js b/public/js/wiki.js index 80b892c..0f3a3be 100644 --- a/public/js/wiki.js +++ b/public/js/wiki.js @@ -3,7 +3,6 @@ (function(){ const root=document.getElementById("curriculumWiki"); - const wikiButton=document.getElementById("btnWiki"); let selectedArea="all"; let query=""; @@ -57,6 +56,7 @@ 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)); @@ -94,7 +94,7 @@ } function renderCurriculumWiki(payload){ - if(payload&&payload.area)selectedArea=payload.area; + if(payload&&payload.area){selectedArea=payload.area;query="";} root.replaceChildren(); const header=node("div","wiki-hero"); const heroText=node("div","wiki-hero-text"); @@ -169,6 +169,10 @@ 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"))); @@ -198,5 +202,4 @@ } }); - wikiButton.addEventListener("click",()=>openSettings("curriculum")); })(); diff --git a/test/curriculum-wiki.test.js b/test/curriculum-wiki.test.js index 15827da..d387350 100644 --- a/test/curriculum-wiki.test.js +++ b/test/curriculum-wiki.test.js @@ -27,16 +27,48 @@ test('curriculumdataset bevat negen leergebieden en alle veertig kerndoelen',asy } }); -test('curriculumwiki is alleen zichtbaar voor alle volwassen rollen',async()=>{ +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]=await Promise.all([ + const [client,wiki,board,html]=await Promise.all([ readFile('public/js/permissions.js','utf8'), - readFile('public/js/wiki.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(wiki,/wikiButton\.addEventListener\("click",\(\)=>openSettings\("curriculum"\)\)/); + 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*=/); }); @@ -51,7 +83,7 @@ test('curriculumwiki is geladen, doorzoekbaar, responsief en tweetalig bediend', assert.ok(html.indexOf('js/settings.js')]*display:none/); + assert.doesNotMatch(html,/id="btnWiki"/); assert.match(html,/data-section="curriculum"/); assert.match(wiki,/input\.type="search"/); assert.match(wiki,/dataset\.area/); @@ -60,6 +92,6 @@ test('curriculumwiki is geladen, doorzoekbaar, responsief en tweetalig bediend', } assert.match(css,/\.wiki-grid\{[^}]*grid-template-columns:repeat\(2/); assert.match(css,/@media \(max-width:720px\)[\s\S]*?\.wiki-grid\{grid-template-columns:1fr\}/); - assert.ok(core.includes('document.getElementById("btnWiki").style.display = can("curriculum.view")')); + assert.doesNotMatch(core,/btnWiki/); assert.ok(core.includes('const VERSION = "'+version.trim()+'"')); }); -- 2.45.2 From b81f5ac5dda7d342f7aaefcfe2c13021fb445a41 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 20 Jul 2026 14:08:37 +0200 Subject: [PATCH 178/192] feat: voeg rolvoorbeeld voor systeemmanager toe (v0.4.39-beta) --- VERSION | 2 +- public/css/teach.css | 17 +++++- public/index.html | 15 ++++++ public/js/admin.js | 12 ++--- public/js/core.js | 22 ++++++-- public/js/explorer.js | 4 +- public/js/permissions.js | 26 ++++++--- public/js/pupil.js | 4 +- public/js/role-preview.js | 108 ++++++++++++++++++++++++++++++++++++++ public/js/settings.js | 2 +- test/role-preview.test.js | 64 ++++++++++++++++++++++ 11 files changed, 252 insertions(+), 24 deletions(-) create mode 100644 public/js/role-preview.js create mode 100644 test/role-preview.test.js diff --git a/VERSION b/VERSION index e735d60..8307d81 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.38-beta +0.4.39-beta diff --git a/public/css/teach.css b/public/css/teach.css index 5699a76..5b99773 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -640,7 +640,8 @@ .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}\n .widget-curriculum-modal{width:min(620px,94vw);max-height:min(760px,88vh);overflow:auto} + .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} @@ -1298,6 +1299,20 @@ .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]{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} +.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} +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);white-space:nowrap}#rolePreviewBanner strong{display:none}} + /* ---------- weergave-instellingen (tabblad "Weergave") ---------- */ .vw-row{ display:flex; align-items:center; justify-content:space-between; gap:12px; diff --git a/public/index.html b/public/index.html index c2266c4..a9ddc2d 100644 --- a/public/index.html +++ b/public/index.html @@ -52,6 +52,7 @@ + @@ -74,6 +75,7 @@ +
@@ -248,6 +250,19 @@