@@ -65,3 +65,4 @@ body.low-power .glass{backdrop-filter:blur(12px)}
|
||||
.eco-switch input:checked{background:rgba(255,180,82,.2)}
|
||||
.eco-switch input:checked:after{background:var(--amber);box-shadow:0 0 10px rgba(255,180,82,.72)}
|
||||
@media(max-width:620px){.view-selector{grid-template-columns:1fr}.metrics span[title]{display:none}}
|
||||
.filter-scope-summary{margin:2px 0 10px;line-height:1.45}.category-option.disabled{opacity:.38}.category-option.disabled span{cursor:not-allowed;border-style:dashed}.category-option span small{display:block;margin-top:2px;font-size:7px;letter-spacing:.04em;color:#8a6f79}.filter-scope-summary.warn{color:#ffb37f}.filter-scope-summary.ok{color:#7898aa}
|
||||
|
||||
+165
-37
@@ -51,8 +51,8 @@
|
||||
lodOpenUntil: new Map(), lodHotUntil: new Map(), lodDirty: true, lodLastBuild: 0, lodNextExpiry: 0, lodZoomBand: 2,
|
||||
renderNodes: [], renderEdges: [], renderIdleEdges: [], renderNodeById: new Map(), renderEdgeById: new Map(), visibleForNode: new Map(),
|
||||
edgeRenderMap: new Map(), renderActive: new Map(), renderEdgeActive: new Map(), renderStats: {nodes: 0, edges: 0, hiddenNodes: 0, hiddenEdges: 0},
|
||||
fullSnapshot: null, fullNodeById: new Map(), runtimeSettings: {learning_enabled: true, thinking_enabled: true, learning_categories: [], display_categories: [], thinking_categories: [], view_mode: 'neural', max_display_nodes: 0, low_power_mode: false},
|
||||
availableCategories: [], viewMode: 'neural', honeycombNodes: [], honeycombSpacing: 0, honeySlotByID: new Map(), honeyPointPool: [], honeyFreeSlots: [],
|
||||
fullSnapshot: null, fullNodeById: new Map(), runtimeSettings: {learning_enabled: true, thinking_enabled: true, learning_categories: [], display_categories: [], thinking_categories: [], learning_sources: [], display_sources: [], thinking_sources: [], effective_learning: {categories: [], sources: [], matches_none: false}, effective_display: {categories: [], sources: [], matches_none: false}, effective_thinking: {categories: [], sources: [], matches_none: false}, admin_learning: {categories: [], sources: []}, admin_display: {categories: [], sources: []}, admin_thinking: {categories: [], sources: []}, view_mode: 'neural', max_display_nodes: 0, low_power_mode: false},
|
||||
availableCategories: [], availableSources: [], viewMode: 'neural', honeycombNodes: [], honeycombSpacing: 0, honeySlotByID: new Map(), honeyPointPool: [], honeyFreeSlots: [],
|
||||
constellationNodes: [], constellationLinks: [], settingsOpen: false, settingsDraft: null,
|
||||
forcedDisplayUntil: new Map(), nextDisplayLimitExpiry: 0, graphVersion: null, displaySignature: '', displayLimitStats: {limit: 0, eligible: 0, shown: 0},
|
||||
researchAnimations: new Map(), researchSequence: 0,
|
||||
@@ -141,13 +141,46 @@
|
||||
return data;
|
||||
}
|
||||
|
||||
function categoryFilterMatches(node, categories) {
|
||||
if (!categories || categories.length === 0) return true;
|
||||
const wanted = new Set(categories.map(value => String(value).trim().toLowerCase()).filter(Boolean));
|
||||
if (wanted.has('*')) return true;
|
||||
const nodeCategories = (node.categories || []).map(value => String(value).trim().toLowerCase()).filter(Boolean);
|
||||
if (nodeCategories.length === 0) return wanted.has('__uncategorized__');
|
||||
return nodeCategories.some(category => wanted.has(category));
|
||||
function normalizedFilterValues(values) {
|
||||
return [...new Set((values || []).map(value => String(value).trim().toLowerCase()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function nodeSource(node) {
|
||||
const explicit = node?.metadata?.source;
|
||||
if (explicit !== undefined && explicit !== null) {
|
||||
const value = String(explicit).trim();
|
||||
if (value && value.toLowerCase() !== '<nil>' && value.toLowerCase() !== 'null') return value;
|
||||
}
|
||||
if (node?.kind === 'source' && String(node.label || '').trim()) return String(node.label).trim();
|
||||
if (node?.kind === 'external') {
|
||||
for (const raw of [node.uri, node.external_id]) {
|
||||
try {
|
||||
const host = new URL(String(raw || '')).hostname.trim().toLowerCase();
|
||||
if (host) return host;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return String(node?.origin || '').trim();
|
||||
}
|
||||
|
||||
function filterDimensionMatches(values, filters, emptyToken) {
|
||||
const wanted = new Set(normalizedFilterValues(filters));
|
||||
if (!wanted.size || wanted.has('*')) return true;
|
||||
const clean = (values || []).map(value => String(value).trim().toLowerCase()).filter(Boolean);
|
||||
if (!clean.length) return wanted.has(emptyToken);
|
||||
return clean.some(value => wanted.has(value));
|
||||
}
|
||||
|
||||
function nodeFilterMatches(node, filter) {
|
||||
if (filter?.matches_none) return false;
|
||||
return filterDimensionMatches(node.categories || [], filter?.categories || [], '__uncategorized__') &&
|
||||
filterDimensionMatches(nodeSource(node) ? [nodeSource(node)] : [], filter?.sources || [], '__unsourced__');
|
||||
}
|
||||
|
||||
function effectiveRuntimeFilter(key, settings = state.runtimeSettings) {
|
||||
const effective = settings?.[`effective_${key}`];
|
||||
if (effective) return effective;
|
||||
return {categories: settings?.[`${key}_categories`] || [], sources: settings?.[`${key}_sources`] || [], matches_none: false};
|
||||
}
|
||||
|
||||
function activeForcedDisplayIDs(now = Date.now()) {
|
||||
@@ -232,18 +265,24 @@
|
||||
}
|
||||
|
||||
function filteredSnapshot(snapshot) {
|
||||
const filters = state.runtimeSettings.display_categories || [];
|
||||
const filter = effectiveRuntimeFilter('display');
|
||||
const restricted = Boolean(filter?.matches_none || (filter?.categories || []).length || (filter?.sources || []).length);
|
||||
let filtered = snapshot;
|
||||
if (filters.length) {
|
||||
if (restricted) {
|
||||
const visible = new Set();
|
||||
const noteKinds = new Set(['knowledge', 'ai-think', 'external']);
|
||||
const taxonomyKinds = new Set(['category', 'source', 'concept']);
|
||||
const nodeMap = new Map(snapshot.nodes.map(node => [node.id, node]));
|
||||
for (const node of snapshot.nodes) {
|
||||
if (noteKinds.has(node.kind) && categoryFilterMatches(node, filters)) visible.add(node.id);
|
||||
if (node.kind === 'category' && filters.some(value => String(value).toLowerCase() === String(node.label || '').toLowerCase())) visible.add(node.id);
|
||||
if (noteKinds.has(node.kind) && nodeFilterMatches(node, filter)) visible.add(node.id);
|
||||
}
|
||||
// Add only the taxonomy directly attached to an already matching note.
|
||||
// Never pull another knowledge/external note through a semantic edge.
|
||||
for (const edge of snapshot.edges) {
|
||||
if (visible.has(edge.source)) visible.add(edge.target);
|
||||
if (visible.has(edge.target)) visible.add(edge.source);
|
||||
const source = nodeMap.get(edge.source);
|
||||
const target = nodeMap.get(edge.target);
|
||||
if (visible.has(edge.source) && target && taxonomyKinds.has(target.kind)) visible.add(edge.target);
|
||||
if (visible.has(edge.target) && source && taxonomyKinds.has(source.kind)) visible.add(edge.source);
|
||||
}
|
||||
filtered = {
|
||||
...snapshot,
|
||||
@@ -254,20 +293,28 @@
|
||||
return limitSnapshot(filtered, state.runtimeSettings.max_display_nodes);
|
||||
}
|
||||
|
||||
function displaySignatureFor(settings = state.runtimeSettings) {
|
||||
const filter = effectiveRuntimeFilter('display', settings);
|
||||
return {categories: filter.categories || [], sources: filter.sources || [], matchesNone: Boolean(filter.matches_none), limit: Number(settings.max_display_nodes || 0)};
|
||||
}
|
||||
|
||||
function currentDisplaySignature() {
|
||||
const forced = [...activeForcedDisplayIDs()].sort();
|
||||
return JSON.stringify({categories: state.runtimeSettings.display_categories || [], limit: Number(state.runtimeSettings.max_display_nodes || 0), forced});
|
||||
return JSON.stringify({...displaySignatureFor(), forced});
|
||||
}
|
||||
|
||||
async function loadRuntimeConfiguration() {
|
||||
try {
|
||||
const [settings, categories] = await Promise.all([api('/api/runtime-settings'), api('/api/categories')]);
|
||||
const [settings, categories, sources] = await Promise.all([api('/api/runtime-settings'), api('/api/categories'), api('/api/sources')]);
|
||||
state.runtimeSettings = {...state.runtimeSettings, ...settings};
|
||||
state.availableCategories = categories.categories || [];
|
||||
state.availableSources = sources.sources || [];
|
||||
state.viewMode = ['neural', 'honeycomb', 'constellation'].includes(state.runtimeSettings.view_mode) ? state.runtimeSettings.view_mode : 'neural';
|
||||
applyPerformanceMode(Boolean(state.runtimeSettings.low_power_mode), true);
|
||||
syncRuntimeControls();
|
||||
renderCategoryFilters();
|
||||
renderSourceFilters();
|
||||
renderFilterScopeSummary();
|
||||
} catch {
|
||||
syncRuntimeControls();
|
||||
}
|
||||
@@ -312,32 +359,97 @@
|
||||
return name === '__uncategorized__' ? 'Ohne Kategorie' : name;
|
||||
}
|
||||
|
||||
function filterSet(key) {
|
||||
const source = state.settingsDraft || state.runtimeSettings;
|
||||
return new Set((source[`${key}_categories`] || []).map(value => String(value).toLowerCase()));
|
||||
function sourceLabel(name) {
|
||||
return name === '__unsourced__' ? 'Ohne Quelle' : name;
|
||||
}
|
||||
|
||||
function renderCategoryFilters(search = '') {
|
||||
function filterSet(key, dimension) {
|
||||
const source = state.settingsDraft || state.runtimeSettings;
|
||||
return new Set((source[`${key}_${dimension}`] || []).map(value => String(value).toLowerCase()));
|
||||
}
|
||||
|
||||
function adminAllows(key, dimension, value) {
|
||||
const admin = state.runtimeSettings?.[`admin_${key}`] || {};
|
||||
const restricted = Boolean(admin[`${dimension}_restricted`]);
|
||||
if (!restricted) return true;
|
||||
const allowed = new Set(normalizedFilterValues(admin[dimension] || []));
|
||||
return allowed.has(String(value).trim().toLowerCase());
|
||||
}
|
||||
|
||||
function renderFilterOptions({dimension, search = '', options, targets, labeler, dataAttribute}) {
|
||||
const query = String(search || '').trim().toLowerCase();
|
||||
const targets = {learning: $('learningCategoryList'), display: $('displayCategoryList'), thinking: $('thinkingCategoryList')};
|
||||
for (const [key, target] of Object.entries(targets)) {
|
||||
if (!target) continue;
|
||||
const selected = filterSet(key);
|
||||
const selected = filterSet(key, dimension);
|
||||
target.innerHTML = '';
|
||||
const categories = state.availableCategories.filter(category => !query || categoryLabel(category.name).toLowerCase().includes(query));
|
||||
for (const category of categories) {
|
||||
const visibleOptions = options.filter(option => !query || labeler(option.name).toLowerCase().includes(query));
|
||||
for (const option of visibleOptions) {
|
||||
const allowed = adminAllows(key, dimension, option.name);
|
||||
const label = document.createElement('label');
|
||||
label.className = 'category-option';
|
||||
const checked = selected.has(String(category.name).toLowerCase());
|
||||
label.innerHTML = `<input type="checkbox" data-category-filter="${key}" value="${escapeHTML(category.name)}" ${checked ? 'checked' : ''}><span>${escapeHTML(categoryLabel(category.name))}<em>${Number(category.count || 0).toLocaleString('de-DE')}</em></span>`;
|
||||
label.className = `category-option${allowed ? '' : ' disabled'}`;
|
||||
const checked = selected.has(String(option.name).toLowerCase());
|
||||
const disabled = allowed ? '' : ' disabled';
|
||||
const notice = allowed ? '' : '<small>durch ENV ausgeschlossen</small>';
|
||||
label.innerHTML = `<input type="checkbox" ${dataAttribute}="${key}" value="${escapeHTML(option.name)}" ${checked ? 'checked' : ''}${disabled}><span>${escapeHTML(labeler(option.name))}<em>${Number(option.count || 0).toLocaleString('de-DE')}</em>${notice}</span>`;
|
||||
target.appendChild(label);
|
||||
}
|
||||
if (!categories.length) target.innerHTML = '<span class="empty-filter">Keine passende Kategorie</span>';
|
||||
if (!visibleOptions.length) target.innerHTML = `<span class="empty-filter">Keine passende ${dimension === 'categories' ? 'Kategorie' : 'Quelle'}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function collectCategoryFilter(key) {
|
||||
return [...document.querySelectorAll(`[data-category-filter="${key}"]:checked`)].map(input => input.value);
|
||||
function renderCategoryFilters(search = '') {
|
||||
renderFilterOptions({
|
||||
dimension: 'categories', search, options: state.availableCategories,
|
||||
targets: {learning: $('learningCategoryList'), display: $('displayCategoryList'), thinking: $('thinkingCategoryList')},
|
||||
labeler: categoryLabel, dataAttribute: 'data-category-filter'
|
||||
});
|
||||
}
|
||||
|
||||
function renderSourceFilters(search = '') {
|
||||
renderFilterOptions({
|
||||
dimension: 'sources', search, options: state.availableSources,
|
||||
targets: {learning: $('learningSourceList'), display: $('displaySourceList'), thinking: $('thinkingSourceList')},
|
||||
labeler: sourceLabel, dataAttribute: 'data-source-filter'
|
||||
});
|
||||
}
|
||||
|
||||
function intersectClientValues(adminValues, runtimeValues, restrictedFlag) {
|
||||
const runtime = normalizedFilterValues(runtimeValues);
|
||||
if (!restrictedFlag) return {values: runtime, restricted: runtime.length > 0, none: false};
|
||||
const admin = normalizedFilterValues(adminValues);
|
||||
if (!runtime.length) return {values: admin, restricted: true, none: false};
|
||||
const allowed = new Set(admin);
|
||||
const values = runtime.filter(value => allowed.has(value));
|
||||
return {values, restricted: true, none: values.length === 0};
|
||||
}
|
||||
|
||||
function previewEffectiveFilter(key) {
|
||||
const settings = state.settingsDraft || state.runtimeSettings;
|
||||
const admin = state.runtimeSettings?.[`admin_${key}`] || {};
|
||||
const categories = intersectClientValues(admin.categories || [], settings?.[`${key}_categories`] || [], Boolean(admin.categories_restricted));
|
||||
const sources = intersectClientValues(admin.sources || [], settings?.[`${key}_sources`] || [], Boolean(admin.sources_restricted));
|
||||
return {categories: categories.values, sources: sources.values, matches_none: categories.none || sources.none};
|
||||
}
|
||||
|
||||
function compactFilterValues(values, labeler) {
|
||||
if (!values?.length) return 'alle';
|
||||
const labels = values.slice(0, 3).map(labeler);
|
||||
return labels.join(', ') + (values.length > 3 ? ` +${values.length - 3}` : '');
|
||||
}
|
||||
|
||||
function renderFilterScopeSummary() {
|
||||
const target = $('filterScopeSummary');
|
||||
if (!target) return;
|
||||
const parts = [];
|
||||
let hasEmpty = false;
|
||||
for (const [key, label] of [['learning', 'Lernen'], ['display', 'Anzeige'], ['thinking', 'Thinking']]) {
|
||||
const effective = previewEffectiveFilter(key);
|
||||
hasEmpty ||= effective.matches_none;
|
||||
parts.push(`${label}: ${effective.matches_none ? 'keine Übereinstimmung' : `${compactFilterValues(effective.categories, categoryLabel)} · ${compactFilterValues(effective.sources, sourceLabel)}`}`);
|
||||
}
|
||||
target.textContent = `Wirksam (ENV ∩ WebUI) — ${parts.join(' | ')}`;
|
||||
target.classList.toggle('warn', hasEmpty);
|
||||
target.classList.toggle('ok', !hasEmpty);
|
||||
}
|
||||
|
||||
async function persistRuntimeSettings(settings = state.runtimeSettings) {
|
||||
@@ -347,18 +459,22 @@
|
||||
learning_categories: [...(settings.learning_categories || [])],
|
||||
display_categories: [...(settings.display_categories || [])],
|
||||
thinking_categories: [...(settings.thinking_categories || [])],
|
||||
learning_sources: [...(settings.learning_sources || [])],
|
||||
display_sources: [...(settings.display_sources || [])],
|
||||
thinking_sources: [...(settings.thinking_sources || [])],
|
||||
view_mode: ['neural', 'honeycomb', 'constellation'].includes(settings.view_mode) ? settings.view_mode : 'neural',
|
||||
max_display_nodes: Math.max(0, Math.min(500000, Math.trunc(Number(settings.max_display_nodes) || 0))),
|
||||
low_power_mode: Boolean(settings.low_power_mode)
|
||||
};
|
||||
const previousDisplay = JSON.stringify({categories: state.runtimeSettings.display_categories || [], limit: Number(state.runtimeSettings.max_display_nodes || 0)});
|
||||
const previousDisplay = JSON.stringify(displaySignatureFor(state.runtimeSettings));
|
||||
const updated = await api('/api/runtime-settings', {method: 'PUT', body: JSON.stringify(normalized)});
|
||||
state.runtimeSettings = {...normalized, ...updated};
|
||||
const nextViewMode = state.runtimeSettings.view_mode;
|
||||
applyPerformanceMode(Boolean(state.runtimeSettings.low_power_mode), true);
|
||||
syncRuntimeControls();
|
||||
renderFilterScopeSummary();
|
||||
applyViewMode(nextViewMode, false);
|
||||
if (previousDisplay !== JSON.stringify({categories: state.runtimeSettings.display_categories || [], limit: Number(state.runtimeSettings.max_display_nodes || 0)})) {
|
||||
if (previousDisplay !== JSON.stringify(displaySignatureFor(state.runtimeSettings))) {
|
||||
state.displaySignature = '';
|
||||
await loadGraph();
|
||||
}
|
||||
@@ -373,6 +489,8 @@
|
||||
$('settingsBackdrop')?.classList.remove('hidden');
|
||||
syncRuntimeControls();
|
||||
renderCategoryFilters($('categorySearch')?.value || '');
|
||||
renderSourceFilters($('sourceSearch')?.value || '');
|
||||
renderFilterScopeSummary();
|
||||
}
|
||||
|
||||
function closeSettingsPanel() {
|
||||
@@ -2882,6 +3000,7 @@
|
||||
$('closeSettings').addEventListener('click', closeSettingsPanel);
|
||||
$('settingsBackdrop').addEventListener('click', closeSettingsPanel);
|
||||
$('categorySearch').addEventListener('input', e => renderCategoryFilters(e.currentTarget.value));
|
||||
$('sourceSearch').addEventListener('input', e => renderSourceFilters(e.currentTarget.value));
|
||||
$('settingsLearning').addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.learning_enabled = e.currentTarget.checked; });
|
||||
$('settingsThinking').addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.thinking_enabled = e.currentTarget.checked; });
|
||||
$('settingsLowPower').addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.low_power_mode = e.currentTarget.checked; });
|
||||
@@ -2900,19 +3019,28 @@
|
||||
$('settingsViewHoneycomb').addEventListener('click', () => selectSettingsView('honeycomb'));
|
||||
$('settingsViewConstellation').addEventListener('click', () => selectSettingsView('constellation'));
|
||||
$('settingsPanel').addEventListener('change', e => {
|
||||
const input = e.target.closest('[data-category-filter]');
|
||||
if (!input || !state.settingsDraft) return;
|
||||
const key = input.dataset.categoryFilter;
|
||||
const property = `${key}_categories`;
|
||||
const input = e.target.closest('[data-category-filter], [data-source-filter]');
|
||||
if (!input || !state.settingsDraft || input.disabled) return;
|
||||
const dimension = input.dataset.categoryFilter !== undefined ? 'categories' : 'sources';
|
||||
const key = input.dataset.categoryFilter ?? input.dataset.sourceFilter;
|
||||
const property = `${key}_${dimension}`;
|
||||
const selected = new Map((state.settingsDraft[property] || []).map(value => [String(value).toLowerCase(), value]));
|
||||
const normalized = String(input.value).toLowerCase();
|
||||
if (input.checked) selected.set(normalized, input.value); else selected.delete(normalized);
|
||||
state.settingsDraft[property] = [...selected.values()];
|
||||
renderFilterScopeSummary();
|
||||
});
|
||||
document.querySelectorAll('[data-clear-filter]').forEach(button => button.addEventListener('click', () => {
|
||||
if (!state.settingsDraft) return;
|
||||
state.settingsDraft[`${button.dataset.clearFilter}_categories`] = [];
|
||||
renderCategoryFilters($('categorySearch').value);
|
||||
renderFilterScopeSummary();
|
||||
}));
|
||||
document.querySelectorAll('[data-clear-source-filter]').forEach(button => button.addEventListener('click', () => {
|
||||
if (!state.settingsDraft) return;
|
||||
state.settingsDraft[`${button.dataset.clearSourceFilter}_sources`] = [];
|
||||
renderSourceFilters($('sourceSearch').value);
|
||||
renderFilterScopeSummary();
|
||||
}));
|
||||
document.querySelectorAll('[data-node-limit]').forEach(button => button.addEventListener('click', () => {
|
||||
if (!state.settingsDraft) return;
|
||||
|
||||
@@ -131,7 +131,8 @@
|
||||
</section>
|
||||
|
||||
<section class="settings-section category-settings">
|
||||
<div class="settings-section-title"><h2>Kategorie-Filter</h2><span>Leer = alle Kategorien</span></div>
|
||||
<div class="settings-section-title"><h2>Kategorie-Filter</h2><span>Leer = alle erlaubten Kategorien</span></div>
|
||||
<p id="filterScopeSummary" class="setting-hint filter-scope-summary">Environment-Grenzen werden geladen …</p>
|
||||
<label class="filter-search"><span>Kategorien durchsuchen</span><input id="categorySearch" type="search" placeholder="z. B. GLPI, Netzwerk, Ollama"></label>
|
||||
|
||||
<div class="filter-block">
|
||||
@@ -148,6 +149,24 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section source-settings">
|
||||
<div class="settings-section-title"><h2>Quellen-Filter</h2><span>Quelle und Kategorie gelten gemeinsam</span></div>
|
||||
<label class="filter-search"><span>Quellen durchsuchen</span><input id="sourceSearch" type="search" placeholder="z. B. GLPI Knowledge Base, internal-kb, docs.example.org"></label>
|
||||
|
||||
<div class="filter-block">
|
||||
<div class="filter-heading"><div><b>Lernen</b><small>Nur Wissen aus diesen Quellen wird neu eingebettet und bei Abfragen verwendet.</small></div><button type="button" data-clear-source-filter="learning">Alle</button></div>
|
||||
<div id="learningSourceList" class="category-list"></div>
|
||||
</div>
|
||||
<div class="filter-block">
|
||||
<div class="filter-heading"><div><b>Anzeige</b><small>Nur passende Quellen werden gerendert; Taxonomie bleibt sichtbar.</small></div><button type="button" data-clear-source-filter="display">Alle</button></div>
|
||||
<div id="displaySourceList" class="category-list"></div>
|
||||
</div>
|
||||
<div class="filter-block">
|
||||
<div class="filter-heading"><div><b>Thinking</b><small>Nur diese Quellen dürfen neue Relationen und Artikel speisen.</small></div><button type="button" data-clear-source-filter="thinking">Alle</button></div>
|
||||
<div id="thinkingSourceList" class="category-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="settings-footer">
|
||||
<span id="settingsFeedback" aria-live="polite"></span>
|
||||
<button id="saveSettings" type="button">ÜBERNEHMEN</button>
|
||||
|
||||
Reference in New Issue
Block a user