demo list and page
2
.gitignore
vendored
|
|
@ -1,3 +1,5 @@
|
|||
/target
|
||||
result
|
||||
.direnv
|
||||
config.toml
|
||||
.env
|
||||
2912
Cargo.lock
generated
23
Cargo.toml
|
|
@ -3,6 +3,25 @@ name = "frontend"
|
|||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
|
||||
serde = { version = "1.0.159", features = ["derive"] }
|
||||
toml = "0.7.3"
|
||||
sqlx = { version = "0.6.3", features = ["postgres", "time", "runtime-tokio-rustls"] }
|
||||
thiserror = "1.0.40"
|
||||
tokio = { version = "1.27.0", features = ["full"] }
|
||||
config = { version = "0.13.3", features = ["toml"] }
|
||||
time = "0.3.20"
|
||||
maud = { version = "0.24.0", git = "https://github.com/lambda-fairy/maud", rev = "7233cda35eed7bba91c9c55564d65498067c3822", features = ["axum"] }
|
||||
axum = "0.6.12"
|
||||
hyper = "0.14.25"
|
||||
hyperlocal = "0.8.0"
|
||||
tower-http = { version = "0.4.0", features = ["trace"] }
|
||||
steamid-ng = "1.0.0"
|
||||
itertools = "0.10.5"
|
||||
|
||||
[build-dependencies]
|
||||
lightningcss = { version = "1.0.0-alpha.40", features = ["browserslist", "visitor"] }
|
||||
base64 = "0.21.0"
|
||||
urlencoding = "2.1.2"
|
||||
|
|
|
|||
98
build.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use lightningcss::bundler::{Bundler, FileProvider};
|
||||
use lightningcss::stylesheet::{MinifyOptions, ParserOptions, PrinterOptions};
|
||||
use lightningcss::targets::Browsers;
|
||||
use lightningcss::values::url::Url;
|
||||
use lightningcss::visit_types;
|
||||
use lightningcss::visitor::{Visit, VisitTypes, Visitor};
|
||||
use std::convert::Infallible;
|
||||
use std::env::var;
|
||||
use std::fs::{read, write};
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let out_dir = var("OUT_DIR").unwrap();
|
||||
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed=style");
|
||||
println!("cargo:rerun-if-changed=images");
|
||||
|
||||
write(format!("{out_dir}/style.css"), build_style()).expect("failed to write compiled style");
|
||||
}
|
||||
|
||||
pub fn build_style() -> String {
|
||||
// todo build time?
|
||||
let fs = FileProvider::new();
|
||||
let mut bundler = Bundler::new(
|
||||
&fs,
|
||||
None,
|
||||
ParserOptions {
|
||||
nesting: true,
|
||||
..ParserOptions::default()
|
||||
},
|
||||
);
|
||||
let mut stylesheet = bundler
|
||||
.bundle(Path::new("style/style.css"))
|
||||
.expect("failed to bundle css");
|
||||
let browsers =
|
||||
Browsers::from_browserslist(["last 2 versions"]).expect("failed to parse browserlist");
|
||||
stylesheet
|
||||
.minify(MinifyOptions {
|
||||
targets: browsers.clone(),
|
||||
..MinifyOptions::default()
|
||||
})
|
||||
.expect("failed to minify css");
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let minify = false;
|
||||
#[cfg(not(debug_assertions))]
|
||||
let minify = true;
|
||||
|
||||
stylesheet.visit(&mut InlineUrlVisitor).unwrap();
|
||||
|
||||
stylesheet
|
||||
.to_css(PrinterOptions {
|
||||
targets: browsers,
|
||||
minify,
|
||||
..PrinterOptions::default()
|
||||
})
|
||||
.expect("failed to output css")
|
||||
.code
|
||||
}
|
||||
|
||||
struct InlineUrlVisitor;
|
||||
|
||||
impl<'i> Visitor<'i> for InlineUrlVisitor {
|
||||
type Error = Infallible;
|
||||
|
||||
const TYPES: VisitTypes = visit_types!(URLS);
|
||||
|
||||
fn visit_url(&mut self, url: &mut Url<'i>) -> Result<(), Self::Error> {
|
||||
if let Some(path) = url.url.strip_prefix("inline://") {
|
||||
let content = read(path).unwrap_or_else(|e| {
|
||||
eprintln!("Failed to write inline file {path}: {e}");
|
||||
panic!("Failed to inline");
|
||||
});
|
||||
let (mime, encode) = guess_mime(path);
|
||||
|
||||
if encode {
|
||||
let encoded = STANDARD.encode(content);
|
||||
url.url = format!("data:{mime};base64,{encoded}").into();
|
||||
} else {
|
||||
let content = String::from_utf8(content).expect("invalid utf8");
|
||||
let encoded = urlencoding::encode(&content);
|
||||
url.url = format!("data:{mime},{encoded}").into();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn guess_mime(path: &str) -> (&'static str, bool) {
|
||||
match path.split('.').last().unwrap() {
|
||||
"svg" => ("image/svg+xml", false),
|
||||
"png" => ("image/png", true),
|
||||
ext => panic!("no mimetype known for {ext}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -20,13 +20,13 @@
|
|||
# `nix develop`
|
||||
devShell = pkgs.mkShell {
|
||||
nativeBuildInputs = with pkgs; [
|
||||
rustc
|
||||
cargo
|
||||
bacon
|
||||
cargo-edit
|
||||
cargo-outdated
|
||||
clippy
|
||||
cargo-audit
|
||||
cargo-watch
|
||||
];
|
||||
};
|
||||
});
|
||||
|
|
|
|||
1
images/charge.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 16.933333 16.933333"><g stroke="#fcfcfc" fill="none"><path d="M1.213 2.67C1.2 7.415 1.78 9.78 3.03 13.16l5.522 3.545 5.208-3.546c1.48-3.094 2.058-6.02 1.948-10.49L8.608.207z" stroke-width=".2908971"/><path d="M2.278 3.366c-.01 4.17.484 6.248 1.552 9.217l4.712 3.115 4.446-3.115c1.263-2.718 1.756-5.288 1.662-9.217L8.59 1.203z" stroke-width=".25185420000000003"/></g></svg>
|
||||
|
After Width: | Height: | Size: 448 B |
1
images/charge_blue.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 16.933333 16.933333"><g stroke="#fcfcfc" fill="#5b818f"><path d="M1.213 2.67C1.2 7.415 1.78 9.78 3.03 13.16l5.522 3.545 5.208-3.546c1.48-3.095 2.058-6.02 1.948-10.49L8.608.206z" stroke-width=".291"/><path d="M2.278 3.366c-.01 4.17.484 6.248 1.552 9.217l4.712 3.115 4.446-3.115c1.263-2.718 1.756-5.288 1.662-9.217L8.59 1.203z" stroke-width=".252"/></g></svg>
|
||||
|
After Width: | Height: | Size: 433 B |
1
images/charge_red.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 16.933333 16.933333"><g stroke="#fcfcfc" fill="#a75d50"><path d="M1.213 2.67C1.2 7.415 1.78 9.78 3.03 13.16l5.522 3.545 5.208-3.546c1.48-3.095 2.058-6.02 1.948-10.49L8.608.206z" stroke-width=".291"/><path d="M2.278 3.366c-.01 4.17.484 6.248 1.552 9.217l4.712 3.115 4.446-3.115c1.263-2.718 1.756-5.288 1.662-9.217L8.59 1.203z" stroke-width=".252"/></g></svg>
|
||||
|
After Width: | Height: | Size: 433 B |
BIN
images/classes.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
images/error.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
15
images/link.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="512px" height="512px" viewBox="0 0 512 512" enable-background="new 0 0 512 512" xml:space="preserve">
|
||||
<path fill="#010101" d="M459.654,233.373l-90.531,90.5c-49.969,50-131.031,50-181,0c-7.875-7.844-14.031-16.688-19.438-25.813
|
||||
l42.063-42.063c2-2.016,4.469-3.172,6.828-4.531c2.906,9.938,7.984,19.344,15.797,27.156c24.953,24.969,65.563,24.938,90.5,0
|
||||
l90.5-90.5c24.969-24.969,24.969-65.563,0-90.516c-24.938-24.953-65.531-24.953-90.5,0l-32.188,32.219
|
||||
c-26.109-10.172-54.25-12.906-81.641-8.891l68.578-68.578c50-49.984,131.031-49.984,181.031,0
|
||||
C509.623,102.342,509.623,183.389,459.654,233.373z M220.326,382.186l-32.203,32.219c-24.953,24.938-65.563,24.938-90.516,0
|
||||
c-24.953-24.969-24.953-65.563,0-90.531l90.516-90.5c24.969-24.969,65.547-24.969,90.5,0c7.797,7.797,12.875,17.203,15.813,27.125
|
||||
c2.375-1.375,4.813-2.5,6.813-4.5l42.063-42.047c-5.375-9.156-11.563-17.969-19.438-25.828c-49.969-49.984-131.031-49.984-181.016,0
|
||||
l-90.5,90.5c-49.984,50-49.984,131.031,0,181.031c49.984,49.969,131.031,49.969,181.016,0l68.594-68.594
|
||||
C274.561,395.092,246.42,392.342,220.326,382.186z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
49
images/link_white.svg
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
width="512px"
|
||||
height="512px"
|
||||
viewBox="0 0 512 512"
|
||||
enable-background="new 0 0 512 512"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="link_white.svg"
|
||||
inkscape:version="0.92.0 r"><metadata
|
||||
id="metadata9"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs7" /><sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1387"
|
||||
id="namedview5"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.4609375"
|
||||
inkscape:cx="2.1694915"
|
||||
inkscape:cy="256"
|
||||
inkscape:window-x="1920"
|
||||
inkscape:window-y="30"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="Layer_1" /><path
|
||||
fill="#010101"
|
||||
d="M459.654,233.373l-90.531,90.5c-49.969,50-131.031,50-181,0c-7.875-7.844-14.031-16.688-19.438-25.813 l42.063-42.063c2-2.016,4.469-3.172,6.828-4.531c2.906,9.938,7.984,19.344,15.797,27.156c24.953,24.969,65.563,24.938,90.5,0 l90.5-90.5c24.969-24.969,24.969-65.563,0-90.516c-24.938-24.953-65.531-24.953-90.5,0l-32.188,32.219 c-26.109-10.172-54.25-12.906-81.641-8.891l68.578-68.578c50-49.984,131.031-49.984,181.031,0 C509.623,102.342,509.623,183.389,459.654,233.373z M220.326,382.186l-32.203,32.219c-24.953,24.938-65.563,24.938-90.516,0 c-24.953-24.969-24.953-65.563,0-90.531l90.516-90.5c24.969-24.969,65.547-24.969,90.5,0c7.797,7.797,12.875,17.203,15.813,27.125 c2.375-1.375,4.813-2.5,6.813-4.5l42.063-42.047c-5.375-9.156-11.563-17.969-19.438-25.828c-49.969-49.984-131.031-49.984-181.016,0 l-90.5,90.5c-49.984,50-49.984,131.031,0,181.031c49.984,49.969,131.031,49.969,181.016,0l68.594-68.594 C274.561,395.092,246.42,392.342,220.326,382.186z"
|
||||
id="path2"
|
||||
style="fill:#ffffff;fill-opacity:1" /></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
BIN
images/logo.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
1
images/logo.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><path d="m379.63 205.57-120.25-17.1a59.61 59.61 0 0 0-31.18-41.52l17.1-120.31c76 19.19 138 91.9 134.32 179zm-231.62-32.95a59.6 59.6 0 0 1 41.5-31.18L206.63 21.2C121.4 17.3 47.44 78.23 27.7 155.5zm25.67 80.19a59.6 59.6 0 0 1-31.17-41.5L21.94 194.58c-1.27 85.54 57.35 158.8 134.63 178.54zm80.2-25.67a60.45 60.45 0 0 1-42 31.3l-16.72 120.15c89.36 1.27 159.2-57 178.93-134.32z" fill="#a12d15"/></svg>
|
||||
|
After Width: | Height: | Size: 461 B |
1
images/steam_login.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 233 233"><path d="M120.06 83L81.5 132.6a39 39 0 0 0-24 6.6l-79.2-32.6.6 49.3 61.4 26a40.4 40.4 0 0 0 80-10l52.2-36.4A55.6 55.6 0 0 0 224.77 80c0-29.4-22.8-52-52.76-52.04-30-.06-52 24.39-52 55zM108.6 185a31.2 31.2 0 0 1-56.9 1.6l18 7.4c34.8 11.8 44.6-31 17.7-42.4l-18.6-7.8a31 31 0 0 1 39.8 17.3 31.1 31.1 0 0 1 0 24m64-68.1a37 37 0 1 1 .1-74 37 37 0 1 1-.2 74" fill="#fff"/><circle cy="80" cx="173" r="28" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 478 B |
BIN
images/teleporter.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
48
src/config.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
use crate::Result;
|
||||
use config::{Environment, File};
|
||||
use serde::Deserialize;
|
||||
use sqlx::postgres::PgConnectOptions;
|
||||
use sqlx::PgPool;
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub listen: Listen,
|
||||
pub database: DbConfig,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &str) -> Result<Self> {
|
||||
let s = config::Config::builder()
|
||||
.add_source(File::with_name(path))
|
||||
.add_source(Environment::default().separator("_"))
|
||||
.build()?;
|
||||
|
||||
Ok(s.try_deserialize()?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DbConfig {
|
||||
hostname: String,
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl DbConfig {
|
||||
pub async fn connect(&self) -> Result<PgPool> {
|
||||
let opt = PgConnectOptions::new()
|
||||
.host(&self.hostname)
|
||||
.username(&self.username)
|
||||
.password(&self.password);
|
||||
Ok(PgPool::connect_with(opt).await?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Listen {
|
||||
Socket { path: PathBuf },
|
||||
Tcp { address: IpAddr, port: u16 },
|
||||
}
|
||||
296
src/data/demo.rs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
use crate::data::player::Player;
|
||||
use crate::data::steam_id::SteamId;
|
||||
use crate::Result;
|
||||
use maud::Render;
|
||||
use sqlx::{query_as, Executor, FromRow, Postgres};
|
||||
use std::fmt::Write;
|
||||
use time::format_description::well_known::Iso8601;
|
||||
use time::{OffsetDateTime, PrimitiveDateTime, UtcOffset};
|
||||
use tracing::instrument;
|
||||
|
||||
pub struct Demo {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub map: String,
|
||||
pub red: String,
|
||||
pub blu: String,
|
||||
pub uploader: i32,
|
||||
pub uploader_name: Option<String>,
|
||||
pub uploader_name_preferred: Option<String>,
|
||||
pub uploader_steam_id: Option<SteamId>,
|
||||
pub duration: i32,
|
||||
pub created_at: PrimitiveDateTime,
|
||||
pub score_red: i32,
|
||||
pub score_blue: i32,
|
||||
pub server: String,
|
||||
pub nick: String,
|
||||
pub player_count: i32,
|
||||
pub players: Vec<Player>,
|
||||
}
|
||||
|
||||
impl Demo {
|
||||
#[instrument(skip(connection))]
|
||||
pub async fn by_id(
|
||||
connection: impl Executor<'_, Database = Postgres> + Copy,
|
||||
id: u32,
|
||||
) -> Result<Option<Self>> {
|
||||
struct RawDemo {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub map: String,
|
||||
pub red: String,
|
||||
pub blu: String,
|
||||
pub uploader: i32,
|
||||
pub uploader_name: Option<String>,
|
||||
pub uploader_name_preferred: Option<String>,
|
||||
pub uploader_steam_id: Option<SteamId>,
|
||||
pub duration: i32,
|
||||
pub created_at: PrimitiveDateTime,
|
||||
pub score_red: i32,
|
||||
pub score_blue: i32,
|
||||
pub server: String,
|
||||
pub nick: String,
|
||||
pub player_count: i32,
|
||||
}
|
||||
|
||||
let Some(raw) = query_as!(
|
||||
RawDemo,
|
||||
r#"SELECT
|
||||
demos.id, demos.name, url, map, red, blu, uploader, duration, demos.created_at,
|
||||
"scoreRed" as score_red, "scoreBlue" as score_blue, server, nick,
|
||||
"playerCount" as player_count,
|
||||
users_named.name as uploader_name_preferred,
|
||||
users.steamid as "uploader_steam_id?: SteamId",
|
||||
users.name as "uploader_name?"
|
||||
FROM demos
|
||||
LEFT JOIN users_named ON uploader = users_named.id
|
||||
LEFT JOIN users ON uploader = users.id
|
||||
WHERE deleted_at IS NULL AND demos.id = $1"#,
|
||||
id as i32
|
||||
)
|
||||
.fetch_optional(connection)
|
||||
.await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let players = Player::for_demo(connection, id).await?;
|
||||
|
||||
Ok(Some(Demo {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
url: raw.url,
|
||||
map: raw.map,
|
||||
red: raw.red,
|
||||
blu: raw.blu,
|
||||
uploader: raw.uploader,
|
||||
uploader_name: raw.uploader_name,
|
||||
uploader_name_preferred: raw.uploader_name_preferred,
|
||||
uploader_steam_id: raw.uploader_steam_id,
|
||||
duration: raw.duration,
|
||||
created_at: raw.created_at,
|
||||
score_red: raw.score_red,
|
||||
score_blue: raw.score_blue,
|
||||
server: raw.server,
|
||||
nick: raw.nick,
|
||||
player_count: raw.player_count,
|
||||
players,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn uploader_steam_id(&self) -> &SteamId {
|
||||
self.uploader_steam_id.as_ref().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn date(&self) -> Date {
|
||||
Date(self.created_at)
|
||||
}
|
||||
|
||||
pub fn relative_date(&self) -> RelativeDate {
|
||||
RelativeDate(self.created_at)
|
||||
}
|
||||
|
||||
pub fn uploader_name(&self) -> &str {
|
||||
self.uploader_name_preferred
|
||||
.as_deref()
|
||||
.or(self.uploader_name.as_deref())
|
||||
.unwrap_or("unknown")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
pub struct ListDemo {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub map: String,
|
||||
pub red: String,
|
||||
pub blu: String,
|
||||
pub duration: i32,
|
||||
pub created_at: PrimitiveDateTime,
|
||||
pub server: String,
|
||||
pub player_count: i32,
|
||||
}
|
||||
|
||||
impl ListDemo {
|
||||
#[instrument(skip(connection))]
|
||||
pub async fn list(
|
||||
connection: impl Executor<'_, Database = Postgres>,
|
||||
before: Option<u32>,
|
||||
) -> Result<Vec<Self>> {
|
||||
if let Some(before) = before {
|
||||
Ok(query_as!(
|
||||
ListDemo,
|
||||
r#"SELECT
|
||||
id, name, map, red, blu, duration, created_at, server, "playerCount" as player_count
|
||||
FROM demos WHERE deleted_at IS NULL and id < $1 ORDER BY id DESC LIMIT 50"#,
|
||||
before as i32
|
||||
)
|
||||
.fetch_all(connection)
|
||||
.await?)
|
||||
} else {
|
||||
Ok(query_as!(
|
||||
ListDemo,
|
||||
r#"SELECT
|
||||
id, name, map, red, blu, duration, created_at, server, "playerCount" as player_count
|
||||
FROM demos WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 50"#
|
||||
)
|
||||
.fetch_all(connection)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn url(&self) -> DemoUrl {
|
||||
DemoUrl(self.id)
|
||||
}
|
||||
|
||||
pub fn format(&self) -> DemoFormat {
|
||||
DemoFormat {
|
||||
player_count: self.player_count,
|
||||
mode: MapMode::from_map(&self.map),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn duration(&self) -> Duration {
|
||||
Duration(self.duration)
|
||||
}
|
||||
|
||||
pub fn date(&self) -> Date {
|
||||
Date(self.created_at)
|
||||
}
|
||||
|
||||
pub fn relative_date(&self) -> RelativeDate {
|
||||
RelativeDate(self.created_at)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DemoUrl(i32);
|
||||
|
||||
impl Render for DemoUrl {
|
||||
fn render_to(&self, buffer: &mut String) {
|
||||
write!(buffer, "/{}", self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DemoFormat {
|
||||
player_count: i32,
|
||||
mode: MapMode,
|
||||
}
|
||||
|
||||
enum MapMode {
|
||||
Other,
|
||||
Bball,
|
||||
Ultiduo,
|
||||
}
|
||||
|
||||
impl MapMode {
|
||||
fn from_map(map: &str) -> Self {
|
||||
if map.contains("bball") || map.contains("ballin") {
|
||||
Self::Bball
|
||||
} else if map.contains("ultiduo") {
|
||||
Self::Ultiduo
|
||||
} else {
|
||||
Self::Other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for DemoFormat {
|
||||
fn render_to(&self, buffer: &mut String) {
|
||||
let name = match self.mode {
|
||||
MapMode::Ultiduo => "Ultiduo",
|
||||
MapMode::Bball => "BBall",
|
||||
MapMode::Other => match self.player_count {
|
||||
17 | 18 | 19 => "HL",
|
||||
15 | 14 => "Prolander",
|
||||
13 | 12 | 11 => "6v6",
|
||||
7 | 8 | 9 => "4v4",
|
||||
_ => "Other",
|
||||
},
|
||||
};
|
||||
write!(buffer, "{name}").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Duration(i32);
|
||||
|
||||
impl Render for Duration {
|
||||
fn render_to(&self, buffer: &mut String) {
|
||||
if self.0 < 1 {
|
||||
write!(buffer, "0:00").unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
let hours = self.0 / 3600;
|
||||
let minutes = (self.0 - (hours * 3600)) / 60;
|
||||
let seconds = self.0 - (hours * 3600) - (minutes * 60);
|
||||
|
||||
if hours == 0 {
|
||||
write!(buffer, "{minutes:02}:{seconds:02}").unwrap();
|
||||
} else {
|
||||
write!(buffer, "{hours:02}:{minutes:02}:{seconds:02}").unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Date(PrimitiveDateTime);
|
||||
|
||||
impl Render for Date {
|
||||
fn render_to(&self, buffer: &mut String) {
|
||||
buffer.push_str(
|
||||
&self
|
||||
.0
|
||||
.assume_offset(UtcOffset::UTC)
|
||||
.format(&Iso8601::DEFAULT)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RelativeDate(PrimitiveDateTime);
|
||||
|
||||
impl Render for RelativeDate {
|
||||
fn render_to(&self, buffer: &mut String) {
|
||||
let date = self.0.assume_offset(UtcOffset::UTC);
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let elapsed = now - date;
|
||||
|
||||
if elapsed.is_positive() {
|
||||
if elapsed.whole_minutes() < 1 {
|
||||
write!(buffer, "seconds ago").unwrap();
|
||||
} else if elapsed.whole_hours() < 1 {
|
||||
write!(buffer, "{} minutes ago", elapsed.whole_minutes()).unwrap();
|
||||
} else if elapsed.whole_days() < 1 {
|
||||
write!(buffer, "{} hours ago", elapsed.whole_hours()).unwrap();
|
||||
} else if elapsed.whole_days() < 32 {
|
||||
write!(buffer, "{} days ago", elapsed.whole_days()).unwrap();
|
||||
} else if elapsed.whole_days() < 365 {
|
||||
write!(buffer, "{} days ago", elapsed.whole_days() / 30).unwrap();
|
||||
} else {
|
||||
write!(buffer, "{} years go", elapsed.whole_days() / 365).unwrap();
|
||||
}
|
||||
} else {
|
||||
write!(buffer, "now").unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/data/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod demo;
|
||||
pub mod player;
|
||||
pub mod steam_id;
|
||||
89
src/data/player.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use crate::data::steam_id::SteamId;
|
||||
use crate::Result;
|
||||
use maud::Render;
|
||||
use sqlx::{query_as, Executor, FromRow, Postgres};
|
||||
use tracing::instrument;
|
||||
|
||||
#[derive(sqlx::Type, Debug, Eq, PartialEq, Copy, Clone)]
|
||||
#[sqlx(rename_all = "lowercase")]
|
||||
pub enum Team {
|
||||
Red,
|
||||
Blue,
|
||||
Other,
|
||||
Spectator,
|
||||
}
|
||||
|
||||
#[derive(sqlx::Type, Debug, Ord, PartialOrd, Eq, PartialEq, Copy, Clone)]
|
||||
#[sqlx(rename_all = "lowercase")]
|
||||
pub enum Class {
|
||||
Scout,
|
||||
Soldier,
|
||||
Pyro,
|
||||
Demoman,
|
||||
HeavyWeapons,
|
||||
Engineer,
|
||||
Medic,
|
||||
Sniper,
|
||||
Spy,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Class {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Class::Scout => "scout",
|
||||
Class::Soldier => "soldier",
|
||||
Class::Pyro => "pyro",
|
||||
Class::Demoman => "demoman",
|
||||
Class::HeavyWeapons => "heavyweapons",
|
||||
Class::Engineer => "engineer",
|
||||
Class::Medic => "medic",
|
||||
Class::Sniper => "sniper",
|
||||
Class::Spy => "spy",
|
||||
Class::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Class {
|
||||
fn render_to(&self, buffer: &mut String) {
|
||||
buffer.push_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
pub struct Player {
|
||||
pub id: i32,
|
||||
pub steam_id: SteamId,
|
||||
pub name: String,
|
||||
pub team: Team,
|
||||
pub class: Class,
|
||||
pub kills: Option<i32>,
|
||||
pub deaths: Option<i32>,
|
||||
pub assists: Option<i32>,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
#[instrument(skip(connection))]
|
||||
pub async fn for_demo(
|
||||
connection: impl Executor<'_, Database = Postgres>,
|
||||
id: u32,
|
||||
) -> Result<Vec<Player>> {
|
||||
let mut players = query_as!(
|
||||
Player,
|
||||
r#"SELECT
|
||||
max(players.id) as "id!", max(players.name) as "name!", max(team) as "team!: Team", max(class) as "class!: Class",
|
||||
max(kills) as "kills", max(deaths) as "deaths", max(assists) as "assists", max(steamid) as "steam_id!: SteamId"
|
||||
FROM players
|
||||
INNER JOIN users ON user_id = users.id
|
||||
WHERE demo_id = $1
|
||||
GROUP BY user_id"#,
|
||||
id as i32
|
||||
)
|
||||
.fetch_all(connection)
|
||||
.await?;
|
||||
|
||||
players.sort_by_key(|player| player.class);
|
||||
Ok(players)
|
||||
}
|
||||
}
|
||||
145
src/data/steam_id.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
use maud::Render;
|
||||
use serde::{Serialize, Serializer};
|
||||
use sqlx::database::HasValueRef;
|
||||
use sqlx::error::BoxDynError;
|
||||
use sqlx::{Database, Decode, Type};
|
||||
use std::borrow::Cow;
|
||||
use std::fmt::Write;
|
||||
use std::fmt::{Debug, Formatter};
|
||||
use steamid_ng::SteamID;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub enum SteamId {
|
||||
Id(u64),
|
||||
Raw(Cow<'static, str>),
|
||||
}
|
||||
|
||||
const UNKNOWN_STEAM_ID: SteamId = SteamId::Raw(Cow::Borrowed("unknown"));
|
||||
|
||||
impl Default for SteamId {
|
||||
fn default() -> Self {
|
||||
UNKNOWN_STEAM_ID
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for &SteamId {
|
||||
fn default() -> Self {
|
||||
&UNKNOWN_STEAM_ID
|
||||
}
|
||||
}
|
||||
|
||||
impl SteamId {
|
||||
pub const fn new(id: u64) -> SteamId {
|
||||
SteamId::Id(id)
|
||||
}
|
||||
|
||||
pub fn steam3(&self) -> String {
|
||||
match self {
|
||||
SteamId::Id(id) => SteamID::from(*id).steam3(),
|
||||
SteamId::Raw(raw) => raw.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn steam2(&self) -> String {
|
||||
match self {
|
||||
SteamId::Id(id) => SteamID::from(*id).steam2(),
|
||||
SteamId::Raw(raw) => raw.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_steam3(s: &str) -> Result<Self, steamid_ng::SteamIDError> {
|
||||
let id = SteamID::from_steam3(s)?;
|
||||
Ok(SteamId::Id(id.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for SteamId {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SteamId::Id(id) => SteamID::from(*id).fmt(f),
|
||||
SteamId::Raw(raw) => raw.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for SteamId {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match self {
|
||||
SteamId::Id(id) => serializer.collect_str(&SteamID::from(*id).steam3()),
|
||||
SteamId::Raw(raw) => serializer.collect_str(raw),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<DB: Database> Type<DB> for SteamId
|
||||
where
|
||||
i64: Type<DB>,
|
||||
str: Type<DB>,
|
||||
{
|
||||
fn type_info() -> DB::TypeInfo {
|
||||
<str as Type<DB>>::type_info()
|
||||
}
|
||||
|
||||
fn compatible(ty: &DB::TypeInfo) -> bool {
|
||||
<str as Type<DB>>::compatible(ty)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r, DB> Decode<'r, DB> for SteamId
|
||||
where
|
||||
DB: Database,
|
||||
&'r str: Decode<'r, DB>,
|
||||
{
|
||||
fn decode(value: <DB as HasValueRef<'r>>::ValueRef) -> Result<Self, BoxDynError> {
|
||||
let str = <&str as Decode<DB>>::decode(value)?;
|
||||
if let Ok(id) = str.parse() {
|
||||
Ok(Self::Id(id))
|
||||
} else if str == "serveme.tf" {
|
||||
Ok(Self::Raw("serveme.tf".into()))
|
||||
} else if str == "essentialstf" {
|
||||
Ok(Self::Raw("essentialstf".into()))
|
||||
} else {
|
||||
Ok(Self::Raw(str.to_string().into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SteamId {
|
||||
fn render_to(&self, buffer: &mut String) {
|
||||
match self {
|
||||
SteamId::Id(id) => write!(buffer, "{id}").unwrap(),
|
||||
SteamId::Raw(raw) => buffer.push_str(raw),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProfileLink<'a>(&'a SteamId);
|
||||
|
||||
impl Render for ProfileLink<'_> {
|
||||
fn render_to(&self, buffer: &mut String) {
|
||||
buffer.push_str("/profiles/");
|
||||
self.0.render_to(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UploadsLink<'a>(&'a SteamId);
|
||||
|
||||
impl Render for UploadsLink<'_> {
|
||||
fn render_to(&self, buffer: &mut String) {
|
||||
buffer.push_str("/uploads/");
|
||||
self.0.render_to(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
impl SteamId {
|
||||
pub fn uploads_link(&self) -> UploadsLink {
|
||||
UploadsLink(self)
|
||||
}
|
||||
|
||||
pub fn profile_link(&self) -> ProfileLink {
|
||||
ProfileLink(self)
|
||||
}
|
||||
}
|
||||
23
src/error.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
use axum::response::{IntoResponse, Response};
|
||||
use config::ConfigError;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Sqlx(#[from] sqlx::Error),
|
||||
#[error(transparent)]
|
||||
Config(#[from] ConfigError),
|
||||
#[error(transparent)]
|
||||
Hyper(#[from] hyper::Error),
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("page not found")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> Response {
|
||||
dbg!(self);
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
120
src/main.rs
|
|
@ -1,3 +1,119 @@
|
|||
fn main() {
|
||||
println!("Hello, world!");
|
||||
mod config;
|
||||
mod data;
|
||||
mod error;
|
||||
mod pages;
|
||||
|
||||
pub use crate::config::Config;
|
||||
use crate::config::Listen;
|
||||
use crate::data::demo::{Demo, ListDemo};
|
||||
use crate::pages::demo::DemoPage;
|
||||
use crate::pages::index::Index;
|
||||
use crate::pages::render;
|
||||
use axum::extract::{MatchedPath, Path};
|
||||
use axum::http::{HeaderValue, Request};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{extract::State, routing::get, Router, Server};
|
||||
pub use error::Error;
|
||||
use hyper::header::CONTENT_TYPE;
|
||||
use hyperlocal::UnixServerExt;
|
||||
use maud::Markup;
|
||||
use sqlx::PgPool;
|
||||
use std::env::{args, var};
|
||||
use std::fs::{remove_file, set_permissions, Permissions};
|
||||
use std::net::SocketAddr;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::Arc;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::{info, info_span};
|
||||
use tracing_subscriber::{
|
||||
fmt::layer, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer,
|
||||
};
|
||||
|
||||
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||
|
||||
struct App {
|
||||
connection: PgPool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(layer().with_filter(EnvFilter::new(
|
||||
var("RUST_LOG").unwrap_or_else(|_| "warn,frontend=info".into()),
|
||||
)))
|
||||
.try_init()
|
||||
.expect("Failed to init tracing");
|
||||
|
||||
let config = args().skip(1).next().expect("no config file provided");
|
||||
let config = Config::load(&config)?;
|
||||
let connection = config.database.connect().await?;
|
||||
|
||||
let state = Arc::new(App { connection });
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/style.css", get(style))
|
||||
.route("/:id", get(demo))
|
||||
.layer(
|
||||
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
|
||||
let matched_path = request
|
||||
.extensions()
|
||||
.get::<MatchedPath>()
|
||||
.map(MatchedPath::as_str);
|
||||
|
||||
info_span!(
|
||||
"http_request",
|
||||
method = ?request.method(),
|
||||
matched_path,
|
||||
some_other_field = tracing::field::Empty,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.fallback(handler_404)
|
||||
.with_state(state);
|
||||
let service = app.into_make_service();
|
||||
|
||||
match config.listen {
|
||||
Listen::Tcp { address, port } => {
|
||||
let addr = SocketAddr::from((address, port));
|
||||
info!("listening on {}", addr);
|
||||
Server::bind(&addr).serve(service).await?;
|
||||
}
|
||||
Listen::Socket { path } => {
|
||||
info!("listening on {}", path.display());
|
||||
if path.exists() {
|
||||
remove_file(&path)?;
|
||||
}
|
||||
let socket = Server::bind_unix(&path)?;
|
||||
set_permissions(&path, Permissions::from_mode(0o666))?;
|
||||
|
||||
socket.serve(service).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn style() -> impl IntoResponse {
|
||||
let style = include_str!(concat!(env!("OUT_DIR"), "/style.css"));
|
||||
(
|
||||
[(CONTENT_TYPE, HeaderValue::from_static("text/css"))],
|
||||
style,
|
||||
)
|
||||
}
|
||||
|
||||
async fn index(State(app): State<Arc<App>>) -> Result<Markup> {
|
||||
let demos = ListDemo::list(&app.connection, None).await?;
|
||||
Ok(render(Index { demos }))
|
||||
}
|
||||
|
||||
async fn demo(State(app): State<Arc<App>>, Path(id): Path<u32>) -> Result<Markup> {
|
||||
let demo = Demo::by_id(&app.connection, id)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
Ok(render(DemoPage { demo }))
|
||||
}
|
||||
|
||||
async fn handler_404() -> impl IntoResponse {
|
||||
Error::NotFound
|
||||
}
|
||||
|
|
|
|||
96
src/pages/demo.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
use crate::data::demo::Demo;
|
||||
use crate::data::player::{Player, Team};
|
||||
use crate::pages::Page;
|
||||
use itertools::{EitherOrBoth, Itertools};
|
||||
use maud::{html, Markup};
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub struct DemoPage {
|
||||
pub demo: Demo,
|
||||
}
|
||||
|
||||
impl Page for DemoPage {
|
||||
fn title(&self) -> Cow<'static, str> {
|
||||
format!("{} - demos.tf", self.demo.name).into()
|
||||
}
|
||||
|
||||
fn render(&self) -> Markup {
|
||||
html! {
|
||||
h2 { (self.demo.server) " - " (self.demo.red) " vs " (self.demo.blu) }
|
||||
h3 { (self.demo.name) }
|
||||
p {
|
||||
"Demo uploaded by "
|
||||
a href = (self.demo.uploader_steam_id().uploads_link()) { (self.demo.uploader_name()) }
|
||||
" "
|
||||
span title = (self.demo.date()) { (self.demo.relative_date()) }
|
||||
}
|
||||
.teams {
|
||||
.red {
|
||||
span.name { (self.demo.red) }
|
||||
span.score { (self.demo.score_red) }
|
||||
}
|
||||
.blue {
|
||||
span.name { (self.demo.blu) }
|
||||
span.score { (self.demo.score_blue) }
|
||||
}
|
||||
.clearfix {}
|
||||
}
|
||||
table.players {
|
||||
thead {
|
||||
th.team.red {}
|
||||
th.name.red { "Name" }
|
||||
th.stat.red { "K" }
|
||||
th.stat.red { "A" }
|
||||
th.stat.red { "D" }
|
||||
th.class {}
|
||||
th.class {}
|
||||
th.stat.blue { "D" }
|
||||
th.stat.blue { "A" }
|
||||
th.stat.blue { "K" }
|
||||
th.name.blue { "Name" }
|
||||
th.team.blue {}
|
||||
}
|
||||
tbody {
|
||||
@for player_pair in player_pairs(&self.demo.players) {
|
||||
tr {
|
||||
td.team.red {}
|
||||
@if let Some(player) = player_pair.as_ref().left() {
|
||||
td.name.red { (player.name) }
|
||||
td.stat.red { (player.kills.unwrap_or_default()) }
|
||||
td.stat.red { (player.assists.unwrap_or_default()) }
|
||||
td.stat.red { (player.deaths.unwrap_or_default()) }
|
||||
td.class.red.(player.class) {}
|
||||
} @else {
|
||||
td.name.red {}
|
||||
td.stat.red {}
|
||||
td.stat.red {}
|
||||
td.stat.red {}
|
||||
td.class {}
|
||||
}
|
||||
@if let Some(player) = player_pair.as_ref().right() {
|
||||
td.class.blue.(player.class) {}
|
||||
td.stat.blue { (player.deaths.unwrap_or_default()) }
|
||||
td.stat.blue { (player.assists.unwrap_or_default()) }
|
||||
td.stat.blue { (player.kills.unwrap_or_default()) }
|
||||
td.name.blue { (player.name) }
|
||||
} @else {
|
||||
td.class {}
|
||||
td.stat.blue {}
|
||||
td.stat.blue {}
|
||||
td.stat.blue {}
|
||||
td.name.blue {}
|
||||
}
|
||||
td.team.blue {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn player_pairs(players: &[Player]) -> impl IntoIterator<Item = EitherOrBoth<&Player, &Player>> {
|
||||
let red = players.iter().filter(|player| player.team == Team::Red);
|
||||
let blue = players.iter().filter(|player| player.team == Team::Blue);
|
||||
red.zip_longest(blue)
|
||||
}
|
||||
44
src/pages/index.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
use crate::data::demo::ListDemo;
|
||||
use crate::pages::Page;
|
||||
use maud::{html, Markup};
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub struct Index {
|
||||
pub demos: Vec<ListDemo>,
|
||||
}
|
||||
|
||||
impl Page for Index {
|
||||
fn title(&self) -> Cow<'static, str> {
|
||||
"Demos - demos.tf".into()
|
||||
}
|
||||
|
||||
fn render(&self) -> Markup {
|
||||
html! {
|
||||
h1 { "Demos" }
|
||||
table.demolist {
|
||||
thead {
|
||||
tr {
|
||||
th .title { "Title" }
|
||||
th .format { "Format" }
|
||||
th .map { "Map" }
|
||||
th .duration { "Duration" }
|
||||
th .date { "Date" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
@for demo in &self.demos {
|
||||
tr {
|
||||
td .title {
|
||||
a href = (demo.url()) { (demo.server) " - " (demo.red) " vs " (demo.blu) }
|
||||
}
|
||||
td .format { (demo.format()) }
|
||||
td .map { (demo.map) }
|
||||
td .duration { (demo.duration()) }
|
||||
td .date title = (demo.date()) { (demo.relative_date()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/pages/mod.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
pub mod demo;
|
||||
pub mod index;
|
||||
|
||||
use maud::{html, Markup, DOCTYPE};
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub trait Page {
|
||||
fn title(&self) -> Cow<'static, str>;
|
||||
fn render(&self) -> Markup;
|
||||
}
|
||||
|
||||
pub fn render<T: Page>(page: T) -> Markup {
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
html {
|
||||
head {
|
||||
title { (page.title()) }
|
||||
link rel="stylesheet" type="text/css" href="/style.css";
|
||||
}
|
||||
body {
|
||||
header {
|
||||
span .main {
|
||||
a href = "/" { "demos.tf" }
|
||||
}
|
||||
span { a href = "/about" { "about" } }
|
||||
span { a href = "/viewer" { "viewer" } }
|
||||
span.beta { a href = "/editor" { "editor" } }
|
||||
span.right { a.steam-login href = "/login" { "Sign in through Steam" } }
|
||||
}
|
||||
.page { (page.render()) }
|
||||
}
|
||||
footer {
|
||||
"©"
|
||||
a href = "https://steamcommunity.com/id/icewind1991" { "Icewind" }
|
||||
" 2017."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
style/footer.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
footer {
|
||||
margin: 100px -31px -100px;
|
||||
text-align: center;
|
||||
line-height: 35px;
|
||||
vertical-align: middle;
|
||||
padding: 10px 0;
|
||||
color: white;
|
||||
background-color: var(--secondary-color);
|
||||
|
||||
& a, & a:visited {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
@media (min-width: 1700px) {
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
right: 15px;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
98
style/header.css
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
header {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
background-color: var(--primary-color);
|
||||
border-bottom: 1px solid rgb(183, 183, 183);
|
||||
display: block;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
text-transform: uppercase;
|
||||
|
||||
& a, & a:visited {
|
||||
color: var(--text-header);
|
||||
cursor: pointer;
|
||||
padding: .5em 1em;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.main a {
|
||||
background: url('inline://images/logo.svg') no-repeat 0;
|
||||
background-size: 30px;
|
||||
padding-left: 35px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
& > span {
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
padding: 0;
|
||||
float: left;
|
||||
max-width: 220px;
|
||||
position: relative;
|
||||
|
||||
&.beta {
|
||||
margin-right: 20px;
|
||||
|
||||
&:after {
|
||||
content: 'beta';
|
||||
color: var(--highlight-primary);
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
right: -15px;
|
||||
top: 2px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > ul {
|
||||
top: 0;
|
||||
float: right;
|
||||
}
|
||||
|
||||
@media (max-width: 550px) {
|
||||
.main a {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 650px) {
|
||||
.upload {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.steam-login:before {
|
||||
content: '';
|
||||
box-shadow: 1px 0 0 rgba(0, 0, 0, 0.05);
|
||||
height: inherit;
|
||||
width: 41px;
|
||||
background-size: 30px 30px;
|
||||
background: rgba(255, 255, 255, 0.08) url('inline://images/steam_login.svg') no-repeat 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
a.steam-login {
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
height: 41px;
|
||||
margin: -1px 0;
|
||||
background-color: #5A8E27;
|
||||
color: white;
|
||||
text-shadow: 0 -1px rgba(0, 0, 0, 0.25);
|
||||
line-height: 22px;
|
||||
position: relative;
|
||||
padding-left: 50px;
|
||||
}
|
||||
85
style/pages/class-icons.css
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
table.players {
|
||||
& .red.class, & .blue.class {
|
||||
background-origin: content-box;
|
||||
background-clip: content-box;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url('inline://images/classes.png');
|
||||
}
|
||||
|
||||
& .red.class {
|
||||
&.spy {
|
||||
background-position: -0px -0px;
|
||||
}
|
||||
|
||||
&.demoman {
|
||||
background-position: -20px -0px;
|
||||
}
|
||||
|
||||
&.soldier {
|
||||
background-position: -40px -0px;
|
||||
}
|
||||
|
||||
&.medic {
|
||||
background-position: -0px -20px;
|
||||
}
|
||||
|
||||
&.pyro {
|
||||
background-position: -20px -20px;
|
||||
}
|
||||
|
||||
&.sniper {
|
||||
background-position: -40px -20px;
|
||||
}
|
||||
|
||||
&.engineer {
|
||||
background-position: -0px -40px;
|
||||
}
|
||||
|
||||
&.heavyweapons {
|
||||
background-position: -20px -40px;
|
||||
}
|
||||
|
||||
&.scout {
|
||||
background-position: -40px -40px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
& .blue.class {
|
||||
&.spy {
|
||||
background-position: -10px -0px;
|
||||
}
|
||||
|
||||
&.demoman {
|
||||
background-position: -30px -0px;
|
||||
}
|
||||
|
||||
&.soldier {
|
||||
background-position: -50px -0px;
|
||||
}
|
||||
|
||||
&.medic {
|
||||
background-position: -10px -20px;
|
||||
}
|
||||
|
||||
&.pyro {
|
||||
background-position: -30px -20px;
|
||||
}
|
||||
|
||||
&.sniper {
|
||||
background-position: -50px -20px;
|
||||
}
|
||||
|
||||
&.engineer {
|
||||
background-position: -10px -40px;
|
||||
}
|
||||
|
||||
&.heavyweapons {
|
||||
background-position: -30px -40px;
|
||||
}
|
||||
|
||||
&.scout {
|
||||
background-position: -50px -40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
218
style/pages/demo.css
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
@import 'class-icons.css';
|
||||
|
||||
table.chat, table.players {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table.players {
|
||||
& td, & th {
|
||||
padding: 5px 9px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& tr:nth-child(odd) {
|
||||
background-color: var(--primary-color-accent);
|
||||
}
|
||||
|
||||
& tr:nth-child(even) {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
& th {
|
||||
padding: 7px 9px;
|
||||
}
|
||||
}
|
||||
|
||||
p.demo-info .time {
|
||||
float: right;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
p.demo-download {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
p.demo-download button, p.demo-download a {
|
||||
margin-right: 40px;
|
||||
}
|
||||
|
||||
@media (max-width: 450px) {
|
||||
p.demo-download button, p.demo-download a {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
p.demo-download button {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
div.teams {
|
||||
margin: 0 -30px;
|
||||
padding: 10px 0 0;
|
||||
height: 72px;
|
||||
color: white;
|
||||
|
||||
& div {
|
||||
display: inline-block;
|
||||
padding: 10px 30px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
& span {
|
||||
font-family: Arial, sans-serif;
|
||||
font-weight: bold;
|
||||
font-size: 45px;
|
||||
}
|
||||
|
||||
& > div {
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
& .red {
|
||||
padding-right: 15px;
|
||||
float: left;
|
||||
background: #a75d50;
|
||||
}
|
||||
|
||||
& .red span.name,
|
||||
& .blue span.name {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
max-width: calc(100% - 30px);
|
||||
}
|
||||
|
||||
& .red .score {
|
||||
float: right;
|
||||
}
|
||||
|
||||
& .blue {
|
||||
padding-left: 15px;
|
||||
float: right;
|
||||
background: #5b818f;
|
||||
}
|
||||
|
||||
& .blue .name {
|
||||
float: right;
|
||||
color: '#fff';
|
||||
}
|
||||
}
|
||||
|
||||
.playerTable {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.playerTable th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
table.players {
|
||||
width: 100%;
|
||||
|
||||
& .name {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
& .name a {
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
& .name a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
& .team {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
& .team.red {
|
||||
background: #a75d50;
|
||||
}
|
||||
|
||||
& .team.blue {
|
||||
background: #5b818f;
|
||||
}
|
||||
|
||||
& .stat {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
& .stat {
|
||||
width: 40px;
|
||||
vertical-align: top;
|
||||
}
|
||||
& .class {
|
||||
width: 10px;
|
||||
padding: 7px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
& .red.class {
|
||||
padding-right: 0;
|
||||
}
|
||||
& .blue.class {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
& .blue.name {
|
||||
text-align: right;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
& .red.name {
|
||||
text-align: left;
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
& th {
|
||||
border-bottom: solid 1px var(--secondary-color-accent);
|
||||
}
|
||||
|
||||
& th.team {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
& tr:last-child td.red {
|
||||
border-bottom: 1px solid #a75d50;
|
||||
}
|
||||
|
||||
& tr:last-child td.blue {
|
||||
border-bottom: 1px solid #5b818f;
|
||||
}
|
||||
|
||||
& tr:last-child td.class {
|
||||
border-bottom: 1px solid #666;
|
||||
}
|
||||
|
||||
& td.pov {
|
||||
background-color: var(--secondary-color-accent);
|
||||
}
|
||||
|
||||
& .demo-info {
|
||||
margin: 20px 0;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& .demo-info .time {
|
||||
float: right;
|
||||
}
|
||||
|
||||
@media (max-width: 650px) {
|
||||
.stat {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
& .highlight-red .red:not(.team) {
|
||||
background-color: #a75d5066;
|
||||
}
|
||||
|
||||
& .highlight-blue .blue:not(.team) {
|
||||
background-color: #5b818f66;
|
||||
}
|
||||
}
|
||||
95
style/pages/index.css
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
.demolist {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-collapse: collapse;
|
||||
|
||||
& tbody {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
& th {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& td, & th {
|
||||
display: table-cell;
|
||||
padding: 9px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& td.title {
|
||||
max-width: 0px;
|
||||
}
|
||||
|
||||
& td.date {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
& td.duration {
|
||||
width: 90px;
|
||||
max-width: 90px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
& td.format {
|
||||
width: 75px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
& td.map {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
& tr:nth-child(even) {
|
||||
background-color: var(--primary-color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.listType {
|
||||
line-height: 62px;
|
||||
|
||||
& > span {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.deleted-demo {
|
||||
background-color: rgba(255, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
margin: -10px -30px 0;
|
||||
padding: 20px;
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media (max-width: 950px) {
|
||||
.demolist .format {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 850px) {
|
||||
.demolist .duration {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 750px) {
|
||||
.demolist .date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 550px) {
|
||||
.demolist .map {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
262
style/style.css
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
@import 'header.css';
|
||||
@import 'footer.css';
|
||||
|
||||
@import 'pages/index.css';
|
||||
@import 'pages/demo.css';
|
||||
|
||||
:root {
|
||||
--primary-color: white;
|
||||
--primary-color-accent: #f4f4f4;
|
||||
--secondary-color: #444;
|
||||
--secondary-color-accent: #333;
|
||||
--text-primary: black;
|
||||
--text-secondary: grey;
|
||||
--highlight-primary: #3e95e6;
|
||||
--highlight-secondary: #daecfa;
|
||||
--button-primary: #0078e7;
|
||||
--button-secondary: #e6e6e6;
|
||||
--button-critical: rgb(202, 60, 60);
|
||||
--text-header: grey;
|
||||
--link-color: #0071b8;
|
||||
--link-color-visited: #004c8b;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--primary-color: rgb(39, 39, 39);
|
||||
--primary-color-accent: #1c1c1c;
|
||||
--secondary-color: #5a5a5a;
|
||||
--secondary-color-accent: #444;
|
||||
--text-primary: #dcdcdc;
|
||||
--text-secondary: rgb(169, 169, 169);
|
||||
--highlight-primary: #0078e7;
|
||||
--highlight-secondary: #448fbe;
|
||||
--button-primary: #2568ae;
|
||||
--button-secondary: #626262;
|
||||
--text-header: #efefef;
|
||||
--link-color: #0093ed;
|
||||
--link-color-visited: #0063ff;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body, html {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: "Raleway", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page {
|
||||
background-color: var(--primary-color);
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
max-width: 1100px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 40px 30px 100px;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
body > div > section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
section:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
section {
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
section > * {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
section > div.subsection {
|
||||
margin-bottom: 20px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-weight: normal;
|
||||
font-family: "Raleway", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
h2 {
|
||||
line-height: 34px;
|
||||
font-size: 40px;
|
||||
text-align: center;
|
||||
padding: 40px 40px 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
line-height: 24px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.clearfix {
|
||||
width: 0 !important;
|
||||
padding: 0 !important;;
|
||||
margin: 0 !important;;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: var(--link-color-visited);
|
||||
}
|
||||
|
||||
label + div.Select,
|
||||
label + select {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 -30px;
|
||||
padding: 20px 30px;
|
||||
line-height: 50px;
|
||||
font-size: 45px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
padding: 10px 10px 10px 19px;
|
||||
margin: 20px 0 20px;
|
||||
font-size: 13px;
|
||||
line-height: 1.42857143;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
word-wrap: break-word;
|
||||
background-color: var(--primary-color-accent);
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.pure-button {
|
||||
border-radius: 0;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.pure-button-primary, .pure-button-selected, a.pure-button-primary, a.pure-button-selected {
|
||||
background-color: var(--button-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pure-button {
|
||||
display: inline-block;
|
||||
zoom: 1;
|
||||
line-height: normal;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
-webkit-user-drag: none;
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
padding: .5em 1em;
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
background-color: #E6E6E6;
|
||||
text-decoration: none;
|
||||
min-width: 150px;
|
||||
|
||||
&.pure-button-tertiary {
|
||||
background-color: transparent;
|
||||
text-decoration: underline;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.pure-button:hover {
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#1a000000', endColorstr='#1a000000', GradientType=0);
|
||||
background-image: -webkit-gradient(linear, 0 0, 0 100%, from(rgba(0, 0, 0, 0.1)), to(rgba(0, 0, 0, 0.1)));
|
||||
background-image: -webkit-linear-gradient(rgba(0, 0, 0, 0.1) 40%, rgba(0, 0, 0, 0.1));
|
||||
background-image: linear-gradient(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.button-delete {
|
||||
background: var(--button-critical);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.button-pause {
|
||||
background: rgb(223, 117, 20);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.button-fa:before {
|
||||
float: left;
|
||||
font-size: 18px;
|
||||
margin: 0 -19px 0 -5px;
|
||||
padding: 0 0.8em 0 0;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
kbd, pre, samp {
|
||||
font-family: monospace, monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.noscript {
|
||||
position: fixed;
|
||||
top: 20%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: var(--primary-color);
|
||||
font-size: 150%;
|
||||
}
|
||||
|
||||
.react-spinner {
|
||||
margin: 250px 0;
|
||||
width: 250px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.react-spinner_bar {
|
||||
background-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: inline-block;
|
||||
height: 64px;
|
||||
margin-left: -30px;
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
p.page-note {
|
||||
border-bottom: 1px solid #ddd;
|
||||
margin: -10px -30px 50px;
|
||||
padding: 15px 30px;
|
||||
background-color: var(--primary-color-accent);
|
||||
line-height: 32px;
|
||||
font-size: 120%;
|
||||
}
|
||||