feat: improvements and ui stuff

also fixed some errors (and added `Unknown Album`s)
This commit is contained in:
Lys 2023-10-14 21:21:21 +03:00
parent 4a746a3371
commit ace6ec1683
Signed by: lyssieth
GPG key ID: C9CF3D614FAA3940
11 changed files with 528 additions and 80 deletions

View file

@ -184,8 +184,6 @@ async fn handle_entry(
debug!("Inserted track {:?}", track.id); debug!("Inserted track {:?}", track.id);
// TODO: figure out how to scan. steal from Gonic if we have to :3
Ok(()) Ok(())
} }
@ -194,6 +192,8 @@ mod mp3;
#[instrument(skip(tx))] #[instrument(skip(tx))]
async fn find_or_create_genre(tx: &DatabaseTransaction, name: &str) -> Result<i64, Report> { async fn find_or_create_genre(tx: &DatabaseTransaction, name: &str) -> Result<i64, Report> {
let name = name.replace('\0', ""); // remove null bytes. they're not allowed :3
let name = name.trim();
debug!("Finding genre with name {name}"); debug!("Finding genre with name {name}");
let res = Genre::find() let res = Genre::find()
.filter(genre::Column::Name.eq(name)) .filter(genre::Column::Name.eq(name))
@ -448,3 +448,7 @@ const fn mime_type_to_ext(mime: MimeType) -> &'static str {
MimeType::Gif => "gif", MimeType::Gif => "gif",
} }
} }
pub async fn clear_errors() {
STATUS.write().await.errors.clear();
}

View file

