diff --git a/VERSION b/VERSION index f900021..d692748 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.20-beta +0.4.21-beta diff --git a/public/css/teach.css b/public/css/teach.css index 282673a..199b49d 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1722,6 +1722,61 @@ body.dark .bd-stage{background:radial-gradient(circle at 50% 20%,var(--surface-2 .wordtypes{display:flex;gap:9px;flex-wrap:wrap;justify-content:center}.wordtypes button{border:0;border-radius:10px;padding:10px 13px;font:inherit;font-weight:900;background:var(--surface-2)}.wordtypes button[data-type=noun]{background:#dce9ff;color:#174c9b}.wordtypes button[data-type=verb]{background:#dcf7e7;color:#176b3a}.wordtypes button[data-type=adjective]{background:#fff0cf;color:#855b00}.wordtypes p{flex-basis:100%;text-align:center;color:var(--muted);font-size:12px} .schedule{width:min(100%,450px);display:flex;flex-direction:column;gap:7px}.schedule>div{display:flex;align-items:center;gap:12px;padding:9px;background:var(--surface-2);border-radius:12px;border-left:4px solid var(--widget-accent)}.schedule-icon{width:33px;height:33px;display:grid;place-items:center;flex:none;border-radius:10px;background:var(--surface);font-size:20px;font-style:normal}.schedule time{font-weight:900;color:var(--blue)}.schedule span{flex:1;font-weight:800}.schedule .tbtn{height:30px;min-height:30px;padding:0 9px}.class-total{flex-basis:100%;width:100%;margin:0 0 3px;color:var(--muted);font-size:12px;font-weight:900;text-align:center}.mood{display:flex;gap:8px;flex-wrap:wrap}.mood .tbtn{flex:1;min-width:62px;font-size:36px;height:auto;padding:12px;background:var(--surface);border:2px solid var(--line);color:var(--ink)}.mood span{display:block;font-size:14px}.poll{width:min(100%,440px);display:flex;flex-direction:column;gap:8px}.poll h3{margin-bottom:0;text-align:center}.poll .tbtn{position:relative;overflow:hidden;text-align:left}.poll .tbtn i{position:absolute;inset:0 auto 0 0;background:color-mix(in srgb,var(--widget-accent) 22%,var(--surface));z-index:0;transition:width .25s}.poll .tbtn span{position:relative;float:right;font-weight:900}.poll .tbtn{isolation:isolate} @media(max-width:600px){.gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.gcard{min-height:120px;padding:13px 8px}.gcard .gd{display:none}.widget .wdup,.widget .wpreview{display:none}.edu-stage{padding:10px}.place-grid b{font-size:9px}.place-grid output{padding:12px 2px}} +/* =========== Geluidsmeter =========== */ +.nm{flex:1;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:14px} +.nm-stage{display:flex;align-items:flex-end;gap:14px;flex:1;min-height:0} +.nm-gauge{position:relative;width:70px;height:min(280px,100%);min-height:120px;border-radius:35px;background:var(--surface-2);border:1px solid var(--line);overflow:hidden;box-shadow:inset 0 2px 8px rgba(20,36,52,.12)} +.nm-fill{position:absolute;left:0;right:0;bottom:0;height:0%;border-radius:35px 35px 0 0;transition:height .25s ease-out,background-color .25s;background:var(--surface-2)} +.nm-fill.green{background:var(--green)} +.nm-fill.yellow{background:var(--yellow)} +.nm-fill.red{background:var(--red)} +.nm-mark{position:absolute;left:0;right:0;height:2px;background:rgba(255,255,255,.8);box-shadow:0 0 0 1px rgba(20,36,52,.25)} +.nm-face{font-size:52px;line-height:1} +.nm-gauge.warn{animation:nmwarn 1s infinite} +@keyframes nmwarn{0%,100%{box-shadow:inset 0 2px 8px rgba(20,36,52,.12)}50%{box-shadow:inset 0 2px 8px rgba(20,36,52,.12),0 0 0 4px color-mix(in srgb,var(--red) 45%,transparent)}} +.nm-status{min-height:26px;font-size:16px;font-weight:900;text-align:center} +.nm-zones{width:100%;max-width:320px;display:flex;flex-direction:column;gap:6px} +.nm-zone-row{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:800;color:var(--muted)} +.nm-zone-row input{flex:1} +.nm-zone-lbl{flex:none;min-width:96px} +.nm-privacy{margin:0;max-width:320px;color:var(--muted);font-size:11px;text-align:center;line-height:1.4} +/* =========== Gevoelszones =========== */ +.fz{flex:1;min-height:0;display:flex;flex-direction:column;gap:14px;padding:14px;overflow:auto} +.fz-zones{display:grid;grid-template-columns:repeat(2,1fr);gap:10px} +.fz-zone{display:flex;flex-direction:column;align-items:center;gap:6px;padding:14px 10px;border-radius:18px;border:2px solid var(--line);background:var(--surface);cursor:pointer;font:inherit;color:var(--ink)} +.fz-zone.blue{border-color:color-mix(in srgb,var(--blue) 40%,var(--line))} +.fz-zone.green{border-color:color-mix(in srgb,var(--green) 40%,var(--line))} +.fz-zone.yellow{border-color:color-mix(in srgb,var(--yellow) 55%,var(--line))} +.fz-zone.red{border-color:color-mix(in srgb,var(--red) 40%,var(--line))} +.fz-zone.active.blue{background:color-mix(in srgb,var(--blue) 16%,var(--surface))} +.fz-zone.active.green{background:color-mix(in srgb,var(--green) 16%,var(--surface))} +.fz-zone.active.yellow{background:color-mix(in srgb,var(--yellow) 22%,var(--surface))} +.fz-zone.active.red{background:color-mix(in srgb,var(--red) 16%,var(--surface))} +.fz-emoji{font-size:32px} +.fz-label{font-weight:900;font-size:14px} +.fz-detail{padding:12px 16px;border-radius:16px;border:1px solid var(--line);background:var(--surface-2);text-align:center} +.fz-detail-title{margin:0 0 4px;font-size:16px} +.fz-detail-desc{margin:0 0 6px;font-weight:800;color:var(--ink)} +.fz-detail-tip{margin:0;color:var(--muted);font-size:13px;line-height:1.4} +.fz-detail.blue{border-color:color-mix(in srgb,var(--blue) 35%,var(--line))} +.fz-detail.green{border-color:color-mix(in srgb,var(--green) 35%,var(--line))} +.fz-detail.yellow{border-color:color-mix(in srgb,var(--yellow) 45%,var(--line))} +.fz-detail.red{border-color:color-mix(in srgb,var(--red) 35%,var(--line))} +/* =========== Ademhalingsoefening =========== */ +.bre{flex:1;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;padding:14px} +.bre-stage{flex:1;min-height:0;width:100%;display:flex;align-items:center;justify-content:center} +.bre-circle{width:120px;height:120px;border-radius:50%;background:radial-gradient(circle at 35% 30%,color-mix(in srgb,var(--blue) 55%,#fff),var(--blue));display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;color:#fff;font-weight:900;transform:scale(1);transition:transform 1s ease-in-out;box-shadow:0 12px 30px rgba(20,54,97,.28)} +.bre-circle.hold{filter:brightness(.92)} +.bre-word{font-size:15px} +.bre-count{font-size:26px;font-variant-numeric:tabular-nums} +.bre-rounds{color:var(--muted);font-size:13px;font-weight:800} +.bre-row{display:flex;gap:10px;align-items:center} +/* =========== Complimentenmachine =========== */ +.cpl{flex:1;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:14px} +.cpl-card{width:min(100%,420px);min-height:140px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:20px;border-radius:22px;background:linear-gradient(135deg,var(--pink),var(--purple));color:#fff;text-align:center;box-shadow:0 14px 30px rgba(139,92,246,.28);opacity:.55;transform:scale(.96);transition:opacity .2s,transform .2s} +.cpl-card.show{opacity:1;transform:scale(1)} +.cpl-icon{font-size:34px} +.cpl-text{margin:0;font-size:17px;font-weight:800;line-height:1.4;text-shadow:0 2px 6px rgba(0,0,0,.2)} /* Wereldoriëntatie: natuurherkenning en topografie */ .world-app{width:100%;min-height:100%;align-self:flex-start;display:flex;flex-direction:column;justify-content:flex-start;color:var(--ink)} diff --git a/public/index.html b/public/index.html index be8443c..b9f1d0a 100644 --- a/public/index.html +++ b/public/index.html @@ -35,6 +35,10 @@ + + + + diff --git a/public/js/board.js b/public/js/board.js index 2afe24b..f124323 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -61,6 +61,10 @@ const REGISTRY = [ { id:"light", cat:"class", icon:"🚦", w:480, h:440, minW:320, minH:320, mount:mountLight }, { id:"mood", cat:"class", icon:"🌡️", w:530, h:360, minW:350, minH:260, mount:mountMood }, { id:"poll", cat:"class", icon:"📊", w:540, h:500, minW:360, minH:330, mount:mountPoll }, + { id:"noise", cat:"class", icon:"🎙️", w:420, h:600, minW:320, minH:460, mount:mountNoise }, + { id:"feelings", cat:"class", icon:"🎭", w:560, h:520, minW:380, minH:400, mount:mountFeelings }, + { id:"breathe", cat:"class", icon:"🌬️", w:440, h:520, minW:320, minH:420, mount:mountBreathe }, + { id:"compliments", cat:"class", icon:"💝", w:460, h:400, minW:340, minH:300, mount:mountCompliments }, { id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia } ]; const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["world","catWorld"],["class","catClass"],["tools","catTools"],["media","catMedia"]]; diff --git a/public/js/core.js b/public/js/core.js index f8446f5..216ffb3 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.20-beta"; +const VERSION = "0.4.21-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -148,6 +148,20 @@ const I18N = { wg_schedule:"Dagplanning", wg_schedule_d:"Maak een duidelijke visuele planning voor de klas.", wg_mood:"Gevoelsmeter", wg_mood_d:"Doe een snelle, anonieme check-in met de klas.", wg_poll:"Klaspoll", wg_poll_d:"Stem samen op maximaal zes antwoorden.", + wg_noise:"Geluidsmeter", wg_noise_d:"Meet het geluidsniveau in de klas met de microfoon - alles blijft lokaal, er wordt niets opgenomen of verstuurd.", + noiseOff:"Zet de microfoon aan om te starten.", noiseStatus_green:"Lekker rustig!", noiseStatus_yellow:"Een beetje luidruchtig…", noiseStatus_red:"Te luid! Even rustig aan.", + noiseDenied:"Kan niet bij de microfoon. Geef toestemming in de browser en probeer opnieuw.", + noiseStart:"Microfoon aan", noiseStop:"Stop", noiseZoneGreen:"Rustig tot", noiseZoneYellow:"Te luid vanaf", + noisePrivacy:"Er wordt niets opgenomen of verstuurd: het geluidsniveau blijft alleen in deze browser.", + wg_feelings:"Gevoelszones", wg_feelings_d:"Individuele check-in: kies je zone en krijg een passende tip om jezelf te helpen.", + fzZone_blue:"Blauw", fzZone_green:"Groen", fzZone_yellow:"Geel", fzZone_red:"Rood", + fzDesc_blue:"Ik voel me moe of verdrietig.", fzDesc_green:"Ik voel me kalm en klaar om te leren.", fzDesc_yellow:"Ik voel me druk, gestrest of opgewonden.", fzDesc_red:"Ik voel me boos of overprikkeld.", + fzTip_blue:"Even rustig zitten, wat water drinken of een boek pakken kan helpen.", fzTip_green:"Fijn zo! Dit is een goed moment om aan de slag te gaan.", fzTip_yellow:"Diep ademhalen of een korte pauze kan helpen om weer rustig te worden.", fzTip_red:"Ga naar de rustige plek, haal diep adem of vraag de juf of meester om hulp.", + wg_breathe:"Ademhalingsoefening", wg_breathe_d:"Adem rustig mee met de groeiende en krimpende cirkel, in drie tempo's.", + bre_in:"Adem in", bre_hold:"Houd vast", bre_out:"Adem uit", breStart:"Start", breStop:"Stop", breRounds:"{n} rondjes gedaan", + "brePattern_4-4":"4 tellen in, 4 uit", "brePattern_4-7-8":"4 in, 7 vast, 8 uit", "brePattern_4-4-4-4":"4 in, 4 vast, 4 uit, 4 vast", + wg_compliments:"Complimentenmachine", wg_compliments_d:"Trek een willekeurig compliment om iemand blij te maken.", + complimentsDraw:"Trek een compliment", clock24Hour:"24-uursklok", widgetSettings:"Instellingen", clockWelcome:"Oefenen met de klok", clockWelcomeHint:"Kies bovenaan niveau 1, 2 of 3. Kies bij Instellingen hoe de opdracht wordt getoond en start daarna.", clockSetTitle:"Zet de wijzers op de goede tijd", clockReadTitle:"Bekijk de opdracht en maak de tijd", clockChooseTitle:"Kies het juiste tijdblok", clockYourTask:"Jouw opdracht", clockLinkHands:"Grote wijzer draait mee", clockTypeHint:"Voer de tijd in als UU:MM.", clockLevel1Hint:"Niveau 1: hele en halve uren", clockLevel2Hint:"Niveau 2: stappen van vijf minuten", clockLevel3Hint:"Niveau 3: alle minuten, met optioneel 24 uur en dagdelen", clockBuildHint:"Sleep vier cijfers naar de vakken en maak UU:MM.", clockDigitGhost:"?", clockDigit:"Cijfer {digit}", clockIncomplete:"Maak eerst de volledige tijd af.", clockCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", clockTryAgain:"Bijna! Probeer het nog een keer.", clockStartTask:"Start opdracht", clockNextTask:"Volgende opdracht", clockLevel:"Niveau", clockLevelHour:"Hele uren", clockLevelQuarter:"Kwartieren", clockLevelFive:"Vijf minuten", clockLevelMinute:"Iedere minuut", clockLearningStyle:"Opdracht tonen", clockStyleAnalogue:"Analoge klok", clockStyleDigital:"Grote digitale tijd", clockStyleWords:"In woorden", clockStyleBoth:"Klok, cijfers en woorden", clockDragHint:"Sleep de wijzers om de tijd te veranderen.", clockGame:"Spel", clockGameSet:"Klok zetten", clockGameRead:"Tijd aflezen", clockGameChoose:"Kies de tijd", clockStart:"Kies ‘Nieuwe opdracht’ om te beginnen", clockRead:"Welke tijd zie je? Stel je antwoord hieronder in.", clockChoose:"Welke digitale tijd hoort bij deze klok?", clockTry:"Nog niet. Het goede antwoord is {time}.", clockScore:"Score: {score} · reeks: {streak}", clockWordsHour:"{hour} uur", clockWordsQuarterPast:"kwart over {hour}", clockWordsHalf:"half {hour}", clockWordsQuarterTo:"kwart voor {hour}", clockWordsPast:"{minutes} over {hour}", clockWordsTo:"{minutes} voor {hour}", clockMorning:"in de ochtend", clockAfternoon:"in de middag", clockEvening:"in de avond", clockNight:"in de nacht", fractionRows:"Rijen", fractionLabels:"Breuken tonen", fractionHint:"Tik in een rij tot waar je de breuk wilt kleuren.", fractionSelected:"Gekozen breuk: {fraction}", fractionClear:"Leegmaken", clockTask:"Oefenopdracht", clockSet:"Zet de klok op {time}", clockHour:"Uur", clockMinute:"Minuten", placeNumber:"Getal", placeGame:"Spel", placeGameExplore:"Verkennen", placeGameBuild:"Bouwen", placeLevel:"Niveau", placeLevel1Hint:"Niveau 1: getallen tot 100 (T en E)", placeLevel2Hint:"Niveau 2: getallen tot 1000 (H, T en E)", placeLevel3Hint:"Niveau 3: getallen tot 1.000.000 (HD, TD, D, H, T en E)", placeBuildTitle:"Bouw het getal", placeYourTask:"Jouw opdracht", placeBuildHint:"Sleep de cijfers naar de vakken en bouw het getal.", placeDigitGhost:"?", placeIncomplete:"Maak eerst het hele getal af.", placeCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", placeTryAgain:"Bijna! Probeer het nog een keer.", placeNextTask:"Volgende opdracht", placeScore:"Score: {score} · reeks: {streak}", moneyMake:"Maak € {amount}", wordtypeSentence:"Zin", wordtypeHint:"Tik: blauw zelfstandig naamwoord · groen werkwoord · geel bijvoeglijk naamwoord", scheduleActivity:"activiteit…", scheduleEmpty:"Voeg de eerste activiteit toe.", scheduleCount:"{count} momenten op de planning", pollReset:"Opnieuw", pollQuestion:"Vraag", pollOptions:"Antwoorden (één per regel)", pollTotal:"{count} stemmen", moodTotal:"{count} kinderen hebben gekozen", moodVote:"Kies {face}", galleryFavorites:"Favorieten", galleryFavorite:"Favoriet maken", galleryEmpty:"Geen widgets gevonden.", gallerySearch:"Zoek widgets…", templateMath:"Rekenles", templateLanguage:"Taalles", templateDay:"Dagstart", exportBoard:"Exporteren", importBoard:"Importeren", importError:"Dit is geen geldig Teach-bordbestand.", widgetDuplicate:"Dupliceren", widgetLock:"Vastzetten", widgetUnlock:"Losmaken", widgetPreview:"Voorbeeld zonder bediening", undo:"Ongedaan maken", redo:"Opnieuw", wg_sentence:"Zinsbouwer", wg_sentence_d:"Bouw zinnen met kleurgecodeerde zinsdelen en voeg eigen woorden toe.", @@ -457,6 +471,20 @@ const I18N = { wg_schedule:"Day schedule", wg_schedule_d:"Create a clear visual class schedule.", wg_mood:"Mood check-in", wg_mood_d:"Run a quick anonymous class check-in.", wg_poll:"Class poll", wg_poll_d:"Vote together on up to six answers.", + wg_noise:"Noise meter", wg_noise_d:"Measure the classroom noise level with the microphone - everything stays local, nothing is recorded or sent anywhere.", + noiseOff:"Turn on the microphone to start.", noiseStatus_green:"Nice and quiet!", noiseStatus_yellow:"A bit noisy…", noiseStatus_red:"Too loud! Let's settle down.", + noiseDenied:"Can't reach the microphone. Allow access in your browser and try again.", + noiseStart:"Microphone on", noiseStop:"Stop", noiseZoneGreen:"Quiet up to", noiseZoneYellow:"Too loud from", + noisePrivacy:"Nothing is recorded or sent anywhere: the noise level stays in this browser only.", + wg_feelings:"Feelings zones", wg_feelings_d:"Individual check-in: pick your zone and get a matching tip to help yourself.", + fzZone_blue:"Blue", fzZone_green:"Green", fzZone_yellow:"Yellow", fzZone_red:"Red", + fzDesc_blue:"I feel tired or sad.", fzDesc_green:"I feel calm and ready to learn.", fzDesc_yellow:"I feel restless, stressed or excited.", fzDesc_red:"I feel angry or overwhelmed.", + fzTip_blue:"Sitting quietly, drinking some water, or grabbing a book can help.", fzTip_green:"Great! This is a good moment to get started.", fzTip_yellow:"Taking a deep breath or a short break can help you feel calm again.", fzTip_red:"Go to the quiet spot, take a deep breath, or ask your teacher for help.", + wg_breathe:"Breathing exercise", wg_breathe_d:"Breathe along with the growing and shrinking circle, in three paces.", + bre_in:"Breathe in", bre_hold:"Hold", bre_out:"Breathe out", breStart:"Start", breStop:"Stop", breRounds:"{n} rounds done", + "brePattern_4-4":"4 counts in, 4 out", "brePattern_4-7-8":"4 in, 7 hold, 8 out", "brePattern_4-4-4-4":"4 in, 4 hold, 4 out, 4 hold", + wg_compliments:"Compliment machine", wg_compliments_d:"Draw a random compliment to make someone's day.", + complimentsDraw:"Draw a compliment", clock24Hour:"24-hour clock", widgetSettings:"Settings", clockWelcome:"Practise telling time", clockWelcomeHint:"Choose level 1, 2 or 3 at the top. In Settings, choose how the task is shown, then start.", clockSetTitle:"Move the hands to the correct time", clockReadTitle:"View the task and make the time", clockChooseTitle:"Choose the correct time block", clockYourTask:"Your task", clockLinkHands:"Move the minute hand along", clockTypeHint:"Enter the time as HH:MM.", clockLevel1Hint:"Level 1: whole and half hours", clockLevel2Hint:"Level 2: five-minute steps", clockLevel3Hint:"Level 3: every minute, with optional 24-hour time and day parts", clockBuildHint:"Drag four digits into the slots to make HH:MM.", clockDigitGhost:"?", clockDigit:"Digit {digit}", clockIncomplete:"Complete the full time first.", clockCorrect:"Well done! Choose the next task. 🌟", clockTryAgain:"Almost! Try again.", clockStartTask:"Start task", clockNextTask:"Next task", clockLevel:"Level", clockLevelHour:"Whole hours", clockLevelQuarter:"Quarter hours", clockLevelFive:"Five minutes", clockLevelMinute:"Every minute", clockLearningStyle:"Show task as", clockStyleAnalogue:"Analogue clock", clockStyleDigital:"Large digital time", clockStyleWords:"In words", clockStyleBoth:"Clock, digits and words", clockDragHint:"Drag the hands to change the time.", clockGame:"Game", clockGameSet:"Set the clock", clockGameRead:"Read the time", clockGameChoose:"Choose the time", clockStart:"Choose ‘New task’ to begin", clockRead:"What time is it? Set your answer below.", clockChoose:"Which digital time matches this clock?", clockTry:"Not yet. The correct answer is {time}.", clockScore:"Score: {score} · streak: {streak}", clockWordsHour:"{hour} o'clock", clockWordsQuarterPast:"quarter past {hour}", clockWordsHalf:"half past {hour}", clockWordsQuarterTo:"quarter to {hour}", clockWordsPast:"{minutes} past {hour}", clockWordsTo:"{minutes} to {hour}", clockMorning:"in the morning", clockAfternoon:"in the afternoon", clockEvening:"in the evening", clockNight:"at night", fractionRows:"Rows", fractionLabels:"Show fractions", fractionHint:"Tap a row up to the part you want to colour.", fractionSelected:"Selected fraction: {fraction}", fractionClear:"Clear", clockTask:"Practice task", clockSet:"Set the clock to {time}", clockHour:"Hour", clockMinute:"Minutes", placeNumber:"Number", placeGame:"Game", placeGameExplore:"Explore", placeGameBuild:"Build", placeLevel:"Level", placeLevel1Hint:"Level 1: numbers up to 100 (T and E)", placeLevel2Hint:"Level 2: numbers up to 1000 (H, T and E)", placeLevel3Hint:"Level 3: numbers up to 1,000,000 (HD, TD, D, H, T and E)", placeBuildTitle:"Build the number", placeYourTask:"Your task", placeBuildHint:"Drag the digits into the slots to build the number.", placeDigitGhost:"?", placeIncomplete:"Complete the whole number first.", placeCorrect:"Well done! Choose the next task. 🌟", placeTryAgain:"Almost! Try again.", placeNextTask:"Next task", placeScore:"Score: {score} · streak: {streak}", moneyMake:"Make € {amount}", wordtypeSentence:"Sentence", wordtypeHint:"Tap: blue noun · green verb · yellow adjective", scheduleActivity:"activity…", scheduleEmpty:"Add the first activity.", scheduleCount:"{count} moments in the schedule", pollReset:"Reset", pollQuestion:"Question", pollOptions:"Answers (one per line)", pollTotal:"{count} votes", moodTotal:"{count} children have chosen", moodVote:"Choose {face}", galleryFavorites:"Favourites", galleryFavorite:"Add favourite", galleryEmpty:"No widgets found.", gallerySearch:"Search widgets…", templateMath:"Maths lesson", templateLanguage:"Language lesson", templateDay:"Day start", exportBoard:"Export", importBoard:"Import", importError:"This is not a valid Teach board file.", widgetDuplicate:"Duplicate", widgetLock:"Lock", widgetUnlock:"Unlock", widgetPreview:"Preview without controls", undo:"Undo", redo:"Redo", wg_sentence:"Sentence builder", wg_sentence_d:"Build sentences with colour-coded parts and add your own words.", diff --git a/public/js/widgets/breathe.js b/public/js/widgets/breathe.js new file mode 100644 index 0000000..95cc8d7 --- /dev/null +++ b/public/js/widgets/breathe.js @@ -0,0 +1,81 @@ +/* widget: Ademhalingsoefening */ +/* ========================================================= + Rustgevende ademhaling met een groeiende/krimpende cirkel, drie tempo's. + De cirkelgrootte wordt via inline style/transition-duration gestuurd + (niet via CSS-klassen) zodat elke fase precies aansluit op de vorige + zonder terugspringen. +==========================================================*/ +const BREATHE_PATTERNS = { "4-4": [["in",4],["out",4]], "4-7-8": [["in",4],["hold",7],["out",8]], "4-4-4-4": [["in",4],["hold",4],["out",4],["hold",4]] }; +const BREATHE_SCALE = { in: 1.4, hold: null, out: .7 }; +function mountBreathe(root, initState){ + let pattern = (initState && BREATHE_PATTERNS[initState.pattern]) ? initState.pattern : "4-4"; + let running = false, phase = -1, phaseStart = 0, rafId = null, rounds = 0; + root.innerHTML = ` +
+

+
+
+
+ +
+ + +
+
`; + const circle = root.querySelector(".bre-circle"), word = root.querySelector(".bre-word"), + count = root.querySelector(".bre-count"), roundsEl = root.querySelector(".bre-rounds"), + patternSel = root.querySelector(".bre-pattern"), toggle = root.querySelector(".bre-toggle"); + Object.keys(BREATHE_PATTERNS).forEach(k=>patternSel.appendChild(new Option("", k))); + patternSel.value = pattern; + + function updateRounds(){ roundsEl.textContent = T("breRounds").replace("{n}", rounds); } + function enterPhase(index){ + const [name, dur] = BREATHE_PATTERNS[pattern][index]; + circle.style.transitionDuration = dur + "s"; + circle.classList.toggle("in", name === "in"); + circle.classList.toggle("out", name === "out"); + if(BREATHE_SCALE[name] !== null) circle.style.transform = "scale(" + BREATHE_SCALE[name] + ")"; + word.textContent = T("bre_" + name); + } + function step(ts){ + if(!running || !document.body.contains(root)) return; + const steps = BREATHE_PATTERNS[pattern]; + if(phase < 0){ phase = 0; phaseStart = ts; enterPhase(phase); } + const [, dur] = steps[phase]; + const elapsed = (ts - phaseStart) / 1000; + count.textContent = Math.max(0, Math.ceil(dur - elapsed)) || ""; + if(elapsed >= dur){ + phase = (phase + 1) % steps.length; + phaseStart = ts; + if(phase === 0){ rounds++; updateRounds(); } + enterPhase(phase); + } + rafId = requestAnimationFrame(step); + } + function start(){ + running = true; phase = -1; rounds = 0; + updateRounds(); + labels(); + rafId = requestAnimationFrame(step); + } + function stop(){ + running = false; + if(rafId) cancelAnimationFrame(rafId); + rafId = null; + circle.classList.remove("in","out"); + circle.style.transform = ""; circle.style.transitionDuration = ""; + word.textContent = ""; count.textContent = ""; + labels(); + } + toggle.addEventListener("click", ()=>{ if(running) stop(); else start(); }); + patternSel.addEventListener("change", ()=>{ pattern = patternSel.value; if(running) stop(); }); + function labels(){ + root.querySelector(".bre-mission").textContent = T("wg_breathe_d"); + toggle.textContent = running ? "⏸ " + T("breStop") : "🌬️ " + T("breStart"); + [...patternSel.options].forEach(o=>{ o.textContent = T("brePattern_" + o.value); }); + updateRounds(); + } + document.addEventListener("langchange", labels); + labels(); + return { getState: ()=>({ pattern }) }; +} diff --git a/public/js/widgets/compliments.js b/public/js/widgets/compliments.js new file mode 100644 index 0000000..2e9720e --- /dev/null +++ b/public/js/widgets/compliments.js @@ -0,0 +1,66 @@ +/* widget: Complimentenmachine */ +/* ========================================================= + Trek een willekeurig compliment - zelfde spin-en-onthul-mechaniek als + de namenkiezer (names.js) +==========================================================*/ +const COMPLIMENTS = Object.freeze([ + { nl:"Jij bent een fijne klasgenoot!", en:"You're a great classmate!" }, + { nl:"Wat knap dat je dat zelf hebt uitgezocht!", en:"Great job figuring that out yourself!" }, + { nl:"Jij luistert altijd goed naar anderen.", en:"You're always a great listener." }, + { nl:"Je hebt vandaag hard gewerkt, goed gedaan!", en:"You worked hard today, well done!" }, + { nl:"Jij deelt graag met anderen.", en:"You love sharing with others." }, + { nl:"Wat fijn dat jij er bent!", en:"We're so glad you're here!" }, + { nl:"Jij bent heel behulpzaam.", en:"You're very helpful." }, + { nl:"Je hebt een mooie glimlach.", en:"You have a lovely smile." }, + { nl:"Jij geeft nooit op, dat is knap!", en:"You never give up, that's impressive!" }, + { nl:"Jij bent een echte teamspeler.", en:"You're a real team player." }, + { nl:"Wat een goed idee had jij daar!", en:"That was a great idea you had!" }, + { nl:"Jij maakt anderen graag blij.", en:"You love making others happy." }, + { nl:"Je hebt heel goed je best gedaan.", en:"You tried really hard." }, + { nl:"Jij bent creatief en origineel.", en:"You're creative and original." }, + { nl:"Jij zegt altijd eerlijk wat je denkt.", en:"You always speak your mind honestly." }, + { nl:"Jij bent een fijne vriend(in).", en:"You're a great friend." }, + { nl:"Wat kun jij goed samenwerken!", en:"You're so good at working together!" }, + { nl:"Jij maakt de klas een fijnere plek.", en:"You make our class a nicer place." }, + { nl:"Jij durft dingen te proberen, goed zo!", en:"You're brave enough to try new things!" }, + { nl:"Jij bent trots op jezelf, en dat mag!", en:"You're proud of yourself, and that's great!" }, +]); +function mountCompliments(root, initState){ + let current = null, spinning = false; + root.innerHTML = ` +
+

+
💝

+ +
`; + const card = root.querySelector(".cpl-card"), text = root.querySelector(".cpl-text"), drawBtn = root.querySelector(".cpl-draw"); + function pickRandom(){ return COMPLIMENTS[Math.floor(Math.random() * COMPLIMENTS.length)]; } + function draw(){ + if(spinning) return; + spinning = true; + card.classList.remove("show"); + let n = 0; + const iv = setInterval(()=>{ + text.textContent = pickRandom()[LANG]; + tone(500 + Math.random() * 200, 0.03, 0, "square", 0.04); + if(++n >= 10){ + clearInterval(iv); + current = pickRandom(); + text.textContent = current[LANG]; + card.classList.add("show"); + confetti(card); + sndWin(); + spinning = false; + } + }, 70); + } + drawBtn.addEventListener("click", draw); + function labels(){ + root.querySelector(".cpl-mission").textContent = T("wg_compliments_d"); + drawBtn.textContent = "💝 " + T("complimentsDraw"); + if(current) text.textContent = current[LANG]; + } + document.addEventListener("langchange", labels); + labels(); + return { getState: ()=>({}) }; +} diff --git a/public/js/widgets/feelings.js b/public/js/widgets/feelings.js new file mode 100644 index 0000000..e83b3b3 --- /dev/null +++ b/public/js/widgets/feelings.js @@ -0,0 +1,60 @@ +/* widget: Gevoelszones */ +/* ========================================================= + Individual check-in (Zones of Regulation-stijl): kies je zone, krijg een + passende tip - anders dan de Gevoelsmeter (mood.js), die is een anonieme + klaspoll, geen persoonlijke check-in met uitleg. +==========================================================*/ +function mountFeelings(root, initState){ + let picked = (initState && initState.picked) || null; + const ZONES = ["blue","green","yellow","red"]; + const EMOJI = { blue:"💧", green:"🌿", yellow:"⚡", red:"🔥" }; + root.innerHTML = ` +
+

+
+ +
`; + const zonesEl = root.querySelector(".fz-zones"), detail = root.querySelector(".fz-detail"); + function pick(id){ + picked = id; + tone(id==="blue"?294:id==="green"?392:id==="yellow"?523:220, 0.14, 0, "sine", .08); + draw(); + } + function draw(){ + zonesEl.innerHTML = ""; + ZONES.forEach(id=>{ + const b = document.createElement("button"); + b.className = "fz-zone " + id + (picked === id ? " active" : ""); + b.setAttribute("aria-pressed", String(picked === id)); + const emoji = document.createElement("span"); + emoji.className = "fz-emoji"; + emoji.textContent = EMOJI[id]; + const label = document.createElement("span"); + label.className = "fz-label"; + label.textContent = T("fzZone_" + id); + b.append(emoji, label); + b.addEventListener("click", ()=>pick(id)); + zonesEl.appendChild(b); + }); + if(picked){ + detail.hidden = false; + detail.className = "fz-detail " + picked; + detail.querySelector(".fz-detail-title").textContent = EMOJI[picked] + " " + T("fzZone_" + picked); + detail.querySelector(".fz-detail-desc").textContent = T("fzDesc_" + picked); + detail.querySelector(".fz-detail-tip").textContent = T("fzTip_" + picked); + }else{ + detail.hidden = true; + } + } + function labels(){ + root.querySelector(".fz-mission").textContent = T("wg_feelings_d"); + draw(); + } + document.addEventListener("langchange", labels); + labels(); + return { getState: ()=>({ picked }) }; +} diff --git a/public/js/widgets/noise.js b/public/js/widgets/noise.js new file mode 100644 index 0000000..aac7794 --- /dev/null +++ b/public/js/widgets/noise.js @@ -0,0 +1,124 @@ +/* widget: Geluidsmeter */ +/* ========================================================= + Live noise meter — mic input stays local, never recorded or sent anywhere +==========================================================*/ +function mountNoise(root, initState){ + let zoneGreen = (initState && initState.zoneGreen) || 40; + let zoneYellow = (initState && initState.zoneYellow) || 70; + let running = false, audioCtx = null, analyser = null, stream = null, rafId = null, warnTimer = null; + let level = 0, peak = 0, timeData = null; + + root.innerHTML = ` +
+

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

+
`; + const gauge = root.querySelector(".nm-gauge"), + fill = root.querySelector(".nm-fill"), + markGreen = root.querySelector(".nm-mark.green"), + markYellow = root.querySelector(".nm-mark.yellow"), + face = root.querySelector(".nm-face"), + status = root.querySelector(".nm-status"), + toggle = root.querySelector(".nm-toggle"), + z1 = root.querySelector(".nm-z1"), + z2 = root.querySelector(".nm-z2"); + z1.value = zoneGreen; + z2.value = zoneYellow; + + function currentZone(){ return level < zoneGreen ? "green" : level < zoneYellow ? "yellow" : "red"; } + function draw(){ + fill.style.height = Math.round(level) + "%"; + fill.className = "nm-fill " + currentZone(); + markGreen.style.bottom = zoneGreen + "%"; + markYellow.style.bottom = zoneYellow + "%"; + face.textContent = !running ? "🎙️" : currentZone() === "green" ? "🙂" : currentZone() === "yellow" ? "😐" : "😬"; + status.textContent = !running ? T("noiseOff") + : T("noiseStatus_" + currentZone()); + gauge.classList.toggle("warn", running && currentZone() === "red"); + } + function loop(){ + if(!running || !document.body.contains(root)){ stop(); return; } + analyser.getByteTimeDomainData(timeData); + let sum = 0; + for(let i = 0; i < timeData.length; i++){ const v = (timeData[i] - 128) / 128; sum += v * v; } + const rms = Math.sqrt(sum / timeData.length); + const pct = Math.min(100, rms * 100 * 4.2); + level = level * 0.75 + pct * 0.25; + peak = Math.max(peak * 0.995, level); + draw(); + rafId = requestAnimationFrame(loop); + } + async function start(){ + try{ + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + }catch(e){ + status.textContent = T("noiseDenied"); + return; + } + audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)(); + if(audioCtx.state === "suspended") await audioCtx.resume().catch(()=>{}); + const source = audioCtx.createMediaStreamSource(stream); + analyser = audioCtx.createAnalyser(); + analyser.fftSize = 1024; + timeData = new Uint8Array(analyser.fftSize); + source.connect(analyser); + running = true; level = 0; peak = 0; + labels(); + loop(); + } + function stop(){ + running = false; + if(rafId) cancelAnimationFrame(rafId); + rafId = null; + if(stream){ stream.getTracks().forEach(t => t.stop()); stream = null; } + analyser = null; + level = 0; + labels(); + draw(); + } + toggle.addEventListener("click", ()=>{ if(running) stop(); else start(); }); + z1.addEventListener("input", ()=>{ + zoneGreen = Math.min(+z1.value, +z2.value - 5); + z1.value = zoneGreen; + draw(); + }); + z2.addEventListener("input", ()=>{ + zoneYellow = Math.max(+z2.value, zoneGreen + 5); + z2.value = zoneYellow; + draw(); + }); + /* geluid over rood laten lopen is een stoorzender op zich - een korte, + zachte waarschuwing volstaat, geen herhaald alarm */ + const watchWarn = ()=>{ + clearInterval(warnTimer); + warnTimer = setInterval(()=>{ + if(running && currentZone() === "red") sndBad(); + }, 6000); + }; + watchWarn(); + function labels(){ + root.querySelector(".nm-mission").textContent = T("wg_noise_d"); + toggle.textContent = running ? "⏸ " + T("noiseStop") : "🎙️ " + T("noiseStart"); + root.querySelectorAll(".nm-zone-lbl")[0].textContent = T("noiseZoneGreen"); + root.querySelectorAll(".nm-zone-lbl")[1].textContent = T("noiseZoneYellow"); + root.querySelector(".nm-privacy").textContent = T("noisePrivacy"); + draw(); + } + document.addEventListener("langchange", labels); + labels(); + return { getState: ()=>({ zoneGreen, zoneYellow }) }; +} diff --git a/test/widgets.test.js b/test/widgets.test.js index 5177737..e7dcc50 100644 --- a/test/widgets.test.js +++ b/test/widgets.test.js @@ -344,6 +344,10 @@ test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px- light:'.tl-house', mood:'.mood', poll:'.poll', + noise:'.nm-gauge', + feelings:'.fz-zones', + breathe:'.bre-circle', + compliments:'.cpl-card', media:'.media-bar', }; assert.deepEqual(registered,Object.keys(coverage).sort(),'ieder geregistreerd widgettype staat in de mobiele audit');