Add expandable source folder tree
This commit is contained in:
+80
-38
@@ -8,7 +8,7 @@
|
||||
const tooltip = document.getElementById('bu-tooltip');
|
||||
const sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path}));
|
||||
const repositoryRoots = ['/mnt/user','/mnt/disks','/mnt/remotes'].map(path=>({name:path,path}));
|
||||
const state = { config: null, runs: [], repositoryStats: [], repositoryStatsLoading: false, view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '', rsyncTargetBrowserPath: '/', rsyncTargetDirectories: repositoryRoots, rsyncTargetBrowserError: '', repositoryBrowserPath: '/', repositoryDirectories: repositoryRoots, repositoryBrowserError: '', liveLogOpen: {} };
|
||||
const state = { config: null, runs: [], repositoryStats: [], repositoryStatsLoading: false, view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceTreeRoots: sourceRoots, sourceTreeChildren: {}, sourceTreeExpanded: {}, sourceTreeLoading: {}, sourceTreeErrors: {}, rsyncTargetBrowserPath: '/', rsyncTargetDirectories: repositoryRoots, rsyncTargetBrowserError: '', repositoryBrowserPath: '/', repositoryDirectories: repositoryRoots, repositoryBrowserError: '', liveLogOpen: {} };
|
||||
|
||||
const fieldHelp = {
|
||||
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.',
|
||||
@@ -73,8 +73,8 @@
|
||||
'clear-activity': 'Löscht alle abgeschlossenen Aktivitätseinträge und deren gespeicherte Laufprotokolle. Laufende und wartende Aufgaben bleiben erhalten.',
|
||||
'cancel-form': 'Verwirft ungespeicherte Eingaben und kehrt zur Übersicht zurück.',
|
||||
'browser-up': 'Öffnet im Snapshot-Browser das übergeordnete Verzeichnis.',
|
||||
'source-up': 'Öffnet im Ordner-Browser das übergeordnete Verzeichnis.',
|
||||
'select-current-source': 'Wählt den aktuell geöffneten Ordner vollständig als Backup-Quelle aus.',
|
||||
'toggle-source-tree': 'Klappt die direkten Unterordner dieses Ordners auf oder zu. Inhalte werden erst beim ersten Öffnen geladen.',
|
||||
'retry-source-tree': 'Versucht erneut, die Unterordner dieses Ordners zu laden.',
|
||||
'repository-up': 'Öffnet im lokalen Repository-Browser das übergeordnete Verzeichnis.',
|
||||
'select-repository-folder': 'Übernimmt den aktuell geöffneten Ordner als lokalen Repository-Pfad.',
|
||||
'select-current-restore': 'Wählt den aktuell geöffneten Snapshot-Ordner vollständig für die Wiederherstellung aus.',
|
||||
@@ -156,7 +156,6 @@
|
||||
'delete-repo': 'Löscht nur die URBM-Konfiguration. Die Backupdaten am Speicherziel werden nicht gelöscht.',
|
||||
'browse': 'Öffnet den Dateibaum dieses Snapshots, um einzelne Dateien oder Ordner auszuwählen.',
|
||||
'open-dir': 'Öffnet dieses Verzeichnis innerhalb des schreibgeschützten Snapshots.',
|
||||
'open-source-dir': 'Öffnet diesen Ordner, um darin weitere Unterordner auszuwählen.',
|
||||
'open-repository-dir': 'Öffnet diesen lokalen Ordner, um ihn oder einen Unterordner als Repository-Ziel auszuwählen.',
|
||||
'cancel-run': 'Bricht die laufende oder wartende Aufgabe ab. Bereinigung und Neustart gestoppter Systeme werden trotzdem versucht.',
|
||||
'pause-run': 'Hält den aktiven Restic-Prozess und seine Unterprozesse an. Mounts sowie gestoppte Container oder VMs bleiben bis zum Fortsetzen oder Abbrechen bestehen.',
|
||||
@@ -380,10 +379,10 @@
|
||||
}
|
||||
|
||||
function jobForm(job = {}) {
|
||||
state.backupSelections = (job.sources || []).filter(s=>s.path).map(s=>s.path);
|
||||
state.backupSelections = normalizeSourceSelections((job.sources || []).filter(s=>s.path).map(s=>s.path));
|
||||
state.workloadSelections = (job.sources || []).filter(s=>s.workloadId).map(s=>s.workloadId);
|
||||
state.workloads = []; state.workloadAvailable = true; state.workloadError = '';
|
||||
state.sourceBrowserPath = '/'; state.sourceDirectories = sourceRoots; state.sourceBrowserError = '';
|
||||
resetSourceTree(sourceRoots);
|
||||
state.rsyncTargetBrowserPath = '/'; state.rsyncTargetDirectories = repositoryRoots; state.rsyncTargetBrowserError = '';
|
||||
const options = state.config.repositories.map(r => `<option value="${esc(r.id)}" ${r.id===job.repositoryId?'selected':''}>${esc(r.name)}</option>`).join('');
|
||||
content.innerHTML = `<section class="bu-card"><h2>Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}</h2><form id="bu-job-form" class="bu-form">
|
||||
@@ -408,9 +407,7 @@
|
||||
document.getElementById('bu-retention-settings').hidden=isRsync;
|
||||
if (type === 'share' || type === 'appdata') {
|
||||
host.innerHTML = `<h3>${type==='appdata'?'Appdata-Ordner':'Freigabeordner'}</h3><div class="bu-muted">Wähle einen oder mehrere Ordner aus. Zusätzliche Dateifilter können darunter eingetragen werden.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Zusätzliche manuelle Pfade<textarea name="sources"></textarea></label>`;
|
||||
state.sourceBrowserPath = type === 'appdata' ? '/mnt/user/appdata' : '/mnt/user';
|
||||
state.sourceDirectories = []; state.sourceBrowserError = '';
|
||||
loadSourceDirectories(state.sourceBrowserPath);
|
||||
initSourceTree([type === 'appdata' ? '/mnt/user/appdata' : '/mnt/user'], true);
|
||||
} else if (type === 'docker' || type === 'vm') {
|
||||
host.innerHTML = `<h3>${type==='docker'?'Docker-Container':'Virtuelle Maschinen'}</h3><div class="bu-muted">Wähle die zu sichernden Systeme aus. Metadaten und erkannte Bind-Mounts oder virtuelle Datenträger werden automatisch einbezogen.</div><div id="bu-workload-browser" class="bu-browser-card"><div class="bu-empty">Wird geladen...</div></div><div class="bu-workload-options"><label>Konsistenz<select name="consistency"><option value="live">Live</option><option value="stop" ${job.consistency?.mode==='stop'?'selected':''}>Kontrolliert stoppen</option></select></label><label>Stopp-Zeitlimit (Sekunden)<input name="shutdownSecs" type="number" min="1" value="${job.shutdownTimeoutSeconds || 120}"></label></div>`;
|
||||
loadWorkloads(type);
|
||||
@@ -418,7 +415,7 @@
|
||||
const o=job.rsync||{};
|
||||
host.innerHTML = `<h3>Direkte Rsync-Kopie</h3><div class="bu-inline-info">Rsync erstellt keine verschlüsselten Snapshots. Die ausgewählten Ordner werden direkt in das Ziel kopiert. Die Option „Am Ziel löschen“ spiegelt Löschungen und sollte bewusst aktiviert werden.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Zusätzliche manuelle Quellpfade<textarea name="sources"></textarea></label><h3>Zielordner</h3><label class="wide">Rsync-Ziel<input name="rsyncTarget" required value="${esc(o.target||'')}" placeholder="/mnt/remotes/backup"></label><div id="bu-rsync-target-browser"></div><fieldset class="wide bu-fieldset"><legend>Rsync-Optionen</legend><div class="bu-event-options"><label><input name="rsyncOverwrite" type="checkbox" ${o.overwrite!==false?'checked':''}><span>Vorhandene Dateien aktualisieren / überschreiben</span></label><label><input name="rsyncDelete" type="checkbox" ${o.delete===true?'checked':''}><span>Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind</span></label><label><input name="rsyncPermissions" type="checkbox" ${o.preservePermissions!==false?'checked':''}><span>Dateiberechtigungen übernehmen</span></label><label><input name="rsyncOwner" type="checkbox" ${o.preserveOwner===true?'checked':''}><span>Besitzer übernehmen</span></label><label><input name="rsyncGroup" type="checkbox" ${o.preserveGroup===true?'checked':''}><span>Gruppen übernehmen</span></label><label><input name="rsyncTimes" type="checkbox" ${o.preserveTimes!==false?'checked':''}><span>Zeitstempel übernehmen</span></label><label><input name="rsyncLinks" type="checkbox" ${o.preserveLinks!==false?'checked':''}><span>Symbolische Links als Links übernehmen</span></label><label><input name="rsyncACLs" type="checkbox" ${o.preserveAcls===true?'checked':''}><span>ACLs übernehmen</span></label><label><input name="rsyncXattrs" type="checkbox" ${o.preserveXattrs===true?'checked':''}><span>Erweiterte Attribute übernehmen</span></label><label><input name="rsyncChecksum" type="checkbox" ${o.checksum===true?'checked':''}><span>Dateien per Prüfsumme vergleichen (langsamer)</span></label><label><input name="rsyncDryRun" type="checkbox" ${o.dryRun===true?'checked':''}><span>Testlauf ohne Änderungen</span></label></div></fieldset>`;
|
||||
host.querySelector('.bu-event-options').insertAdjacentHTML('beforeend', `<label><input name="rsyncSkipSpaceCheck" type="checkbox" ${o.skipSpaceCheck===true?'checked':''}><span>Kapazitätsprüfung überspringen (riskant; nur wenn vorhandene Zieldateien ausreichend Platz freigeben)</span></label>`);
|
||||
state.sourceBrowserPath='/'; state.sourceDirectories=sourceRoots; state.sourceBrowserError=''; renderSourceBrowser(); renderRsyncTargetBrowser();
|
||||
initSourceTree(sourceRoots.map(item=>item.path), false); renderRsyncTargetBrowser();
|
||||
} else {
|
||||
state.backupSelections = ['/boot'];
|
||||
host.innerHTML = `<div class="bu-info-card"><h3>Unraid-USB-Flash-Backup</h3><p>Das vollständige Konfigurationslaufwerk <strong>/boot</strong> wird immer dateibasiert gesichert.</p><label class="bu-inline-check"><input name="flashImage" type="checkbox" ${job.flashImage===true?'checked':''}><span>Zusätzlich vollständiges USB-Rohabbild <strong>urbm-usb-flash.img</strong> sichern</span></label><p class="bu-muted">Das Abbild wird ohne Zwischenkopie direkt zu Restic übertragen und kann nach einer Wiederherstellung mit einem Image-Programm auf einen mindestens gleich großen Stick geschrieben werden. Die an die USB-GUID gebundene Unraid-Lizenz muss bei einem Stickwechsel separat übertragen werden.</p></div>`;
|
||||
@@ -444,36 +441,80 @@
|
||||
renderWorkloadBrowser(type);
|
||||
}
|
||||
|
||||
function resetSourceTree(roots=sourceRoots) {
|
||||
state.sourceTreeRoots=roots.map(item=>typeof item==='string'?{name:item,path:item}:item);
|
||||
state.sourceTreeChildren={}; state.sourceTreeExpanded={}; state.sourceTreeLoading={}; state.sourceTreeErrors={};
|
||||
}
|
||||
|
||||
function normalizeSourceSelections(paths) {
|
||||
const unique=[...new Set(paths)].sort((left,right)=>left.length-right.length);
|
||||
return unique.filter((path,index)=>!unique.slice(0,index).some(parent=>path.startsWith(parent.replace(/\/$/,'')+'/')));
|
||||
}
|
||||
|
||||
function initSourceTree(paths, expand) {
|
||||
resetSourceTree(paths.map(path=>({name:path,path})));
|
||||
renderSourceBrowser();
|
||||
if(expand) paths.forEach(path=>toggleSourceTree(path, true));
|
||||
}
|
||||
|
||||
function selectedAncestor(path) {
|
||||
return state.backupSelections.find(selected=>path!==selected&&path.startsWith(selected.replace(/\/$/,'')+'/')) || '';
|
||||
}
|
||||
|
||||
function sourceSelectionState(path) {
|
||||
const exact=state.backupSelections.includes(path);
|
||||
const inherited=selectedAncestor(path);
|
||||
const prefix=path.replace(/\/$/,'')+'/';
|
||||
const partial=!exact&&!inherited&&state.backupSelections.some(selected=>selected.startsWith(prefix));
|
||||
return {exact,inherited,partial};
|
||||
}
|
||||
|
||||
function sourceTreeNode(item, depth) {
|
||||
const path=item.path;
|
||||
const selection=sourceSelectionState(path);
|
||||
const expanded=state.sourceTreeExpanded[path]===true;
|
||||
const loading=state.sourceTreeLoading[path]===true;
|
||||
const loaded=Object.prototype.hasOwnProperty.call(state.sourceTreeChildren,path);
|
||||
const children=state.sourceTreeChildren[path]||[];
|
||||
const error=state.sourceTreeErrors[path]||'';
|
||||
const hint=selection.inherited?`Durch den ausgewählten Ordner ${selection.inherited} vollständig enthalten.`:selection.partial?'Ein oder mehrere Unterordner sind ausgewählt. Klicken, um den gesamten Ordner auszuwählen.':'Diesen Ordner als vollständige Backup-Quelle auswählen.';
|
||||
const label=selection.partial?'<span class="bu-partial-label">Teilweise ausgewählt</span>':selection.inherited?'<span class="bu-inherited-label">Durch Elternordner ausgewählt</span>':'';
|
||||
const toggle=`<button class="bu-tree-toggle ${expanded?'expanded':''}" type="button" data-action="toggle-source-tree:${encodeURIComponent(path)}" aria-expanded="${expanded}" data-tooltip="${expanded?'Unterordner einklappen':'Unterordner aufklappen'}"><span>▶</span></button>`;
|
||||
let html=`<div class="bu-tree-node ${selection.partial?'bu-source-partial':''}" style="--bu-tree-depth:${depth}"><div class="bu-tree-row">${toggle}<input type="checkbox" class="bu-source-select" value="${esc(path)}" ${(selection.exact||selection.inherited)?'checked':''} ${selection.inherited?'disabled':''} data-partial="${selection.partial?'true':'false'}" data-tooltip="${esc(hint)}"><button class="bu-tree-name" type="button" data-action="toggle-source-tree:${encodeURIComponent(path)}"><span>${esc(item.name)}</span>${label}<small>${esc(path)}</small></button></div>`;
|
||||
if(expanded) {
|
||||
html+='<div class="bu-tree-children">';
|
||||
if(loading) html+='<div class="bu-tree-state">Unterordner werden geladen…</div>';
|
||||
else if(error) html+=`<div class="bu-tree-state error">${esc(error)} ${button('Erneut versuchen',`retry-source-tree:${encodeURIComponent(path)}`)}</div>`;
|
||||
else if(loaded&&!children.length) html+='<div class="bu-tree-state">Keine Unterordner.</div>';
|
||||
else html+=children.map(child=>sourceTreeNode(child,depth+1)).join('');
|
||||
html+='</div>';
|
||||
}
|
||||
return html+'</div>';
|
||||
}
|
||||
|
||||
function renderSourceBrowser() {
|
||||
const host = document.getElementById('bu-source-browser');
|
||||
if (!host) return;
|
||||
const selected = new Set(state.backupSelections);
|
||||
const rows = state.sourceDirectories.map(item => {
|
||||
const exact=selected.has(item.path);
|
||||
const prefix=item.path.replace(/\/$/,'')+'/';
|
||||
const partial=!exact&&state.backupSelections.some(path=>path.startsWith(prefix));
|
||||
const hint=partial?'Ein oder mehrere Unterordner sind ausgewählt. Klicken, um den gesamten Ordner auszuwählen.':'Diesen Ordner als vollständige Backup-Quelle auswählen.';
|
||||
return `<tr class="${partial?'bu-source-partial':''}"><td><input type="checkbox" class="bu-source-select" value="${esc(item.path)}" ${exact?'checked':''} data-partial="${partial?'true':'false'}" data-tooltip="${esc(hint)}"></td><td>Ordner</td><td>${esc(item.name)}${partial?'<span class="bu-partial-label">Teilweise ausgewählt</span>':''}</td><td>${esc(item.path)}</td><td>${button('Öffnen',`open-source-dir:${encodeURIComponent(item.path)}`)}</td></tr>`;
|
||||
}).join('');
|
||||
const chosen = state.backupSelections.length ? state.backupSelections.map(path=>`<span class="bu-selection">${esc(path)}</span>`).join('') : '<span class="bu-muted">Keine Ordner ausgewählt.</span>';
|
||||
const error = state.sourceBrowserError ? `<div class="bu-inline-error">${esc(state.sourceBrowserError)}</div>` : '';
|
||||
host.innerHTML = `<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>Backup-Quellen auswählen</h3><span class="bu-muted">${esc(state.sourceBrowserPath)}</span></div><div class="bu-actions">${state.sourceBrowserPath!=='/'?button('Aktuellen Ordner auswählen','select-current-source'):''}${state.sourceBrowserPath!=='/'?button('Nach oben','source-up'):''}<span class="bu-count">Ausgewählt: ${state.backupSelections.length}</span></div></div>${error}<div class="bu-selection-list">${chosen}</div><div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Auswahl</th><th>Typ</th><th>Name</th><th>Pfad</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan="5" class="bu-empty">Keine Unterordner gefunden.</td></tr>'}</tbody></table></div></section>`;
|
||||
host.querySelectorAll('.bu-source-select[data-partial="true"]').forEach(box=>{ box.indeterminate=true; box.setAttribute('aria-checked','mixed'); });
|
||||
const host=document.getElementById('bu-source-browser');
|
||||
if(!host) return;
|
||||
const chosen=state.backupSelections.length?state.backupSelections.map(path=>`<span class="bu-selection">${esc(path)}</span>`).join(''):'<span class="bu-muted">Keine Ordner ausgewählt.</span>';
|
||||
const tree=state.sourceTreeRoots.map(item=>sourceTreeNode(item,0)).join('');
|
||||
host.innerHTML=`<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>Backup-Quellen auswählen</h3><span class="bu-muted">Ordner mit dem Dreieck aufklappen. Unterordner werden erst bei Bedarf geladen.</span></div><span class="bu-count">Ausgewählt: ${state.backupSelections.length}</span></div><div class="bu-selection-list">${chosen}</div><div class="bu-tree" role="tree">${tree||'<div class="bu-empty">Keine Ordner verfügbar.</div>'}</div></section>`;
|
||||
host.querySelectorAll('.bu-source-select[data-partial="true"]').forEach(box=>{box.indeterminate=true;box.setAttribute('aria-checked','mixed');});
|
||||
enhanceTooltips(host);
|
||||
}
|
||||
|
||||
async function loadSourceDirectories(path) {
|
||||
if (path === '/') {
|
||||
state.sourceDirectories = sourceRoots; state.sourceBrowserPath = '/'; state.sourceBrowserError = ''; renderSourceBrowser(); return;
|
||||
async function toggleSourceTree(path, forceOpen=false) {
|
||||
if(state.sourceTreeExpanded[path]&&!forceOpen) {
|
||||
state.sourceTreeExpanded[path]=false; renderSourceBrowser(); return;
|
||||
}
|
||||
state.sourceTreeExpanded[path]=true; renderSourceBrowser();
|
||||
if(Object.prototype.hasOwnProperty.call(state.sourceTreeChildren,path)&&!state.sourceTreeErrors[path]) return;
|
||||
state.sourceTreeLoading[path]=true; state.sourceTreeErrors[path]=''; renderSourceBrowser();
|
||||
try {
|
||||
state.sourceDirectories = await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`);
|
||||
state.sourceBrowserPath = path;
|
||||
state.sourceBrowserError = '';
|
||||
renderSourceBrowser();
|
||||
} catch (error) {
|
||||
state.sourceBrowserError = `Ordner konnte nicht geladen werden: ${error.message}`;
|
||||
renderSourceBrowser();
|
||||
state.sourceTreeChildren[path]=await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`);
|
||||
} catch(error) {
|
||||
state.sourceTreeErrors[path]=`Ordner konnte nicht geladen werden: ${error.message}`;
|
||||
} finally {
|
||||
state.sourceTreeLoading[path]=false; renderSourceBrowser();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,9 +702,8 @@
|
||||
if (name === 'repo-snapshots') { state.view='snapshots'; document.querySelector('[data-view="snapshots"]').click(); render(); setTimeout(()=>{document.getElementById('bu-snapshot-repo').value=a; handle('load-snapshots');},0); return; }
|
||||
if (name === 'load-snapshots') { const repo=document.getElementById('bu-snapshot-repo').value; state.snapshots=await api(`/v1/repositories/${repo}/snapshots`); state.snapshotRepo=repo; return render(); }
|
||||
if (name === 'browse') { state.selectedSnapshot=a; state.browserPath='/'; state.restoreIncludes=[]; state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${a}/files?path=${encodeURIComponent('/')}`); return render(); }
|
||||
if (name === 'open-source-dir') { return loadSourceDirectories(decodeURIComponent(a)); }
|
||||
if (name === 'source-up') { const parent=state.sourceBrowserPath==='/'?'/':state.sourceBrowserPath.split('/').slice(0,-1).join('/')||'/'; return loadSourceDirectories(parent==='/mnt'?'/':parent); }
|
||||
if (name === 'select-current-source') { if(!state.backupSelections.includes(state.sourceBrowserPath)) state.backupSelections.push(state.sourceBrowserPath); return renderSourceBrowser(); }
|
||||
if (name === 'toggle-source-tree') { return toggleSourceTree(decodeURIComponent(a)); }
|
||||
if (name === 'retry-source-tree') { const path=decodeURIComponent(a); delete state.sourceTreeChildren[path]; state.sourceTreeErrors[path]=''; return toggleSourceTree(path,true); }
|
||||
if (name === 'open-rsync-target') { return loadRsyncTargetDirectories(decodeURIComponent(a)); }
|
||||
if (name === 'rsync-target-up') { const parent=state.rsyncTargetBrowserPath==='/'?'/':state.rsyncTargetBrowserPath.split('/').slice(0,-1).join('/')||'/'; return loadRsyncTargetDirectories(parent==='/mnt'?'/':parent); }
|
||||
if (name === 'select-rsync-target') { const target=document.querySelector('#bu-job-form [name="rsyncTarget"]'); if(target) target.value=state.rsyncTargetBrowserPath; return; }
|
||||
@@ -770,7 +810,9 @@
|
||||
renderJobSpecific(e.target.value);
|
||||
}
|
||||
if (e.target.classList.contains('bu-source-select')) {
|
||||
state.backupSelections = e.target.checked ? [...new Set([...state.backupSelections,e.target.value])] : state.backupSelections.filter(path=>path!==e.target.value);
|
||||
const path=e.target.value; const prefix=path.replace(/\/$/,'')+'/';
|
||||
if(e.target.checked) state.backupSelections=normalizeSourceSelections([...state.backupSelections.filter(selected=>!selected.startsWith(prefix)),path]);
|
||||
else state.backupSelections=state.backupSelections.filter(selected=>selected!==path);
|
||||
renderSourceBrowser();
|
||||
}
|
||||
if (e.target.classList.contains('bu-file-select')) {
|
||||
|
||||
Reference in New Issue
Block a user