@ -1,6 +1,6 @@
use std::borrow::Cow;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::{borrow::Cow, result::Result};
use audiotags::{AudioTagEdit, FlacTag, MimeType, Tag}; use audiotags::{AudioTagEdit, FlacTag, MimeType, Tag};
use color_eyre::Report; use color_eyre::Report;
@ -28,7 +28,9 @@ pub async fn handle(
let meta = { File::open(&path).await?.metadata().await? }; let meta = { File::open(&path).await?.metadata().await? };
let tag = { let tag = {
let tag = Tag::default().read_from_path(&path)?; let tag = Tag::default()
.read_from_path(&path)
.map_err(|e| Report::msg("check ID3 tags for invalid encodings").wrap_err(e))?;
FlacTag::from(tag) FlacTag::from(tag)
}; };
@ -37,6 +39,15 @@ pub async fn handle(
let album = find_album(tx, artist.as_ref().map(|c| c.id), &tag, state.clone()).await?; let album = find_album(tx, artist.as_ref().map(|c| c.id), &tag, state.clone()).await?;
if let Some(track) = Track::find()
.filter(track::Column::Path.eq(path.to_string_lossy()))
.one(tx)
.await
.map_err(|v| Report::msg("error searching for track").wrap_err(v))?
{
return Ok(track); // early exit if we already have this track. need to do an update check though :/
}
let mut am = track::ActiveModel::new(); let mut am = track::ActiveModel::new();
am.title = Set(tag.title().unwrap_or(&stem).to_string()); am.title = Set(tag.title().unwrap_or(&stem).to_string());
@ -81,7 +92,9 @@ pub async fn handle(
}; };
if let Some(data) = data { if let Some(data) = data {
let cover_art = super::find_or_create_cover_art(tx, data, cover_art.mime_type).await?; let cover_art = super::find_or_create_cover_art(tx, data, cover_art.mime_type)
.await
.map_err(|v| Report::msg("error getting cover art").wrap_err(v))?;
am.cover_art_id = Set(Some(cover_art.id)); am.cover_art_id = Set(Some(cover_art.id));
} }
} }
@ -91,7 +104,10 @@ pub async fn handle(
.try_into() .try_into()
.expect("Failed to convert meta len to i64")); .expect("Failed to convert meta len to i64"));
let model = Track::insert(am).exec_with_returning(tx).await?; let model = Track::insert(am)
.exec_with_returning(tx)
.await
.map_err(|v| Report::msg("couldn't add track").wrap_err(v))?;
Ok(model) Ok(model)
} }
@ -103,9 +119,11 @@ async fn find_album(
tag: &FlacTag, tag: &FlacTag,
state: Arc<RwLock<ScanState>>, state: Arc<RwLock<ScanState>>,
) -> Result<album::Model, Report> { ) -> Result<album::Model, Report> {
let Some(album) = tag.album() else { let album = tag.album().unwrap_or(audiotags::types::Album {
return Err(Report::msg("Couldn't get album name from tag")); title: "Unknown Album",
}; artist: None,
cover: None,
});
// if not, search by name // if not, search by name
let search = Album::find() let search = Album::find()
@ -128,8 +146,23 @@ async fn find_album(
let genre = tag.genre(); let genre = tag.genre();
if let Some(genre) = genre { if let Some(genre) = genre {
let genre_id = super::find_or_create_genre(tx, genre).await?; if genre.contains('\0') {
am.genre_ids = Set(Some(vec![genre_id])); let genre_ids = genre
.split('\0')
.map(|genre| super::find_or_create_genre(tx, genre))
.collect::<Vec<_>>();
let genre_ids = futures::future::join_all(genre_ids).await;
let genre_ids = genre_ids
.into_iter()
.filter_map(Result::ok)
.collect::<Vec<_>>();
am.genre_ids = Set(Some(genre_ids));
} else {
let genre_id = super::find_or_create_genre(tx, genre).await?;
am.genre_ids = Set(Some(vec![genre_id]));
}
} }
am.song_count = Set(tag am.song_count = Set(tag
.total_tracks() .total_tracks()

View file

@ -28,7 +28,9 @@ pub async fn handle(
let meta = { File::open(&path).await?.metadata().await? }; let meta = { File::open(&path).await?.metadata().await? };
let tag = { let tag = {
let tag = Tag::default().read_from_path(&path)?; let tag = Tag::default()
.read_from_path(&path)
.map_err(|e| Report::msg("check ID3 tags for invalid encodings").wrap_err(e))?;
audiotags::Id3v2Tag::from(tag) audiotags::Id3v2Tag::from(tag)
}; };
@ -37,6 +39,15 @@ pub async fn handle(
let album = find_album(tx, artist.as_ref().map(|c| c.id), &tag, state.clone()).await?; let album = find_album(tx, artist.as_ref().map(|c| c.id), &tag, state.clone()).await?;
if let Some(track) = Track::find()
.filter(track::Column::Path.eq(path.to_string_lossy()))
.one(tx)
.await
.map_err(|v| Report::msg("error searching for track").wrap_err(v))?
{
return Ok(track); // early exit if we already have this track. need to do an update check though :/
}
let mut am = track::ActiveModel::new(); let mut am = track::ActiveModel::new();
am.title = Set(tag.title().unwrap_or(&stem).to_string()); am.title = Set(tag.title().unwrap_or(&stem).to_string());
@ -81,7 +92,9 @@ pub async fn handle(
}; };
if let Some(data) = data { if let Some(data) = data {
let cover_art = super::find_or_create_cover_art(tx, data, cover_art.mime_type).await?; let cover_art = super::find_or_create_cover_art(tx, data, cover_art.mime_type)
.await
.map_err(|v| Report::msg("error getting cover art").wrap_err(v))?;
am.cover_art_id = Set(Some(cover_art.id)); am.cover_art_id = Set(Some(cover_art.id));
} }
} }
@ -91,7 +104,10 @@ pub async fn handle(
.try_into() .try_into()
.expect("Failed to convert meta len to i64")); .expect("Failed to convert meta len to i64"));
let model = Track::insert(am).exec_with_returning(tx).await?; let model = Track::insert(am)
.exec_with_returning(tx)
.await
.map_err(|v| Report::msg("couldn't add track").wrap_err(v))?;
Ok(model) Ok(model)
} }
@ -103,9 +119,11 @@ async fn find_album(
tag: &Id3v2Tag, tag: &Id3v2Tag,
state: Arc<RwLock<ScanState>>, state: Arc<RwLock<ScanState>>,
) -> Result<album::Model, Report> { ) -> Result<album::Model, Report> {
let Some(album) = tag.album() else { let album = tag.album().unwrap_or(audiotags::types::Album {
return Err(Report::msg("Couldn't get album name from tag")); title: "Unknown Album",
}; artist: None,
cover: None,
});
// if not, search by name // if not, search by name
let search = Album::find() let search = Album::find()
@ -128,8 +146,23 @@ async fn find_album(
let genre = tag.genre(); let genre = tag.genre();
if let Some(genre) = genre { if let Some(genre) = genre {
let genre_id = super::find_or_create_genre(tx, genre).await?; if genre.contains('\0') {
am.genre_ids = Set(Some(vec![genre_id])); let genre_ids = genre
.split('\0')
.map(|genre| super::find_or_create_genre(tx, genre))
.collect::<Vec<_>>();
let genre_ids = futures::future::join_all(genre_ids).await;
let genre_ids = genre_ids
.into_iter()
.filter_map(Result::ok)
.collect::<Vec<_>>();
am.genre_ids = Set(Some(genre_ids));
} else {
let genre_id = super::find_or_create_genre(tx, genre).await?;
am.genre_ids = Set(Some(vec![genre_id]));
}
} }
am.song_count = Set(tag am.song_count = Set(tag
.total_tracks() .total_tracks()

View file

@ -1,20 +1,102 @@
use crate::utils::db::DbTxn; use crate::{
scan::{get_scan_status, ScanStatus},
utils::db::DbTxn,
};
use entities::{ use entities::{
prelude::{User, UserSession}, prelude::{User, UserSession},
user::Model as UserModel, user::Model as UserModel,
user_session,
}; };
use nate::Nate; use nate::Nate;
use poem::{ use poem::{
http::{header, StatusCode}, http::{header, StatusCode},
session::Session, session::Session,
web::Data, web::Data,
Response, Endpoint, EndpointExt, Response, Route,
}; };
use sea_orm::{EntityTrait, ModelTrait}; use sea_orm::{EntityTrait, ModelTrait};
use tracing::error; use tracing::error;
mod create_user;
mod delete_user;
mod scan;
pub fn build() -> Box<(dyn Endpoint<Output = Response> + 'static)> {
Route::new()
.at("/", dashboard)
.at("/create_user", create_user::create_user)
.at("/delete_user", delete_user::delete_user)
.at("/scan", scan::scan)
.boxed()
}
#[poem::handler] #[poem::handler]
pub async fn dashboard(Data(txn): Data<&DbTxn>, cookie: &Session) -> Response { pub async fn dashboard(Data(txn): Data<&DbTxn>, cookie: &Session) -> Response {
let (_, user) = match get_session(txn, cookie).await {
Ok((session, user)) => (session, user),
Err(response) => return response,
};
let message = cookie.get::<String>("message");
cookie.remove("message");
if user.is_admin {
let users = User::find().all(&**txn).await;
let users = match users {
Ok(users) => users,
Err(e) => {
error!("Failed to find users: {e}");
cookie.set("message", "Internal server error occurred. Sorry!");
return Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "/dashboard")
.finish();
}
};
let scan_status = get_scan_status().await;
let scan = match scan_status {
Ok(scan) => scan,
Err(e) => {
error!("Failed to get scan status: {e}");
cookie.set("message", "Internal server error occurred. Sorry!");
return Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "/dashboard")
.finish();
}
};
Response::builder().status(StatusCode::OK).body(
Dashboard {
username: user.name,
message,
admin: Some(Admin { users, scan }),
}
.to_string(),
)
} else {
Response::builder().status(StatusCode::OK).body(
Dashboard {
username: user.name,
message,
admin: None,
}
.to_string(),
)
}
}
async fn get_session(
txn: &DbTxn,
cookie: &Session,
) -> Result<(user_session::Model, UserModel), Response> {
let session_token = cookie.get::<String>("session_token"); let session_token = cookie.get::<String>("session_token");
if session_token.is_none() { if session_token.is_none() {
@ -22,10 +104,10 @@ pub async fn dashboard(Data(txn): Data<&DbTxn>, cookie: &Session) -> Response {
cookie.set("message", "Please log in."); cookie.set("message", "Please log in.");
return Response::builder() return Err(Response::builder()
.status(StatusCode::FOUND) .status(StatusCode::FOUND)
.header(header::LOCATION, "/login") .header(header::LOCATION, "/login")
.finish(); .finish());
} }
let session_token = session_token.expect("Failed to get session token"); let session_token = session_token.expect("Failed to get session token");
@ -35,8 +117,8 @@ pub async fn dashboard(Data(txn): Data<&DbTxn>, cookie: &Session) -> Response {
.one(&**txn) .one(&**txn)
.await; .await;
let (_, user) = match session { match session {
Ok(Some((session, Some(user)))) => (session, user), Ok(Some((session, Some(user)))) => Ok((session, user)),
Ok(Some((session, None))) => { Ok(Some((session, None))) => {
cookie.clear(); cookie.clear();
@ -44,20 +126,20 @@ pub async fn dashboard(Data(txn): Data<&DbTxn>, cookie: &Session) -> Response {
cookie.set("message", "Invalid session. Please log in again."); cookie.set("message", "Invalid session. Please log in again.");
return Response::builder() Err(Response::builder()
.status(StatusCode::FOUND) .status(StatusCode::FOUND)
.header(header::LOCATION, "/login") .header(header::LOCATION, "/login")
.finish(); .finish())
} }
Ok(None) => { Ok(None) => {
cookie.clear(); cookie.clear();
cookie.set("message", "Invalid session. Please log in again."); cookie.set("message", "Invalid session. Please log in again.");
return Response::builder() Err(Response::builder()
.status(StatusCode::FOUND) .status(StatusCode::FOUND)
.header(header::LOCATION, "/login") .header(header::LOCATION, "/login")
.finish(); .finish())
} }
Err(e) => { Err(e) => {
@ -70,50 +152,11 @@ pub async fn dashboard(Data(txn): Data<&DbTxn>, cookie: &Session) -> Response {
"Internal server error occurred. Sorry! Please log in again.", "Internal server error occurred. Sorry! Please log in again.",
); );
return Response::builder() Err(Response::builder()
.status(StatusCode::FOUND) .status(StatusCode::FOUND)
.header(header::LOCATION, "/login") .header(header::LOCATION, "/login")
.finish(); .finish())
} }
};
if user.is_admin {
let users = User::find().all(&**txn).await;
let users = match users {
Ok(users) => users,
Err(e) => {
error!("Failed to find users: {e}");
cookie.set(
"message",
"Internal server error occurred. Sorry! Please log in again.",
);
return Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "/dashboard")
.finish();
}
};
Response::builder().status(StatusCode::OK).body(
Dashboard {
username: user.name,
message: cookie.get::<String>("message"),
admin: Some(Admin { users }),
}
.to_string(),
)
} else {
Response::builder().status(StatusCode::OK).body(
Dashboard {
username: user.name,
message: cookie.get::<String>("message"),
admin: None,
}
.to_string(),
)
} }
} }
@ -128,4 +171,5 @@ pub struct Dashboard {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Admin { pub struct Admin {
pub users: Vec<UserModel>, pub users: Vec<UserModel>,
pub scan: ScanStatus,
} }

View file

@ -0,0 +1,101 @@
use entities::{prelude::User, user};
use poem::{
http::{header, StatusCode},
session::Session,
web::{Data, Form},
Response,
};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set};
use serde::Deserialize;
use tracing::{error, warn};
use crate::{ui::dashboard::get_session, utils::db::DbTxn};
#[poem::handler]
pub async fn create_user(
Data(txn): Data<&DbTxn>,
cookie: &Session,
Form(user_params): Form<CreateUser>,
) -> Response {
let (_, user) = match get_session(txn, cookie).await {
Ok((session, user)) => (session, user),
Err(response) => return response,
};
if !user.is_admin {
cookie.set("message", "ERROR: You are not an admin.");
return Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "/dashboard")
.finish();
}
let existing = User::find()
.filter(user::Column::Name.eq(&user_params.username))
.one(&**txn)
.await;
match existing {
Ok(None) => {}
Ok(Some(u)) => {
warn!(
"Found existing user {} while trying to add a new user {}",
u.name, user_params.username
);
cookie.set("message", "ERROR: User already exists.");
return Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "/dashboard")
.finish();
}
Err(e) => {
error!("Failed to find user: {e}");
cookie.set(
"message",
"ERROR: Internal error while checking for existing user.",
);
return Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "/dashboard")
.finish();
}
};
let new_user = user::ActiveModel {
name: Set(user_params.username),
password: Set(user_params.password),
is_admin: Set(false),
..Default::default()
};
let new_user = User::insert(new_user).exec(&**txn).await;
match new_user {
Ok(_) => {
cookie.set("message", "User created successfully.");
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "/dashboard")
.finish()
}
Err(e) => {
error!("Failed to create user: {e}");
cookie.set("message", "ERROR: Internal error while creating user.");
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "/dashboard")
.finish()
}
}
}
#[derive(Debug, Deserialize)]
pub struct CreateUser {
pub username: String,
pub password: String,
}

