Compare commits
14 Commits
9c79881c4b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ac59f65f2 | |||
| 4a25c377c4 | |||
| 8c64c5dbe3 | |||
| 88abbe8c14 | |||
| c5b85bc359 | |||
| 8a27a74e9f | |||
| 425bac6776 | |||
| 5c456fd5cd | |||
| 08b5932cf8 | |||
| c7377e06e3 | |||
| 4e53f9ed1f | |||
| d692bf1cdd | |||
| ec126bbdc4 | |||
| 0fe13b6ce7 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@
|
||||
.idea
|
||||
Cargo.lock
|
||||
.env
|
||||
config.toml
|
||||
|
||||
@@ -8,6 +8,6 @@ actix-web = {version = "4.11.0"}
|
||||
serde = "1.0.228"
|
||||
serde_json = "1.0.145"
|
||||
toml = "0.9.7"
|
||||
sqlx = {version = "0.8.6", features = ["postgres"]}
|
||||
sqlx = {version = "0.8.6", features = ["postgres", "runtime-tokio"]}
|
||||
futures-util = "0.3.31"
|
||||
clap = { version = "4.5.50", features = ["derive"] }
|
||||
13
assets/css/ip.css
Normal file
13
assets/css/ip.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.ip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ip h1 {
|
||||
font-family: 'Indie Flower', cursive;
|
||||
font-size: 60px;
|
||||
text-align: center;
|
||||
color: var(--clr-primary-a60);
|
||||
}
|
||||
58
assets/css/music.css
Normal file
58
assets/css/music.css
Normal file
@@ -0,0 +1,58 @@
|
||||
.featured {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.featured h1 {
|
||||
font-size: 48px;
|
||||
text-align: center;
|
||||
font-family: 'Indie Flower', cursive;
|
||||
color: var(--clr-primary-a60);
|
||||
max-width: 75vw;
|
||||
}
|
||||
|
||||
.featured iframe {
|
||||
width: 55vw;
|
||||
height: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border: none;
|
||||
filter: drop-shadow(0px 0px 12px rgba(0, 0, 0, 0.75));
|
||||
}
|
||||
|
||||
.playlist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.playlist h1 {
|
||||
font-size: 40px;
|
||||
text-align: center;
|
||||
font-family: 'Indie Flower', cursive;
|
||||
color: var(--clr-primary-a60);
|
||||
max-width: 75vw;
|
||||
}
|
||||
|
||||
|
||||
.playlist iframe {
|
||||
width: 55vw;
|
||||
height: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border: none;
|
||||
filter: drop-shadow(0px 0px 12px rgba(0, 0, 0, 0.75));
|
||||
}
|
||||
|
||||
@media (width <= 1250px) {
|
||||
.featured iframe {
|
||||
width: 90vw;
|
||||
}
|
||||
|
||||
.playlist iframe {
|
||||
width: 90vw;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
[server]
|
||||
# Change with actual location of assets
|
||||
assets="./assets"
|
||||
# Set bind address
|
||||
bind="127.0.0.1:8080"
|
||||
11
config.toml.example
Normal file
11
config.toml.example
Normal file
@@ -0,0 +1,11 @@
|
||||
[server]
|
||||
# Change with actual location of assets
|
||||
assets="./assets"
|
||||
# Set bind address
|
||||
bind="127.0.0.1:8080"
|
||||
|
||||
[database]
|
||||
address="127.0.0.1:5432"
|
||||
user="web"
|
||||
password="apassword"
|
||||
database_name="website"
|
||||
13
migrations/20251027081447_projects.sql
Normal file
13
migrations/20251027081447_projects.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS Projects (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Project_Detail (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"git_url" TEXT NOT NULL,
|
||||
"git_title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL
|
||||
)
|
||||
13
migrations/20251027102341_website_project.sql
Normal file
13
migrations/20251027102341_website_project.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
INSERT INTO Projects ("id", "title", "description") VALUES (
|
||||
'website',
|
||||
'website',
|
||||
'This project is the website you currently are on.'
|
||||
);
|
||||
|
||||
INSERT INTO Project_Detail ("id", "title", "git_url", "git_title", "description") VALUES (
|
||||
'website',
|
||||
'website',
|
||||
'https://git.aindustries.be/AINDUSTRIES/aindustries.be',
|
||||
'aindustries-be',
|
||||
'This project, the website you are on, is made in Rust such that all the pages are generated by code. <br> That is that each html element is represented by a struct which implements the Render trait (as in render the element to html). <br> As it is right now the system is not that impressive but I believe it can do amazing things in the future.'
|
||||
)
|
||||
@@ -390,3 +390,115 @@ impl AnchorBuilder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Iframe {
|
||||
id: String,
|
||||
classes: Vec<String>,
|
||||
src: String,
|
||||
title: String,
|
||||
width: usize,
|
||||
height: usize,
|
||||
}
|
||||
|
||||
impl Render for Iframe {
|
||||
fn render(&self) -> String {
|
||||
let classes = self.classes.join(" ");
|
||||
format!(
|
||||
"<iframe id=\"{}\" classes=\"{}\" title=\"{}\" width=\"{}\" height=\"{}\" src=\"{}\"></iframe>",
|
||||
self.id, classes, self.title, self.width, self.height, self.src
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct IframeBuilder {
|
||||
id: Option<String>,
|
||||
classes: Option<Vec<String>>,
|
||||
src: Option<String>,
|
||||
title: Option<String>,
|
||||
width: Option<usize>,
|
||||
height: Option<usize>,
|
||||
}
|
||||
|
||||
impl IframeBuilder {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
classes: None,
|
||||
src: None,
|
||||
title: None,
|
||||
width: None,
|
||||
height: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn id<T>(self, id: T) -> Self
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
Self {
|
||||
id: Some(id.into()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn classes<T>(self, classes: Vec<T>) -> Self
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
Self {
|
||||
classes: Some(classes.into_iter().map(|x| x.into()).collect()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn src<T>(self, src: T) -> Self
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
Self {
|
||||
src: Some(src.into()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn title<T>(self, title: T) -> Self
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
Self {
|
||||
title: Some(title.into()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn width(self, width: usize) -> Self {
|
||||
Self {
|
||||
width: Some(width),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn height(self, height: usize) -> Self {
|
||||
Self {
|
||||
height: Some(height),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build(self) -> Iframe {
|
||||
let id = self.id.unwrap_or(String::new());
|
||||
let classes = self.classes.unwrap_or(Vec::new());
|
||||
let title = self.title.unwrap_or(String::new());
|
||||
let width = self.width.unwrap_or(0);
|
||||
let height = self.height.unwrap_or(0);
|
||||
let src = self.src.unwrap_or(String::new());
|
||||
Iframe {
|
||||
id,
|
||||
title,
|
||||
classes,
|
||||
width,
|
||||
height,
|
||||
src,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,11 +163,27 @@ impl BasePageBuilder {
|
||||
.classes(vec!["name"])
|
||||
.text("AINDUSTRIES")
|
||||
.build();
|
||||
let home = Anchor::builder().id("home").classes(vec!["nav-button"]).href("/").text("Home").build();
|
||||
let projects = Anchor::builder().id("projects").classes(vec!["nav-button"]).href("/projects").text("Projects").build();
|
||||
let home = Anchor::builder()
|
||||
.id("home")
|
||||
.classes(vec!["nav-button"])
|
||||
.href("/")
|
||||
.text("Home")
|
||||
.build();
|
||||
let projects = Anchor::builder()
|
||||
.id("projects")
|
||||
.classes(vec!["nav-button"])
|
||||
.href("/projects")
|
||||
.text("Projects")
|
||||
.build();
|
||||
let music = Anchor::builder()
|
||||
.id("music")
|
||||
.classes(vec!["nav-button"])
|
||||
.href("/music")
|
||||
.text("Music")
|
||||
.build();
|
||||
let buttons = Division::builder()
|
||||
.classes(vec!["nav-buttons"])
|
||||
.elements(boxed_vec![home, projects])
|
||||
.elements(boxed_vec![home, projects, music])
|
||||
.build();
|
||||
let header = Division::builder()
|
||||
.id("header")
|
||||
|
||||
43
src/main.rs
43
src/main.rs
@@ -5,15 +5,19 @@ mod pages;
|
||||
use actix_web::{App, HttpServer, web};
|
||||
use clap::Parser;
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::process::exit;
|
||||
use toml::from_slice;
|
||||
|
||||
use crate::pages::files::robots;
|
||||
use middleware::MimeType;
|
||||
use pages::{
|
||||
files::file,
|
||||
index,
|
||||
ip::ip,
|
||||
music::music,
|
||||
projects::{project, projects},
|
||||
};
|
||||
|
||||
@@ -32,6 +36,7 @@ struct Cli {
|
||||
#[derive(Deserialize)]
|
||||
struct Config {
|
||||
server: ServerConfig,
|
||||
database: DatabaseConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -40,8 +45,17 @@ struct ServerConfig {
|
||||
bind: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DatabaseConfig {
|
||||
address: String,
|
||||
user: String,
|
||||
password: String,
|
||||
database_name: String,
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
assets: String,
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
@@ -71,14 +85,35 @@ async fn main() {
|
||||
};
|
||||
let bind_address = config.server.bind;
|
||||
let assets = config.server.assets;
|
||||
let data = web::Data::new(AppState { assets });
|
||||
let database_address = format!(
|
||||
"postgres://{}:{}@{}/{}",
|
||||
config.database.user,
|
||||
config.database.password,
|
||||
config.database.address,
|
||||
config.database.database_name
|
||||
);
|
||||
let pool = match PgPool::connect(&database_address).await {
|
||||
Ok(pool) => pool,
|
||||
Err(e) => {
|
||||
eprintln!("Could not connect to database: {e}");
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
let assets = assets.replace("~", "/home/conta/Code/aindustries.be/assets");
|
||||
let data = web::Data::new(AppState { assets, pool });
|
||||
if let Ok(server) = HttpServer::new(move || {
|
||||
App::new().app_data(data.clone()).service(file).service(
|
||||
App::new()
|
||||
.app_data(data.clone())
|
||||
.service(file)
|
||||
.service(robots)
|
||||
.service(
|
||||
web::scope("")
|
||||
.wrap(MimeType::new("text/html"))
|
||||
.service(index)
|
||||
.service(projects)
|
||||
.service(project),
|
||||
.service(project)
|
||||
.service(music)
|
||||
.service(ip),
|
||||
)
|
||||
})
|
||||
.bind(bind_address)
|
||||
@@ -86,7 +121,7 @@ async fn main() {
|
||||
match server.run().await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("An error occurred: {e}");
|
||||
println!("An error occurred: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,10 @@ async fn file(file: Path<(String, String)>, data: web::Data<AppState>) -> impl R
|
||||
};
|
||||
HttpResponse::Ok().body(bytes)
|
||||
}
|
||||
|
||||
#[get("/robots.txt")]
|
||||
async fn robots() -> impl Responder {
|
||||
"User-agent: *\n\
|
||||
Disallow: /\n\
|
||||
Allow: /$"
|
||||
}
|
||||
|
||||
34
src/pages/ip.rs
Normal file
34
src/pages/ip.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use crate::html::elements::{Heading, Link};
|
||||
use crate::html::layouts::Division;
|
||||
use crate::html::pages::BasePage;
|
||||
use crate::html::{Render, boxed_vec};
|
||||
use actix_web::HttpRequest;
|
||||
use actix_web::{Responder, get};
|
||||
|
||||
#[get("/ip")]
|
||||
async fn ip(request: HttpRequest) -> impl Responder {
|
||||
let info = request.connection_info();
|
||||
let ip = info.realip_remote_addr();
|
||||
|
||||
let text = Heading::builder()
|
||||
.text(format!(
|
||||
"Your IP address is {}<br>Don't ask how I know.",
|
||||
ip.unwrap_or("?")
|
||||
))
|
||||
.build();
|
||||
let div = Division::builder()
|
||||
.classes(vec!["ip"])
|
||||
.elements(boxed_vec![text])
|
||||
.build();
|
||||
|
||||
let css = Link::builder()
|
||||
.rel("stylesheet")
|
||||
.href("/static/css/ip.css")
|
||||
.build();
|
||||
BasePage::builder()
|
||||
.title("Your IP address")
|
||||
.head(boxed_vec![css])
|
||||
.body(boxed_vec![div])
|
||||
.build()
|
||||
.render()
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
pub(crate) mod files;
|
||||
pub(crate) mod ip;
|
||||
pub(crate) mod music;
|
||||
pub(crate) mod projects;
|
||||
|
||||
use crate::html::elements::{Heading, Link};
|
||||
|
||||
54
src/pages/music.rs
Normal file
54
src/pages/music.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use crate::html::layouts::DivisionBuilder;
|
||||
use crate::html::pages::BasePageBuilder;
|
||||
use crate::html::{Render, boxed_vec};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::html::elements::{HeadingBuilder, IframeBuilder, Link};
|
||||
use actix_web::web::Data;
|
||||
use actix_web::{Responder, get, web};
|
||||
use sqlx::query_as;
|
||||
|
||||
#[get("/music")]
|
||||
async fn music(state: Data<AppState>) -> impl Responder {
|
||||
let featured_title = HeadingBuilder::new()
|
||||
.level(1)
|
||||
.text("The song I've been listening to the most recently is \"Shiawase - VIP\" by Dion Timmer.")
|
||||
.build();
|
||||
let featured_iframe = IframeBuilder::new()
|
||||
.src("https://www.youtube.com/embed/suisIF4hwyw")
|
||||
.build();
|
||||
let featured = DivisionBuilder::new()
|
||||
.classes(vec!["featured"])
|
||||
.id("featured")
|
||||
.elements(boxed_vec![featured_title, featured_iframe])
|
||||
.build();
|
||||
|
||||
let playlist_title = HeadingBuilder::new()
|
||||
.text(
|
||||
"You can find below my primary playlist, \
|
||||
the one containing almost all the songs I have been listening to throughout the years.",
|
||||
)
|
||||
.build();
|
||||
let playlist_iframe = IframeBuilder::new()
|
||||
.src(
|
||||
"https://open.spotify.com/embed/playlist/\
|
||||
3owjyc1RILDGkgCmdCal39?utm_source=generator",
|
||||
)
|
||||
.build();
|
||||
let playlist = DivisionBuilder::new()
|
||||
.id("playlist")
|
||||
.classes(vec!["playlist"])
|
||||
.elements(boxed_vec![playlist_title, playlist_iframe])
|
||||
.build();
|
||||
|
||||
let css = Link::builder()
|
||||
.rel("stylesheet")
|
||||
.href("/static/css/music.css")
|
||||
.build();
|
||||
let page = BasePageBuilder::new()
|
||||
.title("Music")
|
||||
.head(boxed_vec![css])
|
||||
.body(boxed_vec![featured, playlist])
|
||||
.build();
|
||||
page.render()
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
use crate::AppState;
|
||||
use crate::html::elements::{Anchor, Heading, Link, Paragraph};
|
||||
use crate::html::layouts::Division;
|
||||
use crate::html::pages::BasePage;
|
||||
use crate::html::{Render, boxed_vec};
|
||||
use actix_web::web::Data;
|
||||
use actix_web::{Responder, get, web};
|
||||
use serde::Deserialize;
|
||||
use sqlx::query_as;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Project {
|
||||
id: String,
|
||||
title: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[get("/projects")]
|
||||
async fn projects() -> impl Responder {
|
||||
async fn projects(app_state: Data<AppState>) -> impl Responder {
|
||||
let title = Heading::builder().text("My projects").build();
|
||||
let desc = Heading::builder()
|
||||
.level(2)
|
||||
@@ -14,33 +25,64 @@ async fn projects() -> impl Responder {
|
||||
(I've done a lot of small projects but they are not worth it.)",
|
||||
)
|
||||
.build();
|
||||
let website_title = Heading::builder().text("Website").build();
|
||||
let website_desc = Paragraph::builder()
|
||||
let projects = match query_as!(Project, "SELECT id, title, description FROM Projects")
|
||||
.fetch_all(&app_state.pool)
|
||||
.await
|
||||
{
|
||||
Ok(projects) => projects,
|
||||
Err(_) => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
let items: Vec<Box<dyn Render>> = projects
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let title = Heading::builder().text(p.title).build();
|
||||
let description = Paragraph::builder()
|
||||
.classes(vec!["project-desc"])
|
||||
.text("This project is the website you currently are on.")
|
||||
.text(p.description)
|
||||
.build();
|
||||
let view = Anchor::builder()
|
||||
.classes(vec!["project-view"])
|
||||
.href(format!("/projects/{}", p.id))
|
||||
.text("Learn More")
|
||||
.build();
|
||||
let view = Anchor::builder().classes(vec!["project-view"]).href("/projects/website").text("Learn More").build();
|
||||
let info = Division::builder()
|
||||
.classes(vec!["project-info"])
|
||||
.elements(boxed_vec![website_desc, view])
|
||||
.elements(boxed_vec![description, view])
|
||||
.build();
|
||||
let website = Division::builder()
|
||||
Box::new(
|
||||
Division::builder()
|
||||
.classes(vec!["project"])
|
||||
.elements(boxed_vec![website_title, info])
|
||||
.build();
|
||||
.elements(boxed_vec![title, info])
|
||||
.build(),
|
||||
) as Box<dyn Render>
|
||||
})
|
||||
.collect();
|
||||
let css = Link::builder()
|
||||
.rel("stylesheet")
|
||||
.href("/static/css/projects.css")
|
||||
.build();
|
||||
let mut body = boxed_vec![title, desc];
|
||||
body.extend(items);
|
||||
let page = BasePage::builder()
|
||||
.title("Projects")
|
||||
.head(boxed_vec![css])
|
||||
.body(boxed_vec![title, desc, website])
|
||||
.body(body)
|
||||
.build();
|
||||
page.render()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProjectDetail {
|
||||
id: String,
|
||||
title: String,
|
||||
git_url: String,
|
||||
git_title: String,
|
||||
description: String,
|
||||
}
|
||||
#[get("/projects/{project}")]
|
||||
async fn project(project: web::Path<String>) -> impl Responder {
|
||||
async fn project(project: web::Path<String>, app_state: Data<AppState>) -> impl Responder {
|
||||
let project = project.into_inner();
|
||||
let css = Link::builder()
|
||||
.rel("stylesheet")
|
||||
@@ -50,22 +92,35 @@ async fn project(project: web::Path<String>) -> impl Responder {
|
||||
.title(format!("Project-{}", project))
|
||||
.head(boxed_vec![css])
|
||||
.build();
|
||||
match project.as_str() {
|
||||
"website" => {
|
||||
let title = Heading::builder().text("Website").build();
|
||||
let gitea = Anchor::builder().href("https://git.aindustries.be/AINDUSTRIES/aindustries.be").text("aindustries-be").build();
|
||||
let desc = Paragraph::builder().classes(vec!["description"]).text(
|
||||
format!("This project, the website you are on, \
|
||||
is made in Rust such that all the pages are generated by code.<br>\
|
||||
That is that each html element is represented by a struct which implements the Render trait (as in render the element to html).<br>\
|
||||
As it is right now the system is not that impressive but I believe it can do amazing things in the futur.<br>\
|
||||
<br>\
|
||||
Wish to see more? Check out the gitea repository: {}", gitea.render()),
|
||||
).build();
|
||||
let project = match query_as!(
|
||||
ProjectDetail,
|
||||
"SELECT * FROM Project_Detail WHERE id = $1",
|
||||
project
|
||||
)
|
||||
.fetch_one(&app_state.pool)
|
||||
.await
|
||||
{
|
||||
Ok(project) => project,
|
||||
Err(error) => {
|
||||
// return not found page
|
||||
eprintln!("Error fetching project: {:?}", error);
|
||||
return page.render();
|
||||
}
|
||||
};
|
||||
let title = Heading::builder().text(project.title).build();
|
||||
let gitea = Anchor::builder()
|
||||
.href(project.git_url)
|
||||
.text(project.git_title)
|
||||
.build();
|
||||
let desc = Paragraph::builder()
|
||||
.classes(vec!["description"])
|
||||
.text(format!(
|
||||
"{}<br><br> Wish to see more? Check out the gitea repository: {}",
|
||||
project.description,
|
||||
gitea.render()
|
||||
))
|
||||
.build();
|
||||
page.append_element_to_body(title);
|
||||
page.append_element_to_body(desc);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
page.render()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user