Create web skeleton, including Tailwind for CSS and hot reloading of CSS

and templates. Tihs also adds an ADR for using Tailwind.

Add a pile of dependencies

setup orm, add admin tool

admin tool does random pass if none provided

add tons of css stuff

finish up web skeleton
This commit is contained in:
Nicole Tietz-Sokolskaya 2024-03-16 11:45:32 -04:00
parent 77d4ebb371
commit 3acafda0d3
43 changed files with 6617 additions and 118 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
DATABASE_URL=sqlite:./pique.db?mode=rwc

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
target/
node_modules/
*.db
*.xml
.env

1422
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,31 @@
workspace = { members = ["_experiments/2024-03-02-database-benchmark"] }
[package]
name = "pique"
version = "0.1.0"
edition = "2021"
default-run = "pique"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.81"
argon2 = { version = "0.5.3", features = ["rand", "std"] }
async-trait = "0.1.78"
axum = "0.7.4"
axum-htmx = { version = "0.5.0", features = ["guards", "serde"] }
axum-login = "0.14.0"
clap = { version = "4.5.3", features = ["derive", "env"] }
dotenvy = "0.15.7"
env_logger = "0.11.3"
minijinja = { version = "1.0.14", features = ["loader"] }
minijinja-autoreload = "1.0.14"
rand = "0.8.5"
sea-orm = { version = "0.12.15", features = ["sqlx-sqlite", "macros", "runtime-tokio-rustls"] }
serde = { version = "1.0.197", features = ["derive"] }
thiserror = "1.0.58"
tokio = { version = "1.36.0", features = ["rt", "full"] }
tower-http = { version = "0.5.2", features = ["fs", "trace"] }
tower-sessions = "0.11.1"
tower-sessions-moka-store = "0.11.0"
tower-sessions-sqlx-store = { version = "0.11.0", features = ["sqlite"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }

15
Makefile Normal file
View File

@ -0,0 +1,15 @@
run:
SECURE_SESSIONS=false RUST_LOG=debug cargo run -- --reload-templates
run-release:
SECURE_SESSIONS=false RUST_LOG=info cargo run --release
css-watch:
npx tailwindcss -i ./src/main.css -o ./static/main.css --watch
migrate:
sea-orm-cli migrate
entities:
sea-orm-cli generate entity -o src/entity --with-serde both

View File

@ -34,6 +34,20 @@ We use nightly, and installation and management using [rustup][rustup] is
recommended.
### SeaORM
We use SeaORM for database interaction. You'll want the CLI, which you can
install with `cargo install sea-orm-cli`.
### Tailwind
We use Tailwind for our styling. You'll want to install the CLI:
```
npm install -D tailwindcss
```
### Docs
Decisions are recorded in ADRs[^adr] using a command-line tool to create and

View File

@ -0,0 +1,29 @@
# 3. Use Tailwind for CSS
Date: 2024-03-21
## Status
Proposed
## Context
We need to style our pages to make them look good.
Previously, I've used vanilla CSS, but that can get unwieldy and inconsistent. Here, a unified approach and one that can draw inspiration from other places (including purchasing component galleries) would be beneficial.
## Decision
We're going to use Tailwind CSS, and potentially Tailwind UI, to style the project.
## Consequences
This means that we will have a higher upfront cost to learn some of the Tailwind ways of doing things.
It also means that long-term it's easier to get help from people, and look at how some of the other projects I have interacted with do things if they are using Tailwind.
There is not much risk of lock-in here, because I can stop using Tailwind and go back to vanilla CSS fairly easily.

2351
migration/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

19
migration/Cargo.toml Normal file
View File

@ -0,0 +1,19 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
async-std = { version = "1", features = ["attributes", "tokio1"] }
[dependencies.sea-orm-migration]
version = "0.12.0"
features = [
"runtime-tokio-rustls",
"sqlx-sqlite",
]

41
migration/README.md Normal file
View File

@ -0,0 +1,41 @@
# Running Migrator CLI
- Generate a new migration file
```sh
cargo run -- generate MIGRATION_NAME
```
- Apply all pending migrations
```sh
cargo run
```
```sh
cargo run -- up
```
- Apply first 10 pending migrations
```sh
cargo run -- up -n 10
```
- Rollback last applied migrations
```sh
cargo run -- down
```
- Rollback last 10 applied migrations
```sh
cargo run -- down -n 10
```
- Drop all tables from the database, then reapply all migrations
```sh
cargo run -- fresh
```
- Rollback all applied migrations, then reapply all migrations
```sh
cargo run -- refresh
```
- Rollback all applied migrations
```sh
cargo run -- reset
```
- Check the status of all migrations
```sh
cargo run -- status
```

14
migration/src/lib.rs Normal file
View File

@ -0,0 +1,14 @@
pub use sea_orm_migration::prelude::*;
mod m20240316_155147_create_users_table;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20240316_155147_create_users_table::Migration),
]
}
}