View file

@ -0,0 +1,92 @@
use entities::{prelude::User, user};
use poem::{
http::{header, StatusCode},
session::Session,
web::{Data, Query},
Response,
};
use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter};
use serde::Deserialize;
use tracing::{error, warn};
use crate::{ui::dashboard::get_session, utils::db::DbTxn};
#[poem::handler]
pub async fn delete_user(
Data(txn): Data<&DbTxn>,
cookie: &Session,
Query(user_params): Query<DeleteUser>,
) -> Response {
let (_, user) = match get_session(txn, cookie).await {
Ok((session, user)) => (session, user),
Err(response) => return response,
};
if !user.is_admin {
cookie.set("message", "ERROR: You are not an admin.");
return Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "/dashboard")
.finish();
}
let existing = User::find()
.filter(user::Column::Name.eq(&user_params.username))
.one(&**txn)
.await;
let u = match existing {
Ok(None) => {
warn!(
"Found no user while trying to delete a user {}",
user_params.username
);
cookie.set("message", "ERROR: User does not exist.");
return Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "/dashboard")
.finish();
}
Ok(Some(u)) => u,
Err(e) => {
error!("Failed to find user: {e}");
cookie.set(
"message",
"ERROR: Internal error while checking for existing user.",
);
return Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "/dashboard")
.finish();
}
};
let res = u.delete(&**txn).await;
match res {
Ok(_) => {
cookie.set("message", "Successfully deleted user.");
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "/dashboard")
.finish()
}
Err(e) => {
error!("Failed to delete user: {e}");
cookie.set("message", "ERROR: Internal error while deleting user.");
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "/dashboard")
.finish()
}
}
}
#[derive(Debug, Deserialize)]
pub struct DeleteUser {
pub username: String,
}

