Merge WIP material from "watches" branch.
This commit is contained in:
commit
dc3f90228f
16 changed files with 282 additions and 61 deletions
|
@ -20,11 +20,13 @@ create table if not exists watches (
|
|||
id blob not null primary key,
|
||||
typ int not null, -- enum for movie or tv show or whatev
|
||||
title text not null,
|
||||
imdb text, -- possible url for imdb or other metadata-esque site to show the user
|
||||
runtime int,
|
||||
metadata_url text, -- possible url for imdb or other metadata-esque site to show the user
|
||||
length int,
|
||||
release_date int,
|
||||
added_by blob not null, -- ID of the user that added it
|
||||
created_at int not null default (unixepoch()),
|
||||
last_updated int not null default (unixepoch())
|
||||
last_updated int not null default (unixepoch()),
|
||||
foreign key (added_by) references witches (id)
|
||||
);
|
||||
|
||||
-- table of what people want to watch
|
||||
|
@ -68,6 +70,6 @@ create table if not exists watch_notes (
|
|||
|
||||
-- indices, not needed for covens
|
||||
create index if not exists witch_dex on witches ( username, email );
|
||||
create index if not exists watch_dex on watches ( title, runtime, release_date );
|
||||
create index if not exists watch_dex on watches ( title, length, release_date, added_by );
|
||||
create index if not exists ww_dex on witch_watch ( witch, watch, public );
|
||||
create index if not exists note_dex on watch_notes ( witch, watch, public );
|
||||
|
|
|
@ -20,7 +20,7 @@ const MIN_CONNS: u32 = 5;
|
|||
const TIMEOUT: u64 = 11;
|
||||
const SESSION_TTL: Duration = Duration::from_secs((365.2422 * 24. * 3600.0) as u64);
|
||||
|
||||
pub async fn get_pool() -> SqlitePool {
|
||||
pub async fn get_db_pool() -> SqlitePool {
|
||||
let db_filename = {
|
||||
std::env::var("DATABASE_FILE").unwrap_or_else(|_| {
|
||||
#[cfg(not(test))]
|
||||
|
@ -115,7 +115,7 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn it_migrates_the_db() {
|
||||
let db = super::get_pool().await;
|
||||
let db = super::get_db_pool().await;
|
||||
let r = sqlx::query("select count(*) from witches")
|
||||
.fetch_one(&db)
|
||||
.await;
|
||||
|
@ -130,6 +130,7 @@ mod tests {
|
|||
//-************************************************************************
|
||||
// Session store sub-module, not a public lib.
|
||||
//-************************************************************************
|
||||
#[allow(dead_code)]
|
||||
mod session_store {
|
||||
use async_session::{async_trait, chrono::Utc, log, serde_json, Result, Session};
|
||||
use sqlx::{pool::PoolConnection, Sqlite};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use axum::response::{IntoResponse, Redirect};
|
||||
|
||||
use crate::{templates::Index, AuthContext};
|
||||
use crate::{AuthContext, MainPage};
|
||||
|
||||
pub async fn handle_slash_redir() -> impl IntoResponse {
|
||||
Redirect::to("/")
|
||||
|
@ -13,7 +13,7 @@ pub async fn handle_slash(auth: AuthContext) -> impl IntoResponse {
|
|||
} else {
|
||||
tracing::debug!("Not logged in.");
|
||||
}
|
||||
Index {
|
||||
MainPage {
|
||||
user: auth.current_user,
|
||||
}
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ mod test {
|
|||
|
||||
#[tokio::test]
|
||||
async fn slash_is_ok() {
|
||||
let pool = db::get_pool().await;
|
||||
let pool = db::get_db_pool().await;
|
||||
let secret = [0u8; 64];
|
||||
let app = crate::app(pool.clone(), &secret).await.into_make_service();
|
||||
|
||||
|
@ -37,7 +37,7 @@ mod test {
|
|||
|
||||
#[tokio::test]
|
||||
async fn not_found_is_303() {
|
||||
let pool = db::get_pool().await;
|
||||
let pool = db::get_db_pool().await;
|
||||
let secret = [0u8; 64];
|
||||
let app = crate::app(pool, &secret).await.into_make_service();
|
||||
|
||||
|
|
60
src/lib.rs
60
src/lib.rs
|
@ -1,38 +1,58 @@
|
|||
#[macro_use]
|
||||
extern crate justerror;
|
||||
|
||||
use axum::{middleware, routing::get, Router};
|
||||
use axum_login::SqliteStore;
|
||||
use generic_handlers::{handle_slash, handle_slash_redir};
|
||||
use login::{get_login, get_logout, post_login, post_logout};
|
||||
use signup::{get_create_user, handle_signup_success, post_create_user};
|
||||
use sqlx::SqlitePool;
|
||||
pub use users::User;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub mod db;
|
||||
pub mod generic_handlers;
|
||||
pub mod login;
|
||||
pub mod signup;
|
||||
pub(crate) mod templates;
|
||||
pub mod users;
|
||||
pub(crate) mod util;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod test_utils;
|
||||
|
||||
pub type AuthContext = axum_login::extractors::AuthContext<Uuid, User, SqliteStore<User>>;
|
||||
/// This is used in the bin crate and in tests.
|
||||
pub use db::get_db_pool;
|
||||
|
||||
pub async fn app(db_pool: SqlitePool, secret: &[u8]) -> Router {
|
||||
// everything else is private to the crate
|
||||
mod db;
|
||||
mod generic_handlers;
|
||||
mod login;
|
||||
mod signup;
|
||||
mod templates;
|
||||
mod users;
|
||||
mod util;
|
||||
mod watches;
|
||||
|
||||
// things we want in the crate namespace
|
||||
use templates::*;
|
||||
use users::User;
|
||||
use watches::{templates::*, ShowKind, Watch};
|
||||
|
||||
type AuthContext =
|
||||
axum_login::extractors::AuthContext<uuid::Uuid, User, axum_login::SqliteStore<User>>;
|
||||
|
||||
/// Returns the router to be used as a service or test object, you do you.
|
||||
pub async fn app(db_pool: sqlx::SqlitePool, secret: &[u8]) -> axum::Router {
|
||||
use axum::{middleware, routing::get};
|
||||
let session_layer = db::session_layer(db_pool.clone(), secret).await;
|
||||
let auth_layer = db::auth_layer(db_pool.clone(), secret).await;
|
||||
|
||||
Router::new()
|
||||
// don't bother bringing handlers into the whole crate namespace
|
||||
use generic_handlers::{handle_slash, handle_slash_redir};
|
||||
use login::{get_login, get_logout, post_login, post_logout};
|
||||
use signup::{get_create_user, handle_signup_success, post_create_user};
|
||||
use watches::handlers::{
|
||||
get_search_watch, get_watches, post_add_watch, post_search_watch, put_add_watch,
|
||||
};
|
||||
|
||||
axum::Router::new()
|
||||
.route("/", get(handle_slash).post(handle_slash))
|
||||
.route("/signup", get(get_create_user).post(post_create_user))
|
||||
.route("/signup_success/:id", get(handle_signup_success))
|
||||
.route("/login", get(get_login).post(post_login))
|
||||
.route("/logout", get(get_logout).post(post_logout))
|
||||
.route("/watches", get(get_watches))
|
||||
.route("/search", get(get_search_watch).post(post_search_watch))
|
||||
.route(
|
||||
"/add",
|
||||
get(get_search_watch)
|
||||
.put(put_add_watch)
|
||||
.post(post_add_watch),
|
||||
)
|
||||
.fallback(handle_slash_redir)
|
||||
.layer(middleware::from_fn_with_state(
|
||||
db_pool.clone(),
|
||||
|
|
10
src/login.rs
10
src/login.rs
|
@ -10,11 +10,7 @@ use axum::{
|
|||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{
|
||||
templates::{LoginGet, LoginPost, LogoutGet, LogoutPost},
|
||||
util::form_decode,
|
||||
AuthContext, User,
|
||||
};
|
||||
use crate::{util::form_decode, AuthContext, LoginGet, LoginPost, LogoutGet, LogoutPost, User};
|
||||
|
||||
//-************************************************************************
|
||||
// Constants
|
||||
|
@ -104,7 +100,7 @@ pub async fn post_logout(mut auth: AuthContext) -> impl IntoResponse {
|
|||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::{
|
||||
templates::{Index, LoginGet, LogoutGet, LogoutPost},
|
||||
templates::{LoginGet, LogoutGet, LogoutPost, MainPage},
|
||||
test_utils::{get_test_user, massage, server, FORM_CONTENT_TYPE},
|
||||
};
|
||||
|
||||
|
@ -198,7 +194,7 @@ mod test {
|
|||
.await;
|
||||
assert_eq!(resp.status_code(), 303);
|
||||
|
||||
let logged_in = Index {
|
||||
let logged_in = MainPage {
|
||||
user: Some(get_test_user()),
|
||||
}
|
||||
.to_string();
|
||||
|
|
|
@ -2,7 +2,7 @@ use std::net::SocketAddr;
|
|||
|
||||
use rand_core::{OsRng, RngCore};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
use witch_watch::db;
|
||||
use witch_watch::get_db_pool;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
|
@ -14,7 +14,7 @@ async fn main() {
|
|||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let pool = db::get_pool().await;
|
||||
let pool = get_db_pool().await;
|
||||
|
||||
let secret = {
|
||||
let mut bytes = [0u8; 64];
|
||||
|
@ -25,10 +25,10 @@ async fn main() {
|
|||
|
||||
let app = witch_watch::app(pool, &secret).await;
|
||||
|
||||
let addr = ([127, 0, 0, 1], 3000);
|
||||
let addr: SocketAddr = ([127, 0, 0, 1], 3000).into();
|
||||
tracing::debug!("binding to {addr:?}");
|
||||
|
||||
axum::Server::bind(&SocketAddr::from(addr))
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
|
@ -11,10 +11,7 @@ use sqlx::{query_as, SqlitePool};
|
|||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
templates::{CreateUser, CreateUserSuccess},
|
||||
User,
|
||||
};
|
||||
use crate::{CreateUser, CreateUserSuccess, User};
|
||||
|
||||
pub(crate) const CREATE_QUERY: &str =
|
||||
"insert into witches (id, username, displayname, email, pwhash) values ($1, $2, $3, $4, $5)";
|
||||
|
@ -29,7 +26,7 @@ const ID_QUERY: &str = "select * from witches where id = $1";
|
|||
pub struct CreateUserError(#[from] CreateUserErrorKind);
|
||||
|
||||
impl IntoResponse for CreateUserError {
|
||||
fn into_response(self) -> askama_axum::Response {
|
||||
fn into_response(self) -> Response {
|
||||
match self.0 {
|
||||
CreateUserErrorKind::UnknownDBError => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("{self}")).into_response()
|
||||
|
@ -236,7 +233,7 @@ mod test {
|
|||
use axum::http::StatusCode;
|
||||
|
||||
use crate::{
|
||||
db::get_pool,
|
||||
db::get_db_pool,
|
||||
templates::{CreateUser, CreateUserSuccess},
|
||||
test_utils::{get_test_user, insert_user, massage, server_with_pool, FORM_CONTENT_TYPE},
|
||||
User,
|
||||
|
@ -246,7 +243,7 @@ mod test {
|
|||
|
||||
#[tokio::test]
|
||||
async fn post_create_user() {
|
||||
let pool = get_pool().await;
|
||||
let pool = get_db_pool().await;
|
||||
let server = server_with_pool(&pool).await;
|
||||
let body = massage(GOOD_FORM);
|
||||
|
||||
|
@ -266,7 +263,7 @@ mod test {
|
|||
|
||||
#[tokio::test]
|
||||
async fn get_create_user() {
|
||||
let pool = get_pool().await;
|
||||
let pool = get_db_pool().await;
|
||||
let server = server_with_pool(&pool).await;
|
||||
|
||||
let resp = server.get("/signup").await;
|
||||
|
@ -277,7 +274,7 @@ mod test {
|
|||
|
||||
#[tokio::test]
|
||||
async fn handle_signup_success() {
|
||||
let pool = get_pool().await;
|
||||
let pool = get_db_pool().await;
|
||||
let server = server_with_pool(&pool).await;
|
||||
|
||||
let user = get_test_user();
|
||||
|
@ -313,7 +310,7 @@ mod test {
|
|||
|
||||
#[tokio::test]
|
||||
async fn password_mismatch() {
|
||||
let pool = get_pool().await;
|
||||
let pool = get_db_pool().await;
|
||||
let server = server_with_pool(&pool).await;
|
||||
let body = massage(PASSWORD_MISMATCH_FORM);
|
||||
|
||||
|
@ -336,7 +333,7 @@ mod test {
|
|||
|
||||
#[tokio::test]
|
||||
async fn password_short() {
|
||||
let pool = get_pool().await;
|
||||
let pool = get_db_pool().await;
|
||||
let server = server_with_pool(&pool).await;
|
||||
let body = massage(PASSWORD_SHORT_FORM);
|
||||
|
||||
|
@ -359,7 +356,7 @@ mod test {
|
|||
|
||||
#[tokio::test]
|
||||
async fn password_long() {
|
||||
let pool = get_pool().await;
|
||||
let pool = get_db_pool().await;
|
||||
let server = server_with_pool(&pool).await;
|
||||
let body = massage(PASSWORD_LONG_FORM);
|
||||
|
||||
|
@ -382,7 +379,7 @@ mod test {
|
|||
|
||||
#[tokio::test]
|
||||
async fn username_short() {
|
||||
let pool = get_pool().await;
|
||||
let pool = get_db_pool().await;
|
||||
let server = server_with_pool(&pool).await;
|
||||
let body = massage(USERNAME_SHORT_FORM);
|
||||
|
||||
|
@ -405,7 +402,7 @@ mod test {
|
|||
|
||||
#[tokio::test]
|
||||
async fn username_long() {
|
||||
let pool = get_pool().await;
|
||||
let pool = get_db_pool().await;
|
||||
let server = server_with_pool(&pool).await;
|
||||
let body = massage(USERNAME_LONG_FORM);
|
||||
|
||||
|
@ -428,7 +425,7 @@ mod test {
|
|||
|
||||
#[tokio::test]
|
||||
async fn username_duplicate() {
|
||||
let pool = get_pool().await;
|
||||
let pool = get_db_pool().await;
|
||||
let server = server_with_pool(&pool).await;
|
||||
let body = massage(GOOD_FORM);
|
||||
|
||||
|
@ -459,7 +456,7 @@ mod test {
|
|||
|
||||
#[tokio::test]
|
||||
async fn displayname_long() {
|
||||
let pool = get_pool().await;
|
||||
let pool = get_db_pool().await;
|
||||
let server = server_with_pool(&pool).await;
|
||||
let body = massage(DISPLAYNAME_LONG_FORM);
|
||||
|
||||
|
@ -482,7 +479,7 @@ mod test {
|
|||
|
||||
#[tokio::test]
|
||||
async fn handle_signup_success() {
|
||||
let pool = get_pool().await;
|
||||
let pool = get_db_pool().await;
|
||||
let server = server_with_pool(&pool).await;
|
||||
|
||||
let path = format!("/signup_success/nope");
|
||||
|
|
|
@ -41,6 +41,6 @@ pub struct LogoutPost;
|
|||
|
||||
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[template(path = "index.html")]
|
||||
pub struct Index {
|
||||
pub struct MainPage {
|
||||
pub user: Option<User>,
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ pub fn get_test_user() -> User {
|
|||
}
|
||||
|
||||
pub async fn server() -> TestServer {
|
||||
let pool = crate::db::get_pool().await;
|
||||
let pool = crate::db::get_db_pool().await;
|
||||
let secret = [0u8; 64];
|
||||
|
||||
let user = get_test_user();
|
||||
|
|
68
src/watches/handlers.rs
Normal file
68
src/watches/handlers.rs
Normal file
|
@ -0,0 +1,68 @@
|
|||
use axum::{
|
||||
extract::{Form, Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use sqlx::{query_as, SqlitePool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{AuthContext, GetWatches, ShowKind, User, Watch};
|
||||
|
||||
//-************************************************************************
|
||||
// Constants
|
||||
//-************************************************************************
|
||||
|
||||
const GET_WATCHES_QUERY: &str =
|
||||
"select * from watches left join witch_watch on $1 = witch_watch.witch and watches.id = witch_watch.watch";
|
||||
|
||||
//-************************************************************************
|
||||
// Error types for Watch creation
|
||||
//-************************************************************************
|
||||
|
||||
#[Error]
|
||||
pub struct WatchAddError(#[from] WatchAddErrorKind);
|
||||
|
||||
#[Error]
|
||||
#[non_exhaustive]
|
||||
pub enum WatchAddErrorKind {
|
||||
UnknownDBError,
|
||||
}
|
||||
|
||||
impl IntoResponse for WatchAddError {
|
||||
fn into_response(self) -> Response {
|
||||
match self.0 {
|
||||
WatchAddErrorKind::UnknownDBError => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("{self}")).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a Watch to the whole system
|
||||
pub async fn put_add_watch() {}
|
||||
|
||||
/// A single Watch
|
||||
pub async fn get_watch() {}
|
||||
|
||||
/// everything the user has saved
|
||||
pub async fn get_watches(auth: AuthContext, State(pool): State<SqlitePool>) -> impl IntoResponse {
|
||||
let user = &auth.current_user;
|
||||
let watches: Vec<Watch> = if user.is_some() {
|
||||
query_as(GET_WATCHES_QUERY)
|
||||
.bind(user.as_ref().unwrap().id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
GetWatches {
|
||||
watches,
|
||||
user: user.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_search_watch() {}
|
||||
|
||||
pub async fn post_search_watch() {}
|
79
src/watches/mod.rs
Normal file
79
src/watches/mod.rs
Normal file
|
@ -0,0 +1,79 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub mod handlers;
|
||||
pub mod templates;
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
|
||||
)]
|
||||
#[repr(i32)]
|
||||
pub enum ShowKind {
|
||||
Movie = 0,
|
||||
Series = 1,
|
||||
LimitedSeries = 2,
|
||||
Short = 3,
|
||||
Unknown = 4,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ShowKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ShowKind {
|
||||
fn default() -> Self {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for ShowKind {
|
||||
fn from(value: i32) -> Self {
|
||||
match value {
|
||||
0 => Self::Movie,
|
||||
1 => Self::Series,
|
||||
2 => Self::LimitedSeries,
|
||||
3 => Self::Short,
|
||||
4 => Self::Unknown,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Default,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Eq,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
sqlx::FromRow,
|
||||
)]
|
||||
pub struct Watch {
|
||||
pub id: Uuid,
|
||||
pub kind: ShowKind,
|
||||
pub title: String,
|
||||
pub metadata_url: Option<String>,
|
||||
pub length: Option<i32>,
|
||||
pub release_date: Option<i64>,
|
||||
added_by: Uuid, // this shouldn't be exposed to randos
|
||||
created_at: i64,
|
||||
last_updated: i64,
|
||||
}
|
||||
|
||||
impl Watch {
|
||||
pub fn new(title: &str, added_by: Uuid) -> Self {
|
||||
let id = Uuid::new_v4();
|
||||
Self {
|
||||
id,
|
||||
title: title.to_string(),
|
||||
added_by,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
11
src/watches/templates.rs
Normal file
11
src/watches/templates.rs
Normal file
|
@ -0,0 +1,11 @@
|
|||
use askama::Template;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{User, Watch};
|
||||
|
||||
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[template(path = "get_watches.html")]
|
||||
pub struct GetWatches {
|
||||
pub watches: Vec<Watch>,
|
||||
pub user: Option<User>,
|
||||
}
|
0
templates/get_search_watches.html
Normal file
0
templates/get_search_watches.html
Normal file
37
templates/get_watches.html
Normal file
37
templates/get_watches.html
Normal file
|
@ -0,0 +1,37 @@
|
|||
{% extends "base.html" %}
|
||||
{% import "macros.html" as m %}
|
||||
|
||||
{% block title %}Welcome to Witch Watch, Bish{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Whatcha Watchin?</h1>
|
||||
|
||||
{% match user %}
|
||||
{% when Some with (usr) %}
|
||||
<p>
|
||||
Hello, {{ usr.username }}! It's nice to see you.
|
||||
</p>
|
||||
</br>
|
||||
<p>Here are your things to watch:</p>
|
||||
<div class="watchlist">
|
||||
<ul>
|
||||
{% for watch in watches %}
|
||||
<li><span class="watchtitle">{{watch.title}}</span> -- {% call m::get_or_default(watch.release_date, "when??") %}: </li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<p>
|
||||
<form action="/search" enctype="application/x-www-form-urlencoded" method="post">
|
||||
<label for="search">Looking for something else to watch?</label>
|
||||
<input type="text" name="search" id="search"></br>
|
||||
<input type="submit" value="Let's go!">
|
||||
</form>
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
Heya, why don't you <a href="/login">log in</a> or <a href="/signup">sign up</a>?
|
||||
</p>
|
||||
{% endmatch %}
|
||||
|
||||
{% endblock %}
|
|
@ -9,7 +9,7 @@
|
|||
{% match user %}
|
||||
{% when Some with (usr) %}
|
||||
<p>
|
||||
Hello, {{ usr.username }}! It's nice to see you.
|
||||
Hello, {{ usr.username }}! It's nice to see you. <a href="watches">Let's get watchin'!</a>
|
||||
</p>
|
||||
</br>
|
||||
<p>
|
||||
|
|
10
templates/macros.html
Normal file
10
templates/macros.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
{% macro get_or_default(val, def) %}
|
||||
|
||||
{% match val %}
|
||||
{% when Some with (v) %}
|
||||
{{v}}
|
||||
{% else %}
|
||||
{{def}}
|
||||
{% endmatch %}
|
||||
|
||||
{% endmacro %}
|
Loading…
Reference in a new issue