1use std::{
2 fmt::Debug,
3 path::{Path, PathBuf},
4};
5
6use chrono::{DateTime, NaiveDate, TimeZone};
7use derive_more::{Deref, DerefMut, Display, From, FromStr, Into};
8use i18n_embed::fluent::FluentLanguageLoader;
9use i18n_embed_fl::fl;
10use maud::{html, Markup, PreEscaped};
11use serde::{Deserialize, Serialize};
12use sqlx::error::BoxDynError;
13use ts_rs::TS;
14use uuid::Uuid;
15
16use crate::{
17 self as derived_cms, context::ContextTrait, input::InputInfo, render::FormRenderContext,
18 Column, Input, DB,
19};
20
21#[derive(Debug)]
22pub struct EnumVariant<'a, S: ContextTrait> {
23 pub name: &'a str,
25 pub title: &'a str,
27 pub value: &'a str,
29 pub content: Option<InputInfo<'a, S>>,
30}
31
32#[derive(
37 Clone,
38 Debug,
39 Default,
40 Deref,
41 DerefMut,
42 Display,
43 From,
44 FromStr,
45 Into,
46 PartialEq,
47 Eq,
48 Hash,
49 Deserialize,
50 Serialize,
51 Column,
52)]
53#[serde(transparent)]
54pub struct Text(pub String);
55
56impl TS for Text {
57 type WithoutGenerics = Text;
58
59 fn decl() -> String {
60 String::decl()
61 }
62
63 fn decl_concrete() -> String {
64 String::decl_concrete()
65 }
66
67 fn name() -> String {
68 String::name()
69 }
70
71 fn inline() -> String {
72 String::inline()
73 }
74
75 fn inline_flattened() -> String {
76 String::inline_flattened()
77 }
78}
79
80impl<'r> sqlx::Decode<'r, DB> for Text
81where
82 String: sqlx::Decode<'r, DB>,
83{
84 fn decode(
85 value: <DB as sqlx::Database>::ValueRef<'r>,
86 ) -> Result<Self, sqlx::error::BoxDynError> {
87 Ok(Self(<String as sqlx::Decode<DB>>::decode(value)?))
88 }
89}
90
91impl sqlx::Type<DB> for Text
92where
93 String: sqlx::Type<DB>,
94{
95 fn type_info() -> <DB as sqlx::Database>::TypeInfo {
96 <String as sqlx::Type<DB>>::type_info()
97 }
98}
99
100impl<'r> sqlx::Encode<'r, DB> for Text
101where
102 String: sqlx::Encode<'r, DB>,
103{
104 fn encode_by_ref(
105 &self,
106 buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'r>,
107 ) -> Result<sqlx::encode::IsNull, BoxDynError> {
108 sqlx::Encode::<'_, DB>::encode(&self.0, buf)
109 }
110}
111
112impl<S: ContextTrait> Input<S> for Text {
113 fn render_input(
114 value: Option<&Self>,
115 name: &str,
116 title: &str,
117 required: bool,
118 _ctx: &FormRenderContext<'_, S>,
119 _i18n: &FluentLanguageLoader,
120 ) -> Markup {
121 html! {
122 input type="text" name=(name) placeholder=(title) class="cms-text-input" value=[value] required[required] {}
123 }
124 }
125}
126
127#[derive(
132 Clone,
133 Debug,
134 Default,
135 Deref,
136 DerefMut,
137 Display,
138 From,
139 FromStr,
140 Into,
141 PartialEq,
142 Eq,
143 Hash,
144 Deserialize,
145 Serialize,
146 Column,
147)]
148#[serde(transparent)]
149pub struct Markdown(pub String);
150
151impl TS for Markdown {
152 type WithoutGenerics = Self;
153
154 fn decl() -> String {
155 String::decl()
156 }
157
158 fn decl_concrete() -> String {
159 String::decl_concrete()
160 }
161
162 fn name() -> String {
163 String::name()
164 }
165
166 fn inline() -> String {
167 String::inline()
168 }
169
170 fn inline_flattened() -> String {
171 String::inline_flattened()
172 }
173}
174
175impl<S: ContextTrait> Input<S> for Markdown {
176 fn render_input(
177 value: Option<&Self>,
178 name: &str,
179 title: &str,
180 _required: bool,
181 ctx: &FormRenderContext<'_, S>,
182 _i18n: &FluentLanguageLoader,
183 ) -> Markup {
184 let id = Uuid::new_v4();
185 let editor_construction = ctx.ctx.editor().map(|config| {
186 format!(
187 "new EasyMDE({{ element: this, imageMaxSize: {max_size}, uploadImage: {upload}, \
188 imageUploadEndpoint: '/upload', imagePathAbsolute: true, imageAccept: \
189 '{file_types}' }})",
190 max_size = config.upload_max_size,
191 upload = config.enable_uploads,
192 file_types = config.allowed_file_types.join(", ")
193 )
194 });
195 html! {
196 div .cms-markdown-editor {
197 @if editor_construction.is_some() {
198 link rel="stylesheet" href="/node_modules/easymde/dist/easymde.min.css" {}
199 script src="/node_modules/easymde/dist/easymde.min.js" {}
200 }
201 textarea
202 #(id)
203 name=(name)
204 placeholder=(title)
205 onmount=(editor_construction.unwrap_or_default()) {
206 (value.map(|v| v.0.as_ref()).unwrap_or(""))
207 }
208 }
209 }
210 }
211}
212impl<'r> sqlx::Decode<'r, DB> for Markdown
213where
214 String: sqlx::Decode<'r, DB>,
215{
216 fn decode(
217 value: <DB as sqlx::Database>::ValueRef<'r>,
218 ) -> Result<Self, sqlx::error::BoxDynError> {
219 Ok(Self(<String as sqlx::Decode<DB>>::decode(value)?))
220 }
221}
222impl sqlx::Type<DB> for Markdown
223where
224 String: sqlx::Type<DB>,
225{
226 fn type_info() -> <DB as sqlx::Database>::TypeInfo {
227 <String as sqlx::Type<DB>>::type_info()
228 }
229}
230impl<'r> sqlx::Encode<'r, DB> for Markdown
231where
232 String: sqlx::Encode<'r, DB>,
233{
234 fn encode_by_ref(
235 &self,
236 buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'r>,
237 ) -> Result<sqlx::encode::IsNull, BoxDynError> {
238 sqlx::Encode::<'_, DB>::encode(&self.0, buf)
239 }
240}
241
242impl<S: ContextTrait> Input<S> for i8 {
247 fn render_input(
248 value: Option<&Self>,
249 name: &str,
250 title: &str,
251 required: bool,
252 _ctx: &FormRenderContext<'_, S>,
253 _i18n: &FluentLanguageLoader,
254 ) -> Markup {
255 html! {
256 input type="number" name=(name) placeholder=(title) class="cms-int-input" value=[value] required[required] step="1" {}
257 }
258 }
259}
260impl<S: ContextTrait> Input<S> for i16 {
261 fn render_input(
262 value: Option<&Self>,
263 name: &str,
264 title: &str,
265 required: bool,
266 _ctx: &FormRenderContext<'_, S>,
267 _i18n: &FluentLanguageLoader,
268 ) -> Markup {
269 html! {
270 input type="number" name=(name) placeholder=(title) class="cms-int-input" value=[value] required[required] step="1" {}
271 }
272 }
273}
274impl<S: ContextTrait> Input<S> for i32 {
275 fn render_input(
276 value: Option<&Self>,
277 name: &str,
278 title: &str,
279 required: bool,
280 _ctx: &FormRenderContext<'_, S>,
281 _i18n: &FluentLanguageLoader,
282 ) -> Markup {
283 html! {
284 input type="number" name=(name) placeholder=(title) class="cms-int-input" value=[value] required[required] step="1" {}
285 }
286 }
287}
288impl<S: ContextTrait> Input<S> for i64 {
289 fn render_input(
290 value: Option<&Self>,
291 name: &str,
292 title: &str,
293 required: bool,
294 _ctx: &FormRenderContext<'_, S>,
295 _i18n: &FluentLanguageLoader,
296 ) -> Markup {
297 html! {
298 input type="number" name=(name) placeholder=(title) class="cms-int-input" value=[value] required[required] step="1" {}
299 }
300 }
301}
302impl<S: ContextTrait> Input<S> for i128 {
303 fn render_input(
304 value: Option<&Self>,
305 name: &str,
306 title: &str,
307 required: bool,
308 _ctx: &FormRenderContext<'_, S>,
309 _i18n: &FluentLanguageLoader,
310 ) -> Markup {
311 html! {
312 input type="number" name=(name) placeholder=(title) class="cms-int-input" value=[value] required[required] step="1" {}
313 }
314 }
315}
316impl Column for i8 {
317 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
318 html! {
319 (self)
320 }
321 }
322}
323impl Column for i16 {
324 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
325 html! {
326 (self)
327 }
328 }
329}
330impl Column for i32 {
331 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
332 html! {
333 (self)
334 }
335 }
336}
337impl Column for i64 {
338 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
339 html! {
340 (self)
341 }
342 }
343}
344impl Column for i128 {
345 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
346 html! {
347 (self)
348 }
349 }
350}
351
352impl<S: ContextTrait> Input<S> for u8 {
357 fn render_input(
358 value: Option<&Self>,
359 name: &str,
360 title: &str,
361 required: bool,
362 _ctx: &FormRenderContext<'_, S>,
363 _i18n: &FluentLanguageLoader,
364 ) -> Markup {
365 html! {
366 input type="number" name=(name) placeholder=(title) class="cms-uint-input" value=[value] required[required] step="1" min="0" {}
367 }
368 }
369}
370impl<S: ContextTrait> Input<S> for u16 {
371 fn render_input(
372 value: Option<&Self>,
373 name: &str,
374 title: &str,
375 required: bool,
376 _ctx: &FormRenderContext<'_, S>,
377 _i18n: &FluentLanguageLoader,
378 ) -> Markup {
379 html! {
380 input type="number" name=(name) placeholder=(title) class="cms-uint-input" value=[value] required[required] step="1" min="0" {}
381 }
382 }
383}
384impl<S: ContextTrait> Input<S> for u32 {
385 fn render_input(
386 value: Option<&Self>,
387 name: &str,
388 title: &str,
389 required: bool,
390 _ctx: &FormRenderContext<'_, S>,
391 _i18n: &FluentLanguageLoader,
392 ) -> Markup {
393 html! {
394 input type="number" name=(name) placeholder=(title) class="cms-uint-input" value=[value] required[required] step="1" min="0" {}
395 }
396 }
397}
398impl<S: ContextTrait> Input<S> for u64 {
399 fn render_input(
400 value: Option<&Self>,
401 name: &str,
402 title: &str,
403 required: bool,
404 _ctx: &FormRenderContext<'_, S>,
405 _i18n: &FluentLanguageLoader,
406 ) -> Markup {
407 html! {
408 input type="number" name=(name) placeholder=(title) class="cms-uint-input" value=[value] required[required] step="1" min="0" {}
409 }
410 }
411}
412impl<S: ContextTrait> Input<S> for u128 {
413 fn render_input(
414 value: Option<&Self>,
415 name: &str,
416 title: &str,
417 required: bool,
418 _ctx: &FormRenderContext<'_, S>,
419 _i18n: &FluentLanguageLoader,
420 ) -> Markup {
421 html! {
422 input type="number" name=(name) placeholder=(title) class="cms-uint-input" value=[value] required[required] step="1" min="0" {}
423 }
424 }
425}
426impl Column for u8 {
427 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
428 html! {
429 (self)
430 }
431 }
432}
433impl Column for u16 {
434 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
435 html! {
436 (self)
437 }
438 }
439}
440impl Column for u32 {
441 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
442 html! {
443 (self)
444 }
445 }
446}
447impl Column for u64 {
448 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
449 html! {
450 (self)
451 }
452 }
453}
454impl Column for u128 {
455 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
456 html! {
457 (self)
458 }
459 }
460}
461
462impl<Tz: TimeZone, S: ContextTrait> Input<S> for DateTime<Tz>
467where
468 for<'de> DateTime<Tz>: Deserialize<'de>,
469{
470 fn render_input(
471 value: Option<&Self>,
472 name: &str,
473 _title: &str,
474 required: bool,
475 ctx: &FormRenderContext<'_, S>,
476 _i18n: &FluentLanguageLoader,
477 ) -> Markup {
478 let input_id = Uuid::new_v4();
479 let hidden_id = Uuid::new_v4();
480 html! {
481 input type="datetime-local" id=(input_id) class="cms-datetime-input" required[required] {}
482 input type="hidden" name=(name) id=(hidden_id) value=[value.map(|v|v.to_rfc3339())] {}
483 script type="module" {(PreEscaped(format!(r#"
484const input = document.getElementById("{input_id}");
485const hidden = document.getElementById("{hidden_id}");
486const d = new Date(hidden.value);
487input.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')}}`;
488document.getElementById("{}").addEventListener("submit", () => {{
489 hidden.value = new Date(input.value).toISOString();
490}});
491 "#, ctx.form_id).trim()))}
492 noscript {
493 "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."
494 }
495 }
496 }
497}
498impl<Tz: TimeZone> Column for DateTime<Tz>
499where
500 Tz::Offset: std::fmt::Display,
501{
502 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
503 html! {
504 time datetime=(self.to_rfc3339()) {
505 (self.to_string())
506 }
507 }
508 }
509}
510
511impl<S: ContextTrait> Input<S> for NaiveDate {
516 fn render_input(
517 value: Option<&Self>,
518 name: &str,
519 _title: &str,
520 required: bool,
521 _ctx: &FormRenderContext<'_, S>,
522 _i18n: &FluentLanguageLoader,
523 ) -> Markup {
524 html! {
525 input type="date" name=(name) value=[value] class="cms-date-input" required[required] {}
526 }
527 }
528}
529impl Column for NaiveDate {
530 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
531 html! {
532 time datetime=(self) {
533 (self)
534 }
535 }
536 }
537}
538
539impl<S: ContextTrait> Input<S> for bool {
544 fn render_input(
545 value: Option<&Self>,
546 name: &str,
547 _title: &str,
548 _required: bool,
549 _ctx: &FormRenderContext<'_, S>,
550 _i18n: &FluentLanguageLoader,
551 ) -> Markup {
552 html! {
553 input type="checkbox" name=(name) value="true" checked[*value.unwrap_or(&false)] {}
554 }
555 }
556}
557impl Column for bool {
558 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
559 html! {
560 input type="checkbox" disabled checked[*self] {}
561 }
562 }
563}
564
565impl<T: Input<S>, S: ContextTrait> Input<S> for Vec<T> {
570 fn render_input(
571 value: Option<&Self>,
572 name: &str,
573 title: &str,
574 required: bool,
575 ctx: &FormRenderContext<'_, S>,
576 i18n: &FluentLanguageLoader,
577 ) -> Markup {
578 let add_btn_id = Uuid::new_v4();
579 let list_id = Uuid::new_v4();
580 let template_id = Uuid::new_v4();
581 let onmount = format!(
582 r#"
583const Sortable = (await import("/node_modules/sortablejs/modular/sortable.esm.js")).default;
584
585const addBtn = document.getElementById("{add_btn_id}");
586const list = document.getElementById("{list_id}");
587const template = document.getElementById("{template_id}");
588const name = list.getAttribute("data-name");
589const re = new RegExp(`^${{RegExp.escape(name)}}\[[0-9]*\]`)
590
591const setIndex = (el, i) => {{
592 for (const e of el.querySelectorAll("[data-name]")) {{
593 e.setAttribute("data-name", e.getAttribute("data-name").replace(re, `${{name}}[${{i}}]`));
594 }}
595 for (const e of el.querySelectorAll("[name]")) {{
596 e.name = e.name.replace(re, `${{name}}[${{i}}]`);
597 }}
598 for (const e of el.querySelectorAll("[id]")) {{
599 e.id = e.id.replace(re, `${{name}}[${{i}}]`);
600 }}
601 for (const e of el.querySelectorAll("[for]")) {{
602 e.attributes.for.value = e.attributes.for.value.replace(re, `${{name}}[${{i}}]`);
603 }}
604}}
605const recalculateIndices = () => {{
606 console.log("recalculateInd");
607 for (const [i, el] of list.querySelectorAll(":scope > .cms-list-element-wrapper").entries()) {{
608 setIndex(el, i);
609 }}
610}};
611for (const btn of list.querySelectorAll(":scope > .cms-list-element-wrapper > .cms-list-remove-button")) {{
612 btn.addEventListener("click", function(e) {{
613 e.preventDefault();
614 this.parentNode.remove();
615 recalculateIndices();
616 }});
617}}
618
619template.remove();
620template.removeAttribute("style");
621addBtn.addEventListener("click", (e) => {{
622 e.preventDefault();
623 let el = template.cloneNode(true);
624 el.removeAttribute("id");
625 setIndex(el, list.childElementCount - 1)
626 list.insertBefore(el, addBtn);
627 callOnMountRecursive(el);
628}});
629// TODO: check if this works with nested lists & onmount
630Sortable.create(list, {{ delay: 500, delayOnTouchOnly: true, onEnd: recalculateIndices }});
631"#
632 );
633 html! {
634 div class="cms-list-input" id=(list_id) data-name=(name) onmount=(onmount) {
635 @if let Some(v) = value {
637 @for (i, v) in v.iter().enumerate() {
638 div class="cms-list-element-wrapper" {
639 fieldset class="cms-list-element" {
640 (Input::render_input(Some(v), &format!("{name}[{i}]"), title, required, ctx, i18n))
641 }
642 button class="cms-list-remove-button" {"X"}
643 }
644 }
645 }
646 div id=(template_id) class="cms-list-element-wrapper" style="display: none" onmount="return true" {
648 fieldset class="cms-list-element" {
649 (Input::render_input(Option::<&T>::None, &format!("{name}[]"), title, required, ctx, i18n))
650 }
651 button class="cms-list-remove-button" {"X"}
652 }
653 button id=(add_btn_id) {"+"}
655 }
656 }
657 }
658}
659
660impl<T: Input<S>, S: ContextTrait> Input<S> for Option<T> {
665 fn render_input(
666 value: Option<&Self>,
667 name: &str,
668 title: &str,
669 _required: bool,
670 ctx: &FormRenderContext<'_, S>,
671 i18n: &FluentLanguageLoader,
672 ) -> Markup {
673 let value = match value {
674 Some(v) => v.as_ref(),
675 None => None,
676 };
677 T::render_input(value, name, title, false, ctx, i18n)
678 }
679}
680
681impl<T: Column> Column for Option<T> {
682 fn render(&self, i18n: &FluentLanguageLoader) -> Markup {
683 match self {
684 Some(v) => v.render(i18n),
685 None => html!(),
686 }
687 }
688}
689
690#[cfg(feature = "json")]
695pub use json::Json;
696
697#[cfg(feature = "json")]
698mod json {
699 use super::*;
700
701 #[derive(
702 Copy,
703 Clone,
704 Debug,
705 Deref,
706 DerefMut,
707 PartialEq,
708 Eq,
709 PartialOrd,
710 Ord,
711 Hash,
712 Default,
713 Serialize,
714 Deserialize,
715 )]
716 #[serde(transparent)]
717 pub struct Json<T: ?Sized>(pub T);
718
719 impl<T: TS + ?Sized> TS for Json<T> {
720 type WithoutGenerics = T::WithoutGenerics;
721
722 fn decl() -> String {
723 T::decl()
724 }
725
726 fn decl_concrete() -> String {
727 T::decl_concrete()
728 }
729
730 fn name() -> String {
731 T::name()
732 }
733
734 fn inline() -> String {
735 T::inline()
736 }
737
738 fn inline_flattened() -> String {
739 T::inline_flattened()
740 }
741
742 fn visit_dependencies(visitor: &mut impl ts_rs::TypeVisitor)
743 where
744 Self: 'static,
745 {
746 T::visit_dependencies(visitor)
747 }
748
749 fn visit_generics(visitor: &mut impl ts_rs::TypeVisitor)
750 where
751 Self: 'static,
752 {
753 T::visit_generics(visitor)
754 }
755
756 fn output_path() -> Option<&'static Path> {
757 T::output_path()
758 }
759 }
760
761 impl<'r, T> sqlx::Decode<'r, DB> for Json<T>
762 where
763 sqlx::types::Json<T>: sqlx::Decode<'r, DB>,
764 {
765 fn decode(
766 value: <DB as sqlx::Database>::ValueRef<'r>,
767 ) -> Result<Self, sqlx::error::BoxDynError> {
768 Ok(Self(
769 <sqlx::types::Json<T> as sqlx::Decode<DB>>::decode(value)?.0,
770 ))
771 }
772 }
773
774 impl<T> sqlx::Type<DB> for Json<T>
775 where
776 sqlx::types::Json<T>: sqlx::Type<DB>,
777 {
778 fn type_info() -> <DB as sqlx::Database>::TypeInfo {
779 <sqlx::types::Json<T> as sqlx::Type<DB>>::type_info()
780 }
781 }
782
783 impl<'q, T> sqlx::Encode<'q, DB> for Json<T>
784 where
785 for<'a> sqlx::types::Json<&'a T>: sqlx::Encode<'q, DB>,
786 {
787 fn encode_by_ref(
788 &self,
789 buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'q>,
790 ) -> Result<sqlx_core::encode::IsNull, BoxDynError> {
791 <sqlx::types::Json<&T> as sqlx::Encode<'q, DB>>::encode(sqlx::types::Json(&self.0), buf)
792 }
793 }
794
795 #[cfg(feature = "json")]
796 impl<T: Input<S>, S: ContextTrait> Input<S> for Json<T> {
797 fn render_input(
798 value: Option<&Self>,
799 name: &str,
800 title: &str,
801 required: bool,
802 ctx: &FormRenderContext<'_, S>,
803 i18n: &FluentLanguageLoader,
804 ) -> Markup {
805 T::render_input(value.map(|v| &v.0), name, title, required, ctx, i18n)
806 }
807 }
808 #[cfg(feature = "json")]
809 impl<T: Column> Column for Json<T> {
810 fn render(&self, i18n: &FluentLanguageLoader) -> Markup {
811 self.0.render(i18n)
812 }
813 }
814}
815
816impl Column for Uuid {
821 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
822 html!((self))
823 }
824}
825
826#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, TS)]
831pub struct File {
832 pub(crate) id: Uuid,
834 pub(crate) name: String,
836}
837
838impl File {
839 pub fn new(file_name: String) -> Self {
840 Self::new_with_id(Uuid::new_v4(), file_name)
841 }
842 pub fn new_with_id(id: Uuid, name: String) -> Self {
843 Self { id, name }
844 }
845 pub fn url(&self) -> String {
846 format!("/uploads/{}/{}", self.id, self.name)
847 }
848
849 pub fn path(&self, uploads_dir: &Path) -> PathBuf {
850 uploads_dir.join(self.id.to_string()).join(&self.name)
851 }
852}
853
854impl<'de> Deserialize<'de> for File {
855 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
856 where
857 D: serde::Deserializer<'de>,
858 {
859 #[derive(Debug, Deserialize)]
860 struct File {
861 id: Option<Uuid>,
862 id_old: Option<Uuid>,
863 name: Option<String>,
864 name_old: Option<String>,
865 }
866 let f = File::deserialize(deserializer)?;
867 let id =
868 f.id.or(f.id_old)
869 .map(Into::into)
870 .ok_or(serde::de::Error::missing_field("id"))?;
871 let name = f
872 .name
873 .or(f.name_old)
874 .map(Into::into)
875 .ok_or(serde::de::Error::missing_field("name"))?;
876 Ok(Self { id, name })
878 }
879}
880
881impl<S: ContextTrait> Input<S> for File {
882 fn render_input(
883 value: Option<&Self>,
884 name: &str,
885 _title: &str,
886 required: bool,
887 _ctx: &FormRenderContext<'_, S>,
888 _i18n: &FluentLanguageLoader,
889 ) -> Markup {
890 html! {
891 @if let Some(v) = value {
892 input type="hidden" name=(format!("{name}[id_old]")) value=(v.id) {}
893 input type="hidden" name=(format!("{name}[name_old]")) value=(v.name) {}
894 }
895 input type="file" name=(name) required[required && value.is_none()] {}
896 }
897 }
898}
899
900impl Column for File {
901 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
902 html! {
903 a href=(self.url()) {
904 (self.name)
905 }
906 }
907 }
908}
909
910#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize, TS)]
915pub struct Image {
916 #[serde(flatten)]
917 pub file: File,
918 pub alt_text: Option<String>,
919}
920
921impl<S: ContextTrait> Input<S> for Image {
922 fn render_input(
923 value: Option<&Self>,
924 name: &str,
925 _title: &str,
926 required: bool,
927 _ctx: &FormRenderContext<'_, S>,
928 i18n: &FluentLanguageLoader,
929 ) -> Markup {
930 html! {
931 fieldset class="cms-image cms-prop-group" {
932 @if let Some(v) = value {
933 input type="hidden" name=(format!("{name}[id_old]")) value=(v.file.id) {}
934 input type="hidden" name=(format!("{name}[name_old]")) value=(v.file.name) {}
935 }
936 input type="file" accept="image/*" name=(name) required[required && value.is_none()] {}
937 input
938 type="text"
939 name=(format!("{name}[alt_text]"))
940 placeholder=(fl!(i18n, "image-alt-text"))
941 class="cms-text-input cms-prop-container"
942 value=[value.map(|v| v.alt_text.as_deref().unwrap_or_default())] {}
943 }
944 }
945 }
946}
947
948impl Column for Image {
949 fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
950 html! {
951 a href=(self.file.url()) {
952 (self.file.name)
953 }
954 @if let Some(alt_text) = &self.alt_text {
955 " (" (alt_text) ")"
956 }
957 }
958 }
959}