View file

@ -0,0 +1,45 @@
use poem::{
http::{header, StatusCode, Uri},
session::Session,
web::Data,
Response,
};
use crate::{ui::dashboard::get_session, utils::db::DbTxn};
#[poem::handler]
pub async fn scan(Data(txn): Data<&DbTxn>, cookie: &Session, uri: &Uri) -> Response {
let (_, user) = match get_session(txn, cookie).await {
Ok((session, user)) => (session, user),
Err(response) => return response,
};
if user.is_admin {
if let Some(query) = uri.query() {
match query {
"start" => {
crate::scan::start_scan().await;
cookie.set("message", "Scan started!");
}
"clearErrors" => {
crate::scan::clear_errors().await;
cookie.set("message", "Errors cleared!");
}
_ => {
cookie.set("message", format!("Unknown command: {query}"));
}
}
} else {
cookie.set("message", "No command provided");
}
} else {
cookie.set("message", "You are not an admin!");
}
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, "/dashboard")
.finish()
}

View file

@ -18,6 +18,8 @@ use tracing::{debug, error};
#[poem::handler] #[poem::handler]
pub async fn login_ui(Data(txn): Data<&DbTxn>, cookie: &Session) -> Response { pub async fn login_ui(Data(txn): Data<&DbTxn>, cookie: &Session) -> Response {
let message = cookie.get::<String>("message"); let message = cookie.get::<String>("message");
cookie.remove("message");
let Some(token) = cookie.get::<String>("session_token") else { let Some(token) = cookie.get::<String>("session_token") else {
return Response::builder() return Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)

View file

@ -23,7 +23,7 @@ pub fn build() -> Box<dyn Endpoint<Output = poem::Response>> {
.at("/", index::index) .at("/", index::index)
.at("/login", get(login::login_ui).post(login::login)) .at("/login", get(login::login_ui).post(login::login))
.at("/logout", get(logout::logout)) .at("/logout", get(logout::logout))
.at("/dashboard", dashboard::dashboard) .nest("/dashboard", dashboard::build())
.nest("/css", StaticFilesEndpoint::new(path)) .nest("/css", StaticFilesEndpoint::new(path))
.with(CookieSession::new( .with(CookieSession::new(
CookieConfig::new().name(RAVE_COOKIE_NAME), CookieConfig::new().name(RAVE_COOKIE_NAME),

View file

@ -5,6 +5,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Rave | Dashboard</title> <title>Rave | Dashboard</title>
<link rel="stylesheet" href="css/dashboard.css"> <link rel="stylesheet" href="css/dashboard.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
@ -21,15 +25,58 @@
<div id="admin"> <div id="admin">
<h2>Admin Stuff</h2> <h2>Admin Stuff</h2>
<p>This is where admin-facing stuff is. I do know.</p> <p>This is where admin-facing stuff is. I do know.</p>
<div id="add-users"> <div id="admin-users">
<p>Users:</p> <h3>Users:</h3>
<ul> <ul>
{%- for user in &admin.users {-%} {%- for user in &admin.users {-%}
<li class="user"> <li class="list-item">
<p>{{ user.name }} - {{ user.is_admin }}</p> <span>{{ user.name }} - {{ if user.is_admin { "Admin" } else { "User" } }}</span>
<span>{%- if
user.is_admin{-%}
<span class="user-disabled" title="You cannot delete an admin">Delete</span>
{%-} else {-%}
<a class="user-delete" title="Deletes the user"
href="/dashboard/delete_user?username={{ user.name }}">Delete</a>
{%-}-%}</span>
</li> </li>
{%- } -%} {%- } -%}
</ul> </ul>
<p>Add a user:</p>
<div id="admin-add-user" class="row">
<form method="post" action="/dashboard/create_user">
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<input type="submit" value="Add User">
</form>
</div>
</div>
<hr>
<div id="admin-scan">
<h3>Scan for new files:</h3>
<div class="row"><a class="admin-scan" href="/dashboard/scan?start">Scan</a>
<span>&mdash;</span>
<a class="admin-scan" href="/dashboard">Refresh</a>
<span>&mdash;</span>
<a class="admin-scan" href="/dashboard/scan?clearErrors">Clear Errors</a>
</div>
<div id="admin-scan-results">
<p>Scanning: {{ admin.scan.scanning }}</p>
<p>Scanned: {{ admin.scan.count }}</p>
{%- if !admin.scan.errors.is_empty() {-%}
<p>Errors:</p>
<ul>
{%- for error in &admin.scan.errors {-%}
<li class="list-item">
<span>{{ error.additional }}: </span>
{%- for cause in error.report.chain() { -%}
<code>{{ cause }} &mdash; </code>
{%- } -%}
</li>
{%- } -%}
</ul>
{%-}-%}
</div>
</div> </div>
</div> </div>
{%- } -%} {%- } -%}

View file

@ -1,11 +1,13 @@
body { body {
background-color: #0a0a0a; background-color: #070707;
color: #f0f0f0; color: #f0f0f0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
font-family: 'Inter', sans-serif;
} }
h2 { h2 {
@ -14,28 +16,40 @@ h2 {
#main { #main {
border: 2px solid #707070; border: 2px solid #707070;
padding: 4px;
} }
#admin { #admin {
border: 2px solid #700070; border: 2px solid #700070;
padding: 4px;
} }
a { a {
color: #002ae0; color: #005ae0;
text-decoration: none text-decoration: none
} }
a:hover { a:hover {
color: #002ae0; color: #005ae0;
text-decoration: underline text-decoration: underline
} }
a:visited { a:visited {
color: #002ae0; color: #005ae0;
text-decoration: none; text-decoration: none;
} }
li.user { ul {
padding-left: 0;
margin-left: 5px;
margin-right: 5px;
display: flex;
flex-direction: column;
list-style: none;
}
li.list-item {
list-style: none;
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
@ -43,3 +57,36 @@ li.user {
padding: 5px; padding: 5px;
border-bottom: 1px solid #707070; border-bottom: 1px solid #707070;
} }
li.list-item>p {
margin-top: 5px;
margin-bottom: 5px;
}
#admin-add-user {
padding: 5px;
border-bottom: 1px solid #707070;
}
input {
background-color: #0a0a0a;
color: #f0f0f0;
border: 2px solid #707070;
}
a.user-delete {
color: #c00000;
text-decoration: none;
}
span.user-disabled {
color: grey;
text-decoration: 1px underline grey;
}
.row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}