Compare commits

...

147 commits

Author SHA1 Message Date
Ramon
e34bda5251 test: expliciete regressietest voor de beperkte rechten van groepsleiding (v0.4.08-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 42s
Geen gedragswijziging - dit legt vast (en bevestigt) wat al klopte: een
groepsleiding kan geen rol wijzigen, geen gebruiker aan een school
koppelen/losmaken, geen klas aanmaken en geen school beheren. Alleen
systeemmanager (en voor klassen: ook schoolbeheerder) mag dit.
2026-07-18 04:49:10 +02:00
Ramon
e2ee4820dc fix: schoolbeheerder aan klas koppelen, systeemmanager-promotie dichten, UI-afstand (v0.4.07-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
- db/017: de class_teachers-trigger uit migratie 006 stond alleen 'teacher'
  toe en blokkeerde daarmee (met een verwarrende "groepsleiding en klas..."
  foutmelding) wat de applicatielaag allang toestond voor schoolbeheerder en
  systeemmanager - de trigger is nooit meegegroeid toen die rollen later ook
  aan een klas gekoppeld mochten worden. Nu ook admin/super toegestaan,
  super school-onafhankelijk zoals de rest van de app dat al behandelt.
- rolwijziging kon een bestaande groepsleiding/schoolbeheerder via een simpel
  dropdown-veld naar systeemmanager promoveren, zonder enige bevestiging -
  in tegenstelling tot de nieuwe systeemmanager-aanmaak (met wachtwoord-
  bevestiging). 'super' is nu geen geldig doel meer voor PATCH .../users/:id
  (ook niet via het extraRoles-mechanisme); het rolwijzigingsveld zelf toont
  zich niet meer voor een bestaande systeemmanager. Alleen de aparte
  aanmaakstap (met bevestiging) kan nog een systeemmanager opleveren.
- meer verticale ruimte tussen rijen in het beheerpaneel (was vrijwel 0),
  zodat je niet per ongeluk de verkeerde rij raakt
- de "+ ..."-toevoegknoppen staan nu rechts uitgelijnd i.p.v. links, zodat ze
  duidelijker als aparte actie ogen i.p.v. als onderdeel van de lijst

Geverifieerd in een echte headless Chromium: rolwijziging toont voor een
groepsleiding alleen nog teacher/admin als opties.
2026-07-18 04:40:39 +02:00
Ramon
111e48b044 fix: toevoegformulier duidelijk anders dan een bestaande-gebruikersrij (v0.4.06-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 34s
Een uitgeklapt "+ ..."-formulier (nieuwe schoolbeheerder/leerkracht/leerling/
school/klas) zag er in het beheerpaneel bijna hetzelfde uit als het
bewerkpaneel van een bestaande gebruiker - beide gewoon een rij velden
zonder duidelijk onderscheid.

- .am-add krijgt nu een eigen kader (accentkleur, stippellijn) i.p.v. de
  neutrale achtergrond van een gebruikersrij
- het formulier toont bovenaan een titel die herhaalt wát je toevoegt (bv.
  "LEERLING"), ook zodra de knoptekst erboven uit beeld is
- de dichtgeklapte "+ ..."-knop zelf krijgt ook de accentkleur, zodat
  collapsed en uitgeklapt dezelfde "dit is toevoegen"-taal spreken
- geverifieerd in een echte headless Chromium: duidelijk visueel onderscheid
  tussen een toevoegformulier en een echte gebruikersrij
2026-07-18 04:22:17 +02:00
Ramon
e5890280cc feat: snel-toevoegen-knop onder elke groep in Gebruikers (v0.4.05-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 34s
Net als bij systeemmanagers (vorige commit) krijgt nu elke groep in de
Gebruikers-tab (schoolbeheerder, groepsleiding, en elke klas incl. "geen
klas") direct een eigen "+ ..."-knop eronder om iemand toe te voegen zonder
naar een ander tabblad te hoeven. Bij een klas staat die klas al vast
ingevuld (presetClassId, bestond al als parameter maar werd nog nergens
gebruikt). Een groep blijft zichtbaar zolang er iets aan toegevoegd kan
worden, ook als hij nu leeg is (bv. een gloednieuwe klas zonder leerlingen) -
anders was er nergens een knop om de eerste aan toe te voegen.

Geverifieerd in een echte headless Chromium (gemockte /api/** responses):
groepen en knoppen verschijnen zoals verwacht, en de klas-select bij een
klasgroep staat al goed vooringesteld.
2026-07-18 04:14:27 +02:00
Ramon
c6638d63f1 feat: systeemmanager aanmaken verplaatst naar Gebruikers + eigen wachtwoord ter bevestiging (v0.4.04-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
- het aanmaken van een systeemmanager stond onder Scholen, los van de rest
  van het gebruikersbeheer - verplaatst naar de Gebruikers-tab (bij "alle
  scholen", naast de bestaande systeemmanagers-groep)
- extra veiligheidsstap: de aanmaker moet het eigen wachtwoord invullen ter
  bevestiging vóórdat een nieuwe systeemmanager wordt aangemaakt - server
  verifieert dit (POST /admin/users), niet alleen de client
- wie dit al mocht (alleen systeemmanagers, via creatableRoles) blijft
  ongewijzigd - de bevestigingsstap is een aanvullende, geen vervangende,
  controle
2026-07-18 04:00:30 +02:00
Ramon
cae700f76d fix: leerling verplaatsen naar een andere klas ververst het paneel niet (v0.4.03-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 33s
De klas-select bij een leerling (uitwisselen naar een andere klas) miste de
reload() die elders bij een klaswijziging wel gebeurt: de server sloeg de
nieuwe klas gewoon op, maar het paneel liet nog de oude toestand zien - leek
dus alsof verplaatsen niet werkte. Test toegevoegd die de serverkant van
deze route bevestigt (die werkte al correct); de fix zelf is puur de
ontbrekende reload() in public/js/admin.js.
2026-07-18 03:51:21 +02:00
Ramon
45f66044f2 fix: alle DELETE-verzoeken faalden op een lege JSON-body (v0.4.02-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 38s
De echte, uiteindelijke oorzaak achter elke "serverfout" die niet door een
route-eigen foutafhandeling kwam (klas verwijderen, en zeer waarschijnlijk
ook de oorspronkelijke gebruiker-verwijderen-bug): de client stuurde bij élk
verzoek altijd "Content-Type: application/json" mee, ook bij een DELETE
zonder body. Fastify's standaard JSON-parser weigert zo'n leeg lichaam met
"Body cannot be empty when content-type is set to 'application/json'" -
vóórdat er ook maar één hook of route-handler heeft kunnen draaien, dus nog
vóór req.user gezet wordt en vóór enige try/catch in een route. Dat verklaart
waarom eerdere per-route foutafhandeling dit nooit kon vangen: de fout zat
vóór elke route.

- public/js/core.js: api() stuurt Content-Type alleen nog mee als er
  werkelijk een JSON-body is
- src/body-parser.js (nieuw): serverside vangnet dat een leeg lichaam bij
  Content-Type: application/json gewoon toestaat i.p.v. te weigeren -
  ongeacht wat een client meestuurt
- test/body-parser.test.js: reproduceert de exacte foutmelding tegen een
  echte Fastify-requestcyclus (de gemockte-pool-tests konden dit principieel
  nooit vangen, vandaar dat dit alle eerdere testrondes overleefde)
2026-07-18 03:44:39 +02:00
Ramon
4517f592f7 chore: versienummering terug naar tweecijferig patchformaat (v0.4.01-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 55s
0.3.99 rolde per ongeluk door naar 0.4.0/0.4.1 i.p.v. het gebruikelijke
tweecijferige patchformaat (0.4.00, 0.4.01, ...) - hersteld.
2026-07-18 03:40:51 +02:00
Ramon
6742e07363 fix: altijd de echte serverfout tonen + klasnamen uniek per school (v0.4.1-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 35s
- de generieke errorhandler gaf alleen detail mee als req.user al gezet was;
  bleek de fout zelf al vóór/tijdens dat moment te zitten (zoals bij klas
  verwijderen), dan bleef het toch de kale "serverfout" - nu altijd de echte
  oorzaak, dit is een intern beheertool zonder publieke bezoekers
- klasnamen konden onbeperkt dubbel aangemaakt worden binnen dezelfde school
  (geen enkele controle) - POST /admin/classes weigert dit nu met 409, en een
  nieuwe unieke index (school_id, lower(name)) is het echte vangnet
- migratie 016 hernoemt eerst bestaande dubbele klasnamen (niets verwijderd)
  vóórdat de unieke index wordt gezet: anders was de migratie zelf op deze
  database mislukt en had de app niet meer opgestart
2026-07-18 03:39:04 +02:00
Ramon
4a31dc9351 fix: klas verwijderen toont echte oorzaak i.p.v. kale serverfout (v0.4.0-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 43s
- DELETE /admin/classes/:id had geen try/catch: elke mislukking (bv. een FK
  die nog niet cascade/set-null is op deze database) viel terug op de
  generieke handler en toonde alleen "serverfout" zonder aanknopingspunt
- zelfde aanpak als eerder bij het verwijderen van een gebruiker: de echte
  Postgres-foutmelding komt nu terug i.p.v. een kale "serverfout"
- test toegevoegd voor zowel het gewone pad als de foutmelding
2026-07-18 03:28:31 +02:00
Ramon
be8ecc5378 fix: gebruikerslijst per school laadde nooit (SELECT DISTINCT/ORDER BY) (v0.3.99-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m13s
- linkedParents() gebruikte "SELECT DISTINCT p.* ... ORDER BY lower(p.username)":
  Postgres weigert dit omdat de ORDER BY-expressie niet letterlijk in de
  select-lijst staat. Deze query loopt bij élke school-gescopede
  gebruikerslijst (elke schoolbeheerder, en elke systeemmanager die een
  specifieke school kiest i.p.v. "alle scholen") - dus die lijst laadde
  nooit, klassen werden om dezelfde reden niet (opnieuw) opgehaald, en
  "alle scholen" was de enige werkende weergave
- fix: lower(p.username) expliciet als kolom (sort_key) in de select-lijst,
  ORDER BY daarop i.p.v. op de losse expressie
- regressietest die de exacte SQL-vorm vastlegt (een gemockte pool voert geen
  echte SQL uit en had deze Postgres-grammaticafout niet kunnen vangen)
2026-07-18 03:19:36 +02:00
Ramon
205ba74eff fix: beheerpaneel toont laadfout i.p.v. stilletjes leeg te blijven (v0.3.98-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 34s
- reload() in het beheerpaneel (scholen/klassen/gebruikers) ving elke fout
  stilletjes op en zette alles terug naar lege lijsten, zonder enige melding -
  dat gaf een verwarrend leeg paneel bij een specifieke school terwijl "alle
  scholen" wel werkte, zonder enig aanknopingspunt waarom
- de echte oorzaak verschijnt nu bovenin het paneel (amLoadError), zodat een
  volgende mislukking direct te diagnosticeren is i.p.v. te moeten gokken
2026-07-18 03:08:55 +02:00
Ramon
4ffb69ca61 fix: gebruiker verwijderen toont echte oorzaak i.p.v. kale serverfout (v0.3.97-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m19s
- DELETE /admin/users/🆔 hele handler (niet alleen deleteUserFully) in
  één try/catch, zodat ook een fout vóór het verwijderen zelf (bv. de
  opzoekquery) de echte reden teruggeeft i.p.v. terug te vallen op de
  generieke 500 "serverfout"
- generieke errorhandler logt voortaan altijd het volledige fouteobject en
  geeft ingelogde gebruikers de werkelijke foutmelding mee i.p.v. alleen
  "serverfout"
- test toegevoegd die dit nieuwe pad dekt
2026-07-17 22:27:20 +02:00
Ramon
e48a3a4e46 fix: gebruiker verwijderen werkt gegarandeerd - ruimt afhankelijke rijen zelf op (v0.3.96-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 51s
- De "serverfout" bij verwijderen komt van een foreign key die op de live-database geen ON DELETE CASCADE heeft, waardoor DELETE FROM users faalt zodra er nog een gekoppelde rij bestaat
- Nieuw: de verwijderroute haalt in één transactie eerst alle afhankelijke rijen weg (sessies, rollen, klaskoppelingen, toewijzingen, voortgang, ouderkoppelingen, aanmeldingen, reservekopieën, gedeelde items, afbeeldingen, borden/mappen) en verwijdert daarna de gebruiker - dit werkt ongeacht de exacte FK-instelling en heeft migratie 015 niet nodig
- Mocht verwijderen tóch falen, dan komt nu de echte databasereden terug (constraint-detail) i.p.v. een kale "serverfout", zodat de oorzaak zichtbaar is
- 3 servertests: volledige opruiming in transactievolgorde (afhankelijke rijen vóór users), echte foutreden bij een gesimuleerde fout; 97 tests groen

LET OP: dit werkt pas na deploy van deze versie; v0.3.94 (huidige live) bevat de fix nog niet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-17 21:50:48 +02:00
Ramon
e3036580ce fix: gebruiker verwijderen werkt weer (herstel foreign-key cascade) (v0.3.95-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 32s
- Verwijderen gaf op de live-database een 500 "serverfout". Oorzaak: op databases die zijn ontstaan vóór alle foreign keys ON DELETE CASCADE waren, houdt een tabel de oude constraint (CREATE TABLE IF NOT EXISTS werkt een bestaande constraint nooit bij), waardoor DELETE FROM users een foreign-key-fout geeft
- Migratie 015: zoekt elke FK naar users(id) die niet cascade en niet set-null is en zet die alsnog op ON DELETE CASCADE (bewuste SET NULL-constraints blijven ongemoeid). Draait bij de volgende deploy en is idempotent
- Verwijderroute vangt een resterende FK-fout (23503) op als een begrijpelijke 409 i.p.v. een kale serverfout
- Geverifieerd: alle INSERT INTO users-paden vangen al 23505 op (gebruikersnaam uniek per school); dezelfde naam in verschillende scholen is bewust toegestaan (inloggen kiest de school) en staat sinds 0.3.94 per school gegroepeerd
- 3 nieuwe servertests (FK-fout -> 409, normale verwijdering, migratie-inhoud); 97 tests groen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-17 21:39:29 +02:00
Ramon
30818dc5f2 fix: gebruikers gegroepeerd per school in alle-scholen-weergave + zichtbare verwijderfouten (v0.3.94-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 35s
- In de "alle scholen"-weergave stonden gebruikers op rol gegroepeerd, waardoor je niet zag bij welke school ze horen (Ramon/Rubi/leerlingb1b leken los te staan). Ze worden nu per school gegroepeerd (inklapbaar), met systeemmanagers, ouders en losse accounts apart eronder - je ziet meteen wie bij De Ziep hoort
- Bij één gekozen school blijft de indeling op rol/klas; die rolgroepen zijn nu ook inklapbaar
- Verwijderfouten (en andere actie-fouten in het detailpaneel) verschijnen nu in het detailpaneel zelf i.p.v. alleen bovenaan (buiten beeld op mobiel). Als een verwijdering serverzijdig faalt, zie je nu de reden - het lijkt niet langer of "niets gebeurt"
- Verwijderen zelf is end-to-end geverifieerd (DELETE verstuurd, gebruiker verdwijnt); een gesimuleerde 409 toont de melding in het paneel en laat de gebruiker staan
- 7 nieuwe Playwright-checks (school-groepering, zichtbare fout, geslaagde verwijdering) + volledige regressie van 8 suites; 94 servertests groen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-17 21:21:05 +02:00
Ramon
c86abcf794 fix: onzichtbare knopteksten, lege klassenlijst en correcte klas-schoolregels (v0.3.93-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 32s
- Onzichtbare knoppen opgelost: .tbtn houdt standaard witte tekst (wit-op-blauw), maar in het beheer staat hij op een lichte achtergrond -> tekst was wit-op-wit. Nu expliciet donkere tekst; de primaire accentknop houdt bewust witte tekst
- Systeemmanager kreeg standaard "alle scholen" waardoor klassen leeg leken en niet toe te voegen waren; er wordt nu meteen een concrete school gekozen (klassen/gebruikers direct gevuld). Contextbalk laat wisselen; "alle scholen" blijft een bewuste keuze
- Lege school toont nu een duidelijke uitleg dat bestaande klassen bij een andere school kunnen horen, met verwijzing naar de schoolkeuze
- Correcte gegevensregels: classes.school_id is NOT NULL met ON DELETE CASCADE, dus een klas kan niet "los" bestaan (die eerdere aanname is teruggedraaid). Een klas is nu te VERPLAATSEN naar een andere school (PATCH weigert null/ongeldig); een verwijderde school neemt zijn klassen mee (cascade) - dat verklaart verdwenen klassen
- Bij een leerling naar een andere school koppelen blijft de klas behouden als die al bij de doelschool hoort
- Verificatie uitgebreid met een echte kleurcontrast-scan (vangt wit-op-wit) over alle secties + de default-school-flow; alle 94 servertests en 8 Playwright-suites groen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-17 20:22:36 +02:00
Ramon
aee1c59fbd fix: leerlingen zichtbaar bij gekozen school + uniforme controls in het beheer (v0.3.92-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 35s
- Leerlingen stonden onder standaard-ingeklapte klasgroepen, dus met een gekozen school leek de lijst leeg terwijl de detailweergave de juiste school toonde. Klasgroepen staan nu standaard open zodra een school is gekozen (de schoolkeuze wás al het filter); bewust dichtklappen wordt onthouden
- Losse klassen (school verwijderd) waren nergens zichtbaar: de systeemmanager ziet ze nu zonder gekozen school met een uitleg, en kan ze via een School-veld terugkoppelen; nieuwe PATCH /admin/classes/:id + GET ?school=none (super) met servertests
- Bij het aan een school koppelen van een leerling blijft de klasindeling behouden als die klas al bij de doelschool hoort (eerst klas terugkoppelen, dan de leerlingen)
- UI-consistentie: één uniforme control-hoogte (32px) voor álle knoppen, selects en invoervelden in Instellingen - einde aan de grote maatverschillen; T() valt terug op NL en dan de sleutelnaam zodat een knop nooit zonder tekst kan staan
- Status in de gebruikerstabel als compacte glyph (● in gebruik / ○ wacht) met uitleg bij aanwijzen i.p.v. een afgekapte "nog niet ing…"-chip
- Geverifieerd in headless Chromium: nieuwe suite (21 checks: zichtbaarheid met schoolfilter, losse klassen, geen lege knoppen, uniforme hoogte over 8 secties) + volledige regressie van 5 bestaande suites

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-17 19:46:09 +02:00
Ramon
15618348dc style: alle menu's nagelopen - alles verborgen tot erom gevraagd wordt (v0.3.91-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 33s
- Voortgang: tijdlijn toont 30 regels per keer met een "meer tonen (n)"-knop i.p.v. 200 regels ineens
- Inhoud: de vier blokken (welkomstwoord, melding, widget-uitleg, systeembrede inhoud) zijn inklapbare groepskaarten - alleen het welkomstwoord staat standaard open; open-status overleeft her-renders
- Account: de drie wachtwoordvelden verschijnen pas na een klik op de kop "Wachtwoord"
- Daarmee volgt elke sectie hetzelfde principe: compacte koppen met samenvatting, detail pas op verzoek, verwante items in één kader
- Geverifieerd in headless Chromium (9 nieuwe checks) + volledige regressie van de vier bestaande suites (56 checks)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-17 14:35:24 +02:00
Ramon
b4eee9f78f feat: gebruikers aan een school koppelen en losmaken (v0.3.90-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 52s
- PATCH /admin/users/:id ondersteunt schoolId (alleen systeemmanager, rollen teacher/admin/pupil): null = losmaken, anders koppelen met bestaanscheck (404); bij wissel worden klas, klaskoppelingen en (voor leerlingen) oude toewijzingen opgeruimd; naamconflicten in doelschool of school-loze naamruimte geven een nette 409
- Detailpaneel toont voor de systeemmanager een School-veld bij deze rollen - losse accounts (ooit aan een school gekoppeld) zijn hiermee terug te zetten en bewust los te maken; werkt ook in de beheerder-subrijen op het Scholen-tabblad
- Gebruikers-tab (alle scholen): losse accounts staan in een eigen inklapbare groep "Zonder school (n)" i.p.v. onopvallend tussen de rolgroepen; de oudergroep is nu ook inklapbaar
- 3 nieuwe servertests (koppelen incl. opruiming, losmaken, 404, geen rechten/no-op voor admin en parent, 409 bij naamconflict) + 10 Playwright-checks voor de volledige flow

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-17 14:30:41 +02:00
Ramon
08618ba0b3 feat: Scholen, Klassen en Toewijzingen als groepskaarten (v0.3.89-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 31s
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-17 14:27:19 +02:00
Ramon
c3492ab2ca feat: Opslag per school met groepskaart-component (v0.3.88-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 55s
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-17 14:20:41 +02:00
Ramon
ebe622588f style: tekstknoppen en consistente secties in het beheer - emoji-sweep afgerond (v0.3.87-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 45s
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-17 07:08:17 +02:00
Ramon
af86ff5c47 feat: gebruikersbeheer als master-detail met vast bewerkpaneel (v0.3.86-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 34s
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-17 07:06:18 +02:00
Ramon
8b4bd47407 style: zakelijk ontwerpsysteem voor Instellingen (v0.3.85-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 33s
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 20:42:53 +02:00
Ramon
526e4e5d52 feat: CMS-sectie Inhoud voor de systeemmanager (v0.3.84-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 56s
- Nieuwe sectie "Inhoud" in Instellingen (groep Systeem, alleen systeemmanager, doorzoekbaar via het sectieregister)
- Welkomstwoord (NL/EN) verschijnt op het inlogscherm, direct aanpasbaar zonder release
- Melding voor alle gebruikers: aan/uit, niveau informatie/belangrijk, per taal; verschijnt als wegklikbare banner onder de menubalk en elke wijziging krijgt een nieuw id zodat een eerder weggeklikte melding opnieuw verschijnt
- Widget-uitleg: per widget een eigen beschrijving die de standaardtekst in de widgetbibliotheek vervangt (leeg = standaard, placeholder toont de standaardtekst)
- Snelkoppelingen naar de bestaande kanalen voor systeembrede inhoud: gedeelde bibliotheek (borden & ankerdiagrammen, map 🌐 Systeem) en de afbeeldingencatalogus
- settings.js kreeg een generiek haakje (showSettingsDom) zodat modules met een eigen paneel zich in het sectieregister kunnen voegen
- Geverifieerd in headless Chromium (15 checks): banner + dismiss + nieuw-id-gedrag, welkomstwoord, bewerken/opslaan, galerij-override, sectie onzichtbaar voor andere rollen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 19:33:49 +02:00
Ramon
aa4c5faaf9 feat: server-kant CMS voor de systeemmanager (v0.3.83-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 31s
- Migratie 014: site_content (sleutel/waarde, JSONB) voor site-inhoud die zonder release aanpasbaar moet zijn
- GET /content (publiek): welkomstwoord, update-melding en widget-uitleg in één antwoord - het inlogscherm toont het welkomstwoord al vóór het inloggen
- PUT /admin/content/:key (alleen systeemmanager, nieuwe permissie content.manage): upsert per toegestane sleutel (welcome/announcement/widgetInfo) met maat-limieten
- 4 servertests: publiek lezen, upsert door super, 403/401 voor anderen, whitelist/vorm/omvang-validatie

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 19:29:08 +02:00
Ramon
2fff8a3aa5 perf: gzip-vangnet in nginx voor geproxyde responses (v0.3.82-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m4s
- gzip on + gzip_proxied any (zonder die laatste comprimeert nginx geproxyde responses helemaal niet) + gzip_vary + min_length 1024 voor css/js/json/svg/tekst
- Geen dubbele compressie: responses die al Content-Encoding dragen (brotli/gzip uit de app) laat nginx ongemoeid; dit vangt alleen wat de app mist
- Cache-beleid blijft bewust op één plek (de app), zodat lokaal en productie identiek gedragen
- Configtest erbij in security-config-stijl

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 19:02:53 +02:00
Ramon
456d664718 perf: compressie, cache-headers, cache-busting en parallel ladende scripts (v0.3.81-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m14s
- @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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 19:01:25 +02:00
Ramon
cb97dc2bb1 feat: breid taalwidgets en plaatjesbibliotheek uit (v0.3.80-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 31s
2026-07-16 16:27:38 +02:00
Ramon
4356658c96 fix: versietest pint geen vast nummer meer (v0.3.79-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 43s
- De CI faalde omdat een test het versienummer hardcodeerde (0.3.77-beta) en dus bij elke reguliere versiebump breekt
- De test bewaakt nu het echte beleid: de dev-branch voert altijd een -beta-versie en de VERSION-file loopt gelijk met de const in core.js (die controle stond er al en blijft)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 15:01:59 +02:00
Ramon
2a0de85152 fix: vastgezette lat kleeft altijd en vinger-jitter breekt de snap niet meer (v0.3.78-beta)
Some checks failed
dev - build & deploy naar test / build-and-deploy (push) Failing after 32s
- Op een aanraakscherm wiebelt een vinger in de eerste millimeters: de richtingstoets wees de snap daardoor af en de haal bleef de vinger volgen
- Vastgezet gereedschap (🔒) kleeft nu onvoorwaardelijk binnen 28px van de rand - geen richtingstoets; het slot betekent "hier teken ik langs"
- Losse lat: richting wordt over een langere basis gemeten (10px i.p.v. 7) met een ruimere hoek (40° i.p.v. 30°), zodat vinger-jitter de evenwijdige intentie niet meer maskeert
- Inktlaag kreeg touch-action:none zodat de browser vingerhalen nooit als scroll-gebaar afvangt
- Geverifieerd met échte touch-events (CDP) inclusief nagebootste vinger-jitter: 7 nieuwe checks (vastgezet kleeft mét jitter, op de lat tekenen zonder verschuiven, los evenwijdig kleeft, wegdraaiend blijft vrij) + de vier bestaande muis-suites (42 checks) blijven groen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 14:45:51 +02:00
Ramon
7a5d7c57ff feat: maak widgets speelser en kindvriendelijker (v0.3.77-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 41s
2026-07-16 14:15:28 +02:00
Ramon
85268050fd feat: slot op liniaal/geodriehoek en richtingsbewust langs de lat tekenen (v0.3.76-beta)
Some checks failed
dev - build & deploy naar test / build-and-deploy (push) Failing after 34s
- Slot-knop (🔓/🔒) op liniaal én geodriehoek: vastgezet gereedschap kan niet meer verschuiven of draaien (draaiknop verdwijnt), er kan alleen nog langs getekend worden; tweede klik ontgrendelt
- Naast de lat beslist nu de richting i.p.v. één vaste afstand: binnen 22px van een rand wacht de haal de eerste ~7px beweging af - loopt die grofweg evenwijdig aan de rand (≤30°), dan kleeft hij, anders blijft hij vrij; een tik zet gewoon een stip
- Daarmee is langs de lat tekenen betrouwbaar (ook met dikke digibord-vingers) zónder dat de lat vrije halen in de buurt grijpt
- Op de lat zelf blijft de intentie ondubbelzinnig: direct kleven binnen 24px van de rand
- Geverifieerd in headless Chromium: 12 nieuwe checks (richtingsregel, stip-tik, slot aan/uit, vastgezet = onverplaatsbaar maar betekenbaar) + de drie bestaande suites (35 checks) blijven groen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 14:04:15 +02:00
Ramon
5d12d2f8ec feat: kleinere snapzone, dunnere pennen, potlood met karakter en kalligrafische vulpen (v0.3.75-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 40s
- Snapzone naast de liniaal/geodriehoek verkleind van 24 naar 10px: vrije halen in de buurt worden niet meer "gegrepen"; op de lat zelf blijft de ruime 24px-zone gelden
- Diktekiezer uitgebreid met dun (1 en 2, naast 3/5/8)
- ✏️ heet nu Potlood en tekent zo: een zachte, gelijkmatige lijn (~85% dekking) via de overlay-laag, dus égaal ook waar de haal zichzelf kruist
- Vulpen schrijft nu echt kalligrafisch met een 45°-penpunt: dik in de neerhaal, dun in de ophaal (gemeten 11px vs 1,5px bij dikte 5); stylusdruk versterkt het effect
- Geverifieerd in headless Chromium (9 checks, pixelmetingen van dekking, snapzones en lijndiktes)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 13:55:36 +02:00
Ramon
6e7f24eb40 fix: pen op de lat tekent nu langs de rand i.p.v. de lat te verslepen (v0.3.74-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 27s
- In de praktijk zet je de pen óp de liniaal-/geodriehoekrand, zoals bij een echte lat - maar dat versleepte het gereedschap, waardoor "langs de lat tekenen" niets leek te doen
- Landt de pen (pen/vulpen/stift, penmodus actief) binnen 24px van een tekenrand - ook óp de lat zelf - dan start een vastgeklikte haal en blijft het gereedschap liggen; de events lopen via pointer capture gewoon door naar de inktlaag
- Verder van de rand blijft het gereedschap gewoon te verslepen, en de draaiknop houdt voorrang
- Startzone naast de lat verruimd van 16 naar 24px (digibord-vingers)
- Geverifieerd in headless Chromium: 7 nieuwe checks (tekenen op de randzone zonder de lat te verschuiven, verslepen vanuit het midden, draaien in penmodus, idem geodriehoek) + de bestaande 14 snap-checks blijven groen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 13:46:18 +02:00
Ramon
abafcafe3a feat: breid natuurwidgets uit
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 30s
2026-07-16 13:43:46 +02:00
Ramon
55520ba367 feat: tekenen langs liniaal en geodriehoek + geodriehoek met twee niveaus (v0.3.72-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 27s
- Langs de lat tekenen: begint een haal binnen 16px van een rand van de liniaal of geodriehoek, dan kleeft de hele haal aan die rand - kaarsrecht, ook gedraaid (randen draaien mee); bewust wegtrekken (>60px) laat weer los; gum en vrije halen blijven onaangetast
- Gereedschappen liggen nu boven de inktlaag, zoals een echte lat op het bord: ook in penmodus te verplaatsen, draaien en instellen
- Geodriehoek herbouwd met twee niveaus: basis (grote hoofdhoeken 30/45/60/…, hele centimeters - voor jonge leerlingen) en volledig (graden per 2° met dubbele schaal links- én rechtsom, millimeters, evenwijdige hulplijnen per cm - voor meetkunde in de bovenbouw en daarna)
- Geodriehoek: cm-schaal vanaf 0 in het midden zoals echt, maat in hele cm op de knop (6/8/12 cm), 90°-markering, begrensd op de bordbreedte; alleen de driehoek zelf vangt aanrakingen zodat er vlak naast de schuine randen getekend kan worden
- Geverifieerd in headless Chromium (14 checks, o.a. pixelmetingen van rechtgetrokken halen op rechte én 30°-gedraaide randen)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 13:38:14 +02:00
Ramon
93c287979c feat: breid wereldorientatie-oefeningen uit (v0.3.71-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 30s
2026-07-16 11:44:30 +02:00
Ramon
100dc62087 feat: verbeter wereldorientatie met echte beelden (v0.3.70-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 27s
2026-07-16 10:31:50 +02:00
Ramon
88d1f9cb86 feat: voeg wereldorientatiewidgets toe (v0.3.69-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 26s
2026-07-16 09:42:45 +02:00
Ramon
22233c5f92 fix: laat de minutenwijzer de urenwijzer verzetten (v0.3.68-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 24s
2026-07-16 09:14:15 +02:00
Ramon
66fbac7221 fix: laat de oefenklok door alle 24 uren draaien (v0.3.67-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 27s
2026-07-16 08:53:09 +02:00
Ramon
ecf60304a9 fix: centreer widgetopdrachten en herstel 24-uursinvoer (v0.3.66-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 39s
2026-07-16 08:41:26 +02:00
Ramon
652e039a39 feat: vloeiende pen en stift, egale markeerstift en logischere liniaal (v0.3.65-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 31s
- Pen, vulpen en stift tekenen nu met kwadratische curves door de middelpunten van de pointer-stappen: geen hoekige knikken meer bij snelle halen (het "blikkerige" beeld)
- Markeerstift tekent tijdens de haal dekkend op een eigen doorschijnende laag en wordt bij het loslaten in één keer met vaste transparantie op de inktlaag gezet: overlappingen binnen één haal geven geen donkere vlekken meer, een tweede haal eroverheen wordt wél donkerder (zoals een echte stift)
- devicePixelRatio wordt bij elke resize vers gelezen (scherp blijven na verplaatsen naar een ander scherm of zoomen)
- Liniaal: logische lengtes in hele centimeters (5/10/15/20 cm, knop toont de actuele maat), schaal telt vanaf 0 met een kantlijn zoals een echte liniaal, lengte begrensd op de bordbreedte, en de lengte staat op de liniaal zelf
- Liniaal en geodriehoek: draaien toont live de hoek in graden en klikt vast op veelvouden van 45° (badge kleurt groen); knoppen hebben leesbare titels (Lengte / Hoog contrast)
- Geverifieerd in headless Chromium (12 checks, o.a. pixelmeting van de markeerstift-kruising en exacte 45°-snap)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 08:37:23 +02:00
Ramon
3e6ab09e1f fix: ankerkiezer opent alleen op een bewuste tik en tekenbalk krimpt weer geminimaliseerd (v0.3.64-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 31s
- Oorzaak van het "vanzelf" openende uploadvenster gevonden: elk leeg plaatjesvak in een ankerdiagram had eigen pointer/click-handlers die het pannen blokkeerden en de plaatjeskiezer (met uploadtab) openden bij elke veeg die erop begon - op een telefoon was dat vrijwel elke aanraking, dus ook direct na het verversen
- Lege plaatjesvakken lopen nu via dezelfde tik/sleep-logica als de cellen (6px-drempel): vegen pant het diagram, alleen een stilstaande tik opent de kiezer; woord bewerken blijft ongewijzigd
- Tekenbalk op telefoons weer gecentreerd op inhoudsbreedte i.p.v. vastgepind aan beide randen: geminimaliseerd is hij weer smal (121px i.p.v. volle breedte), nog steeds in de eigen laag boven +/borden
- Hardening: een onbekende achtergrond-waarde in opgeslagen borddata crashte de hydrate en logde de gebruiker stilletjes uit bij verversen - valt nu terug op de standaardachtergrond
- Geverifieerd in iPhone-emulatie (8 checks): geen venster na verversen, veeg pant, tik kiest, tekenbalk smal en vrij van +

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 08:09:33 +02:00
Ramon
99e807595a feat: alle widgets mobielvriendelijk maken (v0.3.64-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 51s
2026-07-16 08:02:20 +02:00
Ramon
7bd35685fe feat: sluitknop op geminimaliseerde widgets in de dock (v0.3.63-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 45s
- Elke dock-pil (geminimaliseerde widget) heeft nu een eigen ✕ om de widget direct te sluiten, zonder hem eerst terug op het bord te zetten
- Klik op de pil zelf blijft de widget herstellen; de ✕ deelt hetzelfde sluitpad als de ✕ in de widgetkop
- Groter tikvlak voor de ✕ op aanraakschermen; titels wisselen mee met de taal
- Geverifieerd in headless Chromium (8 checks, incl. serialisatie na sluiten/herstellen)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 07:50:41 +02:00
Ramon
87b22efc56 fix: tekstzoom terug op telefoon en tekenbalk botst niet meer met +/borden (v0.3.62-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 28s
- A−/A+ (tekstzoom, toegankelijkheid) staan weer bovenaan in de telefoon-balk; alleen het percentage, de catalogusknop en volledig scherm blijven daar verborgen (opslaan-knop zonder minimumbreedte zodat alles past, ook op 320px)
- Echte oorzaak van het steeds opende afbeeldingen-/bordenscherm gevonden: op telefoons lagen de +-knop en de paarse bordenknop bovenop de tekenbalk - wissen/sluiten aantikken opende dus het +-menu of het bordenpaneel
- De tekenbalk ligt op telefoons nu in een eigen laag bóven de knoppenrij (groeit omhoog, dus veilig bij elke schermbreedte); + en borden houden de onderrand
- Geverifieerd op 390px én 320px (8 checks): geen overlap, sluit-/wisknop raakt echt de tekenbalk, balk past volledig, A−/A+ zichtbaar

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 07:44:38 +02:00
Ramon
8b76cf3b4b fix: menubalk past op telefoon - afbeeldingenscherm opent niet meer per ongeluk (v0.3.61-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 26s
- Op telefoons (≤600px) liep de menubalk 143px over: 💾 Opslaan en ⚙ Instellingen vielen buiten beeld en 🖼 (afbeeldingscatalogus) zat precies onder de duim, waardoor het schermvullende afbeeldingen/upload-scherm steeds per ongeluk opende
- Telefoon-balk toont nu alleen de essentie (🔊, 📁, 💾, ⚙); zoom en de catalogusknop verdwijnen daar - afbeeldingen blijven bereikbaar via het +-menu
- Afbeeldingscatalogus: titelbalk met sluitknop blijft vastgeplakt in beeld tijdens scrollen, en op mobiel 100dvh i.p.v. 100vh zodat er niets achter de browserbalk wegvalt
- Geverifieerd in iPhone-emulatie (5 checks): balk past volledig, opslaan/instellingen bereikbaar, sluitknop blijft zichtbaar, catalogus via +-menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 07:25:58 +02:00
Ramon
bdb5ef9875 feat: instellingen volledig herbouwd - sectieregister, rolweergaven en zoeken (v0.3.60-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 34s
- Nieuwe architectuur: één sectieregister (settings.js) als enige bron van waarheid; secties melden zich aan met rol-zichtbaarheid, activate() en zoekgegevens - de schil kent het beheer niet van binnen
- Nieuwe indeling: navigatie links (gegroepeerd Persoonlijk / School / Systeem), inhoud rechts met sectiekop + toelichting; zakelijk en rustig, responsief op smalle schermen
- Alles doorzoekbaar: één zoekbalk vindt secties, instellingen (trefwoorden, o.a. wachtwoord/thema/taal) én data - gebruikers, klassen en scholen op naam; klik navigeert en filtert de gebruikerslijst direct op de treffer
- Per rol alleen de essentie: gast alleen Weergave; leerling en ouder Account + Weergave; groepsleiding + Gebruikers/Toewijzingen/Voortgang; schoolbeheerder ook Klassen; systeemmanager ook Systeem en Opslag
- Thuiskinderen (leerling zonder school) krijgen nu zoals ontworpen het volledige whiteboard met eigen opslag en reservekopieën; schoolleerlingen blijven beperkt tot de toewijzing
- Oude dubbele tabbalk en #adminTabsSlot verwijderd; beheerdata laadt één keer per bezoek en wordt tussen secties hergebruikt
- 28 Playwright-checks over zes rollen (navigatie, zoeken, filteren, thuiskind-whiteboard)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 07:07:53 +02:00
Ramon
42fc323ab1 fix: toon gekoppelde ouders in de gebruikerslijst van de school (v0.3.59-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 28s
- Ouders (school_id NULL) vielen buiten elke schoolgescopeerde gebruikerslijst; /admin/users levert nu ook de ouders die aan leerlingen van de school gekoppeld zijn (groepsleiding: alleen ouders van leerlingen uit eigen klassen)
- Ouder-rijen tonen de namen van hun gekoppelde kinderen (childNames)
- Servertest voor de gekoppelde-ouders-lijst

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 00:23:51 +02:00
Ramon
5f9ffbfdde feat: client-kant ouderportaal (v0.3.58-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 37s
- Derde tab "Ouders" in het inlogscherm: ouderaccount registreren (naam + 2× wachtwoord) met directe login
- Nieuw ouderportaal (parent.js): kinderenlijst met school/klas of "thuis", kind toevoegen (wachtwoord eenmalig als badge bij de rij), koppelen via ouder-code, aanmelden bij een school via doorzoekbare lijst met aanmeldstatus op de rij, voortgang per kind uitklapbaar, ontkoppelen met bevestiging
- Beheer: blok "Aanmeldingen (n)" bovenaan de Gebruikers-tab met per verzoek adopteren (klas + optionele andere gebruikersnaam bij naamconflict), koppelen aan een bestaande leerling, of afwijzen
- Beheer: actie "👪 Ouder-code" op elke leerlingrij (eenmalige code als badge); groep "Ouders" in de gebruikerslijst
- Foutmeldingen in het inlogscherm nu zichtbaar op alle drie de tabs
- i18n nl/en; volledige ouder- en schoolflow geverifieerd in headless Chromium (21 checks)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 00:14:05 +02:00
Ramon
9d83e13bc3 feat: server-kant ouderportaal (v0.3.57-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
- Nieuwe rol parent (school_id NULL): registreert zichzelf via POST /auth/register-parent (rate-limited, stafwachtwoordregels, directe sessie)
- Migratie 013: parent_children-koppeltabel, eenmalige users.parent_code, school_link_requests (aanmeldverzoeken, uniek per kind+school)
- Ouderroutes: kinderen bekijken (met school/klas/aanmeldstatus), thuiskind aanmaken (pupil zonder school, wachtwoord eenmalig terug), koppelen via ouder-code, aanmelden bij school, ontkoppelen
- Staf: ouder-code genereren per leerling, aanmeldingen bekijken en afhandelen (adopteren in klas met optionele nieuwe gebruikersnaam, koppelen aan bestaande leerling met opruiming van lege thuisaccounts, afwijzen)
- Voortgang: thuiskind zonder toewijzing logt met de gekoppelde ouder als leerkracht (bord "thuis"); gekoppelde ouders mogen de voortgang van hun kind opvragen
- 18 nieuwe servertests (registratie, koppelmodel, aanmeldflow, resolve-acties, voortgang)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 00:02:16 +02:00
Ramon
a9bf2270d0 fix: beperk rollen en klaskoppeling tot staf (v0.3.56-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 38s
- Functie-select in gebruikersbeheer alleen zichtbaar voor staf-rollen (teacher/admin/super)
- Klaskoppeling-UI alleen voor staf-rollen
- Server: rolwijziging alleen tussen staf-rollen onderling (leerlingen en toekomstige ouder-accounts kunnen nooit promoveren)
- Server: klaskoppeling weigert alle niet-staf-rollen expliciet
- canPersist() sluit de komende ouderrol uit van borden-opslag
- Servertest: ouder-achtige rol krijgt geen promotie en geen klaskoppeling

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-15 23:56:39 +02:00
Ramon
0d644ba41d feat: voeg afbeeldingscatalogus en opslagquota toe
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 27s
2026-07-15 23:45:17 +02:00
Ramon
3ad7c80bad feat: koppel geldniveaus aan realistische winkelprijzen
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 24s
2026-07-15 22:51:49 +02:00
Ramon
54a95b2c19 revert: herstel officiële biljetten 2026-07-15 22:36:42 +02:00
Ramon
ff91ae0f2b test: gebruik eigen oefenbiljetten zonder markering
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 38s
2026-07-15 22:32:20 +02:00
Ramon
2742196c11 feat: toon echt euromateriaal in geldwidget
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 47s
2026-07-15 22:19:15 +02:00
Ramon
2fa9879107 feat: maak geld realistischer en uitsleepbaar
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 23s
2026-07-15 21:59:38 +02:00
Ramon
c2e6622546 feat: breid geldrekenen uit met niveaus en spellen
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 21s
2026-07-15 21:45:40 +02:00
Ramon
2d176cfc30 feat: voeg 24-uursoptie aan oefenklok toe
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 30s
2026-07-15 21:31:57 +02:00
Ramon
d900c25339 feat: breid niveau drie en wijzerbediening uit
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 23s
2026-07-15 21:10:31 +02:00
Ramon
e0220cdd00 fix: toon klokniveaus en verplaats koppeling
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 32s
2026-07-15 21:03:59 +02:00
Ramon
6e920e32db feat: koppel klokwijzers en start direct
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 20s
2026-07-15 20:53:47 +02:00
Ramon
cd15c0435b feat: voeg klokniveaus en typmodus toe
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 21s
2026-07-15 20:47:22 +02:00
Ramon
6e17dc5982 fix: voeg vrije uurmarkeringen toe
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 21s
2026-07-15 20:38:30 +02:00
Ramon
54782781bf fix: voorkom overlap op klokwijzerplaat
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 50s
2026-07-15 20:35:49 +02:00
Ramon
4cc8d466b8 feat: voeg sleepbare tijdblokken toe
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 27s
2026-07-15 20:32:12 +02:00
Ramon
72ea486518 feat: verbeter klokopdrachten en bediening
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 44s
2026-07-15 20:23:21 +02:00
Ramon
d02f7de72d feat: maak oefenklok visueel bedienbaar
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 20s
2026-07-15 20:12:43 +02:00
Ramon
3efe57b43c feat: breid oefenklok uit met niveaus en spellen
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 27s
2026-07-15 20:05:50 +02:00
Ramon
7287e49e0c v0.3.38-beta: koppel ook schoolbeheerders en systeemmanagers aan klassen
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 23s
- De klas-koppeling (chips + "voeg toe aan klas…") staat nu bij elke
  staf-rol, niet alleen bij groepsleiding; de klasnamen staan ook in de
  rijkop ("Schoolbeheerder · Groep 4")
- Server: /admin/classes/:id/teachers accepteert alle staf-rollen; alleen
  leerlingen niet (die horen al bij één klas). Een systeemmanager is
  school-onafhankelijk en mag aan klassen van elke school; overige staf
  moet bij dezelfde school horen
- Nieuwe servertest: admin en super koppelen ok, leerling en staf van een
  andere school geweigerd
2026-07-15 16:35:55 +02:00
Ramon
804ffb98f6 v0.3.37-beta: één functie per gebruiker + klas-koppeling voor groepsleiding
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 35s
- Het extra-functies-systeem (chips + select door elkaar) is uit de UI:
  een gebruiker heeft één functie, alleen te wijzigen via de Functie-select
- Groepsleiding is nu eenvoudig aan klassen te koppelen: huidige klassen
  als chips met ✕, een select "voeg toe aan klas…" voor nieuwe koppelingen;
  de klassen staan ook in de rijkop ("Groepsleiding · Groep 4")
- /admin/users geeft de klas-koppelingen (class_teachers) per gebruiker mee
- Formulier-fouten (bv. ongeldige gebruikersnaam bij een nieuwe
  systeemmanager) verschijnen nu direct bij het formulier in plaats van
  onzichtbaar bovenaan - dit maakte aanmaken schijnbaar onmogelijk
- Wachtwoord/koppelcode van een nieuwe gebruiker verschijnt direct bij die
  gebruiker in de lijst (rij en groep klappen vanzelf open)
2026-07-15 16:20:21 +02:00
Ramon
df921d5968 v0.3.36-beta: scholen mét schoolbeheerders in het Systeem-tabblad
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 33s
- Systeem toont per school een rij in klassen-stijl (🏫, accentkleur,
  aantal schoolbeheerders) met daaronder de schoolbeheerders als duidelijk
  onderscheiden subrijen (🛡, ingesprongen, eigen randkleur)
- Beide zijn in dit menu aan te passen via hetzelfde ✏-gereedschap:
  de school heeft een Naam-veld (nieuwe route PATCH /admin/schools/:id,
  alleen systeemmanager) en de bevestigde verwijder-actie; beheerders
  hebben hun volledige gereedschap (functie, extra functies, code,
  wachtwoord, verwijderen)
- Systeem-tab laadt beheerders van álle scholen, ook als de contextbalk
  elders op één school staat
- Drie nieuwe servertests voor het hernoemen (super ok, admin 403,
  naamvalidatie)
2026-07-15 16:02:57 +02:00
Ramon
b2ca4f93d5 v0.3.35-beta: schollenlijst in Systeem + verborgen formulieren + gelijke maten
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 32s
- Systeem-tabblad toont nu een overzicht van alle scholen als volwaardige
  rijen met een bevestigde verwijder-actie
- Het school-toevoegen-formulier zit achter een "+ School"-knop (zelfde
  patroon als gebruikers) en houdt de naam-bevestiging; de uitlegtekst is
  weg - teksten kort en krachtig
- Alle formulieren en knoppen in het beheer op dezelfde controlhoogte;
  fix: de rij-flexregels van invoervelden drukten velden binnen een
  veldlabel (kolom-flex) verticaal plat
- Ontbrekende veldlabel-tekst "Naam" toegevoegd (nl/en)
2026-07-15 15:53:10 +02:00
Ramon
03b8e6a41b v0.3.34-beta: één interactiesysteem in het beheer + veilige acties
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 22s
- Elk invoerveld/select/knopgroep in het beheer heeft nu een zichtbaar
  mini-label (field-helper): Functie, Extra functies, Klas, Wachtwoord,
  Naam, Acties - een placeholder alleen is geen toelichting meer
- Acties met gevolgen (nieuw wachtwoord, nieuwe koppelcode, verwijderen,
  wachtwoord-resets) zijn een aparte knopklasse: rood-zacht, tooltip die
  het gevolg uitlegt, en een tweede klik als bevestiging (arm-then-confirm,
  wapening loopt na 4s af); alle native confirm()-dialogen zijn vervangen
- Nieuw gegenereerde wachtwoorden/koppelcodes verschijnen direct bij de
  gebruiker in de rij (badge met kopieerknop, rij klapt vanzelf open),
  niet meer bovenaan; de topbalk is alleen nog voor fouten
- Klassen zijn volwaardige rijen op dezelfde maat als de gebruikersvakken,
  met leerlingaantal en de reset/verwijder-acties als bevestigde knoppen
- Systeem-tabblad: schoolkiezer-balk daar verborgen (school-onafhankelijk);
  school aanmaken heeft nu een uitleg, een naam-label, een knop die pas
  actief wordt bij een echte naam en een bevestigingsstap met de naam erin
- Toevoegen van gebruikers/klassen: knop pas actief bij geldige naam
2026-07-15 15:37:05 +02:00
Ramon
75244cde14 v0.3.33-beta: overzichtelijker beheerpaneel zonder tab-afhankelijkheid
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 21s
- Vaste schoolkiezer-balk (🏫) boven alle beheer-tabs voor de systeem-
  manager: geen omweg meer via het Systeem-tabblad om een school te kiezen;
  de kiezer is uit het Systeem-tabblad verdwenen en de hint verwijst naar
  de balk erboven
- Gebruikers-tab: leerlingen staan onder inklapbare groepskoppen per klas
  (standaard dicht, met aantal en ▸/▾-pijl); zoeken kijkt altijd door alle
  groepen heen zodat een treffer nooit verstopt zit
- Gebruikersrijen tonen standaard alleen naam en rol/klas; alle knoppen en
  velden (rol wijzigen, extra functies, klas, wachtwoord, koppelcode,
  verwijderen) zitten achter een ✏-knopje per rij; uitgeklapte staat blijft
  behouden over her-renders
2026-07-15 05:03:30 +02:00
Ramon
54eb8b18cb v0.3.32-beta: één tabbalk in Instellingen (geen geneste beheer-tabs meer)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 21s
- De beheer-tabs (Systeem/Klassen/Gebruikers/Toewijzingen/Voortgang) staan
  nu als losse knoppen direct in de instellingen-tabbalk naast Account en
  Weergave, in plaats van een tweede tabbalk binnen het Beheer-tabblad
- admin.js vult #adminTabsSlot en herbouwt bij gebruikers-/taalwissel;
  data wordt geladen bij binnenkomst in het beheer-deel, wisselen tussen
  beheer-tabs onderling hergebruikt de geladen data
- De losse "Beheer"-knop en de geneste .ed-tabs-balk zijn verwijderd;
  de tabbalk loopt om op smalle schermen (flex-wrap)
2026-07-15 04:37:15 +02:00
Ramon
d5e0b361bf v0.3.31-beta: geef ankerdiagrammen een prominentere plek
- Ankerwidget staat nu vooraan in de widgetbibliotheek (eerste kaart)
- Nieuw -item in het +-menu: plaatst direct een ankerdiagram op het bord
- Duidelijkere uitleg wat een ankerdiagram is (woordweb voor
  woordenschatonderwijs: woorden in samenhang rond een themawoord leren),
  in de bibliotheekkaart, de widget-hint en het +-menu-label (nl/en)
2026-07-15 04:32:25 +02:00
Ramon
6d5d4ef520 v0.3.30-beta: ankerdiagrammen als vaste tak in de hoofdverkenner
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 37s
- Nieuwe generieke withBranch-combinator (explorer.js): een tweede adapter
  verschijnt als vaste map in de root van de hoofdboom; item-sleutels worden
  geprefixt en slepen tussen de takken wordt geweigerd (ander soort inhoud)
- De borden-verkenner (📁 in de menubalk) toont nu " Ankerdiagrammen" met
  daarin de eigen diagrammappen én de gedeelde 🌐/🏫-takken voor ankers -
  borden en ankerwoorden op één plek, met dezelfde regels (slepen, kopiëren,
  alleen-lezen, één-klik-mappen)
- Diagram openen vanuit de hoofdverkenner plaatst een nieuw ankerwidget op
  het huidige bord met dat diagram geladen; het 🗂-knopje op een widget
  blijft in dát widget laden (adapter naar module-niveau getild met een
  onOpen-parameter, gedeeld door beide ingangen)
2026-07-15 04:12:43 +02:00
Ramon
a919e31e4c v0.3.29-beta: alleen-lezen in de verkenner + mappen openen met één klik
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 21s
- Gedeelde items die je niet mag beheren tonen een 🔒 en weigeren hernoemen/
  verplaatsen/verwijderen direct in de client met een duidelijke melding
  (kopiëren blijft altijd werken); mappen met vergrendelde inhoud tonen 🔒
- Nieuwe knop "🔒 Alleen lezen" in de school-tak, alleen zichtbaar voor
  schoolbeheerder/systeemmanager: vergrendelt het geselecteerde item of
  (zonder selectie) de hele huidige map, en ontgrendelt bij tweede keer
- Mappen openen nu met één klik (Windows-webstijl); slepen blijft werken
  via de bewegingsdrempel en de klik direct ná een sleep wordt genegeerd;
  hernoemen/verwijderen zonder selectie werkt op de huidige map (via het
  broodkruimelpad)
2026-07-14 22:53:47 +02:00
Ramon
24180beec1 v0.3.28-beta: alleen-lezen-vlag voor gedeelde items (server)
- Nieuwe migratie db/011: shared_library.readonly (default false)
- Beheerregel aangescherpt: een vergrendeld school-item is ook voor de
  eigenaar alleen-lezen; alleen schoolbeheerder en systeemmanager kunnen
  het nog wijzigen (systeem-items waren al alleen-lezen voor iedereen
  behalve de systeemmanager) - kopiëren blijft altijd werken
- De vlag zetten/weghalen kan per item (PATCH readonly) of per map
  (PATCH /shared/:kind/folder met {from, readonly}), uitsluitend door de
  schoolbeheerder van die school of de systeemmanager
- Lijst/detail geven readonly + canLock terug voor de UI; drie nieuwe tests
2026-07-14 22:49:17 +02:00
Ramon
e21bc177e1 v0.3.27-beta: gedeelde bibliotheek in de mappenverkenner
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 21s
- withSharedBranches (explorer.js): wikkelt een eigen-inhoud-adapter en
  voegt in de root de vaste takken "🌐 Systeem" (systeembreed) en
  "🏫 School" (binnen de eigen school) toe
- Slepen tussen bomen = kopiëren: eigen bord/diagram of hele map naar een
  gedeelde tak publiceert (POST /shared, kopie - origineel blijft); gedeeld
  item of map naar de eigen boom kopieert (borden krijgen nieuw id + verse
  wids); binnen een tak is slepen echt verplaatsen (PATCH, server bewaakt)
- Dubbelklik op een gedeeld bord kopieert en opent het; op een gedeeld
  ankerdiagram laadt het in het widget
- Verkenner ondersteunt nu async adapters (init/mutaties), groene
  ok-meldingen ("Gedeeld ✓"/"Gekopieerd ✓") en een eigenaar-hint per tegel
- Gasten en leerlingen zien alleen de eigen boom
2026-07-14 22:34:54 +02:00
Ramon
939c4ee68b v0.3.26-beta: server-API voor de gedeelde bibliotheek
- Nieuwe migratie db/010: shared_library-tabel (school_id NULL =
  systeembreed, anders per school; kind board/anchor; folder als pad)
- Routes: GET /shared/:kind (lijst, gefilterd op systeembreed + eigen
  school), GET /shared/:kind/:id (met data, voor kopiëren), POST (publiceren;
  scope global vereist systeemmanager, scope school staf met school),
  PATCH/DELETE per item en PATCH /shared/:kind/folder (prefix-rewrite,
  alleen als álle items onder het pad beheerd mogen worden)
- Beheerregel: systeemmanager altijd, eigenaar altijd, schoolbeheerder voor
  items van de eigen school; leerlingen hebben geen toegang
- Nieuwe permissies shared.global.manage / shared.school.manage (beide
  permissions.js); zes nieuwe servertests
2026-07-14 22:27:16 +02:00
Ramon
6de569c892 v0.3.25-beta: hernoem rol naar systeemmanager
- roleSuper heet nu "Systeemmanager" (en: "System manager"); het
  beheertabblad en de stafgroep "Overkoepelend" heten nu "Systeem"
- Alle overige teksten en commentaren met "overkoepelend beheerder"
  meegenomen; de interne rolcode "super" blijft ongewijzigd
2026-07-14 22:24:34 +02:00
Ramon
86478ebaca v0.3.24-beta: bewaar ankerdiagrammen los van het bord (bibliotheek)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 18s
- Nieuwe ankerbibliotheek ANCHOR_LIB, opgeslagen in een eigen data-sectie
  "anchors" (server-whitelist + 5MB-cap; lijst i.p.v. object toegestaan);
  reist mee in de volledige save, hydrate, gastreset en reservekopie-herstel
- Ankerwidget: 💾 bewaart het huidige diagram (naam = middelwoord, met
  ✓-feedback; leeg diagram wordt geweigerd met een hint), 🗂 opent dezelfde
  Windows-stijl mappenverkenner met de bewaarde diagrammen (geneste mappen,
  slepen, hernoemen, verwijderen)
- Dubbelklik op een bewaard diagram laadt het in het widget (cellen +
  plaatjes, thema-spiegel en bord-autosave volgen automatisch)
- Nieuwe servertest: anchors-sectie accepteert een lijst, andere secties
  weigeren die (en andersom)
2026-07-14 22:14:37 +02:00
Ramon
e327049f1b v0.3.23-beta: beheer woordthema's in dezelfde mappenverkenner
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 33s
- 🗂-knop in de woorden-editor opent de Windows-stijl verkenner met de
  thema's van de actieve taal (★-tegels met woordaantal); dubbelklik
  selecteert het thema in de editor
- Geneste mappen via het folder-veld als pad; nieuwe lege mappen bestaan
  sessie-lokaal tot er een thema in wordt gezet (mappen zijn afgeleid van
  de themavelden)
- Ankerthema's staan vast in de map "": niet versleepbaar, hernoemen/
  verwijderen geweigerd (anchor.js beheert ze)
- Het 📁-knopje + inline map-invoer uit v0.3.21 zijn vervangen door de
  verkenner; de optgroup-weergave in de dropdowns blijft en toont het pad
2026-07-14 22:01:59 +02:00
Ramon
1151c71049 v0.3.22-beta: Windows-stijl mappenverkenner voor borden
- Nieuw public/js/explorer.js: generieke verkenner-modal met broodkruimelpad,
  werkbalk (nieuwe map/nieuw item/hernoem/verwijder met bevestigingsstap),
  raster van map- en itemtegels, dubbelklik om te openen en slepen om te
  verplaatsen (pointer-events, werkt op touch; drop-doel licht op)
- Geneste mappen zonder datamigratie: nesting zit in de mapnaam als pad met
  "/"-scheiding; server en toewijzingen (bord-id's) blijven onaangeroerd;
  "/" wordt geweigerd in nieuwe namen
- Borden-adapter in board.js: bordtegels met boardThumb-preview, actief bord
  gemarkeerd en gevolgd bij verplaatsen/hernoemen (prefix-rewrite), map
  verwijderen alleen als leeg, laatste bord onverwijderbaar
- 📁-knop in de menubalk opent de verkenner; het oude mappenpaneel
  (renderBoardsPanel + #boardsPanel + CSS) is vervangen; de boardStrip-
  snelwissel blijft; curSlot() heelt lege actieve mappen zelf
2026-07-14 21:58:24 +02:00
Ramon
f2243c13c4 v0.3.21-beta: groepeer woordthema's in mappen
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 18s
- Thema's krijgen een optioneel folder-veld; de theme-dropdown in de
  woorden-editor en de kaart-dropdowns in de widgets (fillWordSourceSel)
  groeperen per map met optgroups, mapl0ze thema's bovenaan
- Nieuw 📁-knopje in de editor zet het gekozen thema in een map (leeg veld
  haalt het eruit); zichtbaarheid volgt de themakeuze
- Ankerthema's komen automatisch in de vaste map "" (id-koppeling met de
  ankerwidget blijft onaangeroerd)
- Nieuwe i18n-teksten themeFolder/themeFolderPh (nl/en)
2026-07-14 16:35:22 +02:00
Ramon
18775ae0ec v0.3.20-beta: hernoem mappen en verplaats borden tussen mappen
- Bordenpaneel: dubbelklik op een mapnaam hernoemt de map (zelfde
  inline-input-patroon als bord hernoemen)
- Nieuw 📁-knopje per bord toont een inline select met de overige mappen;
  bij keuze verhuist het bord, het actieve bord volgt mee (BS.f/BS.b
  worden gecorrigeerd) en een leeggeraakte map krijgt een vers bord
- renameTip vermeldt nu ook mappen; nieuwe i18n-tekst moveBoardTip (nl/en)
2026-07-14 16:29:25 +02:00
Ramon
2ce82ab998 v0.3.19-beta: verstuur bij het opslaan alleen gewijzigde secties
- Nieuwe route PUT /me/data/:section (boards/themes/generalExtra): vervangt
  alleen die sub-key via jsonb_set, met dezelfde rev/409-controle en
  snapshot-hook als de volledige PUT (die blijft bestaan als terugval)
- Per-sectie size-caps (boards 20MB, themes/generalExtra 2MB)
- core.js: DIRTY-set houdt bij wat er gewijzigd is; woordwijzigingen sturen
  alleen themes+generalExtra, bordwijzigingen alleen boards (sequentieel,
  rev per respons bijgewerkt); zonder gerichte wijziging volledige save
- Twee nieuwe servertests (jsonb_set op de juiste sub-key, onbekende sectie
  is 404)
2026-07-14 16:26:16 +02:00
Ramon
6980d72041 v0.3.18-beta: bewaar en herstel eerdere versies van borddata
- Elke data-write bewaart eerst de oude data in user_data_versions (hooguit
  één snapshot per 10 minuten, maximaal 10 versies per gebruiker)
- Nieuwe routes: GET /me/data/versions (lijst) en POST /me/data/restore
  (zet een versie terug; bewaart de huidige data eerst zodat herstel zelf
  ook omkeerbaar is); beide niet voor leerlingen
- Nieuw blok "Reservekopieën" in Instellingen → Account met datum/tijd per
  versie en een herstelknop met bevestigingsstap (arm-then-confirm)
- Vier nieuwe servertests (snapshot vóór write, opruimen op 10, pupil-403,
  restore bewaart huidige data eerst)
2026-07-14 16:23:10 +02:00
Ramon
b28d25296d v0.3.17-beta: detecteer save-conflicten tussen tabbladen
- Nieuwe migratie db/009: users.data_rev + data_updated_at en de
  user_data_versions-tabel (voor de komende versiegeschiedenis)
- GET /me/data geeft nu {data, rev}; PUT /me/data controleert een optionele
  x-data-rev-header en weigert een verouderde schrijver met 409 (zonder
  header blijft het oude laatste-schrijver-wint-gedrag voor oude clients)
- core.js: rev wordt bijgehouden en meegestuurd; bij 409 toont de opslaan-
  knop "Elders bewerkt" en haalt een klik eerst de nieuwste serverversie op
  in plaats van te overschrijven
- beforeunload-waarschuwing bij een openstaande, lopende of mislukte save
- Nieuwe test test/save-system.test.js (rev mee, 409, legacy zonder header)
2026-07-14 16:19:25 +02:00
Ramon
d0ab0b2483 v0.3.16-beta: deel één widget via de Toewijzingen-tab
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 34s
- assignmentRow: na bordkeuze verschijnt een widget-select ("heel bord" of
  één widget van dat bord, client-side uit BS-borddata); bij een widgetkeuze
  een modus-select (kijken/werken); PUT-body krijgt widgetId + mode mee
- Widgets zonder wid (borden van vóór v0.3.13) worden overgeslagen tot de
  eerstvolgende save van dat bord
- Infotekst toont nu ook widget en modus van de huidige toewijzing
- Nieuwe i18n-teksten amWholeBoard/amModeWatch/amModeWork (nl/en)
2026-07-14 15:53:31 +02:00
Ramon
873f8e0bee v0.3.15-beta: kijken/werken-modus in de leerling-omgeving
- pupil.js: verwerkt de modus uit /my/assignment; "kijken" toont een
  meekijk-badge, blokkeert alle interactie en logt geen voortgang; "werken"
  (default) is het bestaande gedrag met voortgang-logging
- Poll-interval dynamisch: 5s in kijken-modus (wijzigingen van de leerkracht
  komen vlot door), 20s in werken-modus; moduswissel her-rendert direct
- teach.css: generieke kijken-overlay (pointer-events:none op de widget-body),
  werkt voor elk widgettype zonder eigen readonly-ondersteuning
- Nieuwe i18n-tekst pupilWatching (nl/en)
2026-07-14 15:51:20 +02:00
Ramon
b13fe65da5 v0.3.14-beta: sta toewijzing van één widget toe met kijken/werken-modus (API)
- Nieuwe migratie db/008_widget_share.sql: assignments krijgt widget_id
  (NULL = heel bord, bestaand gedrag) en mode ('kijken'/'werken',
  default 'werken')
- PUT /assignments/class/:id en /assignments/pupil/:id accepteren optioneel
  widgetId + mode; gedeelde validatie (parseShareBody) controleert dat de
  widget echt op het opgegeven eigen bord staat
- describeAssignment geeft widgetId/widgetType/mode terug voor de UI
- GET /my/assignment filtert bij een widget-toewijzing tot die ene widget en
  stuurt de modus mee
2026-07-14 15:48:14 +02:00
Ramon
d27419b9f6 v0.3.13-beta: geef widgets een stabiel instantie-id (wid)
- board.js: genWidgetId() naar het voorbeeld van genBoardId(); toegekend in
  makeWidget (bestaand wid uit opgeslagen data wint), bewaard in
  serializeBoard; dupliceren geeft een nieuw wid; oude borden zonder wid
  krijgen er vanzelf een bij de eerstvolgende save
- /my/assignment stuurt wid mee per widget
- pupil.js: voortgang-widgetId gebruikt nu het wid (terugval op positie voor
  borden van vóór deze versie)

Voorbereiding op het delen van één losse widget (roadmap item 8).
2026-07-14 15:46:15 +02:00
Ramon
be89a7b498 v0.3.12-beta: herstel sessie na refresh (regressie beveiligingsaudit)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 27s
De beveiligingsaudit (v0.3.03-beta) verving de client-side Bearer-token
(localStorage) door een HttpOnly session-cookie, maar de sessie-herstel-IIFE
in app.js las nog steeds de verwijderde TOKEN-variabele uit - een
ReferenceError die de hele boot-restore liet crashen. Resultaat: elke
paginaverversing verloor de ingelogde staat, ook al bleef de cookie zelf
geldig. Fix: geen client-side tokencheck meer nodig (de HttpOnly-cookie is
sowieso niet leesbaar door JS en gaat vanzelf mee); /auth/me wordt nu altijd
geprobeerd en valt bij een 401 terug op gastmodus.
2026-07-14 15:31:17 +02:00
Ramon
aac7fd1c68 v0.3.11-beta: toon leerlingvoortgang in nieuw admin-tabblad
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 17s
- Nieuwe "Voortgang"-tab naast Toewijzingen: leerkracht kiest een leerling
  (zelfde per-klas gegroepeerde select) en ziet de volledige tijdlijn van
  GET /progress/pupil/:id (widgettype, score, tijdstip)
- fillPupilSelect(): gedeelde helper voor de per-klas leerlingselectie,
  onttrokken uit Toewijzingen zodat Voortgang hem ook gebruikt
- Nieuwe i18n-teksten amProgress/amProgressEmpty (nl/en)
2026-07-14 15:05:16 +02:00
Ramon
04e9bf3847 v0.3.10-beta: rapporteer widgetvoortgang vanuit leerling-speelmodus
- board.js: fix bug waarbij de opts-parameter (readonly e.d.) nooit bij
  mountMath aankwam, doordat de registry-wrapper hem liet vallen
- math.js/letters.js/ball.js: nieuwe opts.onProgress-callback, aangeroepen
  bij resp. einde rekenronde, voltooid woord en geknapte ballon
- pupil.js: geeft onProgress mee bij het mounten en post het naar
  /my/progress; alleen actief in leerling-speelmodus, niet bij bewerken
2026-07-14 15:00:31 +02:00
Ramon
e3369e34a2 v0.3.09-beta: voeg voortgang-schema en -API toe
- Nieuwe migratie db/007_progress.sql: progress_events-tabel (per leerling
  per widget-moment: pogingen, goed, sterren)
- POST /my/progress: leerling logt een afgerond moment; teacher_id/board_id
  worden server-side afgeleid uit de bestaande toewijzing, nooit van de client
- GET /progress/pupil/:id en GET /progress/class/🆔 leerkracht-weergave,
  gated met nieuwe progress.view-permissie en dezelfde scoping als assignments
- Permissie progress.view toegevoegd in beide permissions.js-bestanden
2026-07-14 14:51:50 +02:00
Ramon
a20779e602 v0.3.08-beta: breid widgetbibliotheek uit
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 37s
- voeg acht educatieve en klaswidgets toe\n- verbeter galerij met zoeken, favorieten en lessjablonen\n- voeg dupliceren, vergrendelen en voorbeeldweergave toe\n- voeg geschiedenis en begrensde bordexport/import toe\n- verbeter responsiviteit, toegankelijkheid en vertalingen
2026-07-14 14:16:49 +02:00
Ramon
6e41ea1b2e v0.3.07-beta: voeg educatieve widgets toe
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 29s
2026-07-14 13:39:57 +02:00
Ramon
6097d7c9f1 v0.3.06-beta: herstel bereikbaarheid via Pangolin
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 17s
2026-07-14 13:26:15 +02:00
Ramon
50fafc789a v0.3.05-beta: rol nginx-config atomisch uit
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 17s
2026-07-14 13:23:15 +02:00
Ramon
e727d6a07c v0.3.04-beta: draai securitytests in Docker
Some checks failed
dev - build & deploy naar test / build-and-deploy (push) Failing after 27s
2026-07-14 13:09:09 +02:00
Ramon
f9b2be8187 v0.3.03-beta: versterk publieke beveiliging
Some checks failed
dev - build & deploy naar test / build-and-deploy (push) Failing after 4s
2026-07-14 12:42:16 +02:00
Ramon
d864299ea6 v0.3.02-beta: maak tekenwerkbalk compact
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 9s
2026-07-14 10:57:56 +02:00
Ramon
09a5d84bba v0.3.01-beta: verbeter automatisch opslaan van borden 2026-07-14 10:40:58 +02:00
Ramon
4b54ea4a12 v0.3.00-beta: nieuw ontwerp \"teachus.today\" - minimalistisch, nieuw logo, licht+donker
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 8s
- Nieuw logo: low-poly uil als SVG (transparant, vlakke merk-kleuren) + woordmerk
  \"teachus.today\" in de menubalk; op kleine schermen alleen de uil. Ook favicon
  en inlogscherm gebruiken het nieuwe logo.
- Nieuwe huisstijl afgeleid van het logo: teal als primaire actiekleur, navy inkt,
  Quicksand-font (zelf gehost, ~28 KB), dunne lijnen, vlakke minimale chrome-laag;
  het glas-effect blijft alleen op de bord-laag (widgets/HUD).
- Token-systeem herschreven: semantische kleuren (--surface/--line/--accent/...),
  radius-/schaduwschaal en één control-hoogte (--control-h) zodat alle vakken
  (inputs, selects, knoppen) in elke rij even hoog zijn; op aanraakschermen
  groeit alles mee naar 44px. Galerij-tegels hebben nu gelijke hoogtes.
- Modern tabsysteem: underline-tabs die overlopen in hun paneel (geen gele
  file-folder-tabs en omrande panelen meer); kleine keuzegroepen (taal, niveaus,
  timer, chips) delen één segmented-control-stijl.
- Donker thema: schakelbaar (licht/donker/auto volgt het OS), alle panelen,
  bordpatronen en widgets doen mee; hoog contrast wint altijd van donker.
- Nieuw tabblad Instellingen -> Weergave: thema, hoog contrast (verhuisd uit de
  menubalk), taal NL/EN (verhuisd uit de menubalk), menubalk-positie
  (boven/links/rechts), animaties aan/uit en glas-effect aan/uit - allemaal
  apparaat-gebonden en ook als gast bereikbaar via het inlogscherm.
- Menubalk: rustiger (transparante knoppen, hairline i.p.v. schaduw) en
  verplaatsbaar naar links of rechts als verticale kolom; bord, leerling-
  weergave en zwevende knoppen schuiven mee.
- Taalkeuze wordt nu onthouden (localStorage).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DuxHJtk5aHe2pA3xwCLEAk
2026-07-14 09:59:13 +02:00
Ramon
0411375a3c v0.2.22-beta: leerling-omgeving (aparte pagina zonder whiteboard)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 9s
- Nieuwe tabel `assignments`: een leerkracht koppelt één van haar eigen
  borden aan een klas (standaard) of een individuele leerling (uitzondering,
  voor wie op een ander niveau werkt).
- Geen momentopname: /my/assignment zoekt het bord live op bij de
  leerkracht, dus nieuwe woorden die zij toevoegt komen vanzelf door zodra
  de leerling-pagina opnieuw pollt (elke 20s).
- Leerlingen loggen in op een vereenvoudigde pagina zonder whiteboard/
  tekengereedschap: enkel de toegewezen taal-/rekenwidgets, als vaste
  (niet-sleepbare) kaarten in speelmodus.
- Speelmodus verbergt bewerkfuncties in Ankerwoorden (plaatje kiezen,
  woord typen, kleur wijzigen) en Letterblokken (thema/woordbeheer) -
  overige widgets waren al zuiver speelgericht.
- Elk bord krijgt een stabiel id (board.js) zodat een toewijzing een bord
  blijft vinden ook na hernoemen of herordenen.
- Nieuw tabblad "Toewijzingen" in het beheerpaneel: per klas of leerling
  een eigen bord kiezen, met een expliciete toewijs-stap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DuxHJtk5aHe2pA3xwCLEAk
2026-07-13 23:14:26 +02:00
Ramon
2528e5b8f6 v0.2.21-beta: meerdere functies per gebruiker (multi-rol voor staf) + zoekveld
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 7s
- Nieuwe user_roles-koppeltabel (db/004_user_roles.sql): staf kan voortaan naast de hoofdrol extra functies krijgen (bv. groepsleiding + schoolbeheerder tegelijk); leerling blijft altijd exclusief
- can()/PERMISSIONS accepteren nu een array van rollen i.p.v. alleen een losse string (backward compatible), CREATABLE_ROLES-opzoeking wordt een unie over alle rollen van een gebruiker
- Rechten-checks in src/api.js gebruiken voortaan req.user.allRoles (hoofd- + extra rollen); identiteits-/scoping-logica (leerling-filters, aanmaken, groepsleiding-klas-scope) blijft bewust op de hoofdrol staan
- PATCH /admin/users/:id accepteert een nieuw veld extraRoles (super-only, alleen voor staf)
- Beheerpaneel: toggle-knoppen per stafgebruiker om extra rollen aan/uit te zetten; Instellingen toont voortaan alle rollen samengevoegd ("Schoolbeheerder + Groepsleiding")
- Zoekveld boven de gebruikerslijst (live filter op naam, client-side, behoudt focus tijdens typen) omdat die lijst in een grote school lang kan worden

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:41:40 +02:00
Ramon
e24e810313 v0.2.20-beta: Scholen-tab wordt Overkoepelend, bugfix leerlingen-lijst, VERSION bijgewerkt
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 9s
- "Scholen"-tabblad hernoemd naar "Overkoepelend" en het aanmaken van een overkoepelend beheerder verplaatst hierheen vanuit Gebruikers - die rol hoort niet bij één school, dus dat schoolgebonden tabblad was de verkeerde plek
- Klassen-tabblad toonde een lege, onverklaarde pagina als een super nog geen school had gekozen; laat nu een duidelijke hint zien
- Bugfix: leerlingen met een klas-koppeling verdwenen onterecht helemaal uit de Gebruikers-lijst wanneer een super naar "alle scholen" keek (CLASSES was dan leeg, dus geen enkele klas-groep kon ze tonen) - valt nu terug op een platte lijst zoals bij groepsleiding
- VERSION-bestand dit keer wél meteen bijgewerkt

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:24:16 +02:00
Ramon
2ceeec2de2 v0.2.19-beta: leerlingen per klas in het beheerpaneel + groepsleiding-scope + extra stap bij toevoegen
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 8s
- GET /admin/users: groepsleiding (teacher) ziet voortaan alleen leerlingen uit de eigen klas(sen) (via class_teachers), i.p.v. de hele school - staf blijft zichtbaar zoals voorheen
- Gebruikers-tab: leerlingen worden voor schoolbeheerder/super gegroepeerd per klas getoond (met een "geen klas"-groep), i.p.v. één platte lijst los van de Klassen-tab; groepsleiding houdt een platte lijst (nu vanzelf al beperkt tot eigen klas door de backend-scope)
- Alle "gebruiker toevoegen"-formulieren (staf + leerling, incl. de nieuwe per-klas toevoegrij) starten ingeklapt als alleen een "+ …"-knop; pas na een klik verschijnen de invoervelden
- Verwijderen van een gebruiker had al een bevestigingsvraag - ongewijzigd, bevestigd dat dit blijft werken
- Koppelcode-functie bewust ongewijzigd gelaten (privacy-optie naast direct-wachtwoord-aanmaken)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:04:20 +02:00
Ramon
f5140d42c9 v0.2.18-beta: VERSION-bestand was sinds v0.2.10-beta niet meer bijgewerkt
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 8s
- Het versietagje toonde daardoor al zeven commits lang nog steeds "v0.2.10-beta", ook al draaide de echte, nieuwere code allang (Instellingen-herontwerp, beheerpaneel-tabs, ankerwoorden-plaatjes, enz.)
- VERSION teruggezet naar de daadwerkelijke huidige versie
- Robuustere weergave: op dev-builds staat de git-hash er nu altijd bij in de zichtbare tekst zelf (niet meer alleen als hover-tooltip), zodat een nieuwe deploy altijd zichtbaar anders is, ook als het VERSION-bestand een keer vergeten wordt

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 21:26:42 +02:00
Ramon
1b6960b7ba v0.2.17-beta: beheerpaneel opgesplitst in tabs (Scholen/Klassen/Gebruikers)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 10s
- render() bouwde tot nu toe alles in één lange, doorlopende lijst; nu drie tabbladen die hetzelfde ed-tabs-patroon hergebruiken als de login- en Instellingen-tabs
- "Scholen" alleen zichtbaar voor super, "Gebruikers" is het standaard-tabblad
- Actief tabblad blijft behouden na acties (nieuwe klas/gebruiker toevoegen springt niet meer terug naar het eerste tabblad)
- Zuiver een frontend-herstructurering: geen gedragswijziging aan de onderliggende functionaliteit (reload/msg/classSel/userRow/addForm/resetPanel ongewijzigd)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 21:03:13 +02:00
Ramon
270739da09 v0.2.16-beta: ankerwoorden - lengtegrens-bug, geen nep-ankersymbool, koppeling met woordkaart
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 12s
- Lengte-check voor ankerwoorden was strenger dan het invoerveld zelf toestond ({2,12} vs maxLength 24): woorden als "kledingwinkel" werden nooit opgezocht terwijl ARASAAC er prima plaatjes voor heeft
- Geen ankersymbool meer als terugval wanneer ARASAAC geen match heeft (bv. "contant"); de cel blijft leeg en is klikbaar als snelkoppeling naar een nieuwe, herbruikbare plaatjeskiezer (nieuw: public/js/picker.js, hergebruikt dezelfde emoji-lijst/ARASAAC-zoekfunctie/CSS als de bestaande woordkaart-editor)
- Elk ankerraster synchroniseert zijn woorden-met-plaatje voortaan naar een eigen thema in THEMES, zodat ankerwoorden ook beheerbaar zijn via de bestaande woordkaart-editor (Letterblokken-widget) - geen wijziging aan letters.js nodig, het bestaande thema-systeem pakt dit vanzelf op
- Oudere borden met het letterlijke ankersymbool als opgeslagen "plaatje" migreren automatisch naar de nieuwe lege staat

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 19:28:05 +02:00
Ramon
6cb0965a9c v0.2.15-beta: ankerwoorden tonen nu ook zelf hun plaatje
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 8s
- De automatische ARASAAC-plaatjes (sinds v0.2.06-beta) werden alleen doorgegeven aan andere widgets (Hak/Flits/Bal/Woordkaart) als woordbron, maar nooit getoond in het Ankerwoorden-raster zelf - vandaar dat het leek te ontbreken, voor zowel bestaande als nieuwe woorden
- render() tekent nu een klein plaatje naast elk woord in de cel, met een render()-aanroep zodra een opgehaald plaatje binnenkomt
- Getest tegen de echte ARASAAC-API: bestaande woorden (uit een opgeslagen bord) tonen hun plaatje meteen bij het laden, nieuwe woorden tonen eerst het ankersymbool en daarna het echte plaatje zodra dat binnenkomt

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 17:32:42 +02:00
Ramon
baf2a4cb13 v0.2.14-beta: beheerpaneel volledig geïntegreerd in Instellingen (één overzicht)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 18s
- Het beheerpaneel opende nog als los venster (adminWrap) boven op Instellingen; nu is het een tabblad ("Account"/"Beheer") binnen hetzelfde Instellingen-scherm - één modal, geen twee
- adminWrap/adminModal-als-los-venster verwijderd; admin.js rendert nu in een contentblok binnen settingsModal, zonder eigen dubbele titel/sluitknop (die van Instellingen zelf dekt dat al)
- Instellingen is breder geworden om ruimte te bieden aan de beheerinhoud (klassen, gebruikers, aanmaakformulieren); het accountgedeelte blijft smal en overzichtelijk via een eigen max-breedte

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 16:23:07 +02:00
Ramon
43c7892eb7 v0.2.13-beta: één instellingenmenu - beheer, versie en copyright samengevoegd
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 9s
- Werkbalk teruggebracht tot één ⚙️-knop (was 👤 + los 👥-beheerknop + los versienummer bij het logo)
- Beheerpaneel-knop verplaatst in Instellingen (nieuwe "Beheer"-sectie, alleen zichtbaar met rechten); opent nog steeds hetzelfde beheerpaneel, sluit nu ook Instellingen zodat er geen twee schermen over elkaar staan
- Versienummer verplaatst van de werkbalk naar de voettekst van Instellingen
- Copyright-regel toegevoegd in de voettekst ("© <jaar> Ramon Besselink")
- Taal/contrast/geluid bewust NIET verplaatst: die blijven op de werkbalk omdat ze ook zonder inloggen (gastgebruik) bruikbaar moeten blijven, terwijl Instellingen alleen open gaat na inloggen

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 16:13:01 +02:00
Ramon
14115b79ad v0.2.12-beta: Instellingen herzien tot een overzichtelijk, volwassen menu
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 9s
- Losse ⚙️-knop uit de vorige commit weer verwijderd; Instellingen blijft bereikbaar via de bestaande 👤-knop, geen extra knoppen in de werkbalk
- Instellingen ingedeeld in duidelijke secties (Account, Wachtwoord, Sessie) met kaarten en sectielabels, hergebruikt van het beheerpaneel (.am-h3/.am-reset/.am-name/.am-role) i.p.v. losse velden onder elkaar
- Casual "Hoi ... 👋"-begroeting vervangen door een rustige naam/rol-weergave, consistent met hoe andere gebruikers in het beheerpaneel getoond worden
- Expliciete ✕-sluitknop toegevoegd, zelfde patroon als het beheerpaneel

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 15:55:20 +02:00
Ramon
8a3bf1e8fc v0.2.11-beta: aparte ⚙️-knop voor Instellingen in de werkbalk
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 11s
- Instellingen opende voorheen alleen via de bestaande 👤-knop, zonder enig visueel signaal in de werkbalk dat daar iets veranderd was
- Nieuwe ⚙️-knop (zichtbaar zodra je bent ingelogd) opent Instellingen rechtstreeks; de 👤-knop blijft dit ook doen zodra je bent ingelogd

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 15:46:06 +02:00
Ramon
7b6621ff9c v0.2.10-beta: echte semantische versienummering (v0.0.00-beta) i.p.v. git-sha in de UI
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 8s
- Nieuw bestand VERSION (repo-root) is nu de enige bron voor het versienummer; CI leest dit uit en geeft het door als APP_VERSION i.p.v. het opake dev-<sha> van de vorige build
- dev-deploys tonen voortaan "v0.2.10-beta" i.p.v. "dev-<sha> · beta"; productie blijft het releaselabel tonen
- Git-sha blijft beschikbaar via /api/version (nieuw veld "sha") en als hover-title op het versietagje, voor support/debug
- Dockerfile/CI-workflows (dev + release) geven de git-sha nu als apart APP_ARG mee

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 15:30:37 +02:00
Ramon
ca825ce6f8 v0.2.09-beta: versienummer in de UI klopte niet meer met de echte build
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 13s
- #verTag toonde altijd een hardgecodeerde, verouderde versie (0.2.01); nu wordt de echte versie uit /api/version getoond zodra die bekend is (dev-<sha> · beta op dev, of het releaselabel op productie)
- versienummer weer zichtbaar op mobiel (was volledig verborgen op schermen ≤600px)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 15:23:09 +02:00
Ramon
b52c45329c v0.2.08-beta: instellingen-pagina, vernieuwd inlogscherm, centrale permissiematrix + rolwijziging
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 18s
- 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 <noreply@anthropic.com>
2026-07-13 10:39:50 +02:00
Ramon
b80da89ab6 v0.2.07-beta: ankerwoorden automatisch opslaan
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 12s
- 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 <noreply@anthropic.com>
2026-07-13 09:31:52 +02:00
Ramon
d05b12bb86 v0.2.06-beta: automatische plaatjes per woord in Ankerwoorden
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 8s
- 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 <noreply@anthropic.com>
2026-07-13 08:55:43 +02:00
Ramon
5cd1c80073 v0.2.05-beta: bronwaarde widgets als string bewaren + projectafspraken toegevoegd
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 10s
- 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 <noreply@anthropic.com>
2026-07-13 08:04:50 +02:00
Ramon
05d4fc7c8e v0.2.04-beta: mobiel- en tablet-vriendelijk maken van bord, widgets en beheer
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 14s
- 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 <noreply@anthropic.com>
2026-07-13 07:53:52 +02:00
Ramon
64acb84f50 v0.2.03-beta: kopieerknop voor wachtwoorden + multireset klas/school
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 12s
- 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 <noreply@anthropic.com>
2026-07-12 22:53:08 +02:00
Ramon
bd7c7e07e7 v0.2.02: uitgebreid wachtwoordbeheer voor gebruikers
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 17s
- 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 <noreply@anthropic.com>
2026-07-12 22:32:35 +02:00
bes-r
99fadaaedc v0.2.01: schoolsysteem + automatische migraties; deploy: APP_VERSION uit image
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 7s
2026-07-09 23:04:24 +02:00
bes-r
08fd9e11e3 v0.2.01: automatische migraties bij opstarten (transactie per bestand, geen dataverlies)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 17s
2026-07-09 22:34:36 +02:00
bes-r
352e22a64b v0.2.00: schoolsysteem met rollen (super/admin/groepsleiding/leerling), koppelcodes, server-API en beheerpaneel
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 13s
2026-07-09 22:26:01 +02:00
bes-r
d1d3265c6c v0.1.02: opsplitsing in css/, js/core|data|board|app en js/widgets/* (geen functionele wijziging)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 8s
2026-07-09 21:56:49 +02:00
bes-r
334b0f8e14 Versienummering en Widget Ankerwoorden toegevoegd
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 12s
2026-07-08 22:47:23 +02:00
bes-r
3a77442abc Nginx reverse proxy op poort 8081
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 21s
2026-07-08 22:17:28 +02:00
bes-r
01e7f77264 ssh deploy werkend + trigger
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 10s
2026-07-08 22:11:32 +02:00
bes-r
92a7e6e237 ssh deploy: fail-fast (BatchMode + ConnectTimeout)
Some checks failed
dev - build & deploy naar test / build-and-deploy (push) Failing after 6s
2026-07-08 22:01:23 +02:00
bes-r
2b567fe624 Deploy: alleen app-image pullen, niet Docker Hub
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 9s
2026-07-08 21:52:25 +02:00
bes-r
f2cb88f5a6 Schone pipelinerun
Some checks failed
dev - build & deploy naar test / build-and-deploy (push) Has been cancelled
2026-07-08 21:48:01 +02:00
bes-r
910f7953ad Nieuwe testrun 2026-07-08 20:49:02 +02:00
bes-r
b7979dbbcf Deploy via gewone ssh i.p.v. externe action
Some checks failed
dev - build & deploy naar test / build-and-deploy (push) Has been cancelled
2026-07-08 00:14:48 +02:00
bes-r
1db182f440 runs-on: self-hosted runner
Some checks failed
dev - build & deploy naar test / build-and-deploy (push) Failing after 2s
2026-07-07 23:45:25 +02:00
bes-r
ab16f160df Test pipeline dev->test 2026-07-07 23:35:43 +02:00
171 changed files with 19509 additions and 4866 deletions

View file

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

View file

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

View file

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

1
.gitignore vendored
View file

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

11
AGENTS.md Normal file
View file

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

11
CLAUDE.md Normal file
View file

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

View file

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

View file

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

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.4.08-beta

View file

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

52
db/002_schools_auth.sql Normal file
View file

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

View file

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

9
db/004_user_roles.sql Normal file
View file

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

16
db/005_assignments.sql Normal file
View file

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

View file

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

19
db/007_progress.sql Normal file
View file

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

7
db/008_widget_share.sql Normal file
View file

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

16
db/009_save_system.sql Normal file
View file

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

17
db/010_shared_library.sql Normal file
View file

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

View file

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

130
db/012_image_catalog.sql Normal file
View file

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

30
db/013_parent_portal.sql Normal file
View file

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

11
db/014_cms.sql Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

33
deploy/nginx.conf Normal file
View file

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

635
package-lock.json generated
View file

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

View file

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

View file

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

2104
public/css/teach.css Normal file

File diff suppressed because it is too large Load diff

View file

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

Binary file not shown.

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

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

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

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