/* teach - doorzoekbare afbeeldingscatalogus voor whiteboard en plaatjeskiezers. Bestanden staan op de server; op het bord bewaren we alleen een stabiele URL. */ "use strict"; let IMAGE_CATALOG = {images:[],themes:[],folders:[],quota:{},uploadScope:"personal"}; let imageCatalogWrap = null, imageCatalogSelect = null, imageCatalogDeleteArmed = false; const imageBytes = value=>{ const n=Number(value||0); if(n<1024*1024)return Math.max(0,Math.round(n/1024))+" KB"; return (n/(1024*1024)).toLocaleString(LANG,{maximumFractionDigits:1})+" MB"; }; const imageScopeName = scope=>T(scope==="global"?"imgScopeGlobal":scope==="school"?"imgScopeSchool":"imgScopePersonal"); const imageThemeName = theme=>LANG==="nl"?theme.nameNl:theme.nameEn; const imageFileName = file=>(file.name||T("imgUntitled")).replace(/\.[^.]+$/,"").slice(0,80); async function uploadImageToCatalog(file, options={}){ if(!currentUser)throw new Error(T("imgLoginRequired")); if(!file||!file.size)throw new Error(T("imgInvalidFile")); if(file.size>50*1024*1024)throw new Error(T("imgFileTooLarge")); const params=new URLSearchParams({ name:(options.name||imageFileName(file)).slice(0,80), folder:options.folder||"", themes:(options.themeIds||[]).join(",") }); const response=await fetch("/api/images?"+params,{ method:"POST",credentials:"same-origin", headers:{"Content-Type":file.type||"application/octet-stream","x-file-name":encodeURIComponent(file.name||"")}, body:file }); const json=await response.json().catch(()=>({})); if(!response.ok)throw new Error(json.error||T("imgUploadFailed")); return json.image; } async function loadImageCatalog(){ IMAGE_CATALOG=await api("/images"); refreshWordPictureCatalog(); return IMAGE_CATALOG; } function refreshWordPictureCatalog(){ const wordCatalog=languagePictureCatalog(LANG); IMAGE_CATALOG.images=(IMAGE_CATALOG.images||[]).filter(image=>!image.wordPicture); IMAGE_CATALOG.themes=(IMAGE_CATALOG.themes||[]).filter(theme=>!theme.wordPicture); IMAGE_CATALOG.images=wordCatalog.images.concat(IMAGE_CATALOG.images); IMAGE_CATALOG.themes=wordCatalog.themes.concat(IMAGE_CATALOG.themes); } function ensureImageCatalog(){ if(imageCatalogWrap)return imageCatalogWrap; const wrap=document.createElement("div"); wrap.id="imageCatalogWrap";wrap.className="modalwrap";wrap.setAttribute("role","dialog");wrap.setAttribute("aria-modal","true"); wrap.innerHTML=` `; document.body.append(wrap);imageCatalogWrap=wrap; const q=s=>wrap.querySelector(s); q(".ic-close").onclick=closeImageCatalog; wrap.onclick=e=>{if(e.target===wrap)closeImageCatalog()}; q(".ic-search").oninput=renderImageCatalogGrid; q(".ic-theme-filter").onchange=()=>{renderImageCatalogGrid();renderLanguageThemeChips()}; q(".ic-scope-filter").onchange=renderImageCatalogGrid; q(".ic-folder-filter").onchange=renderImageCatalogGrid; q(".ic-folders").onclick=openImageFolderExplorer; q(".ic-cancel").onclick=()=>{q(".ic-editor").hidden=true;imageCatalogDeleteArmed=false}; q(".ic-save").onclick=saveCatalogImage; q(".ic-delete").onclick=deleteCatalogImage; q(".ic-theme-new button").onclick=createCatalogTheme; q(".ic-upload input").onchange=async e=>{ const files=[...e.target.files];e.target.value=""; if(!files.length)return; const folder=q(".ic-upload-folder").value; const themeIds=[...q(".ic-upload-themes").selectedOptions].map(o=>+o.value); setImageCatalogStatus(T("imgUploading")); try{ for(const file of files)await uploadImageToCatalog(file,{folder,themeIds}); await loadImageCatalog();renderImageCatalog();setImageCatalogStatus(T("imgUploaded"),true); }catch(err){setImageCatalogStatus(err.message)} }; return wrap; } function setImageCatalogStatus(text,ok=false){ if(!imageCatalogWrap)return; const status=imageCatalogWrap.querySelector(".ic-status"); status.textContent=text||"";status.classList.toggle("ok",!!ok); } function closeImageCatalog(){ if(imageCatalogWrap)imageCatalogWrap.classList.remove("open"); imageCatalogSelect=null;imageCatalogDeleteArmed=false; } function catalogOwnFolders(){ const scope=IMAGE_CATALOG.uploadScope; const paths=new Set([""]); IMAGE_CATALOG.folders.filter(f=>f.scope===scope&&f.canManage).forEach(f=>paths.add(f.path)); IMAGE_CATALOG.images.filter(i=>i.scope===scope&&i.canManage&&i.folder).forEach(i=>paths.add(i.folder)); return [...paths].sort((a,b)=>a.localeCompare(b,LANG)); } function fillImageSelect(select,items,value,label){ select.innerHTML=""; if(label!=null)select.append(new Option(label,"")); items.forEach(item=>select.append(new Option(item.label,item.value))); if([...select.options].some(o=>o.value===String(value)))select.value=String(value); } function fillThemeSelect(select,selected=[],includeAll=false,asset=null){ const current=new Set((selected||[]).map(Number));select.innerHTML=""; if(includeAll)select.append(new Option(T("imgAllThemes"),"")); IMAGE_CATALOG.themes.filter(theme=>(includeAll||!theme.filterOnly)&&(!asset||theme.scope==="global" ||(asset.scope==="school"&&theme.scope==="school") ||(asset.scope==="personal"&&theme.scope==="personal"))) .forEach(theme=>{ const option=new Option(imageThemeName(theme),theme.id); option.selected=current.has(theme.id);select.append(option); }); } function renderLanguageThemeChips(){ if(!imageCatalogWrap)return; const host=imageCatalogWrap.querySelector(".ic-word-themes"); const select=imageCatalogWrap.querySelector(".ic-theme-filter"); const themes=IMAGE_CATALOG.themes.filter(theme=>theme.languageBuiltin); host.innerHTML="";host.hidden=!themes.length; if(!themes.length)return; const label=document.createElement("strong");label.textContent=T("imgWordThemes");host.append(label); themes.forEach(theme=>{ const button=document.createElement("button");button.type="button"; button.textContent=(theme.icon||"๐Ÿ–ผ๏ธ")+" "+imageThemeName(theme); button.classList.toggle("active",select.value===String(theme.id)); button.onclick=()=>{ select.value=select.value===String(theme.id)?"":String(theme.id); renderImageCatalogGrid();renderLanguageThemeChips(); }; host.append(button); }); } function applyImageCatalogLabels(){ const q=s=>imageCatalogWrap.querySelector(s); q(".ic-title").textContent=T("imgCatalog"); q(".ic-close").title=T("close"); q(".ic-search").placeholder=T("imgSearch"); q(".ic-folders").textContent="๐Ÿ—‚ "+T("imgFolders"); q(".ic-add summary").textContent="๏ผ‹ "+T("imgUpload"); q(".ic-upload span").textContent=T("imgChooseFiles"); q(".ic-upload-folder-label").textContent=T("imgFolder"); q(".ic-upload-themes-label").textContent=T("imgThemes"); q(".ic-theme-new input").placeholder=T("imgNewTheme"); q(".ic-theme-new button").textContent=T("imgAddTheme"); q(".ic-editor h3").textContent=T("imgEdit"); q(".ic-name-label").textContent=T("imgName"); q(".ic-folder-label").textContent=T("imgFolder"); q(".ic-themes-label").textContent=T("imgThemes"); q(".ic-save").textContent=T("save"); q(".ic-delete").textContent=T("exDelete"); q(".ic-cancel").textContent=T("cancel"); } function renderImageCatalog(){ const q=s=>imageCatalogWrap.querySelector(s); refreshWordPictureCatalog(); applyImageCatalogLabels(); const themeValue=q(".ic-theme-filter").value,scopeValue=q(".ic-scope-filter").value,folderValue=q(".ic-folder-filter").value; fillThemeSelect(q(".ic-theme-filter"),[],true); fillImageSelect(q(".ic-scope-filter"),[ {value:"global",label:imageScopeName("global")}, {value:"school",label:imageScopeName("school")}, {value:"personal",label:imageScopeName("personal")} ],scopeValue,T("imgAllScopes")); const allFolders=[...new Set(IMAGE_CATALOG.images.map(i=>i.folder).concat(IMAGE_CATALOG.folders.map(f=>f.path)).filter(Boolean))] .sort((a,b)=>a.localeCompare(b,LANG)).map(path=>({value:path,label:path})); fillImageSelect(q(".ic-folder-filter"),allFolders,folderValue,T("imgAllFolders")); if(themeValue&&[...q(".ic-theme-filter").options].some(o=>o.value===themeValue))q(".ic-theme-filter").value=themeValue; const ownFolders=catalogOwnFolders().map(path=>({value:path,label:path||T("imgFolderRoot")})); fillImageSelect(q(".ic-upload-folder"),ownFolders,q(".ic-upload-folder").value); fillThemeSelect(q(".ic-upload-themes"),[...q(".ic-upload-themes").selectedOptions].map(o=>+o.value),false,{scope:IMAGE_CATALOG.uploadScope}); renderLanguageThemeChips(); const quota=IMAGE_CATALOG.quota||{},limit=quota.limitBytes; let text=T("imgQuotaUsed").replace("{used}",imageBytes(quota.usedBytes)); text+=" ยท "+(limit==null?T("imgUnlimited"):T("imgQuotaOf").replace("{limit}",imageBytes(limit))); if(quota.schoolLimitBytes!=null)text+=" ยท "+T("imgSchoolQuota").replace("{used}",imageBytes(quota.schoolUsedBytes)).replace("{limit}",imageBytes(quota.schoolLimitBytes)); q(".ic-quota").textContent=text; renderImageCatalogGrid(); } function renderImageCatalogGrid(){ if(!imageCatalogWrap)return; const q=s=>imageCatalogWrap.querySelector(s),grid=q(".ic-grid"); const search=q(".ic-search").value.trim().toLowerCase(),theme=+q(".ic-theme-filter").value||null; const scope=q(".ic-scope-filter").value,folder=q(".ic-folder-filter").value; const themeNames=new Map(IMAGE_CATALOG.themes.map(t=>[t.id,imageThemeName(t)])); const images=IMAGE_CATALOG.images.filter(image=>{ const hay=[image.name,image.folder,...(image.tags||[]),...image.themes.map(id=>themeNames.get(id)||"")].join(" ").toLowerCase(); return(!search||hay.includes(search))&&(!theme||image.themes.includes(theme)) &&(!scope||image.scope===scope)&&(!folder||image.folder===folder||image.folder.startsWith(folder+"/")); }); grid.innerHTML=""; images.forEach(image=>{ const card=document.createElement("article");card.className="ic-card"; const choose=document.createElement("button");choose.type="button";choose.className="ic-choose"; const img=document.createElement("img");img.src=image.src;img.alt=image.name;img.loading="lazy"; const name=document.createElement("strong");name.textContent=image.name; const meta=document.createElement("span");meta.textContent=[imageScopeName(image.scope),image.folder].filter(Boolean).join(" ยท "); choose.append(img,name,meta);choose.onclick=()=>{if(imageCatalogSelect){imageCatalogSelect(image.src,image);closeImageCatalog()}else if(image.canManage)openCatalogImageEditor(image)}; card.append(choose); if(image.canManage){ const edit=document.createElement("button");edit.type="button";edit.className="ic-card-edit";edit.textContent="โœ"; edit.title=T("imgEdit");edit.onclick=()=>openCatalogImageEditor(image);card.append(edit); } grid.append(card); }); if(!images.length){const empty=document.createElement("p");empty.className="gallery-empty";empty.textContent=T("imgNoResults");grid.append(empty)} } function openCatalogImageEditor(image){ const q=s=>imageCatalogWrap.querySelector(s),editor=q(".ic-editor"); editor.hidden=false;editor.dataset.id=image.id;q(".ic-edit-name").value=image.name;q(".ic-edit-folder").value=image.folder; fillThemeSelect(q(".ic-edit-themes"),image.themes,false,image); q(".ic-delete").hidden=!image.canDelete;q(".ic-delete").textContent=T("exDelete"); imageCatalogDeleteArmed=false;editor.scrollIntoView({block:"nearest"}); } async function saveCatalogImage(){ const q=s=>imageCatalogWrap.querySelector(s),editor=q(".ic-editor"),id=+editor.dataset.id; const themeIds=[...q(".ic-edit-themes").selectedOptions].map(o=>+o.value); try{ await api("/images/"+id,{method:"PATCH",body:{name:q(".ic-edit-name").value,folder:q(".ic-edit-folder").value,themeIds}}); await loadImageCatalog();editor.hidden=true;renderImageCatalog();setImageCatalogStatus(T("imgSaved"),true); }catch(err){setImageCatalogStatus(err.message)} } async function deleteCatalogImage(){ const q=s=>imageCatalogWrap.querySelector(s),button=q(".ic-delete"); if(!imageCatalogDeleteArmed){imageCatalogDeleteArmed=true;button.textContent="โš  "+T("exConfirm");return} try{ await api("/images/"+q(".ic-editor").dataset.id,{method:"DELETE"}); await loadImageCatalog();q(".ic-editor").hidden=true;renderImageCatalog();setImageCatalogStatus(T("imgDeleted"),true); }catch(err){setImageCatalogStatus(err.message)} imageCatalogDeleteArmed=false; } async function createCatalogTheme(){ const input=imageCatalogWrap.querySelector(".ic-theme-new input"),name=input.value.trim(); if(!name)return; try{ await api("/images/themes",{method:"POST",body:{name}}); input.value="";await loadImageCatalog();renderImageCatalog();setImageCatalogStatus(T("imgThemeAdded"),true); }catch(err){setImageCatalogStatus(err.message)} } function imageScopeBranches(){ const scopes=["global"]; if(currentUser&¤tUser.schoolId!=null)scopes.push("school"); scopes.push("personal"); return scopes.map(scope=>({scope,name:(scope==="global"?"๐ŸŒ ":scope==="school"?"๐Ÿซ ":"๐Ÿ‘ค ")+imageScopeName(scope)})); } function openImageFolderExplorer(){ const branches=imageScopeBranches(),branchFor=path=>branches.find(b=>b.name===path[0]); const join=path=>path.join("/"); const sameScope=(item,branch)=>item.scope===branch.scope; const adapter={ title:()=>T("imgFolders"),allowRootItems:false, list(path){ if(!path.length)return{folders:branches.map(b=>({name:b.name,count:IMAGE_CATALOG.images.filter(i=>sameScope(i,b)).length,fixed:true})),items:[]}; const branch=branchFor(path);if(!branch)return{folders:[],items:[]}; const rel=join(path.slice(1)),prefix=rel?rel+"/":"",folderNames=new Map(); const paths=IMAGE_CATALOG.folders.filter(f=>sameScope(f,branch)).map(f=>f.path) .concat(IMAGE_CATALOG.images.filter(i=>sameScope(i,branch)).map(i=>i.folder)).filter(Boolean); paths.forEach(full=>{if(rel&&full!==rel&&!full.startsWith(prefix))return;const rest=rel?full.slice(prefix.length):full;if(!rest)return;const seg=rest.split("/")[0],child=prefix+seg;folderNames.set(seg,IMAGE_CATALOG.images.filter(i=>sameScope(i,branch)&&(i.folder===child||i.folder.startsWith(child+"/"))).length)}); const items=IMAGE_CATALOG.images.filter(i=>sameScope(i,branch)&&i.folder===rel).map(image=>({ key:image.id,name:image.name,movable:image.canManage,deletable:image.canDelete, tile(){const im=document.createElement("img");im.className="ic-explorer-thumb";im.src=image.src;im.alt="";im.loading="lazy";return im} })); return{folders:[...folderNames].map(([name,count])=>({name,count})),items}; }, async createFolder(path,name){ const branch=branchFor(path);if(!branch||branch.scope!==IMAGE_CATALOG.uploadScope)return T("imgNoManage"); try{await api("/images/folders",{method:"POST",body:{path:join([...path.slice(1),name])}});await loadImageCatalog();return null}catch(err){return err.message} }, async renameFolder(path,oldName,newName){return this.moveFolder([...path,oldName],path,newName)}, async moveFolder(srcPath,targetPath,newName){ const source=branchFor(srcPath),target=branchFor(targetPath); if(!source||!target||source.scope!==target.scope)return T("exWrongBranch"); const from=join(srcPath.slice(1)),folder=IMAGE_CATALOG.folders.find(f=>f.scope===source.scope&&f.path===from); if(!folder||!folder.canManage)return T("imgNoManage"); const to=join([...targetPath.slice(1),newName||srcPath.at(-1)]); try{await api("/images/folders/"+folder.id,{method:"PATCH",body:{path:to}});await loadImageCatalog();return null}catch(err){return err.message} }, async deleteFolder(path,name){ const branch=branchFor(path),full=join([...path.slice(1),name]); const folder=branch&&IMAGE_CATALOG.folders.find(f=>f.scope===branch.scope&&f.path===full); if(!folder||!folder.canManage)return T("imgNoManage"); try{await api("/images/folders/"+folder.id,{method:"DELETE"});await loadImageCatalog();return null}catch(err){return err.message} }, async renameItem(id,name){ try{await api("/images/"+id,{method:"PATCH",body:{name}});await loadImageCatalog();return null}catch(err){return err.message} }, async moveItem(id,targetPath){ const image=IMAGE_CATALOG.images.find(i=>i.id===+id),branch=branchFor(targetPath); if(!image||!branch||image.scope!==branch.scope||!image.canManage)return T("imgNoManage"); try{await api("/images/"+id,{method:"PATCH",body:{folder:join(targetPath.slice(1))}});await loadImageCatalog();return null}catch(err){return err.message} }, async deleteItem(id){ const image=IMAGE_CATALOG.images.find(i=>i.id===+id);if(!image?.canDelete)return T("imgNoManage"); try{await api("/images/"+id,{method:"DELETE"});await loadImageCatalog();return null}catch(err){return err.message} }, openItem(id){ const image=IMAGE_CATALOG.images.find(i=>i.id===+id);if(!image)return T("imgNoResults"); if(imageCatalogSelect)imageCatalogSelect(image.src,image);closeImageCatalog();return null; } }; imageCatalogWrap.style.visibility="hidden"; openExplorer(adapter,()=>{imageCatalogWrap.style.visibility="";if(imageCatalogWrap.classList.contains("open"))renderImageCatalog()}); } async function openImageCatalog(onSelect){ if(!currentUser){alert(T("imgLoginRequired"));return} const wrap=ensureImageCatalog();imageCatalogSelect=onSelect||null;setImageCatalogStatus(T("imgLoading")); wrap.classList.add("open"); try{await loadImageCatalog();renderImageCatalog();setImageCatalogStatus("")} catch(err){setImageCatalogStatus(err.message)} } /* Systeembeheer: limieten per gebruiker en school. Wordt door admin.js als extra Instellingen-tab aangeroepen. */ function renderImageStoragePanel(panel){ 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(heading,status); 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 editor=(item,type)=>{ const row=document.createElement("div");row.className="am-row img-quota-row"; const info=document.createElement("div");info.className="am-name";info.textContent=item.name||item.displayName; 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"; [["default","imgQuotaDefault"],["custom","imgQuotaCustom"],["unlimited","imgUnlimited"]].forEach(([v,k])=>mode.add(new Option(T(k),v))); 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"; amount.value=item.overrideBytes!=null&&item.overrideBytes>=0?Math.round(item.overrideBytes/(1024*1024)):""; amount.placeholder=T("imgQuotaMb");amount.hidden=mode.value!=="custom";mode.onchange=()=>amount.hidden=mode.value!=="custom"; const save=document.createElement("button");save.className="am-btn";save.type="button";save.textContent=T("save"); save.onclick=async()=>{ const limitBytes=mode.value==="default"?null:mode.value==="unlimited"?-1:Math.round(Number(amount.value)*1024*1024); if(mode.value==="custom"&&(!Number.isFinite(limitBytes)||limitBytes<0)){status.textContent=T("imgQuotaInvalid");return} try{await api("/images/admin/"+type+"/"+item.id+"/quota",{method:"PATCH",body:{limitBytes}});renderImageStoragePanel(panel)} catch(err){status.textContent=err.message} }; row.append(info,mode,amount,save);return row; }; (async()=>{ try{ const data=await api("/images/admin/quotas");host.innerHTML=""; const schoolTitle=document.createElement("div");schoolTitle.className="am-group";schoolTitle.textContent=T("imgSchools"); host.append(schoolTitle,...data.schools.map(s=>editor({...s,name:s.name},"schools"))); const userTitle=document.createElement("div");userTitle.className="am-group";userTitle.textContent=T("imgUsers"); const filter=document.createElement("input");filter.className="am-inp";filter.type="search";filter.placeholder=T("imgSearchUsers"); const users=document.createElement("div");users.className="img-quota-users"; 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")))}; filter.oninput=draw;host.append(userTitle,filter,users);draw(); }catch(err){host.textContent="";status.textContent=err.message} })(); } const imageToolbarButton=document.getElementById("btnImages"); if(imageToolbarButton)imageToolbarButton.addEventListener("click",()=>openImageCatalog()); function refreshImageToolbar(){if(imageToolbarButton)imageToolbarButton.title=T("imgCatalog")} document.addEventListener("langchange",()=>{refreshImageToolbar();if(imageCatalogWrap?.classList.contains("open"))renderImageCatalog()}); refreshImageToolbar();