feat: Opslag per school met groepskaart-component (v0.3.88-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 55s

- Nieuw herbruikbaar groepskaart-component (amGroupCard): één kader om items die bij elkaar horen - koprij met grijs icoon, titel, samenvatting-als-uitklaptoggle en Bewerken; compacte subrijen; open-status overleeft re-renders (injecteerbare Set zodat ook andere modules hem gebruiken)
- Opslag-tab volledig herbouwd: eerst één school kiezen (of "Systeem (zonder school)" voor systeemmanagers/ouders/losse accounts) i.p.v. alle scholen én alle gebruikers plat met altijd-open editors
- Na keuze één kaart: schoolnaam + quota-samenvatting in de kop (schoolquota bewerken klapt uit), daaronder alleen de gebruikers van díe school als compacte regels; bewerkvelden pas na "Bewerken" per rij; zoeken binnen de school
- "Standaard"-optie benoemt nu de echte rolstandaard ("Standaard (200 MB)") via defaultBytes
- Mobiel: beheer-rijen stapelen niet meer verticaal (kaartkop en subrijen wrappen netjes; subrij ± twee compacte regels i.p.v. >100px)
- Geverifieerd in headless Chromium (13 checks, o.a. dat schoolkeuze en open bewerkrij een her-render overleven - module-state) + screenshots 390px/desktop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
This commit is contained in:
Ramon 2026-07-17 14:20:41 +02:00
parent ebe622588f
commit c3492ab2ca
5 changed files with 178 additions and 18 deletions

View file

@ -1 +1 @@
0.3.87-beta 0.3.88-beta

View file

@ -2004,3 +2004,42 @@ body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000
padding:14px; max-height:none; box-shadow:var(--shadow-2); padding:14px; max-height:none; box-shadow:var(--shadow-2);
} }
} }
/* ---- groepskaart: één kader om items die bij elkaar horen ---- */
#settingsModal .am-card{
border:1px solid var(--line); border-radius:var(--radius-s); background:var(--surface);
overflow:hidden; margin-bottom:10px;
}
#settingsModal .am-card-head{
display:flex; gap:9px; align-items:center; padding:9px 12px;
background:var(--surface-2); border-bottom:1px solid var(--line);
}
#settingsModal .am-card:has(.am-card-subs[style*="none"]):not(:has(.am-card-tools:not([style*="none"]))) .am-card-head{border-bottom:none;}
#settingsModal .am-ico{filter:grayscale(1); opacity:.55; font-size:13px; flex:none;}
#settingsModal .am-card-title{font-weight:650; font-size:13.5px; flex:1; min-width:80px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
#settingsModal .am-card-sum{margin-left:auto; flex:none; font-size:12px; color:var(--muted);}
#settingsModal .am-card-toggle{cursor:pointer; user-select:none; padding:4px 6px; border-radius:5px;}
#settingsModal .am-card-toggle:hover{background:var(--surface-3); color:var(--ink);}
#settingsModal .am-card-tools{padding:10px 12px; border-bottom:1px solid var(--line); display:flex; flex-wrap:wrap; gap:10px;}
#settingsModal .am-card-subs > .am-row{border-bottom:1px solid var(--line);}
#settingsModal .am-card-subs > .am-row:last-child{border-bottom:none;}
/* compacte subrij binnen een kaart */
#settingsModal .am-card-sub{font-size:12.5px; padding:5px 12px 5px 30px; flex-wrap:wrap;}
#settingsModal .am-card-sub .am-name{font-size:12.5px; font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
#settingsModal .am-card-sub .am-role{font-size:11.5px; margin-left:auto;}
#settingsModal .am-card-sub .am-edit{flex:none;}
#settingsModal .am-card-sub .am-row-tools{width:100%;}
#settingsModal .img-quota-pick{margin-bottom:8px; max-width:420px;}
/* status-glyph (● actief / ○ wacht) met uitleg via title */
#settingsModal .am-glyph{flex:none; font-size:10px; color:var(--muted); cursor:default;}
#settingsModal .am-glyph.ok{color:var(--green);}
/* mobiel: rijen binnen het beheer niet meer verticaal stapelen - de generieke
stapelregel maakte school en subrij ononderscheidbaar; wrap vangt overloop */
@media (max-width:600px){
#settingsModal .am-row{flex-direction:row; align-items:center; flex-wrap:wrap;}
/* kaartkop mag wrappen: titel houdt regel 1, samenvatting+Bewerken eronder */
#settingsModal .am-card-head{flex-wrap:wrap;}
/* compacte subrij op telefoon: naam + Bewerken op regel 1, verbruik eronder */
#settingsModal .am-card-sub .am-role{order:3; flex-basis:100%; margin-left:0;}
#settingsModal .am-card-sub .am-edit{margin-left:auto;}
}