View File

@ -0,0 +1,69 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(User::Table)
.if_not_exists()
.col(
ColumnDef::new(User::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(User::FullName).string_len(100).not_null())
.col(
ColumnDef::new(User::Email)
.string_len(100)
.unique()
.not_null(),
)
.col(
ColumnDef::new(User::Username)
.string_len(32)
.unique()
.not_null(),
)
.col(ColumnDef::new(User::PasswordHash).string().not_null())
.col(
ColumnDef::new(User::Created)
.date_time()
.default(Expr::current_timestamp())
.not_null(),
)
.col(
ColumnDef::new(User::Updated)
.date_time()
.default(Expr::current_timestamp())
.not_null(),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(User::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum User {
Table,
Id,
FullName,
Email,
Username,
PasswordHash,
Created,
Updated,
}

6
migration/src/main.rs Normal file
View File

@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

1379
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"devDependencies": {
"tailwindcss": "^3.4.1"
}
}

2
rust-toolchain.toml Normal file
View File

@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"

90
src/bin/admin.rs Normal file
View File

@ -0,0 +1,90 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use pique::{
db::{NewUser, UserQuery},
prelude::*,
};
use rand::distributions::Alphanumeric;
use rand::{distributions::DistString, thread_rng};
use sea_orm::Database;
#[tokio::main]
pub async fn main() -> Result<()> {
dotenvy::dotenv()?;
match AdminCli::parse().command {
AdminCommand::CreateUser {
full_name,
email,
username,
password,
} => {
let password = match password {
Some(p) => p,
None => {
let mut rng = thread_rng();
let password = Alphanumeric.sample_string(&mut rng, 24);
println!("Generated password: {}", password);
password
}
};
handle_create_user(NewUser {
full_name,
email,
username,
password,
})
.await?
}
AdminCommand::ListUsers => handle_list_users().await?,
};
Ok(())
}
#[derive(Parser, Debug)]
struct AdminCli {
#[clap(subcommand)]
pub command: AdminCommand,
}
#[derive(Subcommand, Debug)]
pub enum AdminCommand {
CreateUser {
full_name: String,
email: String,
username: String,
password: Option<String>,
},
ListUsers,
}
async fn handle_create_user(new_user: NewUser) -> Result<()> {
let db = connect_to_db().await?;
let user = UserQuery(&db).insert(new_user).await?;
println!("User created successfully with id = {}", user.id.unwrap());
Ok(())
}
async fn handle_list_users() -> Result<()> {
let db = connect_to_db().await?;
let users = UserQuery(&db).all().await?;
println!("Found {} users.", users.len());
for user in users {
println!(" > {}: {} ({})", user.id, user.username, user.full_name);
}
Ok(())
}
async fn connect_to_db() -> Result<DatabaseConnection> {
let db_url = dotenvy::var("DATABASE_URL")?;
let db = Database::connect(db_url).await?;
Ok(db)
}

9
src/bin/pique.rs Normal file
View File

@ -0,0 +1,9 @@
use anyhow::Result;
use pique::server;
#[tokio::main]
pub async fn main() -> Result<()> {
server::run().await?;
Ok(())
}

7
src/config.rs Normal file
View File

@ -0,0 +1,7 @@
use clap::Parser;
#[derive(Parser, Debug)]
pub struct CommandLineOptions {
#[arg(short, long, action)]
pub reload_templates: bool,
}

39
src/context.rs Normal file
View File

@ -0,0 +1,39 @@
use std::sync::Arc;
use minijinja_autoreload::AutoReloader;
use crate::{handler::internal_server_error, prelude::*};
#[derive(Clone)]
pub struct Context {
pub db: DatabaseConnection,
template_loader: Arc<AutoReloader>,
}
impl Context {
pub fn new(db: DatabaseConnection, template_loader: AutoReloader) -> Context {
Context {
db,
template_loader: Arc::new(template_loader),
}
}
pub fn render<T: Serialize>(&self, path: &str, data: T) -> anyhow::Result<String> {
// TODO: more graceful handling of the potential errors here; this should not use anyhow
let env = self.template_loader.acquire_env().unwrap();
let template = env.get_template(path)?;
let rendered = template.render(data)?;
Ok(rendered)
}
pub fn render_resp<T: Serialize>(&self, path: &str, data: T) -> Response {
let rendered = self.render(path, data);
match rendered {
Ok(rendered) => Html(rendered).into_response(),
Err(err) => {
error!(?err, "error while rendering template");
internal_server_error()
}
}
}
}

96
src/db.rs Normal file
View File

@ -0,0 +1,96 @@
use sea_orm::Set;
use thiserror::Error;
use crate::{entity::user, password, prelude::*};
pub struct NewUser {
pub full_name: String,
pub email: String,
pub username: String,
pub password: String,
}
impl NewUser {
pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
let mut validation_errors = vec![];
if self.full_name.len() > 100 {
validation_errors.push(ValidationError::on("full_name", "too long (max=100)"));
}
if self.email.len() > 100 {
validation_errors.push(ValidationError::on("email", "too long (max=100)"));
}
if self.username.len() > 100 {
validation_errors.push(ValidationError::on("username", "too long (max=32)"));
}
if validation_errors.is_empty() {
Ok(())
} else {
Err(validation_errors)
}
}
pub fn hash_password(&self) -> String {
password::hash(&self.password)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ValidationError {
pub field: String,
pub message: String,
}
impl ValidationError {
pub fn on(field: &str, message: &str) -> ValidationError {
ValidationError {
field: field.to_owned(),
message: message.to_owned(),
}
}
}
#[derive(Debug, Error)]
pub enum DbError {
#[error("internal database error")]
Internal(#[from] sea_orm::DbErr),
}
pub struct UserQuery<'a>(pub &'a DatabaseConnection);
impl UserQuery<'_> {
pub async fn insert(&self, new_user: NewUser) -> Result<user::ActiveModel, DbError> {
let password_hash = new_user.hash_password();
let user = user::ActiveModel {
full_name: Set(new_user.full_name),
email: Set(new_user.email),
username: Set(new_user.username),
password_hash: Set(password_hash),
..Default::default()
}
.save(self.0)
.await?;
Ok(user)
}
pub async fn all(&self) -> Result<Vec<user::Model>, DbError> {
let users = User::find().all(self.0).await?;
Ok(users)
}
pub async fn by_id(&self, id: i32) -> Result<Option<user::Model>, DbError> {
let user = User::find_by_id(id).one(self.0).await?;
Ok(user)
}
pub async fn by_username(&self, username: &str) -> Result<Option<user::Model>, DbError> {
let user = User::find()
.filter(user::Column::Username.eq(username))
.one(self.0)
.await?;
Ok(user)
}
}

5
src/entity/mod.rs Normal file
View File

@ -0,0 +1,5 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
pub mod prelude;
pub mod user;

3
src/entity/prelude.rs Normal file
View File

@ -0,0 +1,3 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
pub use super::user::Entity as User;

22
src/entity/user.rs Normal file
View File

@ -0,0 +1,22 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub full_name: String,
pub email: String,
pub username: String,
pub password_hash: String,
pub created: String,
pub updated: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

14
src/handler.rs Normal file
View File

@ -0,0 +1,14 @@
pub mod home;
pub mod login;
use axum::http::StatusCode;
use axum::response::Response;
pub use login::login_page;
pub use login::login_submit;
pub fn internal_server_error() -> Response {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body("Internal Server Error".into())
.unwrap()
}

16
src/handler/home.rs Normal file
View File

@ -0,0 +1,16 @@
use axum::response::Redirect;
use axum_login::AuthSession;
use crate::prelude::*;
pub async fn home_page(State(ctx): State<Context>, auth_session: AuthSession<Context>) -> Response {
if let Some(user) = auth_session.user {
let values = context! {
user => user,
};
ctx.render_resp("home.html", values)
} else {
Redirect::to("/login").into_response()
}
}

121
src/handler/login.rs Normal file
View File

@ -0,0 +1,121 @@
use axum::{response::Redirect, Form};
use axum_login::AuthSession;
use crate::{handler::internal_server_error, prelude::*, session::Credentials};
pub struct LoginTemplate {
pub username: String,
pub password: String,
pub error: Option<String>,
}
pub async fn login_page(
State(ctx): State<Context>,
auth_session: AuthSession<Context>,
) -> Response {
if auth_session.user.is_some() {
return Redirect::to("/").into_response();
}
render_login_page(&ctx, "", "", None)
}
fn render_login_page(
ctx: &Context,
username: &str,
password: &str,
error: Option<&'static str>,
) -> Response {
ctx.render_resp(
"login.html",
context! {
username => username,
password => password,
error => error,
},
)
}
const LOGIN_ERROR_MSG: &str = "Invalid username or password";
pub async fn login_submit(
State(ctx): State<Context>,
mut auth_session: AuthSession<Context>,
Form(creds): Form<Credentials>,
) -> Response {
match auth_session.authenticate(creds).await {
Ok(Some(user)) => {
if let Err(err) = auth_session.login(&user).await {
error!(?err, "error while logging in user");
return internal_server_error();
}
Redirect::to("/").into_response()
}
Ok(None) => render_login_page(&ctx, "", "", Some(LOGIN_ERROR_MSG)),
Err(err) => {
error!(?err, "error while authenticating user");
internal_server_error()
}
}
}
pub async fn logout(mut auth_session: AuthSession<Context>) -> Response {
if let Err(err) = auth_session.logout().await {
error!(?err, "error while logging out user");
}
Redirect::to("/login").into_response()
}
//const INVALID_LOGIN_MESSAGE: &str = "Invalid username/password, please try again.";
//
//pub async fn login_submission(
// request: HttpRequest,
// context: web::Data<Context>,
// form: web::Form<LoginForm>,
//) -> impl Responder {
// let mut conn = match context.pool.get() {
// Ok(conn) => conn,
// Err(_) => return internal_server_error(),
// };
//
// let user = match fetch_user_by_username(&mut conn, &form.username) {
// Ok(Some(user)) => user,
// Ok(None) => {
// return LoginTemplate {
// username: form.username.clone(),
// password: String::new(),
// error: Some(INVALID_LOGIN_MESSAGE.into()),
// }
// .to_response()
// }
// Err(_) => return internal_server_error(),
// };
//
// if !user.check_password(&form.password) {
// return LoginTemplate {
// username: form.username.clone(),
// password: String::new(),
// error: Some(INVALID_LOGIN_MESSAGE.into()),
// }
// .to_response();
// }
//
// if Identity::login(&request.extensions(), user.id.to_string()).is_err() {
// return internal_server_error();
// }
//
// return HttpResponse::Found()
// .append_header(("Location", "/"))
// .finish();
//}
//
//#[get("/logout")]
//pub async fn logout(user: Option<Identity>) -> impl Responder {
// if let Some(user) = user {
// user.logout();
// }
//
// redirect_to_login()
//}

11
src/lib.rs Normal file
View File

@ -0,0 +1,11 @@
pub mod config;
pub mod context;
pub mod db;
pub mod entity;
pub mod handler;
pub mod logging;
pub mod password;
pub mod prelude;
pub mod server;
pub mod session;
pub mod templates;

5
src/logging.rs Normal file
View File

@ -0,0 +1,5 @@
pub fn setup_logging() {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.init();
}

3
src/main.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,3 +0,0 @@
fn main() {
println!("Hello, world!");
}

43
src/password.rs Normal file
View File

@ -0,0 +1,43 @@
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
/// Verifies that the given password matches the given hash.
pub fn verify(hash: &str, password: &str) -> bool {
let parsed_hash = match PasswordHash::new(hash) {
Ok(hash) => hash,
Err(_) => return false, // TODO: log an error
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok()
}
/// Hashes the given password.
pub fn hash(password: &str) -> String {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.unwrap();
hash.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_password_hashes_match() {
// this is a well-known example password:
// https://knowyourmeme.com/memes/hunter2
let password = "hunter2";
let hashed_password = hash(password);
let hashed_other = hash("not-the-password");
assert!(verify(&hashed_password, password));
assert!(!verify(&hashed_other, password));
}
}

9
src/prelude.rs Normal file
View File

@ -0,0 +1,9 @@
pub use crate::context::Context;
pub use crate::entity::prelude::*;
pub use axum::extract::State;
pub use axum::response::{Html, IntoResponse, Response};
pub use minijinja::context;
pub use sea_orm::prelude::*;
pub use sea_orm::{ActiveModelTrait, DatabaseConnection};
pub use serde::{Deserialize, Serialize};
pub use tracing::{debug, error, info, warn};

67
src/server.rs Normal file
View File

@ -0,0 +1,67 @@
use std::str::FromStr;
use anyhow::Result;
use axum::{routing::{get, post}, Router};
use axum_login::AuthManagerLayerBuilder;
use clap::Parser;
use sea_orm::Database;
use tower_http::{services::ServeDir, trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}};
use tower_sessions::SessionManagerLayer;
use tower_sessions_sqlx_store::{sqlx::SqlitePool, SqliteStore};
use tracing::Level;
use crate::{config::CommandLineOptions, context::Context, handler::{home::home_page, login::logout, login_page, login_submit}, logging::setup_logging, templates::make_template_loader};
pub async fn run() -> Result<()> {
dotenvy::dotenv()?;
setup_logging();
let opts = CommandLineOptions::parse();
let template_loader = make_template_loader(opts.reload_templates);
let db_url = dotenvy::var("DATABASE_URL")?;
let db = Database::connect(db_url).await?;
let session_layer = create_session_manager_layer().await?;
let context = Context::new(db, template_loader);
let auth_backend = context.clone();
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer.clone()).build();
let trace_layer = TraceLayer::new_for_http()
.on_request(DefaultOnRequest::new().level(Level::INFO))
.on_response(DefaultOnResponse::new().level(Level::INFO));
let app = Router::new()
.nest_service("/static", ServeDir::new("static"))
.route("/", get(home_page))
.route("/login", get(login_page))
.route("/login", post(login_submit))
.route("/logout", get(logout))
.layer(trace_layer)
.layer(session_layer)
.layer(auth_layer)
.with_state(context);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
axum::serve(listener, app).await.unwrap();
Ok(())
}
pub async fn create_session_manager_layer() -> Result<SessionManagerLayer<SqliteStore>> {
let session_db_url =
dotenvy::var("SESSION_DB_URL").unwrap_or("sqlite:./sessions.db?mode=rwc".to_owned());
let pool = SqlitePool::connect(&session_db_url).await?;
let session_store = SqliteStore::new(pool);
session_store.migrate().await?;
let use_secure_sessions: bool =
FromStr::from_str(&dotenvy::var("SECURE_SESSIONS").unwrap_or("true".to_owned()))?;
let session_layer = SessionManagerLayer::new(session_store).with_secure(use_secure_sessions);
Ok(session_layer)
}

49
src/session.rs Normal file
View File

@ -0,0 +1,49 @@
use async_trait::async_trait;
use axum_login::{AuthUser, AuthnBackend, UserId};
use crate::{
db::{DbError, UserQuery},
entity::user,
password,
prelude::*,
};
#[derive(Serialize, Deserialize)]
pub struct Credentials {
pub username: String,
pub password: String,
}
impl AuthUser for user::Model {
type Id = i32;
fn id(&self) -> Self::Id {
self.id
}
fn session_auth_hash(&self) -> &[u8] {
self.password_hash.as_bytes()
}
}
#[async_trait]
impl AuthnBackend for Context {
type User = user::Model;
type Credentials = Credentials;
type Error = DbError;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let user = UserQuery(&self.db)
.by_username(&creds.username)
.await?
.filter(|u| password::verify(&u.password_hash, &creds.password));
Ok(user)
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
UserQuery(&self.db).by_id(*user_id).await
}
}

