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            @let id = f.help.map(|_| Uuid::new_v4());
97            div class="cms-prop-container" {
98                label class="cms-prop-label" aria-describedby=[id] {(f.title)}
99                @if let Some(help) = f.help {
100                    div role="tooltip" id=[id] {(help)}
101                }
102                (f.value.render_input(f.name, f.title, true, &ctx, i18n))
103            }
104        }
105    }
106}
107
108pub fn entity_list_page<E: Entity<S>, S: ContextTrait>(
109    ctx: State<S>,
110    i18n: &FluentLanguageLoader,
111    entities: impl IntoIterator<Item = impl Borrow<E>>,
112) -> Markup {
113    document(html! {
114        (sidebar(&i18n, ctx.names_plural(), E::name_plural()))
115        main {
116            header class="cms-header" {
117                h1 {(E::name_plural().to_case(Case::Title))}
118                a href=(format!("/{}/add", (E::name_plural().to_case(Case::Kebab)))) class="cms-button" {
119                    (fl!(i18n, "enitity-list-add"))
120                }
121            }
122            @for (i, c) in E::columns().iter().enumerate() {
123                @let i = i + 1;
124                @let id = format!("cms-list-column-filter-input-{i}");
125                input id=(id) class=("cms-list-column-filter-input") type="checkbox" checked[!c.hidden] {}
126                label for=(id) {
127                    (c.name)
128                }
129                style {(PreEscaped(format!(r#"
130#{id}:not(:checked) ~ .cms-entity-list .cms-list-column:nth-child({i}) {{
131    display: none;
132}}
133                "#).trim()))}
134            }
135            table class="cms-entity-list" {
136                tr {
137                    @for c in E::columns() {
138                        th class="cms-list-column" {(c.name)}
139                    }
140                    th {}
141                }
142                @for e in entities {
143                    @let e = e.borrow();
144                    @let name = E::name().to_case(Case::Kebab);
145                    @let id = e.id().to_string();
146                    @let id = urlencoding::encode(&id);
147                    @let row_id = Uuid::new_v4();
148                    @let dialog_id = Uuid::new_v4();
149                    tr id=(row_id) {
150                        @for c in e.column_values() {
151                            td class="cms-list-column" onclick=(format!(
152                                "window.location = \"/{name}/{id}\"",
153                            )) {
154                                (c.render(i18n))
155                            }
156                        }
157                        td
158                            class="cms-list-column cms-list-delete-button"
159                            onclick=(format!(r#"document.getElementById("{dialog_id}").showModal()"#))
160                        {
161                            "X"
162                        }
163                        (confirm_delete_modal(
164                            i18n,
165                            dialog_id,
166                            &E::name().to_case(Case::Title),
167                            format!(r#"
168fetch("/api/v1/{name}/{id}", {{ method: "DELETE" }})
169    .then((r) => {{
170        if (!r.ok) return;
171        document.getElementById("{row_id}").remove();
172        document.getElementById("{dialog_id}").remove();
173    }})
174                            "#).trim()
175                        ))
176                    }
177                }
178            }
179        }
180    })
181}
182
183pub fn confirm_delete_modal(
184    i18n: &FluentLanguageLoader,
185    dialog_id: impl Display,
186    name: &str,
187    on_submit: impl Display,
188) -> Markup {
189    html! {
190        dialog id=(dialog_id) class="cms-confirm-delete-modal" {
191            p {(fl!(i18n, "confirm-delete-modal", "title", name = name))}
192            form method="dialog" {
193                button {
194                    (fl!(i18n, "confirm-delete-modal", "cancel"))
195                }
196                button onclick=(on_submit) {
197                    (fl!(i18n, "confirm-delete-modal", "confirm"))
198                }
199            }
200        }
201    }
202}
203
204pub fn entity_page<E: Entity<S>, S: ContextTrait>(
205    State(ctx): State<S>,
206    i18n: &FluentLanguageLoader,
207    entity: Option<&E>,
208) -> Markup {
209    document(html! {
210        (sidebar(i18n, ctx.names_plural(), E::name_plural()))
211        main {
212            h1 {(fl!(i18n, "edit-entity-title", name = E::name().to_case(Case::Title)))}
213            (entity_inputs::<E, S>(ctx, i18n, entity))
214        }
215    })
216}
217
218pub fn add_entity_page<E: Entity<S>, S: ContextTrait>(
219    State(ctx): State<S>,
220    i18n: &FluentLanguageLoader,
221    entity: Option<&E>,
222) -> Markup {
223    document(html! {
224        (sidebar(i18n, ctx.names_plural(), E::name_plural()))
225        main {
226            h1 {(fl!(i18n, "create-entity-title", name = E::name().to_case(Case::Title)))}
227            (entity_inputs::<E, S>(ctx, i18n, entity))
228        }
229    })
230}
231
232pub fn input_enum<S: ContextTrait>(
233    ctx: &FormRenderContext<'_, S>,
234    i18n: &FluentLanguageLoader,
235    variants: &[EnumVariant<'_, S>],
236    selected: usize,
237    required: bool,
238) -> Markup {
239    let id_type = Uuid::new_v4();
240    let id_data = Uuid::new_v4();
241    html! {
242        div class="cms-enum-type" id=(id_type) {
243            @for (i, variant) in variants.iter().enumerate() {
244                @let id = &format!("{}_radio-button_{}", variant.name, variant.value);
245                input
246                    type="radio"
247                    name=(variant.name)
248                    value=(variant.value)
249                    id=(id)
250                    checked[i == selected]
251                    onchange="cmsEnumInputOnchange(this)" {}
252                label for=(id) {(variant.title)}
253            }
254        }
255        div class="cms-enum-data" id=(id_data) {
256            @for (i, variant) in variants.iter().enumerate() {
257                @let class = match i.cmp(&selected) {
258                    Ordering::Less => "cms-enum-container cms-enum-hidden cms-enum-hidden-left",
259                    Ordering::Greater => "cms-enum-container cms-enum-hidden cms-enum-hidden-right",
260                    Ordering::Equal => "cms-enum-container",
261                };
262                fieldset class=(class) disabled[i != selected] {
263                    @if let Some(ref data) = variant.content {
264                        (data.value.render_input(data.name, &variant.value.to_case(Case::Title), required, ctx, i18n))
265                    }
266                }
267            }
268        }
269        script src="/js/enum.js" {}
270    }
271}
272
273pub fn error_page(title: &str, description: &str) -> Markup {
274    document(html! {
275        main {
276            h1 {(title)}
277            p {
278                @for line in description.split('\n') {
279                    (line)
280                    br;
281                }
282            }
283            a href="javascript:history.back()" {"Go Back"}
284        }
285    })
286}