1597 lines
46 KiB
JavaScript
1597 lines
46 KiB
JavaScript
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, ' ')
|
||
}
|
||
|
||
|
||
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); },
|
||
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
|