remove old stacks
This commit is contained in:
93
src/auth.rs
Normal file
93
src/auth.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use axum::{extract::State, middleware::Next, response::IntoResponse};
|
||||
use futures::FutureExt;
|
||||
use tracing::Instrument;
|
||||
|
||||
use hyper::Request;
|
||||
|
||||
use crate::models::user::User;
|
||||
|
||||
use super::models;
|
||||
use super::{AppState, AuthError, Error};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Config {
|
||||
Enabled,
|
||||
Disabled { assume_user: String },
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "check_auth", skip(state, request, next))]
|
||||
pub async fn authorize<B>(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request<B>,
|
||||
next: Next<B>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
let user = async {
|
||||
let auth: Result<Result<User, AuthError>, Error> = match state.auth_config {
|
||||
Config::Disabled { assume_user } => {
|
||||
let user =
|
||||
match models::user::User::find_by_name(&state.database_pool, &assume_user)
|
||||
.await?
|
||||
{
|
||||
Some(user) => Ok(user),
|
||||
None => Err(AuthError::AuthenticationUserNotFound {
|
||||
username: assume_user,
|
||||
}),
|
||||
};
|
||||
Ok(user)
|
||||
}
|
||||
Config::Enabled => match request.headers().get("x-auth-username") {
|
||||
None => Ok(Err(AuthError::AuthenticationHeaderMissing)),
|
||||
Some(username) => match username.to_str() {
|
||||
Err(e) => Ok(Err(AuthError::AuthenticationHeaderInvalid {
|
||||
message: e.to_string(),
|
||||
})),
|
||||
Ok(username) => {
|
||||
match models::user::User::find_by_name(&state.database_pool, username)
|
||||
.await?
|
||||
{
|
||||
Some(user) => Ok(Ok(user)),
|
||||
None => Ok(Err(AuthError::AuthenticationUserNotFound {
|
||||
username: username.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
auth
|
||||
}
|
||||
.instrument(tracing::debug_span!("authorize"))
|
||||
.inspect(|r| {
|
||||
if let Ok(auth) = r {
|
||||
match auth {
|
||||
Ok(user) => tracing::debug!(?user, "auth successful"),
|
||||
Err(e) => e.trace(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.map(|r| {
|
||||
r.map(|auth| {
|
||||
metrics::counter!(
|
||||
format!("packager_auth_{}_total", {
|
||||
match auth {
|
||||
Ok(_) => "success".to_string(),
|
||||
Err(ref e) => format!("failure_{}", e.to_prom_metric_name()),
|
||||
}
|
||||
}),
|
||||
1,
|
||||
&match &auth {
|
||||
Ok(user) => vec![("username", user.username.clone())],
|
||||
Err(e) => e.to_prom_labels(),
|
||||
}
|
||||
);
|
||||
auth
|
||||
})
|
||||
})
|
||||
// outer result: failure of the process, e.g. database connection failed
|
||||
// inner result: auth rejected, with AuthError
|
||||
.await??;
|
||||
|
||||
request.extensions_mut().insert(user);
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
122
src/cli.rs
Normal file
122
src/cli.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use crate::Error;
|
||||
|
||||
#[cfg(feature = "prometheus")]
|
||||
use crate::StartError;
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
|
||||
#[derive(ValueEnum, Clone, Copy, Debug)]
|
||||
pub enum BoolArg {
|
||||
True,
|
||||
False,
|
||||
}
|
||||
|
||||
impl From<BoolArg> for bool {
|
||||
fn from(arg: BoolArg) -> bool {
|
||||
arg.bool()
|
||||
}
|
||||
}
|
||||
|
||||
impl BoolArg {
|
||||
fn bool(self) -> bool {
|
||||
match self {
|
||||
Self::True => true,
|
||||
Self::False => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// this is required because the required_if* functions match against the
|
||||
// *raw* value, before parsing is done
|
||||
impl From<BoolArg> for clap::builder::OsStr {
|
||||
fn from(arg: BoolArg) -> clap::builder::OsStr {
|
||||
match arg {
|
||||
BoolArg::True => "true".into(),
|
||||
BoolArg::False => "false".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
pub struct Args {
|
||||
#[arg(long)]
|
||||
pub database_url: String,
|
||||
|
||||
#[cfg(feature = "jaeger")]
|
||||
#[arg(long, value_enum, default_value_t = BoolArg::False)]
|
||||
pub enable_opentelemetry: BoolArg,
|
||||
|
||||
#[cfg(feature = "tokio-console")]
|
||||
#[arg(long, value_enum, default_value_t = BoolArg::False)]
|
||||
pub enable_tokio_console: BoolArg,
|
||||
|
||||
#[cfg(feature = "prometheus")]
|
||||
#[arg(long, value_enum, default_value_t = BoolArg::False)]
|
||||
pub enable_prometheus: BoolArg,
|
||||
|
||||
#[cfg(feature = "prometheus")]
|
||||
#[arg(long, value_enum, required_if_eq("enable_prometheus", BoolArg::True))]
|
||||
pub prometheus_port: Option<u16>,
|
||||
|
||||
#[cfg(feature = "prometheus")]
|
||||
#[arg(long, value_enum, required_if_eq("enable_prometheus", BoolArg::True))]
|
||||
pub prometheus_bind: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Command {
|
||||
Serve(Serve),
|
||||
#[command(subcommand)]
|
||||
Admin(Admin),
|
||||
Migrate,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct Serve {
|
||||
#[arg(long, default_value_t = 3000)]
|
||||
pub port: u16,
|
||||
#[arg(long)]
|
||||
pub bind: String,
|
||||
#[arg(long, name = "USERNAME")]
|
||||
pub disable_auth_and_assume_user: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Admin {
|
||||
#[command(subcommand)]
|
||||
User(UserCommand),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum UserCommand {
|
||||
Create(UserCreate),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct UserCreate {
|
||||
#[arg(long)]
|
||||
pub username: String,
|
||||
#[arg(long)]
|
||||
pub fullname: String,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
pub fn get() -> Result<Args, Error> {
|
||||
let args = Args::parse();
|
||||
|
||||
#[cfg(feature = "prometheus")]
|
||||
if !args.enable_prometheus.bool()
|
||||
&& (args.prometheus_port.is_some() || args.prometheus_bind.is_some())
|
||||
{
|
||||
return Err(Error::Start(StartError::CallError {
|
||||
message: "do not set prometheus options when prometheus is not enabled".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(args)
|
||||
}
|
||||
}
|
||||
286
src/error.rs
Normal file
286
src/error.rs
Normal file
@@ -0,0 +1,286 @@
|
||||
use std::fmt;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::models;
|
||||
use crate::view;
|
||||
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RequestError {
|
||||
EmptyFormElement { name: String },
|
||||
RefererNotFound,
|
||||
RefererInvalid { message: String },
|
||||
NotFound { message: String },
|
||||
Auth { inner: AuthError },
|
||||
Transport { inner: hyper::Error },
|
||||
}
|
||||
|
||||
impl std::error::Error for RequestError {}
|
||||
|
||||
impl fmt::Display for RequestError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::EmptyFormElement { name } => write!(f, "Form element {name} cannot be empty"),
|
||||
Self::RefererNotFound => write!(f, "Referer header not found"),
|
||||
Self::RefererInvalid { message } => write!(f, "Referer header invalid: {message}"),
|
||||
Self::NotFound { message } => write!(f, "Not found: {message}"),
|
||||
Self::Auth { inner } => {
|
||||
write!(f, "Authentication failed: {inner}")
|
||||
}
|
||||
Self::Transport { inner } => {
|
||||
write!(f, "HTTP error: {inner}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AuthError {
|
||||
AuthenticationUserNotFound { username: String },
|
||||
AuthenticationHeaderMissing,
|
||||
AuthenticationHeaderInvalid { message: String },
|
||||
}
|
||||
|
||||
impl AuthError {
|
||||
pub fn to_prom_metric_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthenticationUserNotFound { username: _ } => "user_not_found",
|
||||
Self::AuthenticationHeaderMissing => "header_missing",
|
||||
Self::AuthenticationHeaderInvalid { message: _ } => "header_invalid",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trace(&self) {
|
||||
match self {
|
||||
Self::AuthenticationUserNotFound { username } => {
|
||||
tracing::info!(username, "auth failed, user not found");
|
||||
}
|
||||
Self::AuthenticationHeaderMissing => {
|
||||
tracing::info!("auth failed, auth header missing");
|
||||
}
|
||||
Self::AuthenticationHeaderInvalid { message } => {
|
||||
tracing::info!(message, "auth failed, auth header invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> AuthError {
|
||||
pub fn to_prom_labels(&'a self) -> Vec<(&'static str, String)> {
|
||||
match self {
|
||||
Self::AuthenticationUserNotFound { username } => vec![("username", username.clone())],
|
||||
Self::AuthenticationHeaderMissing
|
||||
| Self::AuthenticationHeaderInvalid { message: _ } => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AuthError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::AuthenticationUserNotFound { username } => {
|
||||
write!(f, "User \"{username}\" not found")
|
||||
}
|
||||
Self::AuthenticationHeaderMissing => write!(f, "Authentication header not found"),
|
||||
Self::AuthenticationHeaderInvalid { message } => {
|
||||
write!(f, "Authentication header invalid: {message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthError> for Error {
|
||||
fn from(e: AuthError) -> Self {
|
||||
Self::Request(RequestError::Auth { inner: e })
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AuthError {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Model(models::Error),
|
||||
Request(RequestError),
|
||||
Start(StartError),
|
||||
Command(CommandError),
|
||||
Exec(tokio::task::JoinError),
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::Model(model_error) => write!(f, "Model error: {model_error}"),
|
||||
Self::Request(request_error) => write!(f, "Request error: {request_error}"),
|
||||
Self::Start(start_error) => write!(f, "{start_error}"),
|
||||
Self::Command(command_error) => write!(f, "{command_error}"),
|
||||
Self::Exec(join_error) => write!(f, "{join_error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<models::Error> for Error {
|
||||
fn from(value: models::Error) -> Self {
|
||||
Self::Model(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StartError> for Error {
|
||||
fn from(value: StartError) -> Self {
|
||||
Self::Start(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<hyper::Error> for Error {
|
||||
fn from(value: hyper::Error) -> Self {
|
||||
Self::Request(RequestError::Transport { inner: value })
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tokio::task::JoinError> for Error {
|
||||
fn from(value: tokio::task::JoinError) -> Self {
|
||||
Self::Exec(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(String, std::net::AddrParseError)> for Error {
|
||||
fn from(value: (String, std::net::AddrParseError)) -> Self {
|
||||
let (input, error) = value;
|
||||
Self::Start(StartError::AddrParse {
|
||||
input,
|
||||
message: error.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
Self::Model(ref model_error) => match model_error {
|
||||
models::Error::Database(_) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
view::ErrorPage::build(&self.to_string()),
|
||||
),
|
||||
models::Error::Query(error) => match error {
|
||||
models::QueryError::NotFound { description } => {
|
||||
(StatusCode::NOT_FOUND, view::ErrorPage::build(description))
|
||||
}
|
||||
_ => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
view::ErrorPage::build(&error.to_string()),
|
||||
),
|
||||
},
|
||||
},
|
||||
Self::Request(request_error) => match request_error {
|
||||
RequestError::RefererNotFound => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
view::ErrorPage::build("no referer header found"),
|
||||
),
|
||||
RequestError::RefererInvalid { message } => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
view::ErrorPage::build(&format!("referer could not be converted: {message}")),
|
||||
),
|
||||
RequestError::EmptyFormElement { name } => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
view::ErrorPage::build(&format!("empty form element: {name}")),
|
||||
),
|
||||
RequestError::NotFound { message } => (
|
||||
StatusCode::NOT_FOUND,
|
||||
view::ErrorPage::build(&format!("not found: {message}")),
|
||||
),
|
||||
RequestError::Auth { inner: e } => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
view::ErrorPage::build(&format!("authentication failed: {e}")),
|
||||
),
|
||||
RequestError::Transport { inner } => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
view::ErrorPage::build(&inner.to_string()),
|
||||
),
|
||||
},
|
||||
Self::Start(start_error) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
view::ErrorPage::build(&start_error.to_string()),
|
||||
),
|
||||
Self::Command(command_error) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
view::ErrorPage::build(&command_error.to_string()),
|
||||
),
|
||||
Self::Exec(join_error) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
view::ErrorPage::build(&join_error.to_string()),
|
||||
),
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StartError {
|
||||
CallError { message: String },
|
||||
DatabaseInitError { message: String },
|
||||
DatabaseMigrationError { message: String },
|
||||
AddrParse { input: String, message: String },
|
||||
BindError { addr: SocketAddr, message: String },
|
||||
}
|
||||
|
||||
impl std::error::Error for StartError {}
|
||||
|
||||
impl fmt::Display for StartError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::CallError { message } => {
|
||||
write!(f, "invalid invocation: {message}")
|
||||
}
|
||||
Self::DatabaseInitError { message } => {
|
||||
write!(f, "database initialization error: {message}")
|
||||
}
|
||||
Self::DatabaseMigrationError { message } => {
|
||||
write!(f, "database migration error: {message}")
|
||||
}
|
||||
Self::AddrParse { message, input } => {
|
||||
write!(f, "error parsing \"{input}\": {message}")
|
||||
}
|
||||
Self::BindError { message, addr } => {
|
||||
write!(f, "error binding network interface {addr}: {message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for StartError {
|
||||
fn from(value: sqlx::Error) -> Self {
|
||||
Self::DatabaseInitError {
|
||||
message: value.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::migrate::MigrateError> for StartError {
|
||||
fn from(value: sqlx::migrate::MigrateError) -> Self {
|
||||
Self::DatabaseMigrationError {
|
||||
message: value.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CommandError {
|
||||
UserExists { username: String },
|
||||
}
|
||||
|
||||
impl std::error::Error for CommandError {}
|
||||
|
||||
impl fmt::Display for CommandError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::UserExists { username } => {
|
||||
write!(f, "user \"{username}\" already exists")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/htmx.rs
Normal file
52
src/htmx.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use axum::http::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
|
||||
pub enum Event {
|
||||
TripItemEdited,
|
||||
}
|
||||
|
||||
impl From<Event> for HeaderValue {
|
||||
fn from(val: Event) -> Self {
|
||||
HeaderValue::from_static(val.to_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::TripItemEdited => "TripItemEdited",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum ResponseHeaders {
|
||||
Trigger,
|
||||
PushUrl,
|
||||
}
|
||||
|
||||
impl From<ResponseHeaders> for HeaderName {
|
||||
fn from(val: ResponseHeaders) -> Self {
|
||||
match val {
|
||||
ResponseHeaders::Trigger => HeaderName::from_static("hx-trigger"),
|
||||
ResponseHeaders::PushUrl => HeaderName::from_static("hx-push-url"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum RequestHeaders {
|
||||
HtmxRequest,
|
||||
}
|
||||
|
||||
impl From<RequestHeaders> for HeaderName {
|
||||
fn from(val: RequestHeaders) -> Self {
|
||||
match val {
|
||||
RequestHeaders::HtmxRequest => HeaderName::from_static("hx-request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub fn is_htmx(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get::<HeaderName>(RequestHeaders::HtmxRequest.into())
|
||||
.map_or(false, |value| value == "true")
|
||||
}
|
||||
105
src/lib.rs
Normal file
105
src/lib.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use std::fmt;
|
||||
|
||||
pub mod auth;
|
||||
pub mod cli;
|
||||
pub mod error;
|
||||
pub mod htmx;
|
||||
pub mod models;
|
||||
pub mod routing;
|
||||
pub mod sqlite;
|
||||
pub mod telemetry;
|
||||
|
||||
mod view;
|
||||
|
||||
pub use error::{AuthError, CommandError, Error, RequestError, StartError};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppState {
|
||||
pub database_pool: sqlite::Pool,
|
||||
pub client_state: ClientState,
|
||||
pub auth_config: auth::Config,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Context {
|
||||
user: models::user::User,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
fn build(user: models::user::User) -> Self {
|
||||
Self { user }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClientState {
|
||||
pub active_category_id: Option<Uuid>,
|
||||
pub edit_item: Option<Uuid>,
|
||||
pub trip_edit_attribute: Option<models::trips::TripAttribute>,
|
||||
pub trip_type_edit: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl ClientState {
|
||||
pub fn new() -> Self {
|
||||
ClientState {
|
||||
active_category_id: None,
|
||||
edit_item: None,
|
||||
trip_edit_attribute: None,
|
||||
trip_type_edit: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClientState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
struct UriPath(String);
|
||||
|
||||
impl fmt::Display for UriPath {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a UriPath> for &'a str {
|
||||
fn from(val: &'a UriPath) -> Self {
|
||||
val.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug)]
|
||||
pub enum TopLevelPage {
|
||||
Inventory,
|
||||
Trips,
|
||||
}
|
||||
|
||||
impl TopLevelPage {
|
||||
fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Inventory => "inventory",
|
||||
Self::Trips => "trips",
|
||||
}
|
||||
}
|
||||
|
||||
fn path(&self) -> UriPath {
|
||||
UriPath(
|
||||
match self {
|
||||
Self::Inventory => "/inventory/",
|
||||
Self::Trips => "/trips/",
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Inventory => "Inventory",
|
||||
Self::Trips => "Trips",
|
||||
}
|
||||
}
|
||||
}
|
||||
202
src/main.rs
Normal file
202
src/main.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::pin::Pin;
|
||||
use std::process::ExitCode;
|
||||
use std::str::FromStr;
|
||||
|
||||
use packager::{
|
||||
auth, cli, models, routing, sqlite, telemetry, AppState, ClientState, Error, StartError,
|
||||
};
|
||||
|
||||
struct MainResult(Result<(), Error>);
|
||||
|
||||
impl std::process::Termination for MainResult {
|
||||
fn report(self) -> std::process::ExitCode {
|
||||
match self.0 {
|
||||
Ok(_) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for MainResult {
|
||||
fn from(error: Error) -> Self {
|
||||
Self(Err(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tokio::task::JoinError> for MainResult {
|
||||
fn from(error: tokio::task::JoinError) -> Self {
|
||||
Self(Err(error.into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> MainResult {
|
||||
let args = match cli::Args::get() {
|
||||
Ok(args) => args,
|
||||
Err(e) => return e.into(),
|
||||
};
|
||||
|
||||
telemetry::tracing::init(
|
||||
#[cfg(feature = "jaeger")]
|
||||
if args.enable_opentelemetry.into() {
|
||||
telemetry::tracing::OpenTelemetryConfig::Enabled
|
||||
} else {
|
||||
telemetry::tracing::OpenTelemetryConfig::Disabled
|
||||
},
|
||||
#[cfg(feature = "tokio-console")]
|
||||
if args.enable_tokio_console.into() {
|
||||
telemetry::tracing::TokioConsoleConfig::Enabled
|
||||
} else {
|
||||
telemetry::tracing::TokioConsoleConfig::Disabled
|
||||
},
|
||||
args,
|
||||
|args| -> Pin<Box<dyn std::future::Future<Output = MainResult>>> {
|
||||
Box::pin(async move {
|
||||
match args.command {
|
||||
cli::Command::Serve(serve_args) => {
|
||||
if let Err(e) = sqlite::migrate(&args.database_url).await {
|
||||
return <_ as Into<Error>>::into(e).into();
|
||||
}
|
||||
|
||||
let database_pool =
|
||||
match sqlite::init_database_pool(&args.database_url).await {
|
||||
Ok(pool) => pool,
|
||||
Err(e) => return <_ as Into<Error>>::into(e).into(),
|
||||
};
|
||||
|
||||
let state = AppState {
|
||||
database_pool,
|
||||
client_state: ClientState::new(),
|
||||
auth_config: if let Some(assume_user) =
|
||||
serve_args.disable_auth_and_assume_user
|
||||
{
|
||||
auth::Config::Disabled { assume_user }
|
||||
} else {
|
||||
auth::Config::Enabled
|
||||
},
|
||||
};
|
||||
|
||||
// build our application with a route
|
||||
let app = routing::router(state);
|
||||
let app = telemetry::tracing::init_request_tracing(app);
|
||||
|
||||
let mut join_set = tokio::task::JoinSet::new();
|
||||
|
||||
#[cfg(feature = "prometheus")]
|
||||
let app = if args.enable_prometheus.into() {
|
||||
// we `require_if()` prometheus port & bind when `enable_prometheus` is set, so
|
||||
// this cannot fail
|
||||
|
||||
let bind = args.prometheus_bind.unwrap();
|
||||
let port = args.prometheus_port.unwrap();
|
||||
|
||||
let ip = IpAddr::from_str(&bind);
|
||||
|
||||
let addr = match ip {
|
||||
Err(e) => return <_ as Into<Error>>::into((bind, e)).into(),
|
||||
Ok(ip) => SocketAddr::from((ip, port)),
|
||||
};
|
||||
|
||||
let (app, task) = telemetry::metrics::prometheus_server(app, addr);
|
||||
join_set.spawn(task);
|
||||
app
|
||||
} else {
|
||||
app
|
||||
};
|
||||
|
||||
join_set.spawn(async move {
|
||||
let addr = SocketAddr::from((
|
||||
IpAddr::from_str(&serve_args.bind)
|
||||
.map_err(|e| (serve_args.bind, e))?,
|
||||
serve_args.port,
|
||||
));
|
||||
|
||||
tracing::debug!("listening on {}", addr);
|
||||
|
||||
if let Err(e) = axum::Server::try_bind(&addr)
|
||||
.map_err(|e| {
|
||||
Error::Start(StartError::BindError {
|
||||
addr,
|
||||
message: e.to_string(),
|
||||
})
|
||||
})?
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
{
|
||||
return Err(<hyper::Error as Into<Error>>::into(e));
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
// now we wait for all tasks. none of them are supposed to finish
|
||||
|
||||
// EXPECT: join_set cannot be empty as it will always at least contain the main_handle
|
||||
let result = join_set
|
||||
.join_next()
|
||||
.await
|
||||
.expect("join_set is empty, this is a bug");
|
||||
|
||||
// EXPECT: We never expect a JoinError, as all threads run infinitely
|
||||
let result = result.expect("thread panicked");
|
||||
|
||||
// If we get an Ok(()), something weird happened
|
||||
let result = result.expect_err("thread ran to completion");
|
||||
|
||||
return result.into();
|
||||
}
|
||||
cli::Command::Admin(admin_command) => match admin_command {
|
||||
cli::Admin::User(cmd) => match cmd {
|
||||
cli::UserCommand::Create(user) => {
|
||||
let database_pool =
|
||||
match sqlite::init_database_pool(&args.database_url).await {
|
||||
Ok(pool) => pool,
|
||||
Err(e) => return <_ as Into<Error>>::into(e).into(),
|
||||
};
|
||||
|
||||
let id = match models::user::create(
|
||||
&database_pool,
|
||||
models::user::NewUser {
|
||||
username: &user.username,
|
||||
fullname: &user.fullname,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
models::Error::Query(models::QueryError::Duplicate {
|
||||
description: _,
|
||||
}) => Error::Command(packager::CommandError::UserExists {
|
||||
username: user.username.clone(),
|
||||
}),
|
||||
_ => Error::Model(error),
|
||||
}) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
return e.into();
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"User \"{}\" created successfully (id {})",
|
||||
&user.username, id
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
cli::Command::Migrate => {
|
||||
if let Err(e) = sqlite::migrate(&args.database_url).await {
|
||||
return <_ as Into<Error>>::into(e).into();
|
||||
}
|
||||
|
||||
println!("Migrations successfully applied");
|
||||
}
|
||||
}
|
||||
MainResult(Ok(()))
|
||||
})
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
152
src/models/error.rs
Normal file
152
src/models/error.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
use std::fmt;
|
||||
|
||||
use sqlx::error::DatabaseError as _;
|
||||
|
||||
pub enum DatabaseError {
|
||||
/// Errors we can receive **from** the database that are caused by connection
|
||||
/// problems or schema problems (e.g. we get a return value that does not fit our enum,
|
||||
/// or a wrongly formatted date)
|
||||
Sql {
|
||||
description: String,
|
||||
},
|
||||
Uuid {
|
||||
description: String,
|
||||
},
|
||||
Enum {
|
||||
description: String,
|
||||
},
|
||||
TimeParse {
|
||||
description: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for DatabaseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::Sql { description } => {
|
||||
write!(f, "SQL error: {description}")
|
||||
}
|
||||
Self::Uuid { description } => {
|
||||
write!(f, "UUID error: {description}")
|
||||
}
|
||||
Self::Enum { description } => {
|
||||
write!(f, "Enum error: {description}")
|
||||
}
|
||||
Self::TimeParse { description } => {
|
||||
write!(f, "Date parse error: {description}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum QueryError {
|
||||
/// Errors that are caused by wrong input data, e.g. ids that cannot be found, or
|
||||
/// inserts that violate unique constraints
|
||||
Duplicate {
|
||||
description: String,
|
||||
},
|
||||
NotFound {
|
||||
description: String,
|
||||
},
|
||||
ReferenceNotFound {
|
||||
description: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for QueryError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::Duplicate { description } => {
|
||||
write!(f, "Duplicate data entry: {description}")
|
||||
}
|
||||
Self::NotFound { description } => {
|
||||
write!(f, "not found: {description}")
|
||||
}
|
||||
Self::ReferenceNotFound { description } => {
|
||||
write!(f, "SQL foreign key reference was not found: {description}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Error {
|
||||
Database(DatabaseError),
|
||||
Query(QueryError),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::Database(error) => write!(f, "{error}"),
|
||||
Self::Query(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
// defer to Display
|
||||
write!(f, "SQL error: {self}")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<uuid::Error> for Error {
|
||||
fn from(value: uuid::Error) -> Self {
|
||||
Error::Database(DatabaseError::Uuid {
|
||||
description: value.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<time::error::Format> for Error {
|
||||
fn from(value: time::error::Format) -> Self {
|
||||
Error::Database(DatabaseError::TimeParse {
|
||||
description: value.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for Error {
|
||||
fn from(value: sqlx::Error) -> Self {
|
||||
match value {
|
||||
sqlx::Error::RowNotFound => Error::Query(QueryError::NotFound {
|
||||
description: value.to_string(),
|
||||
}),
|
||||
sqlx::Error::Database(ref error) => {
|
||||
let sqlite_error = error.downcast_ref::<sqlx::sqlite::SqliteError>();
|
||||
if let Some(code) = sqlite_error.code() {
|
||||
match &*code {
|
||||
// SQLITE_CONSTRAINT_FOREIGNKEY
|
||||
"787" => Error::Query(QueryError::ReferenceNotFound {
|
||||
description: "foreign key reference not found".to_string(),
|
||||
}),
|
||||
// SQLITE_CONSTRAINT_UNIQUE
|
||||
"2067" => Error::Query(QueryError::Duplicate {
|
||||
description: "item with unique constraint already exists".to_string(),
|
||||
}),
|
||||
_ => Error::Database(DatabaseError::Sql {
|
||||
description: format!("got error with unknown code: {sqlite_error}"),
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
Error::Database(DatabaseError::Sql {
|
||||
description: format!("got error without code: {sqlite_error}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
_ => Error::Database(DatabaseError::Sql {
|
||||
description: format!("got unknown error: {value}"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<time::error::Parse> for Error {
|
||||
fn from(value: time::error::Parse) -> Self {
|
||||
Error::Database(DatabaseError::TimeParse {
|
||||
description: value.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
489
src/models/inventory.rs
Normal file
489
src/models/inventory.rs
Normal file
@@ -0,0 +1,489 @@
|
||||
use super::Error;
|
||||
use crate::{sqlite, Context};
|
||||
|
||||
use futures::{TryFutureExt, TryStreamExt};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct Inventory {
|
||||
pub categories: Vec<Category>,
|
||||
}
|
||||
|
||||
impl Inventory {
|
||||
#[tracing::instrument]
|
||||
pub async fn load(ctx: &Context, pool: &sqlite::Pool) -> Result<Self, Error> {
|
||||
let user_id = ctx.user.id.to_string();
|
||||
|
||||
let mut categories = crate::query_all!(
|
||||
&sqlite::QueryClassification {
|
||||
query_type: sqlite::QueryType::Select,
|
||||
component: sqlite::Component::Inventory,
|
||||
},
|
||||
pool,
|
||||
DbCategoryRow,
|
||||
Category,
|
||||
"SELECT
|
||||
id,
|
||||
name,
|
||||
description
|
||||
FROM inventory_items_categories
|
||||
WHERE user_id = ?",
|
||||
user_id
|
||||
)
|
||||
.await?;
|
||||
|
||||
for category in &mut categories {
|
||||
category.populate_items(ctx, pool).await?;
|
||||
}
|
||||
|
||||
Ok(Self { categories })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Category {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub items: Option<Vec<Item>>,
|
||||
}
|
||||
|
||||
pub struct DbCategoryRow {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<DbCategoryRow> for Category {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(row: DbCategoryRow) -> Result<Self, Self::Error> {
|
||||
Ok(Category {
|
||||
id: Uuid::try_parse(&row.id)?,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
items: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Category {
|
||||
#[tracing::instrument]
|
||||
pub async fn _find(
|
||||
ctx: &Context,
|
||||
pool: &sqlite::Pool,
|
||||
id: Uuid,
|
||||
) -> Result<Option<Category>, Error> {
|
||||
let id_param = id.to_string();
|
||||
let user_id = ctx.user.id.to_string();
|
||||
crate::query_one!(
|
||||
&sqlite::QueryClassification {
|
||||
query_type: sqlite::QueryType::Select,
|
||||
component: sqlite::Component::Inventory,
|
||||
},
|
||||
pool,
|
||||
DbCategoryRow,
|
||||
Category,
|
||||
"SELECT
|
||||
id,
|
||||
name,
|
||||
description
|
||||
FROM inventory_items_categories AS category
|
||||
WHERE
|
||||
category.id = ?
|
||||
AND category.user_id = ?",
|
||||
id_param,
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn save(ctx: &Context, pool: &sqlite::Pool, name: &str) -> Result<Uuid, Error> {
|
||||
let id = Uuid::new_v4();
|
||||
let id_param = id.to_string();
|
||||
let user_id = ctx.user.id.to_string();
|
||||
crate::execute!(
|
||||
&sqlite::QueryClassification {
|
||||
query_type: sqlite::QueryType::Insert,
|
||||
component: sqlite::Component::Inventory,
|
||||
},
|
||||
pool,
|
||||
"INSERT INTO inventory_items_categories
|
||||
(id, name, user_id)
|
||||
VALUES
|
||||
(?, ?, ?)",
|
||||
id_param,
|
||||
name,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub fn items(&self) -> &Vec<Item> {
|
||||
self.items
|
||||
.as_ref()
|
||||
.expect("you need to call populate_items()")
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub fn total_weight(&self) -> i64 {
|
||||
self.items().iter().map(|item| item.weight).sum()
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn populate_items(
|
||||
&mut self,
|
||||
ctx: &Context,
|
||||
pool: &sqlite::Pool,
|
||||
) -> Result<(), Error> {
|
||||
let id = self.id.to_string();
|
||||
let user_id = ctx.user.id.to_string();
|
||||
let items = crate::query_all!(
|
||||
&sqlite::QueryClassification {
|
||||
query_type: sqlite::QueryType::Select,
|
||||
component: sqlite::Component::Inventory,
|
||||
},
|
||||
pool,
|
||||
DbInventoryItemsRow,
|
||||
Item,
|
||||
"SELECT
|
||||
id,
|
||||
name,
|
||||
weight,
|
||||
description,
|
||||
category_id
|
||||
FROM inventory_items
|
||||
WHERE
|
||||
category_id = ?
|
||||
AND user_id = ?",
|
||||
id,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.items = Some(items);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Product {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InventoryItem {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub weight: i64,
|
||||
pub category: Category,
|
||||
pub product: Option<Product>,
|
||||
}
|
||||
|
||||
struct DbInventoryItemRow {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub weight: i64,
|
||||
pub category_id: String,
|
||||
pub category_name: String,
|
||||
pub category_description: Option<String>,
|
||||
pub product_id: Option<String>,
|
||||
pub product_name: Option<String>,
|
||||
pub product_description: Option<String>,
|
||||
pub product_comment: Option<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<DbInventoryItemRow> for InventoryItem {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(row: DbInventoryItemRow) -> Result<Self, Self::Error> {
|
||||
Ok(InventoryItem {
|
||||
id: Uuid::try_parse(&row.id)?,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
weight: row.weight,
|
||||
category: Category {
|
||||
id: Uuid::try_parse(&row.category_id)?,
|
||||
name: row.category_name,
|
||||
description: row.category_description,
|
||||
items: None,
|
||||
},
|
||||
product: row
|
||||
.product_id
|
||||
.map(|id| -> Result<Product, Error> {
|
||||
Ok(Product {
|
||||
id: Uuid::try_parse(&id)?,
|
||||
name: row.product_name.unwrap(),
|
||||
description: row.product_description,
|
||||
comment: row.product_comment,
|
||||
})
|
||||
})
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl InventoryItem {
|
||||
#[tracing::instrument]
|
||||
pub async fn find(ctx: &Context, pool: &sqlite::Pool, id: Uuid) -> Result<Option<Self>, Error> {
|
||||
let id_param = id.to_string();
|
||||
let user_id = ctx.user.id.to_string();
|
||||
|
||||
crate::query_one!(
|
||||
&sqlite::QueryClassification {
|
||||
query_type: sqlite::QueryType::Select,
|
||||
component: sqlite::Component::Inventory,
|
||||
},
|
||||
pool,
|
||||
DbInventoryItemRow,
|
||||
Self,
|
||||
"SELECT
|
||||
item.id AS id,
|
||||
item.name AS name,
|
||||
item.description AS description,
|
||||
weight,
|
||||
category.id AS category_id,
|
||||
category.name AS category_name,
|
||||
category.description AS category_description,
|
||||
product.id AS product_id,
|
||||
product.name AS product_name,
|
||||
product.description AS product_description,
|
||||
product.comment AS product_comment
|
||||
FROM inventory_items AS item
|
||||
INNER JOIN inventory_items_categories as category
|
||||
ON item.category_id = category.id
|
||||
LEFT JOIN inventory_products AS product
|
||||
ON item.product_id = product.id
|
||||
WHERE
|
||||
item.id = ?
|
||||
AND item.user_id = ?",
|
||||
id_param,
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn name_exists(
|
||||
ctx: &Context,
|
||||
pool: &sqlite::Pool,
|
||||
name: &str,
|
||||
) -> Result<bool, Error> {
|
||||
let user_id = ctx.user.id.to_string();
|
||||
crate::query_exists!(
|
||||
&sqlite::QueryClassification {
|
||||
query_type: sqlite::QueryType::Select,
|
||||
component: sqlite::Component::Inventory,
|
||||
},
|
||||
pool,
|
||||
"SELECT id
|
||||
FROM inventory_items
|
||||
WHERE
|
||||
name = ?
|
||||
AND user_id = ?",
|
||||
name,
|
||||
user_id
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn delete(ctx: &Context, pool: &sqlite::Pool, id: Uuid) -> Result<bool, Error> {
|
||||
let id_param = id.to_string();
|
||||
let user_id = ctx.user.id.to_string();
|
||||
let results = crate::execute!(
|
||||
&sqlite::QueryClassification {
|
||||
query_type: sqlite::QueryType::Delete,
|
||||
component: sqlite::Component::Inventory,
|
||||
},
|
||||
pool,
|
||||
"DELETE FROM inventory_items
|
||||
WHERE
|
||||
id = ?
|
||||
AND user_id = ?",
|
||||
id_param,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(results.rows_affected() != 0)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn update(
|
||||
ctx: &Context,
|
||||
pool: &sqlite::Pool,
|
||||
id: Uuid,
|
||||
name: &str,
|
||||
weight: u32,
|
||||
) -> Result<Uuid, Error> {
|
||||
let user_id = ctx.user.id.to_string();
|
||||
let weight = i64::try_from(weight).unwrap();
|
||||
|
||||
let id_param = id.to_string();
|
||||
crate::execute_returning_uuid!(
|
||||
&sqlite::QueryClassification {
|
||||
query_type: sqlite::QueryType::Update,
|
||||
component: sqlite::Component::Inventory,
|
||||
},
|
||||
pool,
|
||||
"UPDATE inventory_items AS item
|
||||
SET
|
||||
name = ?,
|
||||
weight = ?
|
||||
WHERE
|
||||
item.id = ?
|
||||
AND item.user_id = ?
|
||||
RETURNING inventory_items.category_id AS id
|
||||
",
|
||||
name,
|
||||
weight,
|
||||
id_param,
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn save(
|
||||
ctx: &Context,
|
||||
pool: &sqlite::Pool,
|
||||
name: &str,
|
||||
category_id: Uuid,
|
||||
weight: u32,
|
||||
) -> Result<Uuid, Error> {
|
||||
let id = Uuid::new_v4();
|
||||
let id_param = id.to_string();
|
||||
let user_id = ctx.user.id.to_string();
|
||||
let category_id_param = category_id.to_string();
|
||||
|
||||
crate::execute!(
|
||||
&sqlite::QueryClassification {
|
||||
query_type: sqlite::QueryType::Insert,
|
||||
component: sqlite::Component::Inventory,
|
||||
},
|
||||
pool,
|
||||
"INSERT INTO inventory_items
|
||||
(id, name, description, weight, category_id, user_id)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?)",
|
||||
id_param,
|
||||
name,
|
||||
"",
|
||||
weight,
|
||||
category_id_param,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_category_max_weight(
|
||||
ctx: &Context,
|
||||
pool: &sqlite::Pool,
|
||||
category_id: Uuid,
|
||||
) -> Result<i64, Error> {
|
||||
let user_id = ctx.user.id.to_string();
|
||||
let category_id_param = category_id.to_string();
|
||||
let weight = crate::execute_returning!(
|
||||
&sqlite::QueryClassification {
|
||||
query_type: sqlite::QueryType::Select,
|
||||
component: sqlite::Component::Inventory,
|
||||
},
|
||||
pool,
|
||||
"
|
||||
SELECT COALESCE(MAX(i_item.weight), 0) as weight
|
||||
FROM inventory_items_categories as category
|
||||
INNER JOIN inventory_items as i_item
|
||||
ON i_item.category_id = category.id
|
||||
WHERE
|
||||
category_id = ?
|
||||
AND category.user_id = ?
|
||||
",
|
||||
i64,
|
||||
|row| i64::from(row.weight),
|
||||
category_id_param,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(weight)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Item {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub weight: i64,
|
||||
pub category_id: Uuid,
|
||||
}
|
||||
|
||||
pub struct DbInventoryItemsRow {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub weight: i64,
|
||||
pub description: Option<String>,
|
||||
pub category_id: String,
|
||||
}
|
||||
|
||||
impl TryFrom<DbInventoryItemsRow> for Item {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(row: DbInventoryItemsRow) -> Result<Self, Self::Error> {
|
||||
Ok(Item {
|
||||
id: Uuid::try_parse(&row.id)?,
|
||||
name: row.name,
|
||||
description: row.description, // TODO
|
||||
weight: row.weight,
|
||||
category_id: Uuid::try_parse(&row.category_id)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Item {
|
||||
#[tracing::instrument]
|
||||
pub async fn _get_category_total_picked_weight(
|
||||
ctx: &Context,
|
||||
pool: &sqlite::Pool,
|
||||
category_id: Uuid,
|
||||
) -> Result<i64, Error> {
|
||||
let user_id = ctx.user.id.to_string();
|
||||
let category_id_param = category_id.to_string();
|
||||
crate::execute_returning!(
|
||||
&sqlite::QueryClassification {
|
||||
query_type: sqlite::QueryType::Select,
|
||||
component: sqlite::Component::Inventory,
|
||||
},
|
||||
pool,
|
||||
"
|
||||
SELECT COALESCE(SUM(i_item.weight), 0) as weight
|
||||
FROM inventory_items_categories as category
|
||||
INNER JOIN inventory_items as i_item
|
||||
ON i_item.category_id = category.id
|
||||
INNER JOIN trips_items as t_item
|
||||
ON i_item.id = t_item.item_id
|
||||
WHERE
|
||||
category_id = ?
|
||||
AND category.user_id = ?
|
||||
AND t_item.pick = 1
|
||||
",
|
||||
i64,
|
||||
|row| i64::from(row.weight),
|
||||
category_id_param,
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
13
src/models/mod.rs
Normal file
13
src/models/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
pub mod inventory;
|
||||
pub mod trips;
|
||||
pub mod user;
|
||||
|
||||
mod error;
|
||||
pub use error::{DatabaseError, Error, QueryError};
|
||||
|
||||
mod consts {
|
||||
use time::{format_description::FormatItem, macros::format_description};
|
||||
|
||||
pub(super) const DATE_FORMAT: &[FormatItem<'static>] =
|
||||
format_description!("[year]-[month]-[day]");
|
||||
}
|
||||
1489
src/models/trips.rs
Normal file
1489
src/models/trips.rs
Normal file
File diff suppressed because it is too large
Load Diff
81
src/models/user.rs
Normal file
81
src/models/user.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use super::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::sqlite;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub fullname: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NewUser<'a> {
|
||||
pub username: &'a str,
|
||||
pub fullname: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DbUserRow {
|
||||
id: String,
|
||||
username: String,
|
||||
fullname: String,
|
||||
}
|
||||
|
||||
impl TryFrom<DbUserRow> for User {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(row: DbUserRow) -> Result<Self, Self::Error> {
|
||||
Ok(User {
|
||||
id: Uuid::try_parse(&row.id)?,
|
||||
username: row.username,
|
||||
fullname: row.fullname,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl User {
|
||||
#[tracing::instrument]
|
||||
pub async fn find_by_name(
|
||||
pool: &sqlx::Pool<sqlx::Sqlite>,
|
||||
name: &str,
|
||||
) -> Result<Option<Self>, Error> {
|
||||
crate::query_one!(
|
||||
&sqlite::QueryClassification {
|
||||
query_type: sqlite::QueryType::Select,
|
||||
component: sqlite::Component::User,
|
||||
},
|
||||
pool,
|
||||
DbUserRow,
|
||||
Self,
|
||||
"SELECT id,username,fullname FROM users WHERE username = ?",
|
||||
name
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn create(pool: &sqlite::Pool, user: NewUser<'_>) -> Result<Uuid, Error> {
|
||||
let id = Uuid::new_v4();
|
||||
let id_param = id.to_string();
|
||||
|
||||
crate::execute!(
|
||||
&sqlite::QueryClassification {
|
||||
query_type: sqlite::QueryType::Insert,
|
||||
component: sqlite::Component::User,
|
||||
},
|
||||
pool,
|
||||
"INSERT INTO users
|
||||
(id, username, fullname)
|
||||
VALUES
|
||||
(?, ?, ?)",
|
||||
id_param,
|
||||
user.username,
|
||||
user.fullname
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
5
src/routing/html.rs
Normal file
5
src/routing/html.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use maud::{html, Markup};
|
||||
|
||||
pub fn concat(a: &Markup, b: &Markup) -> Markup {
|
||||
html!((a)(b))
|
||||
}
|
||||
178
src/routing/mod.rs
Normal file
178
src/routing/mod.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use axum::{
|
||||
error_handling::HandleErrorLayer,
|
||||
http::header::HeaderMap,
|
||||
http::StatusCode,
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
BoxError, Router,
|
||||
};
|
||||
use serde::de;
|
||||
use uuid::Uuid;
|
||||
|
||||
use std::{fmt, time::Duration};
|
||||
use tower::{timeout::TimeoutLayer, ServiceBuilder};
|
||||
|
||||
use crate::{AppState, Error, RequestError, TopLevelPage};
|
||||
|
||||
use super::auth;
|
||||
|
||||
mod html;
|
||||
mod routes;
|
||||
use routes::*;
|
||||
|
||||
#[tracing::instrument]
|
||||
fn get_referer(headers: &HeaderMap) -> Result<&str, Error> {
|
||||
headers
|
||||
.get("referer")
|
||||
.ok_or(Error::Request(RequestError::RefererNotFound))?
|
||||
.to_str()
|
||||
.map_err(|error| {
|
||||
Error::Request(RequestError::RefererInvalid {
|
||||
message: error.to_string(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn uuid_or_empty<'de, D>(input: D) -> Result<Option<Uuid>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct NoneVisitor;
|
||||
|
||||
impl<'vi> de::Visitor<'vi> for NoneVisitor {
|
||||
type Value = Option<Uuid>;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(formatter, "invalid input")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
|
||||
if value.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(Uuid::try_from(value).map_err(|e| {
|
||||
E::custom(format!("UUID parsing failed: {e}"))
|
||||
})?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input.deserialize_str(NoneVisitor)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/favicon.svg", get(icon))
|
||||
.route("/assets/luggage.svg", get(icon))
|
||||
.route(
|
||||
"/notfound",
|
||||
get(|| async {
|
||||
Error::Request(RequestError::NotFound {
|
||||
message: "hi".to_string(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/slow",
|
||||
get(|| async {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
"Ok"
|
||||
}),
|
||||
)
|
||||
.route("/debug", get(debug))
|
||||
.merge(
|
||||
// thse are routes that require authentication
|
||||
Router::new()
|
||||
.route("/", get(root))
|
||||
.nest(
|
||||
(&TopLevelPage::Trips.path()).into(),
|
||||
Router::new()
|
||||
.route("/", get(trips).post(trip_create))
|
||||
.route("/types/", get(trips_types).post(trip_type_create))
|
||||
.route("/types/:id/edit/name/submit", post(trips_types_edit_name))
|
||||
.route("/:id/", get(trip))
|
||||
.route("/:id/comment/submit", post(trip_comment_set))
|
||||
.route("/:id/categories/:id/select", post(trip_category_select))
|
||||
.route("/:id/packagelist/", get(trip_packagelist))
|
||||
.route(
|
||||
"/:id/packagelist/item/:id/pack",
|
||||
post(trip_item_packagelist_set_pack_htmx),
|
||||
)
|
||||
.route(
|
||||
"/:id/packagelist/item/:id/unpack",
|
||||
post(trip_item_packagelist_set_unpack_htmx),
|
||||
)
|
||||
.route(
|
||||
"/:id/packagelist/item/:id/ready",
|
||||
post(trip_item_packagelist_set_ready_htmx),
|
||||
)
|
||||
.route(
|
||||
"/:id/packagelist/item/:id/unready",
|
||||
post(trip_item_packagelist_set_unready_htmx),
|
||||
)
|
||||
.route("/:id/state/:id", post(trip_state_set))
|
||||
.route("/:id/total_weight", get(trip_total_weight_htmx))
|
||||
.route("/:id/type/:id/add", get(trip_type_add))
|
||||
.route("/:id/type/:id/remove", get(trip_type_remove))
|
||||
.route("/:id/edit/:attribute/submit", post(trip_edit_attribute))
|
||||
.route(
|
||||
"/:id/items/:id/pick",
|
||||
get(trip_item_set_pick).post(trip_item_set_pick_htmx),
|
||||
)
|
||||
.route(
|
||||
"/:id/items/:id/unpick",
|
||||
get(trip_item_set_unpick).post(trip_item_set_unpick_htmx),
|
||||
)
|
||||
.route(
|
||||
"/:id/items/:id/pack",
|
||||
get(trip_item_set_pack).post(trip_item_set_pack_htmx),
|
||||
)
|
||||
.route(
|
||||
"/:id/items/:id/unpack",
|
||||
get(trip_item_set_unpack).post(trip_item_set_unpack_htmx),
|
||||
)
|
||||
.route(
|
||||
"/:id/items/:id/ready",
|
||||
get(trip_item_set_ready).post(trip_item_set_ready_htmx),
|
||||
)
|
||||
.route(
|
||||
"/:id/items/:id/unready",
|
||||
get(trip_item_set_unready).post(trip_item_set_unready_htmx),
|
||||
),
|
||||
)
|
||||
.nest(
|
||||
(&TopLevelPage::Inventory.path()).into(),
|
||||
Router::new()
|
||||
.route("/", get(inventory_inactive))
|
||||
.route("/categories/:id/select", post(inventory_category_select))
|
||||
.route("/category/", post(inventory_category_create))
|
||||
.route("/category/:id/", get(inventory_active))
|
||||
.route("/item/", post(inventory_item_create))
|
||||
.route("/item/:id/", get(inventory_item))
|
||||
.route("/item/:id/cancel", get(inventory_item_cancel))
|
||||
.route("/item/:id/delete", get(inventory_item_delete))
|
||||
.route("/item/:id/edit", post(inventory_item_edit))
|
||||
.route("/item/name/validate", post(inventory_item_validate_name)),
|
||||
)
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
auth::authorize,
|
||||
)),
|
||||
)
|
||||
.layer(
|
||||
ServiceBuilder::new()
|
||||
.layer(HandleErrorLayer::new(|_: BoxError| async {
|
||||
tracing::warn!("request timeout");
|
||||
StatusCode::REQUEST_TIMEOUT
|
||||
}))
|
||||
.layer(TimeoutLayer::new(Duration::from_millis(500))),
|
||||
)
|
||||
// .propagate_x_request_id()
|
||||
.fallback(|| async {
|
||||
Error::Request(RequestError::NotFound {
|
||||
message: "no route found".to_string(),
|
||||
})
|
||||
})
|
||||
.with_state(state)
|
||||
}
|
||||
1240
src/routing/routes.rs
Normal file
1240
src/routing/routes.rs
Normal file
File diff suppressed because it is too large
Load Diff
337
src/sqlite.rs
Normal file
337
src/sqlite.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
use std::fmt;
|
||||
use std::time;
|
||||
|
||||
use base64::Engine as _;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tracing::Instrument;
|
||||
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use sqlx::ConnectOptions;
|
||||
pub use sqlx::{Pool as SqlitePool, Sqlite};
|
||||
|
||||
use std::str::FromStr as _;
|
||||
|
||||
pub use sqlx::Type;
|
||||
|
||||
use crate::StartError;
|
||||
|
||||
pub type Pool = sqlx::Pool<sqlx::Sqlite>;
|
||||
|
||||
pub fn int_to_bool(value: i32) -> bool {
|
||||
match value {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => panic!("got invalid boolean from sqlite"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn init_database_pool(url: &str) -> Result<Pool, StartError> {
|
||||
async {
|
||||
SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(
|
||||
SqliteConnectOptions::from_str(url)?
|
||||
.log_statements(log::LevelFilter::Debug)
|
||||
.log_slow_statements(log::LevelFilter::Warn, time::Duration::from_millis(100))
|
||||
.pragma("foreign_keys", "1"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
.instrument(tracing::info_span!("packager::sql::pool"))
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn migrate(url: &str) -> Result<(), StartError> {
|
||||
async {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(
|
||||
SqliteConnectOptions::from_str(url)?
|
||||
.pragma("foreign_keys", "0")
|
||||
.log_statements(log::LevelFilter::Debug),
|
||||
)
|
||||
.await?;
|
||||
|
||||
sqlx::migrate!().run(&pool).await
|
||||
}
|
||||
.instrument(tracing::info_span!("packager::sql::migrate"))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub enum QueryType {
|
||||
Insert,
|
||||
Update,
|
||||
Select,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl fmt::Display for QueryType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
Self::Insert => "insert",
|
||||
Self::Update => "update",
|
||||
Self::Select => "select",
|
||||
Self::Delete => "delete",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Component {
|
||||
Inventory,
|
||||
User,
|
||||
Trips,
|
||||
}
|
||||
|
||||
impl fmt::Display for Component {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
Self::Inventory => "inventory",
|
||||
Self::User => "user",
|
||||
Self::Trips => "trips",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct QueryClassification {
|
||||
pub query_type: QueryType,
|
||||
pub component: Component,
|
||||
}
|
||||
|
||||
pub fn sqlx_query(
|
||||
classification: &QueryClassification,
|
||||
query: &str,
|
||||
labels: &[(&'static str, String)],
|
||||
) {
|
||||
let query_id = {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(query);
|
||||
hasher.finalize()
|
||||
};
|
||||
|
||||
// 9 bytes is enough to be unique
|
||||
// If this is divisible by 3, it means that we can base64-encode it without
|
||||
// any "=" padding
|
||||
//
|
||||
// cannot panic, as the output for sha256 will always be bit
|
||||
let query_id = &query_id[..9];
|
||||
|
||||
let query_id = base64::engine::general_purpose::STANDARD.encode(query_id);
|
||||
let mut labels = Vec::from(labels);
|
||||
labels.extend_from_slice(&[
|
||||
("query_id", query_id),
|
||||
("query_type", classification.query_type.to_string()),
|
||||
("query_component", classification.component.to_string()),
|
||||
]);
|
||||
metrics::counter!("packager_database_queries_total", 1, &labels);
|
||||
}
|
||||
|
||||
// This does not work, as the query*! macros expect a string literal for the query, so
|
||||
// it has to be there at compile time
|
||||
//
|
||||
// fn query_all<Row, Out>(
|
||||
// classification: &QueryClassification,
|
||||
// pool: &Pool,
|
||||
// query: &'static str,
|
||||
// args: &[&str],
|
||||
// ) {
|
||||
// async {
|
||||
// sqlx_query(classification, query, &[]);
|
||||
// let result: Result<Vec<Out>, Error> = sqlx::query_as!(Row, query, args)
|
||||
// .fetch(pool)
|
||||
// .map_ok(|row: Row| row.try_into())
|
||||
// .try_collect::<Vec<Result<Out, Error>>>()
|
||||
// .await?
|
||||
// .into_iter()
|
||||
// .collect::<Result<Vec<Out>, Error>>();
|
||||
|
||||
// result
|
||||
// }
|
||||
// .instrument(tracing::info_span!("packager::sql::query", "query"))
|
||||
// }
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! query_all {
|
||||
( $class:expr, $pool:expr, $struct_row:path, $struct_into:path, $query:expr, $( $args:tt )* ) => {
|
||||
{
|
||||
use tracing::Instrument as _;
|
||||
async {
|
||||
$crate::sqlite::sqlx_query($class, $query, &[]);
|
||||
let result: Result<Vec<$struct_into>, Error> = sqlx::query_as!(
|
||||
$struct_row,
|
||||
$query,
|
||||
$( $args )*
|
||||
)
|
||||
.fetch($pool)
|
||||
.map_ok(|row: $struct_row| row.try_into())
|
||||
.try_collect::<Vec<Result<$struct_into, Error>>>()
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<$struct_into>, Error>>();
|
||||
|
||||
result
|
||||
|
||||
}.instrument(tracing::info_span!("packager::sql::query", "query"))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! query_one {
|
||||
( $class:expr, $pool:expr, $struct_row:path, $struct_into:path, $query:expr, $( $args:tt )*) => {
|
||||
{
|
||||
use tracing::Instrument as _;
|
||||
async {
|
||||
$crate::sqlite::sqlx_query($class, $query, &[]);
|
||||
let result: Result<Option<$struct_into>, Error> = sqlx::query_as!(
|
||||
$struct_row,
|
||||
$query,
|
||||
$( $args )*
|
||||
)
|
||||
.fetch_optional($pool)
|
||||
.await?
|
||||
.map(|row: $struct_row| row.try_into())
|
||||
.transpose();
|
||||
|
||||
result
|
||||
|
||||
}.instrument(tracing::info_span!("packager::sql::query", "query"))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! query_exists {
|
||||
( $class:expr, $pool:expr, $query:expr, $( $args:tt )*) => {
|
||||
{
|
||||
use tracing::Instrument as _;
|
||||
async {
|
||||
$crate::sqlite::sqlx_query($class, $query, &[]);
|
||||
let result: bool = sqlx::query!(
|
||||
$query,
|
||||
$( $args )*
|
||||
)
|
||||
.fetch_optional($pool)
|
||||
.await?
|
||||
.is_some();
|
||||
|
||||
Ok(result)
|
||||
|
||||
}.instrument(tracing::info_span!("packager::sql::query", "query"))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! strip_plus {
|
||||
(+ $($rest:tt)*) => {
|
||||
$($rest)*
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! execute {
|
||||
( $class:expr, $pool:expr, $query:expr, $( $args:tt )*) => {
|
||||
{
|
||||
use tracing::Instrument as _;
|
||||
async {
|
||||
$crate::sqlite::sqlx_query($class, $query, &[]);
|
||||
let result: Result<sqlx::sqlite::SqliteQueryResult, Error> = sqlx::query!(
|
||||
$query,
|
||||
$( $args )*
|
||||
)
|
||||
.execute($pool)
|
||||
.await
|
||||
.map_err(|e| e.into());
|
||||
|
||||
result
|
||||
}.instrument(tracing::info_span!("packager::sql::query", "query"))
|
||||
}
|
||||
};
|
||||
|
||||
// ( $class:expr, $pool:expr, $( $query:expr )=>+, $( $args:tt )*) => {
|
||||
// {
|
||||
// use tracing::Instrument as _;
|
||||
// async {
|
||||
// // $crate::sqlite::sqlx_query($class, $( $query )+ , &[]);
|
||||
// // println!("haaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaay: {}", $crate::strip_plus!($(+ $query )+));
|
||||
// let result: Result<sqlx::sqlite::SqliteQueryResult, Error> = sqlx::query!(
|
||||
// // "x" + "y",
|
||||
// $crate::strip_plus!($(+ $query )+),
|
||||
// // "UPDATE trips_items
|
||||
// // SET " + "pick" +
|
||||
// // "= ?
|
||||
// // WHERE trip_id = ?
|
||||
// // AND item_id = ?
|
||||
// // AND user_id = ?",
|
||||
// $( $args )*
|
||||
// )
|
||||
// .execute($pool)
|
||||
// .await
|
||||
// .map_err(|e| e.into());
|
||||
|
||||
// result
|
||||
// }.instrument(tracing::info_span!("packager::sql::query", "query"))
|
||||
// }
|
||||
// };
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! execute_returning {
|
||||
( $class:expr, $pool:expr, $query:expr, $t:path, $fn:expr, $( $args:tt )*) => {
|
||||
{
|
||||
use tracing::Instrument as _;
|
||||
async {
|
||||
$crate::sqlite::sqlx_query($class, $query, &[]);
|
||||
let result: Result<$t, Error> = sqlx::query!(
|
||||
$query,
|
||||
$( $args )*
|
||||
)
|
||||
.fetch_one($pool)
|
||||
.map_ok($fn)
|
||||
.await
|
||||
.map_err(Into::into);
|
||||
|
||||
result
|
||||
|
||||
|
||||
}.instrument(tracing::info_span!("packager::sql::query", "query"))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! execute_returning_uuid {
|
||||
( $class:expr, $pool:expr, $query:expr, $( $args:tt )*) => {
|
||||
{
|
||||
use tracing::Instrument as _;
|
||||
async {
|
||||
$crate::sqlite::sqlx_query($class, $query, &[]);
|
||||
let result: Result<Uuid, Error> = sqlx::query!(
|
||||
$query,
|
||||
$( $args )*
|
||||
)
|
||||
.fetch_one($pool)
|
||||
.map_ok(|row| Uuid::try_parse(&row.id))
|
||||
.await?
|
||||
.map_err(Into::into);
|
||||
|
||||
result
|
||||
|
||||
|
||||
}.instrument(tracing::info_span!("packager::sql::query", "query"))
|
||||
}
|
||||
};
|
||||
}
|
||||
44
src/telemetry/metrics.rs
Normal file
44
src/telemetry/metrics.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use std::future::Future;
|
||||
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
|
||||
use axum_prometheus::{Handle, MakeDefaultHandle, PrometheusMetricLayerBuilder};
|
||||
|
||||
use crate::{Error, StartError};
|
||||
|
||||
pub struct LabelBool(bool);
|
||||
|
||||
/// Serves metrics on the specified `addr`.
|
||||
///
|
||||
/// You will get two outputs back: Another router, and a task that you have
|
||||
/// to run to actually spawn the metrics server endpoint
|
||||
pub fn prometheus_server(
|
||||
router: Router,
|
||||
addr: std::net::SocketAddr,
|
||||
) -> (Router, impl Future<Output = Result<(), Error>>) {
|
||||
let (prometheus_layer, metric_handle) = PrometheusMetricLayerBuilder::new()
|
||||
.with_prefix(env!("CARGO_PKG_NAME"))
|
||||
.with_metrics_from_fn(Handle::make_default_handle)
|
||||
.build_pair();
|
||||
|
||||
let app = Router::new().route("/metrics", get(|| async move { metric_handle.render() }));
|
||||
|
||||
let task = async move {
|
||||
if let Err(e) = axum::Server::try_bind(&addr)
|
||||
.map_err(|e| {
|
||||
Error::Start(StartError::BindError {
|
||||
message: e.to_string(),
|
||||
addr,
|
||||
})
|
||||
})?
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
{
|
||||
return Err(<hyper::Error as Into<Error>>::into(e));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
(router.layer(prometheus_layer), task)
|
||||
}
|
||||
2
src/telemetry/mod.rs
Normal file
2
src/telemetry/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod metrics;
|
||||
pub mod tracing;
|
||||
247
src/telemetry/tracing/mod.rs
Normal file
247
src/telemetry/tracing/mod.rs
Normal file
@@ -0,0 +1,247 @@
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use http::Request;
|
||||
use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer};
|
||||
use tracing::Span;
|
||||
|
||||
use tracing_subscriber::{
|
||||
filter::{LevelFilter, Targets},
|
||||
fmt::{format::Format, Layer},
|
||||
layer::SubscriberExt,
|
||||
prelude::*,
|
||||
registry::Registry,
|
||||
};
|
||||
|
||||
use tracing::Instrument;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(feature = "jaeger")]
|
||||
use opentelemetry::{global, runtime::Tokio};
|
||||
|
||||
pub enum OpenTelemetryConfig {
|
||||
Enabled,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
pub enum TokioConsoleConfig {
|
||||
Enabled,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
fn get_stdout_layer<
|
||||
T: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
|
||||
>() -> impl tracing_subscriber::Layer<T> {
|
||||
// default is the Full format, there is no way to specify this, but it can be
|
||||
// overridden via builder methods
|
||||
let stdout_format = Format::default()
|
||||
.pretty()
|
||||
.with_ansi(true)
|
||||
.with_target(true)
|
||||
.with_level(true)
|
||||
.with_file(false);
|
||||
|
||||
let stdout_filter = Targets::new()
|
||||
.with_default(LevelFilter::WARN)
|
||||
.with_targets(vec![
|
||||
(env!("CARGO_PKG_NAME"), LevelFilter::DEBUG),
|
||||
("sqlx", LevelFilter::DEBUG),
|
||||
("runtime", LevelFilter::OFF),
|
||||
("tokio", LevelFilter::OFF),
|
||||
]);
|
||||
|
||||
let stdout_layer = Layer::default()
|
||||
.event_format(stdout_format)
|
||||
.with_writer(io::stdout)
|
||||
.with_filter(stdout_filter);
|
||||
|
||||
stdout_layer.boxed()
|
||||
}
|
||||
|
||||
trait Forwarder {
|
||||
type Config;
|
||||
|
||||
fn build(
|
||||
config: Self::Config,
|
||||
shutdown_functions: &mut Vec<ShutdownFunction>,
|
||||
) -> Option<Box<dyn tracing_subscriber::Layer<dyn tracing::Subscriber>>>;
|
||||
}
|
||||
|
||||
#[cfg(feature = "jaeger")]
|
||||
fn get_jaeger_layer<
|
||||
T: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
|
||||
>(
|
||||
config: &OpenTelemetryConfig,
|
||||
shutdown_functions: &mut Vec<ShutdownFunction>,
|
||||
) -> Option<impl tracing_subscriber::Layer<T>> {
|
||||
match config {
|
||||
OpenTelemetryConfig::Enabled => {
|
||||
global::set_text_map_propagator(opentelemetry_jaeger::Propagator::new());
|
||||
// Sets up the machinery needed to export data to Jaeger
|
||||
// There are other OTel crates that provide pipelines for the vendors
|
||||
// mentioned earlier.
|
||||
let tracer = opentelemetry_jaeger::new_agent_pipeline()
|
||||
.with_service_name(env!("CARGO_PKG_NAME"))
|
||||
.with_max_packet_size(20_000)
|
||||
.with_auto_split_batch(true)
|
||||
.install_batch(Tokio)
|
||||
.unwrap();
|
||||
|
||||
let opentelemetry_filter = {
|
||||
Targets::new()
|
||||
.with_default(LevelFilter::DEBUG)
|
||||
.with_targets(vec![
|
||||
(env!("CARGO_PKG_NAME"), LevelFilter::DEBUG),
|
||||
("sqlx", LevelFilter::DEBUG),
|
||||
("runtime", LevelFilter::OFF),
|
||||
("tokio", LevelFilter::OFF),
|
||||
])
|
||||
};
|
||||
|
||||
let opentelemetry_layer = tracing_opentelemetry::layer()
|
||||
.with_tracer(tracer)
|
||||
.with_filter(opentelemetry_filter);
|
||||
|
||||
shutdown_functions.push(Box::new(|| {
|
||||
global::shutdown_tracer_provider();
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
Some(opentelemetry_layer)
|
||||
}
|
||||
OpenTelemetryConfig::Disabled => None,
|
||||
}
|
||||
}
|
||||
|
||||
type ShutdownFunction = Box<dyn FnOnce() -> Result<(), Box<dyn std::error::Error>>>;
|
||||
|
||||
pub async fn init<Func, T>(
|
||||
#[cfg(feature = "jaeger")] opentelemetry_config: OpenTelemetryConfig,
|
||||
#[cfg(feature = "tokio-console")] tokio_console_config: TokioConsoleConfig,
|
||||
args: crate::cli::Args,
|
||||
f: Func,
|
||||
) -> T
|
||||
where
|
||||
Func: FnOnce(crate::cli::Args) -> Pin<Box<dyn Future<Output = T>>>,
|
||||
T: std::process::Termination,
|
||||
{
|
||||
// mut is dependent on features (it's only required when jaeger is set), so
|
||||
// let's just disable the lint
|
||||
#[cfg(feature = "jaeger")]
|
||||
let mut shutdown_functions: Vec<ShutdownFunction> = vec![];
|
||||
|
||||
#[cfg(not(feature = "jaeger"))]
|
||||
let shutdown_functions: Vec<ShutdownFunction> = vec![];
|
||||
|
||||
#[cfg(feature = "tokio-console")]
|
||||
let console_layer = match tokio_console_config {
|
||||
TokioConsoleConfig::Enabled => Some(console_subscriber::Builder::default().spawn()),
|
||||
TokioConsoleConfig::Disabled => None,
|
||||
};
|
||||
|
||||
let stdout_layer = get_stdout_layer();
|
||||
|
||||
#[cfg(feature = "jaeger")]
|
||||
let jaeger_layer = get_jaeger_layer(&opentelemetry_config, &mut shutdown_functions);
|
||||
|
||||
let registry = Registry::default();
|
||||
|
||||
#[cfg(feature = "tokio-console")]
|
||||
let registry = registry.with(console_layer);
|
||||
|
||||
#[cfg(feature = "jaeger")]
|
||||
let registry = registry.with(jaeger_layer);
|
||||
// just an example, you can actuall pass Options here for layers that might be
|
||||
// set/unset at runtime
|
||||
|
||||
let registry = registry.with(stdout_layer).with(None::<Layer<_>>);
|
||||
|
||||
tracing::subscriber::set_global_default(registry).unwrap();
|
||||
|
||||
tracing_log::log_tracer::Builder::new().init().unwrap();
|
||||
|
||||
let result = f(args)
|
||||
.instrument(tracing::debug_span!(target: env!("CARGO_PKG_NAME"), env!("CARGO_PKG_NAME")))
|
||||
.await;
|
||||
|
||||
for shutdown_func in shutdown_functions {
|
||||
shutdown_func().unwrap();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
struct Latency(Duration);
|
||||
|
||||
impl fmt::Display for Latency {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0.as_micros())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_request_tracing(router: Router) -> Router {
|
||||
router.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(|_request: &Request<_>| {
|
||||
let request_id = Uuid::new_v4();
|
||||
tracing::debug_span!(
|
||||
target: "packager::request",
|
||||
"request",
|
||||
%request_id,
|
||||
)
|
||||
})
|
||||
.on_request(|request: &Request<_>, _span: &Span| {
|
||||
let request_headers = request.headers();
|
||||
let http_version = request.version();
|
||||
tracing::debug!(
|
||||
target: "packager::request",
|
||||
method = request.method().as_str(),
|
||||
path = request.uri().path(),
|
||||
?http_version,
|
||||
?request_headers,
|
||||
"request received",
|
||||
);
|
||||
})
|
||||
.on_response(
|
||||
|response: &axum::response::Response, latency: Duration, _span: &Span| {
|
||||
let response_headers = response.headers();
|
||||
let latency = Latency(latency);
|
||||
tracing::debug!(
|
||||
target: "packager::request",
|
||||
%latency,
|
||||
status = response.status().as_str(),
|
||||
?response_headers,
|
||||
"finished processing request",
|
||||
);
|
||||
},
|
||||
)
|
||||
.on_failure(
|
||||
|error: ServerErrorsFailureClass, latency: Duration, _span: &Span| {
|
||||
let latency = Latency(latency);
|
||||
match error {
|
||||
ServerErrorsFailureClass::StatusCode(code) => {
|
||||
tracing::error!(
|
||||
target: "packager::request",
|
||||
%latency,
|
||||
"request failed with error response {}",
|
||||
code,
|
||||
);
|
||||
}
|
||||
ServerErrorsFailureClass::Error(message) => {
|
||||
tracing::error!(
|
||||
target: "packager::request",
|
||||
%latency,
|
||||
"request failed: {}",
|
||||
message,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
21
src/view/error.rs
Normal file
21
src/view/error.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use maud::{html, Markup, DOCTYPE};
|
||||
|
||||
pub struct ErrorPage;
|
||||
|
||||
impl ErrorPage {
|
||||
#[tracing::instrument]
|
||||
pub fn build(message: &str) -> Markup {
|
||||
html!(
|
||||
(DOCTYPE)
|
||||
html {
|
||||
head {
|
||||
title { "Packager" }
|
||||
}
|
||||
body {
|
||||
h1 { "Error" }
|
||||
p { (message) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
328
src/view/home.rs
Normal file
328
src/view/home.rs
Normal file
@@ -0,0 +1,328 @@
|
||||
use maud::{html, Markup};
|
||||
|
||||
pub struct Home;
|
||||
|
||||
impl Home {
|
||||
#[tracing::instrument]
|
||||
pub fn build() -> Markup {
|
||||
html!(
|
||||
div
|
||||
id="home"
|
||||
."p-8"
|
||||
."flex"
|
||||
."flex-col"
|
||||
."gap-8"
|
||||
."flex-nowrap"
|
||||
{
|
||||
h1
|
||||
."text-2xl"
|
||||
."m-auto"
|
||||
."my-4"
|
||||
{
|
||||
"Welcome!"
|
||||
}
|
||||
section
|
||||
."border-2"
|
||||
."border-gray-200"
|
||||
."flex"
|
||||
."flex-row"
|
||||
{
|
||||
a
|
||||
href="/inventory/"
|
||||
."p-8"
|
||||
."w-1/5"
|
||||
."flex"
|
||||
."hover:bg-gray-200"
|
||||
{
|
||||
span
|
||||
."m-auto"
|
||||
."text-xl"
|
||||
{ "Inventory" }
|
||||
}
|
||||
div
|
||||
."p-8"
|
||||
."w-4/5"
|
||||
."flex"
|
||||
."flex-col"
|
||||
."gap-3"
|
||||
{
|
||||
p {
|
||||
"The inventory contains all the items that you own."
|
||||
}
|
||||
p {
|
||||
"It is effectively a list of items, sectioned into
|
||||
arbitrary categories"
|
||||
}
|
||||
p {
|
||||
"Each item has some important data attached to it,
|
||||
like its weight"
|
||||
}
|
||||
}
|
||||
}
|
||||
section
|
||||
."border-2"
|
||||
."border-gray-200"
|
||||
."flex"
|
||||
."flex-row"
|
||||
{
|
||||
a
|
||||
href="/trips/"
|
||||
."p-8"
|
||||
."w-1/5"
|
||||
."flex"
|
||||
."hover:bg-gray-200"
|
||||
{
|
||||
span
|
||||
."m-auto"
|
||||
."text-xl"
|
||||
{ "Trips" }
|
||||
}
|
||||
div
|
||||
."p-8"
|
||||
."w-4/5"
|
||||
."flex"
|
||||
."flex-col"
|
||||
."gap-6"
|
||||
{
|
||||
div
|
||||
."flex"
|
||||
."flex-col"
|
||||
."gap-3"
|
||||
{
|
||||
p {
|
||||
"Trips is where it gets interesting, as you can put
|
||||
your inventory to good use"
|
||||
}
|
||||
p {
|
||||
r#"With trips, you record any trips you plan to do. A
|
||||
"trip" can be anything you want it to be. Anything
|
||||
from a multi-week hike, a high altitude mountaineering
|
||||
tour or just a visit to the library. Whenever it makes
|
||||
sense to do some planning, creating a trip makes sense."#
|
||||
}
|
||||
p {
|
||||
"Each trip has some metadata attached to it, like start-
|
||||
and end dates or the expected temperature."
|
||||
}
|
||||
}
|
||||
div
|
||||
."flex"
|
||||
."flex-col"
|
||||
."gap-3"
|
||||
{
|
||||
div
|
||||
."flex"
|
||||
."flex-row"
|
||||
."gap-2"
|
||||
."items-center"
|
||||
."justify-start"
|
||||
{
|
||||
span
|
||||
."mdi"
|
||||
."mdi-pound"
|
||||
."text-lg"
|
||||
."text-gray-300"
|
||||
{}
|
||||
h3 ."text-lg" {
|
||||
"States"
|
||||
}
|
||||
}
|
||||
p {
|
||||
"One of the most important parts of each trip is
|
||||
its " em{"state"} ", which determines certain
|
||||
actions on the trip and can have the following values:"
|
||||
}
|
||||
table
|
||||
."table"
|
||||
."table-auto"
|
||||
."border-collapse"
|
||||
{
|
||||
tr
|
||||
."border-b-2"
|
||||
."last:border-b-0"
|
||||
{
|
||||
td ."py-2" ."pr-4" ."border-r-2" {
|
||||
"Init"
|
||||
}
|
||||
td ."py-2" ."w-full" ."pl-4" {
|
||||
"The new trip was just created"
|
||||
}
|
||||
}
|
||||
tr
|
||||
."border-b-2"
|
||||
."last:border-b-0"
|
||||
{
|
||||
td ."py-2" ."pr-4" ."border-r-2" {
|
||||
"Planning"
|
||||
}
|
||||
td ."py-2" ."w-full" ."pl-4" {
|
||||
"Now, you actually start planning the trip.
|
||||
Setting the location, going through your
|
||||
items to decide what to take with you."
|
||||
}
|
||||
}
|
||||
tr
|
||||
."border-b-2"
|
||||
."last:border-b-0"
|
||||
{
|
||||
td ."py-2" ."pr-4" ."border-r-2" {
|
||||
"Planned"
|
||||
}
|
||||
td ."py-2" ."w-full" ."pl-4" {
|
||||
"You are done with the planning. It's time
|
||||
to pack up your stuff and get going."
|
||||
}
|
||||
}
|
||||
tr
|
||||
."border-b-2"
|
||||
."last:border-b-0"
|
||||
{
|
||||
td ."py-2" ."pr-4" ."border-r-2" {
|
||||
"Active"
|
||||
}
|
||||
td ."py-2" ."w-full" ."pl-4" {
|
||||
"The trip is finally underway!"
|
||||
}
|
||||
}
|
||||
tr
|
||||
."border-b-2"
|
||||
."last:border-b-0"
|
||||
{
|
||||
td ."py-2" ."pr-4" ."border-r-2" {
|
||||
"Review"
|
||||
}
|
||||
td ."py-2" ."w-full" ."pl-4" {
|
||||
div
|
||||
."flex"
|
||||
."flex-col"
|
||||
."gap-2"
|
||||
{
|
||||
p {
|
||||
"You returned from your trip. It may make
|
||||
sense to take a look back and see what
|
||||
went well and what went not so well."
|
||||
}
|
||||
p {
|
||||
"Anything you missed? Any items that you
|
||||
took with you that turned out to be useless?
|
||||
Record it and you will remember on your next
|
||||
trip"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tr
|
||||
."border-b-2"
|
||||
."last:border-b-0"
|
||||
{
|
||||
td ."py-2" ."pr-4" ."border-r-2" {
|
||||
"Done"
|
||||
}
|
||||
td ."py-2" ."w-full" ."pl-4" {
|
||||
"Your review is done and the trip can be laid to rest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div
|
||||
."flex"
|
||||
."flex-col"
|
||||
."gap-3"
|
||||
{
|
||||
div
|
||||
."flex"
|
||||
."flex-row"
|
||||
."gap-2"
|
||||
."items-center"
|
||||
."justify-start"
|
||||
{
|
||||
span
|
||||
."mdi"
|
||||
."mdi-pound"
|
||||
."text-lg"
|
||||
."text-gray-300"
|
||||
{}
|
||||
h3 ."text-lg" {
|
||||
"Items"
|
||||
}
|
||||
}
|
||||
p {
|
||||
"Of course, you can use items defined in your
|
||||
inventory in your trips"
|
||||
}
|
||||
p {
|
||||
"Generally, all items are available to you in
|
||||
the same way as the inventory. For each item,
|
||||
there are two specific states for the trip: An
|
||||
item can be " em{"picked"} ", which means that
|
||||
you plan to take it on the trip, and it can
|
||||
be " em{"packed"} ", which means that you actually
|
||||
packed it into your bag (and therefore, you cannot
|
||||
forget it any more)"
|
||||
}
|
||||
}
|
||||
div
|
||||
."flex"
|
||||
."flex-col"
|
||||
."gap-3"
|
||||
{
|
||||
div
|
||||
."flex"
|
||||
."flex-row"
|
||||
."gap-2"
|
||||
."items-center"
|
||||
."justify-start"
|
||||
{
|
||||
span
|
||||
."mdi"
|
||||
."mdi-pound"
|
||||
."text-lg"
|
||||
."text-gray-300"
|
||||
{}
|
||||
h3 ."text-lg" {
|
||||
"Types & Presets"
|
||||
}
|
||||
}
|
||||
p {
|
||||
"Often, you will take a certain set of items to
|
||||
certain trips. Whenever you plan to sleep outdoors,
|
||||
it makes sense to take your sleeping bag and mat
|
||||
with you"
|
||||
}
|
||||
p {
|
||||
"To reflect this, you can attach " em {"types"} " "
|
||||
"to your trips. Types define arbitrary characteristics
|
||||
about a trip and reference a certain set of items."
|
||||
}
|
||||
p {
|
||||
"Here are some examples of types that might make sense:"
|
||||
}
|
||||
ul
|
||||
."list-disc"
|
||||
."list-inside"
|
||||
{
|
||||
li {
|
||||
r#""Biking": Make sure to pack your helmet and
|
||||
some repair tools"#
|
||||
}
|
||||
li {
|
||||
r#""Climbing": You certainly don't want to forget
|
||||
your climbing shoes"#
|
||||
}
|
||||
li {
|
||||
r#""Rainy": Pack a rain jacket and some waterproof
|
||||
shoes"#
|
||||
}
|
||||
}
|
||||
p {
|
||||
"Types are super flexible, it's up to you how to use
|
||||
them"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
636
src/view/inventory.rs
Normal file
636
src/view/inventory.rs
Normal file
@@ -0,0 +1,636 @@
|
||||
use maud::{html, Markup};
|
||||
|
||||
use crate::models;
|
||||
use crate::ClientState;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct Inventory;
|
||||
|
||||
impl Inventory {
|
||||
#[tracing::instrument(
|
||||
target = "packager::html::build",
|
||||
name = "build_inventory",
|
||||
fields(component = "Inventory")
|
||||
skip(categories)
|
||||
)]
|
||||
pub fn build(
|
||||
active_category: Option<&models::inventory::Category>,
|
||||
categories: &Vec<models::inventory::Category>,
|
||||
edit_item_id: Option<Uuid>,
|
||||
) -> Markup {
|
||||
html!(
|
||||
div id="pkglist-item-manager" {
|
||||
div ."p-8" ."grid" ."grid-cols-4" ."gap-5" {
|
||||
div ."col-span-2" ."flex" ."flex-col" ."gap-8" {
|
||||
h1 ."text-2xl" ."text-center" { "Categories" }
|
||||
(InventoryCategoryList::build(active_category, categories))
|
||||
(InventoryNewCategoryForm::build())
|
||||
}
|
||||
div ."col-span-2" ."flex" ."flex-col" ."gap-8" {
|
||||
h1 ."text-2xl" ."text-center" { "Items" }
|
||||
@if let Some(active_category) = active_category {
|
||||
(InventoryItemList::build(edit_item_id, active_category.items()))
|
||||
}
|
||||
(InventoryNewItemForm::build(active_category, categories))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InventoryCategoryList;
|
||||
|
||||
impl InventoryCategoryList {
|
||||
#[tracing::instrument(
|
||||
target = "packager::html::build",
|
||||
name = "build_inventory_category_list",
|
||||
fields(component = "InventoryCategoryList"),
|
||||
skip(categories)
|
||||
)]
|
||||
pub fn build(
|
||||
active_category: Option<&models::inventory::Category>,
|
||||
categories: &Vec<models::inventory::Category>,
|
||||
) -> Markup {
|
||||
let biggest_category_weight: i64 = categories
|
||||
.iter()
|
||||
.map(models::inventory::Category::total_weight)
|
||||
.max()
|
||||
.unwrap_or(1);
|
||||
|
||||
html!(
|
||||
table
|
||||
#category-list
|
||||
."table"
|
||||
."table-auto"
|
||||
."border-collapse"
|
||||
."border-spacing-0"
|
||||
."border"
|
||||
."w-full"
|
||||
{
|
||||
|
||||
colgroup {
|
||||
col style="width:50%" {}
|
||||
col style="width:50%" {}
|
||||
}
|
||||
thead ."bg-gray-200" {
|
||||
tr ."h-10" {
|
||||
th ."border" ."p-2" ."w-3/5" { "Name" }
|
||||
th ."border" ."p-2" { "Weight" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
@for category in categories {
|
||||
@let active = active_category.map_or(false, |c| category.id == c.id);
|
||||
tr
|
||||
."h-10"
|
||||
."hover:bg-gray-100"
|
||||
."m-3"
|
||||
."h-full"
|
||||
."outline"[active]
|
||||
."outline-2"[active]
|
||||
."outline-indigo-300"[active]
|
||||
."pointer-events-none"[active]
|
||||
{
|
||||
|
||||
td
|
||||
class=@if active_category.map_or(false, |c| category.id == c.id) {
|
||||
"border p-0 m-0 font-bold"
|
||||
} @else {
|
||||
"border p-0 m-0"
|
||||
} {
|
||||
a
|
||||
id="select-category"
|
||||
href={
|
||||
"/inventory/category/"
|
||||
(category.id) "/"
|
||||
}
|
||||
hx-post={
|
||||
"/inventory/categories/"
|
||||
(category.id)
|
||||
"/select"
|
||||
}
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#pkglist-item-manager"
|
||||
."inline-block" ."p-2" ."m-0" ."w-full"
|
||||
{
|
||||
(category.name.clone())
|
||||
}
|
||||
}
|
||||
td ."border" ."p-2" ."m-0" style="position:relative;" {
|
||||
p {
|
||||
(category.total_weight().to_string())
|
||||
}
|
||||
div ."bg-blue-600" ."h-1.5"
|
||||
style=(
|
||||
format!(
|
||||
"width: {width}%;position:absolute;left:0;bottom:0;right:0;",
|
||||
width=(
|
||||
(category.total_weight() as f64)
|
||||
/ (biggest_category_weight as f64)
|
||||
* 100.0
|
||||
)
|
||||
)
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
tr ."h-10" ."bg-gray-300" ."font-bold" {
|
||||
td ."border" ."p-0" ."m-0" {
|
||||
p ."p-2" ."m-2" { "Sum" }
|
||||
}
|
||||
td ."border" ."p-0" ."m-0" {
|
||||
p ."p-2" ."m-2" {
|
||||
(categories.iter().map(models::inventory::Category::total_weight).sum::<i64>().to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InventoryItemList;
|
||||
|
||||
impl InventoryItemList {
|
||||
#[tracing::instrument(
|
||||
target = "packager::html::build",
|
||||
name = "build_inventory_item_list",
|
||||
fields(component = "InventoryItemList"),
|
||||
skip(items)
|
||||
)]
|
||||
pub fn build(edit_item_id: Option<Uuid>, items: &Vec<models::inventory::Item>) -> Markup {
|
||||
let biggest_item_weight: i64 = items.iter().map(|item| item.weight).max().unwrap_or(1);
|
||||
html!(
|
||||
div #items {
|
||||
@if items.is_empty() {
|
||||
p ."text-lg" ."text-center" ."py-5" ."text-gray-400" { "[Empty]" }
|
||||
} @else {
|
||||
@if let Some(edit_item_id) = edit_item_id {
|
||||
form
|
||||
name="edit-item"
|
||||
id="edit-item"
|
||||
action={"/inventory/item/" (edit_item_id) "/edit"}
|
||||
target="_self"
|
||||
method="post"
|
||||
{}
|
||||
}
|
||||
table
|
||||
."table"
|
||||
."table-auto"
|
||||
."table-fixed"
|
||||
."border-collapse"
|
||||
."border-spacing-0"
|
||||
."border"
|
||||
."w-full"
|
||||
{
|
||||
thead ."bg-gray-200" {
|
||||
tr ."h-10" {
|
||||
th ."border" ."p-2" ."w-3/5" { "Name" }
|
||||
th ."border" ."p-2" { "Weight" }
|
||||
th ."border" ."p-2" ."w-10" {}
|
||||
th ."border" ."p-2" ."w-10" {}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
@for item in items {
|
||||
@if edit_item_id.map_or(false, |id| id == item.id) {
|
||||
tr ."h-10" {
|
||||
td ."border" ."bg-blue-300" ."px-2" ."py-0" {
|
||||
div ."h-full" ."w-full" ."flex" {
|
||||
input ."m-auto" ."px-1" ."block" ."w-full" ."bg-blue-100" ."hover:bg-white"
|
||||
type="text"
|
||||
id="edit-item-name"
|
||||
name="edit-item-name"
|
||||
form="edit-item"
|
||||
value=(item.name)
|
||||
{}
|
||||
}
|
||||
}
|
||||
td ."border" ."bg-blue-300" ."px-2" ."py-0" {
|
||||
div ."h-full" ."w-full" ."flex" {
|
||||
input ."m-auto" ."px-1"."block" ."w-full" ."bg-blue-100" ."hover:bg-white"
|
||||
type="number"
|
||||
id="edit-item-weight"
|
||||
name="edit-item-weight"
|
||||
form="edit-item"
|
||||
value=(item.weight)
|
||||
{}
|
||||
}
|
||||
}
|
||||
td
|
||||
."border-none"
|
||||
."bg-green-100"
|
||||
."hover:bg-green-200"
|
||||
."p-0"
|
||||
."h-full"
|
||||
{
|
||||
button
|
||||
."aspect-square"
|
||||
."flex"
|
||||
."w-full"
|
||||
."h-full"
|
||||
type="submit"
|
||||
form="edit-item"
|
||||
{
|
||||
span
|
||||
."m-auto"
|
||||
."mdi"
|
||||
."mdi-content-save"
|
||||
."text-xl"
|
||||
{}
|
||||
}
|
||||
}
|
||||
td
|
||||
."border-none"
|
||||
."bg-red-100"
|
||||
."hover:bg-red-200"
|
||||
."p-0"
|
||||
."h-full"
|
||||
{
|
||||
a
|
||||
href=(format!("/inventory/item/{id}/cancel", id = item.id))
|
||||
."aspect-square"
|
||||
."flex"
|
||||
."w-full"
|
||||
."h-full"
|
||||
."p-0"
|
||||
{
|
||||
span
|
||||
."m-auto"
|
||||
."mdi"
|
||||
."mdi-cancel"
|
||||
."text-xl"
|
||||
{}
|
||||
}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
tr ."h-10" {
|
||||
td ."border" ."p-0" {
|
||||
a
|
||||
."p-2" ."w-full" ."inline-block"
|
||||
href=(
|
||||
format!("/inventory/item/{id}/", id=item.id)
|
||||
)
|
||||
{
|
||||
(item.name.clone())
|
||||
}
|
||||
}
|
||||
td ."border" ."p-2" style="position:relative;" {
|
||||
p { (item.weight.to_string()) }
|
||||
div ."bg-blue-600" ."h-1.5" style=(format!("
|
||||
width: {width}%;
|
||||
position:absolute;
|
||||
left:0;
|
||||
bottom:0;
|
||||
right:0;", width=((item.weight as f64) / (biggest_item_weight as f64) * 100.0))) {}
|
||||
}
|
||||
td
|
||||
."border-none"
|
||||
."p-0"
|
||||
."bg-blue-200"
|
||||
."hover:bg-blue-400"
|
||||
."w-8"
|
||||
."h-full"
|
||||
{
|
||||
a
|
||||
href=(format!("?edit_item={id}", id = item.id))
|
||||
."aspect-square"
|
||||
."flex"
|
||||
."w-full"
|
||||
{
|
||||
span ."m-auto" ."mdi" ."mdi-pencil" ."text-xl" {}
|
||||
}
|
||||
}
|
||||
td
|
||||
."border-none"
|
||||
."p-0"
|
||||
."bg-red-200"
|
||||
."hover:bg-red-400"
|
||||
."w-8"
|
||||
."h-full"
|
||||
{
|
||||
a
|
||||
href=(format!("/inventory/item/{id}/delete", id = item.id))
|
||||
."aspect-square"
|
||||
."flex"
|
||||
."w-full"
|
||||
{
|
||||
span ."m-auto" ."mdi" ."mdi-delete" ."text-xl" {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InventoryNewItemFormName;
|
||||
|
||||
impl InventoryNewItemFormName {
|
||||
#[tracing::instrument(
|
||||
target = "packager::html::build",
|
||||
name = "build_inventory_new_item_form_name",
|
||||
fields(component = "InventoryNewItemFormName")
|
||||
)]
|
||||
pub fn build(value: Option<&str>, error: bool) -> Markup {
|
||||
html!(
|
||||
div
|
||||
."grid"
|
||||
."grid-cols-[2fr,3fr]"
|
||||
."justify-items-center"
|
||||
."items-center"
|
||||
hx-post="/inventory/item/name/validate"
|
||||
hx-trigger="input delay:1s, loaded from:document"
|
||||
hx-target="this"
|
||||
hx-params="new-item-name"
|
||||
hx-swap="outerHTML"
|
||||
#abc
|
||||
{
|
||||
label for="name" .font-bold { "Name" }
|
||||
input
|
||||
type="text"
|
||||
id="new-item-name"
|
||||
name="new-item-name"
|
||||
x-on:input="(e) => {save_active = inventory_new_item_check_input()}"
|
||||
."block"
|
||||
."w-full"
|
||||
."p-2"
|
||||
."bg-gray-50"
|
||||
."border-2"
|
||||
."border-red-500"[error]
|
||||
."border-gray-300"[!error]
|
||||
."focus:outline-none"
|
||||
."focus:bg-white"
|
||||
."focus:border-gray-500"[!error]
|
||||
value=[value]
|
||||
{}
|
||||
@if error {
|
||||
div
|
||||
."col-start-2"
|
||||
."text-sm"
|
||||
."text-red-500"
|
||||
{ "name already exists" }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InventoryNewItemFormWeight;
|
||||
|
||||
impl InventoryNewItemFormWeight {
|
||||
#[tracing::instrument(
|
||||
target = "packager::html::build",
|
||||
name = "build_inventory_new_item_form_weight",
|
||||
fields(component = "InventoryNewItemFormWeight")
|
||||
)]
|
||||
pub fn build() -> Markup {
|
||||
html!(
|
||||
div
|
||||
."grid"
|
||||
."grid-cols-[2fr,3fr]"
|
||||
."justify-items-center"
|
||||
."items-center"
|
||||
{
|
||||
label for="weight" .font-bold { "Weight" }
|
||||
input
|
||||
type="number"
|
||||
id="new-item-weight"
|
||||
name="new-item-weight"
|
||||
min="0"
|
||||
x-on:input="(e) => {
|
||||
save_active = inventory_new_item_check_input();
|
||||
weight_error = !check_weight();
|
||||
}"
|
||||
x-bind:class="weight_error && 'border-red-500' || 'border-gray-300 focus:border-gray-500'"
|
||||
."block"
|
||||
."w-full"
|
||||
."p-2"
|
||||
."bg-gray-50"
|
||||
."border-2"
|
||||
."focus:outline-none"
|
||||
."focus:bg-white"
|
||||
{}
|
||||
span
|
||||
// x-on produces some errors, this works as well
|
||||
x-bind:class="!weight_error && 'hidden'"
|
||||
."col-start-2"
|
||||
."text-sm"
|
||||
."text-red-500"
|
||||
{ "invalid input" }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InventoryNewItemFormCategory;
|
||||
|
||||
impl InventoryNewItemFormCategory {
|
||||
#[tracing::instrument(
|
||||
target = "packager::html::build",
|
||||
name = "build_inventory_new_item_form_category",
|
||||
fields(component = "InventoryNewItemFormCategory"),
|
||||
skip(categories)
|
||||
)]
|
||||
pub fn build(
|
||||
active_category: Option<&models::inventory::Category>,
|
||||
categories: &Vec<models::inventory::Category>,
|
||||
) -> Markup {
|
||||
html!(
|
||||
div
|
||||
."grid"
|
||||
."grid-cols-[2fr,3fr]"
|
||||
."justify-items-center"
|
||||
."items-center"
|
||||
{
|
||||
label for="item-category" .font-bold ."w-1/2" .text-center { "Category" }
|
||||
select
|
||||
id="new-item-category-id"
|
||||
name="new-item-category-id"
|
||||
."block"
|
||||
."w-full"
|
||||
."p-2"
|
||||
."bg-gray-50"
|
||||
."border-2"
|
||||
."border-gray-300"
|
||||
."focus:outline-none"
|
||||
."focus:bg-white"
|
||||
."focus:border-gray-500"
|
||||
autocomplete="off" // https://stackoverflow.com/a/10096033
|
||||
{
|
||||
@for category in categories {
|
||||
option value=(category.id) selected[active_category.map_or(false, |c| c.id == category.id)] {
|
||||
(category.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InventoryNewItemForm;
|
||||
|
||||
impl InventoryNewItemForm {
|
||||
#[tracing::instrument(
|
||||
target = "packager::html::build",
|
||||
name = "build_inventory_new_item_form",
|
||||
fields(component = "InventoryNewItemForm"),
|
||||
skip(categories)
|
||||
)]
|
||||
pub fn build(
|
||||
active_category: Option<&models::inventory::Category>,
|
||||
categories: &Vec<models::inventory::Category>,
|
||||
) -> Markup {
|
||||
html!(
|
||||
form
|
||||
x-data="{
|
||||
save_active: inventory_new_item_check_input(),
|
||||
weight_error: !check_weight(),
|
||||
}"
|
||||
name="new-item"
|
||||
hx-post="/inventory/item/"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#pkglist-item-manager"
|
||||
id="new-item"
|
||||
action="/inventory/item/"
|
||||
target="_self"
|
||||
method="post"
|
||||
."p-5" ."border-2" ."border-gray-200" {
|
||||
div ."mb-5" ."flex" ."flex-row" ."items-center" {
|
||||
span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {}
|
||||
p ."inline" ."text-xl" { "Add new item" }
|
||||
}
|
||||
div ."w-11/12" ."mx-auto" ."flex" ."flex-col" ."gap-8" {
|
||||
(InventoryNewItemFormName::build(None, false))
|
||||
(InventoryNewItemFormWeight::build())
|
||||
(InventoryNewItemFormCategory::build(active_category, categories))
|
||||
input type="submit" value="Add"
|
||||
x-bind:disabled="!save_active"
|
||||
."enabled:cursor-pointer"
|
||||
."disabled:opacity-50"
|
||||
."py-2"
|
||||
."border-2"
|
||||
."border-gray-300"
|
||||
."mx-auto"
|
||||
."w-full" {
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InventoryNewCategoryForm;
|
||||
|
||||
impl InventoryNewCategoryForm {
|
||||
#[tracing::instrument(
|
||||
target = "packager::html::build",
|
||||
name = "build_inventory_new_category_form",
|
||||
fields(component = "InventoryNewCategoryForm")
|
||||
)]
|
||||
pub fn build() -> Markup {
|
||||
html!(
|
||||
form
|
||||
x-data="{ save_active: document.getElementById('new-category-name').value.length != 0 }"
|
||||
name="new-category"
|
||||
id="new-category"
|
||||
action="/inventory/category/"
|
||||
target="_self"
|
||||
method="post"
|
||||
."p-5" ."border-2" ."border-gray-200" {
|
||||
div ."mb-5" ."flex" ."flex-row" ."items-center" {
|
||||
span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {}
|
||||
p ."inline" ."text-xl" { "Add new category" }
|
||||
}
|
||||
div ."w-11/12" ."mx-auto" {
|
||||
div ."pb-8" {
|
||||
div ."flex" ."flex-row" ."justify-center" ."items-start"{
|
||||
label for="name" .font-bold ."w-1/2" ."p-2" ."text-center" { "Name" }
|
||||
span ."w-1/2" {
|
||||
input type="text" id="new-category-name" name="new-category-name"
|
||||
x-on:input="(e) => {save_active = e.target.value.length != 0 }"
|
||||
."block"
|
||||
."w-full"
|
||||
."p-2"
|
||||
."bg-gray-50"
|
||||
."border-2"
|
||||
."border-gray-300"
|
||||
."focus:outline-none"
|
||||
."focus:bg-white"
|
||||
."focus:border-gray-500"
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
input type="submit" value="Add"
|
||||
x-bind:disabled="!save_active"
|
||||
."enabled:cursor-pointer"
|
||||
."disabled:opacity-50"
|
||||
."py-2"
|
||||
."border-2"
|
||||
."border-gray-300"
|
||||
."mx-auto"
|
||||
."w-full" {
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InventoryItem;
|
||||
|
||||
impl InventoryItem {
|
||||
#[tracing::instrument(
|
||||
target = "packager::html::build",
|
||||
name = "build_inventory_item",
|
||||
fields(component = "InventoryItem")
|
||||
)]
|
||||
pub fn build(_state: &ClientState, item: &models::inventory::InventoryItem) -> Markup {
|
||||
html!(
|
||||
div ."p-8" {
|
||||
table
|
||||
."table"
|
||||
."table-auto"
|
||||
."border-collapse"
|
||||
."border-spacing-0"
|
||||
."border"
|
||||
."w-full"
|
||||
{
|
||||
tbody {
|
||||
tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" {
|
||||
td ."border" ."p-2" { "Name" }
|
||||
td ."border" ."p-2" { (item.name) }
|
||||
}
|
||||
tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" {
|
||||
td ."border" ."p-2" { "Description" }
|
||||
td ."border" ."p-2" { (item.description.clone().unwrap_or(String::new())) }
|
||||
}
|
||||
tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" {
|
||||
td ."border" ."p-2" { "Weight" }
|
||||
td ."border" ."p-2" { (item.weight.to_string()) }
|
||||
}
|
||||
tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" {
|
||||
td ."border" ."p-2" { "Category" }
|
||||
td ."border" ."p-2" { (item.category.name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@match item.product {
|
||||
Some(ref product) => p { "this item is part of product" (product.name) },
|
||||
None => p { "this item is not part of a product" },
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
128
src/view/mod.rs
Normal file
128
src/view/mod.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use std::fmt;
|
||||
|
||||
use base64::Engine as _;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::Context;
|
||||
use maud::Markup;
|
||||
|
||||
pub mod error;
|
||||
pub mod home;
|
||||
pub mod inventory;
|
||||
pub mod root;
|
||||
pub mod trip;
|
||||
|
||||
pub use error::ErrorPage;
|
||||
pub use root::Root;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum HtmxAction {
|
||||
Get(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for HtmxAction {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::Get(path) => write!(f, "{path}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FallbackAction {
|
||||
Get(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for FallbackAction {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::Get(path) => write!(f, "{path}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ComponentId(String);
|
||||
|
||||
impl ComponentId {
|
||||
#[tracing::instrument]
|
||||
// fn new() -> Self {
|
||||
// NOTE: this could also use a static AtomicUsize incrementing integer, which might be faster
|
||||
// Self(random::<u32>())
|
||||
// }
|
||||
#[tracing::instrument]
|
||||
fn html_id(&self) -> String {
|
||||
let id = {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.0.as_bytes());
|
||||
hasher.finalize()
|
||||
};
|
||||
|
||||
// 9 bytes is enough to be unique
|
||||
// If this is divisible by 3, it means that we can base64-encode it without
|
||||
// any "=" padding
|
||||
//
|
||||
// cannot panic, as the output for sha256 will always be bit
|
||||
let id = &id[..9];
|
||||
|
||||
// URL_SAFE because we cannot have slashes in the output
|
||||
base64::engine::general_purpose::URL_SAFE.encode(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ComponentId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{}", self.html_id())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum HtmxTarget {
|
||||
Myself,
|
||||
Component(ComponentId),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HtmxComponent {
|
||||
id: ComponentId,
|
||||
action: HtmxAction,
|
||||
fallback_action: FallbackAction,
|
||||
target: HtmxTarget,
|
||||
}
|
||||
|
||||
impl HtmxComponent {
|
||||
fn target(&self) -> &ComponentId {
|
||||
match self.target {
|
||||
HtmxTarget::Myself => &self.id,
|
||||
HtmxTarget::Component(ref id) => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Parent {
|
||||
Root,
|
||||
Component(ComponentId),
|
||||
}
|
||||
|
||||
impl From<Parent> for ComponentId {
|
||||
fn from(value: Parent) -> Self {
|
||||
match value {
|
||||
Parent::Root => ComponentId("/".into()),
|
||||
Parent::Component(c) => c,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ComponentId> for Parent {
|
||||
fn from(value: ComponentId) -> Self {
|
||||
Self::Component(value)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Component {
|
||||
type Args;
|
||||
|
||||
fn init(parent: Parent, args: Self::Args) -> Self;
|
||||
fn build(self, context: &Context) -> Markup;
|
||||
}
|
||||
217
src/view/root.rs
Normal file
217
src/view/root.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
use crate::{Context, TopLevelPage};
|
||||
|
||||
use maud::{html, Markup, PreEscaped, DOCTYPE};
|
||||
|
||||
use super::{
|
||||
Component, ComponentId, FallbackAction, HtmxAction, HtmxComponent, HtmxTarget, Parent,
|
||||
};
|
||||
|
||||
pub struct Header;
|
||||
|
||||
impl Header {
|
||||
#[tracing::instrument]
|
||||
pub fn build() -> Markup {
|
||||
html!(
|
||||
head {
|
||||
title { "Packager" }
|
||||
script src="https://unpkg.com/htmx.org@1.9.4" {}
|
||||
script src="https://unpkg.com/alpinejs@3.12.3" defer {}
|
||||
script src="https://cdn.tailwindcss.com/3.3.3" {}
|
||||
link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.2.96/css/materialdesignicons.min.css" {}
|
||||
link rel="shortcut icon" type="image/svg+xml" href="/favicon.svg" {}
|
||||
script { (PreEscaped(include_str!(concat!(env!("CARGO_MANIFEST_DIR"),"/js/app.js")))) }
|
||||
meta name="htmx-config" content=r#"{"useTemplateFragments":true}"# {}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HeaderLink<'a> {
|
||||
htmx: HtmxComponent,
|
||||
args: HeaderLinkArgs<'a>,
|
||||
}
|
||||
|
||||
pub struct HeaderLinkArgs<'a> {
|
||||
pub item: TopLevelPage,
|
||||
pub active_page: Option<&'a TopLevelPage>,
|
||||
}
|
||||
|
||||
impl<'a> Component for HeaderLink<'a> {
|
||||
type Args = HeaderLinkArgs<'a>;
|
||||
|
||||
#[tracing::instrument(skip(args))]
|
||||
fn init(parent: Parent, args: Self::Args) -> Self {
|
||||
Self {
|
||||
htmx: HtmxComponent {
|
||||
id: ComponentId(format!("/header/component/{}", args.item.id())),
|
||||
action: HtmxAction::Get(args.item.path().to_string()),
|
||||
fallback_action: FallbackAction::Get(args.item.path().to_string()),
|
||||
target: HtmxTarget::Component(parent.into()),
|
||||
},
|
||||
args,
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
fn build(self, context: &Context) -> Markup {
|
||||
let active = self
|
||||
.args
|
||||
.active_page
|
||||
.map_or(false, |page| *page == self.args.item);
|
||||
html!(
|
||||
a
|
||||
href=(self.args.item.path())
|
||||
hx-get=(self.args.item.path())
|
||||
hx-target={ "#" (self.htmx.target().html_id()) }
|
||||
hx-swap="outerHtml"
|
||||
hx-push-url="true"
|
||||
#{"header-link-" (self.args.item.id())}
|
||||
."px-5"
|
||||
."flex"
|
||||
."h-full"
|
||||
."text-lg"
|
||||
."hover:bg-gray-300"
|
||||
|
||||
// invisible top border to fix alignment
|
||||
."border-t-gray-200"[active]
|
||||
."hover:border-t-gray-300"[active]
|
||||
|
||||
."border-b-gray-500"[active]
|
||||
."border-y-4"[active]
|
||||
."font-bold"[active]
|
||||
{ span ."m-auto" ."font-semibold" { (self.args.item.name()) }}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Body<'a> {
|
||||
htmx: HtmxComponent,
|
||||
args: BodyArgs<'a>,
|
||||
}
|
||||
|
||||
pub struct BodyArgs<'a> {
|
||||
pub body: &'a Markup,
|
||||
pub active_page: Option<&'a TopLevelPage>,
|
||||
}
|
||||
|
||||
impl<'a> Component for Body<'a> {
|
||||
type Args = BodyArgs<'a>;
|
||||
|
||||
#[tracing::instrument(skip(args))]
|
||||
fn init(parent: Parent, args: Self::Args) -> Self {
|
||||
Self {
|
||||
htmx: HtmxComponent {
|
||||
id: ComponentId("/body/".into()),
|
||||
action: HtmxAction::Get("/".into()),
|
||||
fallback_action: FallbackAction::Get("/".into()),
|
||||
target: HtmxTarget::Myself,
|
||||
},
|
||||
args,
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
fn build(self, context: &Context) -> Markup {
|
||||
html!(
|
||||
body #(self.htmx.id.html_id())
|
||||
{
|
||||
header
|
||||
#header
|
||||
."h-16"
|
||||
."bg-gray-200"
|
||||
."flex"
|
||||
."flex-row"
|
||||
."flex-nowrap"
|
||||
."justify-between"
|
||||
."items-stretch"
|
||||
{
|
||||
a
|
||||
#home
|
||||
href=(self.htmx.fallback_action)
|
||||
hx-get=(self.htmx.action)
|
||||
hx-target={ "#" (self.htmx.target()) }
|
||||
hx-swap="outerHTML"
|
||||
."flex"
|
||||
."flex-row"
|
||||
."items-center"
|
||||
."gap-3"
|
||||
."px-5"
|
||||
."hover:bg-gray-300"
|
||||
{
|
||||
img ."h-12" src="/assets/luggage.svg" {}
|
||||
span
|
||||
."text-xl"
|
||||
."font-semibold"
|
||||
{ "Packager" }
|
||||
}
|
||||
nav
|
||||
."grow"
|
||||
."flex"
|
||||
."flex-row"
|
||||
."justify-center"
|
||||
."gap-x-10"
|
||||
."items-stretch"
|
||||
{
|
||||
(
|
||||
// todo make clone() unnecessary
|
||||
// make ComponentId take &str instead of owned string
|
||||
HeaderLink::init(
|
||||
self.htmx.id.clone().into(),
|
||||
HeaderLinkArgs {
|
||||
item: TopLevelPage::Inventory,
|
||||
active_page: self.args.active_page
|
||||
}
|
||||
).build(context)
|
||||
)
|
||||
(
|
||||
HeaderLink::init(
|
||||
self.htmx.id.clone().into(),
|
||||
HeaderLinkArgs {
|
||||
item: TopLevelPage::Trips,
|
||||
active_page: self.args.active_page
|
||||
}
|
||||
).build(context)
|
||||
)
|
||||
}
|
||||
a
|
||||
."flex"
|
||||
."flex-row"
|
||||
."items-center"
|
||||
."gap-3"
|
||||
."px-5"
|
||||
."bg-gray-200"
|
||||
."hover:bg-gray-300"
|
||||
href=(format!("/user/{}", context.user.id))
|
||||
{
|
||||
span
|
||||
."m-auto"
|
||||
."mdi"
|
||||
."mdi-account"
|
||||
."text-3xl"
|
||||
{}
|
||||
p { (context.user.fullname)}
|
||||
}
|
||||
}
|
||||
(self.args.body)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Root;
|
||||
|
||||
impl Root {
|
||||
#[tracing::instrument]
|
||||
pub fn build(context: &Context, body: &Markup, active_page: Option<&TopLevelPage>) -> Markup {
|
||||
html!(
|
||||
(DOCTYPE)
|
||||
html {
|
||||
(Header::build())
|
||||
(Body::init(Parent::Root, BodyArgs {
|
||||
body,
|
||||
active_page
|
||||
}).build(context))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
1286
src/view/trip/mod.rs
Normal file
1286
src/view/trip/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
359
src/view/trip/packagelist.rs
Normal file
359
src/view/trip/packagelist.rs
Normal file
@@ -0,0 +1,359 @@
|
||||
use maud::{html, Markup};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models;
|
||||
|
||||
pub struct TripPackageListRowReady;
|
||||
|
||||
impl TripPackageListRowReady {
|
||||
#[tracing::instrument]
|
||||
pub fn build(trip_id: Uuid, item: &models::trips::TripItem) -> Markup {
|
||||
html!(
|
||||
li
|
||||
."flex"
|
||||
."flex-row"
|
||||
."justify-between"
|
||||
."items-stretch"
|
||||
."bg-green-50"[item.packed]
|
||||
."bg-red-50"[!item.packed]
|
||||
."hover:bg-white"[!item.packed]
|
||||
."h-full"
|
||||
{
|
||||
span
|
||||
."p-2"
|
||||
{
|
||||
(item.item.name)
|
||||
}
|
||||
@if item.packed {
|
||||
a
|
||||
href={
|
||||
"/trips/" (trip_id)
|
||||
"/items/" (item.item.id)
|
||||
"/unpack"
|
||||
}
|
||||
hx-post={
|
||||
"/trips/" (trip_id)
|
||||
"/packagelist/item/"
|
||||
(item.item.id) "/unpack"
|
||||
}
|
||||
hx-target="closest li"
|
||||
hx-swap="outerHTML"
|
||||
."flex"
|
||||
."flex-row"
|
||||
."aspect-square"
|
||||
{
|
||||
span
|
||||
."mdi"
|
||||
."m-auto"
|
||||
."text-xl"
|
||||
."mdi-check"
|
||||
{}
|
||||
}
|
||||
} @else {
|
||||
a
|
||||
href={
|
||||
"/trips/" (trip_id)
|
||||
"/items/" (item.item.id)
|
||||
"/pack"
|
||||
}
|
||||
hx-post={
|
||||
"/trips/" (trip_id)
|
||||
"/packagelist/item/"
|
||||
(item.item.id) "/pack"
|
||||
}
|
||||
hx-target="closest li"
|
||||
hx-swap="outerHTML"
|
||||
."flex"
|
||||
."flex-row"
|
||||
."aspect-square"
|
||||
{
|
||||
span
|
||||
."mdi"
|
||||
."m-auto"
|
||||
."text-xl"
|
||||
."mdi-checkbox-blank-outline"
|
||||
{}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TripPackageListRowUnready;
|
||||
|
||||
impl TripPackageListRowUnready {
|
||||
#[tracing::instrument]
|
||||
pub fn build(trip_id: Uuid, item: &models::trips::TripItem) -> Markup {
|
||||
html!(
|
||||
li
|
||||
."flex"
|
||||
."flex-row"
|
||||
."justify-between"
|
||||
."items-stretch"
|
||||
."bg-green-50"[item.ready]
|
||||
."bg-red-50"[!item.ready]
|
||||
."hover:bg-white"[!item.ready]
|
||||
."h-full"
|
||||
{
|
||||
span
|
||||
."p-2"
|
||||
{
|
||||
(item.item.name)
|
||||
}
|
||||
@if item.ready {
|
||||
a
|
||||
href={
|
||||
"/trips/" (trip_id)
|
||||
"/items/" (item.item.id)
|
||||
"/unready"
|
||||
}
|
||||
hx-post={
|
||||
"/trips/" (trip_id)
|
||||
"/packagelist/item/"
|
||||
(item.item.id) "/unready"
|
||||
}
|
||||
hx-target="closest li"
|
||||
hx-swap="outerHTML"
|
||||
."flex"
|
||||
."flex-row"
|
||||
."aspect-square"
|
||||
{
|
||||
span
|
||||
."mdi"
|
||||
."m-auto"
|
||||
."text-xl"
|
||||
."mdi-check"
|
||||
{}
|
||||
}
|
||||
} @else {
|
||||
a
|
||||
href={
|
||||
"/trips/" (trip_id)
|
||||
"/items/" (item.item.id)
|
||||
"/ready"
|
||||
}
|
||||
hx-post={
|
||||
"/trips/" (trip_id)
|
||||
"/packagelist/item/"
|
||||
(item.item.id) "/ready"
|
||||
}
|
||||
hx-target="closest li"
|
||||
hx-swap="outerHTML"
|
||||
."flex"
|
||||
."flex-row"
|
||||
."aspect-square"
|
||||
{
|
||||
span
|
||||
."mdi"
|
||||
."m-auto"
|
||||
."text-xl"
|
||||
."mdi-checkbox-blank-outline"
|
||||
{}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TripPackageListCategoryBlockReady;
|
||||
|
||||
impl TripPackageListCategoryBlockReady {
|
||||
#[tracing::instrument]
|
||||
pub fn build(trip: &models::trips::Trip, category: &models::trips::TripCategory) -> Markup {
|
||||
let empty = !category
|
||||
.items
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item.picked);
|
||||
|
||||
html!(
|
||||
div
|
||||
."inline-block"
|
||||
."w-full"
|
||||
."mb-5"
|
||||
."border"
|
||||
."border-2"
|
||||
."border-gray-300"
|
||||
."opacity-30"[empty]
|
||||
{
|
||||
div
|
||||
."bg-gray-100"
|
||||
."border-b-2"
|
||||
."border-gray-300"
|
||||
."p-3"
|
||||
{
|
||||
h3 { (category.category.name) }
|
||||
}
|
||||
@if empty {
|
||||
div
|
||||
."flex"
|
||||
."p-1"
|
||||
{
|
||||
span
|
||||
."text-sm"
|
||||
."m-auto"
|
||||
{
|
||||
"no items picked"
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
ul
|
||||
."flex"
|
||||
."flex-col"
|
||||
{
|
||||
@for item in category.items.as_ref().unwrap().iter().filter(|item| item.picked) {
|
||||
(TripPackageListRowReady::build(trip.id, item))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TripPackageListCategoryBlockUnready;
|
||||
|
||||
impl TripPackageListCategoryBlockUnready {
|
||||
#[tracing::instrument]
|
||||
pub fn build(trip: &models::trips::Trip, category: &models::trips::TripCategory) -> Markup {
|
||||
let empty = !category
|
||||
.items
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item.picked);
|
||||
|
||||
html!(
|
||||
div
|
||||
."inline-block"
|
||||
."w-full"
|
||||
."mb-5"
|
||||
."border"
|
||||
."border-2"
|
||||
."border-gray-300"
|
||||
."opacity-30"[empty]
|
||||
{
|
||||
div
|
||||
."bg-gray-100"
|
||||
."border-b-2"
|
||||
."border-gray-300"
|
||||
."p-3"
|
||||
{
|
||||
h3 { (category.category.name) }
|
||||
}
|
||||
@if empty {
|
||||
div
|
||||
."flex"
|
||||
."p-1"
|
||||
{
|
||||
span
|
||||
."text-sm"
|
||||
."m-auto"
|
||||
{
|
||||
"no items picked"
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
ul
|
||||
."flex"
|
||||
."flex-col"
|
||||
{
|
||||
@for item in category.items.as_ref().unwrap().iter().filter(|item| item.picked && !item.ready) {
|
||||
(TripPackageListRowUnready::build(trip.id, item))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
pub struct TripPackageList;
|
||||
|
||||
impl TripPackageList {
|
||||
#[tracing::instrument]
|
||||
pub fn build(trip: &models::trips::Trip) -> Markup {
|
||||
// let all_packed = trip.categories().iter().all(|category| {
|
||||
// category
|
||||
// .items
|
||||
// .as_ref()
|
||||
// .unwrap()
|
||||
// .iter()
|
||||
// .all(|item| !item.picked || item.packed)
|
||||
// });
|
||||
let has_unready_items: bool = trip.categories.as_ref().unwrap().iter().any(|category| {
|
||||
category
|
||||
.items
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item.picked && !item.ready)
|
||||
});
|
||||
html!(
|
||||
div
|
||||
."p-8"
|
||||
."flex"
|
||||
."flex-col"
|
||||
."gap-8"
|
||||
{
|
||||
div
|
||||
."flex"
|
||||
."flex-row"
|
||||
."justify-between"
|
||||
{
|
||||
h1 ."text-xl" {
|
||||
"Package list for "
|
||||
a
|
||||
href={"/trips/" (trip.id) "/"}
|
||||
."font-bold"
|
||||
{
|
||||
(trip.name)
|
||||
}
|
||||
}
|
||||
a
|
||||
href={"/trips/" (trip.id) "/packagelist/"}
|
||||
// disabled[!all_packed]
|
||||
// ."opacity-50"[!all_packed]
|
||||
."p-2"
|
||||
."border-2"
|
||||
."border-gray-500"
|
||||
."bg-blue-200"
|
||||
."hover:bg-blue-200"
|
||||
{
|
||||
"Finish packing"
|
||||
}
|
||||
}
|
||||
@if has_unready_items {
|
||||
p { "There are items that are not yet ready, get them!"}
|
||||
div
|
||||
."columns-3"
|
||||
."gap-5"
|
||||
{
|
||||
@for category in trip.categories() {
|
||||
@let empty = !category
|
||||
.items
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item.picked);
|
||||
@if !empty {
|
||||
(TripPackageListCategoryBlockUnready::build(trip, category))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p { "Pack the following things:" }
|
||||
div
|
||||
."columns-3"
|
||||
."gap-5"
|
||||
{
|
||||
@for category in trip.categories() {
|
||||
(TripPackageListCategoryBlockReady::build(trip, category))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
163
src/view/trip/types.rs
Normal file
163
src/view/trip/types.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use crate::models;
|
||||
use crate::ClientState;
|
||||
use maud::{html, Markup};
|
||||
|
||||
pub struct TypeList;
|
||||
|
||||
impl TypeList {
|
||||
#[tracing::instrument]
|
||||
pub fn build(state: &ClientState, trip_types: Vec<models::trips::TripsType>) -> Markup {
|
||||
html!(
|
||||
div ."p-8" ."flex" ."flex-col" ."gap-8" {
|
||||
h1 ."text-2xl" {"Trip Types"}
|
||||
|
||||
ul
|
||||
."flex"
|
||||
."flex-col"
|
||||
."items-stretch"
|
||||
."border-t"
|
||||
."border-l"
|
||||
."h-full"
|
||||
{
|
||||
@for trip_type in trip_types {
|
||||
li
|
||||
."border-b"
|
||||
."border-r"
|
||||
."flex"
|
||||
."flex-row"
|
||||
."justify-between"
|
||||
."items-stretch"
|
||||
{
|
||||
@if state.trip_type_edit.map_or(false, |id| id == trip_type.id) {
|
||||
form
|
||||
."hidden"
|
||||
id="edit-trip-type"
|
||||
action={ (trip_type.id) "/edit/name/submit" }
|
||||
target="_self"
|
||||
method="post"
|
||||
{}
|
||||
div
|
||||
."bg-blue-200"
|
||||
."p-2"
|
||||
."grow"
|
||||
{
|
||||
input
|
||||
."bg-blue-100"
|
||||
."hover:bg-white"
|
||||
."w-full"
|
||||
type="text"
|
||||
name="new-value"
|
||||
form="edit-trip-type"
|
||||
value=(trip_type.name)
|
||||
{}
|
||||
}
|
||||
div
|
||||
."flex"
|
||||
."flex-row"
|
||||
{
|
||||
a
|
||||
href="."
|
||||
."bg-red-200"
|
||||
."hover:bg-red-300"
|
||||
."w-8"
|
||||
."flex"
|
||||
{
|
||||
span
|
||||
."mdi"
|
||||
."mdi-cancel"
|
||||
."text-xl"
|
||||
."m-auto"
|
||||
{}
|
||||
}
|
||||
button
|
||||
type="submit"
|
||||
form="edit-trip-type"
|
||||
."bg-green-200"
|
||||
."hover:bg-green-300"
|
||||
."w-8"
|
||||
{
|
||||
span
|
||||
."mdi"
|
||||
."mdi-content-save"
|
||||
."text-xl"
|
||||
{}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
span
|
||||
."p-2"
|
||||
{
|
||||
(trip_type.name)
|
||||
}
|
||||
|
||||
div
|
||||
."bg-blue-100"
|
||||
."hover:bg-blue-200"
|
||||
."p-0"
|
||||
."w-8"
|
||||
{
|
||||
a
|
||||
href={ "?edit=" (trip_type.id) }
|
||||
.flex
|
||||
."w-full"
|
||||
."h-full"
|
||||
{
|
||||
span
|
||||
."m-auto"
|
||||
."mdi"
|
||||
."mdi-pencil"
|
||||
."text-xl"
|
||||
{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form
|
||||
name="new-trip-type"
|
||||
action="/trips/types/"
|
||||
target="_self"
|
||||
method="post"
|
||||
."mt-8" ."p-5" ."border-2" ."border-gray-200"
|
||||
{
|
||||
div ."mb-5" ."flex" ."flex-row" {
|
||||
span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {}
|
||||
p ."inline" ."text-xl" { "Add new trip type" }
|
||||
}
|
||||
div ."w-11/12" ."m-auto" {
|
||||
div ."mx-auto" ."pb-8" {
|
||||
div ."flex" ."flex-row" ."justify-center" {
|
||||
label for="new-trip-type-name" ."font-bold" ."w-1/2" ."p-2" ."text-center" { "Name" }
|
||||
span ."w-1/2" {
|
||||
input
|
||||
type="text"
|
||||
id="new-trip-type-name"
|
||||
name="new-trip-type-name"
|
||||
."block"
|
||||
."w-full"
|
||||
."p-2"
|
||||
."bg-gray-50"
|
||||
."border-2"
|
||||
."focus:outline-none"
|
||||
."focus:bg-white"
|
||||
{}
|
||||
}
|
||||
}
|
||||
}
|
||||
input
|
||||
type="submit"
|
||||
value="Add"
|
||||
."py-2"
|
||||
."border-2"
|
||||
."border-gray-300"
|
||||
."mx-auto"
|
||||
."w-full"
|
||||
{}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user