home template is working
This commit is contained in:
parent
b7cf3c1aab
commit
23d6870f77
1644
Cargo.lock
generated
1644
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -38,21 +38,13 @@ maintenance = { status = "actively-developed" }
|
|||||||
base64 = "0.13.0"
|
base64 = "0.13.0"
|
||||||
dirs = "4.0.0"
|
dirs = "4.0.0"
|
||||||
env_logger = "0.8"
|
env_logger = "0.8"
|
||||||
#golgi = "0.1.0"
|
|
||||||
golgi = { path = "/home/glyph/Projects/playground/rust/golgi" }
|
golgi = { path = "/home/glyph/Projects/playground/rust/golgi" }
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
nest = "1.0.0"
|
maud = "0.23.0"
|
||||||
peach-lib = { path = "../peach-lib" }
|
peach-lib = { path = "../peach-lib" }
|
||||||
peach-network = { path = "../peach-network", features = ["serde_support"] }
|
peach-network = { path = "../peach-network" }
|
||||||
peach-stats = { path = "../peach-stats", features = ["serde_support"] }
|
peach-stats = { path = "../peach-stats" }
|
||||||
rocket = { version = "0.5.0-rc.1", features = ["json", "secrets"] }
|
rouille = "3.5.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_json = "1.0"
|
|
||||||
temporary = "0.6.4"
|
temporary = "0.6.4"
|
||||||
tera = { version = "1.12.1", features = ["builtins"] }
|
|
||||||
xdg = "2.2.0"
|
xdg = "2.2.0"
|
||||||
|
|
||||||
[dependencies.rocket_dyn_templates]
|
|
||||||
version = "0.1.0-rc.1"
|
|
||||||
features = ["tera"]
|
|
||||||
|
19
peach-web/rouille_refactor
Normal file
19
peach-web/rouille_refactor
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
|
||||||
|
go slow and steady.
|
||||||
|
|
||||||
|
optimise for few dependencies and short compilation times.
|
||||||
|
|
||||||
|
we do not need to be super fast or feature-rich.
|
||||||
|
|
||||||
|
[ architecture ]
|
||||||
|
|
||||||
|
- use the one-file-per-route patten
|
||||||
|
|
||||||
|
[ tasks ]
|
||||||
|
|
||||||
|
- write the nav and base templates
|
||||||
|
- get the homepage loading properly
|
||||||
|
- route handler
|
||||||
|
- template
|
||||||
|
- file loading (static assets)
|
@ -1,3 +1,3 @@
|
|||||||
pub mod dns;
|
//pub mod dns;
|
||||||
pub mod network;
|
//pub mod network;
|
||||||
pub mod scuttlebutt;
|
//pub mod scuttlebutt;
|
||||||
|
@ -8,39 +8,34 @@
|
|||||||
//! ## Design
|
//! ## Design
|
||||||
//!
|
//!
|
||||||
//! `peach-web` is written primarily in Rust and presents a web interface for
|
//! `peach-web` is written primarily in Rust and presents a web interface for
|
||||||
//! interacting with the device. The stack currently consists of Rocket (Rust
|
//! interacting with the device. The stack currently consists of Rouille (Rust
|
||||||
//! web framework), Tera (Rust template engine inspired by Jinja2 and the Django
|
//! micro-web-framework), Maud (an HTML template engine for Rust), HTML and
|
||||||
//! template language), HTML, CSS and JavaScript. Additional functionality is
|
//! CSS.
|
||||||
//! provided by JSON-RPC clients for the `peach-network` and `peach-stats`
|
|
||||||
//! microservices.
|
|
||||||
//!
|
|
||||||
//! HTML is rendered server-side. Request handlers call JSON-RPC microservices
|
|
||||||
//! and serve HTML and assets. A JSON API is exposed for remote calls and
|
|
||||||
//! dynamic client-side content updates via vanilla JavaScript following
|
|
||||||
//! unobstructive design principles. Each Tera template is passed a context
|
|
||||||
//! object. In the case of Rust, this object is a `struct` and must implement
|
|
||||||
//! `Serialize`. The fields of the context object are available in the context
|
|
||||||
//! of the template to be rendered.
|
|
||||||
|
|
||||||
mod context;
|
//mod context;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
mod router;
|
//mod router;
|
||||||
pub mod routes;
|
//pub mod routes;
|
||||||
#[cfg(test)]
|
//#[cfg(test)]
|
||||||
mod tests;
|
//mod tests;
|
||||||
|
mod templates;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
use std::{process, sync::RwLock};
|
use std::sync::RwLock;
|
||||||
|
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use log::{debug, error, info};
|
//use log::{debug, error, info};
|
||||||
use peach_lib::{config_manager, config_manager::YAML_PATH as PEACH_CONFIG};
|
use log::info;
|
||||||
use rocket::{fairing::AdHoc, serde::Deserialize, Build, Rocket};
|
//use peach_lib::{config_manager, config_manager::YAML_PATH as PEACH_CONFIG};
|
||||||
|
//use rocket::{fairing::AdHoc, serde::Deserialize, Build, Rocket};
|
||||||
|
|
||||||
|
use rouille::{router, Response};
|
||||||
|
|
||||||
use utils::Theme;
|
use utils::Theme;
|
||||||
|
|
||||||
pub type BoxError = Box<dyn std::error::Error>;
|
pub type BoxError = Box<dyn std::error::Error>;
|
||||||
|
|
||||||
|
/*
|
||||||
/// Application configuration parameters.
|
/// Application configuration parameters.
|
||||||
/// These values are extracted from Rocket's default configuration provider:
|
/// These values are extracted from Rocket's default configuration provider:
|
||||||
/// `Config::figment()`. As such, the values are drawn from `Rocket.toml` or
|
/// `Config::figment()`. As such, the values are drawn from `Rocket.toml` or
|
||||||
@ -52,14 +47,16 @@ pub struct RocketConfig {
|
|||||||
disable_auth: bool,
|
disable_auth: bool,
|
||||||
standalone_mode: bool,
|
standalone_mode: bool,
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref THEME: RwLock<Theme> = RwLock::new(Theme::Light);
|
static ref THEME: RwLock<Theme> = RwLock::new(Theme::Light);
|
||||||
}
|
}
|
||||||
|
|
||||||
static WLAN_IFACE: &str = "wlan0";
|
//static WLAN_IFACE: &str = "wlan0";
|
||||||
static AP_IFACE: &str = "ap0";
|
//static AP_IFACE: &str = "ap0";
|
||||||
|
|
||||||
|
/*
|
||||||
pub fn init_rocket() -> Rocket<Build> {
|
pub fn init_rocket() -> Rocket<Build> {
|
||||||
info!("Initializing Rocket");
|
info!("Initializing Rocket");
|
||||||
// build a basic rocket instance
|
// build a basic rocket instance
|
||||||
@ -84,13 +81,16 @@ pub fn init_rocket() -> Rocket<Build> {
|
|||||||
info!("Attaching application configuration to managed state");
|
info!("Attaching application configuration to managed state");
|
||||||
mounted_rocket.attach(AdHoc::config::<RocketConfig>())
|
mounted_rocket.attach(AdHoc::config::<RocketConfig>())
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
/// Launch the peach-web rocket server.
|
const HOSTNAME_AND_PORT: &str = "localhost:8000";
|
||||||
#[rocket::main]
|
|
||||||
async fn main() {
|
/// Launch the peach-web server.
|
||||||
|
fn main() {
|
||||||
// initialize logger
|
// initialize logger
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
|
/*
|
||||||
// check if /var/lib/peachcloud/config.yml exists
|
// check if /var/lib/peachcloud/config.yml exists
|
||||||
if !std::path::Path::new(PEACH_CONFIG).exists() {
|
if !std::path::Path::new(PEACH_CONFIG).exists() {
|
||||||
info!("PeachCloud configuration file not found; loading default values");
|
info!("PeachCloud configuration file not found; loading default values");
|
||||||
@ -102,14 +102,29 @@ async fn main() {
|
|||||||
// this ensures a config file is created if it does not already exist
|
// this ensures a config file is created if it does not already exist
|
||||||
config_manager::save_peach_config(config).expect("peachcloud configuration saving failed");
|
config_manager::save_peach_config(config).expect("peachcloud configuration saving failed");
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
// initialize rocket
|
info!("Launching web server...");
|
||||||
let rocket = init_rocket();
|
|
||||||
|
|
||||||
// launch rocket
|
// the `start_server` starts listening forever on the given address.
|
||||||
info!("Launching Rocket");
|
rouille::start_server(HOSTNAME_AND_PORT, move |request| {
|
||||||
if let Err(e) = rocket.launch().await {
|
info!("Now listening on {}", HOSTNAME_AND_PORT);
|
||||||
error!("Error in Rocket application: {}", e);
|
|
||||||
process::exit(1);
|
// static file server
|
||||||
}
|
// matches on assets in the `static` directory
|
||||||
|
let response = rouille::match_assets(&request, "static");
|
||||||
|
if response.is_success() {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
router!(request,
|
||||||
|
(GET) (/) => {
|
||||||
|
Response::html(templates::home::build())
|
||||||
|
},
|
||||||
|
|
||||||
|
// The code block is called if none of the other blocks matches the request.
|
||||||
|
// We return an empty response with a 404 status code.
|
||||||
|
_ => Response::empty_404()
|
||||||
|
)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
pub mod authentication;
|
//pub mod authentication;
|
||||||
pub mod catchers;
|
//pub mod catchers;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
pub mod scuttlebutt;
|
//pub mod scuttlebutt;
|
||||||
pub mod settings;
|
//pub mod settings;
|
||||||
pub mod status;
|
//pub mod status;
|
||||||
|
23
peach-web/src/templates/base.rs
Normal file
23
peach-web/src/templates/base.rs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
use maud::{html, PreEscaped, DOCTYPE};
|
||||||
|
|
||||||
|
/// Base template builder.
|
||||||
|
///
|
||||||
|
/// Takes an HTML body as input and splices it into the base template.
|
||||||
|
pub fn build(body: PreEscaped<String>) -> PreEscaped<String> {
|
||||||
|
html! {
|
||||||
|
(DOCTYPE)
|
||||||
|
html lang="en" data-theme="light";
|
||||||
|
head {
|
||||||
|
meta charset="utf-8";
|
||||||
|
meta name="description" content="PeachCloud web interface";
|
||||||
|
meta name="author" content="glyph and notplants";
|
||||||
|
meta name="viewport" content="width=devide-width, initial-scale=1.0";
|
||||||
|
link rel="stylesheet" href="/css/peachcloud.css";
|
||||||
|
link rel="stylesheet" href="/css/_variables.css";
|
||||||
|
title { "PeachCloud" }
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
118
peach-web/src/templates/home.rs
Normal file
118
peach-web/src/templates/home.rs
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
use maud::{html, PreEscaped};
|
||||||
|
use peach_lib::sbot::SbotStatus;
|
||||||
|
|
||||||
|
use crate::templates;
|
||||||
|
|
||||||
|
/// Read the state of the go-sbot process and define status-related
|
||||||
|
/// elements accordingly.
|
||||||
|
fn render_status_elements<'a>() -> (&'a str, &'a str, &'a str) {
|
||||||
|
// retrieve go-sbot systemd process status
|
||||||
|
let sbot_status = SbotStatus::read();
|
||||||
|
|
||||||
|
// conditionally render the center circle class, center circle text and
|
||||||
|
// status circle class color based on the go-sbot process state
|
||||||
|
if let Ok(status) = sbot_status {
|
||||||
|
if status.state == Some("active".to_string()) {
|
||||||
|
(
|
||||||
|
"circle circle-large circle-success",
|
||||||
|
"^_^",
|
||||||
|
"circle circle-small border-circle-small border-success",
|
||||||
|
)
|
||||||
|
} else if status.state == Some("inactive".to_string()) {
|
||||||
|
(
|
||||||
|
"circle circle-large circle-warning",
|
||||||
|
"z_z",
|
||||||
|
"circle circle-small border-circle-small border-warning",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"circle circle-large circle-danger",
|
||||||
|
"x_x",
|
||||||
|
"circle circle-small border-circle-small border-danger",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"circle circle-large circle-danger",
|
||||||
|
"x_x",
|
||||||
|
"circle circle-small border-circle-small border-danger",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Home template builder.
|
||||||
|
pub fn build<'a>() -> PreEscaped<String> {
|
||||||
|
let (center_circle_class, center_circle_text, status_circle_class) = render_status_elements();
|
||||||
|
|
||||||
|
// render the home template html
|
||||||
|
let home_template = html! {
|
||||||
|
(PreEscaped("<!-- RADIAL MENU -->"))
|
||||||
|
div class="grid" {
|
||||||
|
(PreEscaped("<!-- top-left -->"))
|
||||||
|
(PreEscaped("<!-- PEERS LINK AND ICON -->"))
|
||||||
|
a class="top-left" href="/scuttlebutt/peers" title="Scuttlebutt Peers" {
|
||||||
|
div class="circle circle-small border-circle-small border-ssb" {
|
||||||
|
img class="icon-medium" src="/icons/users.svg";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(PreEscaped("<!-- top-middle -->"))
|
||||||
|
(PreEscaped("<!-- CURRENT USER LINK AND ICON -->"))
|
||||||
|
a class="top-middle" href="/scuttlebutt/profile" title="Profile" {
|
||||||
|
div class="circle circle-small border-circle-small border-ssb" {
|
||||||
|
img class="icon-medium" src="/icons/user.svg";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(PreEscaped("<!-- top-right -->"))
|
||||||
|
(PreEscaped("<!-- MESSAGES LINK AND ICON -->"))
|
||||||
|
a class="top-right" href="/scuttlebutt/private" title="Private Messages" {
|
||||||
|
div class="circle circle-small border-circle-small border-ssb" {
|
||||||
|
img class="icon-medium" src="/icons/envelope.svg";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(PreEscaped("<!-- middle -->"))
|
||||||
|
a class="middle" {
|
||||||
|
div class=(center_circle_class) {
|
||||||
|
p style="font-size: 4rem; color: var(--near-black);" {
|
||||||
|
(center_circle_text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(PreEscaped("<!-- bottom-left -->"))
|
||||||
|
(PreEscaped("<!-- SYSTEM STATUS LINK AND ICON -->"))
|
||||||
|
a class="bottom-left" href="/status/scuttlebutt" title="Status" {
|
||||||
|
div class=(status_circle_class) {
|
||||||
|
img class="icon-medium" src="/icons/heart-pulse.svg";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
TODO: render the path of the status circle button based on the mode
|
||||||
|
{%- if standalone_mode == true -%}
|
||||||
|
<a class="bottom-left" href="/status/scuttlebutt" title="Status">
|
||||||
|
{% else -%}
|
||||||
|
<a class="bottom-left" href="/status" title="Status">
|
||||||
|
{%- endif -%}
|
||||||
|
*/
|
||||||
|
(PreEscaped("<!-- bottom-middle -->"))
|
||||||
|
(PreEscaped("<!-- PEACHCLOUD GUIDEBOOK LINK AND ICON -->"))
|
||||||
|
a class="bottom-middle" href="/guide" title="Guide" {
|
||||||
|
div class="circle circle-small border-circle-small border-info" {
|
||||||
|
img class="icon-medium" src="/icons/book.svg";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(PreEscaped("<!-- bottom-right -->"))
|
||||||
|
(PreEscaped("<!-- SYSTEM SETTINGS LINK AND ICON -->"))
|
||||||
|
a class="bottom-right" href="/settings" title="Settings" {
|
||||||
|
div class="circle circle-small border-circle-small border-settings" {
|
||||||
|
img class="icon-medium" src="/icons/cog.svg";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// wrap the nav bars around the home template content
|
||||||
|
// title is "" and back button link is `None` because this is the homepage
|
||||||
|
let body = templates::nav::build(home_template, "", None);
|
||||||
|
|
||||||
|
// render the base template with the provided body
|
||||||
|
templates::base::build(body)
|
||||||
|
}
|
3
peach-web/src/templates/mod.rs
Normal file
3
peach-web/src/templates/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mod base;
|
||||||
|
pub mod home;
|
||||||
|
pub mod nav;
|
59
peach-web/src/templates/nav.rs
Normal file
59
peach-web/src/templates/nav.rs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
use maud::{html, PreEscaped};
|
||||||
|
|
||||||
|
use crate::utils;
|
||||||
|
|
||||||
|
/// Navigation template builder.
|
||||||
|
///
|
||||||
|
/// Takes the main HTML content as input and splices it into the navigation template.
|
||||||
|
pub fn build(main: PreEscaped<String>, title: &str, back: Option<&str>) -> PreEscaped<String> {
|
||||||
|
// retrieve the current theme value
|
||||||
|
let theme = utils::get_theme();
|
||||||
|
|
||||||
|
// conditionally render the hermies icon and theme-switcher icon with correct link
|
||||||
|
let (hermies, switcher) = match theme.as_str() {
|
||||||
|
// if we're using the dark theme, render light icons and "light" query param
|
||||||
|
"dark" => (
|
||||||
|
"/icons/hermies_hex_light.svg",
|
||||||
|
html! {
|
||||||
|
a class="nav-item" href="/theme?theme=light" {
|
||||||
|
img class="icon-medium nav-icon-right icon-active" title="Toggle theme" src="/icons/sun.png" alt="Sun";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// otherwise, assume we're using light mode
|
||||||
|
_ => (
|
||||||
|
"/icons/hermies_hex.svg",
|
||||||
|
html! {
|
||||||
|
a class="nav-item" href="/theme?theme=dark" {
|
||||||
|
img class="icon-medium nav-icon-right icon-active" title="Toggle theme" src="/icons/moon.png" alt="Moon";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
(PreEscaped("<!-- Top navigation bar -->"))
|
||||||
|
nav class="nav-bar" {
|
||||||
|
a class="nav-item" href=[back] title="Back" {
|
||||||
|
img class="icon-medium nav-icon-left icon-active" src="/icons/back.svg" alt="Back";
|
||||||
|
}
|
||||||
|
h1 class="nav-title" { (title) }
|
||||||
|
a class="nav-item" id="logoutButton" href="/logout" title="Logout" {
|
||||||
|
img class="icon-medium nav-icon-right icon-active" src="/icons/enter.svg" alt="Enter";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(PreEscaped("<!-- Main content container -->"))
|
||||||
|
main { (main) }
|
||||||
|
(PreEscaped("<!-- Bottom navigation bar -->"))
|
||||||
|
nav class="nav-bar" {
|
||||||
|
a class="nav-item" href="https://scuttlebutt.nz/" {
|
||||||
|
img class="icon-medium nav-icon-left" title="Scuttlebutt Website" src=(hermies) alt="Secure Scuttlebutt";
|
||||||
|
}
|
||||||
|
a class="nav-item" href="/" {
|
||||||
|
img class="icon nav-icon-left" src="/icons/peach-icon.png" alt="PeachCloud" title="Home";
|
||||||
|
}
|
||||||
|
// render the pre-defined theme-switcher icon
|
||||||
|
(switcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,30 @@
|
|||||||
|
use log::info;
|
||||||
|
|
||||||
|
use crate::THEME;
|
||||||
|
|
||||||
|
// THEME FUNCTIONS
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub enum Theme {
|
||||||
|
Light,
|
||||||
|
Dark,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_theme() -> String {
|
||||||
|
let current_theme = THEME.read().unwrap();
|
||||||
|
match *current_theme {
|
||||||
|
Theme::Dark => "dark".to_string(),
|
||||||
|
_ => "light".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_theme(theme: Theme) {
|
||||||
|
info!("set ui theme to: {:?}", theme);
|
||||||
|
let mut writable_theme = THEME.write().unwrap();
|
||||||
|
*writable_theme = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
pub mod monitor;
|
pub mod monitor;
|
||||||
|
|
||||||
use std::io::prelude::*;
|
use std::io::prelude::*;
|
||||||
@ -84,28 +111,6 @@ pub async fn write_blob_to_store(file: &mut TempFile<'_>) -> Result<String, Peac
|
|||||||
Ok(blob_id)
|
Ok(blob_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// THEME FUNCTIONS
|
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone)]
|
|
||||||
pub enum Theme {
|
|
||||||
Light,
|
|
||||||
Dark,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_theme() -> String {
|
|
||||||
let current_theme = THEME.read().unwrap();
|
|
||||||
match *current_theme {
|
|
||||||
Theme::Dark => "dark".to_string(),
|
|
||||||
_ => "light".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_theme(theme: Theme) {
|
|
||||||
info!("set ui theme to: {:?}", theme);
|
|
||||||
let mut writable_theme = THEME.write().unwrap();
|
|
||||||
*writable_theme = theme;
|
|
||||||
}
|
|
||||||
|
|
||||||
// HELPER FUNCTIONS
|
// HELPER FUNCTIONS
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@ -122,3 +127,4 @@ pub enum TemplateOrRedirect {
|
|||||||
Template(Template),
|
Template(Template),
|
||||||
Redirect(Redirect),
|
Redirect(Redirect),
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
Loading…
x
Reference in New Issue
Block a user