View file

@ -116,6 +116,63 @@
return badge; return badge;
} }
/* ---- groepskaart: één kader om wat bij elkaar hoort ------------------------
Kop (grijs icoon + titel + samenvatting-als-toggle + Bewerken) met daaronder
optioneel uitklapbare compacte subrijen en/of een uitklapbaar bewerkblok.
De open-status leeft in een Set die re-renders overleeft; andere modules
(bv. de Opslag-tab in image-catalog.js) geven hun eigen Set mee. */
function groupCard(opts){
const openSet = opts.openSet || openGroups;
const editSet = opts.editSet || openRows;
const card = h("div","am-card");
const head = h("div","am-card-head");
card.appendChild(head);
if(opts.icon) head.appendChild(h("span","am-ico", opts.icon));
head.appendChild(h("span","am-card-title", opts.title));
const sum = h("span","am-card-sum");
head.appendChild(sum);
let tools = null;
if(opts.edit){
tools = h("div","am-card-tools");
tools.appendChild(opts.edit.tools);
const btn = h("button","am-edit", T("amEdit"));
btn.type = "button";
const ekey = opts.edit.key;
const esync = ()=>{
const open = editSet.has(ekey);
tools.style.display = open ? "" : "none";
btn.classList.toggle("on", open);
};
btn.addEventListener("click", ()=>{
if(editSet.has(ekey)) editSet.delete(ekey); else editSet.add(ekey);
esync();
});
esync();
head.appendChild(btn);
card.appendChild(tools);
}
if(opts.subs && opts.subs.length){
const subHost = h("div","am-card-subs");
opts.subs.forEach(el=>subHost.appendChild(el));
card.appendChild(subHost);
sum.classList.add("am-card-toggle");
const sync = ()=>{
const open = openSet.has(opts.key);
sum.textContent = `${opts.summary} ${open ? "▾" : "▸"}`;
subHost.style.display = open ? "" : "none";
};
sum.addEventListener("click", ()=>{
if(openSet.has(opts.key)) openSet.delete(opts.key); else openSet.add(opts.key);
sync();
});
sync();
}else if(opts.summary){
sum.textContent = opts.summary;
}
return card;
}
window.amGroupCard = groupCard;
/* ---- beheer-secties in het instellingen-register (settings.js) ------------ /* ---- beheer-secties in het instellingen-register (settings.js) ------------
Elke sectie meldt zichzelf aan met rol-zichtbaarheid, een activate() en Elke sectie meldt zichzelf aan met rol-zichtbaarheid, een activate() en
zoekgegevens; de schil bouwt daar navigatie + zoeken uit. Per rol alleen zoekgegevens; de schil bouwt daar navigatie + zoeken uit. Per rol alleen

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* 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. */ Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.3.87-beta"; const VERSION = "0.3.88-beta";
(function(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -190,6 +190,8 @@ const I18N = {
imgLoading:"Afbeeldingen laden…", imgSaved:"Afbeelding opgeslagen ✓", imgDeleted:"Afbeelding verwijderd ✓", imgLoading:"Afbeeldingen laden…", imgSaved:"Afbeelding opgeslagen ✓", imgDeleted:"Afbeelding verwijderd ✓",
imgThemeAdded:"Thema toegevoegd ✓", imgStorageAdmin:"Opslaglimieten afbeeldingen", imgThemeAdded:"Thema toegevoegd ✓", imgStorageAdmin:"Opslaglimieten afbeeldingen",
imgQuotaUsage:"{used} gebruikt · {limit}", imgQuotaDefault:"Standaard", imgQuotaCustom:"Aangepast", imgQuotaUsage:"{used} gebruikt · {limit}", imgQuotaDefault:"Standaard", imgQuotaCustom:"Aangepast",
imgPickSchool:"— kies een school —", imgSystemScope:"Systeem (zonder school)",
imgQuotaDefaultOf:"Standaard ({limit})",
imgQuotaMb:"limiet in MB", imgQuotaInvalid:"Vul een geldige limiet in.", imgSchools:"Scholen", imgQuotaMb:"limiet in MB", imgQuotaInvalid:"Vul een geldige limiet in.", imgSchools:"Scholen",
imgUsers:"Gebruikers", imgSearchUsers:"Zoek gebruiker…", imgUsers:"Gebruikers", imgSearchUsers:"Zoek gebruiker…",
cats:["Dieren","Eten","Natuur","Vervoer","Spullen","Lichaam"], cats:["Dieren","Eten","Natuur","Vervoer","Spullen","Lichaam"],
@ -482,6 +484,8 @@ const I18N = {
imgLoading:"Loading images…", imgSaved:"Image saved ✓", imgDeleted:"Image deleted ✓", imgLoading:"Loading images…", imgSaved:"Image saved ✓", imgDeleted:"Image deleted ✓",
imgThemeAdded:"Theme added ✓", imgStorageAdmin:"Image storage limits", imgThemeAdded:"Theme added ✓", imgStorageAdmin:"Image storage limits",
imgQuotaUsage:"{used} used · {limit}", imgQuotaDefault:"Default", imgQuotaCustom:"Custom", imgQuotaUsage:"{used} used · {limit}", imgQuotaDefault:"Default", imgQuotaCustom:"Custom",
imgPickSchool:"— pick a school —", imgSystemScope:"System (no school)",
imgQuotaDefaultOf:"Default ({limit})",
imgQuotaMb:"limit in MB", imgQuotaInvalid:"Enter a valid limit.", imgSchools:"Schools", imgQuotaMb:"limit in MB", imgQuotaInvalid:"Enter a valid limit.", imgSchools:"Schools",
imgUsers:"Users", imgSearchUsers:"Search user…", imgUsers:"Users", imgSearchUsers:"Search user…",
cats:["Animals","Food","Nature","Transport","Things","Body"], cats:["Animals","Food","Nature","Transport","Things","Body"],

View file

@ -344,19 +344,28 @@ async function openImageCatalog(onSelect){
/* Systeembeheer: limieten per gebruiker en school. Wordt door admin.js als /* Systeembeheer: limieten per gebruiker en school. Wordt door admin.js als
extra Instellingen-tab aangeroepen. */ extra Instellingen-tab aangeroepen. */
/* Opslag-tab: eerst een school kiezen (of "Systeem" voor school-loze accounts),
daarna één groepskaart met de schoolquota in de kop en de gebruikers van die
school als compacte subrijen. Bewerkvelden zijn verborgen tot "Bewerken".
Keuze en open-status leven op moduleniveau: de save-handler en admin-render()
herbouwen het paneel en mogen niets vergeten. */
let imageQuotaSchool = "";
const imageQuotaOpen = new Set();
let imageQuotaFilter = "";
function renderImageStoragePanel(panel){ function renderImageStoragePanel(panel){
panel.innerHTML=""; panel.innerHTML="";
const heading=document.createElement("div");heading.className="am-h3";heading.textContent=T("imgStorageAdmin"); const status=document.createElement("div");status.className="am-msg";panel.append(status);
const status=document.createElement("div");status.className="am-msg";panel.append(heading,status);
const host=document.createElement("div");host.className="img-quota-admin";host.textContent=T("imgLoading");panel.append(host); const host=document.createElement("div");host.className="img-quota-admin";host.textContent=T("imgLoading");panel.append(host);
const fmt=value=>value==null?T("imgUnlimited"):imageBytes(value); const fmt=value=>value==null?T("imgUnlimited"):imageBytes(value);
const editor=(item,type)=>{ /* het bewerkblok (limiet-keuze + opslaan) achter de Bewerken-knop per kaart/rij */
const row=document.createElement("div");row.className="am-row img-quota-row"; const editorTools=(item,type)=>{
const info=document.createElement("div");info.className="am-name";info.textContent=item.name||item.displayName; const wrap=document.createElement("div");wrap.className="am-inline";
const use=document.createElement("span");use.className="am-role";use.textContent=T("imgQuotaUsage").replace("{used}",imageBytes(item.usedBytes)).replace("{limit}",fmt(item.limitBytes));
info.append(document.createElement("br"),use);
const mode=document.createElement("select");mode.className="am-sel"; const mode=document.createElement("select");mode.className="am-sel";
[["default","imgQuotaDefault"],["custom","imgQuotaCustom"],["unlimited","imgUnlimited"]].forEach(([v,k])=>mode.add(new Option(T(k),v))); const defaultLabel=type==="users"&&item.defaultBytes!=null
? T("imgQuotaDefaultOf").replace("{limit}",imageBytes(item.defaultBytes))
: T("imgQuotaDefault");
[[defaultLabel,"default"],[T("imgQuotaCustom"),"custom"],[T("imgUnlimited"),"unlimited"]]
.forEach(([label,v])=>mode.add(new Option(label,v)));
mode.value=item.overrideBytes==null?"default":item.overrideBytes===-1?"unlimited":"custom"; mode.value=item.overrideBytes==null?"default":item.overrideBytes===-1?"unlimited":"custom";
const amount=document.createElement("input");amount.className="am-inp";amount.type="number";amount.min="0";amount.max=String(10*1024);amount.step="10"; const amount=document.createElement("input");amount.className="am-inp";amount.type="number";amount.min="0";amount.max=String(10*1024);amount.step="10";
amount.value=item.overrideBytes!=null&&item.overrideBytes>=0?Math.round(item.overrideBytes/(1024*1024)):""; amount.value=item.overrideBytes!=null&&item.overrideBytes>=0?Math.round(item.overrideBytes/(1024*1024)):"";
@ -368,18 +377,69 @@ function renderImageStoragePanel(panel){
try{await api("/images/admin/"+type+"/"+item.id+"/quota",{method:"PATCH",body:{limitBytes}});renderImageStoragePanel(panel)} try{await api("/images/admin/"+type+"/"+item.id+"/quota",{method:"PATCH",body:{limitBytes}});renderImageStoragePanel(panel)}
catch(err){status.textContent=err.message} catch(err){status.textContent=err.message}
}; };
row.append(info,mode,amount,save);return row; wrap.append(mode,amount,save);
return wrap;
};
const usage=item=>T("imgQuotaUsage").replace("{used}",imageBytes(item.usedBytes)).replace("{limit}",fmt(item.limitBytes));
/* compacte gebruikersrij binnen de kaart: naam + verbruik, Bewerken klapt uit */
const userSub=u=>{
const row=document.createElement("div");row.className="am-row am-card-sub";
const nm=document.createElement("span");nm.className="am-name";nm.textContent=u.displayName;
const use=document.createElement("span");use.className="am-role";use.textContent=usage(u);
row.append(nm,use);
const tools=document.createElement("div");tools.className="am-row-tools";
tools.appendChild(editorTools(u,"users"));
const btn=document.createElement("button");btn.className="am-edit";btn.type="button";btn.textContent=T("amEdit");
const key="u:"+u.id;
const sync=()=>{const open=imageQuotaOpen.has(key);tools.style.display=open?"":"none";btn.classList.toggle("on",open)};
btn.addEventListener("click",()=>{imageQuotaOpen.has(key)?imageQuotaOpen.delete(key):imageQuotaOpen.add(key);sync()});
sync();
row.append(btn,tools);
return row;
}; };
(async()=>{ (async()=>{
try{ try{
const data=await api("/images/admin/quotas");host.innerHTML=""; const data=await api("/images/admin/quotas");host.innerHTML="";
const schoolTitle=document.createElement("div");schoolTitle.className="am-group";schoolTitle.textContent=T("imgSchools"); /* stap 1: kies een school (of Systeem) - geen eindeloze platte lijsten */
host.append(schoolTitle,...data.schools.map(s=>editor({...s,name:s.name},"schools"))); const pick=document.createElement("select");pick.className="am-sel img-quota-pick";
const userTitle=document.createElement("div");userTitle.className="am-group";userTitle.textContent=T("imgUsers"); pick.append(new Option(T("imgPickSchool"),""));
const filter=document.createElement("input");filter.className="am-inp";filter.type="search";filter.placeholder=T("imgSearchUsers"); data.schools.forEach(s=>pick.append(new Option(s.name,String(s.id))));
const users=document.createElement("div");users.className="img-quota-users"; pick.append(new Option(T("imgSystemScope"),"none"));
const draw=()=>{const term=filter.value.trim().toLowerCase();users.innerHTML="";data.users.filter(u=>(u.displayName+" "+u.username).toLowerCase().includes(term)).forEach(u=>users.append(editor(u,"users")))}; pick.value=imageQuotaSchool;
filter.oninput=draw;host.append(userTitle,filter,users);draw(); if(pick.value!==imageQuotaSchool) imageQuotaSchool=""; /* school verdwenen */
pick.onchange=()=>{imageQuotaSchool=pick.value;imageQuotaFilter="";if(pick.value)imageQuotaOpen.add("school:"+pick.value);renderImageStoragePanel(panel)};
host.append(pick);
if(!imageQuotaSchool){
const hint=document.createElement("div");hint.className="guestnote";hint.textContent=T("amPickSchoolHint");
host.append(hint);
return;
}
const system=imageQuotaSchool==="none";
const school=system?null:data.schools.find(s=>String(s.id)===imageQuotaSchool);
let users=data.users.filter(u=>system?u.schoolId==null:Number(u.schoolId)===Number(imageQuotaSchool));
/* zoeken binnen de gekozen school */
const filter=document.createElement("input");filter.className="am-inp";filter.type="search";
filter.placeholder=T("imgSearchUsers");filter.value=imageQuotaFilter;
filter.oninput=()=>{imageQuotaFilter=filter.value;draw()};
host.append(filter);
const cardHost=document.createElement("div");
host.append(cardHost);
const draw=()=>{
cardHost.innerHTML="";
const term=imageQuotaFilter.trim().toLowerCase();
const shown=users.filter(u=>(u.displayName+" "+u.username).toLowerCase().includes(term));
const card=amGroupCard({
icon:"🏫",
title:system?T("imgSystemScope"):school.name,
summary:(school?usage(school)+" · ":"")+shown.length+" "+T("imgUsers").toLowerCase(),
key:"school:"+imageQuotaSchool,
openSet:imageQuotaOpen, editSet:imageQuotaOpen,
edit:school?{key:"s:"+school.id,tools:editorTools(school,"schools")}:null,
subs:shown.map(userSub),
});
cardHost.append(card);
};
draw();
}catch(err){host.textContent="";status.textContent=err.message} }catch(err){host.textContent="";status.textContent=err.message}
})(); })();
} }