Files
radixOS/public/javascript/main.js
2026-04-29 15:44:20 +02:00

1597 lines
46 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
const restartHooks = [];
let tableFetchController = null
let root = document.querySelector(":root");
let copyToastTimeout;
//#region Systemwide colors
const COLORS = {
BLUE: 'rgb(100, 149, 237)',
GRAY: 'rgb(128, 128, 128)',
GREEN: 'rgb(198, 244, 180)',
YELLOW: 'rgb(255, 230, 153)',
RED: 'rgb(248, 191, 173)',
}
//#endregion
//#region Restart
function addRestartHook(fn) {
if (typeof fn === "function") restartHooks.push(fn);
}
function restart() {
restartHooks.forEach(fn => fn());
location.reload();
}
//#endregion
//#region CSS
// sets the css root variable name by value //
function setCSSVariable(name, value) {
root.style.setProperty(`--${name}`, value);
}
// gets the value of asked css variable name //
function getCSSVariable(name) {
return getComputedStyle(root).getPropertyValue(`--${name}`).trim();
}
async function loadServerStyles(activeTheme = 'dark') {
const res = await fetch('models/stylesheet.json');
const json = await res.json();
function toCssVars(obj, prefix = '', group = '') {
let vars = [];
for (const key in obj) {
const value = obj[key];
const newPrefix = prefix ? `${prefix}-${key}` : key;
if (Array.isArray(value)) {
if (value.length === 3) {
vars.push(`--${group}-${newPrefix}: rgb(${value.join(', ')});`);
} else if (value.length === 4) {
vars.push(`--${group}-${newPrefix}: rgba(${value.join(', ')});`);
}
}
else if (typeof value === 'object' && value !== null) {
vars.push(toCssVars(value, newPrefix, group));
}
else if (value !== '') {
vars.push(`--${group}-${newPrefix}: ${value};`);
}
}
return vars.join('\n');
}
let css = '';
// =========================
// 1. GLOBAL ROOT (times + base tokens)
// =========================
if (json.times) {
css += `:root {\n${toCssVars(json.times, '', 'times')}\n}\n`;
}
// =========================
// 2. THEME (selected variant)
// =========================
const themeVars = json.theme?.[activeTheme] || {};
css += `
:root {
${toCssVars(themeVars, '', 'theme')}
}
`;
// =========================
// 3. RESPONSIVE (media queries)
// =========================
const responsive = json.responsive || {};
for (const breakpoint in responsive) {
const bpVars = responsive[breakpoint];
// flatten vars
const vars = toCssVars(bpVars, '', 'responsive');
let media = '';
if (breakpoint === 'mobile') {
media = '@media (max-width: 600px)';
}
else if (breakpoint === 'tablet') {
media = '@media (min-width: 601px) and (max-width: 1024px)';
}
else if (breakpoint === 'desktop') {
media = '@media (min-width: 1025px)';
}
else {
continue; // unknown breakpoint skip
}
css += `${media} {
:root {
${vars}
}
}
`;
}
// =========================
// 4. INJECT STYLE TAG
// =========================
let styleTag = document.getElementById('theme-styles');
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = 'theme-styles';
document.head.appendChild(styleTag);
}
// console.log(css)
styleTag.textContent = css;
const startmenuicon = document.getElementById('start-menu-icon');
if(!startmenuicon) {
return true;
}
startmenuicon.src = `../images/radix_os_${activeTheme}_img.png`;
return true;
}
function switchTheme(themeName) {
setCookie('theme', themeName); // Theme direkt im Cookie speichern
savedTheme = themeName;
loadServerStyles(themeName);
}
function setFontFamily(fontFamily) {
setCookie('fontfamily', fontFamily); // FontFamily direkt im Cookie speichern
setCSSVariable('fontFamily', fontFamily)
document.documentElement.style.fontFamily = fontFamily
}
function setFontSize(fontSize) {
setCookie('fontsize', fontSize); // FontSize direkt im Cookie speichern
setCSSVariable('fontSize', fontSize + 'px')
document.documentElement.style.fontSize = fontSize + 'px'
}
//#endregion
function copyToClipboard(text) {
navigator.clipboard.writeText(text)
.then(() => {
showMessage('Kopiert!', `Text in Zwischenablage kopiert: ${text}`, levelId = -1, onclick = null, duration = 4000)
})
.catch(err => {
console.error('Copy fehlgeschlagen', err);
});
}
//#region Cookies
try {
// create or replace cookie
function setCookie(name, value) {
document.cookie=name + "=" + value + "; path=/; expires=" + (new Date(Number(new Date()) + (365 * 24 * 60 * 60 * 1000 * 10))).toGMTString();
}
// read cookie value
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
// remove cookie
function deleteCookie(name) {
if(getCookie(name) ) {
document.cookie = `${name}= ;expires=Thu, 01 Jan 1970 00:00:01 GMT`;
}
}
} catch( err ) {
alert(err);
}
//#endregion
let savedFontFamily = getCookie("fontfamily") || 'Arial';
let savedFontSize = getCookie("fontsize") || 14;
let savedTheme = getCookie('theme') || 'dark';
//#region Format
function formatHtml(text) {
return text?.replace(/\r?\n/g, '<br>').replace(/\t/g, '&nbsp;&nbsp;&nbsp;&nbsp;')
}
function dateFormat(date, format = null) {
if(date == null) return "";
format = (format == null ? "dd.mm.yyyy HH:MM:SS" : format);
const finish_date = new Date(date);
return format
.replace('yyyy', finish_date.getFullYear())
.replace('yy', finish_date.getFullYear().toString().slice(-2))
.replace('mm', ("0" + (finish_date.getMonth() + 1)).slice(-2))
.replace('dd', ("0" + finish_date.getDate()).slice(-2))
.replace('HH', ("0" + finish_date.getHours()).slice(-2))
.replace('MM', ("0" + finish_date.getMinutes()).slice(-2))
.replace('SS', ("0" + finish_date.getSeconds()).slice(-2));
}
function isNumeric(value) {
return typeof value === "string"
&& value.trim() !== ""
&& !Number.isNaN(Number(value));
}
function numberToCurrency(value, locale = 'de-DE', currency = 'EUR') {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(value);
}
//#endregion
//#region Custom style sheets
async function loadCSSVars(path) {
try {
const res = await fetch(path);
const vars = await res.json();
const root = document.documentElement;
function setVars(obj, prefix = '') {
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'object') {
setVars(value, `${prefix}${key}-`);
} else {
root.style.setProperty(`--${prefix}${key}`, value);
}
}
}
setVars(vars);
} catch(err) {
alert(err)
}
}
//#endregion
//#region Dropzones
/**
* HOWTO USE:
* const dz = makeElementDropzone(tr, {
* accept: ["image/png", "image/jpeg"],
* maxFiles: 5,
* maxFileSizeMB: 5,
* onDrop: files => console.log(files)
* });
* @param {*} container
* @param {*} options
* @returns
*/
function makeElementDropzone(el, {
accept = [],
maxFiles = Infinity,
maxFileSizeMB = Infinity,
onDrop
}, allow = true) {
el.addEventListener('dragover', e => {
e.preventDefault();
el.classList.add(allow ? 'drop-hover' : 'no-drop-hover');
});
el.addEventListener('dragleave', () => {
el.classList.remove(allow ? 'drop-hover' : 'no-drop-hover');
});
el.addEventListener('drop', e => {
e.preventDefault();
if(allow) {
el.classList.remove('drop-hover');
const files = Array.from(e.dataTransfer.files);
if (!files.length) return;
const validFiles = [];
for (const file of files) {
if (accept.length && !accept.includes(file.type)) {
alert(`${file.name}: Dateityp nicht erlaubt`);
continue;
}
if (file.size > maxFileSizeMB * 1024 * 1024) {
alert(`${file.name}: Datei zu groß`);
continue;
}
validFiles.push(file);
if (validFiles.length >= maxFiles) break;
}
if (validFiles.length && typeof onDrop === 'function') {
onDrop(validFiles);
}
}
});
}
/**
* HOWTO USE:
* const dz = createDropzone(document.getElementById("dropzone"), {
* accept: ["image/png", "image/jpeg"],
* maxFiles: 5,
* maxFileSizeMB: 5,
* upload: file => fakeUpload(file), // optional
* onChange: files => console.log(files)
* });
* @param {*} container
* @param {*} options
* @returns
*/
function createDropzone(container, options = {}) {
const {
accept = [],
maxFiles = Infinity,
maxFileSizeMB = Infinity,
upload = null,
onChange = () => {}
} = options;
let files = [];
// ---------- DOM ----------
const wrapper = document.createElement('div');
wrapper.className = 'dropzone-area';
const dropzone = document.createElement("div");
dropzone.className = "dropzone";
dropzone.textContent = "Dateien hier ablegen oder klicken";
const input = document.createElement("input");
input.type = "file";
input.hidden = true;
input.multiple = true;
input.accept = accept.join(",");
const list = document.createElement("ul");
list.className = "file-list";
wrapper.append(dropzone, input, list);
container.append(wrapper);
// ---------- Helpers ----------
const isDuplicate = file =>
files.some(f => f.name === file.name && f.size === file.size);
const isValid = file => {
if (maxFiles !== undefined && files.length >= maxFiles) return "Maximale Dateianzahl erreicht";
if (maxFileSizeMB !== undefined && file.size > maxFileSizeMB * 1024 * 1024)
return `Max. ${maxFileSizeMB}MB erlaubt`;
if (accept !== undefined && accept.length && !accept.includes(file.type))
return "Dateityp nicht erlaubt";
if (isDuplicate(file)) return "Datei bereits vorhanden";
return null;
};
const addFiles = selected => {
Array.from(selected).forEach(file => {
const error = isValid(file);
if (!error) {
files.push({ file, progress: 0 });
if (upload) startUpload(file);
} else {
alert(`${file.name}: ${error}`);
}
});
render();
onChange(getFiles());
};
const removeFile = index => {
files.splice(index, 1);
render();
onChange(getFiles());
};
// ---------- Upload ----------
function startUpload(file) {
const entry = files.find(f => f.file === file);
upload(file, p => {
entry.progress = p;
render();
});
}
// ---------- Drag Reorder ----------
let dragIndex = null;
function handleDragStart(i) {
dragIndex = i;
}
function handleDrop(i) {
const [moved] = files.splice(dragIndex, 1);
files.splice(i, 0, moved);
dragIndex = null;
render();
onChange(getFiles());
}
// ---------- Render ----------
function render() {
list.innerHTML = "";
files.forEach((item, index) => {
const li = document.createElement("li");
li.draggable = true;
li.addEventListener("dragstart", () => handleDragStart(index));
li.addEventListener("dragover", e => e.preventDefault());
li.addEventListener("drop", () => handleDrop(index));
li.innerHTML = `
<span>${item.file.name}</span>
${upload !== null ? `<progress value="${item.progress}" max="100"></progress>` : ''}
<span>${(item.file.size / 1024 / 1000).toFixed(2)} MB</span>
<button type="button" class="removeButton">✕</button>
`;
li.querySelector("button").onclick = () => removeFile(index);
list.appendChild(li);
});
}
// ---------- Events ----------
dropzone.onclick = () => input.click();
input.onchange = e => {
addFiles(e.target.files);
input.value = "";
};
dropzone.ondragover = e => {
e.preventDefault();
dropzone.classList.add("active");
};
dropzone.ondragleave = () => dropzone.classList.remove("active");
dropzone.ondrop = e => {
e.preventDefault();
dropzone.classList.remove("active");
addFiles(e.dataTransfer.files);
};
// ---------- API ----------
const getFiles = () => files.map(f => f.file);
return {
getFiles,
clear() {
files = [];
render();
onChange([]);
}
};
}
//#endregion
/* #region Multiselect textbox
const multi = new MultiSelectTextbox({
container: document.getElementById('users'),
source: [
{ id: 1, name: "Max" },
{ id: 2, name: "Anna" }
],
keyFn: u => u.id,
valueFn: u => u.name
});
// API
multi.add({ id: 3, name: "Tom" });
multi.removeByKey(1);
multi.set([
{ id: 1, name: "Max" },
{ id: 2, name: "Anna" }
]);
console.log(multi.getValues());
// Event
document.getElementById('users')
.addEventListener('change', e => console.log(e.detail));
*/
class MultiSelectTextbox {
constructor(options) {
this.container = options.container;
this.source = options.source ?? [];
this.allowNew = options.allowNew ?? true;
this.keyFn = options.keyFn ?? (item => item.key);
this.valueFn = options.valueFn ?? (item => item.value);
this.selected = options.selected ? [...options.selected] : [];
this.init();
}
/* ================= INIT ================= */
init() {
this.inputWrapper = document.createElement('div');
this.inputWrapper.className = 'mst-wrAberapper';
this.container.appendChild(this.inputWrapper);
this.input = document.createElement('input');
this.input.type = 'text';
this.input.className = 'mst-input';
this.inputWrapper.appendChild(this.input);
this.chipsContainer = document.createElement('div');
this.chipsContainer.className = 'mst-chips-container';
this.inputWrapper.appendChild(this.chipsContainer);
this.dropdown = document.createElement('div');
this.dropdown.className = 'mst-dropdown';
this.dropdown.style.display = 'none';
this.inputWrapper.appendChild(this.dropdown);
this.input.addEventListener('input', () => this.updateDropdown());
this.input.addEventListener('keydown', e => this.handleKey(e));
this.input.addEventListener('dblclick', () => this.open());
document.addEventListener('click', e => {
if (!this.container.contains(e.target)) this.close();
});
this.renderSelected();
}
/* ================= RENDER ================= */
renderSelected() {
this.chipsContainer.innerHTML = '';
this.selected.forEach(item => {
const chip = document.createElement('span');
chip.className = 'mst-chip';
chip.textContent = this.valueFn(item);
const removeBtn = document.createElement('span');
removeBtn.className = 'mst-chip-remove';
removeBtn.textContent = '×';
removeBtn.onclick = () => {
this.removeByKey(this.keyFn(item));
};
chip.appendChild(removeBtn);
this.chipsContainer.appendChild(chip);
});
this.emitChange();
}
/* ================= DROPDOWN ================= */
updateDropdown(query = this.input.value.toLowerCase()) {
query = query.toLowerCase();
const filtered = this.source.filter(item => {
const val = this.valueFn(item).toLowerCase();
return val.includes(query) &&
!this.selected.some(s => this.keyFn(s) === this.keyFn(item));
});
if (filtered.length === 0 && !this.allowNew && !query) {
this.close();
return;
}
this.dropdown.innerHTML = '';
filtered.forEach(item => {
const div = document.createElement('div');
div.className = 'mst-item';
div.textContent = this.valueFn(item);
div.onclick = () => this.add(item);
this.dropdown.appendChild(div);
});
if (this.allowNew && query && !filtered.some(f => this.valueFn(f).toLowerCase() === query)) {
const div = document.createElement('div');
div.className = 'mst-item new';
div.textContent = `Neuer Wert: "${this.input.value}"`;
div.onclick = () => this.add({ key: query, value: this.input.value });
this.dropdown.appendChild(div);
}
this.dropdown.style.display = 'block';
}
handleKey(e) {
if (e.key === 'Enter') {
e.preventDefault();
const first = this.dropdown.querySelector('.mst-item');
if (first) first.click();
}
else if (e.key === 'Backspace' && !this.input.value) {
this.selected.pop();
this.renderSelected();
}
}
open() {
this.updateDropdown('');
this.input.focus();
}
close() {
this.dropdown.style.display = 'none';
}
/* ================= PUBLIC API ================= */
getValues() {
return this.selected.map(s => ({
key: this.keyFn(s),
value: this.valueFn(s)
}));
}
add(item) {
const key = this.keyFn(item);
if (this.selected.some(s => this.keyFn(s) === key))
return;
this.selected.push(item);
this.input.value = '';
this.renderSelected();
this.close();
}
set(items) {
this.selected = [...items];
this.renderSelected();
}
removeByKey(key) {
this.selected = this.selected.filter(s => this.keyFn(s) !== key);
this.renderSelected();
}
clear() {
this.selected = [];
this.renderSelected();
}
setSource(items) {
this.source = [...items];
this.updateDropdown();
}
addSource(items) {
if (!Array.isArray(items)) items = [items];
const existing = new Set(this.source.map(i => this.keyFn(i)));
items.forEach(i => {
if (!existing.has(this.keyFn(i)))
this.source.push(i);
});
this.updateDropdown();
}
/* ================= EVENTS ================= */
emitChange() {
this.container.dispatchEvent(new CustomEvent('change', {
detail: this.getValues()
}));
}
}
//#endregion
/**
* e.g. client-side:
* <script>
* const interval = setInterval(() => {
* console.log("läuft...");
* }, 1000);
*
* registerWindowCleanup(runtimeId, () => {
* clearInterval(interval);
* console.log("cleanup done");
* });
* </script>
* @param {*} runtimeId
* @param {*} fn
*/
function registerWindowCleanup(runtimeId, fn) {
if (!windowCleanup.has(runtimeId)) {
windowCleanup.set(runtimeId, []);
}
windowCleanup.get(runtimeId).push(fn);
}
//#region Reload Plugin Script
function reloadPluginScript(src) {
try {
const old = document.querySelector(`script[src="${src}"]`);
if (old) old.remove();
const script = document.createElement("script");
script.src = src + "?t=" + Date.now();
script.defer = true;
document.body.appendChild(script);
} catch(err) {
alert(err);
}
}
//#endregion
//#region ToolTip
// <td class="ellipsis"
// data-tooltip="Sehr langer Text"
// data-tooltip-mode="ellipsis">
// Sehr langer Text
// </td>
// <td
// data-tooltip="Wichtige Info"
// data-tooltip-mode="always">
//
// </td>
const tooltip = document.createElement('div');
tooltip.className = 'global-tooltip';
document.body.appendChild(tooltip);
let activeEl = null;
let storedTitle = null;
function positionTooltip(e) {
const offset = 20;
let x = e.clientX + offset;
let y = e.clientY + offset;
const rect = tooltip.getBoundingClientRect();
if (x + rect.width > window.innerWidth) {
x = e.clientX - rect.width - offset;
}
if (y + rect.height > window.innerHeight) {
y = e.clientY - rect.height + offset + 8;
}
if (x < 0) x = 0;
if (y < 0) y = 0;
tooltip.style.left = x + 'px';
tooltip.style.top = y + 'px';
}
document.addEventListener('mouseover', e => {
const el = e.target.closest('[data-tooltip]');
if (!el) return;
const isTableCell = el.tagName === 'TD' || el.tagName === 'TH';
const mode = el.dataset.tooltipMode ?? (isTableCell ? 'ellipsis' : 'always');
if (mode === 'ellipsis' && el.scrollWidth <= el.clientWidth) return;
activeEl = el;
storedTitle = el.getAttribute('title');
if (storedTitle !== null) el.removeAttribute('title');
tooltip.innerHTML = el.dataset.tooltip;
tooltip.classList.add('visible');
// 🔥 SOFORT richtig positionieren
positionTooltip(e);
});
document.addEventListener('mousemove', e => {
if (!activeEl) return;
positionTooltip(e);
});
document.addEventListener('mouseout', e => {
if (!activeEl) return;
if (!activeEl.contains(e.relatedTarget)) {
if (storedTitle !== null) activeEl.setAttribute('title', storedTitle);
activeEl = null;
storedTitle = null;
tooltip.classList.remove('visible');
}
});
//#endregion
//#region Virtual table dataset
/*
const vt = virtualTable({
tableEl: tableCAT,
data: [],
groupKey: 'Initiale',
rowHeight: 20,
buffer: 5,
filterConfig:{
exceptedColumns: ['Aktiv', 'ID', 'Anhänge'],
columnModes:{
Aktiv: 'dropdown',
Benutzername:'text',
'Abteilung 3':'dropdown',
Genehmiger:'dropdown',
Bedarfsmelder:'dropdown',
Prioliste:'dropdown',
Einkauf: 'dropdown',
'Admin':'dropdown',
'Mail Aktiv':'dropdown'
},
checkboxFilter: {
column: 'Aktiv',
rules: [
{ label: 'Aktiv: 🟢', test: v => v === true },
{ label: '🔴', test: v => v === false }
]
}
},
customRender: (row, tr) => {
createTd(tr, row['Aktiv'] ? '🟢' : '🔴', 'center');
createTd(tr, row['User_ID'], 'left');
createTd(tr, row['Benutzer'], 'left');
createTd(tr, row['BenutzerName'], 'left');
createTd(tr, row['Bedarfsmelder'], 'center');
createTd(tr, row['Genehmiger'], 'center');
createTd(tr, row['Abteilung 3'], 'center');
createTd(tr, row['Prioliste'], 'center');
createTd(tr, row['Einkauf'], 'center');
createTd(tr, row['Admin'], 'center');
createTd(tr, row['Mail Aktiv'], 'center');
/*
createTd(tr, row['Gewerke'], 'center');
createTd(tr, row['Orgs'], 'center');
createTd(tr, row['Mail Benachrichtigungen'], 'left');
}
});
*/
function virtualTable({
tableEl,
data = [],
estimateHeight = 40,
buffer = 6,
groupKey = null,
rowKey = "id",
customRender = null,
filterConfig = null,
exceptedColumns = []
}) {
let currentGroupKey = groupKey;
let virtualData = [];
let visibleRows = [];
const rowHeights = [];
const prefix = [];
const groupCollapse = new Map();
const filterState = {
selectedColumn: '',
searchText: '',
dropdownValue: '',
dropdownCache: {},
checkbox: new Set()
};
//--------------------------------------------
// Scroll Parent
//--------------------------------------------
function getScrollParent(el) {
let p = el.parentElement;
while (p) {
const s = getComputedStyle(p);
if (/(auto|scroll)/.test(s.overflowY)) return p;
p = p.parentElement;
}
return document.scrollingElement;
}
const wrapper = getScrollParent(tableEl);
function syncFilterWidth(container){
function update(){
const rect = wrapper.getBoundingClientRect();
container.style.width = (rect.width - 11) + "px";
container.style.left = "0px";
}
update();
wrapper.addEventListener('scroll', update, {passive:true});
}
//--------------------------------------------
// Gruppen vorbereiten
//--------------------------------------------
function prepareData() {
virtualData = [];
if (!currentGroupKey) {
virtualData = data.map(r => ({...r, isGroupHeader:false}));
} else {
const groups = {};
data.forEach(r=>{
const key = r[currentGroupKey] ?? "Keine Gruppe";
(groups[key]??=[]).push(r);
});
Object.keys(groups).forEach(key=>{
const gIndex = virtualData.length;
const collapsed = groupCollapse.get(key) || false;
virtualData.push({isGroupHeader:true, groupKey:key, collapsed});
groups[key].forEach(r=>{
virtualData.push({...r,isGroupHeader:false,groupIndex:gIndex});
});
});
}
applyFilters();
rebuildPrefix();
}
//--------------------------------------------
// Filter auf virtuelle Daten anwenden
//--------------------------------------------
function applyFilters() {
visibleRows = [];
let visibleCount = 0;
// alle vorherigen Filterzustände zurücksetzen
virtualData.forEach(row => row._filteredOut = false);
virtualData.forEach(row => {
if (row.isGroupHeader) {
visibleRows.push(row);
return;
}
// Spaltenfilter
if (filterState.selectedColumn) {
const val = row[filterState.selectedColumn];
const mode = filterConfig.columnModes[filterState.selectedColumn] || 'text';
if (mode === 'text' && filterState.searchText) {
if (!val?.toString().toLowerCase().includes(filterState.searchText)) {
row._filteredOut = true;
}
}
if (mode === 'dropdown' && filterState.dropdownValue) {
if (val?.toString() !== filterState.dropdownValue) row._filteredOut = true;
}
} else if (filterState.searchText) {
// globale Suche
const match = Object.keys(row).some(k => {
if(k==='isGroupHeader'||k==='groupIndex') return false;
return row[k]?.toString().toLowerCase().includes(filterState.searchText);
});
if (!match) row._filteredOut = true;
}
// Checkbox-Filter
if (filterConfig?.checkboxFilter && filterState.checkbox.size > 0) {
const val = row[filterConfig?.checkboxFilter.column];
const pass = [...filterState.checkbox].some(r => r.test(val));
if(!pass) row._filteredOut = true;
}
// Gruppierte Rows berücksichtigen
const parent = virtualData[row.groupIndex];
if (parent?.collapsed || row._filteredOut) return;
visibleRows.push(row);
visibleCount++;
});
// Live-Counter
if(filterState.counterEl) filterState.counterEl.textContent = `${visibleCount} Treffer`;
}
//--------------------------------------------
// Visible rows prefix sums
//--------------------------------------------
function rebuildPrefix(){
prefix.length = visibleRows.length+1;
prefix[0]=0;
for(let i=0;i<visibleRows.length;i++){
prefix[i+1]=prefix[i]+(rowHeights[i]||estimateHeight);
}
}
//--------------------------------------------
// Binary search index from scrollTop
//--------------------------------------------
function findStart(scrollTop){
let low=0, high=visibleRows.length;
while(low<high){
let mid=(low+high)>>1;
if(prefix[mid]<scrollTop) low=mid+1;
else high=mid;
}
return Math.max(0,low-1);
}
//--------------------------------------------
// Render
//--------------------------------------------
let loadingInterval;
function render() {
const tbody = tableEl.tBodies[0];
if (!tbody) return;
const scrollTop = wrapper.scrollTop;
const viewport = wrapper.clientHeight;
// Wenn keine Daten zum Anzeigen vorhanden sind
if (!visibleRows || visibleRows.length === 0) {
tbody.innerHTML = '';
const tr = document.createElement("tr");
const td = document.createElement("td");
td.colSpan = 100;
td.style.textAlign = "left";
td.style.fontStyle = "italic";
td.textContent = "Lade Daten";
tr.appendChild(td);
tbody.appendChild(tr);
// Animieren der Punkte
let dots = 0;
clearInterval(loadingInterval); // alte Intervalle stoppen
loadingInterval = setInterval(() => {
dots = (dots + 1) % 4; // 0..3 Punkte
td.textContent = "Lade Daten" + ".".repeat(dots);
}, 500);
return;
}
// Wenn Daten vorhanden sind, Animation stoppen
clearInterval(loadingInterval);
const start = Math.max(0, findStart(scrollTop) - buffer);
let end = start;
while (end < visibleRows.length && prefix[end] - prefix[start] < viewport + buffer * estimateHeight)
end++;
tbody.innerHTML = '';
// top spacer
const top = document.createElement("tr");
top.style.height = prefix[start] + "px";
tbody.appendChild(top);
const frag = document.createDocumentFragment();
for (let i = start; i < end; i++) {
const row = visibleRows[i];
const tr = document.createElement("tr");
tr.dataset.index = i;
if (row.isGroupHeader) {
tr.className = "grouprow";
const td = document.createElement("td");
td.colSpan = 100;
const toggle = document.createElement("span");
toggle.textContent = row.collapsed ? '►' : '▼';
toggle.style.cursor = "var(--theme-cursor-pointer) -16 16 , pointer";
const toggleFn = () => {
row.collapsed = !row.collapsed;
groupCollapse.set(row.groupKey, row.collapsed);
applyFilters();
rebuildPrefix();
render();
};
toggle.onclick = toggleFn;
tr.ondblclick = toggleFn;
td.append(toggle, " ", row.groupKey);
tr.appendChild(td);
} else {
if (customRender) customRender(row, tr);
else {
Object.keys(row).forEach(k => {
if (k === "isGroupHeader" || k === "groupIndex") return;
const td = document.createElement("td");
td.textContent = row[k];
tr.appendChild(td);
});
}
}
frag.appendChild(tr);
}
tbody.appendChild(frag);
// bottom spacer
const bottom = document.createElement("tr");
bottom.style.height = (prefix[visibleRows.length] - prefix[end]) + "px";
tbody.appendChild(bottom);
measureRows();
}
//--------------------------------------------
// echte Höhen messen
//--------------------------------------------
function measureRows(){
const trs=tableEl.querySelectorAll("tbody tr[data-index]");
let changed=false;
trs.forEach(tr=>{
const i=Number(tr.dataset.index);
const h=tr.getBoundingClientRect().height;
if(rowHeights[i]!==h){
rowHeights[i]=h;
changed=true;
}
});
if(changed){
rebuildPrefix();
requestAnimationFrame(render);
}
}
//--------------------------------------------
// Scroll & Resize
//--------------------------------------------
wrapper.addEventListener("scroll",()=>requestAnimationFrame(render));
new ResizeObserver(()=>requestAnimationFrame(render)).observe(wrapper);
// const wrapper = getScrollParent(tableEl);
// Optional: auch Fenstergröße überwachen
window.addEventListener('resize', () => {
rebuildPrefix();
requestAnimationFrame(render);
});
//--------------------------------------------
// Init Filter-UI
//--------------------------------------------
function initFilterUI() {
if(!filterConfig) return;
const container = document.createElement('div');
container.className = 'table-filter-container';
container.style.position = 'sticky';
container.style.top = '0px';
container.style.zIndex = 20;
tableEl.before(container);
// Höhe messen → thead offset setzen
requestAnimationFrame(()=>{
const h = container.getBoundingClientRect().height;
tableEl.style.setProperty('--filter-height', h + 'px');
});
filterState.counterEl = document.createElement('div');
filterState.counterEl.className = 'live-counter';
container.appendChild(filterState.counterEl);
syncFilterWidth(container);
const wrapperResizeObserver = new ResizeObserver(() => {
// Filter-Header anpassen
syncFilterWidth(container);
// Tabelle neu rendern
rebuildPrefix();
render();
});
wrapperResizeObserver.observe(wrapper);
const wrap = document.createElement('div');
wrap.style="display:flex;gap:10px;align-items:center";
// Spaltenauswahl
const colSelect = document.createElement('select');
const emptyOpt = document.createElement('option');
emptyOpt.value=''; emptyOpt.textContent='-- Alles --';
colSelect.appendChild(emptyOpt);
tableEl.querySelectorAll('thead th').forEach(th=>{
if(!filterConfig?.exceptedColumns?.includes(th.textContent.trim())){
const opt = document.createElement('option');
opt.value=th.textContent.trim();
opt.textContent=th.textContent.trim();
colSelect.appendChild(opt);
}
});
wrap.appendChild(colSelect);
// Textinput
const input = document.createElement('input');
input.type='text';
wrap.appendChild(input);
// Dropdown (hidden by default)
const select = document.createElement('select');
select.style.display='none';
wrap.appendChild(select);
// Event für Spaltenwechsel
colSelect.addEventListener('change', () => {
filterState.selectedColumn = colSelect.value;
filterState.searchText = '';
filterState.dropdownValue = '';
input.value = '';
select.value = '';
if (!filterState.selectedColumn) {
input.style.display = '';
select.style.display = 'none';
} else {
const mode = filterConfig.columnModes[filterState.selectedColumn];
if (mode === 'text') {
input.style.display = '';
select.style.display = 'none';
} else if (mode === 'dropdown') {
input.style.display = 'none';
select.style.display = '';
populateDropdownForColumn(filterState.selectedColumn); // alle Werte aus virtualData
}
}
// **Virtuelle Daten filtern**
applyFilters();
render();
});
input.addEventListener('input',()=>{ filterState.searchText = input.value.toLowerCase(); applyFilters(); rebuildPrefix(); measureRows(); render(); });
select.addEventListener('change',()=>{ filterState.dropdownValue = select.value; applyFilters(); rebuildPrefix(); measureRows(); render(); });
container.appendChild(wrap);
// Checkbox-Filter
if (filterConfig?.checkboxFilter) {
const cfgCheck = filterConfig?.checkboxFilter;
const cbWrapper = document.createElement('div');
cbWrapper.style.display = "flex";
cbWrapper.style.gap = "12px";
cbWrapper.style.alignItems = "center";
cfgCheck.rules.forEach(rule => {
const checkWrapper = document.createElement('label');
const checkInput = document.createElement('input');
const checkTrack = document.createElement('span');
const checkThumb = document.createElement('span');
// Deine Klassen
checkWrapper.classList.add("cb", "cb-switch");
checkInput.type = 'checkbox';
checkTrack.classList.add('switch-track');
checkTrack.setAttribute("aria-hidden", 'true');
checkThumb.classList.add('switch-thumb');
checkThumb.setAttribute("aria-hidden", 'true');
// Verhalten
checkInput.addEventListener('change', () => {
if (checkInput.checked) filterState.checkbox.add(rule);
else filterState.checkbox.delete(rule);
applyFilters();
rebuildPrefix();
render();
});
// Aufbau (wichtig: input VOR track für CSS :checked + .switch-track)
checkTrack.appendChild(checkThumb);
checkWrapper.append(rule.label, checkInput, checkTrack);
cbWrapper.appendChild(checkWrapper);
});
container.appendChild(cbWrapper);
}
function populateDropdownForColumn(colName){
// Alle Werte aus der virtuellen Tabelle sammeln
const values = new Set(
virtualData
.filter(r => !r.isGroupHeader) // nur echte Datenzeilen
.map(r => r[colName])
);
// sortieren
const sorted = [...values].sort((a,b)=> a.toString().localeCompare(b.toString()));
// Options bauen
select.innerHTML = '<option value="">-- Alle --</option>' +
sorted.map(v=>`<option value="${v}">${v}</option>`).join('');
}
}
//--------------------------------------------
// Init
//--------------------------------------------
prepareData();
render();
initFilterUI();
return {
addData(newData){
if (!newData) return;
// JSON-Support
if (typeof newData === "string") {
try { newData = JSON.parse(newData); } catch { return; }
}
const incoming = Array.isArray(newData) ? newData : [newData];
// Upsert nach rowKey
if (!Array.isArray(data) || data.length === 0) {
data = incoming.slice();
} else {
const indexMap = new Map();
data.forEach((row,i)=>{
const key = row?.[rowKey];
if(key != null) indexMap.set(key,i);
});
incoming.forEach(row=>{
const key = row?.[rowKey];
if(key != null && indexMap.has(key)){
data[indexMap.get(key)] = row; // ersetzen
} else {
data.push(row); // anhängen
}
});
}
//----------------------------------------
// Jetzt alle Filter korrekt anwenden
//----------------------------------------
// virtualData neu vorbereiten
prepareData(); // virtualData wird neu gebaut
// Dropdown-Werte für ausgewählte Spalte aktualisieren
if(filterState.selectedColumn && filterConfig?.columnModes[filterState.selectedColumn] === 'dropdown'){
populateDropdownForColumn(filterState.selectedColumn);
}
// _filteredOut zurücksetzen UND Filter neu anwenden
applyFilters();
rebuildPrefix();
render(); // alles rendern
},
removeData(target) {
if (!target) return;
//----------------------------------------
// Helper: normalize targets
//----------------------------------------
let matcher;
// Funktion → direkt verwenden
if (typeof target === "function") {
matcher = target;
}
// Array → mehrere IDs oder Objekte
else if (Array.isArray(target)) {
const keys = new Set(
target.map(t => typeof t === "object" ? t[rowKey] : t)
);
matcher = row => keys.has(row[rowKey]);
}
// Objekt → anhand rowKey
else if (typeof target === "object") {
matcher = row => row[rowKey] === target[rowKey];
}
// Primitive → ID
else {
matcher = row => row[rowKey] === target;
}
//----------------------------------------
// Daten filtern
//----------------------------------------
data = data.filter(row => !matcher(row));
//----------------------------------------
// Rebuild + Render
//----------------------------------------
prepareData();
// Dropdown neu füllen (falls aktiv)
if (
filterState.selectedColumn &&
filterConfig?.columnModes[filterState.selectedColumn] === 'dropdown'
) {
populateDropdownForColumn(filterState.selectedColumn);
}
applyFilters();
rebuildPrefix();
render();
},
setGroupBy(newKey){
currentGroupKey = newKey;
groupCollapse.clear(); // optional: alte Collapse-Zustände löschen
prepareData();
render();
},
refresh(){ applyFilters(); render(); },
clearData() { data = [] },
source(newData) { data = []; this.addData(newData); this.refresh(); },
prepareData() { prepareData(); }
};
}
//#endregion
//#region Table cells
function createTd(tr, text, options = {}) {
const {
id = null,
hidden = false,
classes = [],
styles = null,
attributes = {},
onclick = null
} = options;
const td = document.createElement('td');
td.innerHTML = text ?? '';
if(id !== null) {
td.id = id;
}
// Standard-Stile & Attribute
td.hidden = hidden;
classes.forEach(c => td.classList.add(c));
if (styles && typeof styles === 'object') {
Object.entries(styles).forEach(([prop, value]) => {
td.style[prop] = value;
});
}
// Data attributes
if (attributes && typeof attributes === 'object') {
Object.entries(attributes).forEach(([key, value]) => {
td.setAttribute(key, value);
});
}
if(onclick !== undefined) {
td.addEventListener('click', evt => {
if( typeof onclick === 'function' ) onclick();
evt.preventDefault();
});
}
tr.appendChild(td);
requestAnimationFrame(() => {
if (td.scrollWidth > td.clientWidth) {
td.title = td.textContent.trim();
}
});
return td;
};
//#endregion
//#region Tabs
function createTab(tabSelector, name, options = {}) {
const {
id = null,
hidden = false,
classes = [],
styles = null,
attributes = {},
onclick = null
} = options;
const tabElement = document.createElement('div');
tabElement.className = 'tab';
tabElement.dataset.tab = name;
tabElement.textContent = name;
tabSelector.appendChild(tabElement);
if(id !== null) {
tabElement.id = id;
}
// Standard-Stile & Attribute
tabElement.hidden = hidden;
classes.forEach(c => tabElement.classList.add(c));
if (styles && typeof styles === 'object') {
Object.entries(styles).forEach(([prop, value]) => {
tabElement.style[prop] = value;
});
}
// Data attributes
if (attributes && typeof attributes === 'object') {
Object.entries(attributes).forEach(([key, value]) => {
tabElement.setAttribute(key, value);
});
}
tabElement.addEventListener('click', async (evt) => {
Array.from(tabSelector.querySelectorAll('.tab')).forEach(t => t.classList.remove('active'));
tabElement.classList.add('active');
if(typeof onclick === 'function') {
onclick(evt);
}
});
return tabElement;
}
//#endregion