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