derived_cms/
render.rs

1use std::{borrow::Borrow, cmp::Ordering, fmt::Display};
2
3use axum::extract::State;
4use convert_case::{Case, Casing};
5use i18n_embed::fluent::FluentLanguageLoader;
6use i18n_embed_fl::fl;
7use maud::{html, Markup, PreEscaped, DOCTYPE};
8use uuid::Uuid;
9
10use crate::{
11    context::ContextTrait, entity::EntityBase, input::InputInfo, property::EnumVariant, Entity,
12};
13
14#[non_exhaustive]
15pub struct FormRenderContext<'a, S: ContextTrait> {
16    /// unique id of the HTML form element
17    pub form_id: &'a str,
18    pub ctx: S,
19}
20
21pub fn document(body: Markup) -> Markup {
22    html! {
23        (DOCTYPE)
24        html {
25            head {
26                link rel="stylesheet" href="" {}
27                meta charset="utf-8" {}
28                link rel="icon" href="/favicon.png" {}
29                link rel="stylesheet" type="text/css" href="/css/main.css" {}
30                meta name="viewport" content="width=device-width, initial-scale=1" {}
31            }
32            body {
33                (body)
34            }
35        }
36    }
37}
38
39pub fn sidebar(
40    _i18n: &FluentLanguageLoader,
41    names: impl IntoIterator<Item = impl AsRef<str>>,
42    active: &str,
43) -> Markup {
44    html! {
45        nav class="cms-sidebar" {
46            @for name in names {
47                @let name = name.as_ref();
48                a href=(&format!("/{}", name.to_case(Case::Kebab))) class=[(name == active).then_some("active")] {
49                    (name.to_case(Case::Title))
50                }
51            }
52        }
53    }
54}
55
56pub fn entity_inputs<E: Entity<S>, S: ContextTrait>(
57    ctx: S,
58    i18n: &FluentLanguageLoader,
59    value: Option<&E>,
60) -> Markup {
61    let form_id = &Uuid::new_v4().to_string();
62    let ctx = FormRenderContext { form_id, ctx };
63    html! {
64        form id=(form_id) class="cms-entity-form cms-add-form" method="post" enctype="multipart/form-data" {
65            (inputs(&ctx, i18n, EntityBase::inputs(value)))
66            button class="cms-button" type="submit" {
67                (fl!(i18n, "entity-inputs-submit"))
68            }
69            script src="/js/callOnMountRecursive.js" {}
70            script {
71                (PreEscaped(format!(r#"callOnMountRecursive(document.getElementById("{form_id}"));"#)))
72            }
73        }
74    }
75}
76
77pub fn struct_input<'a, S: ContextTrait>(
78    ctx: &FormRenderContext<'_, S>,
79    i18n: &FluentLanguageLoader,
80    fields: impl IntoIterator<Item = InputInfo<'a, S>>,
81) -> Markup {
82    html! {
83        fieldset class="cms-struct-container" {
84            (inputs(ctx, i18n, fields))
85        }
86    }
87}
88
89pub fn inputs<'a, S: ContextTrait>(
90    ctx: &FormRenderContext<'_, S>,
91    i18n: &FluentLanguageLoader,
92    inputs: impl IntoIterator<Item = InputInfo<'a, S>>,
93) -> Markup {
94    html! {
95        @for f in inputs {
96            div class="cms-prop-container" {
97                label class="cms-prop-label" {(f.name_human)}
98                (f.value.render_input(f.name, f.name_human, true, &ctx, i18n))
99            }
100        }
101    }
102}
103
104pub fn entity_list_page<E: Entity<S>, S: ContextTrait>(
105    ctx: State<S>,
106    i18n: &FluentLanguageLoader,
107    entities: impl IntoIterator<Item = impl Borrow<E>>,
108) -> Markup {
109    document(html! {
110        (sidebar(&i18n, ctx.names_plural(), E::name_plural()))
111        main {
112            header class="cms-header" {
113                h1 {(E::name_plural().to_case(Case::Title))}
114                a href=(format!("/{}/add", (E::name_plural().to_case(Case::Kebab)))) class="cms-button" {
115                    (fl!(i18n, "enitity-list-add"))
116                }
117            }
118            @for (i, c) in E::columns().iter().enumerate() {
119                @let i = i + 1;
120                @let id = format!("cms-list-column-filter-input-{i}");
121                input id=(id) class=("cms-list-column-filter-input") type="checkbox" checked[!c.hidden] {}
122                label for=(id) {
123                    (c.name)
124                }
125                style {(PreEscaped(format!(r#"
126#{id}:not(:checked) ~ .cms-entity-list .cms-list-column:nth-child({i}) {{
127    display: none;
128}}
129                "#).trim()))}
130            }
131            table class="cms-entity-list" {
132                tr {
133                    @for c in E::columns() {
134                        th class="cms-list-column" {(c.name)}
135                    }
136                    th {}
137                }
138                @for e in entities {
139                    @let e = e.borrow();
140                    @let name = E::name().to_case(Case::Kebab);
141                    @let id = e.id().to_string();
142                    @let id = urlencoding::encode(&id);
143                    @let row_id = Uuid::new_v4();
144                    @let dialog_id = Uuid::new_v4();
145                    tr id=(row_id) {
146                        @for c in e.column_values() {
147                            td class="cms-list-column" onclick=(format!(
148                                "window.location = \"/{name}/{id}\"",
149                            )) {
150                                (c.render(i18n))
151                            }
152                        }
153                        td
154                            class="cms-list-column cms-list-delete-button"
155                            onclick=(format!(r#"document.getElementById("{dialog_id}").showModal()"#))
156                        {
157                            "X"
158                        }
159                        (confirm_delete_modal(
160                            i18n,
161                            dialog_id,
162                            &E::name().to_case(Case::Title),
163                            format!(r#"
164fetch("/api/v1/{name}/{id}", {{ method: "DELETE" }})
165    .then((r) => {{
166        if (!r.ok) return;
167        document.getElementById("{row_id}").remove();
168        document.getElementById("{dialog_id}").remove();
169    }})
170                            "#).trim()
171                        ))
172                    }
173                }
174            }
175        }
176    })
177}
178
179pub fn confirm_delete_modal(
180    i18n: &FluentLanguageLoader,
181    dialog_id: impl Display,
182    name: &str,
183    on_submit: impl Display,
184) -> Markup {
185    html! {
186        dialog id=(dialog_id) class="cms-confirm-delete-modal" {
187            p {(fl!(i18n, "confirm-delete-modal", "title", name = name))}
188            form method="dialog" {
189                button {
190                    (fl!(i18n, "confirm-delete-modal", "cancel"))
191                }
192                button onclick=(on_submit) {
193                    (fl!(i18n, "confirm-delete-modal", "confirm"))
194                }
195            }
196        }
197    }
198}
199
200pub fn entity_page<E: Entity<S>, S: ContextTrait>(
201    State(ctx): State<S>,
202    i18n: &FluentLanguageLoader,
203    entity: Option<&E>,
204) -> Markup {
205    document(html! {
206        (sidebar(i18n, ctx.names_plural(), E::name_plural()))
207        main {
208            h1 {(fl!(i18n, "edit-entity-title", name = E::name().to_case(Case::Title)))}
209            (entity_inputs::<E, S>(ctx, i18n, entity))
210        }
211    })
212}
213
214pub fn add_entity_page<E: Entity<S>, S: ContextTrait>(
215    State(ctx): State<S>,
216    i18n: &FluentLanguageLoader,
217    entity: Option<&E>,
218) -> Markup {
219    document(html! {
220        (sidebar(i18n, ctx.names_plural(), E::name_plural()))
221        main {
222            h1 {(fl!(i18n, "create-entity-title", name = E::name().to_case(Case::Title)))}
223            (entity_inputs::<E, S>(ctx, i18n, entity))
224        }
225    })
226}
227
228pub fn input_enum<S: ContextTrait>(
229    ctx: &FormRenderContext<'_, S>,
230    i18n: &FluentLanguageLoader,
231    variants: &[EnumVariant<'_, S>],
232    selected: usize,
233    required: bool,
234) -> Markup {
235    let id_type = Uuid::new_v4();
236    let id_data = Uuid::new_v4();
237    html! {
238        div class="cms-enum-type" id=(id_type) {
239            @for (i, variant) in variants.iter().enumerate() {
240                @let id = &format!("{}_radio-button_{}", variant.name, variant.value);
241                input
242                    type="radio"
243                    name=(variant.name)
244                    value=(variant.value)
245                    id=(id)
246                    checked[i == selected]
247                    onchange="cmsEnumInputOnchange(this)" {}
248                label for=(id) {(variant.value.to_case(Case::Title))}
249            }
250        }
251        div class="cms-enum-data" id=(id_data) {
252            @for (i, variant) in variants.iter().enumerate() {
253                @let class = match i.cmp(&selected) {
254                    Ordering::Less => "cms-enum-container cms-enum-hidden cms-enum-hidden-left",
255                    Ordering::Greater => "cms-enum-container cms-enum-hidden cms-enum-hidden-right",
256                    Ordering::Equal => "cms-enum-container",
257                };
258                fieldset class=(class) disabled[i != selected] {
259                    @if let Some(ref data) = variant.content {
260                        (data.value.render_input(data.name, &variant.value.to_case(Case::Title), required, ctx, i18n))
261                    }
262                }
263            }
264        }
265        script src="/js/enum.js" {}
266    }
267}
268
269pub fn error_page(title: &str, description: &str) -> Markup {
270    document(html! {
271        main {
272            h1 {(title)}
273            p {
274                @for line in description.split('\n') {
275                    (line)
276                    br;
277                }
278            }
279            a href="javascript:history.back()" {"Go Back"}
280        }
281    })
282}