teach/public/js/image-catalog.js
Ramon 0d644ba41d
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 27s
feat: voeg afbeeldingscatalogus en opslagquota toe
2026-07-15 23:45:17 +02:00

360 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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");
return IMAGE_CATALOG;
}
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=`
<div class="modal image-catalog-modal">
<div class="gallery-head"><h2 class="ic-title"></h2><button class="wclose ic-close" type="button">✕</button></div>
<div class="ic-tools">
<input class="ic-search" type="search" autocomplete="off">
<select class="ic-theme-filter"></select>
<select class="ic-scope-filter"></select>
<select class="ic-folder-filter"></select>
<button class="tbtn ghost ic-folders" type="button"></button>
</div>
<div class="ic-quota"></div>
<details class="ic-add">
<summary></summary>
<div class="ic-add-grid">
<label class="ic-field"><span class="ic-upload-folder-label"></span><select class="ic-upload-folder"></select></label>
<label class="ic-field"><span class="ic-upload-themes-label"></span><select class="ic-upload-themes" multiple></select></label>
<label class="ed-upload ic-upload"><span></span><input type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/avif" multiple></label>
</div>
<div class="ic-theme-new"><input type="text" maxlength="80"><button class="tbtn ghost" type="button"></button></div>
</details>
<div class="ic-status" role="status"></div>
<div class="ic-grid"></div>
<section class="ic-editor" hidden>
<h3></h3>
<div class="ic-editor-grid">
<label class="ic-field"><span class="ic-name-label"></span><input class="ic-edit-name" maxlength="80"></label>
<label class="ic-field"><span class="ic-folder-label"></span><input class="ic-edit-folder" maxlength="200"></label>
<label class="ic-field"><span class="ic-themes-label"></span><select class="ic-edit-themes" multiple></select></label>
</div>
<div class="ic-editor-actions"><button class="tbtn ic-save" type="button"></button><button class="tbtn danger ic-delete" type="button"></button><button class="tbtn ghost ic-cancel" type="button"></button></div>
</section>
</div>`;
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;
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=>!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 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);
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});
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&&currentUser.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();