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 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}