errrr
This commit is contained in:
@@ -1,52 +1,687 @@
|
||||
window.addEventListener('load', e => {
|
||||
submitSearch()
|
||||
window.addEventListener("load", (e) => {
|
||||
fileInputUpdatePath();
|
||||
|
||||
fileInputUpdatePath()
|
||||
})
|
||||
dateTimeShortcutsOverlay();
|
||||
|
||||
renderCharts();
|
||||
|
||||
filterForm();
|
||||
|
||||
warnWithoutSaving();
|
||||
|
||||
tabNavigation();
|
||||
});
|
||||
|
||||
/*************************************************************
|
||||
* Move not visible tab items to dropdown
|
||||
*************************************************************/
|
||||
function tabNavigation() {
|
||||
const itemsDropdown = document.getElementById("tabs-dropdown");
|
||||
const itemsList = document.getElementById("tabs-items");
|
||||
const widths = [];
|
||||
|
||||
if (!itemsDropdown || !itemsList) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleTabNavigationResize();
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
handleTabNavigationResize();
|
||||
});
|
||||
|
||||
function handleTabNavigationResize() {
|
||||
const contentWidth = document.getElementById("content").offsetWidth;
|
||||
const tabsWidth = document.getElementById("tabs-wrapper").scrollWidth;
|
||||
const availableWidth =
|
||||
itemsList.parentElement.offsetWidth - itemsList.offsetWidth - 48;
|
||||
|
||||
if (tabsWidth > contentWidth) {
|
||||
const lastTabItem = itemsList ? itemsList.lastElementChild : null;
|
||||
|
||||
if (lastTabItem) {
|
||||
widths.push(lastTabItem.offsetWidth);
|
||||
itemsList.removeChild(lastTabItem);
|
||||
itemsDropdown.appendChild(lastTabItem);
|
||||
|
||||
// If there is still not enough space, move the last item to the dropdown again
|
||||
if (
|
||||
document.getElementById("content").offsetWidth <
|
||||
document.getElementById("tabs-wrapper").scrollWidth
|
||||
) {
|
||||
handleTabNavigationResize();
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
widths.length > 0 &&
|
||||
widths[widths.length - 1] < availableWidth
|
||||
) {
|
||||
const lastTabItem = itemsDropdown ? itemsDropdown.lastElementChild : null;
|
||||
|
||||
if (lastTabItem) {
|
||||
itemsDropdown.removeChild(lastTabItem);
|
||||
itemsList.appendChild(lastTabItem);
|
||||
widths.pop();
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide dropdown based on the number of items in dropdown
|
||||
if (itemsDropdown.childElementCount === 0) {
|
||||
itemsDropdown.parentElement.classList.add("hidden");
|
||||
} else {
|
||||
itemsDropdown.parentElement.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*************************************************************
|
||||
* Alpine.sort.js callback after sorting
|
||||
*************************************************************/
|
||||
const sortRecords = (e) => {
|
||||
const orderingField = e.from.dataset.orderingField;
|
||||
|
||||
const weightInputs = Array.from(
|
||||
e.from.querySelectorAll(
|
||||
`.has_original input[name$=-${orderingField}], td.field-${orderingField} input[name$=-${orderingField}]`
|
||||
)
|
||||
);
|
||||
|
||||
weightInputs.forEach((input, index) => {
|
||||
input.value = index;
|
||||
});
|
||||
};
|
||||
|
||||
/*************************************************************
|
||||
* Search form
|
||||
*************************************************************/
|
||||
function searchForm() {
|
||||
return {
|
||||
applyShortcut(event) {
|
||||
if (
|
||||
event.key === "/" &&
|
||||
document.activeElement.tagName.toLowerCase() !== "input" &&
|
||||
document.activeElement.tagName.toLowerCase() !== "textarea" &&
|
||||
!document.activeElement.isContentEditable
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.$refs.searchInput.focus();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*************************************************************
|
||||
* Search dropdown
|
||||
*************************************************************/
|
||||
function searchDropdown() {
|
||||
return {
|
||||
openSearchResults: false,
|
||||
currentIndex: 0,
|
||||
applyShortcut(event) {
|
||||
if (
|
||||
event.key === "t" &&
|
||||
document.activeElement.tagName.toLowerCase() !== "input" &&
|
||||
document.activeElement.tagName.toLowerCase() !== "textarea" &&
|
||||
!document.activeElement.isContentEditable
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.$refs.searchInput.focus();
|
||||
}
|
||||
},
|
||||
nextItem() {
|
||||
if (this.currentIndex < this.maxItem()) {
|
||||
this.currentIndex++;
|
||||
}
|
||||
},
|
||||
prevItem() {
|
||||
if (this.currentIndex > 1) {
|
||||
this.currentIndex--;
|
||||
}
|
||||
},
|
||||
maxItem() {
|
||||
return document.getElementById("search-results").querySelectorAll("li")
|
||||
.length;
|
||||
},
|
||||
selectItem() {
|
||||
const href = this.items[this.currentIndex - 1].querySelector("a").href;
|
||||
window.location = href;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*************************************************************
|
||||
* Search command
|
||||
*************************************************************/
|
||||
function searchCommand() {
|
||||
return {
|
||||
el: document.getElementById("command-results"),
|
||||
items: undefined,
|
||||
hasResults: false,
|
||||
openCommandResults: false,
|
||||
currentIndex: 0,
|
||||
totalItems: 0,
|
||||
commandHistory: JSON.parse(localStorage.getItem("commandHistory") || "[]"),
|
||||
handleOpen() {
|
||||
this.openCommandResults = true;
|
||||
this.toggleBodyOverflow();
|
||||
setTimeout(() => {
|
||||
this.$refs.searchInputCommand.focus();
|
||||
}, 20);
|
||||
|
||||
this.items = document.querySelectorAll("#command-history li");
|
||||
this.totalItems = this.items.length;
|
||||
},
|
||||
handleShortcut(event) {
|
||||
if (
|
||||
event.key === "k" &&
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
document.activeElement.tagName.toLowerCase() !== "input" &&
|
||||
document.activeElement.tagName.toLowerCase() !== "textarea" &&
|
||||
!document.activeElement.isContentEditable
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.handleOpen();
|
||||
}
|
||||
},
|
||||
handleEscape() {
|
||||
if (this.$refs.searchInputCommand.value === "") {
|
||||
this.toggleBodyOverflow();
|
||||
this.openCommandResults = false;
|
||||
this.el.innerHTML = "";
|
||||
this.items = undefined;
|
||||
this.totalItems = 0;
|
||||
this.currentIndex = 0;
|
||||
} else {
|
||||
this.$refs.searchInputCommand.value = "";
|
||||
}
|
||||
},
|
||||
handleContentLoaded(event) {
|
||||
if (
|
||||
event.target.id !== "command-results" &&
|
||||
event.target.id !== "command-results-list"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commandResultsList = document.getElementById(
|
||||
"command-results-list"
|
||||
);
|
||||
if (commandResultsList) {
|
||||
this.items = commandResultsList.querySelectorAll("li");
|
||||
this.totalItems = this.items.length;
|
||||
} else {
|
||||
this.items = undefined;
|
||||
this.totalItems = 0;
|
||||
}
|
||||
|
||||
if (event.target.id === "command-results") {
|
||||
this.currentIndex = 0;
|
||||
|
||||
if (this.items) {
|
||||
this.totalItems = this.items.length;
|
||||
} else {
|
||||
this.totalItems = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.hasResults = this.totalItems > 0;
|
||||
|
||||
if (!this.hasResults) {
|
||||
this.items = document.querySelectorAll("#command-history li");
|
||||
}
|
||||
},
|
||||
handleOutsideClick() {
|
||||
this.$refs.searchInputCommand.value = "";
|
||||
this.openCommandResults = false;
|
||||
this.toggleBodyOverflow();
|
||||
},
|
||||
toggleBodyOverflow() {
|
||||
document
|
||||
.getElementsByTagName("body")[0]
|
||||
.classList.toggle("overflow-hidden");
|
||||
},
|
||||
scrollToActiveItem() {
|
||||
const item = this.items[this.currentIndex - 1];
|
||||
|
||||
if (item) {
|
||||
item.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
},
|
||||
nextItem() {
|
||||
if (this.currentIndex < this.totalItems) {
|
||||
this.currentIndex++;
|
||||
this.scrollToActiveItem();
|
||||
}
|
||||
},
|
||||
prevItem() {
|
||||
if (this.currentIndex > 1) {
|
||||
this.currentIndex--;
|
||||
this.scrollToActiveItem();
|
||||
}
|
||||
},
|
||||
selectItem(addHistory) {
|
||||
const link = this.items[this.currentIndex - 1].querySelector("a");
|
||||
const data = {
|
||||
title: link.dataset.title,
|
||||
description: link.dataset.description,
|
||||
link: link.href,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
if (addHistory) {
|
||||
this.addToHistory(data);
|
||||
}
|
||||
|
||||
window.location = link.href;
|
||||
},
|
||||
addToHistory(data) {
|
||||
let commandHistory = JSON.parse(
|
||||
localStorage.getItem("commandHistory") || "[]"
|
||||
);
|
||||
|
||||
for (const [index, item] of commandHistory.entries()) {
|
||||
if (item.link === data.link) {
|
||||
commandHistory.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
commandHistory.unshift(data);
|
||||
commandHistory = commandHistory.slice(0, 10);
|
||||
this.commandHistory = commandHistory;
|
||||
localStorage.setItem("commandHistory", JSON.stringify(commandHistory));
|
||||
},
|
||||
removeFromHistory(event, index) {
|
||||
event.preventDefault();
|
||||
|
||||
const commandHistory = JSON.parse(
|
||||
localStorage.getItem("commandHistory") || "[]"
|
||||
);
|
||||
commandHistory.splice(index, 1);
|
||||
this.commandHistory = commandHistory;
|
||||
localStorage.setItem("commandHistory", JSON.stringify(commandHistory));
|
||||
},
|
||||
toggleFavorite(event, index) {
|
||||
event.preventDefault();
|
||||
|
||||
const commandHistory = JSON.parse(
|
||||
localStorage.getItem("commandHistory") || "[]"
|
||||
);
|
||||
|
||||
commandHistory[index].favorite = !commandHistory[index].favorite;
|
||||
this.commandHistory = commandHistory.sort(
|
||||
(a, b) => Number(b.favorite) - Number(a.favorite)
|
||||
);
|
||||
localStorage.setItem("commandHistory", JSON.stringify(commandHistory));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*************************************************************
|
||||
* Warn without saving
|
||||
*************************************************************/
|
||||
const warnWithoutSaving = () => {
|
||||
let formChanged = false;
|
||||
const form = document.querySelector("form.warn-unsaved-form");
|
||||
|
||||
const checkFormChanged = () => {
|
||||
const elements = document.querySelectorAll(
|
||||
"form.warn-unsaved-form input, form.warn-unsaved-form select, form.warn-unsaved-form textarea"
|
||||
);
|
||||
|
||||
for (const field of elements) {
|
||||
field.addEventListener("input", () => {
|
||||
formChanged = true;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
new MutationObserver((mutationsList, observer) => {
|
||||
checkFormChanged();
|
||||
}).observe(form, { attributes: true, childList: true, subtree: true });
|
||||
|
||||
checkFormChanged();
|
||||
|
||||
preventLeaving = (e) => {
|
||||
if (formChanged) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
form.addEventListener("submit", (e) => {
|
||||
window.removeEventListener("beforeunload", preventLeaving);
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", preventLeaving);
|
||||
};
|
||||
|
||||
/*************************************************************
|
||||
* Filter form
|
||||
*************************************************************/
|
||||
const filterForm = () => {
|
||||
const filterForm = document.getElementById("filter-form");
|
||||
|
||||
if (!filterForm) {
|
||||
return;
|
||||
}
|
||||
|
||||
filterForm.addEventListener("formdata", (event) => {
|
||||
Array.from(event.formData.entries()).forEach(([key, value]) => {
|
||||
if (value === "") event.formData.delete(key);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/*************************************************************
|
||||
* Class watcher
|
||||
*************************************************************/
|
||||
const watchClassChanges = (selector, callback) => {
|
||||
const body = document.querySelector(selector);
|
||||
|
||||
const observer = new MutationObserver((mutationsList) => {
|
||||
for (const mutation of mutationsList) {
|
||||
if (
|
||||
mutation.type === "attributes" &&
|
||||
mutation.attributeName === "class"
|
||||
) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(body, { attributes: true, attributeFilter: ["class"] });
|
||||
};
|
||||
|
||||
/*************************************************************
|
||||
* Calendar & clock
|
||||
*************************************************************/
|
||||
const dateTimeShortcutsOverlay = () => {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutationRecord of mutations) {
|
||||
const display = mutationRecord.target.style.display;
|
||||
const overlay = document.getElementById("modal-overlay");
|
||||
|
||||
if (display === "block") {
|
||||
overlay.style.display = "block";
|
||||
} else {
|
||||
overlay.style.display = "none";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const targets = document.querySelectorAll(".calendarbox, .clockbox");
|
||||
|
||||
for (const target of targets) {
|
||||
observer.observe(target, {
|
||||
attributes: true,
|
||||
attributeFilter: ["style"],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/*************************************************************
|
||||
* File upload path
|
||||
*************************************************************/
|
||||
const fileInputUpdatePath = () => {
|
||||
const updateFilePath = () => {
|
||||
Array.from(document.querySelectorAll('input[type=file]')).forEach(input => {
|
||||
input.addEventListener('change', e => {
|
||||
const parts = e.target.value.split('\\')
|
||||
const placeholder = input.parentNode.parentNode.querySelector('input[type=text]')
|
||||
placeholder.setAttribute('value', parts[parts.length - 1])
|
||||
})
|
||||
})
|
||||
const checkInputChanged = () => {
|
||||
for (const input of document.querySelectorAll("input[type=file]")) {
|
||||
if (input.hasChangeListener) {
|
||||
continue;
|
||||
}
|
||||
|
||||
input.addEventListener("change", (e) => {
|
||||
const parts = e.target.value.split("\\");
|
||||
const placeholder =
|
||||
input.parentNode.parentNode.parentNode.querySelector(
|
||||
"input[type=text]"
|
||||
);
|
||||
placeholder.setAttribute("value", parts[parts.length - 1]);
|
||||
});
|
||||
|
||||
input.hasChangeListener = true;
|
||||
}
|
||||
};
|
||||
|
||||
updateFilePath()
|
||||
new MutationObserver(() => {
|
||||
checkInputChanged();
|
||||
}).observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
document.addEventListener('DOMNodeInserted', e => {
|
||||
updateFilePath()
|
||||
})
|
||||
}
|
||||
checkInputChanged();
|
||||
};
|
||||
|
||||
/*************************************************************
|
||||
* Search form on changelist view
|
||||
* Chart
|
||||
*************************************************************/
|
||||
const submitSearch = () => {
|
||||
const searchbar = document.getElementById('searchbar')
|
||||
const searchbarSubmit = document.getElementById('searchbar-submit')
|
||||
const DEFAULT_CHART_OPTIONS = {
|
||||
animation: false,
|
||||
barPercentage: 1,
|
||||
base: 0,
|
||||
grouped: false,
|
||||
maxBarThickness: 4,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
datasets: {
|
||||
bar: {
|
||||
borderRadius: 12,
|
||||
border: {
|
||||
width: 0,
|
||||
},
|
||||
borderSkipped: "middle",
|
||||
},
|
||||
line: {
|
||||
borderWidth: 2,
|
||||
pointBorderWidth: 0,
|
||||
pointStyle: false,
|
||||
},
|
||||
pie: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
doughnut: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
align: "end",
|
||||
display: false,
|
||||
position: "top",
|
||||
labels: {
|
||||
boxHeight: 5,
|
||||
boxWidth: 5,
|
||||
color: "#9ca3af",
|
||||
pointStyle: "circle",
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: function (context) {
|
||||
if (["pie", "doughnut", "radar"].includes(context.chart.config.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (searchbar !== null) {
|
||||
searchbar.addEventListener('keypress', e => {
|
||||
if (e.key === 'Enter') {
|
||||
window.location = `?q=${e.target.value}`
|
||||
e.preventDefault()
|
||||
} else {
|
||||
return true;
|
||||
},
|
||||
border: {
|
||||
dash: [5, 5],
|
||||
dashOffset: 2,
|
||||
width: 0,
|
||||
},
|
||||
ticks: {
|
||||
color: "#9ca3af",
|
||||
display: true,
|
||||
maxTicksLimit: function (context) {
|
||||
return context.chart.data.datasets.find(
|
||||
(dataset) => dataset.maxTicksXLimit
|
||||
)?.maxTicksXLimit;
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
display: true,
|
||||
tickWidth: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
display: function (context) {
|
||||
if (["pie", "doughnut", "radar"].includes(context.chart.config.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
border: {
|
||||
dash: [5, 5],
|
||||
dashOffset: 5,
|
||||
width: 0,
|
||||
},
|
||||
ticks: {
|
||||
color: "#9ca3af",
|
||||
display: function (context) {
|
||||
return context.chart.data.datasets.some((dataset) => {
|
||||
return (
|
||||
dataset.hasOwnProperty("displayYAxis") && dataset.displayYAxis
|
||||
);
|
||||
});
|
||||
},
|
||||
callback: function (value) {
|
||||
const suffix = this.chart.data.datasets.find(
|
||||
(dataset) => dataset.suffixYAxis
|
||||
)?.suffixYAxis;
|
||||
if (suffix) {
|
||||
return `${value} ${suffix}`;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
lineWidth: (context) => {
|
||||
if (context.tick.value === 0) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
tickWidth: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const renderCharts = () => {
|
||||
const charts = [];
|
||||
|
||||
const changeDarkModeSettings = () => {
|
||||
const hasDarkClass = document
|
||||
.querySelector("html")
|
||||
.classList.contains("dark");
|
||||
|
||||
const baseColorDark = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--color-base-700")
|
||||
.trim();
|
||||
|
||||
const baseColorLight = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--color-base-300")
|
||||
.trim();
|
||||
|
||||
const borderColor = hasDarkClass ? baseColorDark : baseColorLight;
|
||||
|
||||
for (const chart of charts) {
|
||||
if (chart.options.scales.x) {
|
||||
chart.options.scales.x.grid.color = borderColor;
|
||||
}
|
||||
|
||||
if (chart.options.scales.y) {
|
||||
chart.options.scales.y.grid.color = borderColor;
|
||||
}
|
||||
|
||||
if (chart.options.scales.r) {
|
||||
chart.options.scales.r.grid.color = borderColor;
|
||||
}
|
||||
chart.update();
|
||||
}
|
||||
};
|
||||
|
||||
for (const chart of document.querySelectorAll(".chart")) {
|
||||
const ctx = chart.getContext("2d");
|
||||
const data = chart.dataset.value;
|
||||
const type = chart.dataset.type;
|
||||
const options = chart.dataset.options;
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedData = JSON.parse(chart.dataset.value);
|
||||
|
||||
for (const key in parsedData.datasets) {
|
||||
const dataset = parsedData.datasets[key];
|
||||
const processColor = (colorProp) => {
|
||||
if (Array.isArray(dataset?.[colorProp])) {
|
||||
for (const [index, prop] of dataset?.[colorProp].entries()) {
|
||||
if (prop.startsWith("var(")) {
|
||||
const cssVar = prop.match(/var\((.*?)\)/)[1];
|
||||
const color = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(cssVar)
|
||||
.trim();
|
||||
dataset[colorProp][index] = color;
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (dataset?.[colorProp]?.startsWith("var(")) {
|
||||
const cssVar = dataset[colorProp].match(/var\((.*?)\)/)[1];
|
||||
const color = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(cssVar)
|
||||
.trim();
|
||||
dataset[colorProp] = color;
|
||||
}
|
||||
};
|
||||
|
||||
processColor("borderColor");
|
||||
processColor("backgroundColor");
|
||||
}
|
||||
|
||||
if (searchbarSubmit !== null && searchbar !== null) {
|
||||
searchbarSubmit.addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
window.location = `?q=${searchbar.value}`
|
||||
})
|
||||
CHART_OPTIONS = { ...DEFAULT_CHART_OPTIONS };
|
||||
if (type === "radar") {
|
||||
CHART_OPTIONS.scales = {
|
||||
r: {
|
||||
ticks: {
|
||||
backdropColor: "transparent",
|
||||
},
|
||||
pointLabels: {
|
||||
color: "#9ca3af",
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Chart.defaults.font.family = "Inter";
|
||||
Chart.defaults.font.size = 12;
|
||||
|
||||
charts.push(
|
||||
new Chart(ctx, {
|
||||
type: type || "bar",
|
||||
data: parsedData,
|
||||
options: options ? JSON.parse(options) : { ...CHART_OPTIONS },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
changeDarkModeSettings();
|
||||
|
||||
watchClassChanges("html", () => {
|
||||
changeDarkModeSettings();
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user