derived_cms/
property.rs

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    /// Used in the HTML form's `name` field
24    pub name: &'a str,
25    /// The title shown in the UI
26    pub title: &'a str,
27    /// the value denoting this enum variant / `type`
28    pub value: &'a str,
29    pub content: Option<InputInfo<'a, S>>,
30}
31
32/********
33 * Text *
34 ********/
35
36#[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/************
128 * Markdown *
129 ************/
130
131#[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
242/**************
243 * signed int *
244 **************/
245
246impl<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
352/****************
353 * unsigned int *
354 ****************/
355
356impl<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
462/************
463 * DateTime *
464 ************/
465
466impl<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
511/*************
512 * NaiveDate *
513 *************/
514
515impl<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
539/********
540 * bool *
541 ********/
542
543impl<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
565/**********
566 * Vec<T> *
567 **********/
568
569impl<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                // content
636                @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                // template
647                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                // add button
654                button id=(add_btn_id) {"+"}
655            }
656        }
657    }
658}
659
660/**********
661 * Option *
662 **********/
663
664impl<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/********
691 * Json *
692 ********/
693
694#[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
816/********
817 * Uuid *
818 ********/
819
820impl Column for Uuid {
821    fn render(&self, _i18n: &FluentLanguageLoader) -> Markup {
822        html!((self))
823    }
824}
825
826/********
827 * File *
828 ********/
829
830#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, TS)]
831pub struct File {
832    /// name of the file created in `files_dir`
833    pub(crate) id: Uuid,
834    /// original filename
835    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        // TODO: check if file exists
877        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/*********
911 * Image *
912 *********/
913
914#[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}