Initialize clean CSR inventory repository

This commit is contained in:
DenisToxic
2026-09-29 08:43:05 +02:00
commit 059f386c60
107 changed files with 9639 additions and 0 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,404 @@
(() => {
const csrftoken = document.body?.dataset.csrf;
const toast = (message, type = "success") => { const root=document.querySelector("#toast-root"),item=document.createElement("div");item.className=`toast ${type}`;item.textContent=message;root.append(item);setTimeout(()=>item.remove(),2800); };
const updateKey="csr:data-updated";
const publishUpdate=()=>{try{localStorage.setItem(updateKey,String(Date.now()));}catch{}};
if(document.querySelector(".toast-stack .toast.success"))publishUpdate();
if(document.querySelector("[data-live-refresh]")){
const loadedAt=Date.now();
let dirty=false,pending=false;
const markDirty=event=>{if(event.target.closest("[data-live-refresh] form[method=post]"))dirty=true;};
document.addEventListener("input",markDirty);
document.addEventListener("change",markDirty);
const refreshIfSafe=()=>{if(document.visibilityState!=="visible"||dirty||document.querySelector(".editing, tr.selected"))return;if(pending||Date.now()-loadedAt>30000)window.location.reload();};
window.addEventListener("storage",event=>{if(event.key===updateKey){pending=true;refreshIfSafe();}});
document.addEventListener("visibilitychange",refreshIfSafe);
window.addEventListener("focus",refreshIfSafe);
}
const germanDate=value=>{const text=String(value||"").trim(),iso=text.match(/^(\d{4})-(\d{2})-(\d{2})$/);if(iso)return `${iso[3]}.${iso[2]}.${iso[1]}`;const digits=text.replace(/\D/g,"");return digits.length===8?`${digits.slice(0,2)}.${digits.slice(2,4)}.${digits.slice(4)}`:text;};
const bindGermanDates=(root=document)=>root.querySelectorAll("[data-german-date]").forEach(input=>{if(input.dataset.dateBound)return;input.dataset.dateBound="1";input.value=germanDate(input.value);input.addEventListener("blur",()=>input.value=germanDate(input.value));});bindGermanDates();
const typeFieldsScript=document.querySelector("#device-type-fields");
if(typeFieldsScript){
const typeFields=JSON.parse(typeFieldsScript.textContent);
document.querySelectorAll("[data-device-type-form]").forEach(form=>{
const typeSelect=form.querySelector('[name="device_type"]'),section=form.querySelector("[data-hardware-section]");
if(!typeSelect||!section)return;
const updateHardware=()=>{
const noType=form.hasAttribute("data-hide-hardware-without-type")&&!typeSelect.value;
const visible=new Set(noType?[]:(typeFields[typeSelect.value]||typeFields[""]||[]));
section.hidden=noType;
const selectedLabel=section.querySelector("[data-selected-type-label]");
if(selectedLabel)selectedLabel.textContent=typeSelect.value?`· ${typeSelect.selectedOptions[0]?.textContent.trim()||""}`:"";
section.querySelectorAll("[data-hardware-field]").forEach(wrapper=>{
const active=visible.has(wrapper.dataset.hardwareField);
wrapper.hidden=!active;
wrapper.querySelectorAll("input,select,textarea").forEach(field=>field.disabled=!active||form.dataset.readonly==="true");
});
};
typeSelect.addEventListener("change",updateHardware);
updateHardware();
});
}
document.querySelector("[data-sidebar-toggle]")?.addEventListener("click",()=>document.querySelector("#sidebar")?.classList.toggle("open"));
const filterPanel=document.querySelector("[data-filter-panel]"),filterToggle=document.querySelector("[data-filter-toggle]");
const setFilterOpen=open=>{if(!filterPanel||!filterToggle)return;filterPanel.toggleAttribute("hidden",!open);filterToggle.setAttribute("aria-expanded",String(open));};
filterToggle?.addEventListener("click",()=>setFilterOpen(filterPanel.hasAttribute("hidden")));
document.querySelector("[data-filter-close]")?.addEventListener("click",()=>setFilterOpen(false));
const updateMultiFilter=details=>{const selected=[...details.querySelectorAll('input[type="checkbox"]:checked')],value=details.querySelector("[data-multi-value]");if(!value)return;value.textContent=selected.length===0?"Alle":selected.length===1?selected[0].nextElementSibling.textContent:`${selected.length} ausgewählt`;};
document.querySelectorAll("[data-multi-filter]").forEach(details=>{updateMultiFilter(details);details.addEventListener("change",()=>updateMultiFilter(details));});
const filterForm=document.querySelector("#filter-form"),updateFilterCount=()=>{if(!filterForm)return;const ignored=new Set(["q","page_size","sort","dir","page","partial","exact"]),active=new Set();[...filterForm.elements].forEach(field=>{if(!field.name||ignored.has(field.name))return;const hasValue=(field.type==="checkbox"||field.type==="radio")?field.checked:Boolean(field.value);if(hasValue)active.add(field.name);});const badge=filterToggle?.querySelector(".filter-count");if(badge){badge.textContent=active.size;badge.toggleAttribute("hidden",active.size===0);}filterToggle?.classList.toggle("active",active.size>0);};
filterForm?.addEventListener("input",updateFilterCount);filterForm?.addEventListener("change",updateFilterCount);updateFilterCount();
const bindCpuAutocomplete=(root=document)=>root.querySelectorAll("[data-cpu-autocomplete]").forEach(input=>{if(input.dataset.cpuBound)return;input.dataset.cpuBound="1";input.setAttribute("role","combobox");input.setAttribute("aria-autocomplete","list");input.setAttribute("aria-expanded","false");const host=input.parentElement,menu=document.createElement("div");host.classList.add("cpu-autocomplete-host");menu.className="cpu-suggestions";menu.setAttribute("role","listbox");menu.hidden=true;input.insertAdjacentElement("afterend",menu);let timer,controller,items=[],active=-1;const close=()=>{menu.hidden=true;menu.replaceChildren();items=[];active=-1;input.setAttribute("aria-expanded","false");};const markActive=index=>{if(!items.length)return;active=(index+items.length)%items.length;items.forEach((item,itemIndex)=>item.classList.toggle("active",itemIndex===active));items[active].scrollIntoView({block:"nearest"});};const choose=value=>{input.value=value;input.dispatchEvent(new Event("change",{bubbles:true}));close();input.focus();};const load=async()=>{const term=input.value.trim();controller?.abort();if(!term){close();return;}controller=new AbortController();try{const response=await fetch(`/autocomplete/cpu/?${new URLSearchParams({q:term})}`,{signal:controller.signal}),data=await response.json();menu.replaceChildren();items=data.results.map((result,index)=>{const button=document.createElement("button"),label=document.createElement("strong");button.type="button";button.setAttribute("role","option");label.textContent=result.label;button.append(label);if(index===0){const hint=document.createElement("small");hint.textContent="Wahrscheinlichster Treffer";button.append(hint);}button.addEventListener("mousedown",event=>event.preventDefault());button.addEventListener("click",()=>choose(result.label));menu.append(button);return button;});active=items.length?0:-1;items.forEach((item,index)=>item.classList.toggle("active",index===0));menu.hidden=!items.length;input.setAttribute("aria-expanded",String(items.length>0));}catch(error){if(error.name!=="AbortError")close();}};input.addEventListener("input",()=>{clearTimeout(timer);timer=setTimeout(load,120);});input.addEventListener("focus",()=>{if(input.value.trim())load();});input.addEventListener("keydown",event=>{if(menu.hidden||!items.length)return;if(event.key==="ArrowDown"){event.preventDefault();markActive(active+1);}else if(event.key==="ArrowUp"){event.preventDefault();markActive(active-1);}else if(event.key==="Enter"){event.preventDefault();event.stopImmediatePropagation();choose(items[Math.max(active,0)].querySelector("strong").textContent);}else if(event.key==="Escape"){event.preventDefault();close();}});input.addEventListener("blur",()=>setTimeout(close,160));});bindCpuAutocomplete();
document.querySelectorAll("[data-auto-submit]").forEach(el=>el.addEventListener("change",()=>{const size=Number(el.value);fetch("/preferences/",{method:"POST",headers:{"X-CSRFToken":csrftoken,"Content-Type":"application/json"},body:JSON.stringify({page_size:size})});el.form.submit();}));
document.addEventListener("submit", event => {
const form = event.target.closest?.("form[data-confirm]");
if (!form || form.dataset.confirmed) return;
event.preventDefault();
const overlay = document.createElement("div"), box = document.createElement("div");
overlay.className = "drawer-backdrop";
box.className = "confirm-box";
const heading = document.createElement("h2"), message = document.createElement("p");
heading.textContent = "Bitte bestätigen";
message.textContent = form.dataset.confirm;
const actions = document.createElement("div"), cancel = document.createElement("button"), confirm = document.createElement("button");
cancel.type = confirm.type = "button";
cancel.className = "btn";
confirm.className = "btn primary";
cancel.textContent = "Abbrechen";
confirm.textContent = form.dataset.confirmAction || "Bestätigen";
actions.append(cancel, confirm);
box.append(heading, message, actions);
box.setAttribute("role", "dialog");
box.setAttribute("aria-modal", "true");
const close = () => { overlay.remove(); box.remove(); document.removeEventListener("keydown", onKey); };
const onKey = event => { if (event.key === "Escape") close(); };
overlay.onclick = cancel.onclick = close;
confirm.onclick = () => { form.dataset.confirmed = "1"; close(); form.requestSubmit(); delete form.dataset.confirmed; };
document.addEventListener("keydown", onKey);
document.body.append(overlay, box);
cancel.focus();
});
let searchTimer,searchController;const refreshDeviceTable=async(form,exact=false)=>{const container=document.querySelector("#device-table-container"),params=new URLSearchParams(new FormData(form)),endpoint=form.getAttribute("action")||window.location.pathname;params.set("partial","1");exact&&params.get("q")?params.set("exact","1"):params.delete("exact");searchController?.abort();searchController=new AbortController();container?.setAttribute("aria-busy","true");try{const response=await fetch(`${endpoint}?${params.toString()}`,{signal:searchController.signal,headers:{"X-Requested-With":"XMLHttpRequest"}});if(!response.ok)throw new Error();container.innerHTML=await response.text();const total=container.querySelector("[data-table-total]")?.dataset.tableTotal;const totalLabel=document.querySelector("[data-total-count]");if(totalLabel&&total!==undefined)totalLabel.textContent=total;const browserParams=new URLSearchParams(params);browserParams.delete("partial");browserParams.delete("page");history.replaceState({},"",`${window.location.pathname}${browserParams.toString()?`?${browserParams.toString()}`:""}`);bindDeviceTable();}catch(error){if(error.name!=="AbortError")toast("Die Nummernsuche konnte nicht aktualisiert werden.","error");}finally{container?.removeAttribute("aria-busy");}};const numberSearch=document.querySelector("[data-search-input]");numberSearch?.addEventListener("input",event=>{event.target.value=event.target.value.replace(/\D/g,"");clearTimeout(searchTimer);searchTimer=setTimeout(()=>refreshDeviceTable(event.target.form),200);});numberSearch?.addEventListener("keydown",event=>{if(event.key==="Enter"){event.preventDefault();clearTimeout(searchTimer);refreshDeviceTable(event.target.form,true);}});
let clickTimer;
const displayCell=(cell,display,color="")=>{const field=cell.dataset.field;if(["ram_gb","ssd_gb","hdd_gb"].includes(field)&&display)cell.textContent=`${display} GB`;else if(field==="price"&&display)cell.textContent=`${String(display).replace(".",",")} €`;else if(field==="display_inches"&&display)cell.textContent=`${String(display).replace(".",",")} Zoll`;else if(field==="status"){const badge=document.createElement("span");badge.className="status status-colored";badge.textContent=display||"—";badge.style.setProperty("--status-color",color||"#64748b");cell.replaceChildren(badge);}else cell.textContent=display||"—";};
const editCell = async cell => {
if (cell.classList.contains("editing")) return;
cell.classList.add("editing");
const original = cell.innerHTML, oldValue = cell.dataset.value || "";
const row = cell.closest("tr"), expectedRevision = row.dataset.revision;
let input;
if (cell.dataset.input === "select") {
input = document.createElement("select");
input.add(new Option("—", ""));
try {
const response = await fetch(`/autocomplete/${cell.dataset.field}/?q=`);
if (!response.ok) throw new Error();
const data = await response.json();
data.results.forEach(item => input.add(new Option(item.label, String(item.id))));
// Retain inactive values and values outside any server-side result limit.
if (oldValue && ![...input.options].some(option => option.value === oldValue)) {
input.add(new Option(cell.textContent.trim(), oldValue));
}
input.value = oldValue;
} catch {
cell.classList.remove("editing");
toast("Auswahlwerte konnten nicht geladen werden.", "error");
return;
}
} else if (cell.dataset.input === "tristate") {
input = document.createElement("select");
[["YES", "JA"], ["NO", "NEIN"], ["UNKNOWN", "UNBEKANNT"]].forEach(([value, label]) => input.add(new Option(label, value, false, value === oldValue)));
} else {
input = document.createElement("input");
input.type = cell.dataset.input === "number" ? "number" : "text";
if (input.type === "number") input.step = cell.dataset.step || "1";
input.value = cell.dataset.input === "date" ? germanDate(oldValue) : oldValue;
if (cell.dataset.input === "date") {
input.dataset.germanDate = "";
input.placeholder = "TT.MM.JJJJ";
input.inputMode = "numeric";
}
if (cell.dataset.field === "cpu") { input.dataset.cpuAutocomplete = ""; input.autocomplete = "off"; }
}
cell.replaceChildren(input);
bindGermanDates(cell);
bindCpuAutocomplete(cell);
const initialValue = input.value;
input.focus();
input.select?.();
let finished = false;
const restore = () => { cell.classList.remove("editing"); cell.innerHTML = original; };
const cancel = () => { if (!finished) { finished = true; restore(); } };
const nextCell = () => {
const cells = [...row.querySelectorAll("td[data-field]")], next = cells[cells.indexOf(cell) + 1];
if (next) editCell(next);
};
const save = async (moveNext = false) => {
if (finished) return;
finished = true;
if (input.value === initialValue) { restore(); if (moveNext) nextCell(); return; }
try {
const response = await fetch(`/devices/${row.dataset.deviceId}/inline/`, {
method: "POST", headers: {"X-CSRFToken": csrftoken, "Content-Type": "application/json"},
body: JSON.stringify({field: cell.dataset.field, value: input.value, expected_revision: expectedRevision})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Speichern fehlgeschlagen");
cell.dataset.value = data.value ?? "";
row.dataset.updatedAt = data.updated_at;
row.dataset.revision = data.revision;
cell.classList.remove("editing");
displayCell(cell, data.display, data.color);
toast("Gespeichert");
publishUpdate();
if (moveNext) nextCell();
} catch (error) { restore(); toast(error.message, "error"); }
};
input.addEventListener("keydown", event => {
if (event.key === "Enter") { event.preventDefault(); save(); }
else if (event.key === "Escape") { event.preventDefault(); cancel(); }
else if (event.key === "Tab") { event.preventDefault(); save(true); }
});
input.addEventListener("blur", () => setTimeout(() => { if (!finished) save(); }, 220));
};
let tableBindingController;
const readJSON=(value,fallback)=>{try{return value?JSON.parse(value):fallback;}catch{return fallback;}};
const savePreferences=payload=>fetch("/preferences/",{method:"POST",headers:{"X-CSRFToken":csrftoken,"Content-Type":"application/json"},body:JSON.stringify(payload)}).catch(()=>{});
const reorderTableColumns=(table,order)=>table.querySelectorAll("tr").forEach(row=>{const cells=new Map([...row.children].filter(cell=>cell.dataset.column).map(cell=>[cell.dataset.column,cell]));order.forEach(key=>{const cell=cells.get(key);if(cell)row.append(cell);});});
const bindDeviceTable=()=>{
tableBindingController?.abort();
tableBindingController=new AbortController();
const {signal}=tableBindingController;
const container=document.querySelector("#device-table-container");
container?.querySelectorAll("tr[data-device-id]").forEach(row=>{
row.addEventListener("click",event=>{if(event.target.closest("td.editing,input,button,label,a"))return;clearTimeout(clickTimer);clickTimer=setTimeout(()=>{window.location.href=row.dataset.detailUrl;},220);},{signal});
row.addEventListener("dblclick",()=>clearTimeout(clickTimer),{signal});
});
container?.querySelectorAll("td[data-field]").forEach(cell=>cell.addEventListener("dblclick",event=>{event.preventDefault();event.stopPropagation();clearTimeout(clickTimer);editCell(cell);},{signal}));
const table=container?.querySelector("[data-device-table]"),panel=document.querySelector("[data-columns-panel]");
if(!table||!panel)return;
const bulkForm=document.querySelector("[data-bulk-form]");
if(bulkForm){
const boxes=[...table.querySelectorAll("[data-device-select]")],selectAll=table.querySelector("[data-select-all]"),count=bulkForm.querySelector("[data-bulk-count]"),inputs=bulkForm.querySelector("[data-bulk-inputs]"),submit=bulkForm.querySelector("[data-bulk-submit]"),fields=[...bulkForm.querySelectorAll("[data-bulk-field]")];
const returnQuery=bulkForm.querySelector('[name="return_query"]');
if(returnQuery)returnQuery.value=window.location.search.replace(/^\?/,"");
const updateSelection=()=>{
const selected=boxes.filter(box=>box.checked);
boxes.forEach(box=>box.closest("tr")?.classList.toggle("selected",box.checked));
if(selectAll){selectAll.checked=Boolean(boxes.length)&&selected.length===boxes.length;selectAll.indeterminate=selected.length>0&&selected.length<boxes.length;}
count.textContent=selected.length;
inputs.replaceChildren(...selected.map(box=>{const input=document.createElement("input");input.type="hidden";input.name="device_ids";input.value=box.value;return input;}));
bulkForm.hidden=selected.length===0;
submit.disabled=selected.length===0||!fields.some(field=>field.value);
};
boxes.forEach(box=>box.addEventListener("change",updateSelection,{signal}));
selectAll?.addEventListener("change",()=>{boxes.forEach(box=>box.checked=selectAll.checked);updateSelection();},{signal});
fields.forEach(field=>field.addEventListener("change",updateSelection,{signal}));
bulkForm.querySelector("[data-bulk-clear]")?.addEventListener("click",()=>{boxes.forEach(box=>box.checked=false);updateSelection();},{signal});
boxes.forEach(box=>box.checked=false);
fields.forEach(field=>field.value="");
updateSelection();
}
const userKey=document.body.dataset.userId||"guest";
const orderStorageKey=`csr-column-order-${userKey}`;
const visibleStorageKey=`csr-visible-columns-${userKey}`;
const defaultColumns=[...table.querySelectorAll("thead th[data-column]")].map(th=>({key:th.dataset.column,label:th.textContent.trim()}));
const availableKeys=defaultColumns.map(column=>column.key);
const requiredPhotoColumns=["recipient","location","price","battery","lan","display_inches","donor","comment"];
const serverOrder=readJSON(document.querySelector("#server-column-order")?.textContent,[]);
const localOrder=readJSON(localStorage.getItem(orderStorageKey),null);
const storedOrder=Array.isArray(localOrder)?localOrder:serverOrder;
const orderNeedsUpgrade=!storedOrder?.length||requiredPhotoColumns.some(key=>!storedOrder.includes(key));
let order=orderNeedsUpgrade?[...availableKeys]:[...storedOrder.filter(key=>availableKeys.includes(key)),...availableKeys.filter(key=>!storedOrder.includes(key))];
if(orderNeedsUpgrade){localStorage.setItem(orderStorageKey,JSON.stringify(order));savePreferences({column_order:order});}
reorderTableColumns(table,order);
const columns=order.map(key=>defaultColumns.find(column=>column.key===key)).filter(Boolean);
const serverVisible=readJSON(document.querySelector("#server-visible-columns")?.textContent,[]);
const localVisible=readJSON(localStorage.getItem(visibleStorageKey),null);
const legacyVisible=readJSON(localStorage.getItem("csr-visible-columns"),null);
const storedVisible=Array.isArray(localVisible)?localVisible:(serverVisible.length?serverVisible:legacyVisible);
const visibilityUpgradeKey=`csr-complete-list-columns-${userKey}`;
const visible=new Set((storedVisible||availableKeys).filter(key=>availableKeys.includes(key)));
if(!localStorage.getItem(visibilityUpgradeKey)){
requiredPhotoColumns.forEach(key=>visible.add(key));
localStorage.setItem(visibilityUpgradeKey,"1");
const migratedVisible=order.filter(key=>visible.has(key));
localStorage.setItem(visibleStorageKey,JSON.stringify(migratedVisible));
savePreferences({visible_columns:migratedVisible});
}
const applyVisibility=()=>columns.forEach(column=>container.querySelectorAll(`[data-column="${column.key}"]`).forEach(element=>element.style.display=visible.has(column.key)?"":"none"));
panel.replaceChildren();
const hint=document.createElement("p");
hint.className="column-picker-hint";
hint.textContent="Reihenfolge direkt im Tabellenkopf durch Ziehen ändern.";
panel.append(hint);
columns.forEach(column=>{
const label=document.createElement("label"),check=document.createElement("input");
label.dataset.column=column.key;
check.type="checkbox";
check.checked=visible.has(column.key);
check.addEventListener("change",()=>{
check.checked?visible.add(column.key):visible.delete(column.key);
const values=order.filter(key=>visible.has(key));
localStorage.setItem(visibleStorageKey,JSON.stringify(values));
savePreferences({visible_columns:values});
applyVisibility();
},{signal});
label.append(check,column.label);
panel.append(label);
});
applyVisibility();
document.querySelector("[data-columns-toggle]").onclick=()=>panel.toggleAttribute("hidden");
const syncPickerOrder=()=>order.forEach(key=>{const label=panel.querySelector(`label[data-column="${key}"]`);if(label)panel.append(label);});
const persistOrder=()=>{
localStorage.setItem(orderStorageKey,JSON.stringify(order));
savePreferences({column_order:order});
syncPickerOrder();
};
const animateOrder=(nextOrder,visualBefore=null)=>{
if(!visualBefore&&nextOrder.every((key,index)=>key===order[index]))return;
const headers=[...table.querySelectorAll("thead th[data-column]")];
const before=visualBefore||new Map(headers.map(header=>[header.dataset.column,header.getBoundingClientRect().left]));
headers.forEach(header=>{header.style.transition="none";header.style.transform="";});
order=nextOrder;
reorderTableColumns(table,order);
headers.forEach(header=>{
const delta=(before.get(header.dataset.column)??header.getBoundingClientRect().left)-header.getBoundingClientRect().left;
if(!delta)return;
header.style.transition="none";
header.style.transform=`translateX(${delta}px)`;
});
table.getBoundingClientRect();
requestAnimationFrame(()=>headers.forEach(header=>{
header.style.transition="transform 180ms cubic-bezier(.2,.8,.2,1)";
header.style.transform="";
}));
setTimeout(()=>headers.forEach(header=>header.style.removeProperty("transition")),210);
};
let drag=null,suppressClick=false;
const beginDrag=(event,header)=>{
const rect=header.getBoundingClientRect();
drag={header,key:header.dataset.column,startX:event.clientX,startY:event.clientY,offsetX:event.clientX-rect.left,active:false,ghost:null,placeholder:null,pendingOrder:[...order],targetToken:""};
};
const moveDrag=event=>{
if(!drag)return;
if(!drag.active&&Math.hypot(event.clientX-drag.startX,event.clientY-drag.startY)<6)return;
if(!drag.active){
drag.active=true;
suppressClick=true;
drag.ghost=document.createElement("div");
drag.ghost.className="column-drag-ghost";
drag.ghost.textContent=drag.header.textContent.trim();
document.body.append(drag.ghost);
drag.placeholder=document.createElement("div");
drag.placeholder.className="column-drop-placeholder";
const sourceRect=drag.header.getBoundingClientRect();
const visibleHeaders=order.map(key=>table.querySelector(`thead th[data-column="${key}"]`)).filter(header=>header&&getComputedStyle(header).display!=="none");
drag.layout=new Map(visibleHeaders.map(header=>[header.dataset.column,{header,left:header.offsetLeft,width:header.getBoundingClientRect().width}]));
drag.baseStart=Math.min(...[...drag.layout.values()].map(item=>item.left));
drag.sourceWidth=drag.layout.get(drag.key)?.width||sourceRect.width;
drag.placeholder.style.width=`${Math.max(44,drag.sourceWidth-8)}px`;
drag.placeholder.style.height=`${Math.max(24,sourceRect.height-8)}px`;
table.closest(".table-shell").append(drag.placeholder);
document.body.classList.add("column-dragging");
table.classList.add("column-reordering");
drag.header.classList.add("column-drag-source");
drag.header.setAttribute("aria-grabbed","true");
}
event.preventDefault();
const ghostX=event.clientX-Math.min(drag.offsetX,Math.max(24,drag.header.getBoundingClientRect().width-24));
drag.ghost.style.transform=`translate3d(${ghostX}px,${event.clientY-18}px,0)`;
const shell=table.closest(".table-shell"),shellRect=shell.getBoundingClientRect(),edge=52;
if(event.clientX<shellRect.left+edge)shell.scrollLeft-=Math.ceil((shellRect.left+edge-event.clientX)/7);
else if(event.clientX>shellRect.right-edge)shell.scrollLeft+=Math.ceil((event.clientX-(shellRect.right-edge))/7);
const visibleOrder=order.filter(key=>drag.layout.has(key));
const otherKeys=visibleOrder.filter(key=>key!==drag.key);
const pointerX=event.clientX-shellRect.left+shell.scrollLeft-table.offsetLeft;
let cursor=drag.baseStart,beforeKey=null;
for(const key of otherKeys){
const width=drag.layout.get(key).width;
if(pointerX<cursor+width/2){beforeKey=key;break;}
cursor+=width;
}
const nextOrder=order.filter(key=>key!==drag.key);
let insertAt;
if(beforeKey)insertAt=nextOrder.indexOf(beforeKey);
else{
const lastVisible=otherKeys.at(-1);
insertAt=lastVisible?nextOrder.indexOf(lastVisible)+1:nextOrder.length;
}
nextOrder.splice(Math.max(0,insertAt),0,drag.key);
drag.pendingOrder=nextOrder;
const pendingVisible=nextOrder.filter(key=>drag.layout.has(key));
const targetLefts=new Map();
cursor=drag.baseStart;
pendingVisible.forEach(key=>{targetLefts.set(key,cursor);cursor+=drag.layout.get(key).width;});
drag.layout.forEach((item,key)=>{
const delta=(targetLefts.get(key)??item.left)-item.left;
item.header.style.transform=delta?`translateX(${delta}px)`:"";
});
const targetLeft=targetLefts.get(drag.key)??drag.layout.get(drag.key).left;
const headerRect=drag.header.getBoundingClientRect();
drag.placeholder.style.left=`${table.offsetLeft+targetLeft+drag.sourceWidth/2}px`;
drag.placeholder.style.top=`${headerRect.top-shellRect.top+shell.scrollTop+4}px`;
const targetToken=beforeKey||"__end__";
if(targetToken!==drag.targetToken){
drag.targetToken=targetToken;
drag.placeholder.classList.remove("pop");
void drag.placeholder.offsetWidth;
drag.placeholder.classList.add("pop");
}
};
const endDrag=event=>{
if(!drag)return;
const {header,active,ghost,placeholder,pendingOrder}=drag;
drag=null;
if(active){
const headers=[...table.querySelectorAll("thead th[data-column]")];
const visualBefore=new Map(headers.map(item=>[item.dataset.column,item.getBoundingClientRect().left]));
ghost?.remove();
placeholder?.remove();
header.classList.remove("column-drag-source");
header.setAttribute("aria-grabbed","false");
table.classList.remove("column-reordering");
document.body.classList.remove("column-dragging");
if(event?.type!=="pointercancel"&&pendingOrder.some((key,index)=>key!==order[index])){
animateOrder(pendingOrder,visualBefore);
persistOrder();
}else animateOrder(order,visualBefore);
setTimeout(()=>suppressClick=false,0);
requestAnimationFrame(()=>header.focus({preventScroll:true}));
}
};
table.addEventListener("click",event=>{if(suppressClick){event.preventDefault();event.stopImmediatePropagation();suppressClick=false;}},{capture:true,signal});
window.addEventListener("pointermove",moveDrag,{signal});
window.addEventListener("pointerup",endDrag,{signal});
window.addEventListener("pointercancel",endDrag,{signal});
table.querySelectorAll("thead th[data-column]").forEach(header=>{
header.tabIndex=0;
header.setAttribute("aria-grabbed","false");
header.title=`${header.textContent.trim()} verschieben: ziehen oder Alt + Pfeiltaste verwenden`;
header.querySelectorAll("a").forEach(link=>link.draggable=false);
header.addEventListener("pointerdown",event=>{const rect=header.getBoundingClientRect();if(event.button!==0||rect.right-event.clientX<10)return;beginDrag(event,header);},{signal});
header.addEventListener("keydown",event=>{
if(!event.altKey||!["ArrowLeft","ArrowRight"].includes(event.key))return;
event.preventDefault();
const visibleOrder=order.filter(key=>visible.has(key)),index=visibleOrder.indexOf(header.dataset.column),swapIndex=index+(event.key==="ArrowLeft"?-1:1);
if(index<0||swapIndex<0||swapIndex>=visibleOrder.length)return;
const nextOrder=[...order],otherKey=visibleOrder[swapIndex],first=nextOrder.indexOf(header.dataset.column),second=nextOrder.indexOf(otherKey);
[nextOrder[first],nextOrder[second]]=[nextOrder[second],nextOrder[first]];
animateOrder(nextOrder);
persistOrder();
requestAnimationFrame(()=>header.focus({preventScroll:true}));
},{signal});
});
};
bindDeviceTable();
document.querySelectorAll(".toast-stack .toast").forEach(item=>setTimeout(()=>item.remove(),3500));const initialDevice=document.querySelector("#device-table-container")?.dataset.initialDevice;if(initialDevice){const row=document.querySelector(`tr[data-device-id="${CSS.escape(initialDevice)}"]`);if(row)window.location.href=row.dataset.detailUrl;}
})();

View File

@@ -0,0 +1,39 @@
(() => {
const key = "csr-theme";
const systemTheme = () => window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
const savedTheme = () => {
try {
const value = localStorage.getItem(key);
return value === "dark" || value === "light" ? value : null;
} catch {
return null;
}
};
const applyTheme = theme => {
document.documentElement.dataset.theme = theme;
document.querySelectorAll("[data-theme-toggle]").forEach(button => {
const dark = theme === "dark";
button.setAttribute("aria-pressed", String(dark));
button.setAttribute("aria-label", dark ? "Hellmodus einschalten" : "Dunkelmodus einschalten");
const icon = button.querySelector("[data-theme-icon]");
const label = button.querySelector("[data-theme-label]");
if (icon) icon.textContent = dark ? "☀" : "☾";
if (label) label.textContent = dark ? "Hellmodus" : "Dunkelmodus";
});
};
applyTheme(savedTheme() || systemTheme());
document.addEventListener("DOMContentLoaded", () => {
applyTheme(document.documentElement.dataset.theme);
document.querySelectorAll("[data-theme-toggle]").forEach(button => button.addEventListener("click", () => {
const theme = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
try { localStorage.setItem(key, theme); } catch {}
applyTheme(theme);
}));
});
window.addEventListener("storage", event => {
if (event.key === key) applyTheme(savedTheme() || systemTheme());
});
window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener?.("change", () => {
if (!savedTheme()) applyTheme(systemTheme());
});
})();