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

This commit is contained in:
bes-r 2026-07-09 21:56:49 +02:00
parent 334b0f8e14
commit d1d3265c6c
20 changed files with 4862 additions and 4745 deletions

946
public/css/teach.css Normal file
View file

@ -0,0 +1,946 @@
/* teach - alle opmaak */
:root{
--bg:#eef3f8; --panel:#ffffff; --ink:#26344a; --muted:#7b8aa3;
--blue:#3b7dd8; --blue-d:#2b62b0; --red:#e0554d; --green:#3fae6a;
--yellow:#f7c948; --purple:#9b59b6; --orange:#f28c38;
--wood:#f6e7c9; --wood-d:#e8d2a4;
--radius:16px; --shadow:0 10px 30px rgba(30,50,90,.18);
}
*{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
*::-webkit-scrollbar{width:8px; height:8px;}
*::-webkit-scrollbar-thumb{background:#c3cfde; border-radius:4px;}
*::-webkit-scrollbar-track{background:transparent;}
html,body{height:100%; margin:0; overflow:hidden;}
body{
font-family:"Segoe UI Rounded","Arial Rounded MT Bold",ui-rounded,"Segoe UI",Verdana,sans-serif;
background:var(--bg); color:var(--ink); user-select:none;
}
input,textarea{user-select:text;}
/* ---------- Toolbar ---------- */
#toolbar{
position:fixed; top:0; left:0; right:0; height:58px; z-index:5000;
background:var(--panel); box-shadow:0 2px 10px rgba(30,50,90,.10);
display:flex; align-items:center; gap:10px; padding:0 16px;
overflow-x:auto; overflow-y:hidden; scrollbar-width:none;
}
#toolbar::-webkit-scrollbar{display:none;}
#zoomPct{min-width:58px; padding:9px 8px; font-size:13px; text-align:center;}
#zoomPct.on{background:var(--yellow); color:#6b5200;}
#btnZoomIn:disabled,#btnZoomOut:disabled{opacity:.35; cursor:default;}
.logo{display:flex; align-items:baseline; gap:1px; font-size:30px; font-weight:900; line-height:1;}
.logo span{display:inline-block; transform-origin:bottom center;}
.logo .l1{color:var(--red); transform:rotate(-6deg);}
.logo .l2{color:var(--orange); transform:rotate(4deg) translateY(-2px);}
.logo .l3{color:var(--green); transform:rotate(-3deg);}
.logo .l4{color:var(--blue); transform:rotate(5deg) translateY(-1px);}
.logo .l5{color:var(--purple); transform:rotate(-4deg);}
.logo .crayon{font-size:24px; margin-left:6px; transform:rotate(10deg);}
#verTag{font-size:10.5px; font-weight:700; color:var(--muted); align-self:flex-end; margin-left:4px; transform:none;}
#verTag.beta{color:#fff; background:var(--orange); padding:1px 7px; border-radius:8px;}
.logo:hover span:not(.crayon){animation:bounce .5s;}
.logo .l2{animation-delay:.05s;} .logo .l3{animation-delay:.1s;}
.logo .l4{animation-delay:.15s;} .logo .l5{animation-delay:.2s;}
@keyframes bounce{0%,100%{transform:translateY(0);} 40%{transform:translateY(-8px);}}
#toolbar .spacer{flex:1;}
.tbtn{
border:none; border-radius:12px; padding:9px 14px; font:inherit; font-weight:700;
background:var(--blue); color:#fff; cursor:pointer; font-size:14.5px;
}
.tbtn:hover{background:var(--blue-d);}
.tbtn.ghost{background:#e8eef6; color:var(--ink);}
.tbtn.ghost:hover{background:#dbe5f1;}
#btnSave{min-width:158px; text-align:center;}
#btnSave.ok{background:var(--green); color:#fff;}
#btnUser.on{background:var(--green); color:#fff;}
#langToggle{display:flex; background:#e8eef6; border-radius:12px; overflow:hidden;}
#langToggle button{
border:none; padding:9px 13px; font:inherit; font-weight:700; cursor:pointer;
background:transparent; color:var(--muted); font-size:13.5px;
}
#langToggle button.active{background:var(--blue); color:#fff;}
/* ---------- Board ---------- */
#board{position:absolute; inset:58px 0 0 0; overflow:hidden;}
#board.bg-dots{
background-image:radial-gradient(circle,#c9d6e5 1.4px,transparent 1.4px);
background-size:26px 26px;
}
/* board background line options */
#board.bg-blank{background-image:none;}
#board.bg-lines{background-image:repeating-linear-gradient(#0000 0 39px, #b9cbe3 39px 40px);}
#board.bg-write{background-image:repeating-linear-gradient(
#0000 0 19px,#cddcf0 19px 20px,#0000 20px 39px,#8aa8cf 39px 40.5px,#0000 40.5px 59px,#cddcf0 59px 60px,#0000 60px 79px);}
#board.bg-grid-s{background-image:
repeating-linear-gradient(#0000 0 24px,#cddcf0 24px 25px),
repeating-linear-gradient(90deg,#0000 0 24px,#cddcf0 24px 25px);}
#board.bg-grid-b{background-image:
repeating-linear-gradient(#0000 0 49px,#b9cbe3 49px 50px),
repeating-linear-gradient(90deg,#0000 0 49px,#b9cbe3 49px 50px);}
/* drawing layer on the board: sits ABOVE widgets like a transparency sheet,
so you can annotate over everything; clicks pass through when pen is off */
#boardInk{position:absolute; inset:0; pointer-events:none; z-index:4000;}
body.pen #boardInk{pointer-events:auto; cursor:crosshair;}
body.pen.pen-text #boardInk{cursor:text;}
body.pen.pen-hand #boardInk{pointer-events:none;} /* hand tool: interact with everything */
/* floating add-button (FAB) */
#fab{
position:fixed; right:24px; bottom:24px; z-index:5600; width:60px; height:60px;
border-radius:50%; border:none; background:var(--blue); color:#fff; font-size:28px;
cursor:pointer; box-shadow:0 8px 24px rgba(59,125,216,.45); opacity:.8;
transition:all .2s; line-height:1;
}
#fab:hover{opacity:1; transform:scale(1.1);}
#fab.open{transform:rotate(45deg); opacity:1;}
body.board-empty #fab{opacity:1; animation:fabpulse 2s infinite;}
@keyframes fabpulse{
0%,100%{box-shadow:0 8px 24px rgba(59,125,216,.45);}
50%{box-shadow:0 8px 38px rgba(59,125,216,.9); transform:scale(1.07);}
}
#fabmenu{
position:fixed; right:24px; bottom:96px; z-index:5600; display:none;
flex-direction:column; gap:10px; align-items:flex-end;
}
/* board quick-switch: hover strip with live previews */
#fabBoardsBtn{
position:fixed; right:96px; bottom:31px; z-index:5590; width:46px; height:46px;
border-radius:50%; border:none; background:var(--purple); color:#fff; font-size:19px;
cursor:pointer; box-shadow:0 6px 18px rgba(155,89,182,.45); opacity:.85;
transition:all .2s; line-height:1;
}
#fabBoardsBtn:hover{opacity:1; transform:scale(1.1);}
#boardStrip{
position:fixed; right:16px; bottom:92px; z-index:5590; display:none; gap:10px;
padding:10px; border-radius:16px; background:rgba(255,255,255,.93);
backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
box-shadow:0 10px 28px rgba(30,50,90,.3); border:1px solid rgba(255,255,255,.7);
max-width:calc(100vw - 40px); overflow-x:auto; overflow-y:hidden;
}
#boardStrip.open{display:flex;}
.bs-card{
width:168px; flex:none; background:#fff; border-radius:12px;
border:2.5px solid #e3eaf3; cursor:pointer; overflow:hidden;
}
.bs-card:hover{border-color:var(--blue);}
.bs-card.active{border-color:var(--yellow);}
.bs-card canvas{display:block; width:168px; height:105px; background:#fff;}
.bs-name{
font-size:12px; font-weight:800; padding:5px 8px; display:flex;
justify-content:space-between; align-items:center; gap:6px;
}
.bs-name span{overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.bs-x{
border:none; background:#fbe3e1; color:var(--red); border-radius:7px;
min-width:26px; height:24px; font-size:11px; font-weight:900; cursor:pointer; flex:none;
}
.bs-x:hover{background:var(--red); color:#fff;}
.bs-x.arm{background:var(--red); color:#fff; animation:pop .3s;}
.bs-new{
display:flex; align-items:center; justify-content:center; font-size:30px;
color:#fff; background:var(--green); border-color:var(--green);
width:90px; min-height:130px; font-weight:900;
}
/* folder management panel (save menu) */
#boardsPanel{
position:fixed; right:16px; top:64px; z-index:5600; width:300px; max-height:66vh;
overflow:auto; display:none; flex-direction:column; gap:8px; padding:12px;
border-radius:16px; background:rgba(255,255,255,.96); backdrop-filter:blur(12px);
box-shadow:0 10px 30px rgba(30,50,90,.3); border:1px solid rgba(255,255,255,.7);
}
#boardsPanel.open{display:flex;}
.bp-folder{display:flex; align-items:center; gap:6px; font-weight:800; font-size:14px; margin-top:4px;}
.bp-folder .bpf-name{flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.bp-folder button{
border:none; background:#e8eef6; border-radius:8px; padding:4px 8px; font:inherit;
font-weight:800; font-size:11.5px; cursor:pointer; color:var(--muted); flex:none;
}
.bp-folder button:hover{background:#dbe5f1; color:var(--ink);}
.bp-boards{display:flex; flex-wrap:wrap; gap:6px;}
.bp-board{
display:flex; align-items:center; gap:5px; border:2px solid #e3eaf3; background:#fff;
border-radius:10px; padding:5px 10px; font:inherit; font-weight:700; font-size:12.5px;
cursor:pointer; color:var(--muted);
}
.bp-board.active{border-color:var(--yellow); background:var(--yellow); color:#6b5200;}
.bp-board .bpb-x{border:none; background:transparent; color:var(--red); font-weight:900;
cursor:pointer; padding:0; font-size:11px; opacity:.8;}
.bp-board:hover .bpb-x{opacity:1;}
.bp-board input{border:none; outline:none; font:inherit; width:80px; background:transparent;}
.bp-new{display:flex; gap:6px; margin-top:6px;}
.bp-new input{
flex:1; min-width:0; border:2px solid #cdd9e8; border-radius:10px; padding:7px 9px;
font:inherit; font-size:13px; outline:none;
}
.bp-hint{font-size:11px; color:var(--muted); font-weight:700;}
#fabmenu.open{display:flex;}
.fab-item{
display:flex; align-items:center; gap:10px; border:none;
background:rgba(255,255,255,.92); backdrop-filter:blur(10px);
border-radius:14px; padding:11px 16px; font:inherit; font-weight:800; font-size:14px;
color:var(--ink); cursor:pointer; box-shadow:0 6px 18px rgba(30,50,90,.22);
}
.fab-item:hover{background:#fff; transform:translateX(-4px);}
.fab-item.on{background:var(--yellow);}
.fab-item .fi{font-size:18px;}
/* pen palette (section inside #hud, always visible) */
#penbar{display:flex; gap:6px; align-items:center; flex-wrap:wrap;}
.pen-close{background:#e8eef6 !important; color:var(--ink) !important;}
.pen-close.on{background:var(--yellow) !important; color:#6b5200 !important;}
body:not(.pen) .pen-tools button.on{background:transparent; box-shadow:none;}
.pen-grip{cursor:grab; font-size:15px; color:var(--muted); padding:0 2px; touch-action:none;}
.pen-grip:active{cursor:grabbing;}
#penbar .wp-group{padding:2px;}
#penbar .wp-group button{width:28px; height:28px; font-size:14px;}
#penbar .wp-color i{width:15px; height:15px;}
#penbar .wp-bg i{width:17px; height:17px;}
#penbar .wp-clear{width:28px; height:28px; font-size:13px;}
/* images on the board */
.bimg{
position:absolute; z-index:0; border-radius:10px; touch-action:none; cursor:grab;
box-shadow:0 6px 18px rgba(30,50,90,.25);
}
.bimg:active{cursor:grabbing;}
.bimg img{width:100%; display:block; border-radius:10px; pointer-events:none;}
.bimg .dtool-x,.bimg .bimg-rs{opacity:0; transition:opacity .15s;}
.bimg:hover .dtool-x,.bimg:hover .bimg-rs{opacity:1;}
.bimg-rs{
position:absolute; right:-9px; bottom:-9px; width:20px; height:20px; border-radius:50%;
background:var(--blue); border:2.5px solid #fff; cursor:nwse-resize;
box-shadow:0 2px 6px rgba(0,0,0,.3);
}
body.pen .bimg{pointer-events:none;}
/* frameless (bare) widget for mindmap directly on the board */
.widget.bare{
background:transparent !important; border:none; box-shadow:none !important;
backdrop-filter:none; -webkit-backdrop-filter:none; resize:none; border-radius:0;
pointer-events:none; /* let clicks pass through to widgets/board below */
}
.widget.bare .widget-head,.widget.bare .mm-top,.widget.bare .mm-node,
.widget.bare .mm-pop{pointer-events:auto;}
.widget.bare .widget-head{
position:absolute; top:8px; right:8px; left:auto; width:auto; z-index:6;
background:rgba(255,255,255,.78); border:none; border-radius:12px; padding:5px 8px; gap:6px;
box-shadow:0 4px 12px rgba(30,50,90,.15);
}
.widget.bare .widget-head .wtitle{display:none;}
.widget.bare .widget-body,.widget.bare .mm-stage{background:transparent;}
/* mindmap node settings popover */
.mm-cog{
position:absolute; right:-11px; bottom:-11px; width:22px; height:22px; border-radius:50%;
border:none; background:var(--blue); color:#fff; font-size:11px; cursor:pointer;
line-height:1; display:none; padding:0;
}
.mm-node:hover .mm-cog{display:block;}
.mm-pop{
position:absolute; z-index:20; background:#fff; border-radius:12px; padding:9px 10px;
box-shadow:0 10px 26px rgba(30,50,90,.3); display:flex; flex-direction:column; gap:7px;
}
.mm-pop .mp-row{display:flex; gap:5px; align-items:center;}
.mm-pop .mp-lbl{font-size:11px; font-weight:800; color:var(--muted); width:16px; text-align:center;}
.mm-pop button{border:none; cursor:pointer; padding:0; background:transparent;}
.mm-pop .mp-sw{width:19px; height:19px; border-radius:50%; border:2px solid #fff; box-shadow:0 0 0 1.5px #cdd9e8;}
.mm-pop .mp-sw.on{box-shadow:0 0 0 2.5px var(--ink);}
.mm-pop .mp-fs{font-weight:900; color:var(--ink); width:24px; height:24px; border-radius:7px; background:#e8eef6;}
.mm-pop .mp-fs.on{background:var(--yellow);}
.mm-pop .mp-ls{width:34px; height:24px; border-radius:7px; background:#e8eef6; position:relative;}
.mm-pop .mp-ls.on{background:var(--yellow);}
.mm-pop .mp-ls i{display:block; border-top:2.5px solid var(--ink); width:24px; margin:0 auto;}
.mm-pop .mp-ls[data-ls=dash] i{border-top-style:dashed;}
.mm-pop .mp-ls[data-ls=dot] i{border-top-style:dotted;}
.mm-pop .mp-x{margin-left:auto; font-weight:900; color:var(--red); font-size:12px;}
/* physical tools: ruler, set square, magnifier */
.dtool{position:absolute; z-index:3; touch-action:none; user-select:none; cursor:grab; left:120px; top:120px;}
.dtool:active{cursor:grabbing;}
.dtool .dtool-x{
position:absolute; top:-10px; right:-10px; width:24px; height:24px; border-radius:50%;
border:none; background:#fbe3e1; color:var(--red); font-weight:900; cursor:pointer;
font-size:12px; line-height:1; box-shadow:0 2px 6px rgba(0,0,0,.2); z-index:2;
}
.dtool .dtool-rot{
position:absolute; bottom:-10px; right:-10px; width:26px; height:26px; border-radius:50%;
border:none; background:var(--blue); color:#fff; cursor:grab; font-size:13px;
line-height:1; box-shadow:0 2px 6px rgba(0,0,0,.25); z-index:2;
}
.dtool-ruler{width:600px; height:76px;}
.dtool-geo{width:340px; height:184px;}
.dtool-opts{position:absolute; top:-12px; left:8px; display:flex; gap:5px;}
.dtool-opts button{
width:24px; height:24px; border-radius:50%; border:none; background:#fff;
color:var(--ink); font-size:12px; font-weight:900; cursor:pointer; line-height:1;
box-shadow:0 2px 6px rgba(0,0,0,.25);
}
.dtool-opts button:hover{background:var(--yellow);}
.dtool-lens{z-index:4500;} /* the glass sits above the ink sheet */
.dtool-lens canvas{
border:5px solid #5a6c85; box-shadow:0 8px 22px rgba(0,0,0,.3);
background:#fff; display:block;
}
/* =========== Mindmap widget =========== */
.mm{flex:1; min-height:0; display:flex; flex-direction:column; position:relative;}
.mm-top{
position:absolute; top:8px; left:8px; z-index:7; display:flex; gap:8px; align-items:center;
padding:6px 8px; background:rgba(255,255,255,.85); backdrop-filter:blur(8px);
border-radius:12px; box-shadow:0 4px 12px rgba(30,50,90,.15); cursor:default;
}
.mm-grip{cursor:grab; color:var(--muted); font-size:14px; padding:0 2px; touch-action:none; user-select:none;}
.mm-grip:active{cursor:grabbing;}
.mm-hint{font-size:12px; font-weight:700; color:var(--muted); max-width:180px;}
.mm-stage{flex:1; min-height:0; position:relative; overflow:hidden; background:#fff;}
.mm-stage svg{position:absolute; inset:0; width:100%; height:100%; pointer-events:none;}
.mm-node{
position:absolute; transform:translate(-50%,-50%); background:#fff;
border:2.5px solid var(--blue); padding:8px 16px; font-weight:800; font-size:15px;
cursor:grab; touch-action:none; min-width:64px; text-align:center;
box-shadow:0 3px 10px rgba(30,50,90,.15); border-radius:50px;
}
.mm-node:active{cursor:grabbing;}
.mm-node span{outline:none; cursor:text; display:inline-block; min-width:34px; user-select:text;}
.mm-node span:empty::before{content:"…"; color:#c2cddc;}
.mm-node.root{background:var(--yellow); border-color:#d9a400; font-size:17px; z-index:2;}
.mm.tree .mm-node{border-radius:10px;}
.mm.web .mm-node{border-radius:16px; border-style:dashed;}
.mm-node .mm-add,.mm-node .mm-del{
position:absolute; width:22px; height:22px; border-radius:50%; border:none;
font-size:13px; font-weight:900; cursor:pointer; line-height:1; display:none; padding:0;
}
.mm-node:hover .mm-add,.mm-node:hover .mm-del{display:block;}
.mm-add{right:-11px; top:50%; transform:translateY(-50%); background:var(--green); color:#fff;}
.mm-del{left:-9px; top:-9px; background:#fbe3e1; color:var(--red);}
/* ---------- Widget windows ---------- */
.widget{
position:absolute; background:rgba(255,255,255,.55); border-radius:var(--radius);
backdrop-filter:blur(14px) saturate(1.15); -webkit-backdrop-filter:blur(14px) saturate(1.15);
box-shadow:0 4px 14px rgba(30,50,90,.08); border:1px solid rgba(255,255,255,.6);
display:flex; flex-direction:column;
min-width:280px; min-height:200px; resize:both; overflow:hidden;
transition:background .2s, box-shadow .2s, border-color .2s;
}
.widget:hover,.widget:focus-within{
background:rgba(255,255,255,.92);
box-shadow:0 12px 30px rgba(30,50,90,.2);
border-color:rgba(255,255,255,.9);
}
.widget .wmin,.widget .wclose{opacity:.4; transition:opacity .15s;}
.widget:hover .wmin,.widget:hover .wclose,
.widget:focus-within .wmin,.widget:focus-within .wclose{opacity:1;}
/* unified floating bar: pen palette + dock, draggable, combined when both active */
#hud{
position:fixed; left:50%; bottom:16px; transform:translateX(-50%); z-index:5500;
display:none; gap:8px; align-items:center; flex-wrap:wrap; max-width:94vw;
padding:5px 8px; border-radius:14px;
background:rgba(255,255,255,.72); backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
box-shadow:0 6px 18px rgba(30,50,90,.18); border:1px solid rgba(255,255,255,.7);
}
#hud{display:flex;}
.hud-div{display:none; width:1.5px; height:26px; background:#c3cfde; border-radius:2px; flex:none;}
#hud.has-dock .hud-div{display:block;}
#dock{display:none; gap:8px; align-items:center; flex-wrap:wrap;}
#hud.has-dock #dock{display:flex;}
/* collapse + orientation */
.hud-btn{
border:none; background:#e8eef6; border-radius:8px; width:26px; height:26px;
cursor:pointer; font-size:13px; line-height:1; padding:0; flex:none; font-weight:900; color:var(--ink);
}
.hud-btn:hover{background:#dbe5f1;}
#hud.min #penbar,#hud.min #dock,#hud.min .hud-div{display:none !important;}
#hud.vert{
flex-direction:column; align-items:center; flex-wrap:nowrap;
max-height:calc(100vh - 84px); overflow-y:auto; overflow-x:hidden;
max-width:none; width:92px;
}
#hud.vert #penbar{flex-direction:column; flex-wrap:nowrap; align-items:center;}
#hud.vert #penbar .wp-group{display:grid; grid-template-columns:repeat(2,30px); justify-items:center; align-items:center;}
#hud.vert .hud-div{width:26px; height:1.5px;}
#hud.vert #dock{flex-direction:column; flex-wrap:nowrap;}
.dock-item{
border:none; display:flex; align-items:center; gap:7px; background:#fff;
border-radius:10px; padding:6px 12px; font:inherit; font-weight:700; font-size:13px;
color:var(--ink); cursor:pointer; box-shadow:0 2px 6px rgba(30,50,90,.12);
}
.dock-item:hover{background:#eef3f9; transform:translateY(-2px);}
.dock-item .di{font-size:16px; line-height:1;}
.widget-head{
display:flex; align-items:center; gap:10px; padding:10px 14px;
background:rgba(255,255,255,.4); border-bottom:1px solid rgba(150,170,200,.28);
cursor:grab; flex:none;
}
.widget-head:active{cursor:grabbing;}
.widget-head .wicon{font-size:20px;}
.widget-head .wtitle{font-weight:800; font-size:15px; flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
.wclose{
border:none; background:#fbe3e1; color:var(--red); width:28px; height:28px;
border-radius:9px; font-weight:900; cursor:pointer; font-size:14px; line-height:1; flex:none;
}
.wclose:hover{background:var(--red); color:#fff;}
.wmin{
border:none; background:#e8eef6; color:var(--ink); width:28px; height:28px;
border-radius:9px; font-weight:900; cursor:pointer; font-size:15px; line-height:1; flex:none;
}
.wmin:hover{background:#dbe5f1;}
.wz{font-size:10.5px; width:auto; min-width:30px; padding:0 5px;}
.wz.on{background:var(--yellow); color:#6b5200;}
.widget-body{flex:1; overflow:hidden; position:relative; display:flex; flex-direction:column; min-height:0;}
/* ---------- Modals ---------- */
.modalwrap{
position:fixed; inset:0; z-index:6000; background:rgba(30,45,70,.45);
display:none; align-items:center; justify-content:center;
}
.modalwrap.open{display:flex;}
.modal{
background:var(--panel); border-radius:20px; padding:26px; width:min(680px,92vw);
max-height:88vh; overflow:auto; box-shadow:var(--shadow);
}
.modal h2{margin:0 0 18px; font-size:21px;}
.gallery{display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:14px;}
.gcard{
border:2px solid #e3eaf3; border-radius:16px; padding:18px 14px; text-align:center;
cursor:pointer; transition:all .15s; background:#fbfdff;
}
.gcard:hover{border-color:var(--blue); transform:translateY(-2px); box-shadow:0 6px 18px rgba(59,125,216,.18);}
.gcard .gi{font-size:38px;}
.gcard .gn{font-weight:800; margin-top:8px; font-size:15px;}
.gcard .gd{font-size:12.5px; color:var(--muted); margin-top:4px; line-height:1.35;}
/* login modal */
#loginModal{width:min(400px,92vw);}
#loginModal input{
width:100%; border:2px solid #cdd9e8; border-radius:12px; padding:11px 12px;
font:inherit; font-size:16px; outline:none; margin-bottom:10px;
}
#loginModal input:focus{border-color:var(--blue);}
.lrow{display:flex; gap:8px;}
.lrow .tbtn{flex:1;}
#loginMsg{min-height:20px; font-size:13.5px; font-weight:700; color:var(--red); margin:6px 0;}
#loginMsg.ok{color:var(--green);}
.guestnote{font-size:13px; color:var(--muted); margin-top:12px; line-height:1.45;}
#loggedBox{display:none;}
#loggedBox .hello{font-size:18px; font-weight:800; margin-bottom:14px;}
/* =========================================================
Letter game widget
==========================================================*/
.lg{display:flex; flex-direction:column; flex:1; min-height:0; padding:12px 14px; gap:10px;}
.lg-top{display:flex; gap:8px; align-items:center; flex-wrap:wrap;}
.lg-levels{display:flex; background:#e8eef6; border-radius:12px; overflow:hidden;}
.lg-levels button{
border:none; background:transparent; padding:8px 13px; font:inherit; font-weight:800;
font-size:13.5px; color:var(--muted); cursor:pointer;
}
.lg-levels button.active{background:var(--green); color:#fff;}
.lg-cards{display:flex; gap:5px; flex-wrap:wrap;}
.lg-cards button{
border:2px solid #e3eaf3; background:#fff; width:34px; height:34px; border-radius:10px;
font:inherit; font-weight:800; color:var(--muted); cursor:pointer; font-size:14px;
}
.lg-cards button.active{border-color:var(--yellow); background:var(--yellow); color:#6b5200;}
.lg-cards .lg-theme{
width:auto; padding:0 11px; max-width:120px; overflow:hidden;
text-overflow:ellipsis; white-space:nowrap;
}
.lg-num{
border:2px solid #e3eaf3; border-radius:10px; height:34px; font:inherit;
font-weight:800; font-size:13px; color:var(--muted); background:#fff;
padding:0 4px; outline:none; cursor:pointer;
}
.lg-snd,.lg-mode{
border:2px solid #e3eaf3; border-radius:10px; width:34px; height:34px;
background:#fff; cursor:pointer; font-size:15px; line-height:1; padding:0; flex:none;
}
.lg-snd.off{opacity:.55;}
/* small digit blocks/slots for the maths widgets */
.blok.sm{width:46px; height:74px; border-radius:10px; padding-top:4px;}
.blok.sm .bl{font-size:24px; height:30px; color:var(--purple);}
.blok.sm .dots,.slot.sm .dots{grid-template-columns:repeat(2,7px); grid-template-rows:repeat(3,7px); gap:2.5px;}
.blok.sm .dots i,.slot.sm .dots i{width:7px; height:7px;}
.blok.sm .blok-dots{margin-bottom:7px;}
.slot.sm{width:46px; height:74px; padding-top:4px;}
.slot.sm .ghost{font-size:22px; height:26px;}
.slot.sm .slot-dots{margin-bottom:6px;}
.slot.sm .dots i.off{width:6px; height:6px;}
.mt-slots{display:flex; gap:8px; justify-content:center;}
.mt-tray{gap:8px; padding:10px 8px; min-height:96px;}
.mt.blocks .mt-a,.mt.blocks .mt-pad{display:none;}
.mt:not(.blocks) .mt-slots,.mt:not(.blocks) .mt-tray{display:none;}
/* on-screen letter keyboard (letters widget, type mode) */
.lg-kbd{gap:6px; padding:10px;}
.lg-kbd button{
width:40px; height:46px; border-radius:10px; border:2px solid var(--wood-d);
background:linear-gradient(#fdf3dd,var(--wood)); font:inherit; font-weight:900;
font-size:20px; cursor:pointer; padding:0;
}
.lg-kbd button.v{color:var(--red);}
.lg-kbd button.c{color:var(--blue);}
.lg.typemode .lg-tray.blocks-tray{display:none;}
.lg:not(.typemode) .lg-kbd{display:none;}
.lg-ed-themerow{display:flex; gap:8px; align-items:center;}
.ed-theme-sel{
flex:1; min-width:0; border:2px solid #cdd9e8; border-radius:10px; padding:9px 10px;
font:inherit; font-weight:700; font-size:15px; outline:none; background:#fff; cursor:pointer;
}
.ed-theme-name{
border:2px solid var(--blue); border-radius:10px; padding:9px 10px;
font:inherit; font-size:15px; outline:none; width:180px;
}
.ed-theme-del{
border:none; background:#fbe3e1; color:var(--red); border-radius:10px;
width:40px; height:40px; cursor:pointer; font-size:15px; flex:none;
}
.ed-theme-del:hover{background:var(--red); color:#fff;}
.lg-progress{margin-left:auto; font-size:13.5px; font-weight:700; color:var(--muted); display:flex; align-items:center; gap:8px; white-space:nowrap;}
.lg-stars{color:#d9a400; font-size:14px; min-width:34px; text-align:right;}
.lg-instr{
background:#f1f7ff; border:1.5px dashed #bcd4f2;
border-radius:12px; padding:6px 14px; font-size:14.5px; font-weight:700; color:#37588a;
text-align:center; min-height:42px; display:flex; align-items:center; justify-content:center;
flex:none;
}
.lg-card{
flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:16px; background:linear-gradient(#ffffff,#f6fafe); border:2px solid #e3eaf3;
border-radius:18px; padding:12px; min-height:0; position:relative; overflow:hidden;
}
.lg-pic{font-size:64px; line-height:1; filter:drop-shadow(0 4px 6px rgba(0,0,0,.12));}
.lg-pic img{width:80px; height:80px; object-fit:contain; border-radius:12px; vertical-align:middle;}
.lg-slots{display:flex; gap:10px;}
.slot{
width:64px; height:98px; border-radius:12px; position:relative;
background:#e9eef5; border:2.5px dashed #b9c7d9;
display:flex; flex-direction:column; align-items:center; justify-content:flex-start;
padding-top:6px; transition:all .12s;
}
.slot.droptarget{border-color:var(--green); background:#e3f6ea; transform:scale(1.06);}
.slot .ghost{font-size:30px; font-weight:800; color:#c2cddc; height:40px; display:flex; align-items:center;}
.slot.filled{border-style:solid; border-color:transparent; background:transparent; padding:0;}
.slot.filled .ghost,.slot.filled .slot-dots{display:none;}
.slot.shake{animation:shake .35s;}
@keyframes shake{
0%,100%{transform:translateX(0);} 20%{transform:translateX(-7px);}
40%{transform:translateX(7px);} 60%{transform:translateX(-5px);} 80%{transform:translateX(5px);}
}
.blok{
width:64px; height:98px; border-radius:12px; cursor:grab; touch-action:none;
display:flex; flex-direction:column; align-items:center; padding-top:6px;
background:linear-gradient(#fdf3dd,var(--wood)); border:2.5px solid var(--wood-d);
box-shadow:0 4px 0 var(--wood-d), 0 7px 14px rgba(60,40,0,.16);
position:relative; flex:none;
}
.blok:active{cursor:grabbing;}
.blok .bl{font-size:34px; font-weight:900; height:44px; display:flex; align-items:center;}
.blok.vowel .bl{color:var(--red);}
.blok.cons .bl{color:var(--blue);}
.blok .blok-dots{margin-top:auto; margin-bottom:10px;}
.blok.in-slot{cursor:pointer; animation:pop .3s;}
.blok.dragging{opacity:.25;}
@keyframes pop{0%{transform:scale(.6);} 60%{transform:scale(1.12);} 100%{transform:scale(1);}}
.slot .slot-dots{margin-top:auto; margin-bottom:9px;}
.dots{display:grid; grid-template-columns:repeat(2,9px); grid-template-rows:repeat(3,9px); gap:3px;}
.dots i{width:9px; height:9px; border-radius:50%; background:transparent;}
.blok .dots i.on{background:#b98a3c; box-shadow:inset 0 -1.5px 2px rgba(0,0,0,.35), 0 1px 0 rgba(255,255,255,.5);}
.blok .dots i.off{background:rgba(0,0,0,.05);}
.slot .dots i.on{background:#aebcce; box-shadow:inset 0 2px 3px rgba(30,50,80,.4);}
.slot .dots i.off{background:transparent; border:1px dotted #ccd7e4; width:7px; height:7px;}
.lg-tray{
display:flex; gap:12px; justify-content:center; flex-wrap:wrap;
background:#eef3f9; border-radius:16px; padding:14px 12px; min-height:130px; flex:none;
}
.lg-bottom{display:flex; align-items:center; justify-content:center; gap:12px; min-height:46px; flex:none;}
.lg-next{
border:none; border-radius:12px; padding:10px 22px; font:inherit; font-weight:800;
background:var(--green); color:#fff; cursor:pointer; font-size:15.5px; display:none;
}
.lg-next.show{display:inline-block; animation:pop .3s;}
.lg-done{
position:absolute; inset:0; border-radius:18px; display:none; align-items:center;
justify-content:center; flex-direction:column; gap:6px;
background:rgba(255,255,255,.88); backdrop-filter:blur(2px); z-index:5;
}
.lg-done.show{display:flex;}
.lg-done .dg{font-size:52px;}
.lg-done .dt{font-size:24px; font-weight:900; color:var(--green);}
.confetti{position:absolute; width:10px; height:10px; border-radius:3px; top:-12px; z-index:6; pointer-events:none; animation:fall linear forwards;}
@keyframes fall{to{transform:translateY(110vh) rotate(720deg);}}
#dragClone{
position:fixed; z-index:9000; pointer-events:none; transform:translate(-50%,-60%) scale(1.08);
filter:drop-shadow(0 12px 16px rgba(0,0,0,.28));
}
/* ---------- custom words editor ---------- */
.lg-editor{
position:absolute; inset:0; z-index:20; background:#fff; display:none;
flex-direction:column; gap:10px; padding:14px 16px; overflow:auto;
}
.lg-editor.open{display:flex;}
.lg-ed-head{display:flex; align-items:center; justify-content:space-between;}
.lg-ed-head b{font-size:17px;}
.lg-ed-form{
display:flex; gap:10px; align-items:center; flex-wrap:wrap;
background:#f1f7ff; border-radius:14px; padding:12px;
}
.lg-ed-form input[type=text]{
border:2px solid #cdd9e8; border-radius:10px; padding:9px 10px; font:inherit;
font-size:16px; outline:none;
}
.lg-ed-form input.ed-word{width:170px; text-transform:lowercase;}
.ed-preview{width:52px; height:52px; display:flex; align-items:center; justify-content:center;
font-size:34px; background:#fff; border-radius:10px; border:2px dashed #cdd9e8; overflow:hidden; flex:none;}
.ed-preview img{width:100%; height:100%; object-fit:contain;}
.ed-add{border:none; border-radius:10px; padding:11px 20px; font:inherit; font-weight:800;
background:var(--green); color:#fff; cursor:pointer; font-size:14.5px;}
.ed-msg{width:100%; color:var(--red); font-weight:700; font-size:13.5px; min-height:16px;}
/* picture picker tabs */
.ed-tabs{display:flex; gap:6px;}
.ed-tabs button{
border:none; border-radius:10px 10px 0 0; padding:9px 16px; font:inherit; font-weight:800;
font-size:13.5px; background:#e8eef6; color:var(--muted); cursor:pointer;
}
.ed-tabs button.active{background:var(--yellow); color:#6b5200;}
.ed-pane{background:#fbfdff; border:2px solid #e3eaf3; border-radius:0 12px 12px 12px; padding:12px; min-height:120px;}
.ed-cats{display:flex; gap:5px; flex-wrap:wrap; margin-bottom:10px;}
.ed-cats button{
border:none; background:#e8eef6; border-radius:9px; padding:6px 11px; font:inherit;
font-weight:800; font-size:12.5px; color:var(--muted); cursor:pointer;
}
.ed-cats button.active{background:var(--blue); color:#fff;}
.ed-grid{display:flex; gap:4px; flex-wrap:wrap; max-height:170px; overflow:auto;}
.ed-grid button{
border:none; background:transparent; border-radius:9px; font-size:26px;
width:42px; height:42px; cursor:pointer; padding:0; line-height:1;
}
.ed-grid button:hover{background:#dbe8f8;}
.ed-grid button.sel{background:var(--yellow);}
.ed-pictorow{display:flex; gap:8px; margin-bottom:10px;}
.ed-pictorow input{flex:1; border:2px solid #cdd9e8; border-radius:10px; padding:9px 10px; font:inherit; font-size:15px; outline:none;}
.ed-pictorow .tbtn{flex:none;}
.ed-pgrid{display:flex; gap:8px; flex-wrap:wrap; max-height:180px; overflow:auto;}
.ed-pgrid button{
border:2px solid #e3eaf3; background:#fff; border-radius:12px; width:74px; height:74px;
cursor:pointer; padding:5px;
}
.ed-pgrid button:hover{border-color:var(--blue);}
.ed-pgrid button.sel{border-color:var(--yellow); background:#fff8e1;}
.ed-pgrid img{width:100%; height:100%; object-fit:contain;}
.ed-credit{font-size:11.5px; color:var(--muted); margin-top:8px; line-height:1.4;}
.ed-credit a{color:var(--blue);}
.ed-status{font-size:13.5px; font-weight:700; color:var(--muted); padding:6px 0;}
.ed-upload{background:#e8eef6; border-radius:10px; padding:11px 16px; font-weight:700;
font-size:14px; cursor:pointer; display:inline-block;}
.ed-upload input{display:none;}
.ed-uphint{font-size:12.5px; color:var(--muted); margin-top:10px; line-height:1.45;}
.lg-ed-list{display:flex; flex-direction:column; gap:6px;}
.lg-ed-list .empty{color:var(--muted); font-weight:700; text-align:center; padding:18px 0;}
.ed-row{display:flex; align-items:center; gap:12px; background:#f7fafd; border-radius:12px; padding:7px 12px;}
.ed-row .ep{width:40px; height:40px; font-size:28px; display:flex; align-items:center; justify-content:center;}
.ed-row .ep img{width:100%; height:100%; object-fit:contain; border-radius:8px;}
.ed-row .ew{font-weight:800; font-size:17px; letter-spacing:2px; flex:1;}
.ed-row .edel{border:none; background:#fbe3e1; color:var(--red); border-radius:8px;
width:30px; height:30px; cursor:pointer; font-weight:900;}
.ed-row .edel:hover{background:var(--red); color:#fff;}
.ed-row.base{opacity:.65;}
/* =========== Notes widget =========== */
.notes{flex:1; width:100%; min-height:0; border:none; resize:none; font:inherit; font-size:16px;
padding:14px; outline:none; background:#fffdf2; color:var(--ink);}
/* =========== Writing pad widget =========== */
.wp{flex:1; min-height:0; display:flex; flex-direction:column;}
.wp-bar{
display:flex; gap:10px; padding:8px 10px; flex-wrap:wrap; align-items:center;
flex:none; background:#f7fafd; border-bottom:1px solid #e3eaf3;
}
.wp-group{display:flex; gap:4px; align-items:center; background:#e8eef6; border-radius:10px; padding:3px;}
.wp-group button{
border:none; background:transparent; border-radius:8px; width:32px; height:32px;
cursor:pointer; font-size:16px; line-height:1; padding:0;
display:flex; align-items:center; justify-content:center;
}
.wp-group button.on{background:#fff; box-shadow:0 1px 4px rgba(30,50,90,.2);}
.wp-color{border-radius:50% !important; position:relative;}
.wp-color i{width:18px; height:18px; border-radius:50%; display:block;}
.wp-color.on{outline:2.5px solid var(--ink); outline-offset:-3px;}
.wp-size i{border-radius:50%; background:var(--ink); display:block;}
.wp-bg{font-size:0 !important;}
.wp-bg i{
width:20px; height:20px; display:block; border-radius:4px; border:1.5px solid #9db6d8;
background-color:#fff; background-size:100% 25%;
}
.wp-bg[data-bg=blank] i{background-image:none;}
.wp-bg[data-bg=dots] i{background-image:radial-gradient(circle,#9db6d8 1px,transparent 1px); background-size:5px 5px;}
.wp-bg[data-bg=lines] i{background-image:repeating-linear-gradient(#0000 0 4px, #9db6d8 4px 5px);}
.wp-bg[data-bg=write] i{background-image:repeating-linear-gradient(#0000 0 2px, #c3d5ec 2px 3px, #0000 3px 5px, #6d92c4 5px 6px);}
.wp-bg[data-bg=grid-s] i{background-image:repeating-linear-gradient(#0000 0 4px, #9db6d8 4px 5px),repeating-linear-gradient(90deg,#0000 0 4px, #9db6d8 4px 5px);}
.wp-bg[data-bg=grid-b] i{background-image:repeating-linear-gradient(#0000 0 8px, #9db6d8 8px 9px),repeating-linear-gradient(90deg,#0000 0 8px, #9db6d8 8px 9px);}
.wp-clear{border:none; background:#fbe3e1; color:var(--red); border-radius:10px;
width:34px; height:34px; cursor:pointer; font-size:15px; margin-left:auto;}
.wp-clear:hover{background:var(--red); color:#fff;}
.wp-stage{flex:1; min-height:0; position:relative; background:#fff;}
.wp-stage canvas{position:absolute; inset:0; touch-action:none; cursor:crosshair;}
.wp-stage.t-text canvas{cursor:text;}
.wp-text-input{
position:absolute; z-index:5; background:rgba(255,255,255,.85);
border:1.5px dashed var(--blue); border-radius:6px; padding:2px 6px;
font-family:inherit; font-weight:700; outline:none; min-width:130px;
}
/* paper backgrounds (CSS, so the eraser only removes ink) */
.wp-stage.bg-lines{background-image:repeating-linear-gradient(#0000 0 31px, #aec5e4 31px 32px);}
.wp-stage.bg-write{background-image:repeating-linear-gradient(
#0000 0 15px, #c3d5ec 15px 16px, #0000 16px 31px, #6d92c4 31px 32.5px, #0000 32.5px 47px, #c3d5ec 47px 48px, #0000 48px 63px);}
.wp-stage.bg-grid-s{background-image:
repeating-linear-gradient(#0000 0 19px, #c9d9ee 19px 20px),
repeating-linear-gradient(90deg,#0000 0 19px, #c9d9ee 19px 20px);}
.wp-stage.bg-grid-b{background-image:
repeating-linear-gradient(#0000 0 39px, #aec5e4 39px 40px),
repeating-linear-gradient(90deg,#0000 0 39px, #aec5e4 39px 40px);}
/* =========== Media widget =========== */
.media{flex:1; min-height:0; display:flex; flex-direction:column;}
.media-bar{
display:flex; gap:8px; padding:8px 10px; flex:none;
background:#f7fafd; border-bottom:1px solid #e3eaf3;
}
.media-bar input{
flex:1; min-width:0; border:2px solid #cdd9e8; border-radius:10px;
padding:8px 10px; font:inherit; font-size:14px; outline:none;
}
.media-bar input:focus{border-color:var(--blue);}
.media-stage{
flex:1; min-height:0; background:#141a24; display:flex;
align-items:center; justify-content:center; overflow:hidden;
}
.media-stage iframe{width:100%; height:100%; border:none;}
.media-stage img,.media-stage video{max-width:100%; max-height:100%;}
.media-msg{color:#93a4bd; font-weight:700; font-size:14px; text-align:center; padding:20px; line-height:1.5;}
.media-yt{position:relative; width:100%; height:100%; display:flex; align-items:center; justify-content:center;}
.media-yt img{max-width:100%; max-height:100%;}
.media-play{
position:absolute; border:none; width:76px; height:54px; border-radius:14px;
background:rgba(224,60,50,.95); color:#fff; font-size:24px; cursor:pointer;
display:flex; align-items:center; justify-content:center;
box-shadow:0 6px 18px rgba(0,0,0,.4);
}
.media-play:hover{transform:scale(1.08);}
/* =========== Math widgets =========== */
.mt{flex:1; min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px 14px;}
.mt-top{display:flex; gap:8px; align-items:center; flex:none;}
.mt-score{margin-left:auto; font-size:13.5px; font-weight:700; color:var(--muted); white-space:nowrap;}
.mt-card{
flex:1; min-height:0; display:flex; flex-direction:column; align-items:center;
justify-content:center; gap:12px; background:linear-gradient(#fff,#f6fafe);
border:2px solid #e3eaf3; border-radius:18px; position:relative; padding:10px; overflow:hidden;
}
.mt-q{font-size:40px; font-weight:900; letter-spacing:1px; text-align:center;}
.mt-a{
min-width:110px; height:56px; border-radius:14px; border:2.5px dashed #b9c7d9;
background:#eef3f9; font-size:32px; font-weight:900; display:flex; align-items:center;
justify-content:center; color:var(--blue); padding:0 16px;
}
.mt-a.ok{border-style:solid; border-color:var(--green); background:#e3f6ea; color:var(--green);}
.mt-a.shake{animation:shake .35s;}
.mt-fb{min-height:24px; font-size:16px; font-weight:800; color:var(--green); text-align:center;}
.mt-fb.ans{color:var(--red);}
.mt-pad{display:grid; grid-template-columns:repeat(4,1fr); gap:8px; flex:none;}
.mt-pad button{
border:none; border-radius:12px; background:#e8eef6; font:inherit; font-weight:900;
font-size:22px; padding:12px 0; cursor:pointer; color:var(--ink);
}
.mt-pad button:hover{background:#dbe5f1;}
.mt-pad .ok{background:var(--green); color:#fff;}
.mt-done{
position:absolute; inset:0; border-radius:18px; display:none; flex-direction:column;
align-items:center; justify-content:center; gap:8px; background:rgba(255,255,255,.9);
backdrop-filter:blur(2px); z-index:5;
}
.mt-done.show{display:flex;}
.mt-done .dg{font-size:46px;}
.mt-done .dt{font-size:22px; font-weight:900; color:var(--green);}
.mt-done .ds{font-size:16px; font-weight:800; color:var(--muted);}
.mt-again{
border:none; border-radius:12px; padding:10px 22px; background:var(--green);
color:#fff; font:inherit; font-weight:800; font-size:15px; cursor:pointer; margin-top:6px;
}
/* =========== high-contrast accessibility mode =========== */
body.hc{--bg:#fff; --ink:#000; --muted:#2b2b2b; --blue:#0047ab; --red:#c40000;
--green:#006b2d; --yellow:#ffd400; --wood:#ffe9b0; --wood-d:#8a6a00;}
body.hc #board.bg-dots{background-image:radial-gradient(circle,#7d8ea3 1.7px,transparent 1.7px);}
body.hc .widget{background:#fff; border:2.5px solid #000; box-shadow:0 4px 14px rgba(0,0,0,.35); backdrop-filter:none;}
body.hc .widget:hover,.body.hc .widget:focus-within{background:#fff;}
body.hc .widget .wmin,body.hc .widget .wclose{opacity:1;}
body.hc .widget-head{background:#f2f2f2; border-bottom:2px solid #000;}
body.hc .slot{border-color:#000; background:#fff;}
body.hc .slot .ghost{color:#666;}
body.hc .slot .dots i.on{background:#333;}
body.hc .blok .dots i.on{background:#6a4a00;}
body.hc .lg-instr{background:#fff; border-color:#000; color:#000;}
body.hc #hud,body.hc .dock-item,body.hc .fab-item,body.hc #penbar .wp-group{border:2px solid #000; background:#fff;}
body.hc #fab{background:#0047ab;}
body.hc :focus-visible{outline:3px solid #0047ab; outline-offset:2px;}
body.hc .mm-node{box-shadow:0 3px 10px rgba(0,0,0,.4); border-width:3px;}
#btnHC.on{background:var(--yellow); color:#000;}
/* high contrast inside every widget */
body.hc .lg-levels,body.hc .tseg{background:#fff; border:2px solid #000; border-radius:12px;}
body.hc .lg-levels button,body.hc .tseg button{color:#000;}
body.hc .lg-levels button.active{background:#006b2d; color:#fff;}
body.hc .lg-cards button,body.hc .lg-num,body.hc .fl-sel,body.hc .lg-snd,body.hc .lg-mode{
border-color:#000; color:#000; background:#fff;
}
body.hc .lg-cards button.active{background:#ffd400; border-color:#000; color:#000;}
body.hc .lg-tray{background:#fff; border:2px dashed #000;}
body.hc .lg-card,body.hc .mt-card{background:#fff; border-color:#000;}
body.hc .lg-progress,body.hc .mt-score,body.hc .mm-hint,body.hc .dc-sum{color:#000;}
body.hc .lg-stars{color:#7a5d00;}
body.hc .mt-a{border-color:#000; background:#fff;}
body.hc .mt-pad button{background:#fff; border:2px solid #000; color:#000;}
body.hc .mt-pad .ok{background:#006b2d; border-color:#006b2d; color:#fff;}
body.hc .mt-fb{color:#006b2d;}
body.hc .mt-fb.ans{color:#c40000;}
body.hc .lg-kbd button,body.hc .blok{border-color:#6a4a00;}
body.hc .fl-word.dim{color:#8a8a8a;}
body.hc .slot .dots i.off{border-color:#666;}
body.hc .np textarea,body.hc .media-bar input,body.hc .ed-theme-sel,
body.hc .lg-ed-form input[type=text],body.hc .ed-pictorow input{border-color:#000; color:#000;}
body.hc .np-result{background:#0047ab;}
body.hc .np-row label,body.hc .thint,body.hc .guestnote,body.hc .ed-credit,
body.hc .bp-hint,body.hc .gcard .gd{color:#222;}
body.hc .gcard{border-color:#000; background:#fff;}
body.hc .gcat-title{color:#000;}
body.hc .tbtn.ghost{background:#fff; border:2px solid #000; color:#000;}
body.hc .wmin,body.hc .wclose{border:1.5px solid #000; background:#fff;}
body.hc .wclose{color:#c40000;}
body.hc .wz.on,body.hc #zoomPct.on{background:#ffd400; color:#000;}
body.hc .ed-tabs button,body.hc .ed-cats button{color:#000; background:#fff; border:1.5px solid #000;}
body.hc .ed-tabs button.active,body.hc .ed-cats button.active{background:#ffd400;}
body.hc .ed-pane{border-color:#000; background:#fff;}
body.hc .timer .presets button,body.hc .timer .main button,body.hc .ticon,
body.hc .hud-btn,body.hc .mm-seg button{background:#fff; border:1.5px solid #000; color:#000;}
body.hc .timer .main .bstart{background:#006b2d; border-color:#006b2d; color:#fff;}
body.hc .bp-board{border-color:#000; color:#000;}
body.hc .bp-board.active{background:#ffd400;}
body.hc .dock-item{color:#000;}
body.hc .media-msg{color:#ddd;}
/* =========== Flash words widget =========== */
.fl-word{font-size:58px; font-weight:900; letter-spacing:3px; min-height:70px; display:flex; align-items:center;}
.fl-word.dim{color:#c2cddc;}
.fl-pic{font-size:46px; min-height:56px; display:flex; align-items:center;}
.fl-pic img{width:56px; height:56px; object-fit:contain;}
.fl-sel{
border:2px solid #e3eaf3; border-radius:10px; height:34px; font:inherit;
font-weight:800; font-size:13px; color:var(--muted); background:#fff;
padding:0 6px; outline:none; cursor:pointer;
}
/* =========== Chop & blend widget =========== */
.hk-word{min-height:70px; display:flex; align-items:center; gap:4px; flex-wrap:wrap; justify-content:center;}
.hk-whole{font-size:52px; font-weight:900; letter-spacing:3px;}
.hk-l{font-size:46px; font-weight:900; animation:pop .4s both;}
.hk-l.v{color:var(--red);}
.hk-l.c{color:var(--blue);}
.hk-sep{font-size:30px; color:var(--muted); font-weight:700; margin:0 3px;}
/* =========== Balloon word widget =========== */
.bl-balloons{font-size:34px; letter-spacing:4px; min-height:44px;}
.bl-word{font-size:44px; font-weight:900; letter-spacing:6px; min-height:56px; display:flex; align-items:center;}
.bl-msg{min-height:26px; font-size:17px; font-weight:800;}
.bl-msg.win{color:var(--green);}
.bl-msg.lose{color:var(--red);}
.bl-kbd button:disabled{cursor:default;}
/* =========== Anchor words widget =========== */
.an{flex:1; min-height:0; display:flex; flex-direction:column;}
.an-hint{
flex:none; font-size:11.5px; font-weight:700; color:var(--muted);
padding:6px 10px; background:#f7fafd; border-bottom:1px solid #e3eaf3;
}
.an-stage{flex:1; min-height:0; position:relative; overflow:hidden; background:#fff; cursor:grab; touch-action:none;}
.an-stage:active{cursor:grabbing;}
.an-layer{position:absolute; inset:0;}
.an-cell{
position:absolute; width:128px; height:56px; border-radius:12px; border:2px dashed #dbe4f0;
display:flex; align-items:center; justify-content:center; font-weight:800; font-size:15px;
color:var(--ink); padding:2px 8px; overflow:hidden; text-align:center; cursor:pointer;
}
.an-cell:hover{border-color:#b6c6dc;}
.an-cell.filled{border-style:solid; border-color:#cdd7e4; background:#fff; box-shadow:0 3px 8px rgba(30,50,90,.12);}
.an-cell.root{border:2.5px solid #d9a400; background:#fff8dd;}
.an-cell.root.filled{background:var(--yellow); font-size:16px;}
.an-cell input{width:100%; border:none; outline:none; font:inherit; text-align:center; background:transparent;}
.an-word{overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:100%;}
.an-dot{
position:absolute; right:4px; bottom:4px; width:15px; height:15px; border-radius:50%;
border:1.5px solid #fff; box-shadow:0 1px 3px rgba(0,0,0,.3); cursor:pointer; padding:0;
opacity:.55; transition:opacity .15s;
}
.an-cell:hover .an-dot{opacity:1;}
body.hc .an-cell{border-color:#555; color:#000;}
/* =========== Dice widget =========== */
.dc{flex:1; min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px; align-items:center;}
.dc-row{flex:1; display:flex; gap:14px; align-items:center; justify-content:center; flex-wrap:wrap; cursor:pointer;}
.die{
width:92px; height:92px; background:#fff; border:3.5px solid var(--ink); border-radius:18px;
display:grid; grid-template-columns:repeat(3,1fr); grid-template-rows:repeat(3,1fr);
padding:10px; box-shadow:0 5px 0 rgba(0,0,0,.15);
}
.die.rolling{animation:shake .3s infinite;}
.die i{width:16px; height:16px; border-radius:50%; background:var(--ink); place-self:center; visibility:hidden;}
.die i.on{visibility:visible;}
.dc-sum{font-size:22px; font-weight:900; min-height:28px;}
/* =========== Traffic light widget =========== */
.tl{flex:1; min-height:0; display:flex; align-items:center; justify-content:center; padding:14px;}
.tl-house{background:#22272f; border-radius:26px; padding:20px 18px; display:flex; flex-direction:column; gap:16px; box-shadow:0 10px 26px rgba(0,0,0,.35);}
.tl-lamp{
width:100px; height:100px; border-radius:50%; border:none; cursor:pointer;
opacity:.22; transition:all .2s; padding:0;
}
.tl-lamp.red{background:#e33; color:#e33;}
.tl-lamp.orange{background:#f5a623; color:#f5a623;}
.tl-lamp.green{background:#2ecc71; color:#2ecc71;}
.tl-lamp.on{opacity:1; box-shadow:0 0 46px currentColor;}
.tl-mini{padding:12px 10px; gap:10px; border-radius:20px;}
.tl-mini .tl-lamp{width:62px; height:62px;}
/* =========== Name picker widget =========== */
.np{flex:1; min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px;}
.np textarea{
flex:1; min-height:0; border:2px solid #cdd9e8; border-radius:12px; padding:10px;
font:inherit; font-size:14.5px; resize:none; outline:none;
}
.np-result{
min-height:72px; display:flex; align-items:center; justify-content:center;
font-size:32px; font-weight:900; color:#fff; text-align:center; padding:8px 12px;
background:linear-gradient(135deg,var(--blue),var(--purple));
border-radius:18px; position:relative; overflow:hidden;
text-shadow:0 2px 6px rgba(0,0,0,.3); letter-spacing:1px;
}
.np-result.spin{animation:npspin .25s infinite; background:linear-gradient(135deg,var(--orange),var(--red));}
.np-result.winner{animation:pop .45s;}
@keyframes npspin{0%,100%{transform:scale(1) rotate(-1deg);} 50%{transform:scale(1.04) rotate(1deg);}}
.np-row{display:flex; gap:10px; align-items:center; flex:none;}
.np-row label{font-size:13px; font-weight:700; color:var(--muted); display:flex; gap:5px; align-items:center; margin-left:auto;}
/* gallery categories */
.gcat-title{font-size:13.5px; font-weight:800; color:var(--muted); margin:16px 0 8px; text-transform:uppercase; letter-spacing:.5px;}
.gcat-title:first-child{margin-top:0;}
/* =========== Timer widget =========== */
.timer{display:flex; flex-direction:column; align-items:center; flex:1; min-height:0; gap:10px; padding:14px;}
.tstage{
flex:1; min-height:0; width:100%; display:flex; flex-direction:column;
align-items:center; justify-content:center; gap:8px;
}
.topts{display:flex; justify-content:space-between; align-items:center; width:100%; flex:none;}
.tseg{display:flex; background:#e8eef6; border-radius:10px; overflow:hidden;}
.tseg button{border:none; background:transparent; padding:7px 13px; font-size:15px; cursor:pointer; line-height:1;}
.tseg button.on{background:var(--blue); color:#fff;}
.mm-seg button{font:inherit; font-weight:800; font-size:13px;}
.ticon{border:none; background:#e8eef6; border-radius:10px; padding:7px 11px; font-size:15px; cursor:pointer; line-height:1;}
.ticon.on{background:var(--yellow);}
.timer .tpie{
flex:1 1 0; min-height:80px; aspect-ratio:1/1; width:auto; max-width:100%; max-height:220px;
display:none; cursor:grab; touch-action:none;
}
.timer .tpie:active{cursor:grabbing;}
.timer.m-pie .tpie,.timer.m-both .tpie{display:block;}
.timer .tdisp{font-size:46px; font-weight:900; font-variant-numeric:tabular-nums; flex:none;}
.timer.m-both .tdisp{font-size:26px;}
.timer.m-pie .tdisp{font-size:17px; color:var(--muted);}
.timer .tdisp.warn{color:var(--red); animation:blink 1s infinite;}
@keyframes blink{50%{opacity:.35;}}
.timer .trow{display:flex; gap:8px; flex-wrap:wrap; justify-content:center; flex:none;}
.thint{flex:none;}
.timer .presets button{
border:none; border-radius:999px; padding:6px 13px; background:#e8eef6; font:inherit;
font-weight:800; font-size:12.5px; color:var(--muted); cursor:pointer;
}
.timer .presets button:hover{background:#dbe5f1; color:var(--ink);}
.timer .main button{
border:none; border-radius:12px; padding:10px 26px; font:inherit; font-weight:800;
font-size:15.5px; cursor:pointer; background:#e8eef6; color:var(--ink);
}
.timer .main .bstart{background:var(--green); color:#fff; min-width:110px;}
.thint{font-size:11.5px; color:var(--muted); text-align:center; line-height:1.4;}
.timer.flash{animation:tflash .5s 4;}
@keyframes tflash{50%{background:#fdeaa0;}}

File diff suppressed because it is too large Load diff

55
public/js/app.js Normal file
View file

@ -0,0 +1,55 @@
/* teach - start: beeldgrootte, contrast, sessie-herstel */
/* =========================================================
init
==========================================================*/
/* display size: scale the whole app up for pupils who need it (device setting) */
let ZOOM = 100;
const btnZoomIn = document.getElementById("btnZoomIn");
const btnZoomOut = document.getElementById("btnZoomOut");
const zoomPct = document.getElementById("zoomPct");
function setZoom(z){
ZOOM = Math.max(100, Math.min(200, Math.round(z/10)*10));
document.body.style.zoom = ZOOM/100;
zoomPct.textContent = ZOOM+"%";
zoomPct.classList.toggle("on", ZOOM!==100);
btnZoomIn.title = btnZoomOut.title = zoomPct.title = T("zoomUI");
btnZoomIn.disabled = ZOOM>=200;
btnZoomOut.disabled = ZOOM<=100;
try{ localStorage.setItem("teach.zoom", ZOOM); }catch(e){}
}
btnZoomIn.addEventListener("click", ()=>setZoom(ZOOM+10));
btnZoomOut.addEventListener("click", ()=>setZoom(ZOOM-10));
zoomPct.addEventListener("click", ()=>setZoom(100)); /* tap the % to reset */
document.addEventListener("langchange", ()=>{ btnZoomIn.title = btnZoomOut.title = zoomPct.title = T("zoomUI"); });
try{ const z = +localStorage.getItem("teach.zoom"); if(z>100) setZoom(z); else setZoom(100); }catch(e){ setZoom(100); }
/* high-contrast accessibility mode (device setting, persists) */
const btnHC = document.getElementById("btnHC");
function setHC(on){
document.body.classList.toggle("hc", on);
btnHC.classList.toggle("on", on);
btnHC.title = T("a11y");
try{ localStorage.setItem("teach.hc", on ? "1" : ""); }catch(e){}
}
btnHC.addEventListener("click", ()=>setHC(!document.body.classList.contains("hc")));
document.addEventListener("langchange", ()=>{ btnHC.title = T("a11y"); });
try{ if(localStorage.getItem("teach.hc")==="1") setHC(true); }catch(e){}
applyI18n();
updateEmptyHint();
/* restore session (same browser tab session only) */
(function(){
let name = null;
try{ name = sessionStorage.getItem("teach.session"); }catch(e){}
const users = loadUsers();
if(name && users[name]){
currentUser = { name, data: users[name].data };
THEMES = themesFromData(currentUser.data);
GENERAL_EXTRA = generalExtraFromData(currentUser.data);
updateUserUI();
BS = boardsFromData(currentUser.data);
loadCurrentSlot();
}else{
updateBoardsUI();
}
})();

1064
public/js/board.js Normal file

File diff suppressed because it is too large Load diff

392
public/js/core.js Normal file
View file

@ -0,0 +1,392 @@
/* teach - kern: versie, i18n, accounts, opslag, geluid */
"use strict";
/* version — bump on every change (form 0.0.00) */
const VERSION = "0.1.02";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
/* dev branch deploys report a dev- build via /api/version mark as beta;
main/release builds show only the version number */
fetch("/api/version")
.then(r=>r.ok ? r.json() : null)
.then(v=>{
if(v && typeof v.version==="string" && v.version.startsWith("dev")){
tag.textContent = "v"+VERSION+" · beta";
tag.classList.add("beta");
}
})
.catch(()=>{});
})();
/* =========================================================
i18n
==========================================================*/
const I18N = {
nl:{
addWidget:"Widget", chooseWidget:"Kies een widget", saveBoard:"Bord opslaan",
emptyBoard:"Het bord is leeg. Klik op “Widget” om te beginnen.",
wg_letters:"Letterblokken", wg_letters_d:"Leer lezen en spellen met letterblokken 3 niveaus, zoals Ik leer lezen.",
wg_notes:"Notities", wg_notes_d:"Een vel om op te schrijven.",
wg_timer:"Timer", wg_timer_d:"Afteltimer met kijkklok voor de klas.",
wg_write:"Schrijfblad", wg_write_d:"Schrijven en rekenen: pen, gum en papier met schrijflijnen of rekenblokjes.",
bgBlank:"Leeg blad", bgLines:"Lijntjes", bgWrite:"Schrijflijnen",
bgGridS:"Kleine rekenblokjes", bgGridB:"Grote rekenblokjes",
penSize:"Dikte", eraser:"Gum", clearAll:"Alles wissen",
toolPen:"Pen", toolFountain:"Vulpen (dun en dik, reageert op druk of snelheid)",
toolMark:"Markeerstift", toolText:"Tekst typen (klik op het blad)",
wMin:"Minimaliseren", wMax:"Herstellen",
lvl1:"Niveau 1", lvl2:"Niveau 2", lvl3:"Niveau 3",
instr1:"Leg de letterblokken op de goede plek. Kijk goed naar de letters!",
instr2_begin:"Zoek de <b>beginletter</b> van het woord.",
instr2_mid:"Zoek de <b>middelste letter</b> van het woord.",
instr2_end:"Zoek de <b>eindletter</b> van het woord.",
instr3:"Spel het woord bij het plaatje. De nopjes helpen je!",
card:"Kaart", cardGeneral:"Algemeen", word:"Woord", welldone:"Goed zo!", next:"Volgende woord ▸",
cardDone:"Kaart klaar! 🎉", pickCard:"Kies een nieuwe kaart of een ander niveau.",
myWords:"Mijn woorden", manage:"Thema's en woorden beheren", addWord:" Toevoegen",
newTheme:"Nieuw thema", themeNamePh:"naam van het thema…", delTheme:"Thema verwijderen",
numWords:"Woorden per spel", all:"Alle", noThemes:"— nog geen thema's —",
muteAll:"Alle geluiden aan/uit", sndWidget:"Geluid van deze widget aan/uit",
catTaal:"Taal", catRekenen:"Rekenen", catTools:"Hulpmiddelen", catMedia:"Media",
wg_madd:"Plussommen", wg_madd_d:"Optellen tot 10, 20 of 100.",
wg_msub:"Minsommen", wg_msub_d:"Aftrekken tot 10, 20 of 100.",
wg_mmul:"Keersommen", wg_mmul_d:"De tafels van 1 t/m 12 oefenen.",
wg_mdiv:"Deelsommen", wg_mdiv_d:"Delen met de tafels van 1 t/m 12.",
wg_mpct:"Procenten", wg_mpct_d:"Procenten uitrekenen met mooie getallen.",
mGood:"Goed zo!", mAnsWas:"Het goede antwoord is", mAgain:"Nog een keer ▸",
mGoed:"goed", mOf:"van", mDoneT:"Klaar!",
amode:"Blokjes slepen of typen", penMode:"Tekenen op het bord",
fabWidgets:"Widget toevoegen", fabDraw:"Tekenen & schrijven", fabMind:"Mindmap op het bord",
fabImg:"Afbeelding op het bord",
nodeColor:"Kleur", nodeFont:"Lettergrootte", lineColor:"Lijnkleur", lineStyle:"Lijnstijl",
a11y:"Hoog contrast (voor slechtzienden)",
zoomUI:"Beeldgrootte: alles groter of kleiner tonen",
lensBig:"Loep groter", lensSmall:"Loep kleiner", lensShape:"Rond of vierkant",
toolHand:"Handje: verplaatsen en bedienen",
boardsNav:"Borden en mappen", newBoard:" Bord", newFolder:" Map",
folderPh:"naam nieuwe map…", boardName:"Bord", folderName:"Map",
delBoard:"Bord verwijderen (2× tikken)",
renameTip:"Dubbelklik op een bord om het te hernoemen",
hudMin:"Menu inklappen/uitklappen", hudOrient:"Horizontaal of verticaal",
wFull:"Maximaliseren", wUnfull:"Terugzetten",
wZoom:"Inhoud groter of kleiner (voor slechtzienden)",
wg_flits:"Flitswoorden", wg_flits_d:"Een woord flitst kort in beeld lezen voordat het verdwijnt!",
flFlash:"⚡ Flits", flShow:"👁 Toon", flNext:"Volgende ▸", flSec:"sec",
wg_dice:"Dobbelstenen", wg_dice_d:"Gooi 1, 2 of 3 grote dobbelstenen.",
diceRoll:"🎲 Gooi!", diceSum:"Samen",
wg_light:"Stoplicht", wg_light_d:"Rood, oranje of groen bijvoorbeeld voor het werkrumoer.",
wg_names:"Namenkiezer", wg_names_d:"Kies willekeurig een naam uit de klas.",
namesPh:"Eén naam per regel…", namesPick:"🎯 Kies!", namesNoRep:"Zonder herhaling",
namesDone:"Iedereen is geweest! 🎉",
wg_hak:"Hakken & plakken", wg_hak_d:"Woorden in klanken hakken en weer plakken, met voorlezen.",
hakChop:"✂️ Hak", hakGlue:"🔗 Plak", hakSay:"🔊 Lees voor",
wg_ball:"Ballonnenwoord", wg_ball_d:"Raad het woord letter voor letter voordat de ballonnen knappen.",
wg_anchor:"Ankerwoorden", wg_anchor_d:"Onbeperkt woordraster rond een thema: elk woord hoort bij de woorden eromheen.",
anchorPh:"thema…", anchorHint:"Tik op een vak en typ · sleep de achtergrond om te schuiven",
anchorColor:"Kleur: klik om te wisselen (verbind woorden met dezelfde kleur)",
ballWin:"Geraden! 🎉", ballLose:"Helaas! Het woord was:",
bgDots:"Stippen", toolRuler:"Liniaal", toolGeo:"Geodriehoek", toolLens:"Vergrootglas",
wg_mind:"Mindmap", wg_mind_d:"Woordweb, spin of boomdiagram om samen ideeën te ordenen.",
mmSpider:"Spin", mmTree:"Boom", mmWeb:"Woordweb", mmRoot:"Onderwerp",
mmHint:"Klik op tekst om te typen · sleep aan de rand",
wg_media:"Foto & video", wg_media_d:"Toon foto's en video's van internet, zoals YouTube of Vimeo.",
mediaPh:"Plak een link (YouTube, Vimeo, foto of video)…", mediaShow:"Tonen",
mediaEmpty:"Plak hierboven een link en klik op Tonen.",
mediaErr:"Deze link kan niet worden getoond. Probeer een YouTube-, Vimeo-, foto- of videolink, of open hem met ↗ in een nieuw tabblad.",
mediaOpen:"Open in nieuw tabblad",
wordPh:"woord (25 letters)", close:"Sluiten",
badWord:"Gebruik 2 t/m 5 letters (a-z).", needPic:"Kies eerst een plaatje.",
noWords:"Nog geen eigen woorden. Voeg er hierboven een toe!",
tabEmoji:"😀 Emoji", tabPicto:"🖼️ Picto's", tabUpload:"📷 Upload",
pictoSearch:"Zoeken", pictoPh:"zoekwoord…", pictoNone:"Niets gevonden. Probeer een ander woord.",
pictoLoad:"Zoeken…", pictoErr:"Kon picto's niet laden (internet nodig).",
pictoCredit:"Picto's: <a href='https://arasaac.org' target='_blank'>ARASAAC</a> (CC BY-NC-SA, Gobierno de Aragón). Ook leuk: <a href='https://www.sclera.be' target='_blank'>sclera.be</a> (gratis download, dan via Upload toevoegen).",
upload:"📷 Kies een afbeelding…",
upHint:"Tip: vierkant plaatje, witte of doorzichtige achtergrond, één duidelijk voorwerp, géén tekst in het plaatje.",
cats:["Dieren","Eten","Natuur","Vervoer","Spullen","Lichaam"],
notesPh:"Schrijf hier…",
start:"Start", pause:"Pauze", reset:"Reset", min:"min",
tmDigits:"Cijfers", tmVisual:"Kijkklok", tmBoth:"Allebei", tmSound:"Geluid aan/uit",
tmHint:"Sleep aan de rode schijf om de tijd in te stellen.",
login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen",
namePh:"naam", passPh:"wachtwoord",
guestNote:"Zonder inloggen wordt niets bewaard: eigen woorden en het bord zijn weg als je de pagina sluit. Log in om alles op dit apparaat te bewaren.",
storedNote:"Je eigen woorden worden automatisch bewaard. Met “Bord opslaan” bewaar je ook de widgets op het bord.",
errLogin:"Onbekende naam of verkeerd wachtwoord.",
errExists:"Deze naam bestaat al.",
errName:"Naam: 220 letters of cijfers.",
errPass:"Wachtwoord: minimaal 4 tekens.",
errCrypto:"Inloggen werkt niet in deze browser.",
hello:"Hoi", boardSaved:"Opgeslagen ✓", loginFirst:"Eerst inloggen"
},
en:{
addWidget:"Widget", chooseWidget:"Choose a widget", saveBoard:"Save board",
emptyBoard:"The board is empty. Click “Widget” to get started.",
wg_letters:"Letter blocks", wg_letters_d:"Learn to read and spell with letter blocks 3 levels, like Ik leer lezen.",
wg_notes:"Notes", wg_notes_d:"A sheet to write on.",
wg_timer:"Timer", wg_timer_d:"Countdown timer with a visual clock for class.",
wg_write:"Writing pad", wg_write_d:"Writing and maths: pen, eraser and paper with handwriting lines or maths squares.",
bgBlank:"Blank sheet", bgLines:"Lines", bgWrite:"Handwriting lines",
bgGridS:"Small maths squares", bgGridB:"Big maths squares",
penSize:"Thickness", eraser:"Eraser", clearAll:"Clear all",
toolPen:"Pen", toolFountain:"Fountain pen (thin and thick, follows pressure or speed)",
toolMark:"Highlighter", toolText:"Type text (click the sheet)",
wMin:"Minimize", wMax:"Restore",
lvl1:"Level 1", lvl2:"Level 2", lvl3:"Level 3",
instr1:"Place the letter blocks in the right spot. Look closely at the letters!",
instr2_begin:"Find the <b>beginning letter</b> of the word.",
instr2_mid:"Find the <b>middle letter</b> of the word.",
instr2_end:"Find the <b>ending letter</b> of the word.",
instr3:"Spell the word for the picture. The little studs will help you!",
card:"Card", cardGeneral:"General", word:"Word", welldone:"Well done!", next:"Next word ▸",
cardDone:"Card complete! 🎉", pickCard:"Pick a new card or another level.",
myWords:"My words", manage:"Manage themes and words", addWord:" Add",
newTheme:"New theme", themeNamePh:"name of the theme…", delTheme:"Delete theme",
numWords:"Words per game", all:"All", noThemes:"— no themes yet —",
muteAll:"All sounds on/off", sndWidget:"This widget's sound on/off",
catTaal:"Language", catRekenen:"Maths", catTools:"Tools", catMedia:"Media",
wg_madd:"Addition", wg_madd_d:"Adding up to 10, 20 or 100.",
wg_msub:"Subtraction", wg_msub_d:"Subtracting up to 10, 20 or 100.",
wg_mmul:"Multiplication", wg_mmul_d:"Practise the times tables 112.",
wg_mdiv:"Division", wg_mdiv_d:"Dividing with the tables 112.",
wg_mpct:"Percentages", wg_mpct_d:"Working out percentages with friendly numbers.",
mGood:"Well done!", mAnsWas:"The right answer is", mAgain:"Again ▸",
mGoed:"correct", mOf:"of", mDoneT:"Done!",
amode:"Drag blocks or type", penMode:"Draw on the board",
fabWidgets:"Add widget", fabDraw:"Draw & write", fabMind:"Mindmap on the board",
fabImg:"Picture on the board",
nodeColor:"Colour", nodeFont:"Font size", lineColor:"Line colour", lineStyle:"Line style",
a11y:"High contrast (for low vision)",
zoomUI:"Display size: show everything bigger or smaller",
lensBig:"Larger magnifier", lensSmall:"Smaller magnifier", lensShape:"Round or square",
toolHand:"Hand: move and interact",
boardsNav:"Boards and folders", newBoard:" Board", newFolder:" Folder",
folderPh:"name of new folder…", boardName:"Board", folderName:"Folder",
delBoard:"Delete board (tap twice)",
renameTip:"Double-click a board to rename it",
hudMin:"Collapse/expand menu", hudOrient:"Horizontal or vertical",
wFull:"Maximize", wUnfull:"Restore",
wZoom:"Content bigger or smaller (low vision)",
wg_flits:"Flash words", wg_flits_d:"A word flashes briefly read it before it disappears!",
flFlash:"⚡ Flash", flShow:"👁 Show", flNext:"Next ▸", flSec:"sec",
wg_dice:"Dice", wg_dice_d:"Roll 1, 2 or 3 big dice.",
diceRoll:"🎲 Roll!", diceSum:"Total",
wg_light:"Traffic light", wg_light_d:"Red, orange or green e.g. for classroom noise.",
wg_names:"Name picker", wg_names_d:"Pick a random name from the class.",
namesPh:"One name per line…", namesPick:"🎯 Pick!", namesNoRep:"No repeats",
namesDone:"Everyone has had a turn! 🎉",
wg_hak:"Chop & blend", wg_hak_d:"Chop words into sounds and blend them back, with read-aloud.",
hakChop:"✂️ Chop", hakGlue:"🔗 Blend", hakSay:"🔊 Read aloud",
wg_ball:"Balloon word", wg_ball_d:"Guess the word letter by letter before the balloons pop.",
wg_anchor:"Anchor words", wg_anchor_d:"Unlimited word grid around a theme: every word relates to its neighbours.",
anchorPh:"theme…", anchorHint:"Tap a cell and type · drag the background to pan",
anchorColor:"Colour: click to cycle (connect words with the same colour)",
ballWin:"You got it! 🎉", ballLose:"Oh no! The word was:",
bgDots:"Dots", toolRuler:"Ruler", toolGeo:"Set square", toolLens:"Magnifier",
wg_mind:"Mind map", wg_mind_d:"Word web, spider or tree diagram to organise ideas together.",
mmSpider:"Spider", mmTree:"Tree", mmWeb:"Word web", mmRoot:"Topic",
mmHint:"Click text to type · drag by the edge",
wg_media:"Photo & video", wg_media_d:"Show photos and videos from the internet, such as YouTube or Vimeo.",
mediaPh:"Paste a link (YouTube, Vimeo, photo or video)…", mediaShow:"Show",
mediaEmpty:"Paste a link above and click Show.",
mediaErr:"This link cannot be shown. Try a YouTube, Vimeo, photo or video link, or open it with ↗ in a new tab.",
mediaOpen:"Open in new tab",
wordPh:"word (25 letters)", close:"Close",
badWord:"Use 2 to 5 letters (a-z).", needPic:"Pick a picture first.",
noWords:"No custom words yet. Add one above!",
tabEmoji:"😀 Emoji", tabPicto:"🖼️ Pictos", tabUpload:"📷 Upload",
pictoSearch:"Search", pictoPh:"search word…", pictoNone:"Nothing found. Try another word.",
pictoLoad:"Searching…", pictoErr:"Could not load pictos (internet required).",
pictoCredit:"Pictos: <a href='https://arasaac.org' target='_blank'>ARASAAC</a> (CC BY-NC-SA, Gobierno de Aragón). Also nice: <a href='https://www.sclera.be' target='_blank'>sclera.be</a> (free download, then add via Upload).",
upload:"📷 Choose an image…",
upHint:"Tip: square picture, white or transparent background, one clear object, no text in the picture.",
cats:["Animals","Food","Nature","Transport","Things","Body"],
notesPh:"Write here…",
start:"Start", pause:"Pause", reset:"Reset", min:"min",
tmDigits:"Digits", tmVisual:"Visual clock", tmBoth:"Both", tmSound:"Sound on/off",
tmHint:"Drag the red disc to set the time.",
login:"Log in", loginBtn:"Log in", registerBtn:"Create account", logout:"Log out",
namePh:"name", passPh:"password",
guestNote:"Without logging in nothing is saved: custom words and the board are gone when you close the page. Log in to keep everything on this device.",
storedNote:"Your custom words are saved automatically. “Save board” also stores the widgets on the board.",
errLogin:"Unknown name or wrong password.",
errExists:"This name already exists.",
errName:"Name: 220 letters or digits.",
errPass:"Password: at least 4 characters.",
errCrypto:"Login does not work in this browser.",
hello:"Hi", boardSaved:"Saved ✓", loginFirst:"Log in first"
}
};
let LANG = "nl";
const T = k => I18N[LANG][k];
/* effective CSS zoom of the app (display-size setting); pointer coordinates
are visual pixels, absolute positions are layout pixels divide by VZ() */
const VZ = ()=>{ const z = parseFloat(document.body.style.zoom); return z>0 ? z : 1; };
function applyI18n(){
document.querySelectorAll("[data-i18n]").forEach(el=>{ el.textContent = T(el.dataset.i18n); });
document.querySelectorAll("#langToggle button").forEach(b=>b.classList.toggle("active", b.dataset.lang===LANG));
document.getElementById("liName").placeholder = T("namePh");
document.getElementById("liPass").placeholder = T("passPh");
document.getElementById("btnMute").title = T("muteAll");
updateUserUI();
document.dispatchEvent(new CustomEvent("langchange"));
}
document.querySelectorAll("#langToggle button").forEach(b=>{
b.addEventListener("click", ()=>{ LANG=b.dataset.lang; applyI18n(); });
});
document.getElementById("btnFs").addEventListener("click", ()=>{
if(document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen();
});
/* =========================================================
Accounts & storage
- Guest: everything in memory only, gone on close.
- Logged in: PBKDF2-SHA256 hashed password (Web Crypto),
per-user data (custom words + saved board) in localStorage.
Note: this protects the password itself; anyone with physical
access to this device/browser profile can still clear or read
stored data. For real multi-device security a server is needed.
==========================================================*/
const USERS_KEY = "teach.users";
let currentUser = null; // {name, data}
function loadUsers(){ try{ return JSON.parse(localStorage.getItem(USERS_KEY)) || {}; }catch(e){ return {}; } }
function saveUsers(u){ try{ localStorage.setItem(USERS_KEY, JSON.stringify(u)); }catch(e){} }
const bufToHex = b => [...new Uint8Array(b)].map(x=>x.toString(16).padStart(2,"0")).join("");
const hexToBuf = h => new Uint8Array(h.match(/.{2}/g).map(x=>parseInt(x,16)));
async function hashPw(pw, saltHex, iterations){
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(pw), "PBKDF2", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits(
{name:"PBKDF2", hash:"SHA-256", salt:hexToBuf(saltHex), iterations}, key, 256);
return bufToHex(bits);
}
const cryptoOK = !!(window.crypto && crypto.subtle && crypto.getRandomValues);
function persistUser(){
if(!currentUser) return;
const users = loadUsers();
if(users[currentUser.name]){ users[currentUser.name].data = currentUser.data; saveUsers(users); }
}
function persistWords(){
if(currentUser){
currentUser.data.themes = THEMES;
currentUser.data.generalExtra = GENERAL_EXTRA;
persistUser();
}
}
function generalExtraFromData(data){
return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]};
}
/* login UI */
const loginWrap = document.getElementById("loginWrap");
const loginMsg = document.getElementById("loginMsg");
function updateUserUI(){
document.getElementById("userLbl").textContent = currentUser ? currentUser.name : T("login");
document.getElementById("btnUser").classList.toggle("on", !!currentUser);
document.getElementById("loginBox").style.display = currentUser ? "none" : "block";
document.getElementById("loggedBox").style.display = currentUser ? "block" : "none";
if(currentUser) document.getElementById("helloLbl").textContent = `${T("hello")} ${currentUser.name}! 👋`;
}
document.getElementById("btnUser").addEventListener("click", ()=>{
loginMsg.textContent = ""; loginMsg.classList.remove("ok");
loginWrap.classList.add("open");
});
loginWrap.addEventListener("click", e=>{ if(e.target===loginWrap) loginWrap.classList.remove("open"); });
async function doLogin(register){
const name = document.getElementById("liName").value.trim();
const pass = document.getElementById("liPass").value;
loginMsg.classList.remove("ok");
if(!cryptoOK){ loginMsg.textContent = T("errCrypto"); return; }
if(!/^[a-zA-Z0-9_-]{2,20}$/.test(name)){ loginMsg.textContent = T("errName"); return; }
if(pass.length < 4){ loginMsg.textContent = T("errPass"); return; }
const users = loadUsers();
try{
if(register){
if(users[name]){ loginMsg.textContent = T("errExists"); return; }
const salt = bufToHex(crypto.getRandomValues(new Uint8Array(16)));
const iterations = 150000;
const hash = await hashPw(pass, salt, iterations);
users[name] = { salt, iterations, hash, data:{ themes:{nl:[],en:[]}, board:null } };
saveUsers(users);
}else{
const u = users[name];
if(!u){ loginMsg.textContent = T("errLogin"); return; }
const hash = await hashPw(pass, u.salt, u.iterations);
if(hash !== u.hash){ loginMsg.textContent = T("errLogin"); return; }
}
}catch(e){ loginMsg.textContent = T("errCrypto"); return; }
currentUser = { name, data: loadUsers()[name].data };
THEMES = themesFromData(currentUser.data);
GENERAL_EXTRA = generalExtraFromData(currentUser.data);
document.getElementById("liPass").value = "";
try{ sessionStorage.setItem("teach.session", name); }catch(e){}
updateUserUI();
loginWrap.classList.remove("open");
document.dispatchEvent(new CustomEvent("wordschange"));
BS = boardsFromData(currentUser.data);
loadCurrentSlot();
}
document.getElementById("btnLogin").addEventListener("click", ()=>doLogin(false));
document.getElementById("btnRegister").addEventListener("click", ()=>doLogin(true));
document.getElementById("liPass").addEventListener("keydown", e=>{ if(e.key==="Enter") doLogin(false); });
document.getElementById("btnLogout").addEventListener("click", ()=>{
persistUser();
currentUser = null;
THEMES = {nl:[],en:[]};
GENERAL_EXTRA = {nl:[],en:[]};
try{ sessionStorage.removeItem("teach.session"); }catch(e){}
updateUserUI();
loginWrap.classList.remove("open");
document.dispatchEvent(new CustomEvent("wordschange"));
BS = freshBS();
loadCurrentSlot();
});
/* save board */
document.getElementById("btnSave").addEventListener("click", ()=>{
const btn = document.getElementById("btnSave");
const lbl = btn.querySelector("span");
if(!currentUser){
lbl.textContent = T("loginFirst");
setTimeout(()=>{ lbl.textContent = T("saveBoard"); }, 2200);
loginWrap.classList.add("open");
return;
}
stashCurrent();
currentUser.data.boards = BS;
delete currentUser.data.board;
persistUser();
lbl.textContent = T("boardSaved");
btn.classList.add("ok");
setTimeout(()=>{ lbl.textContent = T("saveBoard"); btn.classList.remove("ok"); }, 1800);
});
/* =========================================================
Sounds
==========================================================*/
let AC=null, MUTED=false;
const btnMute = document.getElementById("btnMute");
btnMute.addEventListener("click", ()=>{
MUTED = !MUTED;
btnMute.textContent = MUTED ? "🔇" : "🔊";
btnMute.title = T("muteAll");
});
function tone(freq, dur=0.12, delay=0, type="sine", vol=0.18){
if(MUTED) return;
try{
AC = AC || new (window.AudioContext||window.webkitAudioContext)();
const o=AC.createOscillator(), g=AC.createGain();
o.type=type; o.frequency.value=freq;
g.gain.setValueAtTime(vol, AC.currentTime+delay);
g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime+delay+dur);
o.connect(g); g.connect(AC.destination);
o.start(AC.currentTime+delay); o.stop(AC.currentTime+delay+dur+0.02);
}catch(e){}
}
const sndGood = ()=> tone(880,0.1);
const sndBad = ()=> tone(180,0.18,0,"square",0.10);
const sndWin = ()=>{ [523,659,784,1047].forEach((f,i)=>tone(f,0.16,i*0.12,"triangle",0.2)); };

132
public/js/data.js Normal file
View file

@ -0,0 +1,132 @@
/* teach - woorddata: algemene kaart, thema's, gedeelde woordbronnen */
/* =========================================================
Letter game data
==========================================================*/
const DOTCODE = {
a:[1], b:[1,2], c:[1,4], d:[1,4,5], e:[1,5], f:[1,2,4], g:[1,2,4,5], h:[1,2,5],
i:[2,4], j:[2,4,5], k:[1,3], l:[1,2,3], m:[1,3,4], n:[1,3,4,5], o:[1,3,5],
p:[1,2,3,4], q:[1,2,3,4,5], r:[1,2,3,5], s:[2,3,4], t:[2,3,4,5], u:[1,3,6],
v:[1,2,3,6], w:[2,4,5,6], x:[1,3,4,6], y:[1,3,4,5,6], z:[1,3,5,6],
/* digits use the braille number patterns (aj) */
"1":[1], "2":[1,2], "3":[1,4], "4":[1,4,5], "5":[1,5],
"6":[1,2,4], "7":[1,2,4,5], "8":[1,2,5], "9":[2,4], "0":[2,4,5]
};
const VOWELS = "aeiou";
function dotGrid(letter){
const code = DOTCODE[letter] || [];
const grid = document.createElement("div");
grid.className = "dots";
[1,4,2,5,3,6].forEach(p=>{
const d = document.createElement("i");
d.className = code.includes(p) ? "on" : "off";
grid.appendChild(d);
});
return grid;
}
/* built-in general words: ONE card per language (extendable via the editor) */
const WORDS = {
nl:[
["aap","🐵"],["vis","🐟"],["zon","☀️"],["kat","🐈"],["bal","⚽"],["ei","🥚"],
["boom","🌳"],["huis","🏠"],["maan","🌙"],["boot","⛵"],["kip","🐔"],["muis","🐭"],
["boek","📕"],["ster","⭐"],["roos","🌹"],["vuur","🔥"],["fiets","🚲"],["taart","🎂"],
["jas","🧥"],["bril","👓"],["klok","⏰"],["peer","🍐"],["kaas","🧀"],["eend","🦆"],
["hand","✋"],["voet","🦶"],["neus","👃"],["oog","👁️"],["oor","👂"],["mond","👄"]
],
en:[
["cat","🐈"],["dog","🐕"],["sun","☀️"],["egg","🥚"],["ball","⚽"],["fish","🐟"],
["tree","🌳"],["moon","🌙"],["boat","⛵"],["star","⭐"],["book","📕"],["cake","🎂"],
["house","🏠"],["mouse","🐭"],["apple","🍎"],["train","🚂"],["fire","🔥"],["rose","🌹"],
["bear","🐻"],["duck","🦆"],["frog","🐸"],["bee","🐝"],["cow","🐄"],["pig","🐷"],
["hand","✋"],["foot","🦶"],["nose","👃"],["eye","👁️"],["ear","👂"],["milk","🥛"]
]
};
/* teacher's own additions to the general card (persisted per user) */
let GENERAL_EXTRA = {nl:[], en:[]};
function generalList(){ return WORDS[LANG].concat(GENERAL_EXTRA[LANG]); }
const shuffle = a => { a=a.slice(); for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]];} return a; };
/* custom themes: folders of own words.
{nl:[{name:"Herfst", words:[[word,pic],...]}], en:[...]} */
let THEMES = {nl:[], en:[]};
function themesFromData(data){
if(!data) return {nl:[],en:[]};
if(data.themes) return data.themes;
/* migrate old flat word lists into one theme */
const t = {nl:[],en:[]};
if(data.words) ["nl","en"].forEach(l=>{
if(Array.isArray(data.words[l]) && data.words[l].length)
t[l] = [{name: l==="nl" ? "Mijn woorden" : "My words", words: data.words[l]}];
});
return t;
}
const isImg = p => typeof p==="string" && (p.startsWith("data:") || p.startsWith("http"));
function setPic(el, pic){
if(isImg(pic)){ el.innerHTML = ""; const im = document.createElement("img"); im.src = pic; im.alt=""; el.appendChild(im); }
else el.textContent = pic;
}
/* ---- shared word source (general card + themes + anchor grids) ---- */
/* anchor-word widgets register their words here so every language widget
can use them as a theme automatically */
let ANCHORS = []; /* [{el, name, words:[[w,pic],...]}] */
function anchorSources(){
ANCHORS = ANCHORS.filter(a=>a.el && document.contains(a.el));
return ANCHORS.filter(a=>a.words.length);
}
function normWordSource(card){
if(typeof card==="string"){
if(card.startsWith("t")){
const th = THEMES[LANG][+card.slice(1)];
if(th && th.words.length) return card;
}else if(card.startsWith("a")){
if(anchorSources()[+card.slice(1)]) return card;
}
}
return "g";
}
function wordSourceList(card){
card = normWordSource(card);
if(card.startsWith("t")) return THEMES[LANG][+card.slice(1)].words;
if(card.startsWith("a")) return anchorSources()[+card.slice(1)].words;
return generalList();
}
function fillWordSourceSel(sel, card){
sel.innerHTML = "";
const og = document.createElement("option");
og.value = "g"; og.textContent = T("cardGeneral");
sel.appendChild(og);
THEMES[LANG].forEach((th,i)=>{
if(!th.words.length) return;
const o = document.createElement("option");
o.value = "t"+i; o.textContent = "★ "+th.name;
sel.appendChild(o);
});
anchorSources().forEach((a,i)=>{
const o = document.createElement("option");
o.value = "a"+i; o.textContent = "⚓ "+a.name;
sel.appendChild(o);
});
sel.value = normWordSource(card);
if(sel.selectedIndex < 0) sel.selectedIndex = 0;
}
/* emoji palette (grouped) */
const splitEmoji = s => {
const out = [];
for(const ch of s){
if(out.length && (ch==="" || ch==="")) out[out.length-1]+=ch;
else if(out.length && out[out.length-1].endsWith("")) out[out.length-1]+=ch;
else out.push(ch);
}
return out;
};
const EMOJI_CATS = [
splitEmoji("🐵🐶🐱🐭🐹🐰🦊🐻🐼🐨🐯🦁🐮🐷🐸🐔🐧🐦🦆🦉🐴🦄🐝🐛🦋🐌🐞🐠🐟🐬🐳🐢🐍🦎🐘🦒"),
splitEmoji("🍎🍐🍊🍋🍌🍉🍇🍓🍒🍑🥝🍅🥕🌽🥔🧀🥚🍞🥐🥞🍕🍟🍦🍪🎂🍫🍬🥛🧃🍯"),
splitEmoji("🌳🌲🌴🌵🌷🌹🌻🌸💐🍄⭐🌙☀️⛅🌈❄️🔥💧🌊🪨"),
splitEmoji("🚗🚕🚌🚑🚒🚜🚲🛴🏍️🚂✈️🚁⛵🚀🛶🚢"),
splitEmoji("⚽🏀🎈🎁🧸📕✏️🖍️✂️🎨🥁🎸👓⌚⏰💡🔑🧤🧢👟👗🧥🛏️🪑🚪🏠"),
splitEmoji("✋👂👃👁️👄🦶😀😢😴🤧👶👦👧👵👴")
];

159
public/js/widgets/anchor.js Normal file
View file

@ -0,0 +1,159 @@
/* widget: Ankerwoorden */
/* =========================================================
Anchor words unlimited word grid around a theme
==========================================================*/
function mountAnchor(root, initState){
const CW = 128, CH = 56, G = 10;
/* colour cycle for connecting words: none → green → blue → red → purple → orange → yellow */
const COLS = [null,"#3fae6a","#3b7dd8","#e0554d","#9b59b6","#f28c38","#d9a400"];
const S = { cells:{}, pan:{x:0, y:0} };
if(initState){
const src = initState.cells || {};
Object.keys(src).forEach(k=>{
const v = src[k];
/* migrate: old format stored plain strings */
S.cells[k] = (typeof v==="string") ? {t:v, c:null} : {t:v.t||"", c:v.c||null};
});
S.pan = initState.pan || {x:0, y:0};
}
root.innerHTML = `
<div class="an">
<div class="an-hint"></div>
<div class="an-stage"><div class="an-layer"></div></div>
</div>`;
const stage = root.querySelector(".an-stage"),
layer = root.querySelector(".an-layer"),
hint = root.querySelector(".an-hint");
const key = (x,y)=>x+","+y;
/* register this grid as a word source for the language widgets */
const reg = { el: root, name: T("wg_anchor"), words: [] };
ANCHORS.push(reg);
function syncSource(quiet){
const center = S.cells["0,0"];
reg.name = (center && center.t) ? center.t : T("wg_anchor");
const seen = new Set();
reg.words = [];
Object.keys(S.cells).forEach(k=>{
const w = (S.cells[k].t||"").trim().toLowerCase();
if(/^[a-zà-ÿ]{2,12}$/.test(w) && !seen.has(w)){
seen.add(w);
reg.words.push([w, "⚓"]);
}
});
if(!quiet) document.dispatchEvent(new CustomEvent("wordschange"));
}
function applyPan(){ layer.style.transform = `translate(${-S.pan.x}px,${-S.pan.y}px)`; }
function render(){
layer.innerHTML = "";
const w = stage.offsetWidth, h = stage.offsetHeight;
if(w < 2) return;
const cx = w/2, cy = h/2;
const x0 = Math.floor((S.pan.x - cx - CW)/(CW+G)), x1 = Math.ceil((S.pan.x + w - cx + CW)/(CW+G));
const y0 = Math.floor((S.pan.y - cy - CH)/(CH+G)), y1 = Math.ceil((S.pan.y + h - cy + CH)/(CH+G));
for(let gx=x0; gx<=x1; gx++){
for(let gy=y0; gy<=y1; gy++){
const c = document.createElement("div");
const cell = S.cells[key(gx,gy)];
const word = cell && cell.t;
const isRoot = gx===0 && gy===0;
c.className = "an-cell" + (word ? " filled" : "") + (isRoot ? " root" : "");
c.style.left = (cx + gx*(CW+G) - CW/2)+"px";
c.style.top = (cy + gy*(CH+G) - CH/2)+"px";
if(word){
const span = document.createElement("span");
span.className = "an-word";
span.textContent = word;
c.appendChild(span);
/* only the theme word is styled by default; other words are plain
until the teacher gives them a colour to show a connection */
if(!isRoot){
if(cell.c){
c.style.borderColor = cell.c;
c.style.background = cell.c + "1c";
}
const dot = document.createElement("button");
dot.className = "an-dot";
dot.style.background = cell.c || "#cdd7e4";
dot.title = T("anchorColor");
dot.addEventListener("pointerdown", ev=>ev.stopPropagation());
dot.addEventListener("click", ev=>{
ev.stopPropagation();
cell.c = COLS[(COLS.indexOf(cell.c)+1) % COLS.length];
render();
});
c.appendChild(dot);
}
}
c.dataset.gx = gx; c.dataset.gy = gy;
layer.appendChild(c);
}
}
applyPan();
hint.textContent = T("anchorHint");
}
/* pan by dragging, click (without moving) to type */
let pd = null;
stage.addEventListener("pointerdown", e=>{
if(e.target.tagName==="INPUT") return;
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
stage.setPointerCapture(e.pointerId);
const sc = stage.getBoundingClientRect().width / stage.offsetWidth;
pd = { x:e.clientX/sc, y:e.clientY/sc, px:S.pan.x, py:S.pan.y,
moved:false, target:e.target.closest(".an-cell"), sc };
});
stage.addEventListener("pointermove", e=>{
if(!pd) return;
const dx = e.clientX/pd.sc - pd.x, dy = e.clientY/pd.sc - pd.y;
if(Math.abs(dx) > 6 || Math.abs(dy) > 6) pd.moved = true;
if(pd.moved){ S.pan.x = pd.px - dx; S.pan.y = pd.py - dy; applyPan(); }
});
stage.addEventListener("pointerup", ()=>{
if(!pd) return;
const {moved, target} = pd;
pd = null;
if(moved){ render(); return; }
if(target) editCell(target);
});
stage.addEventListener("pointercancel", ()=>{
const moved = pd && pd.moved;
pd = null;
if(moved) render();
});
function editCell(cell){
if(cell.querySelector("input")) return;
const gx = +cell.dataset.gx, gy = +cell.dataset.gy, k = key(gx,gy);
const inp = document.createElement("input");
inp.maxLength = 24;
inp.value = S.cells[k] ? S.cells[k].t : "";
if(gx===0 && gy===0) inp.placeholder = T("anchorPh");
cell.textContent = "";
cell.appendChild(inp);
setTimeout(()=>{ inp.focus(); inp.select(); }, 0);
let done = false;
const commit = ()=>{
if(done) return; done = true;
const v = inp.value.trim();
if(v) S.cells[k] = { t:v, c:(S.cells[k] && S.cells[k].c) || null };
else delete S.cells[k];
syncSource();
render();
};
inp.addEventListener("keydown", ev=>{
ev.stopPropagation();
if(ev.key==="Enter") commit();
if(ev.key==="Escape"){ done = true; render(); }
});
inp.addEventListener("blur", commit);
inp.addEventListener("pointerdown", ev=>ev.stopPropagation());
}
new ResizeObserver(render).observe(stage);
document.addEventListener("langchange", render);
syncSource(true);
render();
if(reg.words.length) document.dispatchEvent(new CustomEvent("wordschange"));
return { getState: ()=>({ cells:S.cells, pan:S.pan }) };
}

109
public/js/widgets/ball.js Normal file
View file

@ -0,0 +1,109 @@
/* widget: Ballonnenwoord */
/* =========================================================
Balloon word widget guess before the balloons pop
==========================================================*/
function mountBall(root, initState){
const S = { card:0, snd:true, lives:6, guessed:new Set(), over:false, order:[], idx:0, stars:0 };
if(initState){ S.card = initState.card ?? 0; S.snd = initState.snd !== false; }
const play = f=>{ if(S.snd) f(); };
root.innerHTML = `
<div class="mt">
<div class="mt-top">
<button class="lg-snd"></button>
<select class="fl-sel bl-src"></select>
<div class="mt-score bl-score"></div>
</div>
<div class="mt-card">
<div class="bl-balloons"></div>
<div class="bl-word"></div>
<div class="bl-msg"></div>
<button class="lg-next bl-next"></button>
</div>
<div class="lg-tray bl-kbd lg-kbd" style="display:flex;"></div>
</div>`;
const el = s => root.querySelector(s);
const srcSel = el(".bl-src"), balloonsEl = el(".bl-balloons"),
wordEl = el(".bl-word"), msgEl = el(".bl-msg"),
nextBtn = el(".bl-next"), kbdEl = el(".bl-kbd"),
scoreEl = el(".bl-score"), sndBtn = el(".lg-snd");
function list(){ S.card = normWordSource(S.card); return wordSourceList(S.card); }
function buildSrc(){ fillWordSourceSel(srcSel, S.card); }
"abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{
const b = document.createElement("button");
b.textContent = l; b.dataset.l = l;
b.className = VOWELS.includes(l) ? "v" : "c";
b.addEventListener("click", ()=>guess(l, b));
kbdEl.appendChild(b);
});
function word(){ return list()[S.order[S.idx]][0]; }
function drawWord(reveal){
wordEl.textContent = word().split("").map(l=>(reveal || S.guessed.has(l)) ? l : "_").join(" ");
}
function drawBalloons(){
balloonsEl.textContent = "🎈".repeat(S.lives) + "💥".repeat(6-S.lives);
}
function newWord(){
if(!S.order.length || S.idx >= S.order.length){
S.order = shuffle(list().map((_,i)=>i));
S.idx = 0;
}
S.lives = 6; S.guessed = new Set(); S.over = false;
msgEl.textContent = ""; msgEl.className = "bl-msg";
nextBtn.classList.remove("show");
kbdEl.querySelectorAll("button").forEach(b=>{ b.disabled = false; b.style.opacity = 1; });
drawWord(false); drawBalloons();
scoreEl.textContent = S.stars ? S.stars+" ★" : "";
}
function guess(l, btn){
if(S.over || btn.disabled) return;
btn.disabled = true; btn.style.opacity = .3;
if(word().includes(l)){
S.guessed.add(l);
play(sndGood);
drawWord(false);
if(word().split("").every(x=>S.guessed.has(x))){
S.over = true; S.stars++;
msgEl.textContent = T("ballWin");
msgEl.classList.add("win");
play(sndWin); confetti(el(".mt-card"));
nextBtn.classList.add("show");
scoreEl.textContent = S.stars+" ★";
}
}else{
S.lives--;
play(sndBad);
drawBalloons();
if(S.lives <= 0){
S.over = true;
drawWord(true);
msgEl.textContent = `${T("ballLose")} ${word()}`;
msgEl.classList.add("lose");
nextBtn.classList.add("show");
}
}
}
nextBtn.addEventListener("click", ()=>{ S.idx++; newWord(); });
srcSel.addEventListener("change", ()=>{
S.card = srcSel.value.startsWith("t") ? srcSel.value : +srcSel.value;
S.order = []; newWord();
});
sndBtn.addEventListener("click", ()=>{
S.snd = !S.snd;
sndBtn.textContent = S.snd ? "🔔" : "🔕";
sndBtn.classList.toggle("off", !S.snd);
});
function labels(){
sndBtn.textContent = S.snd ? "🔔" : "🔕";
sndBtn.classList.toggle("off", !S.snd);
sndBtn.title = T("sndWidget");
nextBtn.textContent = T("next");
buildSrc();
}
document.addEventListener("langchange", ()=>{ S.card=0; S.order=[]; labels(); newWord(); });
document.addEventListener("wordschange", ()=>{ buildSrc(); S.order=[]; newWord(); });
labels(); newWord();
return { getState: ()=>({card:S.card, snd:S.snd}) };
}

74
public/js/widgets/dice.js Normal file
View file

@ -0,0 +1,74 @@
/* widget: Dobbelstenen */
/* =========================================================
Dice widget
==========================================================*/
function mountDice(root, initState){
let count = (initState && initState.count) || 2, rolling = false;
const PIPS = {1:[4],2:[0,8],3:[0,4,8],4:[0,2,6,8],5:[0,2,4,6,8],6:[0,2,3,5,6,8]};
root.innerHTML = `
<div class="dc">
<div class="mt-top" style="width:100%;">
<div class="tseg dc-seg">
<button data-n="1">1</button><button data-n="2">2</button><button data-n="3">3</button>
</div>
<div class="mt-score dc-sumlbl"></div>
</div>
<div class="dc-row"></div>
<div class="dc-sum"></div>
<button class="tbtn dc-roll"></button>
</div>`;
const row = root.querySelector(".dc-row"),
sumEl = root.querySelector(".dc-sum"),
rollBtn = root.querySelector(".dc-roll");
function face(die, v){
die.querySelectorAll("i").forEach((p,i)=>p.classList.toggle("on", PIPS[v].includes(i)));
die.dataset.v = v;
}
function build(){
row.innerHTML = "";
for(let i=0;i<count;i++){
const d = document.createElement("div");
d.className = "die";
for(let j=0;j<9;j++) d.appendChild(document.createElement("i"));
face(d, 1+Math.floor(Math.random()*6));
row.appendChild(d);
}
sumEl.textContent = "";
}
function roll(){
if(rolling) return;
rolling = true;
sumEl.textContent = "";
const dice = [...row.querySelectorAll(".die")];
dice.forEach(d=>d.classList.add("rolling"));
let n = 0;
const iv = setInterval(()=>{
dice.forEach(d=>face(d, 1+Math.floor(Math.random()*6)));
tone(300+Math.random()*250, 0.04, 0, "square", 0.05);
if(++n >= 9){
clearInterval(iv);
dice.forEach(d=>d.classList.remove("rolling"));
const total = dice.reduce((s,d)=>s + +d.dataset.v, 0);
if(count>1) sumEl.textContent = `${T("diceSum")}: ${total}`;
rolling = false;
}
}, 90);
}
row.addEventListener("click", roll);
rollBtn.addEventListener("click", roll);
root.querySelectorAll(".dc-seg button").forEach(b=>{
b.addEventListener("click", ()=>{
count = +b.dataset.n;
root.querySelectorAll(".dc-seg button").forEach(x=>x.classList.toggle("on", x===b));
build();
});
});
function labels(){
rollBtn.textContent = T("diceRoll");
root.querySelectorAll(".dc-seg button").forEach(x=>x.classList.toggle("on", +x.dataset.n===count));
}
document.addEventListener("langchange", labels);
labels(); build();
return { getState: ()=>({count}) };
}

View file

@ -0,0 +1,90 @@
/* widget: Flitswoorden */
/* =========================================================
Flash words widget
==========================================================*/
function mountFlits(root, initState){
const S = { card:0, dur:2, idx:0, order:[] };
if(initState){ S.card = initState.card ?? 0; S.dur = initState.dur || 2; }
let flashTimer = null;
root.innerHTML = `
<div class="mt">
<div class="mt-top">
<select class="fl-sel fl-src"></select>
<select class="fl-sel fl-dur"></select>
<div class="mt-score fl-cnt"></div>
</div>
<div class="mt-card">
<div class="fl-word dim"> </div>
<div class="fl-pic"></div>
</div>
<div class="trow" style="display:flex;gap:10px;justify-content:center;flex:none;">
<button class="tbtn fl-flash"></button>
<button class="tbtn ghost fl-show"></button>
<button class="tbtn ghost fl-next"></button>
</div>
</div>`;
const el = s => root.querySelector(s);
const srcSel = el(".fl-src"), durSel = el(".fl-dur"),
wordEl = el(".fl-word"), picEl = el(".fl-pic"), cntEl = el(".fl-cnt");
function list(){ S.card = normWordSource(S.card); return wordSourceList(S.card); }
function buildSrc(){
fillWordSourceSel(srcSel, S.card);
durSel.innerHTML = "";
[0.5,1,2,3,5].forEach(d=>{
const o = document.createElement("option");
o.value = d; o.textContent = d+" "+T("flSec");
durSel.appendChild(o);
});
durSel.value = S.dur;
}
function newRound(){
S.order = shuffle(list().map((_,i)=>i));
S.idx = 0;
}
function cur(){ return list()[S.order[S.idx]]; }
function hide(){
wordEl.textContent = "• • •";
wordEl.classList.add("dim");
picEl.textContent = "";
}
function flash(){
clearTimeout(flashTimer);
wordEl.textContent = cur()[0];
wordEl.classList.remove("dim");
picEl.textContent = "";
cntEl.textContent = `${T("word")} ${S.idx+1}/${S.order.length}`;
flashTimer = setTimeout(hide, S.dur*1000);
}
function show(){
clearTimeout(flashTimer);
wordEl.textContent = cur()[0];
wordEl.classList.remove("dim");
setPic(picEl, cur()[1]);
}
el(".fl-flash").addEventListener("click", flash);
el(".fl-show").addEventListener("click", show);
el(".fl-next").addEventListener("click", ()=>{
S.idx++;
if(S.idx >= S.order.length) newRound();
flash();
});
srcSel.addEventListener("change", ()=>{
S.card = srcSel.value.startsWith("t") ? srcSel.value : +srcSel.value;
newRound(); hide();
cntEl.textContent = "";
});
durSel.addEventListener("change", ()=>{ S.dur = +durSel.value; });
function labels(){
el(".fl-flash").textContent = T("flFlash");
el(".fl-show").textContent = T("flShow");
el(".fl-next").textContent = T("flNext");
buildSrc();
}
document.addEventListener("langchange", ()=>{ S.card=0; labels(); newRound(); hide(); });
document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); hide(); });
labels(); newRound();
return { getState: ()=>({card:S.card, dur:S.dur}) };
}

94
public/js/widgets/hak.js Normal file
View file

@ -0,0 +1,94 @@
/* widget: Hakken & plakken */
/* =========================================================
Chop & blend widget words in sounds, with read-aloud
==========================================================*/
function mountHak(root, initState){
const S = { card:0, idx:0, order:[], chopped:false };
if(initState) S.card = initState.card ?? 0;
const canSpeak = "speechSynthesis" in window;
root.innerHTML = `
<div class="mt">
<div class="mt-top">
<select class="fl-sel hk-src"></select>
<div class="mt-score hk-cnt"></div>
</div>
<div class="mt-card">
<div class="fl-pic hk-pic"></div>
<div class="hk-word"></div>
</div>
<div class="trow" style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap;flex:none;">
<button class="tbtn hk-say" ${canSpeak?"":'style="display:none"'}></button>
<button class="tbtn ghost hk-chop"></button>
<button class="tbtn ghost hk-glue"></button>
<button class="tbtn ghost hk-next"></button>
</div>
</div>`;
const el = s => root.querySelector(s);
const srcSel = el(".hk-src"), wordEl = el(".hk-word"),
picEl = el(".hk-pic"), cntEl = el(".hk-cnt");
function list(){ S.card = normWordSource(S.card); return wordSourceList(S.card); }
function buildSrc(){ fillWordSourceSel(srcSel, S.card); }
function cur(){ return list()[S.order[S.idx]]; }
function newRound(){ S.order = shuffle(list().map((_,i)=>i)); S.idx = 0; }
function display(){
const [w, pic] = cur();
wordEl.innerHTML = "";
if(S.chopped){
w.split("").forEach((l,i)=>{
if(i>0){
const sep = document.createElement("span");
sep.className = "hk-sep"; sep.textContent = "";
wordEl.appendChild(sep);
}
const s = document.createElement("span");
s.className = "hk-l " + (VOWELS.includes(l) ? "v" : "c");
s.textContent = l;
s.style.animationDelay = (i*0.08)+"s";
wordEl.appendChild(s);
});
}else{
const s = document.createElement("span");
s.className = "hk-whole";
s.textContent = w;
wordEl.appendChild(s);
}
setPic(picEl, pic);
cntEl.textContent = `${T("word")} ${S.idx+1}/${S.order.length}`;
}
function speak(text, rate){
if(!canSpeak) return;
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.lang = LANG==="nl" ? "nl-NL" : "en-GB";
u.rate = rate;
speechSynthesis.speak(u);
}
el(".hk-say").addEventListener("click", ()=>{
if(S.chopped) speak(cur()[0].split("").join(", "), 0.7);
else speak(cur()[0], 0.85);
});
el(".hk-chop").addEventListener("click", ()=>{ S.chopped = true; display(); play(sndGood); });
el(".hk-glue").addEventListener("click", ()=>{ S.chopped = false; display(); play(sndGood); });
el(".hk-next").addEventListener("click", ()=>{
S.idx++; if(S.idx >= S.order.length) newRound();
S.chopped = false; display();
});
const play = f=>f();
srcSel.addEventListener("change", ()=>{
S.card = srcSel.value.startsWith("t") ? srcSel.value : +srcSel.value;
newRound(); S.chopped = false; display();
});
function labels(){
el(".hk-say").textContent = T("hakSay");
el(".hk-chop").textContent = T("hakChop");
el(".hk-glue").textContent = T("hakGlue");
buildSrc();
}
document.addEventListener("langchange", ()=>{ S.card=0; labels(); newRound(); S.chopped=false; display(); });
document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); S.chopped=false; display(); });
labels(); newRound(); display();
return { getState: ()=>({card:S.card}) };
}

View file

@ -0,0 +1,615 @@
/* widget: Letterblokken (Ik leer lezen) */
function mountLetterGame(root, initState){
const S = { level:1, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks" };
if(initState){
S.level = initState.level||1;
S.card = initState.card ?? "g";
S.count = initState.count||6;
S.snd = initState.snd !== false;
S.amode = initState.amode||"blocks";
}
const play = f=>{ if(S.snd) f(); };
root.innerHTML = `
<div class="lg">
<div class="lg-top">
<button class="lg-snd"></button>
<button class="lg-mode"></button>
<div class="lg-levels">
<button data-l="1"></button><button data-l="2"></button><button data-l="3"></button>
</div>
<div class="lg-cards"></div>
<select class="lg-num"></select>
<div class="lg-progress"><span class="lg-count"></span><span class="lg-stars"></span></div>
</div>
<div class="lg-instr"></div>
<div class="lg-card">
<div class="lg-pic"></div>
<div class="lg-slots"></div>
<div class="lg-done"><div class="dg">🌟</div><div class="dt"></div><div class="dp" style="color:var(--muted);font-weight:700;"></div></div>
</div>
<div class="lg-tray blocks-tray"></div>
<div class="lg-tray lg-kbd"></div>
<div class="lg-bottom"><button class="lg-next"></button></div>
<div class="lg-editor">
<div class="lg-ed-head"><b class="ed-title"></b><button class="wclose lg-ed-close"></button></div>
<div class="lg-ed-themerow">
<select class="ed-theme-sel"></select>
<button class="tbtn ed-theme-add"></button>
<input type="text" class="ed-theme-name" maxlength="16" style="display:none;">
<button class="ed-theme-del">🗑</button>
</div>
<div class="lg-ed-form">
<input type="text" class="ed-word" maxlength="5">
<div class="ed-preview">?</div>
<button class="ed-add"></button>
<div class="ed-msg"></div>
</div>
<div class="ed-tabs">
<button data-t="emoji"></button><button data-t="picto"></button><button data-t="upload"></button>
</div>
<div class="ed-pane">
<div class="ed-p-emoji">
<div class="ed-cats"></div>
<div class="ed-grid"></div>
</div>
<div class="ed-p-picto" style="display:none;">
<div class="ed-pictorow">
<input type="text" class="ed-psearch" maxlength="30">
<button class="tbtn ed-pgo"></button>
</div>
<div class="ed-status"></div>
<div class="ed-pgrid"></div>
<div class="ed-credit"></div>
</div>
<div class="ed-p-upload" style="display:none;">
<label class="ed-upload"><span class="ed-uplbl"></span><input type="file" accept="image/*"></label>
<div class="ed-uphint"></div>
</div>
</div>
<div class="lg-ed-list"></div>
</div>
</div>`;
const el = s => root.querySelector(s);
const slotsEl = el(".lg-slots"), trayEl = el(".blocks-tray"), picEl = el(".lg-pic"),
instrEl = el(".lg-instr"), nextBtn = el(".lg-next"), doneEl = el(".lg-done"),
countEl = el(".lg-count"), starsEl = el(".lg-stars"), cardsEl = el(".lg-cards");
/* card buttons (built dynamically: N cards + star + edit) */
const numSel = root.querySelector(".lg-num");
function buildNumSel(){
numSel.innerHTML = "";
[4,6,8,10,"all"].forEach(v=>{
const o = document.createElement("option");
o.value = v;
o.textContent = v==="all" ? T("all") : v;
numSel.appendChild(o);
});
numSel.value = S.count;
numSel.title = T("numWords");
}
numSel.addEventListener("change", ()=>{
S.count = numSel.value==="all" ? "all" : +numSel.value;
newRound(); render();
});
const sndBtn = root.querySelector(".lg-snd");
sndBtn.addEventListener("click", ()=>{
S.snd = !S.snd;
updateSndBtn();
});
function updateSndBtn(){
sndBtn.textContent = S.snd ? "🔔" : "🔕";
sndBtn.classList.toggle("off", !S.snd);
sndBtn.title = T("sndWidget");
modeBtn.textContent = S.amode==="blocks" ? "🧱" : "⌨️";
modeBtn.title = T("amode");
root.querySelector(".lg").classList.toggle("typemode", S.amode==="type");
}
const modeBtn = root.querySelector(".lg-mode");
modeBtn.addEventListener("click", ()=>{
S.amode = S.amode==="blocks" ? "type" : "blocks";
updateSndBtn();
});
/* on-screen letter keyboard for type mode */
const kbdEl = root.querySelector(".lg-kbd");
"abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{
const b = document.createElement("button");
b.textContent = l;
b.className = VOWELS.includes(l) ? "v" : "c";
b.addEventListener("click", ()=>typeLetter(l));
kbdEl.appendChild(b);
});
function typeLetter(l){
if(doneEl.classList.contains("show") || edEl.classList.contains("open")) return;
const slot = slotsEl.querySelector(".slot:not(.filled)");
if(!slot) return;
if(slot.dataset.letter === l){
const b = makeBlock(l);
b.classList.add("in-slot");
b.dataset.placedAt = Date.now();
slot.appendChild(b);
slot.classList.add("filled");
play(sndGood);
checkComplete();
}else{
slot.classList.add("shake");
setTimeout(()=>slot.classList.remove("shake"), 400);
play(sndBad);
}
}
/* word source: one compact dropdown (general card + themes + anchor grids) */
let srcSel = null;
function buildCardButtons(){
cardsEl.innerHTML = "";
srcSel = document.createElement("select");
srcSel.className = "fl-sel";
fillWordSourceSel(srcSel, S.card);
srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; newRound(); render(); });
cardsEl.appendChild(srcSel);
const edit = document.createElement("button");
edit.className = "lg-edit"; edit.textContent = "✎";
edit.addEventListener("click", openEditor);
cardsEl.appendChild(edit);
}
buildCardButtons();
root.querySelectorAll(".lg-levels button").forEach(b=>{
b.addEventListener("click", ()=>{ S.level = +b.dataset.l; newRound(); render(); });
});
nextBtn.addEventListener("click", ()=>{
S.wordIdx++;
if(S.wordIdx >= S.order.length){ showCardDone(); } else render();
});
document.addEventListener("langchange", ()=>{
S.lang=LANG; S.card="g"; buildCardButtons(); buildNumSel(); newRound(); render();
});
document.addEventListener("wordschange", ()=>{
buildCardButtons(); newRound(); render();
if(edEl.classList.contains("open")){ renderThemeSel(); renderEdList(); }
});
function fullList(){
S.card = normWordSource(S.card);
return wordSourceList(S.card);
}
function newRound(){
const list = fullList();
const n = S.count==="all" ? list.length : Math.min(+S.count, list.length);
S.order = shuffle(list.map((_,i)=>i)).slice(0,n);
S.wordIdx = 0; S.stars = 0;
}
function currentWord(){ return fullList()[S.order[S.wordIdx]]; }
function makeBlock(letter){
const b = document.createElement("div");
b.className = "blok " + (VOWELS.includes(letter) ? "vowel" : "cons");
b.dataset.letter = letter;
b.innerHTML = `<div class="bl">${letter}</div>`;
const dg = dotGrid(letter); dg.classList.add("blok-dots");
b.appendChild(dg);
attachDrag(b);
return b;
}
function makeSlot(letter, showGhost){
const s = document.createElement("div");
s.className = "slot";
s.dataset.letter = letter;
s.innerHTML = `<div class="ghost">${showGhost ? letter : ""}</div>`;
const dg = dotGrid(letter); dg.classList.add("slot-dots");
s.appendChild(dg);
s.addEventListener("click", ()=>{
const placed = s.querySelector(".blok");
/* ignore the synthetic click that follows a successful drop */
if(placed && !placed.classList.contains("fixed") &&
Date.now() - (+placed.dataset.placedAt || 0) > 400){
placed.classList.remove("in-slot");
trayEl.appendChild(placed);
s.classList.remove("filled");
}
});
return s;
}
function distractors(word, n){
const pool = "abcdefghijklmnoprstuvwz".split("").filter(c=>!word.includes(c));
return shuffle(pool).slice(0,n);
}
function render(){
if(!S.order.length || S.wordIdx >= S.order.length) newRound();
doneEl.classList.remove("show");
nextBtn.classList.remove("show");
nextBtn.textContent = T("next");
root.querySelectorAll(".lg-levels button").forEach(b=>{
b.textContent = T("lvl"+b.dataset.l);
b.classList.toggle("active", +b.dataset.l===S.level);
});
cardsEl.querySelector(".lg-edit").title = T("manage");
numSel.title = T("numWords");
const allOpt = numSel.querySelector('option[value="all"]');
if(allOpt) allOpt.textContent = T("all");
const [word, pic] = currentWord();
setPic(picEl, pic);
countEl.textContent = `${T("word")} ${S.wordIdx+1}/${S.order.length}`;
starsEl.textContent = S.stars ? S.stars+" ★" : "";
slotsEl.innerHTML = ""; trayEl.innerHTML = "";
const letters = word.split("");
if(S.level===1){
instrEl.innerHTML = T("instr1");
letters.forEach(l=>slotsEl.appendChild(makeSlot(l,true)));
shuffle(letters.concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
}
else if(S.level===2){
const types = ["begin","end","mid"];
const posType = types[S.wordIdx % 3];
let idx = posType==="begin" ? 0 : posType==="end" ? letters.length-1 : Math.floor(letters.length/2);
instrEl.innerHTML = T("instr2_"+posType);
letters.forEach((l,i)=>{
if(i===idx){ slotsEl.appendChild(makeSlot(l,false)); }
else{
const s = makeSlot(l,true);
s.classList.add("filled");
const b = makeBlock(l); b.classList.add("in-slot","fixed");
b.style.pointerEvents = "none";
s.appendChild(b);
slotsEl.appendChild(s);
}
});
shuffle([letters[idx]].concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
}
else{
instrEl.innerHTML = T("instr3");
letters.forEach(l=>slotsEl.appendChild(makeSlot(l,false)));
shuffle(letters.concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
}
}
function checkComplete(){
const open = slotsEl.querySelectorAll(".slot:not(.filled)").length;
if(open===0){
S.stars++;
starsEl.textContent = S.stars ? S.stars+" ★" : "";
play(sndWin); confetti(el(".lg-card"));
doneEl.querySelector(".dt").textContent = T("welldone");
doneEl.querySelector(".dp").textContent = currentWord()[0];
doneEl.classList.add("show");
nextBtn.classList.add("show");
}
}
function showCardDone(){
doneEl.querySelector(".dt").textContent = T("cardDone");
doneEl.querySelector(".dp").textContent = T("pickCard");
doneEl.classList.add("show");
nextBtn.classList.remove("show");
newRound();
}
/* ---------- custom words editor ---------- */
const edEl = el(".lg-editor"), edWord = el(".ed-word"),
edPrev = el(".ed-preview"), edMsg = el(".ed-msg"), edList = el(".lg-ed-list"),
edFile = el(".ed-p-upload input"), edPSearch = el(".ed-psearch"),
edPGrid = el(".ed-pgrid"), edStatus = el(".ed-status"),
edThemeSel = el(".ed-theme-sel"), edThemeAdd = el(".ed-theme-add"),
edThemeName = el(".ed-theme-name"), edThemeDel = el(".ed-theme-del");
let edPic = "", edTab = "emoji", edCat = 0, edTheme = "g";
/* ---- word target: the general card or one of the themes ---- */
function renderThemeSel(){
const list = THEMES[S.lang];
if(edTheme !== "g" && edTheme >= list.length) edTheme = "g";
edThemeSel.innerHTML = "";
const og = document.createElement("option");
og.value = "g";
og.textContent = `${T("cardGeneral")} (${generalList().length})`;
edThemeSel.appendChild(og);
list.forEach((th,i)=>{
const o = document.createElement("option");
o.value = i; o.textContent = `${th.name} (${th.words.length})`;
edThemeSel.appendChild(o);
});
edThemeSel.value = edTheme;
edThemeAdd.title = T("newTheme");
edThemeDel.title = T("delTheme");
edThemeName.placeholder = T("themeNamePh");
}
edThemeSel.addEventListener("change", ()=>{
edTheme = edThemeSel.value==="g" ? "g" : +edThemeSel.value;
renderEdList();
});
edThemeAdd.addEventListener("click", ()=>{
edThemeName.style.display = "block";
edThemeName.value = "";
edThemeName.focus();
});
let themeDone = false;
function commitTheme(){
if(themeDone) return; themeDone = true;
const nm = edThemeName.value.trim();
if(nm){
THEMES[S.lang].push({name:nm, words:[]});
edTheme = THEMES[S.lang].length-1;
persistWords();
renderThemeSel(); renderEdList();
document.dispatchEvent(new CustomEvent("wordschange"));
}
edThemeName.style.display = "none";
setTimeout(()=>{ themeDone = false; }, 100);
}
edThemeName.addEventListener("keydown", ev=>{
ev.stopPropagation();
if(ev.key==="Enter") commitTheme();
if(ev.key==="Escape"){ themeDone=true; edThemeName.style.display="none"; setTimeout(()=>{themeDone=false;},100); }
});
edThemeName.addEventListener("blur", commitTheme);
edThemeDel.addEventListener("click", ()=>{
if(edTheme==="g" || !THEMES[S.lang].length) return;
THEMES[S.lang].splice(edTheme,1);
edTheme = "g";
persistWords();
renderThemeSel(); renderEdList();
document.dispatchEvent(new CustomEvent("wordschange"));
});
function selectPic(p, btn){
edPic = p; setPic(edPrev, p);
root.querySelectorAll(".ed-grid button.sel,.ed-pgrid button.sel").forEach(b=>b.classList.remove("sel"));
if(btn) btn.classList.add("sel");
}
/* tabs */
root.querySelectorAll(".ed-tabs button").forEach(b=>{
b.addEventListener("click", ()=>{ edTab = b.dataset.t; refreshTabs(); });
});
function refreshTabs(){
root.querySelectorAll(".ed-tabs button").forEach(b=>{
b.textContent = T("tab"+b.dataset.t[0].toUpperCase()+b.dataset.t.slice(1));
b.classList.toggle("active", b.dataset.t===edTab);
});
el(".ed-p-emoji").style.display = edTab==="emoji" ? "block" : "none";
el(".ed-p-picto").style.display = edTab==="picto" ? "block" : "none";
el(".ed-p-upload").style.display = edTab==="upload" ? "block" : "none";
if(edTab==="picto" && !edPSearch.value) edPSearch.value = edWord.value.trim();
}
/* emoji tab */
function renderEmojiCats(){
const cats = el(".ed-cats");
cats.innerHTML = "";
T("cats").forEach((name,i)=>{
const b = document.createElement("button");
b.textContent = name;
b.classList.toggle("active", i===edCat);
b.addEventListener("click", ()=>{ edCat=i; renderEmojiCats(); });
cats.appendChild(b);
});
const grid = el(".ed-grid");
grid.innerHTML = "";
EMOJI_CATS[edCat].forEach(em=>{
const b = document.createElement("button");
b.type="button"; b.textContent = em;
b.classList.toggle("sel", em===edPic);
b.addEventListener("click", ()=>selectPic(em, b));
grid.appendChild(b);
});
}
/* picto tab (ARASAAC) */
async function searchPictos(){
const term = edPSearch.value.trim();
if(!term) return;
edStatus.textContent = T("pictoLoad");
edPGrid.innerHTML = "";
try{
const resp = await fetch(`https://api.arasaac.org/api/pictograms/${S.lang}/search/${encodeURIComponent(term)}`);
if(!resp.ok) throw new Error("http "+resp.status);
const arr = await resp.json();
if(!arr.length){ edStatus.textContent = T("pictoNone"); return; }
edStatus.textContent = "";
arr.slice(0,12).forEach(p=>{
const url = `https://static.arasaac.org/pictograms/${p._id}/${p._id}_300.png`;
const b = document.createElement("button");
b.type="button";
const im = document.createElement("img");
im.src = url; im.loading = "lazy"; im.alt = term;
b.appendChild(im);
b.addEventListener("click", ()=>selectPic(url, b));
edPGrid.appendChild(b);
});
}catch(e){ edStatus.textContent = T("pictoErr"); }
}
el(".ed-pgo").addEventListener("click", searchPictos);
edPSearch.addEventListener("keydown", e=>{ if(e.key==="Enter") searchPictos(); });
/* typing a word auto-fills picto search */
edWord.addEventListener("input", ()=>{ edPSearch.value = edWord.value.trim(); });
/* upload tab */
edFile.addEventListener("change", ()=>{
const f = edFile.files[0]; if(!f) return;
const img = new Image();
img.onload = ()=>{
const c = document.createElement("canvas");
const s = Math.min(1, 128/Math.max(img.width, img.height));
c.width = Math.round(img.width*s); c.height = Math.round(img.height*s);
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
selectPic(c.toDataURL("image/png"));
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(f);
edFile.value = "";
});
el(".ed-add").addEventListener("click", ()=>{
const w = edWord.value.trim().toLowerCase();
if(!/^[a-z]{2,5}$/.test(w)){ edMsg.textContent = T("badWord"); return; }
if(!edPic){ edMsg.textContent = T("needPic"); return; }
edMsg.textContent = "";
if(edTheme==="g"){
GENERAL_EXTRA[S.lang].push([w, edPic]); /* extend the general card */
}else{
THEMES[S.lang][edTheme].words.push([w, edPic]);
}
persistWords();
renderThemeSel();
edWord.value = ""; edPSearch.value = ""; edPic = ""; edPrev.textContent = "?";
root.querySelectorAll(".ed-grid button.sel,.ed-pgrid button.sel").forEach(b=>b.classList.remove("sel"));
renderEdList();
play(sndGood);
document.dispatchEvent(new CustomEvent("wordschange"));
});
el(".lg-ed-close").addEventListener("click", ()=>{
edEl.classList.remove("open");
buildCardButtons();
newRound();
render();
});
function openEditor(){
el(".ed-title").textContent = T("manage");
el(".ed-uplbl").textContent = T("upload");
el(".ed-uphint").textContent = T("upHint");
el(".ed-credit").innerHTML = T("pictoCredit");
el(".ed-pgo").textContent = T("pictoSearch");
edPSearch.placeholder = T("pictoPh");
edWord.placeholder = T("wordPh");
el(".ed-add").textContent = T("addWord");
el(".lg-ed-close").title = T("close");
edMsg.textContent = ""; edStatus.textContent = "";
refreshTabs();
renderEmojiCats();
renderThemeSel();
renderEdList();
edEl.classList.add("open");
}
function renderEdList(){
edList.innerHTML = "";
const isGen = edTheme==="g";
const base = isGen ? WORDS[S.lang] : [];
const list = isGen ? GENERAL_EXTRA[S.lang] : (THEMES[S.lang][edTheme] ? THEMES[S.lang][edTheme].words : []);
if(!base.length && !list.length){
edList.innerHTML = `<div class="empty">${T("noWords")}</div>`;
return;
}
const addRow = (entry, delFn)=>{
const row = document.createElement("div");
row.className = "ed-row" + (delFn ? "" : " base");
const p = document.createElement("div"); p.className = "ep"; setPic(p, entry[1]);
const wv = document.createElement("div"); wv.className = "ew"; wv.textContent = entry[0];
row.appendChild(p); row.appendChild(wv);
if(delFn){
const del = document.createElement("button"); del.className = "edel"; del.textContent = "✕";
del.addEventListener("click", delFn);
row.appendChild(del);
}
edList.appendChild(row);
};
/* own words first (deletable), built-in general words below (fixed) */
list.forEach((entry, i)=>addRow(entry, ()=>{
list.splice(i,1);
persistWords();
renderThemeSel(); renderEdList();
document.dispatchEvent(new CustomEvent("wordschange"));
}));
base.forEach(entry=>addRow(entry, null));
}
/* ---------- drag & drop ----------
Hit-testing is geometric (cursor vs slot rectangles) instead of
elementFromPoint, so overlays/transforms can never break drops. */
function attachDrag(block){
block.addEventListener("pointerdown", e=>{
if(block.classList.contains("in-slot")) return;
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
/* avoid implicit touch capture retargeting the follow-up click */
try{ block.releasePointerCapture(e.pointerId); }catch(_){}
const clone = block.cloneNode(true);
clone.id = "dragClone";
document.body.appendChild(clone);
block.classList.add("dragging");
const moveTo = ev=>{ const zf = VZ(); clone.style.left = (ev.clientX/zf)+"px"; clone.style.top = (ev.clientY/zf)+"px"; };
moveTo(e);
let hovered = null;
const hitSlot = ev=>{
const pad = 8;
let found = null;
slotsEl.querySelectorAll(".slot:not(.filled)").forEach(s=>{
const r = s.getBoundingClientRect();
if(ev.clientX >= r.left-pad && ev.clientX <= r.right+pad &&
ev.clientY >= r.top-pad && ev.clientY <= r.bottom+pad) found = s;
});
return found;
};
const move = ev=>{
moveTo(ev);
const slot = hitSlot(ev);
if(hovered && hovered!==slot) hovered.classList.remove("droptarget");
hovered = slot;
if(hovered) hovered.classList.add("droptarget");
};
const up = ev=>{
document.removeEventListener("pointermove",move);
document.removeEventListener("pointerup",up);
document.removeEventListener("pointercancel",cancel);
clone.remove();
block.classList.remove("dragging");
if(hovered) hovered.classList.remove("droptarget");
const target = hitSlot(ev);
if(target){
if(target.dataset.letter === block.dataset.letter){
target.classList.add("filled");
block.classList.add("in-slot");
block.dataset.placedAt = Date.now();
target.appendChild(block);
play(sndGood);
checkComplete();
}else{
target.classList.add("shake");
setTimeout(()=>target.classList.remove("shake"), 400);
play(sndBad);
}
}
};
const cancel = ()=>{
document.removeEventListener("pointermove",move);
document.removeEventListener("pointerup",up);
document.removeEventListener("pointercancel",cancel);
clone.remove();
block.classList.remove("dragging");
if(hovered) hovered.classList.remove("droptarget");
};
document.addEventListener("pointermove",move);
document.addEventListener("pointerup",up);
document.addEventListener("pointercancel",cancel);
});
}
buildNumSel();
updateSndBtn();
newRound();
render();
document.addEventListener("langchange", updateSndBtn);
return {
getState: ()=>({ level:S.level, card:S.card, count:S.count, snd:S.snd, amode:S.amode }),
setState: st=>{ if(st){ S.level=st.level||1; S.card=st.card??0; S.count=st.count||6; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; buildNumSel(); updateSndBtn(); newRound(); render(); } }
};
}
function confetti(container){
const colors = ["#e0554d","#3b7dd8","#3fae6a","#f7c948","#9b59b6"];
const rect = container.getBoundingClientRect();
for(let i=0;i<28;i++){
const c = document.createElement("div");
c.className = "confetti";
c.style.left = Math.random()*rect.width + "px";
c.style.background = colors[i%colors.length];
c.style.animationDuration = (1.2+Math.random()*1.4)+"s";
c.style.animationDelay = (Math.random()*0.3)+"s";
container.appendChild(c);
setTimeout(()=>c.remove(), 3200);
}
}

View file

@ -0,0 +1,24 @@
/* stoplicht (bordgereedschap-variant zit in board.js) */
/* =========================================================
Traffic light widget
==========================================================*/
function mountLight(root, initState){
let on = (initState && initState.on) || "green";
root.innerHTML = `
<div class="tl">
<div class="tl-house">
<button class="tl-lamp red" data-c="red"></button>
<button class="tl-lamp orange" data-c="orange"></button>
<button class="tl-lamp green" data-c="green"></button>
</div>
</div>`;
function apply(){
root.querySelectorAll(".tl-lamp").forEach(l=>l.classList.toggle("on", l.dataset.c===on));
}
root.querySelectorAll(".tl-lamp").forEach(l=>{
l.addEventListener("click", ()=>{ on = l.dataset.c; apply(); });
});
apply();
return { getState: ()=>({on}) };
}

292
public/js/widgets/math.js Normal file
View file

@ -0,0 +1,292 @@
/* widgets: Rekenen (+ - x : %) */
/* =========================================================
Math practice engine add / sub / mul / div / percentages
==========================================================*/
function mountMath(root, initState, mode){
const ROUND = 10;
const S = { level:1, snd:true, amode:"blocks", q:null, tries:0, done:0, good:0, input:"", busy:false };
if(initState){
S.level = initState.level||1;
S.snd = initState.snd!==false;
S.amode = initState.amode||"blocks";
}
const play = f=>{ if(S.snd) f(); };
const rnd = n => Math.floor(Math.random()*n);
root.innerHTML = `
<div class="mt blocks">
<div class="mt-top">
<button class="lg-snd"></button>
<button class="lg-mode"></button>
<div class="lg-levels">
<button data-l="1"></button><button data-l="2"></button><button data-l="3"></button>
</div>
<div class="mt-score"></div>
</div>
<div class="mt-card">
<div class="mt-q"></div>
<div class="mt-a"></div>
<div class="mt-slots"></div>
<div class="mt-fb"></div>
<div class="mt-done">
<div class="dg">🌟</div><div class="dt"></div><div class="ds"></div>
<button class="mt-again"></button>
</div>
</div>
<div class="lg-tray mt-tray"></div>
<div class="mt-pad"></div>
</div>`;
const el = s => root.querySelector(s);
const mtEl = el(".mt"), qEl = el(".mt-q"), aEl = el(".mt-a"), fbEl = el(".mt-fb"),
scoreEl = el(".mt-score"), doneEl = el(".mt-done"),
sndBtn = el(".lg-snd"), modeBtn = el(".lg-mode"),
padEl = el(".mt-pad"), slotsEl = el(".mt-slots"), trayEl = el(".mt-tray");
/* ---- digit blocks with dot patterns (like the letter blocks) ---- */
function makeDigitBlock(d){
const b = document.createElement("div");
b.className = "blok sm";
b.dataset.digit = d;
b.innerHTML = `<div class="bl">${d}</div>`;
const dg = dotGrid(d); dg.classList.add("blok-dots");
b.appendChild(dg);
attachDigitDrag(b);
return b;
}
"0123456789".split("").forEach(d=>trayEl.appendChild(makeDigitBlock(d)));
function makeDigitSlot(d){
const s = document.createElement("div");
s.className = "slot sm";
s.dataset.digit = d;
s.innerHTML = `<div class="ghost"></div>`;
const dg = dotGrid(d); dg.classList.add("slot-dots");
s.appendChild(dg);
s.addEventListener("click", ()=>{
const placed = s.querySelector(".blok");
if(placed && Date.now() - (+placed.dataset.placedAt||0) > 400){
placed.remove();
s.classList.remove("filled");
}
});
return s;
}
function attachDigitDrag(block){
block.addEventListener("pointerdown", e=>{
if(e.pointerType==="mouse" && e.button!==0) return;
if(S.busy || doneEl.classList.contains("show")) return;
e.preventDefault();
try{ block.releasePointerCapture(e.pointerId); }catch(_){}
const clone = block.cloneNode(true);
clone.id = "dragClone";
document.body.appendChild(clone);
const moveTo = ev=>{ const zf = VZ(); clone.style.left = (ev.clientX/zf)+"px"; clone.style.top = (ev.clientY/zf)+"px"; };
moveTo(e);
let hovered = null;
const hit = ev=>{
const pad = 8;
let found = null;
slotsEl.querySelectorAll(".slot:not(.filled)").forEach(s=>{
const r = s.getBoundingClientRect();
if(ev.clientX>=r.left-pad && ev.clientX<=r.right+pad &&
ev.clientY>=r.top-pad && ev.clientY<=r.bottom+pad) found = s;
});
return found;
};
const move = ev=>{
moveTo(ev);
const s = hit(ev);
if(hovered && hovered!==s) hovered.classList.remove("droptarget");
hovered = s;
if(hovered) hovered.classList.add("droptarget");
};
const up = ev=>{
document.removeEventListener("pointermove",move);
document.removeEventListener("pointerup",up);
document.removeEventListener("pointercancel",cancel);
clone.remove();
if(hovered) hovered.classList.remove("droptarget");
const target = hit(ev);
if(target){
if(target.dataset.digit === block.dataset.digit){
const copy = block.cloneNode(true);
copy.classList.add("in-slot");
copy.dataset.placedAt = Date.now();
target.appendChild(copy);
target.classList.add("filled");
play(sndGood);
if(!slotsEl.querySelector(".slot:not(.filled)")) blocksSolved();
}else{
target.classList.add("shake");
setTimeout(()=>target.classList.remove("shake"), 400);
play(sndBad);
}
}
};
const cancel = ()=>{
document.removeEventListener("pointermove",move);
document.removeEventListener("pointerup",up);
document.removeEventListener("pointercancel",cancel);
clone.remove();
if(hovered) hovered.classList.remove("droptarget");
};
document.addEventListener("pointermove",move);
document.addEventListener("pointerup",up);
document.addEventListener("pointercancel",cancel);
});
}
function blocksSolved(){
S.busy = true;
S.good++;
fbEl.textContent = T("mGood");
play(sndWin);
confetti(el(".mt-card"));
setTimeout(next, 1000);
}
function gen(){
const L = S.level;
let a, b, ans, txt;
if(mode==="add"){
const max = L===1 ? 10 : L===2 ? 20 : 100;
a = rnd(max+1); b = rnd(max-a+1); ans = a+b;
txt = `${a} + ${b} =`;
}else if(mode==="sub"){
const max = L===1 ? 10 : L===2 ? 20 : 100;
a = rnd(max+1); b = rnd(a+1); ans = a-b;
txt = `${a} ${b} =`;
}else if(mode==="mul"){
const tmax = L===1 ? 5 : L===2 ? 10 : 12;
a = 1+rnd(tmax); b = 1+rnd(L===3 ? 12 : 10); ans = a*b;
txt = `${a} × ${b} =`;
}else if(mode==="div"){
const tmax = L===1 ? 5 : L===2 ? 10 : 12;
const t = 1+rnd(tmax), q = 1+rnd(L===3 ? 12 : 10);
a = t*q; ans = q;
txt = `${a} ${S.lang==="nl" || LANG==="nl" ? ":" : "÷"} ${t} =`;
}else{ /* pct */
const sets = L===1 ? [10,50,100] : L===2 ? [10,20,25,50,75,100] : [5,10,20,25,30,40,50,60,70,75,80,90];
const p = sets[rnd(sets.length)];
const step = {5:20,10:10,20:5,25:4,30:10,40:5,50:2,60:5,70:10,75:4,80:5,90:10,100:1}[p];
const base = step*(1+rnd(12));
ans = p*base/100;
txt = `${p}% ${T("mOf")} ${base} =`;
}
return { txt, ans };
}
function newQuestion(){
let q = gen();
if(S.q && q.txt===S.q.txt) q = gen(); /* avoid the same sum twice in a row */
S.q = q; S.tries = 0; S.input = ""; S.busy = false;
qEl.textContent = q.txt;
aEl.textContent = "";
aEl.classList.remove("ok");
fbEl.textContent = ""; fbEl.classList.remove("ans");
slotsEl.innerHTML = "";
if(S.amode==="blocks"){
String(q.ans).split("").forEach(d=>slotsEl.appendChild(makeDigitSlot(d)));
}
updateScore();
}
function updateScore(){
scoreEl.textContent = `${S.good} ${T("mGoed")} · ${Math.min(S.done+1,ROUND)}/${ROUND}`;
}
function endRound(){
doneEl.querySelector(".dt").textContent = T("mDoneT");
doneEl.querySelector(".ds").textContent = `${S.good}/${ROUND} ${T("mGoed")}`;
doneEl.querySelector(".dg").textContent = S.good>=8 ? "🌟" : S.good>=5 ? "👍" : "💪";
doneEl.classList.add("show");
if(S.good>=8) play(sndWin);
}
function next(){
S.done++;
if(S.done >= ROUND){ endRound(); }
else newQuestion();
}
function check(){
if(S.busy || S.input==="") return;
if(+S.input === S.q.ans){
S.busy = true;
S.good++;
aEl.classList.add("ok");
fbEl.textContent = T("mGood");
play(sndGood);
confetti(el(".mt-card"));
setTimeout(next, 900);
}else{
S.tries++;
aEl.classList.add("shake");
setTimeout(()=>aEl.classList.remove("shake"), 400);
play(sndBad);
S.input = ""; aEl.textContent = "";
if(S.tries >= 3){
S.busy = true;
fbEl.textContent = `${T("mAnsWas")} ${S.q.ans}`;
fbEl.classList.add("ans");
setTimeout(next, 1800);
}
}
}
["7","8","9","⌫","4","5","6","0","1","2","3","✓"].forEach(k=>{
const b = document.createElement("button");
b.textContent = k;
if(k==="✓") b.className = "ok";
b.addEventListener("click", ()=>{
if(doneEl.classList.contains("show")) return;
if(k==="⌫"){ if(!S.busy){ S.input = S.input.slice(0,-1); aEl.textContent = S.input; } }
else if(k==="✓") check();
else if(!S.busy && S.input.length<4){ S.input += k; aEl.textContent = S.input; }
});
padEl.appendChild(b);
});
el(".mt-again").addEventListener("click", ()=>{
S.done = 0; S.good = 0;
doneEl.classList.remove("show");
newQuestion();
});
root.querySelectorAll(".lg-levels button").forEach(b=>{
b.addEventListener("click", ()=>{
S.level = +b.dataset.l;
S.done = 0; S.good = 0;
doneEl.classList.remove("show");
labels(); newQuestion();
});
});
sndBtn.addEventListener("click", ()=>{
S.snd = !S.snd;
sndBtn.textContent = S.snd ? "🔔" : "🔕";
sndBtn.classList.toggle("off", !S.snd);
});
modeBtn.addEventListener("click", ()=>{
S.amode = S.amode==="blocks" ? "type" : "blocks";
labels(); newQuestion();
});
function labels(){
root.querySelectorAll(".lg-levels button").forEach(b=>{
b.textContent = T("lvl"+b.dataset.l);
b.classList.toggle("active", +b.dataset.l===S.level);
});
sndBtn.textContent = S.snd ? "🔔" : "🔕";
sndBtn.classList.toggle("off", !S.snd);
sndBtn.title = T("sndWidget");
modeBtn.textContent = S.amode==="blocks" ? "🧱" : "⌨️";
modeBtn.title = T("amode");
mtEl.classList.toggle("blocks", S.amode==="blocks");
el(".mt-again").textContent = T("mAgain");
updateScore();
}
document.addEventListener("langchange", ()=>{ labels(); newQuestion(); });
labels();
newQuestion();
return {
getState: ()=>({ level:S.level, snd:S.snd, amode:S.amode }),
setState: st=>{ if(st){ S.level=st.level||1; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; labels(); newQuestion(); } }
};
}

107
public/js/widgets/media.js Normal file
View file

@ -0,0 +1,107 @@
/* widget: Foto & video */
/* =========================================================
Media widget photos and videos from external sites
==========================================================*/
function mountMedia(root, initState){
let cur = null;
root.innerHTML = `
<div class="media">
<div class="media-bar">
<input type="text" class="m-url" spellcheck="false">
<button class="tbtn m-go"></button>
<button class="tbtn ghost m-ext"></button>
</div>
<div class="media-stage"><div class="media-msg"></div></div>
</div>`;
const urlInp = root.querySelector(".m-url"),
goBtn = root.querySelector(".m-go"),
extBtn = root.querySelector(".m-ext"),
stage = root.querySelector(".media-stage");
function labels(){
urlInp.placeholder = T("mediaPh");
goBtn.textContent = T("mediaShow");
extBtn.title = T("mediaOpen");
const msg = stage.querySelector(".media-msg");
if(msg) msg.textContent = T(msg.dataset.err ? "mediaErr" : "mediaEmpty");
}
function showMsg(err){
stage.innerHTML = "";
const m = document.createElement("div");
m.className = "media-msg";
if(err) m.dataset.err = "1";
m.textContent = T(err ? "mediaErr" : "mediaEmpty");
stage.appendChild(m);
}
function show(raw){
let url = raw.trim();
if(!url){ showMsg(false); return; }
if(!/^https?:\/\//i.test(url)) url = "https://"+url;
let node = null;
const yt = url.match(/(?:youtube\.com\/(?:watch\?[^#]*v=|shorts\/|embed\/|live\/)|youtu\.be\/)([\w-]{6,})/);
const vi = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
if(yt){
/* show the thumbnail first (always works); play attempts the embed */
const id = yt[1];
stage.innerHTML = "";
const wrap = document.createElement("div");
wrap.className = "media-yt";
const im = document.createElement("img");
im.src = "https://i.ytimg.com/vi/"+id+"/hqdefault.jpg"; im.alt = "";
const pb = document.createElement("button");
pb.className = "media-play"; pb.textContent = "▶";
pb.addEventListener("click", ()=>{
const f = document.createElement("iframe");
f.src = "https://www.youtube.com/embed/"+id+"?rel=0&autoplay=1";
f.referrerPolicy = "strict-origin-when-cross-origin";
f.allow = "autoplay; encrypted-media; picture-in-picture";
f.allowFullscreen = true;
stage.innerHTML = "";
stage.appendChild(f);
});
wrap.appendChild(im); wrap.appendChild(pb);
stage.appendChild(wrap);
cur = url; urlInp.value = url;
return;
}
if(vi){
node = document.createElement("iframe");
node.src = "https://player.vimeo.com/video/"+vi[1];
node.allow = "autoplay; fullscreen; picture-in-picture";
node.allowFullscreen = true;
}else if(/\.(jpe?g|png|gif|webp|svg|avif)([?#]|$)/i.test(url)){
node = document.createElement("img");
node.src = url; node.alt = "";
node.addEventListener("error", ()=>showMsg(true));
}else if(/\.(mp4|webm|ogg|mov)([?#]|$)/i.test(url)){
node = document.createElement("video");
node.src = url; node.controls = true;
node.addEventListener("error", ()=>showMsg(true));
}
if(node){
stage.innerHTML = "";
stage.appendChild(node);
cur = url;
urlInp.value = url;
}else{
showMsg(true);
}
}
goBtn.addEventListener("click", ()=>show(urlInp.value));
extBtn.addEventListener("click", ()=>{
let u = (cur || urlInp.value).trim();
if(!u) return;
if(!/^https?:\/\//i.test(u)) u = "https://"+u;
window.open(u, "_blank");
});
urlInp.addEventListener("keydown", e=>{
e.stopPropagation();
if(e.key==="Enter") show(urlInp.value);
});
document.addEventListener("langchange", labels);
if(initState && initState.url){ urlInp.value = initState.url; show(initState.url); }
else showMsg(false);
labels();
return { getState: ()=>({url: cur || ""}) };
}

252
public/js/widgets/mind.js Normal file
View file

@ -0,0 +1,252 @@
/* widget: Mindmap */
/* =========================================================
Mindmap widget spider, tree or word web
==========================================================*/
function mountMind(root, initState){
const S = { layout:"spider", nodes:[{id:1, x:0.5, y:0.45, t:"", parent:null}], nextId:2 };
if(initState){
S.layout = initState.layout || "spider";
if(Array.isArray(initState.nodes) && initState.nodes.length) S.nodes = initState.nodes;
S.nextId = initState.nextId || (Math.max(...S.nodes.map(n=>n.id))+1);
}
const PALETTE = ["#3b7dd8","#3fae6a","#e0554d","#9b59b6","#f28c38"];
root.innerHTML = `
<div class="mm spider">
<div class="tseg mm-seg">
<button data-m="spider"></button><button data-m="tree"></button><button data-m="web"></button>
</div>
<div class="mm-hint"></div>
<div class="mm-stage"><svg></svg></div>
</div>`;
const mmEl = root.querySelector(".mm");
/* toolbar row wrapper */
const topRow = document.createElement("div");
topRow.className = "mm-top";
mmEl.insertBefore(topRow, mmEl.firstChild);
const mmGrip = document.createElement("span");
mmGrip.className = "mm-grip"; mmGrip.textContent = "⠿";
topRow.appendChild(mmGrip);
topRow.appendChild(root.querySelector(".mm-seg"));
topRow.appendChild(root.querySelector(".mm-hint"));
mmGrip.addEventListener("pointerdown", e=>{
e.preventDefault(); e.stopPropagation();
const mr = mmEl.getBoundingClientRect();
const sc = mr.width / mmEl.offsetWidth; /* effective zoom on this widget */
const ox = e.clientX/sc - topRow.offsetLeft, oy = e.clientY/sc - topRow.offsetTop;
const mv = ev=>{
topRow.style.left = Math.max(0, Math.min(mmEl.offsetWidth - topRow.offsetWidth, ev.clientX/sc - ox))+"px";
topRow.style.top = Math.max(0, Math.min(mmEl.offsetHeight - topRow.offsetHeight, ev.clientY/sc - oy))+"px";
};
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
});
const stage = root.querySelector(".mm-stage"),
svg = stage.querySelector("svg");
function nodeById(id){ return S.nodes.find(n=>n.id===id); }
function depth(n){ let d=0, c=n; while(c && c.parent!=null){ c = nodeById(c.parent); d++; } return d; }
function drawLines(){
const w = stage.clientWidth, h = stage.clientHeight;
let paths = "";
S.nodes.forEach(n=>{
if(n.parent==null) return;
const p = nodeById(n.parent);
if(!p) return;
const x1 = p.x*w, y1 = p.y*h, x2 = n.x*w, y2 = n.y*h;
const col = n.lc || PALETTE[(depth(n)-1) % PALETTE.length];
const dash = n.ls==="dash" ? ' stroke-dasharray="11 8"' : n.ls==="dot" ? ' stroke-dasharray="2.5 7"' : "";
if(S.layout==="spider"){
const mx = (x1+x2)/2 + (y2-y1)*0.18, my = (y1+y2)/2 - (x2-x1)*0.18;
paths += `<path d="M${x1},${y1} Q${mx},${my} ${x2},${y2}" fill="none" stroke="${col}" stroke-width="3"${dash}/>`;
}else if(S.layout==="tree"){
const my = (y1+y2)/2;
paths += `<path d="M${x1},${y1} L${x1},${my} L${x2},${my} L${x2},${y2}" fill="none" stroke="${col}" stroke-width="3"${dash}/>`;
}else{
paths += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${col}" stroke-width="3"${dash}/>`;
}
});
svg.innerHTML = paths;
}
function applyNodeStyle(n, d){
if(n.parent!=null) d.style.borderColor = n.c || PALETTE[(depth(n)-1) % PALETTE.length];
else if(n.c) d.style.borderColor = n.c;
d.style.fontSize = (n.fs || (n.parent==null ? 17 : 15))+"px";
}
function buildNode(n){
const d = document.createElement("div");
d.className = "mm-node" + (n.parent==null ? " root" : "");
applyNodeStyle(n, d);
d.dataset.id = n.id;
const span = document.createElement("span");
span.contentEditable = "true";
span.spellcheck = false;
span.textContent = n.t || (n.parent==null ? T("mmRoot") : "");
span.addEventListener("input", ()=>{ n.t = span.textContent; });
span.addEventListener("keydown", e=>e.stopPropagation());
span.addEventListener("pointerdown", e=>e.stopPropagation());
d.appendChild(span);
const add = document.createElement("button");
add.className = "mm-add"; add.textContent = "";
add.addEventListener("pointerdown", e=>e.stopPropagation());
add.addEventListener("click", ()=>{
const a = Math.random()*Math.PI*2;
S.nodes.push({
id: S.nextId++, parent: n.id,
x: Math.min(.95, Math.max(.05, n.x + Math.cos(a)*0.17)),
y: Math.min(.92, Math.max(.06, n.y + Math.sin(a)*0.2)),
t: ""
});
render();
});
d.appendChild(add);
const cog = document.createElement("button");
cog.className = "mm-cog"; cog.textContent = "🎨";
cog.addEventListener("pointerdown", e=>e.stopPropagation());
cog.addEventListener("click", ()=>openNodePop(n, d));
d.appendChild(cog);
if(n.parent!=null){
const del = document.createElement("button");
del.className = "mm-del"; del.textContent = "✕";
del.addEventListener("pointerdown", e=>e.stopPropagation());
del.addEventListener("click", ()=>{
const doomed = new Set([n.id]);
let grew = true;
while(grew){
grew = false;
S.nodes.forEach(m=>{
if(m.parent!=null && doomed.has(m.parent) && !doomed.has(m.id)){ doomed.add(m.id); grew = true; }
});
}
S.nodes = S.nodes.filter(m=>!doomed.has(m.id));
render();
});
d.appendChild(del);
}
/* drag */
d.addEventListener("pointerdown", e=>{
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
const r = stage.getBoundingClientRect();
const mv = ev=>{
n.x = Math.min(.98, Math.max(.02, (ev.clientX-r.left)/r.width));
n.y = Math.min(.96, Math.max(.04, (ev.clientY-r.top)/r.height));
d.style.left = (n.x*100)+"%";
d.style.top = (n.y*100)+"%";
drawLines();
};
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
document.addEventListener("pointermove",mv);
document.addEventListener("pointerup",up);
});
d.style.left = (n.x*100)+"%";
d.style.top = (n.y*100)+"%";
return d;
}
/* per-node settings popover: colour, font size, line colour & style */
function closeNodePop(){ stage.querySelectorAll(".mm-pop").forEach(x=>x.remove()); }
function openNodePop(n, nodeEl){
closeNodePop();
const pop = document.createElement("div");
pop.className = "mm-pop";
const w = stage.clientWidth, h = stage.clientHeight;
pop.style.left = Math.min(n.x*w + 30, w-190)+"px";
pop.style.top = Math.min(n.y*h + 24, h-150)+"px";
pop.addEventListener("pointerdown", e=>e.stopPropagation());
const restyle = ()=>{ applyNodeStyle(n, nodeEl); drawLines(); refreshPop(); };
const row = (lbl, title)=>{
const r = document.createElement("div");
r.className = "mp-row"; r.title = title;
const l = document.createElement("span");
l.className = "mp-lbl"; l.textContent = lbl;
r.appendChild(l);
pop.appendChild(r);
return r;
};
/* node colour */
const r1 = row("⬤", T("nodeColor"));
PALETTE.concat(["#26344a"]).forEach(c=>{
const b = document.createElement("button");
b.className = "mp-sw"; b.dataset.v = c; b.style.background = c;
b.addEventListener("click", ()=>{ n.c = (n.c===c ? null : c); restyle(); });
r1.appendChild(b);
});
const xBtn = document.createElement("button");
xBtn.className = "mp-x"; xBtn.textContent = "✕";
xBtn.addEventListener("click", closeNodePop);
r1.appendChild(xBtn);
/* font size */
const r2 = row("A", T("nodeFont"));
[["A",13],["A",16],["A",20]].forEach(([txt,fs],i)=>{
const b = document.createElement("button");
b.className = "mp-fs"; b.dataset.v = fs;
b.textContent = txt; b.style.fontSize = (11+i*3)+"px";
b.addEventListener("click", ()=>{ n.fs = fs; restyle(); });
r2.appendChild(b);
});
if(n.parent!=null){
/* line colour */
const r3 = row("", T("lineColor"));
PALETTE.concat(["#26344a"]).forEach(c=>{
const b = document.createElement("button");
b.className = "mp-sw"; b.dataset.v = "l"+c; b.style.background = c;
b.addEventListener("click", ()=>{ n.lc = (n.lc===c ? null : c); restyle(); });
r3.appendChild(b);
});
/* line style */
const r4 = row("┄", T("lineStyle"));
[["solid",""],["dash","dash"],["dot","dot"]].forEach(([id,val])=>{
const b = document.createElement("button");
b.className = "mp-ls"; b.dataset.ls = id; b.dataset.v = "s"+id;
b.appendChild(document.createElement("i"));
b.addEventListener("click", ()=>{ n.ls = val || null; restyle(); });
r4.appendChild(b);
});
}
function refreshPop(){
pop.querySelectorAll(".mp-sw").forEach(b=>{
const v = b.dataset.v;
b.classList.toggle("on", v.startsWith("l") ? n.lc===v.slice(1) : n.c===v);
});
pop.querySelectorAll(".mp-fs").forEach(b=>
b.classList.toggle("on", +b.dataset.v === (n.fs || (n.parent==null?17:15))));
pop.querySelectorAll(".mp-ls").forEach(b=>
b.classList.toggle("on", (n.ls||"") === (b.dataset.ls==="solid" ? "" : b.dataset.ls)));
}
refreshPop();
stage.appendChild(pop);
}
document.addEventListener("pointerdown", e=>{
if(!stage.querySelector(".mm-pop")) return;
if(!e.target.closest(".mm-pop") && !e.target.closest(".mm-cog")) closeNodePop();
});
function render(){
mmEl.className = "mm "+S.layout;
root.querySelectorAll(".mm-seg button").forEach(b=>{
b.textContent = T(b.dataset.m==="spider" ? "mmSpider" : b.dataset.m==="tree" ? "mmTree" : "mmWeb");
b.classList.toggle("on", b.dataset.m===S.layout);
});
root.querySelector(".mm-hint").textContent = T("mmHint");
closeNodePop();
stage.querySelectorAll(".mm-node").forEach(x=>x.remove());
S.nodes.forEach(n=>stage.appendChild(buildNode(n)));
drawLines();
}
root.querySelectorAll(".mm-seg button").forEach(b=>{
b.addEventListener("click", ()=>{ S.layout = b.dataset.m; render(); });
});
new ResizeObserver(drawLines).observe(stage);
document.addEventListener("langchange", render);
render();
return {
getState: ()=>({ layout:S.layout, nodes:S.nodes, nextId:S.nextId }),
setState: st=>{ if(st && st.nodes){ S.layout=st.layout||"spider"; S.nodes=st.nodes; S.nextId=st.nextId||S.nextId; render(); } }
};
}

View file

@ -0,0 +1,68 @@
/* widget: Namenkiezer */
/* =========================================================
Name picker widget
==========================================================*/
function mountNames(root, initState){
let used = new Set(), spinning = false;
root.innerHTML = `
<div class="np">
<div class="np-result">🎯</div>
<textarea class="np-list" spellcheck="false"></textarea>
<div class="np-row">
<button class="tbtn np-pick"></button>
<label><input type="checkbox" class="np-nr"><span class="np-nrl"></span></label>
</div>
</div>`;
const ta = root.querySelector(".np-list"),
result = root.querySelector(".np-result"),
pickBtn = root.querySelector(".np-pick"),
nr = root.querySelector(".np-nr");
if(initState){
ta.value = initState.names || "";
nr.checked = !!initState.nr;
}
ta.addEventListener("input", ()=>used.clear());
ta.addEventListener("keydown", e=>e.stopPropagation());
function names(){ return ta.value.split("\n").map(s=>s.trim()).filter(Boolean); }
pickBtn.addEventListener("click", ()=>{
if(spinning) return;
const all = names();
if(!all.length){ ta.focus(); return; }
let pool = nr.checked ? all.filter(n=>!used.has(n)) : all;
if(!pool.length){
result.textContent = T("namesDone");
used.clear();
return;
}
spinning = true;
result.classList.remove("winner");
result.classList.add("spin");
const FUN = ["🌟","🎉","🦄","🚀","🐸","🎈","🍀","⚡"];
let n = 0;
const iv = setInterval(()=>{
result.textContent = all[Math.floor(Math.random()*all.length)];
tone(400+Math.random()*300, 0.03, 0, "square", 0.04);
if(++n >= 16){
clearInterval(iv);
const winner = pool[Math.floor(Math.random()*pool.length)];
const em = FUN[Math.floor(Math.random()*8)];
result.textContent = `${em} ${winner} ${em}`;
result.classList.remove("spin");
result.classList.add("winner");
confetti(result.parentElement);
if(nr.checked) used.add(winner);
sndWin();
spinning = false;
}
}, 80);
});
function labels(){
ta.placeholder = T("namesPh");
pickBtn.textContent = T("namesPick");
root.querySelector(".np-nrl").textContent = T("namesNoRep");
}
document.addEventListener("langchange", labels);
labels();
return { getState: ()=>({names:ta.value, nr:nr.checked}) };
}

View file

@ -0,0 +1,14 @@
/* widget: Notities */
/* =========================================================
Notes widget
==========================================================*/
function mountNotes(root, initState){
const ta = document.createElement("textarea");
ta.className = "notes";
ta.placeholder = T("notesPh");
if(initState && typeof initState.text==="string") ta.value = initState.text;
document.addEventListener("langchange", ()=>{ ta.placeholder = T("notesPh"); });
root.appendChild(ta);
return { getState: ()=>({text: ta.value}) };
}

151
public/js/widgets/timer.js Normal file
View file

@ -0,0 +1,151 @@
/* widget: Timer */
/* =========================================================
Timer widget digits, visual "kijkklok" disc, or both
==========================================================*/
function mountTimer(root, initState){
const DIAL = 3600; // the disc is a 60-minute dial, like a real kijkklok
let total = 300, left = 300, iv = null,
mode = "both", // dig | pie | both
snd = true;
if(initState){
total = initState.total || 300; left = total;
mode = initState.mode || "both";
snd = initState.snd !== false;
}
root.innerHTML = `
<div class="timer m-both">
<div class="topts">
<button class="bsnd ticon"></button>
<div class="tseg">
<button data-m="dig">🔢</button>
<button data-m="pie">🔴</button>
<button data-m="both">🔢🔴</button>
</div>
</div>
<div class="tstage">
<svg class="tpie" viewBox="0 0 150 150">
<circle cx="75" cy="75" r="70" fill="#eef3f9" stroke="#d5dfeb" stroke-width="2"/>
<g class="ticks"></g>
<path class="parc" fill="var(--red)"/>
<circle class="knob" r="7" fill="#fff" stroke="var(--red)" stroke-width="3"/>
<circle cx="75" cy="75" r="6" fill="#fff" stroke="#b9c7d9" stroke-width="2"/>
</svg>
<div class="tdisp">05:00</div>
</div>
<div class="trow presets"></div>
<div class="trow main">
<button class="bstart"></button>
<button class="breset"></button>
</div>
<div class="thint"></div>
</div>`;
const box = root.querySelector(".timer"),
svg = root.querySelector(".tpie"),
disp = root.querySelector(".tdisp"),
arc = root.querySelector(".parc"),
knob = root.querySelector(".knob"),
bstart = root.querySelector(".bstart"),
breset = root.querySelector(".breset"),
bsnd = root.querySelector(".bsnd"),
presets = root.querySelector(".presets"),
hint = root.querySelector(".thint");
const fmt = s => String(Math.floor(s/60)).padStart(2,"0")+":"+String(s%60).padStart(2,"0");
/* minute tick marks (every 5 min) */
const ticks = root.querySelector(".ticks");
for(let i=0;i<12;i++){
const a = i*30*Math.PI/180;
const l = document.createElementNS("http://www.w3.org/2000/svg","line");
l.setAttribute("x1", 75+62*Math.sin(a)); l.setAttribute("y1", 75-62*Math.cos(a));
l.setAttribute("x2", 75+69*Math.sin(a)); l.setAttribute("y2", 75-69*Math.cos(a));
l.setAttribute("stroke", "#b9c7d9"); l.setAttribute("stroke-width", i%3===0?2.5:1.5);
ticks.appendChild(l);
}
function drawPie(){
const a = Math.min(360, left/DIAL*360);
const rad = a*Math.PI/180;
const kx = 75 + 63*Math.sin(rad), ky = 75 - 63*Math.cos(rad);
knob.setAttribute("cx", kx.toFixed(2)); knob.setAttribute("cy", ky.toFixed(2));
if(a<=0){ arc.setAttribute("d",""); return; }
if(a>=359.99){ arc.setAttribute("d","M75,5 A70,70 0 1 1 74.99,5 Z"); return; }
const x = 75 + 70*Math.sin(rad), y = 75 - 70*Math.cos(rad);
arc.setAttribute("d",`M75,75 L75,5 A70,70 0 ${a>180?1:0} 1 ${x.toFixed(2)},${y.toFixed(2)} Z`);
}
function draw(){
disp.textContent = fmt(left);
disp.classList.toggle("warn", left<=10 && left>0 && iv!==null);
drawPie();
}
function labels(){
bstart.textContent = (iv ? "⏸ "+T("pause") : "▶ "+T("start"));
breset.textContent = "↺ "+T("reset");
presets.querySelectorAll("button").forEach(b=>b.textContent = b.dataset.m+" "+T("min"));
root.querySelectorAll(".tseg button").forEach(b=>{
const key = b.dataset.m==="dig" ? "tmDigits" : b.dataset.m==="pie" ? "tmVisual" : "tmBoth";
b.title = T(key);
b.classList.toggle("on", b.dataset.m===mode);
});
bsnd.textContent = snd ? "🔔" : "🔕";
bsnd.title = T("tmSound");
bsnd.classList.toggle("on", snd);
box.className = "timer m-"+mode + (box.classList.contains("flash")?" flash":"");
hint.textContent = mode==="dig" ? "" : T("tmHint");
}
[1,5,10,15,30].forEach(m=>{
const b = document.createElement("button");
b.dataset.m = m;
b.addEventListener("click", ()=>{ stop(); total=left=m*60; draw(); labels(); });
presets.appendChild(b);
});
root.querySelectorAll(".tseg button").forEach(b=>{
b.addEventListener("click", ()=>{ mode = b.dataset.m; labels(); });
});
bsnd.addEventListener("click", ()=>{ snd=!snd; labels(); });
/* drag on the disc to set the time */
function angleFromEvent(ev){
const r = svg.getBoundingClientRect();
const px = (ev.clientX - r.left)*150/r.width - 75;
const py = (ev.clientY - r.top)*150/r.height - 75;
return (Math.atan2(px, -py)*180/Math.PI + 360) % 360;
}
svg.addEventListener("pointerdown", e=>{
e.preventDefault();
svg.setPointerCapture(e.pointerId);
const setFrom = ev=>{
const mins = Math.min(60, Math.max(1, Math.round(angleFromEvent(ev)/6)));
total = left = mins*60;
draw();
};
setFrom(e);
const move = ev=>setFrom(ev);
const up = ()=>{ svg.removeEventListener("pointermove",move); svg.removeEventListener("pointerup",up); };
svg.addEventListener("pointermove",move);
svg.addEventListener("pointerup",up);
});
function stop(){ if(iv){ clearInterval(iv); iv=null; } }
bstart.addEventListener("click", ()=>{
if(iv){ stop(); }
else if(left>0){
iv = setInterval(()=>{
left--;
if(left<=0){
left=0; stop();
if(snd) sndWin();
box.classList.add("flash");
setTimeout(()=>box.classList.remove("flash"), 2200);
labels();
}
draw();
},1000);
}
labels(); draw();
});
breset.addEventListener("click", ()=>{ stop(); left=total; draw(); labels(); });
document.addEventListener("langchange", labels);
labels(); draw();
return { getState: ()=>({total, mode, snd}) };
}

205
public/js/widgets/write.js Normal file
View file

@ -0,0 +1,205 @@
/* widget: Schrijfblad */
/* =========================================================
Writing pad pen on paper with lines or maths squares
==========================================================*/
function mountWrite(root, initState){
const COLORS = ["#26344a","#3b7dd8","#e0554d","#3fae6a","#f7c948"];
const SIZES = [3,6,10];
let color = COLORS[0], size = SIZES[1], tool = "pen", // pen | mark | text | erase
bg = (initState && initState.bg) || "write";
root.innerHTML = `
<div class="wp">
<div class="wp-bar">
<div class="wp-group wp-tools"></div>
<div class="wp-group wp-bgs"></div>
<div class="wp-group wp-colors"></div>
<div class="wp-group wp-sizes"></div>
<button class="wp-clear">🗑</button>
</div>
<div class="wp-stage"><canvas></canvas></div>
</div>`;
const stage = root.querySelector(".wp-stage"),
canvas = root.querySelector("canvas"),
ctx = canvas.getContext("2d"),
toolsEl = root.querySelector(".wp-tools"),
bgsEl = root.querySelector(".wp-bgs"),
colorsEl = root.querySelector(".wp-colors"),
sizesEl = root.querySelector(".wp-sizes"),
clearBtn = root.querySelector(".wp-clear");
const TOOLS = [["pen","✏️","toolPen"],["vulp","🖋️","toolFountain"],["mark","🖍️","toolMark"],["text","🔤","toolText"],["erase","🧽","eraser"]];
TOOLS.forEach(([id,icon,key])=>{
const b = document.createElement("button");
b.dataset.tool = id; b.textContent = icon;
b.addEventListener("click", ()=>{ tool = id; refresh(); });
toolsEl.appendChild(b);
});
/* background choices */
const BGS = [["blank","bgBlank"],["lines","bgLines"],["write","bgWrite"],["grid-s","bgGridS"],["grid-b","bgGridB"]];
BGS.forEach(([id,key])=>{
const b = document.createElement("button");
b.className = "wp-bg"; b.dataset.bg = id;
b.appendChild(document.createElement("i"));
b.addEventListener("click", ()=>{ bg = id; refresh(); });
bgsEl.appendChild(b);
});
COLORS.forEach(c=>{
const b = document.createElement("button");
b.className = "wp-color"; b.dataset.c = c;
const i = document.createElement("i"); i.style.background = c;
b.appendChild(i);
b.addEventListener("click", ()=>{ color = c; if(tool==="erase") tool = "pen"; refresh(); });
colorsEl.appendChild(b);
});
SIZES.forEach(s=>{
const b = document.createElement("button");
b.className = "wp-size"; b.dataset.s = s;
const i = document.createElement("i");
i.style.width = i.style.height = (s+4)+"px";
b.appendChild(i);
b.addEventListener("click", ()=>{ size = s; refresh(); });
sizesEl.appendChild(b);
});
clearBtn.addEventListener("click", ()=>{ ctx.clearRect(0,0,canvas.width,canvas.height); });
function refresh(){
stage.className = "wp-stage" + (bg==="blank" ? "" : " bg-"+bg) + (tool==="text" ? " t-text" : "");
toolsEl.querySelectorAll("button").forEach(b=>{
b.classList.toggle("on", b.dataset.tool===tool);
b.title = T(TOOLS.find(x=>x[0]===b.dataset.tool)[2]);
});
bgsEl.querySelectorAll("button").forEach(b=>{
b.classList.toggle("on", b.dataset.bg===bg);
b.title = T(BGS.find(x=>x[0]===b.dataset.bg)[1]);
});
colorsEl.querySelectorAll("button").forEach(b=>b.classList.toggle("on", b.dataset.c===color && tool!=="erase"));
sizesEl.querySelectorAll("button").forEach(b=>{ b.classList.toggle("on", +b.dataset.s===size); b.title = T("penSize"); });
clearBtn.title = T("clearAll");
}
document.addEventListener("langchange", refresh);
/* canvas sizing (preserves ink on resize) */
const dpr = window.devicePixelRatio || 1;
function resize(){
const w = stage.clientWidth, h = stage.clientHeight;
if(w<2 || h<2) return;
const keep = document.createElement("canvas");
keep.width = canvas.width; keep.height = canvas.height;
if(canvas.width) keep.getContext("2d").drawImage(canvas,0,0);
canvas.width = Math.round(w*dpr); canvas.height = Math.round(h*dpr);
canvas.style.width = w+"px"; canvas.style.height = h+"px";
if(keep.width) ctx.drawImage(keep,0,0);
ctx.lineCap = "round"; ctx.lineJoin = "round";
}
new ResizeObserver(resize).observe(stage);
resize();
/* restore saved ink */
if(initState && initState.ink){
const im = new Image();
im.onload = ()=>{ resize(); ctx.drawImage(im,0,0); };
im.src = initState.ink;
}
/* drawing */
let drawing = false, lx=0, ly=0, nibW = size;
const pos = ev=>{
const r = canvas.getBoundingClientRect();
/* ratio-based: correct under app zoom and widget zoom */
return [(ev.clientX-r.left)*(canvas.width/r.width), (ev.clientY-r.top)*(canvas.height/r.height)];
};
canvas.addEventListener("pointerdown", e=>{
if(e.pointerType==="mouse" && e.button!==0) return;
e.preventDefault();
if(tool==="text"){ startText(e); return; }
canvas.setPointerCapture(e.pointerId);
drawing = true;
nibW = size*0.8;
[lx,ly] = pos(e);
strokeTo(lx+0.01, ly+0.01, e.pressure, e.pointerType);
});
canvas.addEventListener("pointermove", e=>{
if(!drawing) return;
const evs = e.getCoalescedEvents ? e.getCoalescedEvents() : [e];
evs.forEach(ev=>{ const [x,y] = pos(ev); strokeTo(x, y, ev.pressure, ev.pointerType); });
});
const stopDraw = ()=>{ drawing = false; ctx.globalAlpha = 1; };
canvas.addEventListener("pointerup", stopDraw);
canvas.addEventListener("pointercancel", stopDraw);
function strokeTo(x,y,pressure,ptype){
ctx.globalCompositeOperation = tool==="erase" ? "destination-out" : "source-over";
ctx.globalAlpha = tool==="mark" ? 0.32 : 1;
ctx.strokeStyle = color;
let w = size;
if(tool==="erase") w = size*5;
else if(tool==="mark") w = size*3.5;
else if(tool==="vulp"){
/* nib: real pen pressure when available, otherwise slower = thicker */
let target;
if(ptype==="pen" && pressure > 0){
target = size*(0.45 + pressure*1.9);
}else{
const speed = Math.hypot(x-lx, y-ly)/dpr;
target = Math.max(size*0.5, Math.min(size*2.3, size*2.3 - speed*0.12));
}
nibW += (target - nibW)*0.3; /* smooth transitions between thin and thick */
w = nibW;
}
ctx.lineWidth = w*dpr;
ctx.beginPath();
ctx.moveTo(lx,ly);
ctx.lineTo(x,y);
ctx.stroke();
lx = x; ly = y;
}
/* text tool: click the sheet, type, Enter (or click away) to place */
function startText(e){
if(stage.querySelector(".wp-text-input")) return;
const r = stage.getBoundingClientRect();
const sc = r.width / stage.offsetWidth; /* effective zoom on this widget */
const x = (e.clientX - r.left)/sc, y = (e.clientY - r.top)/sc;
const fontPx = size*6 + 10;
const inp = document.createElement("input");
inp.type = "text"; inp.className = "wp-text-input";
inp.style.left = Math.min(x, r.width-140)+"px";
inp.style.top = Math.min(y, r.height-34)+"px";
inp.style.color = color;
inp.style.fontSize = fontPx+"px";
stage.appendChild(inp);
setTimeout(()=>inp.focus(), 0);
let done = false;
const commit = ()=>{
if(done) return; done = true;
const v = inp.value.trim();
if(v){
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = 1;
ctx.fillStyle = color;
ctx.font = `700 ${fontPx*dpr}px "Segoe UI Rounded","Segoe UI",Verdana,sans-serif`;
ctx.textBaseline = "top";
ctx.fillText(v, x*dpr, y*dpr);
}
inp.remove();
};
inp.addEventListener("keydown", ev=>{
ev.stopPropagation();
if(ev.key==="Enter") commit();
if(ev.key==="Escape"){ done = true; inp.remove(); }
});
inp.addEventListener("blur", commit);
}
refresh();
return {
getState: ()=>{
let ink = null;
try{ ink = canvas.toDataURL("image/png"); }catch(e){}
return { bg, ink };
}
};
}