mirror of
https://github.com/RetroShare/RSNewWebUI.git
synced 2026-09-14 11:05:47 +05:00
Create classed components for tabs
The Tabs class in rswebui.js will handle all common tab boilerplate and store tabs state as well as route tables for the main routings.
This commit is contained in:
parent
70524f5e10
commit
89e2de2f2e
@ -5,7 +5,7 @@ function sidebar(links) {
|
||||
return m('.sidebar',
|
||||
Object.keys(links)
|
||||
.map(function(panelName) {
|
||||
return m('a.sidebar-link' + (Panel.active === panelName ? '#selected' : ''), {
|
||||
return m('a.sidebar-link' + (Panel.active === panelName ? '#selected-sidebar-link' : ''), {
|
||||
onclick: function() {
|
||||
Panel.active = panelName;
|
||||
},
|
||||
@ -37,14 +37,14 @@ function setMaxRates() {
|
||||
let upload = document.getElementById('upload-limit')
|
||||
.value;
|
||||
if(isNaN(download) || isNaN(upload)) {
|
||||
// TODO show proper error
|
||||
// TODO display error on setting non-numeric value
|
||||
return;
|
||||
}
|
||||
rs.rsJsonApiRequest('/rsConfig/SetMaxDataRates', {
|
||||
downKb: Number(download),
|
||||
upKb: Number(upload),
|
||||
},
|
||||
//TODO display success animation
|
||||
//TODO display success animation(fontawesome)
|
||||
() => {},
|
||||
);
|
||||
};
|
||||
@ -57,7 +57,7 @@ new Panel('Network', {
|
||||
.value = data.outKb;
|
||||
});
|
||||
},
|
||||
// TODO show info from UI hover message
|
||||
// TODO show info from UI hover message
|
||||
view: function() {
|
||||
return m('.node-panel', [
|
||||
m('h3', 'Network Configuration'),
|
||||
@ -142,6 +142,7 @@ let component = {
|
||||
},
|
||||
};
|
||||
|
||||
new rs.Tab('config', component);
|
||||
module.exports = {
|
||||
component,
|
||||
};
|
||||
|
||||
@ -16,96 +16,109 @@ const FT_STATE_PAUSED = 0x0006;
|
||||
const FT_STATE_CHECKING_HASH = 0x0007;
|
||||
|
||||
let Downloads = {
|
||||
statusMap : new Map(),
|
||||
hashes : [],
|
||||
statusMap: new Map(),
|
||||
hashes: [],
|
||||
|
||||
loadHashes() {
|
||||
rs.rsJsonApiRequest(
|
||||
'/rsFiles/FileDownloads',
|
||||
{},
|
||||
function(d) {
|
||||
Downloads.hashes = d.hashs;
|
||||
},
|
||||
'/rsFiles/FileDownloads', {},
|
||||
function(d) {
|
||||
Downloads.hashes = d.hashs;
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
loadStatus() {
|
||||
Downloads.loadHashes();
|
||||
if (Downloads.hashes.length !== Downloads.statusMap.size)
|
||||
if(Downloads.hashes.length !== Downloads.statusMap.size)
|
||||
Downloads.statusMap.clear();
|
||||
for (let hash of Downloads.hashes) {
|
||||
for(let hash of Downloads.hashes) {
|
||||
let json_params = {
|
||||
hash,
|
||||
hintflags : 16, // RS_FILE_HINTS_DOWNLOAD
|
||||
hintflags: 16, // RS_FILE_HINTS_DOWNLOAD
|
||||
};
|
||||
rs.rsJsonApiRequest(
|
||||
'/rsFiles/FileDetails',
|
||||
json_params,
|
||||
function(fileStat) {
|
||||
Downloads.statusMap.set(hash, fileStat.info);
|
||||
},
|
||||
'/rsFiles/FileDetails',
|
||||
json_params,
|
||||
function(fileStat) {
|
||||
Downloads.statusMap.set(hash, fileStat.info);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function makeFriendlyUnit(bytes) {
|
||||
if (bytes < 1e3)
|
||||
if(bytes < 1e3)
|
||||
return bytes.toFixed(1) + 'B';
|
||||
if (bytes < 1e6)
|
||||
return (bytes / 1e3).toFixed(1) + 'kB';
|
||||
if (bytes < 1e9)
|
||||
return (bytes / 1e6).toFixed(1) + 'MB';
|
||||
if (bytes < 1e12)
|
||||
return (bytes / 1e9).toFixed(1) + 'GB';
|
||||
return (bytes / 1e12).toFixed(1) + 'TB';
|
||||
if(bytes < 1e6)
|
||||
return (bytes / 1e3)
|
||||
.toFixed(1) + 'kB';
|
||||
if(bytes < 1e9)
|
||||
return (bytes / 1e6)
|
||||
.toFixed(1) + 'MB';
|
||||
if(bytes < 1e12)
|
||||
return (bytes / 1e9)
|
||||
.toFixed(1) + 'GB';
|
||||
return (bytes / 1e12)
|
||||
.toFixed(1) + 'TB';
|
||||
}
|
||||
|
||||
function progressBar(rate) {
|
||||
console.log('rate: ', rate)
|
||||
rate = rate.toPrecision(3);
|
||||
return m('.progressbar[]',
|
||||
{style : {content : rate + '%'}},
|
||||
m('span.progress-status', {style : {width : rate + '%'}}, rate + '%')
|
||||
return m('.progressbar[]', {
|
||||
style: {
|
||||
content: rate + '%'
|
||||
}
|
||||
},
|
||||
m('span.progress-status', {
|
||||
style: {
|
||||
width: rate + '%'
|
||||
}
|
||||
}, rate + '%')
|
||||
);
|
||||
};
|
||||
|
||||
function fileAction(hash, action) {
|
||||
let action_header = '';
|
||||
let json_params = {hash, flags : 0};
|
||||
let json_params = {
|
||||
hash,
|
||||
flags: 0
|
||||
};
|
||||
switch (action) {
|
||||
case 'cancel':
|
||||
action_header = '/rsFiles/FileCancel';
|
||||
break;
|
||||
case 'cancel':
|
||||
action_header = '/rsFiles/FileCancel';
|
||||
break;
|
||||
|
||||
case 'pause':
|
||||
action_header = '/rsFiles/FileControl';
|
||||
json_params.flags = RS_FILE_CTRL_PAUSE;
|
||||
break;
|
||||
case 'pause':
|
||||
action_header = '/rsFiles/FileControl';
|
||||
json_params.flags = RS_FILE_CTRL_PAUSE;
|
||||
break;
|
||||
|
||||
case 'resume':
|
||||
action_header = '/rsFiles/FileControl';
|
||||
json_params.flags = RS_FILE_CTRL_START;
|
||||
break;
|
||||
case 'resume':
|
||||
action_header = '/rsFiles/FileControl';
|
||||
json_params.flags = RS_FILE_CTRL_START;
|
||||
break;
|
||||
|
||||
case 'force_check':
|
||||
action_header = '/rsFiles/FileControl';
|
||||
json_params.flags = RS_FILE_CTRL_FORCE_CHECK;
|
||||
break;
|
||||
case 'force_check':
|
||||
action_header = '/rsFiles/FileControl';
|
||||
json_params.flags = RS_FILE_CTRL_FORCE_CHECK;
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error('Unknown action in Downloads.control()');
|
||||
return;
|
||||
default:
|
||||
console.error('Unknown action in Downloads.control()');
|
||||
return;
|
||||
};
|
||||
rs.rsJsonApiRequest(action_header, json_params, () => {}); // false
|
||||
};
|
||||
|
||||
function actionButton(file, action) {
|
||||
return m('button', {
|
||||
onclick : function() {
|
||||
fileAction(file.hash, action);
|
||||
onclick: function() {
|
||||
fileAction(file.hash, action);
|
||||
}
|
||||
},
|
||||
},
|
||||
action);
|
||||
};
|
||||
|
||||
@ -116,59 +129,50 @@ let backgroundCallback = function() {
|
||||
backgroundCallback = backgroundCallback.bind(Downloads);
|
||||
|
||||
let isComponentActive = function() {
|
||||
if (m.route.get() === '/downloads')
|
||||
return true;
|
||||
else
|
||||
return false;
|
||||
return (m.route.get() === '/downloads');
|
||||
}
|
||||
|
||||
component = {
|
||||
oninit : function() {
|
||||
rs.setBackgroundTask(backgroundCallback, 5000, isComponentActive);
|
||||
oninit: function() {
|
||||
rs.setBackgroundTask(backgroundCallback, 1000, isComponentActive);
|
||||
},
|
||||
view : function() {
|
||||
view: function() {
|
||||
return m('.tab.frame-center', [
|
||||
m('h3', 'Downloads (' + Downloads.statusMap.size + ')'), m('hr'),
|
||||
m(
|
||||
'table',
|
||||
[
|
||||
m('tr',
|
||||
[
|
||||
m('th', 'Name'),
|
||||
m('th', 'Size'),
|
||||
m('th', 'Transfer rate'),
|
||||
m('th', 'Status'),
|
||||
m('th', 'Progress'),
|
||||
m('th', 'Action'),
|
||||
]),
|
||||
Array.from(
|
||||
Downloads.statusMap,
|
||||
function(fileStatus) {
|
||||
let info = fileStatus[1];
|
||||
let progress = info.transfered / info.size * 100;
|
||||
// Using hash of file as vnode key
|
||||
return m('tr', {key : fileStatus[0]}, [
|
||||
m('td', info.name),
|
||||
m('td', makeFriendlyUnit(info.size)),
|
||||
m('td', makeFriendlyUnit(info.tfRate * 1024) + '/s'),
|
||||
m('td', info.download_status),
|
||||
m('td', progressBar(progress)),
|
||||
m('td',
|
||||
[
|
||||
actionButton(info,
|
||||
info.downloadStatus === FT_STATE_PAUSED
|
||||
? 'resume'
|
||||
: 'pause'),
|
||||
|
||||
actionButton(info, 'cancel'),
|
||||
]),
|
||||
]);
|
||||
})
|
||||
])
|
||||
m('table', [
|
||||
m('tr', [
|
||||
m('th', 'Name'),
|
||||
m('th', 'Size'),
|
||||
m('th', 'Transfer rate'),
|
||||
m('th', 'Status'),
|
||||
m('th', 'Progress'),
|
||||
m('th', 'Action'),
|
||||
]),
|
||||
Array.from(Downloads.statusMap, function(fileStatus) {
|
||||
let info = fileStatus[1];
|
||||
let progress = info.transfered / info.size * 100;
|
||||
// Using hash of file as vnode key
|
||||
return m('tr', {
|
||||
key: fileStatus[0]
|
||||
}, [
|
||||
m('td', info.name),
|
||||
m('td', makeFriendlyUnit(info.size)),
|
||||
m('td', makeFriendlyUnit(info.tfRate * 1024) + '/s'),
|
||||
m('td', info.download_status),
|
||||
m('td', progressBar(progress)),
|
||||
m('td', [
|
||||
actionButton(info, info.downloadStatus === FT_STATE_PAUSED ? 'resume' : 'pause'),
|
||||
actionButton(info, 'cancel'),
|
||||
]),
|
||||
]);
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
new rs.Tab('downloads', component);
|
||||
module.exports = {
|
||||
component,
|
||||
};
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ function copyToClipboard() {
|
||||
document.execCommand('copy');
|
||||
};
|
||||
|
||||
new rs.Tab('home', component);
|
||||
module.exports = {
|
||||
component,
|
||||
};
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
var m = require('mithril');
|
||||
var rs = require('rswebui');
|
||||
|
||||
let onSuccessCallback = function() {};
|
||||
let onSuccessCallback = undefined;
|
||||
|
||||
function renderLoginPage(callback) {
|
||||
// Cannot use mount because vDOM will not let any other component overrides
|
||||
m.render(document.getElementById('main'), m(loginComponent));
|
||||
onSuccessCallback = callback;
|
||||
}
|
||||
};
|
||||
|
||||
let loginComponent = {
|
||||
view: function() {
|
||||
@ -17,24 +17,21 @@ let loginComponent = {
|
||||
m('input.field[type=text][placeholder=Username][id=uname]'),
|
||||
m('input.field[type=password][placeholder=Password][id=passwd]'),
|
||||
m('button.submit-btn', {
|
||||
onclick: verifyLogin
|
||||
onclick: verifyLogin,
|
||||
}, 'Login'),
|
||||
m('p.error[id=error]'),
|
||||
]));
|
||||
}
|
||||
};
|
||||
|
||||
let uname = '';
|
||||
let passwd = '';
|
||||
|
||||
function verifyLogin() {
|
||||
[uname, passwd] = getKeys();
|
||||
let [uname, passwd] = getKeys();
|
||||
let loginHeader = {
|
||||
'Authorization': 'Basic ' + btoa(uname + ':' + passwd)
|
||||
};
|
||||
rs.rsJsonApiRequest('/rsPeers/GetRetroshareInvite', {}, onResponse, true,
|
||||
rs.rsJsonApiRequest('/rsPeers/GetRetroshareInvite', {}, loginHandleWrapper(uname, passwd), true,
|
||||
loginHeader);
|
||||
}
|
||||
};
|
||||
|
||||
function getKeys() {
|
||||
let uname = document.getElementById('uname')
|
||||
@ -42,22 +39,25 @@ function getKeys() {
|
||||
let passwd = document.getElementById('passwd')
|
||||
.value;
|
||||
return [uname, passwd];
|
||||
}
|
||||
};
|
||||
|
||||
function onResponse(data, successful) {
|
||||
if(successful) {
|
||||
rs.setKeys(uname, passwd);
|
||||
onSuccessCallback();
|
||||
} else {
|
||||
displayErrorMessage();
|
||||
}
|
||||
}
|
||||
function loginHandleWrapper(uname, passwd) {
|
||||
let onResponse = function(data, successful) {
|
||||
if(successful) {
|
||||
rs.setKeys(uname, passwd);
|
||||
onSuccessCallback();
|
||||
} else {
|
||||
displayErrorMessage();
|
||||
}
|
||||
};
|
||||
return onResponse;
|
||||
};
|
||||
|
||||
function displayErrorMessage() {
|
||||
m.render(document.getElementById('error'), 'Incorrect login/password.');
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
renderLoginPage,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
let m = require('mithril');
|
||||
let login = require('login');
|
||||
let rs = require('rswebui');
|
||||
|
||||
login.renderLoginPage(onSuccess);
|
||||
|
||||
@ -8,16 +9,26 @@ function onSuccess() {
|
||||
let dl = require('downloads');
|
||||
let config = require('config');
|
||||
|
||||
renderMainStructure();
|
||||
m.route(document.getElementById('tab-section'), '/home', {
|
||||
'/home': home.component,
|
||||
'/downloads': dl.component,
|
||||
'/config': config.component,
|
||||
});
|
||||
rs.Tab.active = 'home';
|
||||
m.route(document.getElementById('main'), '/home', rs.Tab.routeTable);
|
||||
//renderMainStructure();
|
||||
//m.route(document.getElementById('tab-section'), '/home', {
|
||||
// '/home': home.component,
|
||||
// '/downloads': dl.component,
|
||||
// '/config': config.component,
|
||||
//});
|
||||
};
|
||||
|
||||
function renderMainStructure() {
|
||||
m.render(document.getElementById('main'), [
|
||||
rs.Tab.menuBar(),
|
||||
]);
|
||||
};
|
||||
|
||||
/*
|
||||
function renderMainStructure() {
|
||||
m.render(document.getElementById('main'), [
|
||||
rs.Tab.menuBar(),
|
||||
m('nav.tab-container',
|
||||
[
|
||||
m('a.tab-header[href=/home]', {
|
||||
@ -34,4 +45,5 @@ function renderMainStructure() {
|
||||
m('div#tab-section')
|
||||
]);
|
||||
};
|
||||
*/
|
||||
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
var m = require('mithril');
|
||||
let m = require('mithril');
|
||||
|
||||
const API_URL = 'http://127.0.0.1:9092';
|
||||
let loginKey = {
|
||||
username : '',
|
||||
passwd : '',
|
||||
isVerified : false,
|
||||
username: '',
|
||||
passwd: '',
|
||||
isVerified: false,
|
||||
};
|
||||
|
||||
// Make this as object property?
|
||||
function setKeys(username, password, verified = true) {
|
||||
loginKey.username = username;
|
||||
loginKey.passwd = password;
|
||||
@ -16,64 +17,109 @@ function setKeys(username, password, verified = true) {
|
||||
}
|
||||
|
||||
function rsJsonApiRequest(path, data, callback, async = true, headers = {}) {
|
||||
// retroshare will crash if data is not of object type.
|
||||
// Retroshare will crash if data is not of object type.
|
||||
data = data || {};
|
||||
headers['Accept'] = 'application/json';
|
||||
if (loginKey.isVerified) {
|
||||
if(loginKey.isVerified) {
|
||||
headers['Authorization'] =
|
||||
'Basic ' + btoa(loginKey.username + ':' + loginKey.passwd);
|
||||
'Basic ' + btoa(loginKey.username + ':' + loginKey.passwd);
|
||||
}
|
||||
|
||||
console.info('Sending request: \nPath: ' + path + '\nData: ' + data +
|
||||
'\nHeaders:' + headers);
|
||||
'\nHeaders:' + headers);
|
||||
// TODO: Properly handle types of fail situtations
|
||||
// Eg. Retroshare switched off, wrong path, incorrect data, etc.
|
||||
m.request({
|
||||
method : 'POST',
|
||||
url : API_URL + path,
|
||||
async,
|
||||
extract : (xhr) => {
|
||||
// empty string is not valid json and fails on parse
|
||||
if (xhr.responseText === '')
|
||||
xhr.responseText = '""';
|
||||
return {
|
||||
status : xhr.status,
|
||||
body : JSON.parse(xhr.responseText),
|
||||
};
|
||||
},
|
||||
headers : headers,
|
||||
data : data,
|
||||
})
|
||||
.then((result) => {
|
||||
if (typeof(callback) === 'function') {
|
||||
if (result.status === 200) {
|
||||
callback(result.body, true);
|
||||
} else {
|
||||
loginKey.isVerified = false;
|
||||
callback(result.body, false);
|
||||
}
|
||||
method: 'POST',
|
||||
url: API_URL + path,
|
||||
async,
|
||||
extract: (xhr) => {
|
||||
// Empty string is not valid json and fails on parse
|
||||
if(xhr.responseText === '')
|
||||
xhr.responseText = '""';
|
||||
return {
|
||||
status: xhr.status,
|
||||
body: JSON.parse(xhr.responseText),
|
||||
};
|
||||
},
|
||||
headers: headers,
|
||||
data: data,
|
||||
})
|
||||
.then((result) => {
|
||||
if(typeof(callback) === 'function') {
|
||||
if(result.status === 200) {
|
||||
callback(result.body, true);
|
||||
} else {
|
||||
loginKey.isVerified = false;
|
||||
callback(result.body, false);
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
callback({}, false);
|
||||
console.error('Error sending request: ', e);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
callback({}, false);
|
||||
console.error('Error sending request: ', e);
|
||||
});
|
||||
}
|
||||
|
||||
function setBackgroundTask(task, interval, checkTaskScope) {
|
||||
// Always use bound(.bind) function when accsssing outside objects
|
||||
// to avoid loss of scope
|
||||
let taskId;
|
||||
taskId = setTimeout(function caller() {
|
||||
if (checkTaskScope()) {
|
||||
let taskId = setTimeout(function caller() {
|
||||
if(checkTaskScope()) {
|
||||
task();
|
||||
taskId = setTimeout(caller, interval);
|
||||
} else {
|
||||
clearTimeout(taskId);
|
||||
}
|
||||
}, interval);
|
||||
return taskId;
|
||||
};
|
||||
|
||||
class Tab {
|
||||
constructor(name, content) {
|
||||
Tab.componentList[name] = this;
|
||||
// Overriding view function to add menubar
|
||||
this.mainView = content.view;
|
||||
delete content.view;
|
||||
// Object itself will be passed as mithril component
|
||||
Object.assign(this, content);
|
||||
|
||||
Tab.routeTable['/' + name] = this;
|
||||
}
|
||||
view() {
|
||||
return m('.content', [
|
||||
Tab.menuBar(),
|
||||
m('#tab-content', this.mainView()),
|
||||
]);
|
||||
}
|
||||
|
||||
static menuBar() {
|
||||
return m('nav.tab-menu',
|
||||
Object.keys(this.componentList)
|
||||
.map(function(tabName) {
|
||||
return m('a.tab-menu-item' + (tabName === Tab.active ? '#selected-tab-item' : '') + '[href=/' + tabName + ']', {
|
||||
oncreate: m.route.link,
|
||||
onclick: function() {
|
||||
// NOTE: investigate why onclick does not run when clicked on downloads tab
|
||||
// NOTE 2: does not run ONLY when clicked before its backgroundtask first returns
|
||||
// Note 3: workaround: not use oncreate, set #! link in href
|
||||
Tab.active = tabName;
|
||||
},
|
||||
},
|
||||
tabName); //TODO add icons
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
// Static class variables are still experimental
|
||||
Tab.active = '';
|
||||
Tab.componentList = {};
|
||||
Tab.routeTable = {};
|
||||
|
||||
module.exports = {
|
||||
rsJsonApiRequest,
|
||||
setKeys,
|
||||
setBackgroundTask,
|
||||
Tab,
|
||||
};
|
||||
|
||||
|
||||
@ -51,15 +51,6 @@ li {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
@keyframes fadein {
|
||||
from {opacity: 0;}
|
||||
to {opacity: 1;}
|
||||
}
|
||||
|
||||
.tab {
|
||||
animation: fadein 0.5s;
|
||||
}
|
||||
|
||||
.frame-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -87,11 +78,25 @@ li {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/******************************
|
||||
Animations
|
||||
******************************/
|
||||
|
||||
@keyframes fadein {
|
||||
from {opacity: 0;}
|
||||
to {opacity: 1;}
|
||||
}
|
||||
|
||||
.tab {
|
||||
animation: fadein 0.5s;
|
||||
}
|
||||
|
||||
/******************************
|
||||
Target specific sections
|
||||
******************************/
|
||||
|
||||
#tab-section {
|
||||
/* Margin on top to avoid getting covered by navbar */
|
||||
#tab-content {
|
||||
margin-top: 3em;
|
||||
}
|
||||
|
||||
@ -121,7 +126,7 @@ Target specific sections
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.tab-container {
|
||||
.tab-menu {
|
||||
padding: 1em 1em;
|
||||
background-color: #333;
|
||||
overflow: hidden;
|
||||
@ -130,15 +135,15 @@ Target specific sections
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
}
|
||||
.tab-header {
|
||||
.tab-menu-item {
|
||||
text-align: center;
|
||||
padding: 1em 1em;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
transition: all 0.5s;
|
||||
} .tab-header:hover {
|
||||
} .tab-menu-item:hover {
|
||||
background-color: #999;
|
||||
} .tab-header:focus {
|
||||
} .tab-menu-item#selected-tab-item {
|
||||
background-color: #3ba4d7;
|
||||
}
|
||||
.tab-section {
|
||||
@ -161,7 +166,7 @@ Target specific sections
|
||||
border-bottom: 1px solid lightgrey;
|
||||
}.sidebar-link:hover{
|
||||
background-color: #ccc;
|
||||
}.sidebar-link#selected {
|
||||
}.sidebar-link#selected-sidebar-link {
|
||||
background-color: lightgrey;
|
||||
}
|
||||
/* Content adjacent to sidebar */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user