mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-07-15 23:09:56 +00:00
feat: add cookies support and improve autorefresh UX
This commit also includes changing of load logic to update row by row instead of full table toggle. It also changes behaviour on openned dropdowns blocking refresh
This commit is contained in:
@ -99,6 +99,8 @@
|
||||
|
||||
<table id="packages"
|
||||
data-classes="table table-hover"
|
||||
data-cookie="true"
|
||||
data-cookie-id-table="ahriman-packages"
|
||||
data-export-options='{"fileName": "packages"}'
|
||||
data-filter-control="true"
|
||||
data-filter-control-visible="false"
|
||||
@ -117,8 +119,8 @@
|
||||
data-sortable="true"
|
||||
data-sort-name="base"
|
||||
data-sort-order="asc"
|
||||
data-toggle="table"
|
||||
data-toolbar="#toolbar">
|
||||
data-toolbar="#toolbar"
|
||||
data-unique-id="id">
|
||||
<thead class="table-primary">
|
||||
<tr>
|
||||
<th data-checkbox="true"></th>
|
||||
|
@ -80,8 +80,7 @@
|
||||
data-classes="table table-hover"
|
||||
data-sortable="true"
|
||||
data-sort-name="timestamp"
|
||||
data-sort-order="desc"
|
||||
data-toggle="table">
|
||||
data-sort-order="desc">
|
||||
<thead class="table-primary">
|
||||
<tr>
|
||||
<th data-align="right" data-field="timestamp">date</th>
|
||||
@ -509,6 +508,8 @@
|
||||
}
|
||||
|
||||
ready(_ => {
|
||||
packageInfoEventsTable.bootstrapTable({});
|
||||
|
||||
packageInfoEventsUpdateChart = new Chart(packageInfoEventsUpdateChartCanvas, {
|
||||
type: "line",
|
||||
data: {},
|
||||
@ -539,5 +540,7 @@
|
||||
clearInterval(packageInfoAutoReloadTask);
|
||||
packageInfoAutoReloadTask = null; // not really required (?) but lets clear everything
|
||||
});
|
||||
|
||||
restoreAutoReloadSettings(packageInfoAutoReloadButton, packageInfoAutoReloadInput);
|
||||
});
|
||||
</script>
|
||||
|
@ -113,24 +113,24 @@
|
||||
convert: response => response.json(),
|
||||
},
|
||||
data => {
|
||||
const payload = data.map(description => {
|
||||
const package_base = description.package.base;
|
||||
const web_url = description.package.remote.web_url;
|
||||
return {
|
||||
id: package_base,
|
||||
base: web_url ? safeLink(web_url, package_base, package_base).outerHTML : safe(package_base),
|
||||
version: safe(description.package.version),
|
||||
packager: description.package.packager ? safe(description.package.packager) : "",
|
||||
packages: listToTable(Object.keys(description.package.packages)),
|
||||
groups: listToTable(extractListProperties(description.package, "groups")),
|
||||
licenses: listToTable(extractListProperties(description.package, "licenses")),
|
||||
timestamp: new Date(1000 * description.status.timestamp).toISOStringShort(),
|
||||
status: description.status.status,
|
||||
};
|
||||
});
|
||||
const payload = data
|
||||
.map(description => {
|
||||
const package_base = description.package.base;
|
||||
const web_url = description.package.remote.web_url;
|
||||
return {
|
||||
id: package_base,
|
||||
base: web_url ? safeLink(web_url, package_base, package_base).outerHTML : safe(package_base),
|
||||
version: safe(description.package.version),
|
||||
packager: description.package.packager ? safe(description.package.packager) : "",
|
||||
packages: listToTable(Object.keys(description.package.packages)),
|
||||
groups: listToTable(extractListProperties(description.package, "groups")),
|
||||
licenses: listToTable(extractListProperties(description.package, "licenses")),
|
||||
timestamp: new Date(1000 * description.status.timestamp).toISOStringShort(),
|
||||
status: description.status.status,
|
||||
};
|
||||
});
|
||||
|
||||
table.bootstrapTable("load", payload);
|
||||
table.bootstrapTable("uncheckAll");
|
||||
updateTable(table, payload);
|
||||
table.bootstrapTable("hideLoading");
|
||||
},
|
||||
error => {
|
||||
@ -241,15 +241,20 @@
|
||||
function toggleTableAutoReload(interval) {
|
||||
clearInterval(tableAutoReloadTask);
|
||||
tableAutoReloadTask = toggleAutoReload(tableAutoReloadButton, interval, tableAutoReloadInput, _ => {
|
||||
if ((getSelection().length === 0) &&
|
||||
(table.bootstrapTable("getOptions").pageNumber === 1) &&
|
||||
(!dashboardModal.classList.contains("show"))) {
|
||||
if (!dashboardModal.classList.contains("show") &&
|
||||
!hasActiveDropdown()) {
|
||||
reload(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ready(_ => {
|
||||
const onCheckFunction = function () {
|
||||
if (packageRemoveButton) {
|
||||
packageRemoveButton.disabled = !getSelection().length;
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll("#repositories a").forEach(element => {
|
||||
element.onclick = _ => {
|
||||
repository = {
|
||||
@ -264,49 +269,52 @@
|
||||
};
|
||||
});
|
||||
|
||||
table.on("check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table", _ => {
|
||||
if (packageRemoveButton) {
|
||||
packageRemoveButton.disabled = !table.bootstrapTable("getSelections").length;
|
||||
}
|
||||
});
|
||||
table.on("click-row.bs.table", (self, data, row, cell) => {
|
||||
if (0 === cell || "base" === cell) {
|
||||
const method = data[0] === true ? "uncheckBy" : "checkBy"; // fck javascript
|
||||
table.bootstrapTable(method, {field: "id", values: [data.id]});
|
||||
} else showPackageInfo(data.id);
|
||||
});
|
||||
table.on("created-controls.bs.table", _ => {
|
||||
new easepick.create({
|
||||
element: document.querySelector(".bootstrap-table-filter-control-timestamp"),
|
||||
css: [
|
||||
"https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css",
|
||||
],
|
||||
grid: 2,
|
||||
calendars: 2,
|
||||
autoApply: false,
|
||||
locale: {
|
||||
cancel: "Clear",
|
||||
},
|
||||
RangePlugin: {
|
||||
tooltip: false,
|
||||
},
|
||||
plugins: [
|
||||
"RangePlugin",
|
||||
],
|
||||
setup: picker => {
|
||||
picker.on("select", _ => { table.bootstrapTable("triggerSearch"); });
|
||||
// replace "Cancel" behaviour to "Clear"
|
||||
picker.onClickCancelButton = element => {
|
||||
if (picker.isCancelButton(element)) {
|
||||
picker.clear();
|
||||
picker.hide();
|
||||
table.bootstrapTable("triggerSearch");
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
table.bootstrapTable({
|
||||
onCheck: onCheckFunction,
|
||||
onCheckAll: onCheckFunction,
|
||||
onClickRow: (data, row, cell) => {
|
||||
if (0 === cell || "base" === cell) {
|
||||
const method = data[0] === true ? "uncheckBy" : "checkBy"; // fck javascript
|
||||
table.bootstrapTable(method, {field: "id", values: [data.id]});
|
||||
} else showPackageInfo(data.id);
|
||||
},
|
||||
onCreatedControls: _ => {
|
||||
new easepick.create({
|
||||
element: document.querySelector(".bootstrap-table-filter-control-timestamp"),
|
||||
css: [
|
||||
"https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css",
|
||||
],
|
||||
grid: 2,
|
||||
calendars: 2,
|
||||
autoApply: false,
|
||||
locale: {
|
||||
cancel: "Clear",
|
||||
},
|
||||
RangePlugin: {
|
||||
tooltip: false,
|
||||
},
|
||||
plugins: [
|
||||
"RangePlugin",
|
||||
],
|
||||
setup: picker => {
|
||||
picker.on("select", _ => { table.bootstrapTable("triggerSearch"); });
|
||||
// replace "Cancel" behaviour to "Clear"
|
||||
picker.onClickCancelButton = element => {
|
||||
if (picker.isCancelButton(element)) {
|
||||
picker.clear();
|
||||
picker.hide();
|
||||
table.bootstrapTable("triggerSearch");
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
onUncheck: onCheckFunction,
|
||||
onUncheckAll: onCheckFunction,
|
||||
});
|
||||
|
||||
restoreAutoReloadSettings(tableAutoReloadButton, tableAutoReloadInput);
|
||||
|
||||
selectRepository();
|
||||
{% if autorefresh_intervals %}
|
||||
toggleTableAutoReload();
|
||||
|
@ -53,8 +53,7 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
|
||||
data-show-search-clear-button="true"
|
||||
data-sortable="true"
|
||||
data-sort-name="base"
|
||||
data-sort-order="asc"
|
||||
data-toggle="table">
|
||||
data-sort-order="asc">
|
||||
<thead class="table-primary">
|
||||
<tr>
|
||||
<th data-sortable="true" data-switchable="false" data-field="name" data-filter-control="input" data-filter-control-placeholder="(any package)">package</th>
|
||||
@ -128,36 +127,38 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
|
||||
}
|
||||
|
||||
ready(_ => {
|
||||
table.on("created-controls.bs.table", _ => {
|
||||
new easepick.create({
|
||||
element: document.querySelector(".bootstrap-table-filter-control-timestamp"),
|
||||
css: [
|
||||
"https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css",
|
||||
],
|
||||
grid: 2,
|
||||
calendars: 2,
|
||||
autoApply: false,
|
||||
locale: {
|
||||
cancel: "Clear",
|
||||
},
|
||||
RangePlugin: {
|
||||
tooltip: false,
|
||||
},
|
||||
plugins: [
|
||||
"RangePlugin",
|
||||
],
|
||||
setup: picker => {
|
||||
picker.on("select", _ => { table.bootstrapTable("triggerSearch"); });
|
||||
// replace "Cancel" behaviour to "Clear"
|
||||
picker.onClickCancelButton = element => {
|
||||
if (picker.isCancelButton(element)) {
|
||||
picker.clear();
|
||||
picker.hide();
|
||||
table.bootstrapTable("triggerSearch");
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
table.bootstrapTable({
|
||||
onCreatedControls: _ => {
|
||||
new easepick.create({
|
||||
element: document.querySelector(".bootstrap-table-filter-control-timestamp"),
|
||||
css: [
|
||||
"https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css",
|
||||
],
|
||||
grid: 2,
|
||||
calendars: 2,
|
||||
autoApply: false,
|
||||
locale: {
|
||||
cancel: "Clear",
|
||||
},
|
||||
RangePlugin: {
|
||||
tooltip: false,
|
||||
},
|
||||
plugins: [
|
||||
"RangePlugin",
|
||||
],
|
||||
setup: picker => {
|
||||
picker.on("select", _ => { table.bootstrapTable("triggerSearch"); });
|
||||
// replace "Cancel" behaviour to "Clear"
|
||||
picker.onClickCancelButton = element => {
|
||||
if (picker.isCancelButton(element)) {
|
||||
picker.clear();
|
||||
picker.hide();
|
||||
table.bootstrapTable("triggerSearch");
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
@ -1,23 +1,26 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/js-md5@0.8.3/src/md5.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/tableexport.jquery.plugin@1.30.0/tableExport.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/tableexport.jquery.plugin@1.33.0/tableExport.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/bootstrap-table.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.24.1/dist/bootstrap-table.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/extensions/export/bootstrap-table-export.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/extensions/resizable/bootstrap-table-resizable.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/extensions/filter-control/bootstrap-table-filter-control.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.24.1/dist/extensions/export/bootstrap-table-export.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.24.1/dist/extensions/resizable/bootstrap-table-resizable.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.24.1/dist/extensions/filter-control/bootstrap-table-filter-control.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.24.1/dist/extensions/cookie/bootstrap-table-cookie.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.umd.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.10.0/build/highlight.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/js-cookie@3.0.5/dist/js.cookie.min.js" crossorigin="anonymous" type="application/javascript"></script>
|
||||
|
||||
<script>
|
||||
async function copyToClipboard(text, button) {
|
||||
@ -62,6 +65,11 @@
|
||||
return !document.getSelection().isCollapsed; // not sure if it is a valid way, but I guess so
|
||||
}
|
||||
|
||||
function hasActiveDropdown() {
|
||||
return Array.from(document.querySelectorAll(".dropdown-menu"))
|
||||
.some(el => el.classList.contains("show"));
|
||||
}
|
||||
|
||||
function headerClass(status) {
|
||||
if (status === "pending") return ["bg-warning"];
|
||||
if (status === "building") return ["bg-warning"];
|
||||
@ -110,6 +118,12 @@
|
||||
.catch(error => onFailure && onFailure(error));
|
||||
}
|
||||
|
||||
function readOptionalCookies(key, callback) {
|
||||
for (let value = Cookies.get(key); !!value; value = undefined) {
|
||||
callback(value);
|
||||
}
|
||||
}
|
||||
|
||||
function ready(fn) {
|
||||
if (document.readyState === "complete" || document.readyState === "interactive") {
|
||||
setTimeout(fn, 1);
|
||||
@ -118,6 +132,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function restoreAutoReloadSettings(toggle, intervalSelector) {
|
||||
readOptionalCookies(`ahriman-${toggle.id}-refresh-enabled`, value => toggle.checked = value === "true");
|
||||
readOptionalCookies(`ahriman-${toggle.id}-refresh-interval`, value => toggleActiveElement(intervalSelector, "interval", value));
|
||||
}
|
||||
|
||||
function safe(string) {
|
||||
return String(string)
|
||||
.replace(/&/g, "&")
|
||||
@ -137,6 +156,18 @@
|
||||
return element;
|
||||
}
|
||||
|
||||
function toggleActiveElement(selector, dataType, value) {
|
||||
const targetElement = selector.querySelector(`a[data-${dataType}="${value}"]`);
|
||||
if (targetElement?.classList?.contains("active")) {
|
||||
return; // element is already active, skip processing
|
||||
}
|
||||
|
||||
Array.from(selector.children).forEach(il => {
|
||||
Array.from(il.children).forEach(el => el.classList.remove("active"));
|
||||
});
|
||||
targetElement?.classList?.add("active");
|
||||
}
|
||||
|
||||
function toggleAutoReload(toggle, interval, intervalSelector, callback) {
|
||||
if (interval) {
|
||||
toggle.checked = true; // toggle reload
|
||||
@ -144,24 +175,52 @@
|
||||
interval = intervalSelector.querySelector(".active")?.dataset?.interval; // find active element
|
||||
}
|
||||
|
||||
let intervalId = null;
|
||||
if (interval) {
|
||||
if (toggle.checked) {
|
||||
// refresh UI
|
||||
Array.from(intervalSelector.children).forEach(il => {
|
||||
Array.from(il.children).forEach(el => el.classList.remove("active"));
|
||||
});
|
||||
intervalSelector.querySelector(`a[data-interval="${interval}"]`)?.classList?.add("active");
|
||||
toggleActiveElement(intervalSelector, "interval", interval);
|
||||
// finally create timer task
|
||||
return setInterval(callback, interval);
|
||||
intervalId = setInterval(callback, interval);
|
||||
}
|
||||
} else {
|
||||
toggle.checked = false; // no active interval found, disable toggle
|
||||
}
|
||||
|
||||
return null; // return null to assign to keep method sane
|
||||
Cookies.set(`ahriman-${toggle.id}-refresh-enabled`, toggle.checked);
|
||||
Cookies.set(`ahriman-${toggle.id}-refresh-interval`, interval);
|
||||
return intervalId;
|
||||
}
|
||||
|
||||
Date.prototype.toISOStringShort = function() {
|
||||
function updateTable(table, rows) {
|
||||
// instead of using load method here, we just update rows manually to avoid table reinitialization
|
||||
const currentData = table.bootstrapTable("getData").reduce((accumulator, row) => {
|
||||
accumulator[row.id] = row["0"];
|
||||
return accumulator;
|
||||
}, {});
|
||||
// insert or update rows
|
||||
rows.forEach(row => {
|
||||
if (Object.hasOwn(currentData, row.id)) {
|
||||
row["0"] = currentData[row.id]; // copy checkbox state
|
||||
table.bootstrapTable("updateByUniqueId", {
|
||||
id: row.id,
|
||||
row: row,
|
||||
replace: true,
|
||||
});
|
||||
} else {
|
||||
table.bootstrapTable("insertRow", {index: 0, row: row});
|
||||
}
|
||||
});
|
||||
// remove old rows
|
||||
const newData = rows.map(value => value.id);
|
||||
Object.keys(currentData).forEach(id => {
|
||||
if (!newData.includes(id)) {
|
||||
table.bootstrapTable("removeByUniqueId", id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Date.prototype.toISOStringShort = function () {
|
||||
const pad = number => String(number).padStart(2, "0");
|
||||
return `${this.getFullYear()}-${pad(this.getMonth() + 1)}-${pad(this.getDate())} ${pad(this.getHours())}:${pad(this.getMinutes())}:${pad(this.getSeconds())}`;
|
||||
}
|
||||
|
@ -1,15 +1,15 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" crossorigin="anonymous" type="text/css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" crossorigin="anonymous" type="text/css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" crossorigin="anonymous" type="text/css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.css" crossorigin="anonymous" type="text/css">
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/bootstrap-table.min.css" crossorigin="anonymous" type="text/css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.24.1/dist/bootstrap-table.min.css" crossorigin="anonymous" type="text/css">
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" crossorigin="anonymous" type="text/css">
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/extensions/filter-control/bootstrap-table-filter-control.css" crossorigin="anonymous" type="text/css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.24.1/dist/extensions/filter-control/bootstrap-table-filter-control.css" crossorigin="anonymous" type="text/css">
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@5.3.3/dist/cosmo/bootstrap.min.css" crossorigin="anonymous" type="text/css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/cosmo/bootstrap.min.css" crossorigin="anonymous" type="text/css">
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.10.0/build/styles/github.min.css" crossorigin="anonymous" type="text/css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/styles/github.min.css" crossorigin="anonymous" type="text/css">
|
||||
|
||||
<style>
|
||||
.pre-scrollable {
|
||||
|
Reference in New Issue
Block a user