use std::{
fmt::Debug,
path::{Path, PathBuf},
};
use chrono::{DateTime, NaiveDate, TimeZone};
use derive_more::{Deref, DerefMut, Display, From, FromStr, Into};
use i18n_embed::fluent::FluentLanguageLoader;
use i18n_embed_fl::fl;
use maud::{html, Markup, PreEscaped};
use serde::{Deserialize, Serialize};
use sqlx::error::BoxDynError;
use ts_rs::TS;
use uuid::Uuid;
use crate::{
self as derived_cms, context::ContextTrait, input::InputInfo, render::FormRenderContext,
Column, Input, DB,
};
#[derive(Debug)]
pub struct EnumVariant<'a, S: ContextTrait> {
pub name: &'a str,
pub title: &'a str,
pub value: &'a str,
pub content: Option<InputInfo<'a, S>>,
}
#[derive(
Clone,
Debug,
Default,
Deref,
DerefMut,
Display,
From,
FromStr,
Into,
PartialEq,
Eq,
Hash,
Deserialize,
Serialize,
Column,
)]
#[serde(transparent)]
pub struct Text(pub String);
impl TS for Text {
type WithoutGenerics = Text;
fn decl() -> String {
String::decl()
}
fn decl_concrete() -> String {
String::decl_concrete()
}
fn name() -> String {
String::name()
}
fn inline() -> String {
String::inline()
}
fn inline_flattened() -> String {
String::inline_flattened()
}
}
impl<'r> sqlx::Decode<'r, DB> for Text
where
String: sqlx::Decode<'r, DB>,
{
fn decode(
value: <DB as sqlx::Database>::ValueRef<'r>,
) -> Result<Self, sqlx::error::BoxDynError> {
Ok(Self(<String as sqlx::Decode<DB>>::decode(value)?))
}
}
impl sqlx::Type<DB> for Text
where
String: sqlx::Type<DB>,
{
fn type_info() -> <DB as sqlx::Database>::TypeInfo {
<String as sqlx::Type<DB>>::type_info()
}
}
impl<'r> sqlx::Encode<'r, DB> for Text
where
String: sqlx::Encode<'r, DB>,
{
fn encode_by_ref(
&self,
buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'r>,
) -> Result<sqlx::encode::IsNull, BoxDynError> {
sqlx::Encode::<'_, DB>::encode(&self.0, buf)
}
}
impl<S: ContextTrait> Input<S> for Text {
fn render_input(
value: Option<&Self>,
name: &str,
title: &str,
required: bool,
_ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
html! {
input type="text" name=(name) placeholder=(title) class="cms-text-input" value=[value] required[required] {}
}
}
}
#[derive(
Clone,
Debug,
Default,
Deref,
DerefMut,
Display,
From,
FromStr,
Into,
PartialEq,
Eq,
Hash,
Deserialize,
Serialize,
Column,
)]
#[serde(transparent)]
pub struct Markdown(pub String);
impl TS for Markdown {
type WithoutGenerics = Self;
fn decl() -> String {
String::decl()
}
fn decl_concrete() -> String {
String::decl_concrete()
}
fn name() -> String {
String::name()
}
fn inline() -> String {
String::inline()
}
fn inline_flattened() -> String {
String::inline_flattened()
}
}
impl<S: ContextTrait> Input<S> for Markdown {
fn render_input(
value: Option<&Self>,
name: &str,
title: &str,
_required: bool,
ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
let id = Uuid::new_v4();
let editor_construction = ctx.ctx.editor().map(|config| {
format!(
"new EasyMDE({{ element: this, imageMaxSize: {max_size}, uploadImage: {upload}, \
imageUploadEndpoint: '/upload', imagePathAbsolute: true, imageAccept: \
'{file_types}' }})",
max_size = config.upload_max_size,
upload = config.enable_uploads,
file_types = config.allowed_file_types.join(", ")
)
});
html! {
div .cms-markdown-editor {
@if editor_construction.is_some() {
link rel="stylesheet" href="/node_modules/easymde/dist/easymde.min.css" {}
script src="/node_modules/easymde/dist/easymde.min.js" {}
}
textarea
#(id)
name=(name)
placeholder=(title)
onmount=(editor_construction.unwrap_or_default()) {
(value.map(|v| v.0.as_ref()).unwrap_or(""))
}
}
}
}
}
impl<'r> sqlx::Decode<'r, DB> for Markdown
where
String: sqlx::Decode<'r, DB>,
{
fn decode(
value: <DB as sqlx::Database>::ValueRef<'r>,
) -> Result<Self, sqlx::error::BoxDynError> {
Ok(Self(<String as sqlx::Decode<DB>>::decode(value)?))
}
}
impl sqlx::Type<DB> for Markdown
where
String: sqlx::Type<DB>,
{
fn type_info() -> <DB as sqlx::Database>::TypeInfo {
<String as sqlx::Type<DB>>::type_info()
}
}
impl<'r> sqlx::Encode<'r, DB> for Markdown
where
String: sqlx::Encode<'r, DB>,
{
fn encode_by_ref(
&self,
buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'r>,
) -> Result<sqlx::encode::IsNull, BoxDynError> {
sqlx::Encode::<'_, DB>::encode(&self.0, buf)
}
}
impl<S: ContextTrait> Input<S> for i8 {
fn render_input(
value: Option<&Self>,
name: &str,
title: &str,
required: bool,
_ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
html! {
input type="number" name=(name) placeholder=(title) class="cms-int-input" value=[value] required[required] step="1" {}
}
}
}
impl<S: ContextTrait> Input<S> for i16 {
fn render_input(
value: Option<&Self>,
name: &str,
title: &str,
required: bool,
_ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
html! {
input type="number" name=(name) placeholder=(title) class="cms-int-input" value=[value] required[required] step="1" {}
}
}
}
impl<S: ContextTrait> Input<S> for i32 {
fn render_input(
value: Option<&Self>,
name: &str,
title: &str,
required: bool,
_ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
html! {
input type="number" name=(name) placeholder=(title) class="cms-int-input" value=[value] required[required] step="1" {}
}
}
}
impl<S: ContextTrait> Input<S> for i64 {
fn render_input(
value: Option<&Self>,
name: &str,
title: &str,
required: bool,
_ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
html! {
input type="number" name=(name) placeholder=(title) class="cms-int-input" value=[value] required[required] step="1" {}
}
}
}
impl<S: ContextTrait> Input<S> for i128 {
fn render_input(
value: Option<&Self>,
name: &str,
title: &str,
required: bool,
_ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
html! {
input type="number" name=(name) placeholder=(title) class="cms-int-input" value=[value] required[required] step="1" {}
}
}
}
impl Column for i8 {
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html! {
(self)
}
}
}
impl Column for i16 {
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html! {
(self)
}
}
}
impl Column for i32 {
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html! {
(self)
}
}
}
impl Column for i64 {
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html! {
(self)
}
}
}
impl Column for i128 {
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html! {
(self)
}
}
}
impl<S: ContextTrait> Input<S> for u8 {
fn render_input(
value: Option<&Self>,
name: &str,
title: &str,
required: bool,
_ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
html! {
input type="number" name=(name) placeholder=(title) class="cms-uint-input" value=[value] required[required] step="1" min="0" {}
}
}
}
impl<S: ContextTrait> Input<S> for u16 {
fn render_input(
value: Option<&Self>,
name: &str,
title: &str,
required: bool,
_ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
html! {
input type="number" name=(name) placeholder=(title) class="cms-uint-input" value=[value] required[required] step="1" min="0" {}
}
}
}
impl<S: ContextTrait> Input<S> for u32 {
fn render_input(
value: Option<&Self>,
name: &str,
title: &str,
required: bool,
_ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
html! {
input type="number" name=(name) placeholder=(title) class="cms-uint-input" value=[value] required[required] step="1" min="0" {}
}
}
}
impl<S: ContextTrait> Input<S> for u64 {
fn render_input(
value: Option<&Self>,
name: &str,
title: &str,
required: bool,
_ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
html! {
input type="number" name=(name) placeholder=(title) class="cms-uint-input" value=[value] required[required] step="1" min="0" {}
}
}
}
impl<S: ContextTrait> Input<S> for u128 {
fn render_input(
value: Option<&Self>,
name: &str,
title: &str,
required: bool,
_ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
html! {
input type="number" name=(name) placeholder=(title) class="cms-uint-input" value=[value] required[required] step="1" min="0" {}
}
}
}
impl Column for u8 {
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html! {
(self)
}
}
}
impl Column for u16 {
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html! {
(self)
}
}
}
impl Column for u32 {
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html! {
(self)
}
}
}
impl Column for u64 {
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html! {
(self)
}
}
}
impl Column for u128 {
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html! {
(self)
}
}
}
impl<Tz: TimeZone, S: ContextTrait> Input<S> for DateTime<Tz>
where
for<'de> DateTime<Tz>: Deserialize<'de>,
{
fn render_input(
value: Option<&Self>,
name: &str,
_title: &str,
required: bool,
ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
let input_id = Uuid::new_v4();
let hidden_id = Uuid::new_v4();
html! {
input type="datetime-local" id=(input_id) class="cms-datetime-input" required[required] {}
input type="hidden" name=(name) id=(hidden_id) value=[value.map(|v|v.to_rfc3339())] {}
script type="module" {(PreEscaped(format!(r#"
const input = document.getElementById("{input_id}");
const hidden = document.getElementById("{hidden_id}");
const d = new Date(hidden.value);
input.value = `${{d.getFullYear()}}-${{(d.getMonth()+1).toString().padStart(2, '0')}}-${{d.getDate().toString().padStart(2, '0')}}T${{d.getHours().toString().padStart(2, '0')}}:${{d.getMinutes().toString().padStart(2, '0')}}`;
document.getElementById("{}").addEventListener("submit", () => {{
hidden.value = new Date(input.value).toISOString();
}});
"#, ctx.form_id).trim()))}
noscript {
"It appears that JavaScript is disabled. JavaScript is required to set dates in your current timezone. Please enter dates in UTC (Coordinated universal time) instead."
}
}
}
}
impl<Tz: TimeZone> Column for DateTime<Tz>
where
Tz::Offset: std::fmt::Display,
{
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html! {
time datetime=(self.to_rfc3339()) {
(self.to_string())
}
}
}
}
impl<S: ContextTrait> Input<S> for NaiveDate {
fn render_input(
value: Option<&Self>,
name: &str,
_title: &str,
required: bool,
_ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
html! {
input type="date" name=(name) value=[value] class="cms-date-input" required[required] {}
}
}
}
impl Column for NaiveDate {
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html! {
time datetime=(self) {
(self)
}
}
}
}
impl<S: ContextTrait> Input<S> for bool {
fn render_input(
value: Option<&Self>,
name: &str,
_title: &str,
_required: bool,
_ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
html! {
input type="checkbox" name=(name) value="true" checked[*value.unwrap_or(&false)] {}
}
}
}
impl Column for bool {
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html! {
input type="checkbox" disabled checked[*self] {}
}
}
}
impl<T: Input<S>, S: ContextTrait> Input<S> for Vec<T> {
fn render_input(
value: Option<&Self>,
name: &str,
title: &str,
required: bool,
ctx: &FormRenderContext<'_, S>,
i18n: &FluentLanguageLoader,
) -> Markup {
let add_btn_id = Uuid::new_v4();
let list_id = Uuid::new_v4();
let template_id = Uuid::new_v4();
let onmount = format!(
r#"
const Sortable = (await import("/node_modules/sortablejs/modular/sortable.esm.js")).default;
const addBtn = document.getElementById("{add_btn_id}");
const list = document.getElementById("{list_id}");
const template = document.getElementById("{template_id}");
const name = list.getAttribute("data-name");
const re = new RegExp(`^${{RegExp.escape(name)}}\[[0-9]*\]`)
const setIndex = (el, i) => {{
for (const e of el.querySelectorAll("[data-name]")) {{
e.setAttribute("data-name", e.getAttribute("data-name").replace(re, `${{name}}[${{i}}]`));
}}
for (const e of el.querySelectorAll("[name]")) {{
e.name = e.name.replace(re, `${{name}}[${{i}}]`);
}}
for (const e of el.querySelectorAll("[id]")) {{
e.id = e.id.replace(re, `${{name}}[${{i}}]`);
}}
for (const e of el.querySelectorAll("[for]")) {{
e.attributes.for.value = e.attributes.for.value.replace(re, `${{name}}[${{i}}]`);
}}
}}
const recalculateIndices = () => {{
console.log("recalculateInd");
for (const [i, el] of list.querySelectorAll(":scope > .cms-list-element-wrapper").entries()) {{
setIndex(el, i);
}}
}};
for (const btn of list.querySelectorAll(":scope > .cms-list-element-wrapper > .cms-list-remove-button")) {{
btn.addEventListener("click", function(e) {{
e.preventDefault();
this.parentNode.remove();
recalculateIndices();
}});
}}
template.remove();
template.removeAttribute("style");
addBtn.addEventListener("click", (e) => {{
e.preventDefault();
let el = template.cloneNode(true);
el.removeAttribute("id");
setIndex(el, list.childElementCount - 1)
list.insertBefore(el, addBtn);
callOnMountRecursive(el);
}});
// TODO: check if this works with nested lists & onmount
Sortable.create(list, {{ delay: 500, delayOnTouchOnly: true, onEnd: recalculateIndices }});
"#
);
html! {
div class="cms-list-input" id=(list_id) data-name=(name) onmount=(onmount) {
@if let Some(v) = value {
@for (i, v) in v.iter().enumerate() {
div class="cms-list-element-wrapper" {
fieldset class="cms-list-element" {
(Input::render_input(Some(v), &format!("{name}[{i}]"), title, required, ctx, i18n))
}
button class="cms-list-remove-button" {"X"}
}
}
}
div id=(template_id) class="cms-list-element-wrapper" style="display: none" onmount="return true" {
fieldset class="cms-list-element" {
(Input::render_input(Option::<&T>::None, &format!("{name}[]"), title, required, ctx, i18n))
}
button class="cms-list-remove-button" {"X"}
}
button id=(add_btn_id) {"+"}
}
}
}
}
impl<T: Input<S>, S: ContextTrait> Input<S> for Option<T> {
fn render_input(
value: Option<&Self>,
name: &str,
title: &str,
_required: bool,
ctx: &FormRenderContext<'_, S>,
i18n: &FluentLanguageLoader,
) -> Markup {
let value = match value {
Some(v) => v.as_ref(),
None => None,
};
T::render_input(value, name, title, false, ctx, i18n)
}
}
impl<T: Column> Column for Option<T> {
fn render(&self, i18n: &FluentLanguageLoader) -> Markup {
match self {
Some(v) => v.render(i18n),
None => html!(),
}
}
}
#[cfg(feature = "json")]
pub use json::Json;
#[cfg(feature = "json")]
mod json {
use super::*;
#[derive(
Copy,
Clone,
Debug,
Deref,
DerefMut,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Default,
Serialize,
Deserialize,
)]
#[serde(transparent)]
pub struct Json<T: ?Sized>(pub T);
impl<T: TS + ?Sized> TS for Json<T> {
type WithoutGenerics = T::WithoutGenerics;
fn decl() -> String {
T::decl()
}
fn decl_concrete() -> String {
T::decl_concrete()
}
fn name() -> String {
T::name()
}
fn inline() -> String {
T::inline()
}
fn inline_flattened() -> String {
T::inline_flattened()
}
fn visit_dependencies(visitor: &mut impl ts_rs::TypeVisitor)
where
Self: 'static,
{
T::visit_dependencies(visitor)
}
fn visit_generics(visitor: &mut impl ts_rs::TypeVisitor)
where
Self: 'static,
{
T::visit_generics(visitor)
}
fn output_path() -> Option<&'static Path> {
T::output_path()
}
}
impl<'r, T> sqlx::Decode<'r, DB> for Json<T>
where
sqlx::types::Json<T>: sqlx::Decode<'r, DB>,
{
fn decode(
value: <DB as sqlx::Database>::ValueRef<'r>,
) -> Result<Self, sqlx::error::BoxDynError> {
Ok(Self(
<sqlx::types::Json<T> as sqlx::Decode<DB>>::decode(value)?.0,
))
}
}
impl<T> sqlx::Type<DB> for Json<T>
where
sqlx::types::Json<T>: sqlx::Type<DB>,
{
fn type_info() -> <DB as sqlx::Database>::TypeInfo {
<sqlx::types::Json<T> as sqlx::Type<DB>>::type_info()
}
}
impl<'q, T> sqlx::Encode<'q, DB> for Json<T>
where
for<'a> sqlx::types::Json<&'a T>: sqlx::Encode<'q, DB>,
{
fn encode_by_ref(
&self,
buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'q>,
) -> Result<sqlx_core::encode::IsNull, BoxDynError> {
<sqlx::types::Json<&T> as sqlx::Encode<'q, DB>>::encode(sqlx::types::Json(&self.0), buf)
}
}
#[cfg(feature = "json")]
impl<T: Input<S>, S: ContextTrait> Input<S> for Json<T> {
fn render_input(
value: Option<&Self>,
name: &str,
title: &str,
required: bool,
ctx: &FormRenderContext<'_, S>,
i18n: &FluentLanguageLoader,
) -> Markup {
T::render_input(value.map(|v| &v.0), name, title, required, ctx, i18n)
}
}
#[cfg(feature = "json")]
impl<T: Column> Column for Json<T> {
fn render(&self, i18n: &FluentLanguageLoader) -> Markup {
self.0.render(i18n)
}
}
}
impl Column for Uuid {
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html!((self))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, TS)]
pub struct File {
pub(crate) id: Uuid,
pub(crate) name: String,
}
impl File {
pub fn new(file_name: String) -> Self {
Self::new_with_id(Uuid::new_v4(), file_name)
}
pub fn new_with_id(id: Uuid, name: String) -> Self {
Self { id, name }
}
pub fn url(&self) -> String {
format!("/uploads/{}/{}", self.id, self.name)
}
pub fn path(&self, uploads_dir: &Path) -> PathBuf {
uploads_dir.join(self.id.to_string()).join(&self.name)
}
}
impl<'de> Deserialize<'de> for File {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Debug, Deserialize)]
struct File {
id: Option<Uuid>,
id_old: Option<Uuid>,
name: Option<String>,
name_old: Option<String>,
}
let f = File::deserialize(deserializer)?;
let id =
f.id.or(f.id_old)
.map(Into::into)
.ok_or(serde::de::Error::missing_field("id"))?;
let name = f
.name
.or(f.name_old)
.map(Into::into)
.ok_or(serde::de::Error::missing_field("name"))?;
Ok(Self { id, name })
}
}
impl<S: ContextTrait> Input<S> for File {
fn render_input(
value: Option<&Self>,
name: &str,
_title: &str,
required: bool,
_ctx: &FormRenderContext<'_, S>,
_i18n: &FluentLanguageLoader,
) -> Markup {
html! {
@if let Some(v) = value {
input type="hidden" name=(format!("{name}[id_old]")) value=(v.id) {}
input type="hidden" name=(format!("{name}[name_old]")) value=(v.name) {}
}
input type="file" name=(name) required[required && value.is_none()] {}
}
}
}
impl Column for File {
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html! {
a href=(self.url()) {
(self.name)
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize, TS)]
pub struct Image {
#[serde(flatten)]
pub file: File,
pub alt_text: Option<String>,
}
impl<S: ContextTrait> Input<S> for Image {
fn render_input(
value: Option<&Self>,
name: &str,
_title: &str,
required: bool,
_ctx: &FormRenderContext<'_, S>,
i18n: &FluentLanguageLoader,
) -> Markup {
html! {
fieldset class="cms-image cms-prop-group" {
@if let Some(v) = value {
input type="hidden" name=(format!("{name}[id_old]")) value=(v.file.id) {}
input type="hidden" name=(format!("{name}[name_old]")) value=(v.file.name) {}
}
input type="file" accept="image/*" name=(name) required[required && value.is_none()] {}
input
type="text"
name=(format!("{name}[alt_text]"))
placeholder=(fl!(i18n, "image-alt-text"))
class="cms-text-input cms-prop-container"
value=[value.map(|v| v.alt_text.as_deref().unwrap_or_default())] {}
}
}
}
}
impl Column for Image {
fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
html! {
a href=(self.file.url()) {
(self.file.name)
}
@if let Some(alt_text) = &self.alt_text {
" (" (alt_text) ")"
}
}
}
}