Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5

Open
bes-r wants to merge 208 commits from bes-r/coloring-quality-v0.4.67 into main AGit
6 changed files with 133 additions and 8 deletions
Showing only changes of commit df921d5968 - Show all commits

View file

@ -1 +1 @@
0.3.35-beta
0.3.36-beta

View file

@ -1153,6 +1153,13 @@
/* inklapbare groepskop (leerlingen per klas): klikbaar, pijltje in de tekst */
.am-group-toggle{cursor:pointer; user-select:none;}
.am-group-toggle:hover{color:var(--ink);}
/* Systeem-tab: schoolrij duidelijk zwaarder dan de beheerder-subrijen */
.am-row-school{background:var(--accent-soft);}
.am-row-school > .am-name{font-size:15px;}
.am-row-sub{
margin-left:22px; background:var(--surface);
border-left:3px solid var(--line); border-radius:var(--radius-s);
}
/* uitklapbaar gereedschapsblok per gebruikersrij (achter het ✏-knopje) */
.am-row-tools{
display:flex; gap:8px; align-items:center; flex-wrap:wrap; width:100%;

View file

@ -6,6 +6,8 @@
geen los venster meer - modal is alleen nog de content-container voor render() */
const modal = document.getElementById("adminModal");
let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null;
/* schoolbeheerders van alle scholen, voor het Systeem-tabblad (super) */
let SYS_ADMINS = [];
/* huidige klas-toewijzingen (leerling-omgeving), per klas-id - opnieuw
opgehaald bij elke reload() zoals CLASSES/USERS hierboven */
let ASSIGNMENTS = {};
@ -159,6 +161,13 @@
if(currentUser.role==="super") SCHOOLS = await api("/schools");
const q = currentUser.role==="super" && selSchool ? "?school="+selSchool : "";
USERS = (await api("/admin/users"+q)).users;
/* het Systeem-tabblad toont schoolbeheerders van álle scholen, ook als
de contextbalk op één school staat; zonder schoolfilter is USERS al
compleet en is de extra call overbodig */
if(currentUser.role==="super"){
SYS_ADMINS = q ? ((await api("/admin/users")).users || []).filter(u=>u.role==="admin")
: USERS.filter(u=>u.role==="admin");
}
const cq = currentUser.role==="super" ? (selSchool ? "?school="+selSchool : "?school=0") : "";
CLASSES = (await api("/admin/classes"+cq)).classes || [];
ASSIGNMENTS = {};
@ -178,13 +187,14 @@
return sel;
}
function userRow(u){
const row = h("div","am-row");
function userRow(u, opts){
const row = h("div","am-row" + (opts && opts.sub ? " am-row-sub" : ""));
/* rustig standaardbeeld: alleen naam + rol/klas; alle knoppen en velden
zitten achter het -knopje in een uitklapbaar gereedschapsblok */
const tools = h("div","am-row-tools");
const actions = h("div","am-actions");
row.appendChild(h("span","am-name", u.displayName + (u.displayName!==u.username ? ` (${u.username})` : "")));
const icon = opts && opts.icon ? opts.icon + " " : "";
row.appendChild(h("span","am-name", icon + u.displayName + (u.displayName!==u.username ? ` (${u.username})` : "")));
if(u.role==="pupil"){
const cls = CLASSES.find(c=>Number(c.id)===Number(u.classId));
row.appendChild(h("span","am-role", T(roleKey(u.role)) + (cls ? ` · ${cls.name}` : "")));
@ -427,8 +437,25 @@
function renderScholenPanel(panel){
const list = h("div","am-list");
SCHOOLS.forEach(s=>{
const row = h("div","am-row");
row.appendChild(h("span","am-name", s.name));
const admins = SYS_ADMINS.filter(a=>Number(a.schoolId)===Number(s.id));
const row = h("div","am-row am-row-school");
row.appendChild(h("span","am-name", "🏫 " + s.name));
row.appendChild(h("span","am-role",
`${admins.length} ${(admins.length===1 ? T("amAdminOne") : T("amAdmins")).toLowerCase()}`));
/* zelfde -systeem als bij gebruikers: hernoemen en verwijderen
zitten achter het gereedschapsblok, de rij zelf blijft rustig */
const tools = h("div","am-row-tools");
const nameInp = h("input","am-inp");
nameInp.value = s.name;
nameInp.maxLength = 80;
nameInp.addEventListener("keydown", ev=>ev.stopPropagation());
nameInp.addEventListener("change", async ()=>{
const name = nameInp.value.trim();
if(name.length < 2 || name === s.name) return;
try{ await api("/admin/schools/"+s.id, {method:"PATCH", body:{ name }}); reload(); }
catch(e){ msg(e.message); }
});
tools.appendChild(field("amFieldName", nameInp));
const acts = h("div","am-actions");
acts.appendChild(dangerBtn("🗑 " + T("amDelete"), "amDeleteSchoolWarn", async ()=>{
try{
@ -437,8 +464,27 @@
reload();
}catch(e){ msg(e.message); }
}));
row.appendChild(acts);
tools.appendChild(field("amFieldActions", acts));
const edit = h("button","am-edit","✏");
edit.type = "button";
edit.title = T("amEdit");
const key = "school:"+s.id;
const sync = ()=>{
const open = openRows.has(key);
tools.style.display = open ? "" : "none";
edit.classList.toggle("on", open);
};
edit.addEventListener("click", ()=>{
if(openRows.has(key)) openRows.delete(key); else openRows.add(key);
sync();
});
sync();
row.appendChild(edit);
row.appendChild(tools);
list.appendChild(row);
/* de schoolbeheerders van deze school, duidelijk onderscheiden van de
schoolrij (🛡-icoon, ingesprongen) maar met hetzelfde -gereedschap */
admins.forEach(a=>list.appendChild(userRow(a, { icon:"🛡", sub:true })));
});
panel.appendChild(list);

View file

@ -2,7 +2,7 @@
"use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.3.35-beta";
const VERSION = "0.3.36-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@ -183,6 +183,7 @@ const I18N = {
amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Systeem",
amPickSchoolHint:"Kies hierboven eerst een school (🏫).", amPickSchool:"Kies een school",
amPupils:"Leerlingen", amPupilOne:"Leerling", amEdit:"Bewerken",
amAdmins:"Schoolbeheerders", amAdminOne:"Schoolbeheerder",
amSure:"weet je het zeker?", amDelete:"verwijder",
amFieldName:"Naam", amFieldRole:"Functie", amFieldExtraRoles:"Extra functies", amFieldClass:"Klas",
amFieldPw:"Wachtwoord", amFieldActions:"Acties", amPwTypePh:"typ een wachtwoord…",
@ -379,6 +380,7 @@ const I18N = {
amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"System",
amPickSchoolHint:"First pick a school above (🏫).", amPickSchool:"Pick a school",
amPupils:"Pupils", amPupilOne:"Pupil", amEdit:"Edit",
amAdmins:"School admins", amAdminOne:"School admin",
amSure:"are you sure?", amDelete:"delete",
amFieldName:"Name", amFieldRole:"Role", amFieldExtraRoles:"Extra roles", amFieldClass:"Class",
amFieldPw:"Password", amFieldActions:"Actions", amPwTypePh:"type a password…",

View file

@ -267,6 +267,14 @@ export default async function api(app) {
const r = await pool.query('INSERT INTO schools (name) VALUES ($1) RETURNING id, name', [name.trim()]);
return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } };
});
app.patch('/admin/schools/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['schools.manage']);
const { name } = req.body ?? {};
if (!name || typeof name !== 'string' || !name.trim() || name.trim().length > 80) return fail(reply, 400, 'ongeldige naam');
const r = await pool.query('UPDATE schools SET name = $1 WHERE id = $2 RETURNING id, name', [name.trim(), req.params.id]);
if (!r.rows[0]) return fail(reply, 404, 'school onbekend');
return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } };
});
app.delete('/admin/schools/:id', async (req, reply) => {
need(req, reply, PERMISSIONS['schools.manage']);
await pool.query('DELETE FROM schools WHERE id = $1', [req.params.id]);

62
test/schools.test.js Normal file
View file

@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import rateLimit from '@fastify/rate-limit';
import api from '../src/api.js';
function makeApp(user) {
const calls = [];
const pool = {
async query(sql, params = []) {
calls.push({ sql, params });
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] };
if (sql.includes('FROM user_roles')) return { rows: [] };
if (sql.startsWith('UPDATE schools SET name')) return { rows: [{ id: params[1], name: params[0] }] };
return { rows: [] };
},
};
return (async () => {
const app = Fastify({ trustProxy: 2 });
await app.register(cookie);
await app.register(rateLimit, { global: false });
app.decorate('pg', pool);
await app.register(api, { prefix: '/api' });
await app.ready();
return { app, calls };
})();
}
const cookies = { teach_session: 'x'.repeat(64) };
const superUser = { id: 1, username: 'sm', role: 'super', school_id: null, class_id: null, data: {}, data_rev: 0 };
const admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 };
test('systeemmanager kan een school hernoemen', async () => {
const { app, calls } = await makeApp(superUser);
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
payload: { name: 'De Nieuwe Naam' } });
assert.equal(res.statusCode, 200, res.body);
assert.equal(res.json().school.name, 'De Nieuwe Naam');
const upd = calls.find((c) => c.sql.startsWith('UPDATE schools SET name'));
assert.deepEqual(upd.params, ['De Nieuwe Naam', '5']);
await app.close();
});
test('schoolbeheerder mag geen scholen hernoemen', async () => {
const { app } = await makeApp(admin);
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
payload: { name: 'Hack' } });
assert.equal(res.statusCode, 403);
await app.close();
});
test('hernoemen weigert een lege of te lange naam', async () => {
const { app } = await makeApp(superUser);
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
payload: { name: ' ' } });
assert.equal(res.statusCode, 400);
const res2 = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,
payload: { name: 'x'.repeat(90) } });
assert.equal(res2.statusCode, 400);
await app.close();
});