15
src/templates.rs Normal file
View File

@ -0,0 +1,15 @@
use minijinja::{path_loader, Environment};
use minijinja_autoreload::AutoReloader;
pub fn make_template_loader(auto_reload: bool) -> AutoReloader {
let reloader = AutoReloader::new(move |notifier| {
let mut env = Environment::new();
let templates_path = "templates/";
env.set_loader(path_loader(templates_path));
if auto_reload {
notifier.watch_path(templates_path, true);
}
Ok(env)
});
reloader
}

0
static/.gitkeep Normal file
View File

646
static/main.css Normal file
View File

@ -0,0 +1,646 @@
/*
! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com
*/
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
*/
*,
::before,
::after {
box-sizing: border-box;
/* 1 */
border-width: 0;
/* 2 */
border-style: solid;
/* 2 */
border-color: #e5e7eb;
/* 2 */
}
::before,
::after {
--tw-content: '';
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS
*/
html,
:host {
line-height: 1.5;
/* 1 */
-webkit-text-size-adjust: 100%;
/* 2 */
-moz-tab-size: 4;
/* 3 */
-o-tab-size: 4;
tab-size: 4;
/* 3 */
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */
font-feature-settings: normal;
/* 5 */
font-variation-settings: normal;
/* 6 */
-webkit-tap-highlight-color: transparent;
/* 7 */
}
/*
1. Remove the margin in all browsers.
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
*/
body {
margin: 0;
/* 1 */
line-height: inherit;
/* 2 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Ensure horizontal rules are visible by default.
*/
hr {
height: 0;
/* 1 */
color: inherit;
/* 2 */
border-top-width: 1px;
/* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font-family by default.
2. Use the user's configured `mono` font-feature-settings by default.
3. Use the user's configured `mono` font-variation-settings by default.
4. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */
font-feature-settings: normal;
/* 2 */
font-variation-settings: normal;
/* 3 */
font-size: 1em;
/* 4 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0;
/* 1 */
border-color: inherit;
/* 2 */
border-collapse: collapse;
/* 3 */
}
/*
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
3. Remove default padding in all browsers.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
/* 1 */
font-feature-settings: inherit;
/* 1 */
font-variation-settings: inherit;
/* 1 */
font-size: 100%;
/* 1 */
font-weight: inherit;
/* 1 */
line-height: inherit;
/* 1 */
color: inherit;
/* 1 */
margin: 0;
/* 2 */
padding: 0;
/* 3 */
}
/*
Remove the inheritance of text transform in Edge and Firefox.
*/
button,
select {
text-transform: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Remove default button styles.
*/
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
/* 1 */
background-color: transparent;
/* 2 */
background-image: none;
/* 2 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield;
/* 1 */
outline-offset: -2px;
/* 2 */
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button;
/* 1 */
font: inherit;
/* 2 */
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Removes the default spacing and border for appropriate elements.
*/
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
fieldset {
margin: 0;
padding: 0;
}
legend {
padding: 0;
}
ol,
ul,
menu {
list-style: none;
margin: 0;
padding: 0;
}
/*
Reset default styling for dialogs.
*/
dialog {
padding: 0;
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
2. Set the default placeholder color to the user's configured gray 400 color.
*/
input::-moz-placeholder, textarea::-moz-placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
input::placeholder,
textarea::placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
/*
Set the default cursor for buttons.
*/
button,
[role="button"] {
cursor: pointer;
}
/*
Make sure disabled buttons don't get the pointer cursor.
*/
:disabled {
cursor: default;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block;
/* 1 */
vertical-align: middle;
/* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/* Make elements with the HTML hidden attribute stay hidden by default */
[hidden] {
display: none;
}
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
::backdrop {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;