|
| 1 | +// The structure of the `plugins` directory is to have a subdirectory for each locale, and then a list of plugins inside |
| 2 | +// Note that the same plugins must be defined for every locale |
| 3 | + |
| 4 | +use crate::components::container::{Container, ContainerProps}; |
| 5 | +use crate::components::trusted_svg::TRUSTED_SVG; |
| 6 | +use perseus::{t, RenderFnResultWithCause, Template}; |
| 7 | +use serde::{Deserialize, Serialize}; |
| 8 | +use std::fs; |
| 9 | +use sycamore::prelude::Template as SycamoreTemplate; |
| 10 | +use sycamore::prelude::*; |
| 11 | +use walkdir::WalkDir; |
| 12 | +use wasm_bindgen::JsCast; |
| 13 | +use web_sys::HtmlInputElement; |
| 14 | + |
| 15 | +#[derive(Serialize, Deserialize)] |
| 16 | +struct PluginsPageProps { |
| 17 | + /// The list of plugins with minimal details. These will be displayed in cards on the |
| 18 | + /// index page. |
| 19 | + plugins: Vec<PluginDetails>, |
| 20 | +} |
| 21 | +/// The minimal amount of details for a plugin, which will be displayed in a card on the |
| 22 | +/// root page. This is a subset of `PluginDetails` (except for the `slug`). This needs to be `Eq` for Sycamore's keyed list diffing algorithm. |
| 23 | +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] |
| 24 | +struct PluginDetails { |
| 25 | + /// The plugins's name. |
| 26 | + name: String, |
| 27 | + /// A short description of the plugin. |
| 28 | + description: String, |
| 29 | + /// The author of the plugin. |
| 30 | + author: String, |
| 31 | + /// Whether or not the plugin is trusted by the Perseus development team. Note that this is just a superficial measure, and it does not indicate |
| 32 | + /// security, audit status, or anything else of the like. It should NOT be relied on when deciding whether or not a plugin is secure! |
| 33 | + trusted: bool, |
| 34 | + /// The plugin's home URL, which the plugins registry will redirect the user to. This avoids developers having to update documentation in many places |
| 35 | + /// or ask for the website to be rebuilt every time their READMEs change. |
| 36 | + url: String, |
| 37 | +} |
| 38 | + |
| 39 | +#[perseus::template(PluginsPage)] |
| 40 | +#[component(PluginsPage<G>)] |
| 41 | +fn plugins_page(props: PluginsPageProps) -> SycamoreTemplate<G> { |
| 42 | + let plugins = Signal::new(props.plugins); |
| 43 | + // This will store the plugins relevant to the user's search (all of them by |
| 44 | + // This stores the search that the user provides |
| 45 | + let filter = Signal::new(String::new()); |
| 46 | + // A derived state that will filter the plugins that the user searches for |
| 47 | + let filtered_plugins = create_memo(cloned!((plugins, filter) => move || { |
| 48 | + plugins.get().iter().filter(|plugin| { |
| 49 | + let filter_text = &*filter.get().to_lowercase(); |
| 50 | + plugin.name.to_lowercase().contains(filter_text) || |
| 51 | + plugin.author.to_lowercase().contains(filter_text) || |
| 52 | + plugin.description.to_lowercase().contains(filter_text) |
| 53 | + }).cloned().collect::<Vec<PluginDetails>>() |
| 54 | + })); |
| 55 | + // This renders a single plugin card |
| 56 | + let plugin_renderer = |plugin: PluginDetails| { |
| 57 | + let PluginDetails { |
| 58 | + name, |
| 59 | + description, |
| 60 | + author, |
| 61 | + trusted, |
| 62 | + url, |
| 63 | + } = plugin; |
| 64 | + template! { |
| 65 | + li(class = "inline-block align-top m-2") { |
| 66 | + a( |
| 67 | + class = "block text-left cursor-pointer rounded-xl shadow-md hover:shadow-2xl transition-shadow duration-100 p-8 max-w-sm dark:text-white", |
| 68 | + href = &url // This is an external link to the plugin's homepage |
| 69 | + ) { |
| 70 | + p(class = "text-xl xs:text-2xl inline-flex") { |
| 71 | + (name) |
| 72 | + (if trusted { |
| 73 | + template! { |
| 74 | + div(class = "ml-1 self-center", dangerously_set_inner_html = TRUSTED_SVG) |
| 75 | + } |
| 76 | + } else { |
| 77 | + SycamoreTemplate::empty() |
| 78 | + }) |
| 79 | + } |
| 80 | + p(class = "text-sm text-gray-500 dark:text-gray-300 mb-1") { (t!("plugin-card-author", { "author": author.clone() })) } |
| 81 | + p { (description) } |
| 82 | + } |
| 83 | + } |
| 84 | + } |
| 85 | + }; |
| 86 | + |
| 87 | + template! { |
| 88 | + Container(ContainerProps { |
| 89 | + title: t!("perseus"), |
| 90 | + children: template! { |
| 91 | + div(class = "mt-14 xs:mt-16 sm:mt-20 lg:mt-25 dark:text-white") { |
| 92 | + div(class = "w-full flex flex-col justify-center text-center") { |
| 93 | + h1(class = "text-5xl xs:text-7xl sm:text-8xl font-extrabold mb-5") { (t!("plugins-title")) } |
| 94 | + br() |
| 95 | + p(class = "mx-1") { (t!("plugins-desc")) } |
| 96 | + // TODO Remove `hidden` class once next Sycamore version is released and search bar works again |
| 97 | + input(class = "mx-2 sm:mx-4 md:mx-8 p-3 rounded-lg border-2 border-indigo-600 mb-3 dark:bg-navy hidden", on:input = cloned!((filter) => move |ev| { |
| 98 | + // This longwinded code gets the actual value that the user typed in |
| 99 | + let target: HtmlInputElement = ev.target().unwrap().unchecked_into(); |
| 100 | + let new_input = target.value(); |
| 101 | + filter.set(new_input); |
| 102 | + }), placeholder = t!("plugin-search.placeholder")) |
| 103 | + } |
| 104 | + div(class = "w-full flex justify-center") { |
| 105 | + ul(class = "text-center w-full max-w-7xl mx-2 mb-16") { |
| 106 | + Indexed(IndexedProps { |
| 107 | + iterable: filtered_plugins, |
| 108 | + template: plugin_renderer |
| 109 | + }) |
| 110 | + } |
| 111 | + } |
| 112 | + } |
| 113 | + } |
| 114 | + }) |
| 115 | + } |
| 116 | +} |
| 117 | + |
| 118 | +#[perseus::head] |
| 119 | +fn head() -> SycamoreTemplate<SsrNode> { |
| 120 | + template! { |
| 121 | + title { (format!("{} | {}", t!("plugins-title"), t!("perseus"))) } |
| 122 | + link(rel = "stylesheet", href = ".perseus/static/styles/markdown.css") |
| 123 | + } |
| 124 | +} |
| 125 | + |
| 126 | +pub fn get_template<G: GenericNode>() -> Template<G> { |
| 127 | + Template::new("plugins") |
| 128 | + .template(plugins_page) |
| 129 | + .head(head) |
| 130 | + .build_state_fn(get_build_state) |
| 131 | +} |
| 132 | + |
| 133 | +#[perseus::autoserde(build_state)] |
| 134 | +async fn get_build_state( |
| 135 | + _path: String, |
| 136 | + locale: String, |
| 137 | +) -> RenderFnResultWithCause<PluginsPageProps> { |
| 138 | + // This is the root page, so we want a list of plugins and a small amount of information about each |
| 139 | + // This directory loop is relative to `.perseus/` |
| 140 | + let mut plugins = Vec::new(); |
| 141 | + for entry in WalkDir::new(&format!("../plugins/{}", locale)) { |
| 142 | + let entry = entry?; |
| 143 | + let path = entry.path(); |
| 144 | + // Ignore any empty directories or the like |
| 145 | + if path.is_file() { |
| 146 | + // Get the JSON contents and parse them as plugin details |
| 147 | + let contents = fs::read_to_string(&path)?; |
| 148 | + let details = serde_json::from_str::<PluginDetails>(&contents)?; |
| 149 | + |
| 150 | + plugins.push(details); |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + Ok(PluginsPageProps { plugins }) |
| 155 | +} |
0 commit comments