authbeam/
layout.rs

1use langbeam::LangFile;
2use pathbufd::PathBufD;
3use serde::{Serialize, Deserialize};
4use std::{
5    collections::HashMap,
6    sync::{LazyLock, RwLock},
7};
8
9use crate::model::{Profile, RelationshipStatus};
10use reva_axum::Template;
11use rainbeam_shared::config::Config;
12
13pub static LAYOUTS: LazyLock<RwLock<HashMap<String, String>>> =
14    LazyLock::new(|| RwLock::new(HashMap::new()));
15
16#[derive(Template)]
17#[template(path = "profile/layout_components/renderer.html")]
18pub struct RendererTemplate<'a> {
19    pub other: &'a Profile,
20    pub component: &'a LayoutComponent,
21    // profile
22    pub config: &'a Config,
23    pub profile: &'a Option<Box<Profile>>,
24    pub lang: &'a LangFile,
25    pub response_count: usize,
26    pub questions_count: usize,
27    pub followers_count: usize,
28    pub following_count: usize,
29    pub friends_count: usize,
30    pub is_following: bool,
31    pub is_following_you: bool,
32    pub relationship: RelationshipStatus,
33    pub lock_profile: bool,
34    pub disallow_anonymous: bool,
35    pub require_account: bool,
36    pub hide_social: bool,
37    pub is_powerful: bool, // at least "manager"
38    pub is_helper: bool,   // at least "helper"
39    pub is_self: bool,
40}
41
42/// Renderer which does not require a bunch of junk to render.
43///
44/// Does not render profile-specific components properly. They will be replaced with
45/// their name.
46#[derive(Template)]
47#[template(path = "profile/layout_components/free_renderer.html")]
48pub struct FreeRendererTemplate<'a> {
49    pub other: &'a Profile,
50    pub component: &'a LayoutComponent,
51}
52
53#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
54pub enum ComponentName {
55    /// An empty element.
56    #[serde(alias = "empty")]
57    Empty,
58    /// A flex container.
59    #[serde(alias = "flex")]
60    Flex,
61    /// The profile's banner.
62    #[serde(alias = "banner")]
63    Banner,
64    /// A markdown block.
65    #[serde(alias = "markdown")]
66    Markdown,
67    /// The profile's feed (responses, questions, etc.).
68    #[serde(alias = "feed")]
69    Feed,
70    /// The profile's tabs (social, feed/questions/mod, etc.).
71    #[serde(alias = "tabs")]
72    Tabs,
73    /// The profile's ask box.
74    #[serde(alias = "ask")]
75    Ask,
76    /// The profile's name and avatar.
77    #[serde(alias = "name")]
78    Name,
79    /// The profile's about section (about and biography).
80    #[serde(alias = "about")]
81    About,
82    /// The profile's action buttons.
83    #[serde(alias = "actions")]
84    Actions,
85    /// A `<hr>` element.
86    #[serde(alias = "divider")]
87    Divider,
88    /// A `<style>` element.
89    #[serde(alias = "style")]
90    Style,
91    /// The site footer.
92    #[serde(alias = "footer")]
93    Footer,
94}
95
96impl Default for ComponentName {
97    fn default() -> Self {
98        Self::Empty
99    }
100}
101
102/// A component of the layout. Essentially just a limited description of an HTML element.
103#[derive(Serialize, Deserialize, Clone, Debug)]
104pub struct LayoutComponent {
105    #[serde(default)]
106    pub json: String,
107    #[serde(default)]
108    pub component: ComponentName,
109    #[serde(default)]
110    pub options: HashMap<String, String>,
111    #[serde(default)]
112    pub children: Vec<LayoutComponent>,
113}
114
115impl Default for LayoutComponent {
116    fn default() -> Self {
117        Self {
118            json: String::new(),
119            component: ComponentName::Empty,
120            options: HashMap::new(),
121            children: Vec::new(),
122        }
123    }
124}
125
126impl LayoutComponent {
127    /// Create a [`LayoutComponent`] from the name of a file in `./.config/layouts`.
128    pub fn from_json_file(file: &str) -> Self {
129        Self {
130            json: file.to_string(),
131            component: ComponentName::Empty,
132            options: HashMap::new(),
133            children: Vec::new(),
134        }
135        .fill()
136    }
137
138    /// Follow component template to get full template.
139    ///
140    /// All imports are relative to `./.config/layouts`.
141    pub fn fill(&self) -> LayoutComponent {
142        if !self.json.is_empty() {
143            let reader = match LAYOUTS.read() {
144                Ok(r) => r,
145                Err(_) => {
146                    LAYOUTS.clear_poison();
147                    return LayoutComponent::default();
148                }
149            };
150
151            return {
152                if let Some(l) = (*reader).get(&self.json) {
153                    serde_json::from_str::<LayoutComponent>(l)
154                } else {
155                    let l = match std::fs::read_to_string(PathBufD::current().extend(&[
156                        ".config",
157                        "layouts",
158                        self.json.as_str(),
159                    ])) {
160                        Ok(l) => l,
161                        Err(_) => return LayoutComponent::default(),
162                    };
163
164                    drop(reader); // drop the reader so we can create a writer
165                    let mut writer = LAYOUTS.write().unwrap();
166                    (*writer).insert(self.json.clone(), l.clone());
167
168                    serde_json::from_str::<LayoutComponent>(&l)
169                }
170                .unwrap()
171            };
172        }
173
174        self.to_owned()
175    }
176
177    /// Get the value of an option in the `options` map. Accepts a default substitute.
178    pub fn option(&self, k: &str, d: Option<String>) -> String {
179        match self.options.get(k) {
180            Some(v) => v.to_owned(),
181            None => {
182                if let Some(d) = d {
183                    d
184                } else {
185                    String::new()
186                }
187            }
188        }
189    }
190
191    /// Render the component as HTML. Skips rendering with extra junk.
192    ///
193    /// See [`FreeRendererTemplate`].
194    /// See [`LayoutComponent::render_with_junk`] to include junk.
195    pub fn render(&self, user: &Profile) -> String {
196        use ComponentName as T;
197
198        // json import
199        if !self.json.is_empty() {
200            return self.fill().render(user);
201        }
202
203        // regular
204        match self.component {
205            T::Flex => format!(
206                "<div class=\"flex {} {} {} {} {}\" style=\"{}\" id=\"{}\">{}</div>",
207                // extra classes
208                {
209                    let direction = self.option("direction", None);
210                    if !direction.is_empty() {
211                        format!("flex-{direction}")
212                    } else {
213                        String::new()
214                    }
215                },
216                {
217                    let gap = self.option("gap", None);
218                    if !gap.is_empty() {
219                        format!("gap-{gap}")
220                    } else {
221                        String::new()
222                    }
223                },
224                {
225                    let collapse = self.option("collapse", None);
226                    if !collapse.is_empty() {
227                        "flex-collapse"
228                    } else {
229                        ""
230                    }
231                },
232                {
233                    let width = self.option("width", None);
234                    if !width.is_empty() {
235                        format!("w-{width}")
236                    } else {
237                        String::new()
238                    }
239                },
240                self.option("class", None),
241                self.option("style", None),
242                self.option("id", None),
243                // children
244                {
245                    let mut children: String = String::new();
246
247                    for child in &self.children {
248                        children.push_str(&child.render(user));
249                    }
250
251                    children
252                }
253            ),
254            T::Divider => format!("<hr class=\"{}\" />", self.option("class", None)),
255            T::Markdown => format!(
256                "<div class=\"{}\">{}</div>",
257                self.option("class", None),
258                rainbeam_shared::ui::render_markdown(&self.option("text", None))
259            ),
260            T::Style => format!(
261                "<style>{}</style>",
262                self.option("data", None).replace("</", "")
263            ),
264            T::Empty => String::new(),
265            _ => format!("ComponentName::{:?}", self.component),
266        }
267    }
268
269    /// Render the component as HTML. Since this is a profile layout, we require
270    /// a reference to the [`Profile`] this layout is being rendered for.
271    pub fn render_with_junk(
272        &self,
273        user: &Profile,
274        // this is absurd
275        config: &Config,
276        profile: &Option<Box<Profile>>,
277        lang: &LangFile,
278        response_count: usize,
279        questions_count: usize,
280        followers_count: usize,
281        following_count: usize,
282        friends_count: usize,
283        is_following: bool,
284        is_following_you: bool,
285        relationship: RelationshipStatus,
286        lock_profile: bool,
287        disallow_anonymous: bool,
288        require_account: bool,
289        hide_social: bool,
290        is_powerful: bool,
291        is_helper: bool,
292        is_self: bool,
293    ) -> String {
294        use ComponentName as T;
295
296        // json import
297        if !self.json.is_empty() {
298            return self.fill().render_with_junk(
299                user,
300                config,
301                profile,
302                lang,
303                response_count,
304                questions_count,
305                followers_count,
306                following_count,
307                friends_count,
308                is_following,
309                is_following_you,
310                relationship,
311                lock_profile,
312                disallow_anonymous,
313                require_account,
314                hide_social,
315                is_powerful,
316                is_helper,
317                is_self,
318            );
319        }
320
321        // regular
322        match self.component {
323            T::Flex => format!(
324                "<div class=\"flex {} {} {} {} {}\" style=\"{}\" id=\"{}\">{}</div>",
325                // extra classes
326                {
327                    let direction = self.option("direction", None);
328                    if !direction.is_empty() {
329                        format!("flex-{direction}")
330                    } else {
331                        String::new()
332                    }
333                },
334                {
335                    let gap = self.option("gap", None);
336                    if !gap.is_empty() {
337                        format!("gap-{gap}")
338                    } else {
339                        String::new()
340                    }
341                },
342                {
343                    let collapse = self.option("collapse", None);
344                    if !collapse.is_empty() {
345                        "flex-collapse"
346                    } else {
347                        ""
348                    }
349                },
350                {
351                    let width = self.option("width", None);
352                    if !width.is_empty() {
353                        format!("w-{width}")
354                    } else {
355                        String::new()
356                    }
357                },
358                self.option("class", None),
359                self.option("style", None),
360                self.option("id", None),
361                // children
362                {
363                    let mut children: String = String::new();
364
365                    for child in &self.children {
366                        children.push_str(
367                            &RendererTemplate {
368                                other: user,
369                                component: child,
370                                // profile
371                                config,
372                                profile,
373                                lang,
374                                response_count,
375                                questions_count,
376                                followers_count,
377                                following_count,
378                                friends_count,
379                                is_following,
380                                is_following_you,
381                                relationship: relationship.clone(),
382                                lock_profile,
383                                disallow_anonymous,
384                                require_account,
385                                hide_social,
386                                is_powerful,
387                                is_helper,
388                                is_self,
389                            }
390                            .render()
391                            .unwrap(),
392                        );
393                    }
394
395                    children
396                }
397            ),
398            T::Divider => format!("<hr class=\"{}\" />", {
399                let class = self.option("class", None);
400                if !class.is_empty() {
401                    class
402                } else {
403                    String::new()
404                }
405            }),
406            T::Markdown => format!(
407                "<div class=\"{}\">{}</div>",
408                self.option("class", None),
409                rainbeam_shared::ui::render_markdown(&self.option("text", None))
410            ),
411            T::Style => format!(
412                "<style>{}</style>",
413                self.option("data", None).replace("</", "")
414            ),
415            T::Empty => String::new(),
416            _ => format!("ComponentName::{:?}", self.component),
417        }
418    }
419
420    /// Render the component to block format. This format doesn't show the fully
421    /// rendered form of the layout, but instead just blocks which represent the
422    /// component.
423    ///
424    /// This rendering is used in the editor because it saves so many server resources.
425    /// The normal rendering eats memory, as it recursively renders the same HTML template.
426    ///
427    /// The only component rendered halfway normally as a block is [`ComponentName::Flex`] components.
428    pub fn render_block(&self) -> String {
429        use ComponentName as T;
430
431        match self.component {
432            T::Flex => format!(
433                "<div data-component-name=\"{:?}\" class=\"layout_editor_block flex {} {} {} {} {}\">{}</div>",
434                self.component,
435                // extra classes
436                {
437                    let direction = self.option("direction", None);
438                    if !direction.is_empty() {
439                        format!("flex-{direction}")
440                    } else {
441                        String::new()
442                    }
443                },
444                {
445                    let gap = self.option("gap", None);
446                    if !gap.is_empty() {
447                        format!("gap-{gap}")
448                    } else {
449                        String::new()
450                    }
451                },
452                {
453                    let collapse = self.option("collapse", None);
454                    if !collapse.is_empty() {
455                        "flex-collapse"
456                    } else {
457                        ""
458                    }
459                },
460                {
461                    let width = self.option("width", None);
462                    if !width.is_empty() {
463                        format!("w-{width}")
464                    } else {
465                        String::new()
466                    }
467                },
468                {
469                    let mobile = self.option("mobile", None);
470                    if !mobile.is_empty() {
471                        format!("sm:{mobile}")
472                    } else {
473                        String::new()
474                    }
475                },
476                {
477                    let mut children: String = String::new();
478
479                    for child in &self.children {
480                        children.push_str(&child.render_block());
481                    }
482
483                    children
484                }
485            ),
486            _ => format!(
487                "<div class=\"layout_editor_block {}\" data-component-name=\"{:?}\">{:?} ({}b)</div>",
488                if self.component == T::Markdown {
489                    "w-full"
490                } else {
491                    ""
492                },
493                self.component, self.component, {
494                    let mut size: usize = 0;
495
496                    for option in &self.options {
497                        size += option.0.len() + option.1.len()
498                    }
499
500                    size
501                }
502            ),
503        }
504    }
505
506    /// Render the component to tree format (using HTML `<details>`).
507    pub fn render_tree(&self) -> String {
508        // rustfmt has left as, as usual
509        let tag = if self.children.len() == 0 {
510            "div"
511        } else {
512            "details"
513        };
514
515        format!(
516            "<{tag} class=\"layout_editor_tree_block flex flex-col gap-2 w-full\" data-component-name=\"{:?}\">
517                <summary><b>{:?} ({}b)</b></summary>
518                {}
519            </{tag}>",
520            self.component,
521            self.component,
522            {
523                let mut size: usize = 0;
524
525                for option in &self.options {
526                    size += option.0.len() + option.1.len()
527                }
528
529                size
530            },
531            {
532                let mut children: String = String::new();
533
534                for child in &self.children {
535                    children.push_str(&child.render_tree());
536                }
537
538                children
539            }
540        )
541    }
542}
543
544impl AsRef<LayoutComponent> for LayoutComponent {
545    fn as_ref(&self) -> &LayoutComponent {
546        // the fact that this is a fix is crazy
547        self
548    }
549}