├── src ├── ui.rs ├── ui │ ├── components │ │ ├── home.rs │ │ ├── layouts.rs │ │ ├── login.rs │ │ ├── comment.rs │ │ ├── modals.rs │ │ ├── post.rs │ │ ├── communities.rs │ │ ├── common.rs │ │ ├── communities │ │ │ ├── communities_page.rs │ │ │ ├── create_community_page.rs │ │ │ └── community_form.rs │ │ ├── common │ │ │ ├── markdown_content.rs │ │ │ ├── sidebar │ │ │ │ ├── user_stat_row.rs │ │ │ │ ├── sidebar_data.rs │ │ │ │ └── team_member_card.rs │ │ │ ├── fedilink.rs │ │ │ ├── filter_bar │ │ │ │ ├── sort_type_link.rs │ │ │ │ └── listing_type_link.rs │ │ │ ├── creator_listing.rs │ │ │ ├── community_listing.rs │ │ │ ├── content_actions │ │ │ │ ├── hide_post_button.rs │ │ │ │ └── report_button.rs │ │ │ ├── vote_buttons │ │ │ │ └── vote_button.rs │ │ │ ├── icon.rs │ │ │ ├── filter_bar.rs │ │ │ ├── vote_buttons.rs │ │ │ ├── text_input.rs │ │ │ ├── content_actions.rs │ │ │ └── sidebar.rs │ │ ├── login │ │ │ ├── login_page.rs │ │ │ └── login_form.rs │ │ ├── comment │ │ │ ├── comment_node.rs │ │ │ └── comment_nodes.rs │ │ ├── post │ │ │ ├── post_listings.rs │ │ │ ├── post_page.rs │ │ │ ├── post_listing │ │ │ │ └── thumbnail.rs │ │ │ └── post_listing.rs │ │ ├── layouts │ │ │ ├── base_layout │ │ │ │ ├── top_nav │ │ │ │ │ ├── notification_bell.rs │ │ │ │ │ ├── theme_select.rs │ │ │ │ │ └── auth_dropdown.rs │ │ │ │ ├── mobile_nav.rs │ │ │ │ ├── top_nav.rs │ │ │ │ └── side_nav.rs │ │ │ └── base_layout.rs │ │ ├── home │ │ │ └── home_page.rs │ │ └── modals │ │ │ └── report_modal.rs │ └── components.rs ├── contexts.rs ├── serverfns │ ├── users.rs │ ├── auth.rs │ ├── theme.rs │ ├── comments.rs │ ├── communities.rs │ ├── posts.rs │ ├── theme │ │ ├── get_theme.rs │ │ └── set_theme.rs │ ├── get_site.rs │ ├── posts │ │ ├── list_posts.rs │ │ ├── get_post.rs │ │ ├── report_post.rs │ │ ├── hide_post.rs │ │ ├── save_post.rs │ │ └── vote_post.rs │ ├── auth │ │ ├── logout.rs │ │ └── login.rs │ ├── comments │ │ ├── list_comments.rs │ │ └── report_comment.rs │ ├── communities │ │ ├── list_communities.rs │ │ └── create_community.rs │ └── users │ │ └── block_user.rs ├── utils │ ├── traits.rs │ ├── types.rs │ ├── traits │ │ ├── bool_option_str.rs │ │ └── to_str.rs │ ├── types │ │ ├── dialog_types.rs │ │ ├── server_action.rs │ │ ├── theme.rs │ │ └── content_action_types.rs │ ├── derive_user_is_logged_in.rs │ ├── get_client_and_session.rs │ ├── markdown.rs │ ├── get_time_since.rs │ ├── apub_name.rs │ ├── derive_query_param_type.rs │ └── filetype.rs ├── serverfns.rs ├── constants.rs ├── contexts │ ├── theme_resource_context.rs │ └── site_resource_context.rs ├── cookie_middleware.rs ├── utils.rs ├── host.rs ├── main.rs └── lib.rs ├── .prettierignore ├── end2end ├── playwright-report │ └── .gitkeep ├── tsconfig.json ├── lemmy.hjson ├── package.json ├── tests │ ├── desktop │ │ ├── theme.spec.ts │ │ ├── theme.hydrate.spec.ts │ │ ├── auth.spec.ts │ │ └── navigation.spec.ts │ └── mobile │ │ ├── theme.spec.ts │ │ ├── theme.hydrate.spec.ts │ │ └── navigation.spec.ts ├── docker-compose.yml ├── playwright.config.ts └── pnpm-lock.yaml ├── .github └── CODEOWNERS ├── scripts ├── format.sh └── run_end2end_tests.sh ├── public ├── default-avatar.png ├── favicon.svg └── icons.svg ├── .leptosfmt.toml ├── .rustfmt.toml ├── settings.json ├── renovate.json ├── .gitignore ├── package.json ├── Makefile.toml ├── docker ├── lemmy.hjson ├── nginx.conf └── docker-compose.yml ├── pnpm-lock.yaml ├── Dockerfile ├── CONTRIBUTING.md ├── README.md ├── locales └── en │ └── main.ftl ├── Cargo.toml ├── .woodpecker.yml └── style └── tailwind.css /src/ui.rs: -------------------------------------------------------------------------------- 1 | pub mod components; 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | -------------------------------------------------------------------------------- /end2end/playwright-report/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ui/components/home.rs: -------------------------------------------------------------------------------- 1 | pub mod home_page; 2 | -------------------------------------------------------------------------------- /src/ui/components/layouts.rs: -------------------------------------------------------------------------------- 1 | pub mod base_layout; 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @dessalines @SleeplessOne1917 2 | -------------------------------------------------------------------------------- /src/ui/components/login.rs: -------------------------------------------------------------------------------- 1 | pub mod login_form; 2 | pub mod login_page; 3 | -------------------------------------------------------------------------------- /src/ui/components/comment.rs: -------------------------------------------------------------------------------- 1 | pub mod comment_node; 2 | pub mod comment_nodes; 3 | -------------------------------------------------------------------------------- /src/contexts.rs: -------------------------------------------------------------------------------- 1 | pub mod site_resource_context; 2 | pub mod theme_resource_context; 3 | -------------------------------------------------------------------------------- /src/ui/components/modals.rs: -------------------------------------------------------------------------------- 1 | mod report_modal; 2 | pub use report_modal::ReportModal; 3 | -------------------------------------------------------------------------------- /src/serverfns/users.rs: -------------------------------------------------------------------------------- 1 | mod block_user; 2 | pub use block_user::create_block_user_action; 3 | -------------------------------------------------------------------------------- /end2end/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["node"] 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | leptosfmt -c .leptosfmt.toml src 2 | taplo format 3 | cargo +nightly fmt 4 | pnpm fmt -------------------------------------------------------------------------------- /src/ui/components/post.rs: -------------------------------------------------------------------------------- 1 | pub mod post_listing; 2 | pub mod post_listings; 3 | pub mod post_page; 4 | -------------------------------------------------------------------------------- /public/default-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LemmyNet/lemmy-ui-leptos/main/public/default-avatar.png -------------------------------------------------------------------------------- /src/ui/components/communities.rs: -------------------------------------------------------------------------------- 1 | pub mod communities_page; 2 | pub mod community_form; 3 | pub mod create_community_page; 4 | -------------------------------------------------------------------------------- /src/utils/traits.rs: -------------------------------------------------------------------------------- 1 | mod bool_option_str; 2 | pub use bool_option_str::BoolOptionStr; 3 | 4 | mod to_str; 5 | pub use to_str::ToStr; 6 | -------------------------------------------------------------------------------- /src/serverfns/auth.rs: -------------------------------------------------------------------------------- 1 | mod logout; 2 | pub use logout::create_logout_action; 3 | 4 | mod login; 5 | pub use login::create_login_action; 6 | -------------------------------------------------------------------------------- /src/serverfns/theme.rs: -------------------------------------------------------------------------------- 1 | mod get_theme; 2 | pub use get_theme::get_theme; 3 | 4 | mod set_theme; 5 | pub use set_theme::create_set_theme_action; 6 | -------------------------------------------------------------------------------- /.leptosfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 100 2 | tab_spaces = 2 3 | attr_value_brace_style = "WhenRequired" # "Always", "AlwaysUnlessLit", "WhenRequired" or "Preserve" 4 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | tab_spaces = 2 2 | edition = "2021" 3 | imports_layout = "HorizontalVertical" 4 | imports_granularity = "Crate" 5 | group_imports = "One" 6 | -------------------------------------------------------------------------------- /settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.procMacro.ignored": { 3 | "leptos_macro": ["server"] 4 | }, 5 | "rust-analyzer.cargo.features": ["ssr"] 6 | } 7 | -------------------------------------------------------------------------------- /src/serverfns.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod comments; 3 | pub mod communities; 4 | pub mod get_site; 5 | pub mod posts; 6 | pub mod theme; 7 | pub mod users; 8 | -------------------------------------------------------------------------------- /src/serverfns/comments.rs: -------------------------------------------------------------------------------- 1 | mod list_comments; 2 | pub use list_comments::list_comments; 3 | 4 | mod report_comment; 5 | pub use report_comment::create_report_comment_action; 6 | -------------------------------------------------------------------------------- /src/ui/components.rs: -------------------------------------------------------------------------------- 1 | pub mod comment; 2 | pub mod common; 3 | pub mod communities; 4 | pub mod home; 5 | pub mod layouts; 6 | pub mod login; 7 | pub mod modals; 8 | pub mod post; 9 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "schedule": ["before 4am on the first day of the month"] 5 | } 6 | -------------------------------------------------------------------------------- /src/constants.rs: -------------------------------------------------------------------------------- 1 | pub const INTERNAL_HOST: &str = "localhost:8536"; 2 | pub const HTTPS: bool = false; 3 | pub const AUTH_COOKIE: &str = "jwt"; 4 | pub const DEFAULT_AVATAR_PATH: &str = "/default-avatar.png"; 5 | -------------------------------------------------------------------------------- /src/serverfns/communities.rs: -------------------------------------------------------------------------------- 1 | mod create_community; 2 | pub use create_community::{create_community, CommunityResponse, CreateCommunityBody}; 3 | 4 | mod list_communities; 5 | pub use list_communities::list_communities; 6 | -------------------------------------------------------------------------------- /src/utils/types.rs: -------------------------------------------------------------------------------- 1 | mod theme; 2 | pub use theme::Theme; 3 | 4 | mod server_action; 5 | pub use server_action::*; 6 | 7 | mod dialog_types; 8 | pub use dialog_types::*; 9 | 10 | mod content_action_types; 11 | pub use content_action_types::*; 12 | -------------------------------------------------------------------------------- /src/utils/traits/bool_option_str.rs: -------------------------------------------------------------------------------- 1 | pub trait BoolOptionStr { 2 | fn then_str(self) -> Option<&'static str>; 3 | } 4 | 5 | impl BoolOptionStr for bool { 6 | fn then_str(self) -> Option<&'static str> { 7 | self.then_some("true") 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/traits/to_str.rs: -------------------------------------------------------------------------------- 1 | pub trait ToStr { 2 | fn to_str(self) -> &'static str; 3 | } 4 | 5 | impl ToStr for bool { 6 | fn to_str(self) -> &'static str { 7 | if self { 8 | "true" 9 | } else { 10 | "false" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/ui/components/common.rs: -------------------------------------------------------------------------------- 1 | pub mod community_listing; 2 | pub mod content_actions; 3 | pub mod creator_listing; 4 | pub mod fedilink; 5 | pub mod filter_bar; 6 | pub mod icon; 7 | pub mod markdown_content; 8 | pub mod sidebar; 9 | pub mod text_input; 10 | pub mod vote_buttons; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ide config 2 | .idea 3 | .vscode 4 | 5 | # build 6 | target 7 | node_modules 8 | dist 9 | 10 | # test 11 | docker/volumes 12 | end2end/test-results 13 | end2end/playwright-report/** 14 | !end2end/playwright-report/.gitkeep 15 | 16 | # local woodpecker exec 17 | .cargo/** -------------------------------------------------------------------------------- /src/ui/components/communities/communities_page.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use leptos_fluent::move_tr; 3 | 4 | #[component] 5 | pub fn CommunitiesPage() -> impl IntoView { 6 | view! { 7 |
8 |

{move_tr!("communities")}

9 |
10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/ui/components/common/markdown_content.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::markdown_to_html; 2 | use leptos::{prelude::*, text_prop::TextProp}; 3 | 4 | #[component] 5 | pub fn MarkdownContent(#[prop(into)] content: TextProp) -> impl IntoView { 6 | view! {
} 7 | } 8 | -------------------------------------------------------------------------------- /src/ui/components/communities/create_community_page.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use leptos_fluent::move_tr; 3 | 4 | #[component] 5 | pub fn CreateCommunityPage() -> impl IntoView { 6 | view! { 7 |
8 |

{move_tr!("create-community")}

9 |
10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/ui/components/login/login_page.rs: -------------------------------------------------------------------------------- 1 | use crate::ui::components::login::login_form::LoginForm; 2 | use leptos::prelude::*; 3 | 4 | #[component] 5 | pub fn LoginPage() -> impl IntoView { 6 | view! { 7 |
8 | 9 |
10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/contexts/theme_resource_context.rs: -------------------------------------------------------------------------------- 1 | use crate::{serverfns::theme::get_theme, utils::types::Theme}; 2 | use leptos::prelude::*; 3 | 4 | pub type ThemeResource = Resource>; 5 | 6 | pub fn provide_theme_resource_context() { 7 | let theme_resource = Resource::new_blocking(|| (), |_| get_theme()); 8 | 9 | provide_context(theme_resource); 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "daisyui": "^5.0.4", 4 | "prettier": "^3.5.3", 5 | "tailwindcss": "^4.0.14" 6 | }, 7 | "scripts": { 8 | "fmt": "prettier . --write" 9 | }, 10 | "packageManager": "pnpm@10.6.3+sha512.bb45e34d50a9a76e858a95837301bfb6bd6d35aea2c5d52094fa497a467c43f5c440103ce2511e9e0a2f89c3d6071baac3358fc68ac6fb75e2ceb3d2736065e6" 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/types/dialog_types.rs: -------------------------------------------------------------------------------- 1 | use super::PostOrCommentId; 2 | use leptos::{html::Dialog, prelude::NodeRef}; 3 | 4 | #[derive(Clone, Default)] 5 | pub struct ReportModalData { 6 | pub post_or_comment_id: PostOrCommentId, 7 | pub creator_actor_id: String, 8 | pub creator_name: String, 9 | } 10 | 11 | #[derive(Clone, Copy)] 12 | pub struct ReportModalNode(pub NodeRef); 13 | -------------------------------------------------------------------------------- /Makefile.toml: -------------------------------------------------------------------------------- 1 | extend = [ 2 | { path = "../cargo-make/main.toml" }, 3 | { path = "../cargo-make/cargo-leptos-test.toml" }, 4 | ] 5 | 6 | [tasks.build] 7 | command = "cargo" 8 | args = ["+nightly", "build-all-features"] 9 | install_crate = "cargo-all-features" 10 | 11 | [tasks.check] 12 | command = "cargo" 13 | args = ["+nightly", "check-all-features"] 14 | install_crate = "cargo-all-features" 15 | -------------------------------------------------------------------------------- /src/serverfns/posts.rs: -------------------------------------------------------------------------------- 1 | mod get_post; 2 | pub use get_post::get_post; 3 | 4 | mod list_posts; 5 | pub use list_posts::list_posts; 6 | 7 | mod report_post; 8 | pub use report_post::create_report_post_action; 9 | 10 | mod save_post; 11 | pub use save_post::*; 12 | 13 | mod vote_post; 14 | pub use vote_post::*; 15 | 16 | mod hide_post; 17 | pub use hide_post::{create_hide_post_action, HidePostAction}; 18 | -------------------------------------------------------------------------------- /src/utils/derive_user_is_logged_in.rs: -------------------------------------------------------------------------------- 1 | use crate::contexts::site_resource_context::SiteResource; 2 | use leptos::prelude::{Read, Signal}; 3 | 4 | pub fn derive_user_is_logged_in(site_signal: SiteResource) -> Signal { 5 | Signal::derive(move || { 6 | site_signal 7 | .read() 8 | .as_ref() 9 | .and_then(|data| data.as_ref().ok()) 10 | .is_some_and(|s| s.my_user.is_some()) 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/components/common/sidebar/user_stat_row.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use pretty_num::PrettyNumber; 3 | 4 | #[component] 5 | pub fn UserStatRow(count: i64, text: Signal) -> impl IntoView { 6 | view! { 7 | 8 | {text} 9 | {count.pretty_format()} 10 | 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/components/comment/comment_node.rs: -------------------------------------------------------------------------------- 1 | use lemmy_client::lemmy_api_common::lemmy_db_views::structs::CommentView; 2 | use leptos::prelude::*; 3 | 4 | #[component] 5 | pub fn CommentNode(#[prop(into)] comment_view: Signal) -> impl IntoView { 6 | view! { 7 |
8 | {move || { 9 | format!("{} - {}", comment_view.get().creator.name, comment_view.get().comment.content) 10 | }} 11 | 12 |
13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/get_client_and_session.rs: -------------------------------------------------------------------------------- 1 | use actix_session::Session; 2 | use actix_web::web; 3 | use lemmy_client::LemmyClient; 4 | use leptos::{prelude::ServerFnError, server_fn::error::NoCustomError}; 5 | use leptos_actix::extract; 6 | 7 | pub async fn get_client_and_session( 8 | ) -> Result<(web::Data, Session), ServerFnError> { 9 | let (client, session) = tokio::join!(extract(), extract()); 10 | Ok((client?, session?)) 11 | } 12 | -------------------------------------------------------------------------------- /src/contexts/site_resource_context.rs: -------------------------------------------------------------------------------- 1 | use crate::serverfns::get_site::get_site; 2 | use lemmy_client::lemmy_api_common::site::GetSiteResponse; 3 | use leptos::prelude::{provide_context, Resource, ServerFnError}; 4 | 5 | pub type SiteResource = Resource>; 6 | 7 | pub fn provide_site_resource_context() { 8 | let site_resource = Resource::new_blocking(|| (), |_| get_site()); 9 | 10 | provide_context(site_resource); 11 | } 12 | -------------------------------------------------------------------------------- /docker/lemmy.hjson: -------------------------------------------------------------------------------- 1 | { 2 | # for more info about the config, check out the documentation 3 | # https://join-lemmy.org/docs/en/administration/configuration.html 4 | 5 | hostname: "localhost" 6 | setup: { 7 | admin_username: "lemmy" 8 | admin_password: "lemmylemmy" 9 | site_name: "lemmy-dev" 10 | } 11 | database: { 12 | host: postgres 13 | password: "password" 14 | } 15 | pictrs: { 16 | url: "http://pictrs:8080/" 17 | api_key: "string" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /end2end/lemmy.hjson: -------------------------------------------------------------------------------- 1 | { 2 | # for more info about the config, check out the documentation 3 | # https://join-lemmy.org/docs/en/administration/configuration.html 4 | 5 | hostname: "localhost" 6 | setup: { 7 | admin_username: "lemmy" 8 | admin_password: "lemmylemmy" 9 | site_name: "lemmy-dev" 10 | } 11 | database: { 12 | host: postgres 13 | password: "password" 14 | } 15 | pictrs: { 16 | url: "http://pictrs:8080/" 17 | api_key: "string" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/serverfns/theme/get_theme.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::types::Theme; 2 | use leptos::prelude::{server_fn::codec::GetUrl, *}; 3 | use std::str::FromStr; 4 | 5 | #[server(prefix = "/serverfn", input = GetUrl)] 6 | pub async fn get_theme() -> Result { 7 | use actix_web::HttpRequest; 8 | use leptos_actix::extract; 9 | 10 | let req = extract::().await?; 11 | 12 | Ok(req.cookie("theme").map_or(Theme::Light, |c| { 13 | Theme::from_str(c.value()).unwrap_or(Theme::Light) 14 | })) 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/types/server_action.rs: -------------------------------------------------------------------------------- 1 | use leptos::server_fn::{ 2 | client::browser::BrowserClient, 3 | codec::PostUrl, 4 | error::NoCustomError, 5 | ServerFn, 6 | }; 7 | use serde::de::DeserializeOwned; 8 | 9 | pub trait ServerActionFn: 10 | DeserializeOwned 11 | + Clone 12 | + Send 13 | + Sync 14 | + 'static 15 | + ServerFn< 16 | InputEncoding = PostUrl, 17 | Client = BrowserClient, 18 | Output = Self::Out, 19 | Error = NoCustomError, 20 | > 21 | { 22 | type Out: Send + Sync + 'static; 23 | } 24 | -------------------------------------------------------------------------------- /end2end/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "end2end", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": {}, 7 | "keywords": [], 8 | "author": "", 9 | "license": "ISC", 10 | "devDependencies": { 11 | "@playwright/test": "^1.51.0", 12 | "@types/node": "^22.13.10", 13 | "typescript": "^5.8.2" 14 | }, 15 | "packageManager": "pnpm@10.6.3+sha512.bb45e34d50a9a76e858a95837301bfb6bd6d35aea2c5d52094fa497a467c43f5c440103ce2511e9e0a2f89c3d6071baac3358fc68ac6fb75e2ceb3d2736065e6" 16 | } 17 | -------------------------------------------------------------------------------- /src/ui/components/comment/comment_nodes.rs: -------------------------------------------------------------------------------- 1 | use crate::ui::components::comment::comment_node::CommentNode; 2 | use lemmy_client::lemmy_api_common::lemmy_db_views::structs::CommentView; 3 | use leptos::prelude::*; 4 | 5 | #[component] 6 | pub fn CommentNodes(#[prop(into)] _comments: Signal>) -> impl IntoView { 7 | view! { 8 |
    9 | 10 |
  • 11 | 12 |
  • 13 |
    14 |
15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/types/theme.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | use serde::{Deserialize, Serialize}; 3 | use strum::{EnumString, IntoStaticStr}; 4 | 5 | #[derive( 6 | Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, EnumString, IntoStaticStr, 7 | )] 8 | #[strum(serialize_all = "lowercase")] 9 | pub enum Theme { 10 | Light, 11 | Dark, 12 | Retro, 13 | } 14 | 15 | impl IntoAttributeValue for Theme { 16 | type Output = Oco<'static, str>; 17 | 18 | fn into_attribute_value(self) -> Self::Output { 19 | Oco::Borrowed(self.into()) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/serverfns/get_site.rs: -------------------------------------------------------------------------------- 1 | use lemmy_client::lemmy_api_common::site::GetSiteResponse; 2 | use leptos::prelude::{server_fn::codec::GetUrl, *}; 3 | 4 | #[server(prefix = "/serverfn", input = GetUrl)] 5 | pub async fn get_site() -> Result { 6 | use crate::utils::{get_client_and_session, GetJwt}; 7 | use lemmy_client::LemmyRequest; 8 | 9 | let (client, session) = get_client_and_session().await?; 10 | let jwt = session.get_jwt()?; 11 | 12 | client 13 | .get_site(LemmyRequest::from_jwt(jwt)) 14 | .await 15 | .map_err(|e| ServerFnError::ServerError(e.to_string())) 16 | } 17 | -------------------------------------------------------------------------------- /src/ui/components/communities/community_form.rs: -------------------------------------------------------------------------------- 1 | use crate::ui::components::common::text_input::TextInput; 2 | use leptos::prelude::*; 3 | use leptos_fluent::{move_tr, tr}; 4 | 5 | #[component] 6 | pub fn CommunityForm() -> impl IntoView { 7 | view! { 8 |
9 | 16 | 19 |
20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/ui/components/common/fedilink.rs: -------------------------------------------------------------------------------- 1 | use crate::ui::components::common::icon::{Icon, IconType}; 2 | use leptos::{prelude::*, text_prop::TextProp}; 3 | use leptos_fluent::move_tr; 4 | 5 | #[component] 6 | pub fn Fedilink(#[prop(into)] href: TextProp) -> impl IntoView { 7 | let label = move_tr!("fedilink-label"); 8 | view! { 9 | 15 | 16 | 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/types/content_action_types.rs: -------------------------------------------------------------------------------- 1 | use lemmy_client::lemmy_api_common::lemmy_db_schema::newtypes::{CommentId, PostId}; 2 | 3 | #[derive(Clone, Copy)] 4 | pub enum PostOrCommentId { 5 | Post(PostId), 6 | #[allow(dead_code)] 7 | Comment(CommentId), 8 | } 9 | 10 | impl PostOrCommentId { 11 | pub fn get_id(&self) -> i32 { 12 | match self { 13 | Self::Post(id) => id.0, 14 | Self::Comment(id) => id.0, 15 | } 16 | } 17 | } 18 | 19 | impl Default for PostOrCommentId { 20 | fn default() -> Self { 21 | Self::Post(PostId(0)) 22 | } 23 | } 24 | 25 | #[derive(Clone, Copy, PartialEq)] 26 | pub struct Hidden(pub bool); 27 | -------------------------------------------------------------------------------- /src/serverfns/posts/list_posts.rs: -------------------------------------------------------------------------------- 1 | use lemmy_client::lemmy_api_common::post::{GetPosts, GetPostsResponse}; 2 | use leptos::prelude::{server, server_fn::codec::GetUrl, ServerFnError}; 3 | 4 | #[server(prefix = "/serverfn", input = GetUrl)] 5 | pub async fn list_posts(body: GetPosts) -> Result { 6 | use crate::utils::{get_client_and_session, GetJwt}; 7 | use lemmy_client::LemmyRequest; 8 | 9 | let (client, session) = get_client_and_session().await?; 10 | let jwt = session.get_jwt()?; 11 | 12 | client 13 | .list_posts(LemmyRequest { body, jwt }) 14 | .await 15 | .map_err(|e| ServerFnError::ServerError(e.to_string())) 16 | } 17 | -------------------------------------------------------------------------------- /src/ui/components/post/post_listings.rs: -------------------------------------------------------------------------------- 1 | use crate::ui::components::post::post_listing::PostListing; 2 | use lemmy_client::lemmy_api_common::lemmy_db_views::structs::PostView; 3 | use leptos::prelude::*; 4 | 5 | #[component] 6 | pub fn PostListings(posts: Vec) -> impl IntoView { 7 | view! { 8 |
    9 | {posts 10 | .into_iter() 11 | .map(|pv| { 12 | view! { 13 |
  • 14 | 15 |
  • 16 | } 17 | }) 18 | .collect::>()} 19 |
20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/serverfns/auth/logout.rs: -------------------------------------------------------------------------------- 1 | use lemmy_client::LemmyRequest; 2 | use leptos::prelude::{server_fn::error::NoCustomError, *}; 3 | 4 | #[server(prefix = "/serverfn")] 5 | async fn logout() -> Result<(), ServerFnError> { 6 | use crate::utils::{get_client_and_session, GetJwt}; 7 | let (client, session) = get_client_and_session().await?; 8 | 9 | let jwt = session.get_jwt()?; 10 | client 11 | .logout(LemmyRequest::from_jwt(jwt)) 12 | .await 13 | .map_err(|e| ServerFnError::::ServerError(e.to_string()))?; 14 | 15 | session.purge(); 16 | Ok(()) 17 | } 18 | 19 | pub fn create_logout_action() -> ServerAction { 20 | ServerAction::new() 21 | } 22 | -------------------------------------------------------------------------------- /src/serverfns/posts/get_post.rs: -------------------------------------------------------------------------------- 1 | use lemmy_client::lemmy_api_common::post::{GetPost as GetPostBody, GetPostResponse}; 2 | use leptos::prelude::{server, server_fn::codec::GetUrl, ServerFnError}; 3 | 4 | #[server(prefix = "/serverfn", input = GetUrl)] 5 | pub async fn get_post(body: GetPostBody) -> Result { 6 | use crate::utils::{get_client_and_session, GetJwt}; 7 | use lemmy_client::LemmyRequest; 8 | 9 | let (client, session) = get_client_and_session().await?; 10 | let jwt = session.get_jwt()?; 11 | 12 | client 13 | .get_post(LemmyRequest { body, jwt }) 14 | .await 15 | .map_err(|e| ServerFnError::ServerError(e.to_string())) 16 | } 17 | -------------------------------------------------------------------------------- /src/serverfns/comments/list_comments.rs: -------------------------------------------------------------------------------- 1 | use lemmy_client::lemmy_api_common::comment::{GetComments, GetCommentsResponse}; 2 | use leptos::prelude::{server_fn::codec::GetUrl, *}; 3 | 4 | #[server(prefix = "/serverfn", input = GetUrl)] 5 | pub async fn list_comments(body: GetComments) -> Result { 6 | use crate::utils::{get_client_and_session, GetJwt}; 7 | use lemmy_client::LemmyRequest; 8 | 9 | let (client, session) = get_client_and_session().await?; 10 | 11 | let jwt = session.get_jwt()?; 12 | 13 | client 14 | .list_comments(LemmyRequest { body, jwt }) 15 | .await 16 | .map_err(|e| ServerFnError::ServerError(e.to_string())) 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/markdown.rs: -------------------------------------------------------------------------------- 1 | use markdown_it::{ 2 | plugins::{cmark, extra}, 3 | MarkdownIt, 4 | }; 5 | use std::sync::LazyLock; 6 | 7 | pub fn markdown_to_html(text: &str) -> String { 8 | static MARKDOWN_PARSER: LazyLock = LazyLock::new(|| { 9 | let mut parser = MarkdownIt::new(); 10 | 11 | cmark::add(&mut parser); 12 | extra::add(&mut parser); 13 | markdown_it_sup::add(&mut parser); 14 | markdown_it_sub::add(&mut parser); 15 | markdown_it_ruby::add(&mut parser); 16 | markdown_it_block_spoiler::add(&mut parser); 17 | markdown_it_footnote::add(&mut parser); 18 | 19 | parser 20 | }); 21 | 22 | MARKDOWN_PARSER.parse(text).xrender() 23 | } 24 | -------------------------------------------------------------------------------- /end2end/tests/desktop/theme.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | 3 | test("Persists theme selection between sessions", async ({ page }) => { 4 | await page.goto("/"); 5 | 6 | const root = page.getByRole("document"); 7 | await expect(root).toHaveAttribute("data-theme"); 8 | 9 | const themeButton = page.getByLabel("Theme"); 10 | await expect(themeButton).toBeVisible(); 11 | 12 | await themeButton.click(); 13 | await page.getByRole("button", { name: "Dark", exact: true }).click(); 14 | 15 | await expect(root).toHaveAttribute("data-theme", "dark"); 16 | await page.reload(); 17 | 18 | await expect(root).toHaveAttribute("data-theme", "dark"); 19 | }); 20 | -------------------------------------------------------------------------------- /end2end/tests/mobile/theme.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | 3 | test("Persists theme selection between sessions", async ({ page }) => { 4 | await page.goto("/"); 5 | 6 | const root = page.getByRole("document"); 7 | await expect(root).toHaveAttribute("data-theme"); 8 | 9 | const themeButton = page.getByLabel("Theme"); 10 | await expect(themeButton).toBeVisible(); 11 | 12 | await themeButton.click(); 13 | await page.getByRole("button", { name: "Dark", exact: true }).click(); 14 | 15 | await expect(root).toHaveAttribute("data-theme", "dark"); 16 | await page.reload(); 17 | 18 | await expect(root).toHaveAttribute("data-theme", "dark"); 19 | }); 20 | -------------------------------------------------------------------------------- /src/ui/components/common/sidebar/sidebar_data.rs: -------------------------------------------------------------------------------- 1 | use lemmy_client::lemmy_api_common::lemmy_db_schema::{ 2 | aggregates::structs::{CommunityAggregates, SiteAggregates}, 3 | source::{community::Community, person::Person, site::Site}, 4 | }; 5 | 6 | #[derive(Clone)] 7 | pub struct SiteSidebarData { 8 | pub site: Site, 9 | pub admins: Vec, 10 | pub counts: SiteAggregates, 11 | } 12 | 13 | #[derive(Clone)] 14 | pub struct CommunitySidebarData { 15 | pub community: Community, 16 | pub moderators: Vec, 17 | pub counts: CommunityAggregates, 18 | } 19 | 20 | #[derive(Clone)] 21 | pub enum SidebarData { 22 | Site(SiteSidebarData), 23 | Community(CommunitySidebarData), 24 | } 25 | -------------------------------------------------------------------------------- /src/serverfns/communities/list_communities.rs: -------------------------------------------------------------------------------- 1 | use lemmy_client::lemmy_api_common::community::{ 2 | ListCommunities as ListCommunitiesBody, 3 | ListCommunitiesResponse, 4 | }; 5 | use leptos::prelude::{server_fn::codec::GetUrl, *}; 6 | 7 | #[server(prefix = "/serverfn", input = GetUrl)] 8 | pub async fn list_communities( 9 | body: ListCommunitiesBody, 10 | ) -> Result { 11 | use crate::utils::{get_client_and_session, GetJwt}; 12 | use lemmy_client::LemmyRequest; 13 | 14 | let (client, session) = get_client_and_session().await?; 15 | 16 | let jwt = session.get_jwt()?; 17 | 18 | client 19 | .list_communities(LemmyRequest { body, jwt }) 20 | .await 21 | .map_err(|e| ServerFnError::ServerError(e.to_string())) 22 | } 23 | -------------------------------------------------------------------------------- /src/serverfns/communities/create_community.rs: -------------------------------------------------------------------------------- 1 | pub use lemmy_client::lemmy_api_common::community::{ 2 | CommunityResponse, 3 | CreateCommunity as CreateCommunityBody, 4 | }; 5 | use leptos::prelude::{server, server_fn::codec::PostUrl, ServerFnError}; 6 | 7 | #[server(prefix = "/serverfn", input = PostUrl)] 8 | pub async fn create_community( 9 | body: CreateCommunityBody, 10 | ) -> Result { 11 | use crate::utils::{get_client_and_session, GetJwt}; 12 | use lemmy_client::LemmyRequest; 13 | 14 | let (client, session) = get_client_and_session().await?; 15 | 16 | let jwt = session.get_jwt()?; 17 | 18 | client 19 | .create_community(LemmyRequest { body, jwt }) 20 | .await 21 | .map_err(|e| ServerFnError::ServerError(e.to_string())) 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/components/layouts/base_layout/top_nav/notification_bell.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | contexts::site_resource_context::SiteResource, 3 | ui::components::common::icon::{Icon, IconType}, 4 | utils::derive_user_is_logged_in, 5 | }; 6 | use leptos::prelude::*; 7 | use leptos_fluent::move_tr; 8 | use leptos_router::components::A; 9 | 10 | #[component] 11 | pub fn NotificationBell() -> impl IntoView { 12 | let site_resource = expect_context::(); 13 | let user_is_logged_in = derive_user_is_logged_in(site_resource); 14 | 15 | view! { 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/serverfns/theme/set_theme.rs: -------------------------------------------------------------------------------- 1 | use leptos::prelude::*; 2 | 3 | #[server(prefix = "/serverfn")] 4 | pub async fn change_theme(theme: String) -> Result<(), ServerFnError> { 5 | use actix_web::{ 6 | cookie::{Cookie, SameSite}, 7 | http::{header, header::HeaderValue}, 8 | }; 9 | use leptos_actix::ResponseOptions; 10 | 11 | let response = expect_context::(); 12 | 13 | let cookie = Cookie::build("theme", theme) 14 | .path("/") 15 | .secure(!cfg!(debug_assertions)) 16 | .same_site(SameSite::Strict) 17 | .finish(); 18 | 19 | if let Ok(cookie) = HeaderValue::from_str(&cookie.to_string()) { 20 | response.insert_header(header::SET_COOKIE, cookie); 21 | } 22 | 23 | Ok(()) 24 | } 25 | 26 | pub fn create_set_theme_action() -> ServerAction { 27 | ServerAction::new() 28 | } 29 | -------------------------------------------------------------------------------- /end2end/tests/desktop/theme.hydrate.spec.ts: -------------------------------------------------------------------------------- 1 | import test, { expect } from "@playwright/test"; 2 | 3 | test("Can close theme dropdown by clicking outside", async ({ page }) => { 4 | page.goto("/"); 5 | 6 | const themeButton = page.getByLabel("Theme"); 7 | const themeDropdown = page.getByRole("group").filter({ has: themeButton }); 8 | const themeDropdownList = themeDropdown.getByRole("list"); 9 | 10 | await expect(themeDropdown).not.toHaveAttribute("open"); 11 | await expect(themeDropdownList).not.toBeVisible(); 12 | 13 | await themeButton.click(); 14 | await expect(themeDropdown).toHaveAttribute("open"); 15 | await expect(themeDropdownList).toBeVisible(); 16 | 17 | await page.getByRole("document").click({ force: true }); 18 | await expect(themeDropdown).not.toHaveAttribute("open"); 19 | await expect(themeDropdownList).not.toBeVisible(); 20 | }); 21 | -------------------------------------------------------------------------------- /end2end/tests/mobile/theme.hydrate.spec.ts: -------------------------------------------------------------------------------- 1 | import test, { expect } from "@playwright/test"; 2 | 3 | test("Can close theme dropdown by clicking outside", async ({ page }) => { 4 | page.goto("/"); 5 | 6 | const themeButton = page.getByLabel("Theme"); 7 | const themeDropdown = page.getByRole("group").filter({ has: themeButton }); 8 | const themeDropdownList = themeDropdown.getByRole("list"); 9 | 10 | await expect(themeDropdown).not.toHaveAttribute("open"); 11 | await expect(themeDropdownList).not.toBeVisible(); 12 | 13 | await themeButton.click(); 14 | await expect(themeDropdown).toHaveAttribute("open"); 15 | await expect(themeDropdownList).toBeVisible(); 16 | 17 | await page.getByRole("document").click({ force: true }); 18 | await expect(themeDropdown).not.toHaveAttribute("open"); 19 | await expect(themeDropdownList).not.toBeVisible(); 20 | }); 21 | -------------------------------------------------------------------------------- /src/serverfns/users/block_user.rs: -------------------------------------------------------------------------------- 1 | use lemmy_client::{ 2 | lemmy_api_common::{ 3 | lemmy_db_schema::newtypes::PersonId, 4 | person::{BlockPerson, BlockPersonResponse}, 5 | }, 6 | LemmyRequest, 7 | }; 8 | use leptos::prelude::*; 9 | 10 | #[server(prefix = "/serverfn")] 11 | async fn block_user(id: PersonId, block: bool) -> Result { 12 | use crate::utils::{get_client_and_session, GetJwt}; 13 | let (client, session) = get_client_and_session().await?; 14 | 15 | let jwt = session.get_jwt()?; 16 | 17 | client 18 | .block_person(LemmyRequest { 19 | body: BlockPerson { 20 | person_id: id, 21 | block, 22 | }, 23 | jwt, 24 | }) 25 | .await 26 | .map_err(|e| ServerFnError::ServerError(e.to_string())) 27 | } 28 | 29 | pub fn create_block_user_action() -> ServerAction { 30 | ServerAction::new() 31 | } 32 | -------------------------------------------------------------------------------- /end2end/tests/mobile/navigation.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | page.goto("/"); 5 | }); 6 | 7 | test("Successfully navigates around the page", async ({ page, baseURL }) => { 8 | const assertUrl = (path: string) => 9 | expect(page.url()).toBe(`${baseURL}/${path}`); 10 | const mobileNav = page.getByLabel("Mobile nav"); 11 | 12 | await mobileNav.getByRole("link", { name: "Communities" }).click(); 13 | assertUrl("communities"); 14 | 15 | await mobileNav.getByRole("link", { name: "Search" }).click(); 16 | assertUrl("search"); 17 | 18 | await mobileNav.getByRole("link", { name: "Saved" }).click(); 19 | await page.waitForURL(`${baseURL}/saved`); 20 | assertUrl("saved"); 21 | 22 | await mobileNav.getByRole("link", { name: "Home" }).click(); 23 | await page.waitForURL(`${baseURL}/`); 24 | assertUrl(""); 25 | }); 26 | -------------------------------------------------------------------------------- /src/serverfns/posts/report_post.rs: -------------------------------------------------------------------------------- 1 | use lemmy_client::{ 2 | lemmy_api_common::{ 3 | lemmy_db_schema::newtypes::PostId, 4 | post::{CreatePostReport, PostReportResponse}, 5 | }, 6 | LemmyRequest, 7 | }; 8 | use leptos::prelude::*; 9 | 10 | #[server(prefix = "/serverfn")] 11 | async fn report_post(id: PostId, reason: String) -> Result { 12 | use crate::utils::{get_client_and_session, GetJwt}; 13 | let (client, session) = get_client_and_session().await?; 14 | 15 | let jwt = session.get_jwt()?; 16 | 17 | client 18 | .report_post(LemmyRequest { 19 | body: CreatePostReport { 20 | post_id: id, 21 | reason, 22 | }, 23 | jwt, 24 | }) 25 | .await 26 | .map_err(|e| ServerFnError::ServerError(e.to_string())) 27 | } 28 | 29 | pub fn create_report_post_action() -> ServerAction { 30 | ServerAction::new() 31 | } 32 | -------------------------------------------------------------------------------- /src/cookie_middleware.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::AUTH_COOKIE; 2 | use actix_session::{ 3 | config::{CookieContentSecurity, PersistentSession}, 4 | storage::CookieSessionStore, 5 | SessionMiddleware, 6 | }; 7 | use actix_web::cookie::{Key, SameSite}; 8 | pub fn cookie_middleware() -> SessionMiddleware { 9 | let debug_mode = cfg!(debug_assertions); 10 | 11 | SessionMiddleware::builder( 12 | CookieSessionStore::default(), 13 | Key::from(&[0; 64]), // TODO: The key should definitely be read from a config value for production 14 | ) 15 | .cookie_name(AUTH_COOKIE.into()) 16 | .cookie_secure(!debug_mode) 17 | .session_lifecycle(PersistentSession::default()) 18 | .cookie_same_site(if debug_mode { 19 | SameSite::Lax 20 | } else { 21 | SameSite::Strict 22 | }) 23 | .cookie_content_security(CookieContentSecurity::Private) 24 | .cookie_http_only(true) 25 | .build() 26 | } 27 | -------------------------------------------------------------------------------- /scripts/run_end2end_tests.sh: -------------------------------------------------------------------------------- 1 | # This script assumes docker can be run as a non-root user. See the following docs if the script fails because you need to use sudo: 2 | # https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user 3 | 4 | CONTAINER='end2end' 5 | DOCKERFILE_PATH='end2end/docker-compose.yml' 6 | 7 | # define some colors to use for output 8 | RED='\033[0;31m' 9 | NC='\033[0m' 10 | 11 | # kill and remove any running containers 12 | cleanup () { 13 | docker compose -p $CONTAINER kill 14 | docker compose -p $CONTAINER rm -f --all 15 | } 16 | 17 | # catch unexpected failures, do cleanup and output an error message 18 | trap 'cleanup ; printf "${RED}Tests Failed For Unexpected Reasons${NC}\n"'\ 19 | HUP INT QUIT PIPE TERM 20 | 21 | # build and run the composed services 22 | docker compose -p $CONTAINER -f $DOCKERFILE_PATH build 23 | docker compose -p $CONTAINER -f $DOCKERFILE_PATH run $CONTAINER 24 | 25 | cleanup -------------------------------------------------------------------------------- /src/serverfns/comments/report_comment.rs: -------------------------------------------------------------------------------- 1 | use lemmy_client::{ 2 | lemmy_api_common::{ 3 | comment::{CommentResponse, CreateCommentReport}, 4 | lemmy_db_schema::newtypes::CommentId, 5 | }, 6 | LemmyRequest, 7 | }; 8 | use leptos::prelude::*; 9 | 10 | #[server(prefix = "/serverfn")] 11 | async fn report_comment(id: CommentId, reason: String) -> Result { 12 | use crate::utils::{get_client_and_session, GetJwt}; 13 | let (client, session) = get_client_and_session().await?; 14 | 15 | let jwt = session.get_jwt()?; 16 | 17 | client 18 | .create_comment_report(LemmyRequest { 19 | body: CreateCommentReport { 20 | comment_id: id, 21 | reason, 22 | }, 23 | jwt, 24 | }) 25 | .await 26 | .map_err(|e| ServerFnError::ServerError(e.to_string())) 27 | } 28 | 29 | pub fn create_report_comment_action() -> ServerAction { 30 | ServerAction::new() 31 | } 32 | -------------------------------------------------------------------------------- /src/serverfns/auth/login.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::AUTH_COOKIE; 2 | use leptos::prelude::{server_fn::error::NoCustomError, *}; 3 | 4 | #[server(prefix = "/serverfn")] 5 | pub async fn login(username_or_email: String, password: String) -> Result<(), ServerFnError> { 6 | use crate::utils::get_client_and_session; 7 | use lemmy_client::lemmy_api_common::person::Login as LoginBody; 8 | 9 | let (client, session) = get_client_and_session().await?; 10 | 11 | let req = LoginBody { 12 | username_or_email: username_or_email.into(), 13 | password: password.into(), 14 | totp_2fa_token: None, 15 | }; 16 | 17 | if let Some(jwt) = client 18 | .login(req) 19 | .await 20 | .map_err(|e| ServerFnError::::ServerError(e.to_string()))? 21 | .jwt 22 | { 23 | session.insert(AUTH_COOKIE, jwt.into_inner())?; 24 | } 25 | 26 | Ok(()) 27 | } 28 | 29 | pub fn create_login_action() -> ServerAction { 30 | ServerAction::new() 31 | } 32 | -------------------------------------------------------------------------------- /src/serverfns/posts/hide_post.rs: -------------------------------------------------------------------------------- 1 | use lemmy_client::{ 2 | lemmy_api_common::{ 3 | lemmy_db_schema::newtypes::PostId, 4 | post::HidePost as HidePostForm, 5 | SuccessResponse, 6 | }, 7 | LemmyRequest, 8 | }; 9 | use leptos::prelude::*; 10 | 11 | #[server(prefix = "/serverfn")] 12 | async fn hide_post(id: PostId, hide: bool) -> Result { 13 | use crate::utils::{get_client_and_session, GetJwt}; 14 | let (client, session) = get_client_and_session().await?; 15 | 16 | let jwt = session.get_jwt()?; 17 | 18 | client 19 | .hide_post(LemmyRequest { 20 | body: HidePostForm { 21 | post_ids: Vec::from([id]), 22 | hide, 23 | }, 24 | jwt, 25 | }) 26 | .await 27 | .map_err(|e| ServerFnError::ServerError(e.to_string())) 28 | } 29 | 30 | pub fn create_hide_post_action() -> ServerAction { 31 | ServerAction::new() 32 | } 33 | 34 | pub type HidePostAction = ServerAction; 35 | -------------------------------------------------------------------------------- /src/serverfns/posts/save_post.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::types::ServerActionFn; 2 | use lemmy_client::{ 3 | lemmy_api_common::{ 4 | lemmy_db_schema::newtypes::PostId, 5 | post::{PostResponse, SavePost as SavePostBody}, 6 | }, 7 | LemmyRequest, 8 | }; 9 | use leptos::prelude::*; 10 | 11 | #[server(prefix = "/serverfn")] 12 | async fn save_post(id: PostId, save: bool) -> Result { 13 | use crate::utils::{get_client_and_session, GetJwt}; 14 | let (client, session) = get_client_and_session().await?; 15 | 16 | let jwt = session.get_jwt()?; 17 | 18 | client 19 | .save_post(LemmyRequest { 20 | body: SavePostBody { post_id: id, save }, 21 | jwt, 22 | }) 23 | .await 24 | .map_err(|e| ServerFnError::ServerError(e.to_string())) 25 | } 26 | 27 | pub fn create_save_post_action() -> ServerAction { 28 | ServerAction::new() 29 | } 30 | 31 | impl ServerActionFn for SavePost { 32 | type Out = PostResponse; 33 | } 34 | -------------------------------------------------------------------------------- /src/serverfns/posts/vote_post.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::types::ServerActionFn; 2 | use lemmy_client::{ 3 | lemmy_api_common::{ 4 | lemmy_db_schema::newtypes::PostId, 5 | post::{CreatePostLike, PostResponse}, 6 | }, 7 | LemmyRequest, 8 | }; 9 | use leptos::prelude::*; 10 | 11 | #[server(prefix = "/serverfn")] 12 | async fn vote_post(id: PostId, score: i16) -> Result { 13 | use crate::utils::{get_client_and_session, GetJwt}; 14 | 15 | let (client, session) = get_client_and_session().await?; 16 | 17 | let jwt = session.get_jwt()?; 18 | 19 | client 20 | .like_post(LemmyRequest { 21 | body: CreatePostLike { post_id: id, score }, 22 | jwt, 23 | }) 24 | .await 25 | .map_err(|e| ServerFnError::ServerError(e.to_string())) 26 | } 27 | 28 | pub fn create_vote_post_action() -> ServerAction { 29 | ServerAction::new() 30 | } 31 | 32 | impl ServerActionFn for VotePost { 33 | type Out = PostResponse; 34 | } 35 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | mod apub_name; 2 | pub use apub_name::*; 3 | 4 | mod derive_user_is_logged_in; 5 | pub use derive_user_is_logged_in::*; 6 | 7 | mod filetype; 8 | pub use filetype::*; 9 | 10 | mod get_time_since; 11 | pub use get_time_since::get_time_since; 12 | 13 | pub mod traits; 14 | pub mod types; 15 | 16 | mod derive_query_param_type; 17 | pub use derive_query_param_type::*; 18 | 19 | pub mod markdown; 20 | pub use markdown::markdown_to_html; 21 | 22 | #[cfg(feature = "ssr")] 23 | mod get_client_and_session; 24 | use crate::constants::AUTH_COOKIE; 25 | #[cfg(feature = "ssr")] 26 | pub use get_client_and_session::*; 27 | 28 | #[cfg(feature = "ssr")] 29 | pub trait GetJwt { 30 | fn get_jwt(&self) -> Result, actix_session::SessionGetError>; 31 | } 32 | 33 | #[cfg(feature = "ssr")] 34 | impl GetJwt for actix_session::Session { 35 | fn get_jwt(&self) -> Result, actix_session::SessionGetError> { 36 | self.get::(AUTH_COOKIE) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/ui/components/common/filter_bar/sort_type_link.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | contexts::site_resource_context::SiteResource, 3 | utils::{derive_sort_type, traits::BoolOptionStr}, 4 | }; 5 | use lemmy_client::lemmy_api_common::lemmy_db_schema::SortType; 6 | use leptos::prelude::*; 7 | use leptos_router::{components::A, hooks::use_query_map}; 8 | 9 | #[component] 10 | pub fn SortTypeLink(link_sort_type: SortType, text: Signal) -> impl IntoView { 11 | let query = use_query_map(); 12 | let site_resource = expect_context::(); 13 | let sort_type = derive_sort_type(site_resource); 14 | 15 | view! { 16 |
  • 17 | 27 | {text} 28 | 29 |
  • 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | devDependencies: 11 | daisyui: 12 | specifier: ^5.0.4 13 | version: 5.0.54 14 | prettier: 15 | specifier: ^3.5.3 16 | version: 3.5.3 17 | tailwindcss: 18 | specifier: ^4.0.14 19 | version: 4.0.14 20 | 21 | packages: 22 | 23 | daisyui@5.0.54: 24 | resolution: {integrity: sha512-03iuq06+lLq/VczY/+YpADgLXVC1HYO63PNiH6A9hn/+f6IkVoONVc+Jh08xizkLQQCVVMMUBp+KeIdcWSBLcg==} 25 | 26 | prettier@3.5.3: 27 | resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} 28 | engines: {node: '>=14'} 29 | hasBin: true 30 | 31 | tailwindcss@4.0.14: 32 | resolution: {integrity: sha512-92YT2dpt671tFiHH/e1ok9D987N9fHD5VWoly1CdPD/Cd1HMglvZwP3nx2yTj2lbXDAHt8QssZkxTLCCTNL+xw==} 33 | 34 | snapshots: 35 | 36 | daisyui@5.0.54: {} 37 | 38 | prettier@3.5.3: {} 39 | 40 | tailwindcss@4.0.14: {} 41 | -------------------------------------------------------------------------------- /src/ui/components/common/sidebar/team_member_card.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::DEFAULT_AVATAR_PATH; 2 | use lemmy_client::lemmy_api_common::lemmy_db_schema::source::person::Person; 3 | use leptos::prelude::*; 4 | use leptos_router::components::A; 5 | 6 | #[component] 7 | pub fn TeamMemberCard(person: Person) -> impl IntoView { 8 | view! { 9 |
  • 10 | 21 |
    {person.display_name.unwrap_or_else(|| person.name.clone())}
    22 | 26 | {format!("@{}", person.name.clone())} 27 | 28 |
  • 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:slim-bookworm AS base 2 | RUN apt update && apt -y install wget pkg-config libssl-dev 3 | RUN wget -O- https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz | tar -xvz -C /usr/local/cargo/bin 4 | 5 | FROM base AS leptos-ui 6 | WORKDIR /usr/src/app 7 | 8 | COPY *.toml Cargo.lock package.json pnpm-lock.yaml ./ 9 | COPY src src 10 | COPY public public 11 | COPY locales locales 12 | COPY style style 13 | 14 | RUN rustup target add wasm32-unknown-unknown 15 | RUN cargo-binstall -y cargo-leptos 16 | RUN wget -O- https://deb.nodesource.com/setup_20.x | bash 17 | RUN apt-get install -y nodejs 18 | 19 | # Enable corepack to use pnpm 20 | RUN npm i -g corepack 21 | RUN corepack enable 22 | RUN pnpm install --frozen-lockfile 23 | 24 | FROM leptos-ui AS playwright 25 | COPY --from=leptos-ui . . 26 | COPY end2end end2end 27 | RUN pnpx playwright@1.44.1 install --with-deps 28 | RUN cd end2end 29 | RUN pnpm install --frozen-lockfile 30 | RUN cd .. 31 | ENV INTERNAL_HOST=lemmy:8536 32 | ENV HTTPS=false 33 | CMD cargo leptos end-to-end 34 | -------------------------------------------------------------------------------- /end2end/tests/desktop/auth.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | 3 | test("Can successfully login and logout multiple times", async ({ page }) => { 4 | await page.goto("/"); 5 | 6 | for (let i = 3; i; --i) { 7 | const loginLink = page.getByRole("link", { 8 | name: "Login", 9 | exact: true, 10 | }); 11 | 12 | await expect(loginLink).toBeVisible(); 13 | 14 | await loginLink.click({ force: true }); 15 | 16 | const loginButton = page.getByRole("button", { 17 | name: "Login", 18 | exact: true, 19 | }); 20 | 21 | await expect(loginButton).toBeVisible(); 22 | 23 | await page.getByLabel("Username", { exact: true }).fill("lemmy"); 24 | await page.getByLabel("Password", { exact: true }).fill("lemmylemmy"); 25 | await loginButton.click({ force: true }); 26 | 27 | const userDropdownSummary = page.getByLabel("Logged in user dropdown"); 28 | 29 | await expect(userDropdownSummary).toBeVisible(); 30 | await userDropdownSummary.click({ force: true }); 31 | await page.getByRole("button", { name: "Logout", exact: true }).click(); 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /end2end/tests/desktop/navigation.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | 3 | test("Successfully navigates around the page", async ({ page, baseURL }) => { 4 | await page.goto("/"); 5 | 6 | const assertUrl = (path: string) => 7 | expect(page.url()).toBe(`${baseURL}/${path}`); 8 | 9 | await page.getByRole("link", { name: "Create Post" }).click(); 10 | assertUrl("create_post"); 11 | 12 | await page.getByRole("link", { name: "Create Community" }).click(); 13 | assertUrl("create_community"); 14 | 15 | await page.getByRole("link", { name: "Communities" }).click(); 16 | assertUrl("communities"); 17 | 18 | await page.getByRole("link", { name: "Search" }).click(); 19 | assertUrl("search"); 20 | 21 | await page.getByRole("link", { name: "Modlog" }).click(); 22 | assertUrl("modlog"); 23 | 24 | await page.getByRole("link", { name: "Instances" }).click(); 25 | assertUrl("instances"); 26 | 27 | await page.getByRole("link", { name: "Legal" }).click(); 28 | assertUrl("legal"); 29 | 30 | await page.getByRole("link", { name: "lemmy-dev" }).click(); 31 | await page.waitForURL(`${baseURL}/`); 32 | assertUrl(""); 33 | }); 34 | -------------------------------------------------------------------------------- /src/utils/get_time_since.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use leptos_fluent::tr; 3 | 4 | pub fn get_time_since(date_time: &DateTime) -> String { 5 | let now = Utc::now(); 6 | 7 | let years = now.years_since(*date_time).unwrap_or_default(); 8 | if years > 0 { 9 | return tr!("years-ago", { "years" => years }); 10 | } 11 | 12 | let delta = now - date_time; 13 | 14 | let weeks = delta.num_weeks(); 15 | let months = weeks / 4; 16 | 17 | if months > 0 { 18 | return tr!("months-ago", { "months" => months }); 19 | } else if weeks > 0 { 20 | return tr!("weeks-ago", { "weeks" => weeks }); 21 | } 22 | 23 | let days = delta.num_days(); 24 | 25 | if days > 0 { 26 | return tr!("days-ago", { "days" => days }); 27 | } 28 | 29 | let hours = delta.num_hours(); 30 | if hours > 0 { 31 | return tr!("hours-ago", { "hours" => hours }); 32 | } 33 | 34 | let minutes = delta.num_minutes(); 35 | if minutes > 0 { 36 | return tr!("minutes-ago", { "minutes" => minutes }); 37 | } 38 | 39 | let seconds = delta.num_seconds(); 40 | if seconds > 0 { 41 | return tr!("seconds-ago", { "seconds" => seconds }); 42 | } 43 | 44 | tr!("now") 45 | } 46 | -------------------------------------------------------------------------------- /src/ui/components/common/creator_listing.rs: -------------------------------------------------------------------------------- 1 | use crate::{constants::DEFAULT_AVATAR_PATH, utils::create_user_apub_name}; 2 | use lemmy_client::lemmy_api_common::lemmy_db_schema::source::person::Person; 3 | use leptos::prelude::*; 4 | use leptos_router::components::A; 5 | 6 | #[component] 7 | pub fn CreatorListing(#[prop(into)] creator: Signal) -> impl IntoView { 8 | let creator = creator.read_untracked(); 9 | let user_apub_name = create_user_apub_name(&creator.name, creator.actor_id.inner().as_str()); 10 | let creator_display_name = creator.display_name.as_ref().unwrap_or(&creator.name); 11 | let avatar = creator 12 | .avatar 13 | .as_deref() 14 | .map(AsRef::as_ref) 15 | .map(ToOwned::to_owned) 16 | .unwrap_or_else(|| DEFAULT_AVATAR_PATH.to_owned()); 17 | 18 | view! { 19 |
    20 | 21 |
    22 |
    {creator_display_name.clone()}
    23 | 24 | {user_apub_name} 25 | 26 |
    27 |
    28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/components/common/community_listing.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | ui::components::common::icon::{Icon, IconType}, 3 | utils::create_community_apub_name, 4 | }; 5 | use lemmy_client::lemmy_api_common::lemmy_db_schema::source::community::Community; 6 | use leptos::{either::Either, prelude::*}; 7 | use leptos_router::components::A; 8 | 9 | #[component] 10 | pub fn CommunityListing(community: Community) -> impl IntoView { 11 | let community_apub_name = 12 | create_community_apub_name(&community.name, community.actor_id.inner().as_str()); 13 | let icon = community.icon.as_ref().map(|i| i.inner().to_string()); 14 | 15 | view! { 16 |
    17 | {icon 18 | .map_or_else( 19 | || Either::Left(view! { }), 20 | |icon| Either::Right(view! { }), 21 | )}
    22 |
    {community.title.clone()}
    23 | 27 | {community_apub_name} 28 | 29 |
    30 |
    31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ui/components/common/content_actions/hide_post_button.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | serverfns::posts::HidePostAction, 3 | ui::components::common::icon::{Icon, IconType}, 4 | utils::{traits::ToStr, types::Hidden}, 5 | }; 6 | use lemmy_client::lemmy_api_common::lemmy_db_schema::newtypes::PostId; 7 | use leptos::{form::ActionForm, prelude::*}; 8 | use leptos_fluent::tr; 9 | 10 | #[component] 11 | pub fn HidePostButton(id: PostId) -> impl IntoView { 12 | let hide_post_action = expect_context::(); 13 | let hidden = expect_context::>(); 14 | let icon = Signal::derive(move || { 15 | if hidden.get().0 { 16 | IconType::Eye 17 | } else { 18 | IconType::EyeSlash 19 | } 20 | }); 21 | 22 | view! { 23 |
  • 24 | 25 | 26 | 27 | 32 | 33 |
  • 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ui/components/layouts/base_layout/mobile_nav.rs: -------------------------------------------------------------------------------- 1 | use crate::ui::components::common::icon::{Icon, IconType}; 2 | use leptos::prelude::*; 3 | use leptos_fluent::move_tr; 4 | use leptos_router::components::A; 5 | 6 | #[component] 7 | pub fn MobileNav() -> impl IntoView { 8 | view! { 9 | 19 | } 20 | } 21 | 22 | #[component] 23 | fn NavLink(href: &'static str, icon: IconType, text: Signal) -> impl IntoView { 24 | // TODO: Apply active to aria-current=page once the relevant Daisy UI issue is fixed: https://github.com/saadeghi/daisyui/issues/3170 25 | view! { 26 | 27 | 28 | {text} 29 | 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/ui/components/login/login_form.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | contexts::site_resource_context::SiteResource, 3 | serverfns::auth::create_login_action, 4 | ui::components::common::text_input::{InputType, TextInput}, 5 | }; 6 | use leptos::{form::ActionForm, prelude::*}; 7 | use leptos_fluent::{move_tr, tr}; 8 | 9 | #[component] 10 | pub fn LoginForm() -> impl IntoView { 11 | let login = create_login_action(); 12 | let site_resource = expect_context::(); 13 | // TODO: make unified, better looking way of handling errors. 14 | let login_error = move || { 15 | login.value().get().and_then(|v| { 16 | v.map_err(|e| view! {
    {e.to_string()}
    }) 17 | .err() 18 | }) 19 | }; 20 | 21 | Effect::new(move |_| { 22 | if login.value().get().is_some_and(|r| r.is_ok()) { 23 | site_resource.refetch(); 24 | } 25 | }); 26 | 27 | view! { 28 | 29 | {login_error} 30 | 37 | 38 | 46 | 49 | 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ui/components/common/vote_buttons/vote_button.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | ui::components::common::icon::{Icon, IconType}, 3 | utils::types::{PostOrCommentId, ServerActionFn}, 4 | }; 5 | use leptos::{form::ActionForm, prelude::*}; 6 | use tailwind_fuse::{tw_merge, AsTailwindClass, TwVariant}; 7 | 8 | #[derive(TwVariant)] 9 | #[tw(class = "align-bottom disabled:cursor-not-allowed disabled:text-neutral-content")] 10 | pub enum VoteType { 11 | #[tw(default, class = "text-success")] 12 | Up, 13 | #[tw(class = "text-error")] 14 | Down, 15 | } 16 | 17 | #[component] 18 | pub fn VoteButton( 19 | vote_action: ServerAction, 20 | id: PostOrCommentId, 21 | is_voted: Signal, 22 | user_is_logged_in: Signal, 23 | title: Signal, 24 | icon: IconType, 25 | vote_value: i8, 26 | vote_type: VoteType, 27 | ) -> impl IntoView 28 | where 29 | VA: ServerActionFn, 30 | { 31 | view! { 32 | 33 | 34 | 39 | 51 | 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/host.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::{HTTPS, INTERNAL_HOST}; 2 | use cfg_if::cfg_if; 3 | use lemmy_client::{ClientOptions, LemmyClient}; 4 | 5 | #[cfg(feature = "ssr")] 6 | fn get_internal_host() -> String { 7 | std::env::var("INTERNAL_HOST").unwrap_or_else(|_| INTERNAL_HOST.into()) 8 | } 9 | 10 | #[cfg(not(feature = "ssr"))] 11 | fn get_external_host() -> String { 12 | let location = leptos::prelude::window().location(); 13 | 14 | format!( 15 | "{}:{}", 16 | location.hostname().unwrap(), 17 | location.port().unwrap() 18 | ) 19 | } 20 | 21 | pub fn get_host() -> String { 22 | cfg_if! { 23 | if #[cfg(feature="ssr")] { 24 | get_internal_host() 25 | } else { 26 | get_external_host() 27 | } 28 | } 29 | } 30 | 31 | pub fn get_https() -> String { 32 | cfg_if! { 33 | if #[cfg(feature="ssr")] { 34 | std::env::var("HTTPS").unwrap_or_else(|_| format!("{HTTPS}")) 35 | } else { 36 | option_env!("HTTPS").map_or_else(|| format!("{HTTPS}"), Into::into) 37 | } 38 | } 39 | } 40 | 41 | fn should_use_https() -> bool { 42 | #[allow(clippy::needless_late_init)] 43 | let https_env_var; 44 | cfg_if! { 45 | if #[cfg(feature="ssr")] { 46 | https_env_var = std::env::var("HTTPS"); 47 | } else { 48 | https_env_var = option_env!("HTTPS"); 49 | } 50 | }; 51 | 52 | https_env_var.map_or(HTTPS, |var| var == "true") 53 | } 54 | 55 | pub fn get_client() -> LemmyClient { 56 | LemmyClient::new(ClientOptions { 57 | domain: get_host(), 58 | secure: should_use_https(), 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /src/ui/components/common/content_actions/report_button.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | ui::components::common::icon::{Icon, IconType}, 3 | utils::types::{PostOrCommentId, ReportModalData, ReportModalNode}, 4 | }; 5 | use lemmy_client::lemmy_api_common::lemmy_db_schema::source::person::Person; 6 | use leptos::prelude::*; 7 | use leptos_fluent::move_tr; 8 | 9 | fn report_content<'a>(creator: &'a Person, post_or_comment_id: PostOrCommentId) { 10 | let set_report_modal_data = expect_context::>(); 11 | let report_modal = expect_context::().0; 12 | 13 | set_report_modal_data.set(ReportModalData { 14 | post_or_comment_id, 15 | creator_actor_id: creator.actor_id.inner().as_str().to_owned(), 16 | creator_name: creator.name.clone(), 17 | }); 18 | let _ = report_modal 19 | .get_untracked() 20 | .expect("Report dialog should exist") 21 | .show_modal(); 22 | } 23 | 24 | #[component] 25 | pub fn ReportButton( 26 | #[prop(into)] creator: Signal, 27 | post_or_comment_id: PostOrCommentId, 28 | ) -> impl IntoView { 29 | let report_content_label = if matches!(post_or_comment_id, PostOrCommentId::Comment(_)) { 30 | move_tr!("report-comment") 31 | } else { 32 | move_tr!("report-post") 33 | }; 34 | let onclick = move |_| report_content(&creator.read_untracked(), post_or_comment_id); 35 | 36 | view! { 37 | 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ui/components/common/filter_bar/listing_type_link.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | contexts::site_resource_context::SiteResource, 3 | utils::{derive_listing_type, derive_user_is_logged_in, traits::BoolOptionStr}, 4 | }; 5 | use lemmy_client::lemmy_api_common::lemmy_db_schema::ListingType; 6 | use leptos::prelude::*; 7 | use leptos_router::{components::A, hooks::use_query_map}; 8 | 9 | #[component] 10 | pub fn ListingTypeLink(link_listing_type: ListingType, text: Signal) -> impl IntoView { 11 | let query = use_query_map(); 12 | let site_resource = expect_context::(); 13 | let user_is_logged_in = derive_user_is_logged_in(site_resource); 14 | let disabled = Signal::derive(move || { 15 | !user_is_logged_in.get() 16 | && matches!( 17 | link_listing_type, 18 | ListingType::Subscribed | ListingType::ModeratorView 19 | ) 20 | }); 21 | let listing_type = derive_listing_type(site_resource); 22 | 23 | view! { 24 | 39 | 40 | {text} 41 | 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/apub_name.rs: -------------------------------------------------------------------------------- 1 | use cfg_if::cfg_if; 2 | use std::str::FromStr; 3 | 4 | pub fn create_user_apub_name(name: &str, actor_id: &str) -> String { 5 | create_apub_name::<'@'>(name, actor_id).unwrap_or_default() 6 | } 7 | 8 | pub fn create_community_apub_name(name: &str, actor_id: &str) -> String { 9 | create_apub_name::<'!'>(name, actor_id).unwrap_or_default() 10 | } 11 | 12 | fn format_apub_name(name: &str, instance: &str) -> String { 13 | format!("{PREFIX}{name}@{instance}") 14 | } 15 | 16 | fn create_apub_name(name: &str, actor_id: &str) -> Option { 17 | cfg_if! { 18 | if #[cfg(feature = "ssr")] { 19 | use actix_web::http::Uri; 20 | let url = Uri::from_str(actor_id).ok()?; 21 | let instance = url.host().expect("No host name in actor id"); 22 | } else { 23 | use web_sys::Url; 24 | let instance = Url::new(actor_id).ok()?.host(); 25 | let instance = instance.as_str(); 26 | } 27 | } 28 | 29 | Some(format_apub_name::(name, instance)) 30 | } 31 | 32 | // TODO: Figure out how to test functions when targeting wasm 33 | #[cfg(test)] 34 | mod tests { 35 | use super::{create_community_apub_name, create_user_apub_name}; 36 | 37 | #[test] 38 | fn formats_user_apub_correctly() { 39 | let result = create_user_apub_name("lemmy", "http://lemmy.ml/u/lemmy"); 40 | assert_eq!(result, "@lemmy@lemmy.ml"); 41 | } 42 | 43 | #[test] 44 | fn formats_community_apub_correctly() { 45 | let result = create_community_apub_name("memes", "https://lemmy.ml/c/memes"); 46 | assert_eq!(result, "!memes@lemmy.ml"); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ui/components/layouts/base_layout/top_nav.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | contexts::site_resource_context::SiteResource, 3 | ui::components::{ 4 | common::icon::{Icon, IconType}, 5 | layouts::base_layout::top_nav::{ 6 | auth_dropdown::AuthDropdown, 7 | notification_bell::NotificationBell, 8 | theme_select::ThemeSelect, 9 | }, 10 | }, 11 | }; 12 | use leptos::prelude::*; 13 | use leptos_router::components::A; 14 | 15 | mod auth_dropdown; 16 | mod notification_bell; 17 | mod theme_select; 18 | 19 | #[component] 20 | fn InstanceName() -> impl IntoView { 21 | let site_resource = expect_context::(); 22 | 23 | move || { 24 | Suspend::new(async move { 25 | site_resource.await.map(|site_response| { 26 | view! { 27 | 28 | {site_response.site_view.site.name} 29 | 30 | } 31 | }) 32 | }) 33 | } 34 | } 35 | 36 | #[component] 37 | pub fn TopNav() -> impl IntoView { 38 | view! { 39 | 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | ### Tools and environment 4 | 5 | Install `cargo-leptos` with: 6 | 7 | `cargo install cargo-leptos` 8 | 9 | Install `taplo` with: 10 | 11 | `cargo install taplo-cli` 12 | 13 | Install `shear` with: 14 | 15 | `cargo install cargo-shear` 16 | 17 | Then run: 18 | 19 | `pnpm i` 20 | 21 | to install Tailwind and daisyUI. 22 | 23 | You can run your own local instance of Lemmy or run the UI with a test instance provided by the Lemmy community. 24 | 25 | Ensure that the version of the Lemmy API you are using in the UI is compatible with the instance you are using. 26 | 27 | This project does yet not handle multiple versions of the Lemmy API gracefully. Changing the API version will cause compilation errors in this project and errors when communicating with your Lemmy instance. 28 | 29 | Create the environment variables to point to your instance and specify Tailwind version (defaults shown here): 30 | 31 | ``` 32 | export LEPTOS_TAILWIND_VERSION=v3.4.1 33 | export INTERNAL_HOST=localhost:8536 34 | export HTTPS=false 35 | ``` 36 | 37 | Compile and run with: 38 | 39 | `cargo leptos watch` 40 | 41 | and browse to `http://localhost:1237` to see the UI. 42 | 43 | Any changes you make while coding might require a page refresh as the automatic reload may become detached. 44 | 45 | ### Running against a local Lemmy instance in Docker 46 | 47 | In the [docker](/docker) directory you will find a [docker-compose file](/docker/docker-compose.yml) that will launch a full lemmy instance and will serve your development version of the Lemmy-UI-Leptos at http://localhost as long as it is running with the config `export LEMMY_UI_LEPTOS_LEMMY_HOST=localhost`. 48 | 49 | ### Formatting 50 | 51 | Code submissions need to follow strict formatting guidelines. Run `./format.sh` or use the commands within to automate this process. 52 | -------------------------------------------------------------------------------- /src/ui/components/common/icon.rs: -------------------------------------------------------------------------------- 1 | use leptos::{prelude::*, text_prop::TextProp}; 2 | use strum::{EnumString, IntoStaticStr}; 3 | use tailwind_fuse::{tw_merge, AsTailwindClass, TwVariant}; 4 | 5 | #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr)] 6 | #[strum(serialize_all = "kebab-case")] 7 | #[non_exhaustive] 8 | pub enum IconType { 9 | Eye, 10 | EyeSlash, 11 | Notifications, 12 | Donate, 13 | Search, 14 | Upvote, 15 | Downvote, 16 | Crosspost, 17 | VerticalDots, 18 | Report, 19 | Comment, 20 | Comments, 21 | Block, 22 | Save, 23 | SaveFilled, 24 | Saved, 25 | CreatePost, 26 | CreateCommunity, 27 | Communities, 28 | Community, 29 | Documentation, 30 | Code, 31 | Info, 32 | Modlog, 33 | Instances, 34 | Legal, 35 | Theme, 36 | DropdownCaret, 37 | Home, 38 | Profile, 39 | Hamburger, 40 | Users, 41 | Posts, 42 | Fediverse, 43 | X, 44 | Image, 45 | Video, 46 | ExternalLink, 47 | Clock, 48 | Language, 49 | Warning, 50 | Quote, 51 | } 52 | 53 | #[derive(Debug, TwVariant)] 54 | pub enum IconSize { 55 | #[tw(default, class = "size-6")] 56 | Normal, 57 | #[tw(class = "size-9")] 58 | Large, 59 | #[tw(class = "size-12")] 60 | ExtraLarge, 61 | #[tw(class = "size-3")] 62 | Small, 63 | } 64 | 65 | #[component] 66 | pub fn Icon( 67 | #[prop(into)] icon: Signal, 68 | #[prop(into, default = TextProp::from(""))] class: TextProp, 69 | #[prop(into, default = Signal::stored(IconSize::Normal))] size: Signal, 70 | ) -> impl IntoView { 71 | let href = 72 | Signal::derive(move || format!("/icons.svg#{}", Into::<&'static str>::into(icon.get()))); 73 | let class = Signal::derive(move || tw_merge!(class.get().to_string(), size.get())); 74 | 75 | view! { 76 | 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/ui/components/common/filter_bar.rs: -------------------------------------------------------------------------------- 1 | use crate::ui::components::common::icon::{Icon, IconSize, IconType}; 2 | use lemmy_client::lemmy_api_common::lemmy_db_schema::{ListingType, SortType}; 3 | use leptos::prelude::*; 4 | use leptos_fluent::move_tr; 5 | use listing_type_link::ListingTypeLink; 6 | use sort_type_link::SortTypeLink; 7 | 8 | mod listing_type_link; 9 | mod sort_type_link; 10 | 11 | #[component] 12 | pub fn FilterBar() -> impl IntoView { 13 | view! { 14 |
    15 |
    16 | 17 | 18 |
    19 |
    20 | 21 | 22 | 23 |
    24 | 41 |
    42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ui/components/common/vote_buttons.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | contexts::site_resource_context::SiteResource, 3 | ui::components::common::icon::IconType, 4 | utils::{ 5 | derive_user_is_logged_in, 6 | types::{PostOrCommentId, ServerActionFn}, 7 | }, 8 | }; 9 | use leptos::prelude::*; 10 | use leptos_fluent::move_tr; 11 | use pretty_num::PrettyNumber; 12 | use tailwind_fuse::tw_merge; 13 | use vote_button::{VoteButton, VoteType}; 14 | 15 | mod vote_button; 16 | 17 | #[component] 18 | pub fn VoteButtons( 19 | my_vote: Signal>, 20 | id: PostOrCommentId, 21 | score: Signal, 22 | vote_action: ServerAction, 23 | #[prop(optional)] class: &'static str, 24 | ) -> impl IntoView 25 | where 26 | VA: ServerActionFn, 27 | { 28 | let site_resource = expect_context::(); 29 | let user_is_logged_in = derive_user_is_logged_in(site_resource); 30 | let is_upvote = Signal::derive(move || my_vote.get().unwrap_or_default() == 1); 31 | let is_downvote = Signal::derive(move || my_vote.get().unwrap_or_default() == -1); 32 | 33 | view! { 34 |
    35 | 45 |
    {move || score.get().pretty_format()}
    46 | 56 |
    57 | } 58 | } 59 | -------------------------------------------------------------------------------- /end2end/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | end2end: 3 | build: 4 | context: .. 5 | target: playwright 6 | depends_on: 7 | - lemmy 8 | networks: 9 | - frontend 10 | volumes: 11 | - ./playwright-report:/usr/src/app/end2end/playwright-report 12 | 13 | lemmy: 14 | # use "image" to pull down an already compiled lemmy. make sure to comment out "build". 15 | image: dessalines/lemmy:0.19.9 16 | # platform: linux/x86_64 # no arm64 support. uncomment platform if using m1. 17 | # use "build" to build your local lemmy server image for development. make sure to comment out "image". 18 | # run: docker compose up --build 19 | 20 | # build: 21 | # context: ../../lemmy 22 | # dockerfile: docker/Dockerfile 23 | # args: 24 | # RUST_RELEASE_MODE: release 25 | # CARGO_BUILD_FEATURES: default 26 | # this hostname is used in nginx reverse proxy and also for lemmy ui to connect to the backend, do not change 27 | hostname: lemmy 28 | restart: unless-stopped 29 | volumes: 30 | - ./lemmy.hjson:/config/config.hjson:Z 31 | depends_on: 32 | - postgres 33 | - pictrs 34 | networks: 35 | - backend 36 | - frontend 37 | 38 | pictrs: 39 | image: asonix/pictrs:0.5.19 40 | # this needs to match the pictrs url in lemmy.hjson 41 | hostname: pictrs 42 | environment: 43 | - PICTRS__API_KEY=API_KEY 44 | user: 991:991 45 | restart: unless-stopped 46 | networks: 47 | - backend 48 | 49 | postgres: 50 | image: postgres:17-alpine 51 | # this needs to match the database host in lemmy.hson 52 | # Tune your settings via 53 | # https://pgtune.leopard.in.ua/#/ 54 | # You can use this technique to add them here 55 | # https://stackoverflow.com/a/30850095/1655478 56 | hostname: postgres 57 | environment: 58 | - POSTGRES_USER=lemmy 59 | - POSTGRES_PASSWORD=password 60 | - POSTGRES_DB=lemmy 61 | restart: unless-stopped 62 | networks: 63 | - backend 64 | 65 | networks: 66 | frontend: 67 | backend: 68 | -------------------------------------------------------------------------------- /src/ui/components/layouts/base_layout.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | contexts::theme_resource_context::ThemeResource, 3 | ui::components::layouts::base_layout::{ 4 | mobile_nav::MobileNav, 5 | side_nav::SideNav, 6 | top_nav::TopNav, 7 | }, 8 | }; 9 | use leptos::prelude::*; 10 | use leptos_meta::Html; 11 | use leptos_router::components::Outlet; 12 | 13 | mod mobile_nav; 14 | mod side_nav; 15 | mod top_nav; 16 | 17 | #[component] 18 | pub fn BaseLayout() -> impl IntoView { 19 | let theme = expect_context::(); 20 | 21 | view! { 22 |
    23 | 24 |
    25 | 26 | {move || Suspend::new(async move { 27 | theme 28 | .await 29 | .ok() 30 | .map(|theme| { 31 | view! { 32 | 33 | } 34 | }) 35 | })} 36 | 37 | 38 |
    39 | 45 |
    46 | 47 |
    48 |
    49 | 50 |
    51 |
    52 | 53 | 56 |
    57 |
    58 | } 59 | } 60 | -------------------------------------------------------------------------------- /end2end/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | devices, 3 | defineConfig, 4 | Project, 5 | PlaywrightTestOptions, 6 | PlaywrightWorkerOptions, 7 | } from "@playwright/test"; 8 | 9 | type ProjectDefinition = Project< 10 | PlaywrightTestOptions, 11 | PlaywrightWorkerOptions 12 | >; 13 | 14 | type UseOptions = ProjectDefinition["use"]; 15 | 16 | const createProject = ( 17 | name: string, 18 | use: UseOptions, 19 | screen: "desktop" | "mobile", 20 | ): ProjectDefinition => ({ 21 | name, 22 | use, 23 | testMatch: screen === "desktop" ? "desktop/**" : "mobile/**", 24 | }); 25 | 26 | const ssr = (def: ProjectDefinition): ProjectDefinition => ({ 27 | ...def, 28 | use: { 29 | ...def.use!, 30 | javaScriptEnabled: false, 31 | }, 32 | testIgnore: /.*hydrate.*/, 33 | }); 34 | 35 | export default defineConfig({ 36 | testDir: "./tests", 37 | timeout: 60 * 1000, 38 | expect: { 39 | timeout: 5000, 40 | }, 41 | fullyParallel: true, 42 | forbidOnly: !!process.env.CI, 43 | retries: process.env.CI ? 2 : 0, 44 | // only 1 worker because server can only handle login tests 45 | // one at a time from the same account due to token clashes 46 | // another solution is to use multiple account fixture data 47 | workers: 1, 48 | reporter: [["html", { open: "never" }]], 49 | use: { 50 | trace: "on-first-retry", 51 | baseURL: "http://localhost:1237", 52 | }, 53 | projects: [ 54 | createProject("Chromium Hydrate", devices["Desktop Chrome"], "desktop"), 55 | ssr(createProject("Chromium SSR", devices["Desktop Chrome"], "desktop")), 56 | createProject("Firefox Hydrate", devices["Desktop Firefox"], "desktop"), 57 | ssr(createProject("Firefox SSR", devices["Desktop Firefox"], "desktop")), 58 | createProject("Edge Hydrate", devices["Desktop Edge"], "desktop"), 59 | ssr(createProject("Edge Hydrate", devices["Desktop Edge"], "desktop")), 60 | createProject("Galaxy S9+ Hydrate", devices["Galaxy S9+"], "mobile"), 61 | ssr(createProject("Galaxy S9+ SSR", devices["Galaxy S9+"], "mobile")), 62 | createProject("Pixel 7 Hydrate", devices["Pixel 7"], "mobile"), 63 | ssr(createProject("Pixel 7 SSR", devices["Pixel 7"], "mobile")), 64 | ], 65 | }); 66 | -------------------------------------------------------------------------------- /src/utils/derive_query_param_type.rs: -------------------------------------------------------------------------------- 1 | use crate::contexts::site_resource_context::SiteResource; 2 | use lemmy_client::lemmy_api_common::lemmy_db_schema::{ 3 | source::{local_site::LocalSite, local_user::LocalUser}, 4 | ListingType, 5 | SortType, 6 | }; 7 | use leptos::prelude::{Read, Signal}; 8 | use leptos_router::hooks::query_signal; 9 | use std::str::FromStr; 10 | 11 | fn derive_link_type( 12 | site_resource: SiteResource, 13 | key: &'static str, 14 | get_user_default: impl Fn(&LocalUser) -> T + Send + Sync + 'static, 15 | get_site_default: impl Fn(&LocalSite) -> T + Send + Sync + 'static, 16 | ) -> Signal 17 | where 18 | T: Copy + Default + Send + Sync + FromStr + ToString + PartialEq + 'static, 19 | { 20 | let (query_type, _) = query_signal::(key); 21 | 22 | Signal::derive(move || { 23 | // The warning "you are reading a resource in `hydrate` mode outside a or or effect." 24 | // is a false positive that occurs dues to a bug in Leptos. 25 | // See https://github.com/leptos-rs/leptos/issues/3372 26 | let site_response = site_resource.read(); 27 | let site_response = site_response 28 | .as_ref() 29 | .and_then(|site_response| site_response.as_ref().ok()); 30 | 31 | query_type 32 | .read() 33 | .or_else(|| { 34 | site_response.and_then(|site_response| { 35 | site_response 36 | .my_user 37 | .as_ref() 38 | .map(|my_user| get_user_default(&my_user.local_user_view.local_user)) 39 | }) 40 | }) 41 | .or_else(|| { 42 | site_response.map(|site_response| get_site_default(&site_response.site_view.local_site)) 43 | }) 44 | .unwrap_or_default() 45 | }) 46 | } 47 | 48 | pub fn derive_sort_type(site_resource: SiteResource) -> Signal { 49 | derive_link_type( 50 | site_resource, 51 | "sort", 52 | |u| u.default_sort_type, 53 | |s| s.default_sort_type, 54 | ) 55 | } 56 | 57 | pub fn derive_listing_type(site_resource: SiteResource) -> Signal { 58 | derive_link_type( 59 | site_resource, 60 | "listingType", 61 | |u| u.default_listing_type, 62 | |s| s.default_post_listing_type, 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /end2end/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | devDependencies: 11 | '@playwright/test': 12 | specifier: ^1.51.0 13 | version: 1.55.1 14 | '@types/node': 15 | specifier: ^22.13.10 16 | version: 22.13.10 17 | typescript: 18 | specifier: ^5.8.2 19 | version: 5.8.2 20 | 21 | packages: 22 | 23 | '@playwright/test@1.55.1': 24 | resolution: {integrity: sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==} 25 | engines: {node: '>=18'} 26 | hasBin: true 27 | 28 | '@types/node@22.13.10': 29 | resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==} 30 | 31 | fsevents@2.3.2: 32 | resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} 33 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 34 | os: [darwin] 35 | 36 | playwright-core@1.55.1: 37 | resolution: {integrity: sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==} 38 | engines: {node: '>=18'} 39 | hasBin: true 40 | 41 | playwright@1.55.1: 42 | resolution: {integrity: sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==} 43 | engines: {node: '>=18'} 44 | hasBin: true 45 | 46 | typescript@5.8.2: 47 | resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} 48 | engines: {node: '>=14.17'} 49 | hasBin: true 50 | 51 | undici-types@6.20.0: 52 | resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} 53 | 54 | snapshots: 55 | 56 | '@playwright/test@1.55.1': 57 | dependencies: 58 | playwright: 1.55.1 59 | 60 | '@types/node@22.13.10': 61 | dependencies: 62 | undici-types: 6.20.0 63 | 64 | fsevents@2.3.2: 65 | optional: true 66 | 67 | playwright-core@1.55.1: {} 68 | 69 | playwright@1.55.1: 70 | dependencies: 71 | playwright-core: 1.55.1 72 | optionalDependencies: 73 | fsevents: 2.3.2 74 | 75 | typescript@5.8.2: {} 76 | 77 | undici-types@6.20.0: {} 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lemmy-UI-Leptos 2 | 3 | A complete rewrite of [Lemmy UI](//github.com/LemmyNet/lemmy-ui) in [Rust](//www.rust-lang.org/), [Leptos](//github.com/leptos-rs/leptos), [Daisy](//daisyui.com) and [Tailwind](//tailwindcss.com). 4 | 5 | Using Rust everywhere means we get to use Rust's expressive type system and powerful language in the UI. It also means we inherit types and APIs from the server project [Lemmy](//github.com/LemmyNet/lemmy) that automates consistency and enables isomorphic code-reuse between components. 6 | 7 | Leptos's signal based framework is both fast and easy to use making it ideal for apps based on web technologies. 8 | 9 | Daisy and Tailwind give us components and utility classes that look great and are compiled into the project efficiently. 10 | 11 | ## Development 12 | 13 | See [CONTRIBUTING.md](/CONTRIBUTING.md) for information on setting up your development environment. 14 | 15 | It's a standard contemporary web development environment. The development feedback loop is made fast and convenient with the [cargo-leptos](//github.com/leptos-rs/cargo-leptos) CLI development server. 16 | 17 | ## Objectives 18 | 19 | - initially leverage pure Daisy components to provide common component styling with the least markup 20 | - when a custom look and feel is agreed upon, implement using Tailwind's fine grained styling 21 | - use Tailwind's layout and responsive tools to adapt UI to screens of all common sizes 22 | - use isomorphic Leptos code to ensure that features work in the following contexts: 23 | - SSR only - server side rendering only. Search engine bots and browsers with diverse technical requirements (JS and WASM are disabled) must be able to read and interact with all core features. There will be sophisticated (non-core) features where we agree this is not possible 24 | - Hydrate - features progressively enhance from being rendered on the server to running almost entirely in the browser (JS and WASM are available). Feature logic must handle this context switch gracefully 25 | - CSR only - client side rendering only - when a mobile/desktop app framework target is agreed upon (e.g. Tauri) all UI and interaction code is bundled into an app that communicates directly with its Lemmy instance 26 | - all core features should be accessible to as diverse a user base as we agree is possible 27 | - all UI text must be internationalized and rendered effectively for RTL languages 28 | - the badge feature must be recognizable across all Lemmy front ends for ease of identification and administration 29 | -------------------------------------------------------------------------------- /src/ui/components/layouts/base_layout/side_nav.rs: -------------------------------------------------------------------------------- 1 | use crate::ui::components::common::icon::{Icon, IconType}; 2 | use leptos::prelude::*; 3 | use leptos_fluent::move_tr; 4 | use leptos_router::components::A; 5 | 6 | #[component] 7 | pub fn SideNav() -> impl IntoView { 8 | view! { 9 |
    10 |
      11 | 12 | 17 | 18 | 19 | 20 | 21 | 22 |
    23 |
    24 |
    25 |
    26 | {move_tr!("lemmy-resources")} 27 |
    28 |
      29 | 34 | 39 | 40 | 45 |
    46 |
    47 | } 48 | } 49 | 50 | #[component] 51 | fn NavLink(href: &'static str, icon: IconType, text: Signal) -> impl IntoView { 52 | view! { 53 |
  • 54 | 58 | 59 | {text} 60 | 61 |
  • 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /docker/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | events { 3 | worker_connections 1024; 4 | } 5 | http { 6 | upstream lemmy { 7 | # this needs to map to the lemmy (server) docker service hostname 8 | server "lemmy:8536"; 9 | } 10 | upstream leptos { 11 | # this needs to map to the lemmy-ui-leptos service 12 | server "172.18.0.1:1237"; 13 | } 14 | 15 | server { 16 | # this is the port inside docker, not the public one yet 17 | listen 1236; 18 | listen 8536; 19 | listen 80; 20 | # change if needed, this is facing the public web 21 | server_name localhost; 22 | server_tokens off; 23 | 24 | gzip on; 25 | gzip_types text/css application/javascript image/svg+xml; 26 | gzip_vary on; 27 | 28 | # Upload limit, relevant for pictrs 29 | client_max_body_size 20M; 30 | 31 | add_header X-Frame-Options SAMEORIGIN; 32 | add_header X-Content-Type-Options nosniff; 33 | add_header X-XSS-Protection "1; mode=block"; 34 | 35 | # frontend general requests 36 | location / { 37 | # distinguish between ui requests and backend 38 | # don't change lemmy-ui or lemmy here, they refer to the upstream definitions on top 39 | set $proxpass "http://leptos"; 40 | 41 | if ($http_accept = "application/activity+json") { 42 | set $proxpass "http://lemmy"; 43 | } 44 | if ($http_accept = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") { 45 | set $proxpass "http://lemmy"; 46 | } 47 | if ($request_method = POST) { 48 | set $proxpass "http://lemmy"; 49 | } 50 | proxy_pass $proxpass; 51 | 52 | rewrite ^(.+)/+$ $1 permanent; 53 | # Send actual client IP upstream 54 | proxy_set_header X-Real-IP $remote_addr; 55 | proxy_set_header Host $host; 56 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 57 | } 58 | 59 | # backend 60 | location ~ ^/(api|pictrs|feeds|nodeinfo|version|.well-known) { 61 | proxy_pass "http://lemmy"; 62 | # proxy common stuff 63 | proxy_http_version 1.1; 64 | proxy_set_header Upgrade $http_upgrade; 65 | proxy_set_header Connection "upgrade"; 66 | 67 | # Send actual client IP upstream 68 | proxy_set_header X-Real-IP $remote_addr; 69 | proxy_set_header Host $host; 70 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/ui/components/post/post_page.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | serverfns::{comments::list_comments, posts::get_post}, 3 | ui::components::{ 4 | common::sidebar::{ 5 | sidebar_data::{CommunitySidebarData, SidebarData}, 6 | Sidebar, 7 | }, 8 | post::post_listing::PostListing, 9 | }, 10 | }; 11 | use lemmy_client::lemmy_api_common::{ 12 | comment::GetComments, 13 | lemmy_db_schema::newtypes::PostId, 14 | post::GetPost, 15 | }; 16 | use leptos::prelude::*; 17 | use leptos_router::hooks::use_params_map; 18 | 19 | #[component] 20 | pub fn PostPage() -> impl IntoView { 21 | let params = use_params_map(); 22 | 23 | let post_id = Signal::derive(move || { 24 | params 25 | .read() 26 | .get("id") 27 | .and_then(|post_id| Some(PostId(post_id.as_str().parse().ok()?))) 28 | .unwrap_or_default() 29 | }); 30 | 31 | let post_resource = Resource::new_blocking( 32 | move || GetPost { 33 | id: Some(*post_id.read()), 34 | comment_id: None, 35 | }, 36 | get_post, 37 | ); 38 | 39 | let _list_comments_resource = Resource::new( 40 | move || GetComments { 41 | post_id: Some(*post_id.read()), 42 | max_depth: Some(8), 43 | ..Default::default() 44 | }, 45 | list_comments, 46 | ); 47 | 48 | view! { 49 |
    50 |
    51 | 52 | {move || Suspend::new(async move { 53 | post_resource 54 | .await 55 | .map(|post_response| view! { }) 56 | })} 57 | 58 | 59 | // 60 | //
    61 | // 62 | //
    63 | //
    64 |
    65 | 82 |
    83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/ui/components/layouts/base_layout/top_nav/theme_select.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | contexts::theme_resource_context::ThemeResource, 3 | serverfns::theme::create_set_theme_action, 4 | ui::components::common::icon::{Icon, IconSize, IconType}, 5 | utils::types::Theme, 6 | }; 7 | use leptos::{form::ActionForm, html::Details, prelude::*}; 8 | use leptos_fluent::move_tr; 9 | #[cfg(not(feature = "ssr"))] 10 | use leptos_use::on_click_outside; 11 | 12 | #[component] 13 | pub fn ThemeSelect() -> impl IntoView { 14 | let theme_action = create_set_theme_action(); 15 | let theme = expect_context::(); 16 | Effect::new(move |_| { 17 | if theme_action.version().get() > 0 { 18 | theme.refetch(); 19 | } 20 | }); 21 | 22 | #[allow(unused_variables)] 23 | let dropdown_node_ref = NodeRef::
    ::new(); 24 | #[cfg(not(feature = "ssr"))] 25 | let _ = on_click_outside(dropdown_node_ref, move |_| { 26 | // Using this approach instead of conditional rendering so that the dropdown works at least somewhat when JS is disabled 27 | if let Some(el) = dropdown_node_ref.get() { 28 | use leptos::attr::*; 29 | let _ = el.attr(Attr(Open, None::<&str>)); 30 | } 31 | }); 32 | 33 | view! { 34 | 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/ui/components/common/text_input.rs: -------------------------------------------------------------------------------- 1 | use crate::ui::components::common::icon::{ 2 | Icon, 3 | IconType::{Eye, EyeSlash}, 4 | }; 5 | use leptos::{prelude::*, text_prop::TextProp}; 6 | use leptos_fluent::tr; 7 | 8 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 9 | pub enum InputType { 10 | Text, 11 | Password, 12 | } 13 | 14 | #[component] 15 | pub fn TextInput( 16 | #[prop(optional, into)] disabled: MaybeProp, 17 | #[prop(optional, into)] required: MaybeProp, 18 | #[prop(optional, into)] min_length: MaybeProp, 19 | #[prop(optional, into)] pattern: Option<&'static str>, 20 | #[prop(into)] id: TextProp, 21 | #[prop(into)] name: TextProp, 22 | #[prop(into)] label: TextProp, 23 | #[prop(default = InputType::Text)] input_type: InputType, 24 | #[prop(optional)] validation_class: Signal, 25 | #[prop(default = false)] autofocus: bool, 26 | ) -> impl IntoView { 27 | let show_password = RwSignal::new(false); 28 | let for_id = id.get().clone(); 29 | let eye_icon = Signal::derive(move || if show_password.get() { EyeSlash } else { Eye }); 30 | 31 | view! { 32 |
    33 | 54 | 55 | 56 | 67 | 68 | 74 |
    75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/utils/filetype.rs: -------------------------------------------------------------------------------- 1 | const IMAGE_TYPES: [&str; 6] = ["jpg", "jpeg", "gif", "png", "svg", "webp"]; 2 | const VIDEO_TYPES: [&str; 2] = ["mp4", "webm"]; 3 | 4 | pub fn is_image(url: &str) -> bool { 5 | is_filetype(url, &IMAGE_TYPES) 6 | } 7 | 8 | pub fn is_video(url: &str) -> bool { 9 | is_filetype(url, &VIDEO_TYPES) 10 | } 11 | 12 | fn is_filetype(url: &str, exts: &[&str]) -> bool { 13 | url 14 | .split('?') 15 | .next() 16 | .and_then(|s| s.rsplit('.').next().map(str::to_lowercase)) 17 | .is_some_and(|ext| exts.iter().any(|file_type| ext.ends_with(file_type))) 18 | } 19 | 20 | #[cfg(test)] 21 | mod test { 22 | use crate::utils::{is_image, is_video}; 23 | use rstest::rstest; 24 | 25 | #[rstest] 26 | #[case("https://my.test.image.co/keNu2D9.jpg")] 27 | #[case("https://my.test.image.co/keNu2D9.jpeg")] 28 | #[case("https://my.test.image.co/keNu2D9.gif")] 29 | #[case("https://my.test.image.co/keNu2D9.png")] 30 | #[case("https://my.test.image.co/keNu2D9.svg")] 31 | #[case("https://my.test.image.co/keNu2D9.webp")] 32 | #[case("https://lemmy.foo.bar.us/pictrs/image/d1821922-855d-47d3-86da-a516d0c0f188.jpg?format=webp&thumbnail=256")] 33 | #[case("https://lemmy.foo.bar.us/pictrs/image/d1821922-855d-47d3-86da-a516d0c0f188.jpeg?format=webp&thumbnail=256")] 34 | #[case("https://lemmy.foo.bar.us/pictrs/image/d1821922-855d-47d3-86da-a516d0c0f188.gif?format=webp&thumbnail=256")] 35 | #[case("https://lemmy.foo.bar.us/pictrs/image/d1821922-855d-47d3-86da-a516d0c0f188.png?format=webp&thumbnail=256")] 36 | #[case("https://lemmy.foo.bar.us/pictrs/image/d1821922-855d-47d3-86da-a516d0c0f188.svg?format=webp&thumbnail=256")] 37 | #[case("https://lemmy.foo.bar.us/pictrs/image/d1821922-855d-47d3-86da-a516d0c0f188.webp?format=webp&thumbnail=256")] 38 | fn is_image_test(#[case] url: &str) { 39 | assert!(is_image(url)); 40 | } 41 | 42 | #[rstest] 43 | #[case("http://lemmy.instance.com")] 44 | #[case("http://lemmy.instance.com/")] 45 | #[case("https://foo.bar.xyz/baz/qux.pdf")] 46 | #[case("https://foo.bar.xyz/baz/qux.txt")] 47 | #[case("https://foo.bar.xyz/baz/qux.mp4?arg=thing")] 48 | fn is_not_image_test(#[case] url: &str) { 49 | assert!(!is_image(url)); 50 | } 51 | 52 | #[rstest] 53 | #[case("http://vids.inv.pizza/myvid.mp4")] 54 | #[case("http://vids.inv.pizza/myvid.webm")] 55 | #[case("http://vids.inv.pizza/myvid.mp4?tracking=plsno")] 56 | #[case("http://vids.inv.pizza/myvid.webm?tracking=plsno")] 57 | fn is_video_test(#[case] url: &str) { 58 | assert!(is_video(url)); 59 | } 60 | 61 | #[rstest] 62 | #[case("http://lemmy.instance.com")] 63 | #[case("http://lemmy.instance.com/")] 64 | #[case("https://foo.bar.xyz/baz/qux.pdf")] 65 | #[case("https://foo.bar.xyz/baz/qux.txt")] 66 | #[case("https://foo.bar.xyz/baz/qux.png?arg=thing")] 67 | fn is_not_video_test(#[case] url: &str) { 68 | assert!(!is_video(url)); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/ui/components/home/home_page.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | contexts::site_resource_context::SiteResource, 3 | serverfns::posts::list_posts, 4 | ui::components::{ 5 | common::{ 6 | filter_bar::FilterBar, 7 | sidebar::{ 8 | sidebar_data::{SidebarData, SiteSidebarData}, 9 | Sidebar, 10 | }, 11 | }, 12 | post::post_listings::PostListings, 13 | }, 14 | utils::{derive_listing_type, derive_sort_type}, 15 | }; 16 | use lemmy_client::lemmy_api_common::{post::GetPosts, site::GetSiteResponse}; 17 | use leptos::prelude::*; 18 | use leptos_fluent::move_tr; 19 | 20 | #[component] 21 | pub fn HomePage() -> impl IntoView { 22 | let site_resource = expect_context::(); 23 | let listing_type = derive_listing_type(site_resource); 24 | let sort_type = derive_sort_type(site_resource); 25 | 26 | let posts_resource = Resource::new_blocking( 27 | move || GetPosts { 28 | type_: Some(listing_type.get()), 29 | sort: Some(sort_type.get()), 30 | limit: Some(20), 31 | ..Default::default() 32 | }, 33 | list_posts, 34 | ); 35 | 36 | view! { 37 |
    38 |
    39 |
    40 |

    {move_tr!("home-feed")}

    41 | 42 | 43 | 44 |
    45 | 46 | 49 | {move || Suspend::new(async move { 50 | posts_resource 51 | .await 52 | .map(|posts_response| { 53 | view! { } 54 | }) 55 | })} 56 | 57 | 58 | 59 |
    60 | 61 | 79 |
    80 | } 81 | } 82 | -------------------------------------------------------------------------------- /locales/en/main.ftl: -------------------------------------------------------------------------------- 1 | communities = Communities 2 | create-post = Create post 3 | create-community = Create community 4 | name = Name 5 | create = Create 6 | donate = Donate 7 | search = Search 8 | login = Login 9 | signup = Sign up 10 | unread-messages = unread messages 11 | profile = Profile 12 | settings = Settings 13 | logout = Logout 14 | modlog = Modlog 15 | instances = Instances 16 | documentation = Documentation 17 | source-code = Source Code 18 | about = About 19 | legal = Legal 20 | active = Active 21 | hot = Hot 22 | new = New 23 | save-post = Save post 24 | save-comment = Save comment 25 | crosspost = Crosspost 26 | block-user = Block user 27 | fedilink-label = View content on origin instance 28 | hide-password = Hide password 29 | show-password = Show password 30 | hide-post = Hide post 31 | unhide-post = Unhide post 32 | report-post = Report post 33 | report-comment = Report comment 34 | could-not-load-posts = Could not load posts! 35 | today = Today 36 | past-week = Past Week 37 | past-month = Past Month 38 | past-6-months = Past 6 Months 39 | all-time = All Time 40 | home = Home 41 | saved = Saved 42 | main-nav-links = Main navigation links 43 | lemmy-resources = Lemmy Resources 44 | dark = Dark 45 | light = Light 46 | retro = Retro 47 | theme = Theme 48 | sort-type = Sort Type 49 | posts = Posts 50 | comments = Comments 51 | username = Username 52 | password = Password 53 | creator-of-post = Creator of post 54 | creator-of-comment = Creator of comment 55 | reason = reason 56 | cancel = Cancel 57 | submit-report = Submit report 58 | all = All 59 | local = Local 60 | subscribed = Subscribed 61 | mobile-nav = Mobile nav 62 | logged-in-user-dropdown = Logged in user dropdown 63 | authentication-nav = Authentication nav 64 | instance-stats = Instance Stats 65 | active-users = Active Users 66 | time-frame = Time Frame 67 | count = Count 68 | admins = Admins 69 | loading = Loading 70 | home-feed = Home Feed 71 | upvote = Upvote 72 | downvote = Downvote 73 | local-subscribers = Local Subscribers 74 | subscribers = Subscribers 75 | community-stats = Community Stats 76 | moderators = Moderators 77 | now = Now 78 | years-ago = {$years -> 79 | [one] {$years} year ago 80 | *[other] {$years} years ago 81 | } 82 | months-ago = {$months -> 83 | [one] {$months} month ago 84 | *[other] {$months} months ago 85 | } 86 | weeks-ago = {$weeks -> 87 | [one] {$weeks} week ago 88 | *[other] {$weeks} weeks ago 89 | } 90 | days-ago = {$days -> 91 | [one] {$days} day ago 92 | *[other] {$days} days ago 93 | } 94 | hours-ago = {$hours -> 95 | [one] {$hours} hour ago 96 | *[other] {$hours} hours ago 97 | } 98 | minutes-ago = {$minutes -> 99 | [one] {$minutes} minute ago 100 | *[other] {$minutes} minutes ago 101 | } 102 | seconds-ago = {$seconds -> 103 | [one] {$seconds} second ago 104 | *[other] {$seconds} seconds ago 105 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lemmy-ui-leptos" 3 | version = "0.0.2" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib", "rlib"] 8 | 9 | [dependencies] 10 | leptos = "0.7" 11 | leptos_meta = "0.7" 12 | leptos_router = "0.7" 13 | cfg-if = "1" 14 | lemmy-client = "1.0.5" 15 | serde = "1.0" 16 | 17 | # required for better debug messages 18 | console_error_panic_hook = { version = "0.1", optional = true } 19 | 20 | # dependecies for server (enable when ssr set) 21 | leptos_actix = { version = "0.7", optional = true } 22 | actix-files = { version = "0.6", optional = true } 23 | actix-web = { version = "4.9", features = ["macros"], optional = true } 24 | actix-session = { version = "0.10", features = [ 25 | "cookie-session", 26 | ], optional = true } 27 | tokio = { version = "1.44", optional = true, features = ["macros"] } 28 | strum = { version = "0.27", features = ["derive"] } 29 | leptos-use = "0.15" 30 | pretty-num = "0.1" 31 | tailwind_fuse = { version = "0.3", features = ["variant"] } 32 | web-sys = { version = "0.3", optional = true, features = ["Url"] } 33 | wasm-bindgen = { version = "0.2", optional = true } 34 | leptos-fluent = { version = "0.2", features = ["actix"] } 35 | fluent-templates = "0.13" 36 | chrono = "0.4" 37 | markdown-it = "0.6" 38 | markdown-it-sup = "1" 39 | markdown-it-sub = "1" 40 | markdown-it-ruby = "1" 41 | markdown-it-block-spoiler = "1" 42 | markdown-it-footnote = "0.2" 43 | 44 | uuid = "=1.12.1" 45 | 46 | [package.metadata.cargo-shear] 47 | ignored = ["uuid"] 48 | 49 | [features] 50 | default = ["ssr"] 51 | hydrate = [ 52 | "leptos/hydrate", 53 | "leptos-fluent/hydrate", 54 | "dep:console_error_panic_hook", 55 | "dep:web-sys", 56 | "dep:wasm-bindgen", 57 | ] 58 | csr = [ 59 | "leptos/csr", 60 | "dep:console_error_panic_hook", 61 | "dep:web-sys", 62 | "dep:wasm-bindgen", 63 | ] 64 | ssr = [ 65 | "leptos/ssr", 66 | "leptos_meta/ssr", 67 | "leptos_router/ssr", 68 | "leptos-fluent/ssr", 69 | "leptos-fluent/actix", 70 | "dep:leptos_actix", 71 | "dep:actix-web", 72 | "dep:actix-files", 73 | "dep:actix-session", 74 | "dep:tokio", 75 | ] 76 | 77 | [lints.clippy] 78 | # leptos can't deal with these lints 79 | needless_lifetimes = "allow" 80 | let_unit_value = "allow" 81 | 82 | [package.metadata.cargo-all-features] 83 | denylist = [ 84 | "actix-files", 85 | "actix-web", 86 | "leptos_actix", 87 | "console_error_panic_hook", 88 | "wasm-bindgen", 89 | "web-sys", 90 | ] 91 | skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]] 92 | 93 | [profile.release] 94 | codegen-units = 1 95 | lto = true 96 | 97 | [profile.wasm-release] 98 | inherits = "release" 99 | opt-level = "z" 100 | panic = "abort" 101 | 102 | [dev-dependencies] 103 | rstest = "0.25" 104 | 105 | [package.metadata.leptos] 106 | output-name = "lemmy-ui-leptos" 107 | env = "DEV" 108 | watch = false 109 | watch-additional-files = ["locales"] 110 | 111 | bin-features = ["ssr"] 112 | bin-default-features = false 113 | 114 | lib-features = ["hydrate"] 115 | lib-default-features = false 116 | lib-profile-release = "wasm-release" 117 | 118 | tailwind-input-file = "style/tailwind.css" 119 | browserquery = "defaults" 120 | 121 | site-root = "target/site" 122 | site-pkg-dir = "pkg" 123 | assets-dir = "public" 124 | site-addr = "127.0.0.1:1237" 125 | reload-port = 3001 126 | 127 | end2end-cmd = "npx playwright test" 128 | end2end-dir = "end2end" 129 | -------------------------------------------------------------------------------- /src/ui/components/layouts/base_layout/top_nav/auth_dropdown.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | contexts::site_resource_context::SiteResource, 3 | serverfns::auth::create_logout_action, 4 | ui::components::common::icon::{Icon, IconSize, IconType}, 5 | }; 6 | use lemmy_client::lemmy_api_common::lemmy_db_schema::source::person::Person; 7 | use leptos::{either::Either, prelude::*}; 8 | use leptos_fluent::move_tr; 9 | use leptos_router::components::A; 10 | 11 | #[component] 12 | pub fn AuthDropdown() -> impl IntoView { 13 | let site_resource = expect_context::(); 14 | 15 | let logout_action = create_logout_action(); 16 | 17 | Effect::new(move |_| { 18 | if logout_action.version().get() > 0 { 19 | site_resource.refetch(); 20 | } 21 | }); 22 | 23 | view! { 24 | 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use cfg_if::cfg_if; 2 | use leptos_meta::MetaTags; 3 | 4 | cfg_if! { 5 | if #[cfg(feature = "ssr")] { 6 | use lemmy_ui_leptos::{App, cookie_middleware::cookie_middleware, host::get_client}; 7 | use actix_files::Files; 8 | use actix_web::{*, App as ActixApp}; 9 | use leptos::prelude::*; 10 | use leptos_actix::{generate_route_list, LeptosRoutes}; 11 | 12 | macro_rules! asset_route { 13 | ($name:ident, $file:expr) => { 14 | #[actix_web::get($file)] 15 | async fn $name( 16 | leptos_options: web::Data 17 | ) -> impl actix_web::Responder { 18 | let leptos_options = leptos_options.into_inner(); 19 | let site_root = &leptos_options.site_root; 20 | actix_files::NamedFile::open_async(format!("./{site_root}{}", $file)).await 21 | } 22 | }; 23 | } 24 | 25 | asset_route!(favicon, "/favicon.svg"); 26 | asset_route!(icons, "/icons.svg"); 27 | asset_route!(default_avatar, "/default-avatar.png"); 28 | 29 | #[actix_web::main] 30 | async fn main() -> std::io::Result<()> { 31 | // Setting this to None means we'll be using cargo-leptos and its env vars. 32 | let conf = get_configuration(None).unwrap(); 33 | let addr = conf.leptos_options.site_addr; 34 | 35 | HttpServer::new(move || { 36 | let routes = generate_route_list(App); 37 | let leptos_options = &conf.leptos_options; 38 | let site_root = &leptos_options.site_root; 39 | 40 | ActixApp::new() 41 | .route("/serverfn/{tail:.*}", leptos_actix::handle_server_fns()) 42 | .wrap(cookie_middleware()) 43 | .service(favicon) 44 | .service(icons) 45 | .leptos_routes( 46 | routes, 47 | { 48 | let options = leptos_options.clone(); 49 | move || view! { 50 | 51 | 52 | 53 | 54 | 55 | 56 | // debug where there is no visible console (mobile/live/desktop) 57 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | } 72 | } 73 | ) 74 | .app_data(web::Data::new(get_client())) 75 | .app_data(web::Data::new(leptos_options.clone())) 76 | .service(Files::new("/", site_root.as_ref())) 77 | }) 78 | .bind(&addr)? 79 | .run() 80 | .await 81 | } 82 | } else { 83 | fn main() { 84 | use lemmy_ui_leptos::App; 85 | console_error_panic_hook::set_once(); 86 | leptos::mount::mount_to_body(App); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | x-logging: &default-logging 2 | driver: "json-file" 3 | options: 4 | max-size: "50m" 5 | max-file: "4" 6 | 7 | services: 8 | proxy: 9 | image: nginx:1-alpine 10 | ports: 11 | # actual and only port facing any connection from outside 12 | # Note, change the left number if port 1236 is already in use on your system 13 | # You could use port 80 if you won't use a reverse proxy 14 | - "1236:1236" 15 | - "8536:8536" 16 | volumes: 17 | - ./nginx.conf:/etc/nginx/nginx.conf:ro,Z 18 | restart: unless-stopped 19 | depends_on: 20 | - pictrs 21 | logging: *default-logging 22 | 23 | lemmy: 24 | # use "image" to pull down an already compiled lemmy. make sure to comment out "build". 25 | image: dessalines/lemmy:0.19.9 26 | # platform: linux/x86_64 # no arm64 support. uncomment platform if using m1. 27 | # use "build" to build your local lemmy server image for development. make sure to comment out "image". 28 | # run: docker compose up --build 29 | 30 | # build: 31 | # context: ../../lemmy 32 | # dockerfile: docker/Dockerfile 33 | # args: 34 | # RUST_RELEASE_MODE: release 35 | # CARGO_BUILD_FEATURES: default 36 | # this hostname is used in nginx reverse proxy and also for lemmy ui to connect to the backend, do not change 37 | hostname: lemmy 38 | restart: unless-stopped 39 | environment: 40 | - RUST_LOG="warn,lemmy_server=debug,lemmy_api=debug,lemmy_api_common=debug,lemmy_api_crud=debug,lemmy_apub=debug,lemmy_db_schema=debug,lemmy_db_views=debug,lemmy_db_views_actor=debug,lemmy_db_views_moderator=debug,lemmy_routes=debug,lemmy_utils=debug,lemmy_websocket=debug" 41 | - RUST_BACKTRACE=full 42 | ports: 43 | # prometheus metrics can be enabled with the `prometheus` config option. they are available on 44 | # port 10002, path /metrics by default 45 | - "10002:10002" 46 | volumes: 47 | - ./lemmy.hjson:/config/config.hjson:Z 48 | depends_on: 49 | - postgres 50 | - pictrs 51 | logging: *default-logging 52 | 53 | pictrs: 54 | image: asonix/pictrs:0.5.0-rc.2 55 | # this needs to match the pictrs url in lemmy.hjson 56 | hostname: pictrs 57 | # we can set options to pictrs like this, here we set max. image size and forced format for conversion 58 | # entrypoint: /sbin/tini -- /usr/local/bin/pict-rs -p /mnt -m 4 --image-format webp 59 | environment: 60 | - PICTRS_OPENTELEMETRY_URL=http://otel:4137 61 | - PICTRS__API_KEY=API_KEY 62 | - RUST_LOG=debug 63 | - RUST_BACKTRACE=full 64 | - PICTRS__MEDIA__VIDEO_CODEC=vp9 65 | - PICTRS__MEDIA__GIF__MAX_WIDTH=256 66 | - PICTRS__MEDIA__GIF__MAX_HEIGHT=256 67 | - PICTRS__MEDIA__GIF__MAX_AREA=65536 68 | - PICTRS__MEDIA__GIF__MAX_FRAME_COUNT=400 69 | user: 991:991 70 | volumes: 71 | - ./volumes/pictrs:/mnt:Z 72 | restart: unless-stopped 73 | logging: *default-logging 74 | 75 | postgres: 76 | image: postgres:17-alpine 77 | # this needs to match the database host in lemmy.hson 78 | # Tune your settings via 79 | # https://pgtune.leopard.in.ua/#/ 80 | # You can use this technique to add them here 81 | # https://stackoverflow.com/a/30850095/1655478 82 | hostname: postgres 83 | command: 84 | [ 85 | "postgres", 86 | "-c", 87 | "session_preload_libraries=auto_explain", 88 | "-c", 89 | "auto_explain.log_min_duration=5ms", 90 | "-c", 91 | "auto_explain.log_analyze=true", 92 | "-c", 93 | "auto_explain.log_triggers=true", 94 | "-c", 95 | "track_activity_query_size=1048576", 96 | ] 97 | ports: 98 | # use a different port so it doesn't conflict with potential postgres db running on the host 99 | - "5433:5432" 100 | environment: 101 | - POSTGRES_USER=lemmy 102 | - POSTGRES_PASSWORD=password 103 | - POSTGRES_DB=lemmy 104 | volumes: 105 | - ./volumes/postgres:/var/lib/postgresql/data:Z 106 | restart: unless-stopped 107 | logging: *default-logging 108 | -------------------------------------------------------------------------------- /.woodpecker.yml: -------------------------------------------------------------------------------- 1 | variables: 2 | - &cd_workdir "cd /usr/src/app" 3 | - &rust_image "rustlang/rust:nightly-bookworm" 4 | - &install_binstall "wget -O- https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-gnu.tgz | tar -xvz -C /usr/local/cargo/bin" 5 | 6 | steps: 7 | prettier_check: 8 | image: tmknom/prettier:3.2.5 9 | commands: 10 | - prettier -c . 11 | when: 12 | - event: pull_request 13 | 14 | toml_fmt: 15 | image: tamasfe/taplo:0.9.3 16 | commands: 17 | - taplo format --check 18 | when: 19 | - event: pull_request 20 | 21 | cargo_fmt: 22 | image: *rust_image 23 | environment: 24 | # store cargo data in repo folder so that it gets cached between steps 25 | CARGO_HOME: .cargo_home 26 | commands: 27 | - rustup component add rustfmt 28 | - cargo +nightly fmt -- --check 29 | when: 30 | - event: pull_request 31 | 32 | leptos_fmt: 33 | image: *rust_image 34 | commands: 35 | - *install_binstall 36 | - cargo binstall -y leptosfmt 37 | - leptosfmt -c .leptosfmt.toml --check src 38 | when: 39 | - event: pull_request 40 | 41 | cargo_shear: 42 | image: *rust_image 43 | commands: 44 | - *install_binstall 45 | - cargo binstall -y cargo-shear 46 | - cargo shear 47 | when: 48 | - event: pull_request 49 | 50 | cargo_clippy: 51 | image: *rust_image 52 | environment: 53 | CARGO_HOME: .cargo_home 54 | commands: 55 | - apt-get update && apt-get upgrade -y && apt-get install -y pkg-config 56 | - rustup component add clippy 57 | - cargo clippy 58 | when: 59 | - event: pull_request 60 | 61 | cargo_test: 62 | image: *rust_image 63 | environment: 64 | CARGO_HOME: .cargo_home 65 | commands: 66 | - cargo test 67 | when: 68 | - event: pull_request 69 | 70 | cargo_leptos_build: 71 | image: *rust_image 72 | environment: 73 | CARGO_HOME: .cargo_home 74 | LEPTOS_TAILWIND_VERSION: v4.0.14 75 | commands: 76 | - *install_binstall 77 | - rustup target add wasm32-unknown-unknown 78 | - wget -O- https://deb.nodesource.com/setup_22.x | bash 79 | - apt-get install -y nodejs 80 | - npm i -g corepack 81 | - corepack enable 82 | - pnpm install --frozen-lockfile 83 | - cargo binstall -y cargo-leptos 84 | - cargo leptos build 85 | when: 86 | - event: pull_request 87 | 88 | # Commenting this for now, until we figure out how to do it. 89 | # playwright_check: 90 | # image: local/playwright 91 | # commands: 92 | # - *cd_workdir 93 | # when: 94 | # - event: pull_request 95 | 96 | notify_success: 97 | image: alpine:3 98 | commands: 99 | - apk add curl 100 | - "curl -H'Title: ✔️ ${CI_REPO_NAME}/${CI_COMMIT_SOURCE_BRANCH}' -d'${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci" 101 | when: 102 | - event: pull_request 103 | status: [success] 104 | 105 | notify_failure: 106 | image: alpine:3 107 | commands: 108 | - apk add curl 109 | - "curl -H'Title: ❌ ${CI_REPO_NAME}/${CI_COMMIT_SOURCE_BRANCH}' -d'${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci" 110 | when: 111 | - event: pull_request 112 | status: [failure] 113 | 114 | notify_on_tag_deploy: 115 | image: alpine:3 116 | commands: 117 | - apk add curl 118 | - "curl -H'Title: ${CI_REPO_NAME}:${CI_COMMIT_TAG} deployed' -d'${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci" 119 | when: 120 | event: tag 121 | # Commenting this out as it is only needed for puppeteer tests, which still need to be figured out 122 | # services: 123 | # database: 124 | # image: pgautoupgrade/pgautoupgrade:17-alpine 125 | # environment: 126 | # POSTGRES_USER: lemmy 127 | # POSTGRES_PASSWORD: password 128 | # POSTGRES_DB: lemmy 129 | # when: 130 | # - event: pull_request 131 | 132 | # lemmy: 133 | # image: dessalines/lemmy:0.19.9 134 | # environment: 135 | # LEMMY_CONFIG_LOCATION: /woodpecker/src/github.com/LemmyNet/lemmy-ui-leptos/end2end/lemmy.hjson 136 | # when: 137 | # - event: pull_request 138 | -------------------------------------------------------------------------------- /src/ui/components/post/post_listing/thumbnail.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | ui::components::common::icon::{Icon, IconSize, IconType}, 3 | utils::{is_image, is_video}, 4 | }; 5 | use lemmy_client::lemmy_api_common::lemmy_db_schema::newtypes::PostId; 6 | use leptos::{either::Either, prelude::*}; 7 | use leptos_router::components::A; 8 | use std::sync::Arc; 9 | use tailwind_fuse::{AsTailwindClass, TwVariant}; 10 | 11 | #[derive(TwVariant)] 12 | enum ThumbnailIconType { 13 | #[tw(default, class = "m-auto")] 14 | NoImage, 15 | #[tw(class = "rounded-bl bg-slate-600/75 block text-white/75 absolute right-0 top-0")] 16 | Image, 17 | } 18 | 19 | #[derive(TwVariant)] 20 | #[tw(class = "border-2 border-neutral size-20 rounded-lg p-0 grid-in-thumbnail")] 21 | enum ThumbnailWrapperType { 22 | #[tw(default, class = "flex")] 23 | NoImage, 24 | #[tw(class = "relative overflow-hidden")] 25 | Image, 26 | } 27 | 28 | #[derive(Clone)] 29 | struct ThumbnailData { 30 | pub icon: IconType, 31 | pub icon_size: IconSize, 32 | pub wrapper_class: Arc, 33 | pub icon_class: Arc, 34 | } 35 | 36 | #[component] 37 | pub fn Thumbnail( 38 | url: Memo>>, 39 | image_url: Memo>>, 40 | #[prop(into)] has_embed_url: Signal, 41 | id: PostId, 42 | ) -> impl IntoView { 43 | let thumbnail_data = Signal::derive(move || { 44 | // When there is a thumbnail URL, use the normal size icon and use classes to display the icon in the upper right corner 45 | let (icon_size, icon_class, wrapper_class) = if image_url.read().is_some() { 46 | ( 47 | IconSize::Normal, 48 | ThumbnailIconType::Image.as_class().into(), 49 | ThumbnailWrapperType::Image.as_class().into(), 50 | ) 51 | } 52 | // When there isn't a thumbnail URL, use a larger icon that gets centered in the thumbnail 53 | else { 54 | ( 55 | IconSize::ExtraLarge, 56 | ThumbnailIconType::NoImage.as_class().into(), 57 | ThumbnailWrapperType::NoImage.as_class().into(), 58 | ) 59 | }; 60 | 61 | let icon = match url.read().as_ref().map(Arc::clone) { 62 | url if *has_embed_url.read() || url.as_ref().is_some_and(|url| is_video(url.as_ref())) => { 63 | IconType::Video 64 | } 65 | // Video URLs are handled in the previous case, so if the URL isn't an image, it must be an external link 66 | Some(url) if !is_image(url.as_ref()) => IconType::ExternalLink, 67 | // Since there are already cases for video and external links URLs, the only other possible type of URL it can be is an image 68 | Some(_) => IconType::Image, 69 | None => IconType::Comments, 70 | }; 71 | 72 | ThumbnailData { 73 | icon, 74 | icon_size, 75 | wrapper_class, 76 | icon_class, 77 | } 78 | }); 79 | 80 | move || { 81 | let wrapper_class = Arc::clone(&thumbnail_data.read().wrapper_class); 82 | 83 | if matches!( 84 | thumbnail_data.read().icon, 85 | IconType::ExternalLink | IconType::Comments 86 | ) { 87 | Either::Left(view! { 88 | 97 | 98 | 99 | }) 100 | } else { 101 | Either::Right(view! { 102 | 106 | }) 107 | } 108 | } 109 | } 110 | 111 | #[component] 112 | fn Inner( 113 | image_url: Memo>>, 114 | thumbnail_data: Signal, 115 | ) -> impl IntoView { 116 | view! { 117 | {move || { 118 | image_url 119 | .read() 120 | .as_ref() 121 | .map(Arc::clone) 122 | .map(|thumbnail| { 123 | view! { } 124 | }) 125 | }} 126 | 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/ui/components/modals/report_modal.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | serverfns::{comments::create_report_comment_action, posts::create_report_post_action}, 3 | ui::components::common::{ 4 | icon::{Icon, IconType}, 5 | text_input::TextInput, 6 | }, 7 | utils::{ 8 | create_user_apub_name, 9 | types::{PostOrCommentId, ReportModalData}, 10 | }, 11 | }; 12 | use leptos::{ 13 | form::ActionForm, 14 | html::{Dialog, Form as FormElement}, 15 | prelude::*, 16 | }; 17 | use leptos_fluent::{move_tr, tr}; 18 | 19 | #[component] 20 | fn ReportForm( 21 | creator_name: Signal, 22 | creator_actor_id: Signal, 23 | post_or_comment_id: Signal, 24 | ) -> impl IntoView { 25 | let content_type = Signal::derive(move || { 26 | if matches!(post_or_comment_id.get(), PostOrCommentId::Post(_)) { 27 | tr!("report-post") 28 | } else { 29 | tr!("report-comment") 30 | } 31 | }); 32 | let creator_of_start = Signal::derive(move || { 33 | if matches!(post_or_comment_id.get(), PostOrCommentId::Post(_)) { 34 | tr!("creator-of-post") 35 | } else { 36 | tr!("creator-of-comment") 37 | } 38 | }); 39 | 40 | view! { 41 | 48 |

    {move || content_type.get()}

    49 |
    50 | {move || creator_of_start.get()} 51 | ": " 52 | 53 | {move || { create_user_apub_name(&creator_name.read(), &creator_actor_id.read()) }} 54 | 55 |
    56 | 57 | 64 | 72 | } 73 | } 74 | 75 | #[component] 76 | pub fn ReportModal( 77 | dialog_ref: NodeRef, 78 | modal_data: ReadSignal, 79 | ) -> impl IntoView { 80 | let post_or_comment_id = Signal::derive(move || modal_data.read().post_or_comment_id); 81 | let creator_actor_id = Signal::derive(move || modal_data.read().creator_actor_id.clone()); 82 | let creator_name = Signal::derive(move || modal_data.read().creator_name.clone()); 83 | 84 | let form_ref = NodeRef::::new(); 85 | let close = move |_| { 86 | if let (Some(form_ref), Some(dialog_ref)) = (form_ref.get(), dialog_ref.get()) { 87 | form_ref.reset(); 88 | dialog_ref.close(); 89 | } 90 | }; 91 | 92 | let report_post_action = create_report_post_action(); 93 | Effect::new(move |_| { 94 | if report_post_action.value().get().is_some() { 95 | close(()); 96 | } 97 | }); 98 | 99 | let report_comment_action = create_report_comment_action(); 100 | Effect::new(move |_| { 101 | if report_comment_action.value().get().is_some() { 102 | close(()); 103 | } 104 | }); 105 | 106 | view! { 107 | 116 | 121 | 126 | 127 | } 128 | } 129 | > 130 | 131 | 136 | 137 | 138 | 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::empty_docs)] 2 | 3 | mod constants; 4 | mod contexts; 5 | #[cfg(feature = "ssr")] 6 | pub mod cookie_middleware; 7 | pub mod host; 8 | mod serverfns; 9 | mod ui; 10 | mod utils; 11 | use crate::{ 12 | contexts::{ 13 | site_resource_context::provide_site_resource_context, 14 | theme_resource_context::provide_theme_resource_context, 15 | }, 16 | ui::components::{ 17 | communities::communities_page::CommunitiesPage, 18 | home::home_page::HomePage, 19 | layouts::base_layout::BaseLayout, 20 | login::login_page::LoginPage, 21 | post::post_page::PostPage, 22 | }, 23 | }; 24 | use contexts::site_resource_context::SiteResource; 25 | use fluent_templates::static_loader; 26 | use leptos::{html::Dialog, prelude::*}; 27 | use leptos_fluent::leptos_fluent; 28 | use leptos_meta::*; 29 | use leptos_router::{components::*, *}; 30 | use ui::components::{ 31 | communities::create_community_page::CreateCommunityPage, 32 | modals::ReportModal, 33 | }; 34 | use utils::types::{ReportModalData, ReportModalNode}; 35 | 36 | static_loader! { 37 | static TRANSLATIONS = { 38 | locales: "./locales", 39 | fallback_language: "en", 40 | }; 41 | } 42 | 43 | #[component] 44 | fn I18n(children: Children) -> impl IntoView { 45 | leptos_fluent! { 46 | children: children(), 47 | translations: [TRANSLATIONS], 48 | locales: "./locales", 49 | check_translations: "./src/**/*.rs", 50 | sync_html_tag_lang: true, 51 | initial_language_from_accept_language_header: true, 52 | cookie_attrs: "SameSite=Strict; Secure;", 53 | initial_language_from_cookie: true, 54 | set_language_to_cookie: true, 55 | initial_language_from_navigator: true 56 | } 57 | } 58 | 59 | #[component] 60 | fn AppRoutes() -> impl IntoView { 61 | let site_resource = expect_context::(); 62 | let user_is_logged_in = move || { 63 | site_resource.read().as_ref().map(|response| { 64 | response 65 | .as_ref() 66 | .ok() 67 | .is_some_and(|response| response.my_user.is_some()) 68 | }) 69 | }; 70 | 71 | let is_routing = RwSignal::new(false); 72 | 73 | let (report_modal_data, set_report_modal_data) = 74 | RwSignal::new(ReportModalData::default()).split(); 75 | let report_modal = ReportModalNode(NodeRef::::new()); 76 | provide_context(set_report_modal_data); 77 | provide_context(report_modal); 78 | 79 | view! { 80 | 81 | 82 | 83 | 84 | 85 | 86 | <Routes fallback=NotFound> 87 | <ParentRoute path=path!("") view=BaseLayout ssr=SsrMode::Async> 88 | <Route path=path!("") view=HomePage /> 89 | 90 | <Route path=path!("create_post") view=CommunitiesPage /> 91 | <Route path=path!("post/:id") view=PostPage /> 92 | 93 | <Route path=path!("search") view=CommunitiesPage /> 94 | <Route path=path!("communities") view=CommunitiesPage /> 95 | <Route path=path!("create_community") view=CreateCommunityPage /> 96 | <Route path=path!("c/:id") view=CommunitiesPage /> 97 | <ProtectedRoute 98 | path=path!("login") 99 | view=LoginPage 100 | redirect_path=move || "/" 101 | condition=move || user_is_logged_in().map(|show| !show) 102 | /> 103 | 104 | <ProtectedRoute 105 | path=path!("signup") 106 | view=CommunitiesPage 107 | redirect_path=move || "/" 108 | condition=move || user_is_logged_in().map(|show| !show) 109 | /> 110 | 111 | <Route path=path!("inbox") view=CommunitiesPage /> 112 | <Route path=path!("settings") view=CommunitiesPage /> 113 | <Route path=path!("u/:id") view=CommunitiesPage /> 114 | <Route path=path!("saved") view=CommunitiesPage /> 115 | 116 | <Route path=path!("modlog") view=CommunitiesPage /> 117 | <Route path=path!("instances") view=CommunitiesPage /> 118 | <Route path=path!("legal") view=CommunitiesPage /> 119 | </ParentRoute> 120 | </Routes> 121 | 122 | <ReportModal dialog_ref=report_modal.0 modal_data=report_modal_data /> 123 | </Router> 124 | } 125 | } 126 | 127 | #[component] 128 | pub fn App() -> impl IntoView { 129 | provide_meta_context(); 130 | provide_site_resource_context(); 131 | provide_theme_resource_context(); 132 | 133 | view! { 134 | <I18n> 135 | <AppRoutes /> 136 | </I18n> 137 | } 138 | } 139 | 140 | #[component] 141 | fn NotFound() -> impl IntoView { 142 | #[cfg(feature = "ssr")] 143 | { 144 | let resp = expect_context::<leptos_actix::ResponseOptions>(); 145 | resp.set_status(actix_web::http::StatusCode::NOT_FOUND); 146 | } 147 | 148 | view! { <h1>"Not Found"</h1> } 149 | } 150 | 151 | #[cfg(feature = "hydrate")] 152 | #[wasm_bindgen::prelude::wasm_bindgen] 153 | pub fn hydrate() { 154 | console_error_panic_hook::set_once(); 155 | leptos::mount::hydrate_body(App); 156 | } 157 | -------------------------------------------------------------------------------- /src/ui/components/common/content_actions.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | contexts::site_resource_context::SiteResource, 3 | serverfns::users::create_block_user_action, 4 | ui::components::common::{ 5 | fedilink::Fedilink, 6 | icon::{Icon, IconType}, 7 | }, 8 | utils::{ 9 | traits::ToStr, 10 | types::{PostOrCommentId, ServerActionFn}, 11 | }, 12 | }; 13 | use hide_post_button::HidePostButton; 14 | use lemmy_client::lemmy_api_common::lemmy_db_schema::source::person::Person; 15 | use leptos::prelude::*; 16 | use leptos_fluent::move_tr; 17 | use leptos_router::components::A; 18 | use report_button::ReportButton; 19 | use tailwind_fuse::tw_join; 20 | 21 | mod hide_post_button; 22 | mod report_button; 23 | 24 | #[component] 25 | pub fn ContentActions<SA>( 26 | post_or_comment_id: PostOrCommentId, 27 | saved: Signal<bool>, 28 | save_action: ServerAction<SA>, 29 | #[prop(into)] creator: Signal<Person>, 30 | ap_id: String, 31 | ) -> impl IntoView 32 | where 33 | SA: ServerActionFn, 34 | { 35 | let site_resource = expect_context::<SiteResource>(); 36 | 37 | let save_content_label = if matches!(post_or_comment_id, PostOrCommentId::Post(_)) { 38 | move_tr!("save-post") 39 | } else { 40 | move_tr!("save-comment") 41 | }; 42 | let save_icon = Signal::derive(move || { 43 | if saved.get() { 44 | IconType::SaveFilled 45 | } else { 46 | IconType::Save 47 | } 48 | }); 49 | let crosspost_label = move_tr!("crosspost"); 50 | 51 | let block_user_action = create_block_user_action(); 52 | 53 | view! { 54 | <Fedilink href=ap_id.to_string() /> 55 | <Transition> 56 | {move || Suspend::new(async move { 57 | site_resource 58 | .await 59 | .map(|site| { 60 | let logged_in_user_id = site.my_user.map(|u| u.local_user_view.person.id); 61 | logged_in_user_id 62 | .map(|user_id| { 63 | 64 | view! { 65 | <ActionForm action=save_action attr:class="flex items-center"> 66 | <input type="hidden" name="id" value=post_or_comment_id.get_id() /> 67 | <input type="hidden" name="save" value=move || (!saved.get()).to_str() /> 68 | <button 69 | type="submit" 70 | title=save_content_label 71 | aria-label=save_content_label 72 | 73 | class=move || { 74 | tw_join!( 75 | "disabled:cursor-not-allowed disabled:text-neutral-content", saved.get() 76 | .then_some("text-accent") 77 | ) 78 | } 79 | 80 | disabled=move || save_action.pending().get() 81 | > 82 | <Icon icon=save_icon /> 83 | 84 | </button> 85 | </ActionForm> 86 | {(matches!(post_or_comment_id, PostOrCommentId::Post(_))) 87 | .then(|| { 88 | view! { 89 | <A 90 | href="/create_post" 91 | attr:title=crosspost_label 92 | attr:aria-label=crosspost_label 93 | > 94 | <Icon icon=IconType::Crosspost /> 95 | </A> 96 | } 97 | })} 98 | 99 | <div class="dropdown"> 100 | <div tabindex="0" role="button"> 101 | <Icon icon=IconType::VerticalDots /> 102 | </div> 103 | <menu 104 | tabindex="0" 105 | class="menu dropdown-content z-1 bg-base-100 rounded-box shadow-sm" 106 | > 107 | <Show when=move || { 108 | user_id == creator.read_untracked().id 109 | }> 110 | {if let PostOrCommentId::Post(id) = post_or_comment_id { 111 | Some(view! { <HidePostButton id=id /> }) 112 | } else { 113 | None 114 | }} <li> 115 | <ReportButton creator=creator post_or_comment_id=post_or_comment_id /> 116 | </li> <li> 117 | <ActionForm action=block_user_action> 118 | <input type="hidden" name="id" value=creator.read_untracked().id.0 /> 119 | <input type="hidden" name="block" value="true" /> 120 | <button class="text-xs whitespace-nowrap" type="submit"> 121 | <Icon icon=IconType::Block class="inline-block" /> 122 | " " 123 | {move_tr!("block-user")} 124 | </button> 125 | </ActionForm> 126 | </li> 127 | </Show> 128 | </menu> 129 | </div> 130 | } 131 | }) 132 | }) 133 | })} 134 | </Transition> 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /style/tailwind.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss" source("../src"); 2 | 3 | @plugin "daisyui" { 4 | themes: 5 | light --default, 6 | dark --prefersdark, 7 | retro; 8 | } 9 | 10 | @custom-variant aria-current-page (&[aria-current="page"]); 11 | 12 | @theme { 13 | --animate-color-cycle: color-cycle 6s linear infinite; 14 | 15 | @keyframes color-cycle { 16 | 0%, 17 | 100% { 18 | color: var(--color-red-400); 19 | } 20 | 21 | 6% { 22 | color: var(--color-orange-400); 23 | } 24 | 25 | 12% { 26 | color: var(--color-amber-400); 27 | } 28 | 29 | 18% { 30 | color: var(--color-yellow-400); 31 | } 32 | 33 | 24% { 34 | color: var(--color-lime-400); 35 | } 36 | 37 | 30% { 38 | color: var(--color-green-400); 39 | } 40 | 41 | 36% { 42 | color: var(--color-emerald-400); 43 | } 44 | 45 | 42% { 46 | color: var(--color-teal-400); 47 | } 48 | 49 | 48% { 50 | color: var(--color-cyan-400); 51 | } 52 | 53 | 54% { 54 | color: var(--color-sky-400); 55 | } 56 | 57 | 60% { 58 | color: var(--color-blue-400); 59 | } 60 | 61 | 66% { 62 | color: var(--color-indigo-400); 63 | } 64 | 65 | 72% { 66 | color: var(--color-violet-400); 67 | } 68 | 69 | 78% { 70 | color: var(--color-purple-400); 71 | } 72 | 73 | 84% { 74 | color: var(--color-fuchsia-400); 75 | } 76 | 77 | 90% { 78 | color: var(--color-pink-400); 79 | } 80 | 81 | 95% { 82 | color: var(--color-rose-400); 83 | } 84 | } 85 | } 86 | 87 | .grid-areas-post-listing { 88 | grid-template-areas: 89 | "title title thumbnail" 90 | "to to thumbnail" 91 | "vote actions thumbnail"; 92 | grid-template-rows: 1fr 1fr 1fr; 93 | grid-template-columns: min-content auto min-content; 94 | 95 | @variant sm { 96 | grid-template-areas: 97 | "vote thumbnail title" 98 | "vote thumbnail to" 99 | "vote thumbnail actions"; 100 | grid-template-rows: 1fr 2fr 1fr; 101 | grid-template-columns: min-content min-content auto; 102 | } 103 | } 104 | 105 | .grid-in-vote { 106 | grid-area: vote; 107 | } 108 | 109 | .grid-in-title { 110 | grid-area: title; 111 | } 112 | 113 | .grid-in-to { 114 | grid-area: to; 115 | } 116 | 117 | .grid-in-thumbnail { 118 | grid-area: thumbnail; 119 | } 120 | 121 | .grid-in-actions { 122 | grid-area: actions; 123 | } 124 | 125 | .markdown-content { 126 | @apply bg-base-200 mt-4 p-5 rounded-sm space-y-4; 127 | 128 | h1 { 129 | @apply text-2xl font-black; 130 | } 131 | 132 | h2 { 133 | @apply text-2xl font-bold; 134 | } 135 | 136 | h3 { 137 | @apply text-xl font-black; 138 | } 139 | 140 | h4 { 141 | @apply text-xl font-bold; 142 | } 143 | 144 | h5 { 145 | @apply text-lg font-black; 146 | } 147 | 148 | h6 { 149 | @apply text-lg font-bold; 150 | } 151 | 152 | code { 153 | @apply font-mono bg-neutral text-neutral-content p-1 rounded-md not-italic font-medium slashed-zero text-sm; 154 | } 155 | 156 | blockquote { 157 | @apply ps-1.5 pe-7 py-2 bg-base-300 border-s-4 border-info font-light text-sm italic relative rounded-se-md rounded-ee-md break-all; 158 | 159 | code { 160 | @apply text-xs font-normal; 161 | } 162 | 163 | strong { 164 | @apply font-semibold; 165 | } 166 | 167 | em { 168 | @apply not-italic; 169 | } 170 | 171 | &:not(blockquote blockquote)::after { 172 | @apply bg-info block size-6 absolute end-1 bottom-1; 173 | content: " "; 174 | mask-image: url("/icons.svg#css-quote"); 175 | -webkit-mask-image: url("/icons.svg#css-quote"); 176 | } 177 | } 178 | 179 | ul { 180 | @apply list-inside list-disc; 181 | } 182 | 183 | ol { 184 | @apply list-inside list-decimal; 185 | } 186 | 187 | li { 188 | @apply my-1.5; 189 | } 190 | 191 | a { 192 | @apply text-accent font-medium; 193 | 194 | &:hover { 195 | @apply underline underline-offset-2; 196 | } 197 | } 198 | 199 | img { 200 | @apply max-w-full h-auto max-h-[40vh]; 201 | 202 | &[title^="emoji"] { 203 | @apply inline w-16; 204 | } 205 | } 206 | 207 | hr { 208 | @apply border-secondary; 209 | } 210 | 211 | table { 212 | @apply table w-auto mx-auto shadow-lg rounded-md bg-base-100; 213 | } 214 | 215 | thead { 216 | tr { 217 | @apply bg-base-300; 218 | } 219 | 220 | th:not(:first-child) { 221 | @apply border-accent border-l-2; 222 | } 223 | } 224 | 225 | tbody { 226 | tr { 227 | @apply border-t-2 border-accent; 228 | } 229 | 230 | td:not(:first-child) { 231 | @apply border-accent border-l-2; 232 | } 233 | } 234 | 235 | summary { 236 | @apply flex justify-start cursor-pointer break-all; 237 | 238 | &::before { 239 | @apply bg-warning-content block size-6 min-w-6 min-h-6 me-1; 240 | content: " "; 241 | mask-image: url("/icons.svg#css-warning"); 242 | -webkit-mask-image: url("/icons.svg#css-warning"); 243 | } 244 | 245 | &::after { 246 | @apply bg-warning-content block size-6 min-w-6 min-h-6 ms-auto transition-transform; 247 | content: " "; 248 | mask-image: url("/icons.svg#css-caret"); 249 | -webkit-mask-image: url("/icons.svg#css-caret"); 250 | } 251 | } 252 | 253 | details { 254 | @apply bg-warning text-warning-content p-3 rounded-md; 255 | 256 | &[open] > summary { 257 | @apply mb-2; 258 | 259 | &::after { 260 | @apply rotate-180; 261 | } 262 | } 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/ui/components/common/sidebar.rs: -------------------------------------------------------------------------------- 1 | use crate::ui::components::common::icon::{Icon, IconSize, IconType}; 2 | use leptos::{either::Either, prelude::*}; 3 | use leptos_fluent::move_tr; 4 | use pretty_num::PrettyNumber; 5 | use sidebar_data::SidebarData; 6 | use team_member_card::TeamMemberCard; 7 | use user_stat_row::UserStatRow; 8 | 9 | pub mod sidebar_data; 10 | mod team_member_card; 11 | mod user_stat_row; 12 | 13 | #[component] 14 | pub fn Sidebar(data: SidebarData) -> impl IntoView { 15 | let today = move_tr!("today"); 16 | let past_week = move_tr!("past-week"); 17 | let past_month = move_tr!("past-month"); 18 | let past_6_months = move_tr!("past-6-months"); 19 | let time_frame = move_tr!("time-frame"); 20 | let all_time = move_tr!("all-time"); 21 | let local_subscribers = move_tr!("local-subscribers"); 22 | let subscribers = move_tr!("subscribers"); 23 | 24 | // Extract the common elements 25 | let ( 26 | heading, 27 | team_heading, 28 | team, 29 | name, 30 | description, 31 | posts, 32 | comments, 33 | users_today, 34 | users_week, 35 | users_month, 36 | users_6_months, 37 | ) = match data { 38 | SidebarData::Site(ref s) => ( 39 | move_tr!("instance-stats"), 40 | move_tr!("admins"), 41 | s.admins.clone(), 42 | s.site.name.clone(), 43 | s.site.description.clone(), 44 | s.counts.posts, 45 | s.counts.comments, 46 | s.counts.users_active_day, 47 | s.counts.users_active_week, 48 | s.counts.users_active_month, 49 | s.counts.users_active_half_year, 50 | ), 51 | SidebarData::Community(ref c) => ( 52 | move_tr!("community-stats"), 53 | move_tr!("moderators"), 54 | c.moderators.clone(), 55 | c.community.name.clone(), 56 | c.community.description.clone(), 57 | c.counts.posts, 58 | c.counts.comments, 59 | c.counts.users_active_day, 60 | c.counts.users_active_week, 61 | c.counts.users_active_month, 62 | c.counts.users_active_half_year, 63 | ), 64 | }; 65 | 66 | view! { 67 | <div class="card w-full mb-3 bg-base-200"> 68 | <div class="card-body"> 69 | <h2 class="card-title">{name}</h2> 70 | {description.map(|description| view! { <p>{description}</p> })} 71 | <section aria-labelledby="instance-stats-heading" class="my-4"> 72 | <h3 id="instance-stats-heading" class="text-2xl font-bold mb-2"> 73 | {heading} 74 | </h3> 75 | <div class="font-semibold flex flex-wrap *:m-1.5"> 76 | <div> 77 | <Icon icon=IconType::Posts size=IconSize::Large class="inline" /> 78 | {posts.pretty_format()} 79 | " " 80 | <span class="text-sm">{move_tr!("posts")}</span> 81 | </div> 82 | <div> 83 | <Icon icon=IconType::Comments size=IconSize::Large class="inline" /> 84 | {comments.pretty_format()} 85 | " " 86 | <span class="text-sm">{move_tr!("comments")}</span> 87 | </div> 88 | 89 | {if let SidebarData::Site(ref s) = data { 90 | Some( 91 | view! { 92 | <div> 93 | <Icon icon=IconType::Communities size=IconSize::Large class="inline" /> 94 | {s.counts.communities.pretty_format()} 95 | " " 96 | <span class="text-sm">{move_tr!("communities")}</span> 97 | </div> 98 | }, 99 | ) 100 | } else { 101 | None 102 | }} 103 | </div> 104 | <table class="w-full mt-3 table shadow-lg"> 105 | <caption class="text-lg font-semibold whitespace-nowrap align-middle text-start mb-2"> 106 | <Icon icon=IconType::Users size=IconSize::Large class="inline me-2" /> 107 | {move_tr!("active-users")} 108 | </caption> 109 | <thead> 110 | <tr class="font-extrabold text-sm bg-base-300 *:p-3"> 111 | <th class="text-start" scope="col"> 112 | {time_frame} 113 | </th> 114 | <th class="text-center" scope="col"> 115 | {move_tr!("count")} 116 | </th> 117 | </tr> 118 | </thead> 119 | <tbody class="bg-base-100"> 120 | <UserStatRow text=today count=users_today /> 121 | <UserStatRow text=past_week count=users_week /> 122 | <UserStatRow text=past_month count=users_month /> 123 | <UserStatRow text=past_6_months count=users_6_months /> 124 | {match data { 125 | SidebarData::Site(ref s) => { 126 | Either::Left(view! { <UserStatRow text=all_time count=s.counts.users /> }) 127 | } 128 | SidebarData::Community(ref c) => { 129 | Either::Right( 130 | view! { 131 | <UserStatRow text=local_subscribers count=c.counts.subscribers_local /> 132 | <UserStatRow text=subscribers count=c.counts.subscribers /> 133 | }, 134 | ) 135 | } 136 | }} 137 | </tbody> 138 | </table> 139 | </section> 140 | <section aria-labelledby="instances-admins-heading"> 141 | <h3 id="instance-admins-heading" class="text-2xl font-bold mb-2"> 142 | {team_heading} 143 | </h3> 144 | <ul class="flex flex-wrap gap-2 my-4"> 145 | <For each=move || team.clone() key=|member| member.id let:member> 146 | <TeamMemberCard person=member /> 147 | </For> 148 | </ul> 149 | </section> 150 | </div> 151 | </div> 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | <svg 2 | xmlns="http://www.w3.org/2000/svg" 3 | width="1024" 4 | height="1024" 5 | viewBox="0 0 1024 1024" 6 | version="1.1"> 7 | <g transform="translate(0,-26.066658)"> 8 | <path 9 | style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:28;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" 10 | d="m 167.03908,270.78735 c -0.94784,-0.002 -1.8939,0.004 -2.83789,0.0215 -4.31538,0.0778 -8.58934,0.3593 -12.8125,0.8457 -33.78522,3.89116 -64.215716,21.86394 -82.871086,53.27344 -18.27982,30.77718 -22.77749,64.66635 -13.46094,96.06837 9.31655,31.40203 31.88488,59.93174 65.296886,82.5332 0.20163,0.13618 0.40678,0.26709 0.61523,0.39258 28.65434,17.27768 57.18167,28.93179 87.74218,34.95508 -0.74566,12.61339 -0.72532,25.5717 0.082,38.84375 2.43989,40.10943 16.60718,77.03742 38.0957,109.67187 l -77.00781,31.4375 c -8.30605,3.25932 -12.34178,12.68234 -8.96967,20.94324 3.37211,8.2609 12.84919,12.16798 21.06342,8.68371 l 84.69727,-34.57617 c 15.70675,18.72702 33.75346,35.68305 53.12109,50.57032 0.74013,0.56891 1.4904,1.12236 2.23437,1.68554 l -49.61132,65.69141 c -5.45446,7.0474 -4.10058,17.19288 3.01098,22.5634 7.11156,5.37052 17.24028,3.89649 22.52612,-3.27824 l 50.38672,-66.71876 c 27.68572,17.53469 57.07524,31.20388 86.07227,40.25196 14.88153,27.28008 43.96965,44.64648 77.58789,44.64648 33.93762,0 63.04252,-18.68693 77.80082,-45.4375 28.7072,-9.21295 57.7527,-22.93196 85.1484,-40.40234 l 51.0977,67.66016 c 5.2858,7.17473 15.4145,8.64876 22.5261,3.27824 7.1115,-5.37052 8.4654,-15.516 3.011,-22.5634 l -50.3614,-66.68555 c 0.334,-0.25394 0.6727,-0.50077 1.0059,-0.75586 19.1376,-14.64919 37.0259,-31.28581 52.7031,-49.63476 l 82.5625,33.70507 c 8.2143,3.48427 17.6913,-0.42281 21.0634,-8.68371 3.3722,-8.2609 -0.6636,-17.68392 -8.9696,-20.94324 l -74.5391,-30.42773 c 22.1722,-32.82971 37.0383,-70.03397 40.1426,-110.46094 1.0253,-13.35251 1.2292,-26.42535 0.6387,-39.17578 30.3557,-6.05408 58.7164,-17.66833 87.2011,-34.84375 0.2085,-0.12549 0.4136,-0.2564 0.6153,-0.39258 33.412,-22.60147 55.9803,-51.13117 65.2968,-82.5332 9.3166,-31.40202 4.8189,-65.29118 -13.4609,-96.06837 -18.6553,-31.40951 -49.0859,-49.38228 -82.8711,-53.27344 -4.2231,-0.4864 -8.4971,-0.76791 -12.8125,-0.8457 -30.2077,-0.54448 -62.4407,8.82427 -93.4316,26.71484 -22.7976,13.16063 -43.3521,33.31423 -59.4375,55.30469 -44.9968,-25.75094 -103.5444,-40.25065 -175.4785,-41.43945 -6.4522,-0.10663 -13.0125,-0.10696 -19.67974,0.002 -80.18875,1.30929 -144.38284,16.5086 -192.87109,43.9922 -0.11914,-0.19111 -0.24287,-0.37932 -0.37109,-0.56446 -16.29,-22.764 -37.41085,-43.73706 -60.89649,-57.29493 -30.02247,-17.33149 -61.21051,-26.66489 -90.59375,-26.73633 z" /> 11 | <path 12 | style="display:inline;opacity:1;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:28;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" 13 | d="m 716.85595,362.96478 c 15.29075,-21.36763 35.36198,-41.10921 56.50979,-53.31749 66.66377,-38.48393 137.02617,-33.22172 170.08018,22.43043 33.09493,55.72093 14.98656,117.48866 -47.64399,159.85496 -31.95554,19.26819 -62.93318,30.92309 -97.22892,35.54473 M 307.14407,362.96478 C 291.85332,341.59715 271.78209,321.85557 250.63429,309.64729 183.97051,271.16336 113.60811,276.42557 80.554051,332.07772 47.459131,387.79865 65.56752,449.56638 128.19809,491.93268 c 31.95554,19.26819 62.93319,30.92309 97.22893,35.54473" /> 14 | <path 15 | style="display:inline;opacity:1;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:28;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" 16 | d="M 801.23205,576.8699 C 812.73478,427.06971 720.58431,321.98291 511.99999,325.38859 303.41568,328.79426 213.71393,428.0311 222.76794,576.8699 c 8.64289,142.08048 176.80223,246.40388 288.12038,246.40388 111.31815,0 279.45076,-104.5447 290.34373,-246.40388 z" /> 17 | <path 18 | style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" 19 | d="m 610.4991,644.28932 c 0,23.11198 18.70595,41.84795 41.78091,41.84795 23.07495,0 41.7809,-18.73597 41.7809,-41.84795 0,-23.112 -18.70594,-41.84796 -41.7809,-41.84796 -23.07496,0 -41.78091,18.73596 -41.78091,41.84796 z m -280.56002,0 c 0,23.32492 18.87829,42.23352 42.16586,42.23352 23.28755,0 42.16585,-18.9086 42.16585,-42.23352 0,-23.32494 -18.87829,-42.23353 -42.16585,-42.23353 -23.28757,0 -42.16586,18.90859 -42.16586,42.23353 z" /> 20 | <path 21 | style="display:inline;opacity:1;fill:none;stroke:#000000;stroke-width:32;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" 22 | d="m 339.72919,769.2467 -54.54422,72.22481 m 399.08582,-72.22481 54.54423,72.22481 M 263.68341,697.82002 175.92752,733.64353 m 579.85765,-35.82351 87.7559,35.82351" /> 23 | <path 24 | style="display:inline;opacity:1;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:28;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" 25 | d="m 512.00082,713.08977 c -45.86417,0 -75.13006,31.84485 -74.14159,71.10084 1.07048,42.51275 32.46865,71.10323 74.14159,71.10323 41.67296,0 74.05118,-32.99608 74.14161,-71.10323 0.0932,-39.26839 -28.27742,-71.10084 -74.14161,-71.10084 z" /> 26 | </g> 27 | </svg> -------------------------------------------------------------------------------- /src/ui/components/post/post_listing.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | contexts::site_resource_context::SiteResource, 3 | serverfns::posts::{create_hide_post_action, create_save_post_action, create_vote_post_action}, 4 | ui::components::common::{ 5 | community_listing::CommunityListing, 6 | content_actions::ContentActions, 7 | creator_listing::CreatorListing, 8 | icon::{Icon, IconSize, IconType}, 9 | markdown_content::MarkdownContent, 10 | vote_buttons::VoteButtons, 11 | }, 12 | utils::{ 13 | get_time_since, 14 | is_image, 15 | types::{Hidden, PostOrCommentId}, 16 | }, 17 | }; 18 | use components::A; 19 | use hooks::use_matched; 20 | use lemmy_client::lemmy_api_common::lemmy_db_views::structs::*; 21 | use leptos::prelude::*; 22 | use leptos_router::*; 23 | use std::sync::Arc; 24 | use thumbnail::Thumbnail; 25 | 26 | mod thumbnail; 27 | 28 | #[component] 29 | pub fn PostListing(post_view: PostView) -> impl IntoView { 30 | let post_state = RwSignal::new(post_view.clone()); 31 | 32 | // These post fields cannot change, so no need for signals 33 | let id = post_view.post.id; 34 | let ap_id = post_view.post.ap_id.to_string(); 35 | let creator = Signal::stored(post_view.creator); 36 | let community = post_view.community; 37 | 38 | let post_body = Memo::new(move |_| post_state.read().post.body.as_deref().map(Arc::<str>::from)); 39 | 40 | let post_url = Memo::new(move |_| { 41 | post_state 42 | .read() 43 | .post 44 | .url 45 | .as_deref() 46 | .map(AsRef::as_ref) 47 | .map(Arc::<str>::from) 48 | }); 49 | 50 | let image_url = Memo::new(move |_| { 51 | post_state 52 | .read() 53 | .post 54 | .thumbnail_url 55 | .as_deref() 56 | .map(AsRef::as_ref) 57 | .map(Arc::from) 58 | .or_else(|| post_url.get().filter(|url| is_image(url.as_ref()))) // Fall back to post url if no thumbnail, but only if it is an image url 59 | }); 60 | 61 | let embed_video_url = Memo::new(move |_| { 62 | post_state 63 | .read() 64 | .post 65 | .embed_video_url 66 | .as_ref() 67 | .map(ToString::to_string) 68 | }); 69 | let has_embed_video_url = Signal::derive(move || embed_video_url.read().is_some()); 70 | 71 | let time_since_post = Memo::new(move |_| get_time_since(&post_state.read().post.published)); 72 | let site_resource = expect_context::<SiteResource>(); 73 | 74 | // TODO: These fields will need to be updateable once editing posts is supported 75 | let (post_name, _set_post_name) = slice!(post_state.post.name); 76 | 77 | // TODO: Will need setter once creating comments is supported 78 | let (comments, _set_comments) = slice!(post_state.counts.comments); 79 | let num_comments_label = Signal::derive(move || format!("{} comments", comments.get())); 80 | 81 | let (my_vote, set_my_vote) = slice!(post_state.my_vote); 82 | let (score, set_score) = slice!(post_state.counts.score); 83 | let (_upvotes, set_upvotes) = slice!(post_state.counts.upvotes); 84 | let (_downvotes, set_downvotes) = slice!(post_state.counts.downvotes); 85 | 86 | let vote_action = create_vote_post_action(); 87 | Effect::new(move |_| { 88 | let response = vote_action.value(); 89 | 90 | if let Some(response) = response.read().as_ref().and_then(|r| r.as_ref().ok()) { 91 | set_my_vote.set(response.post_view.my_vote); 92 | set_score.set(response.post_view.counts.score); 93 | set_upvotes.set(response.post_view.counts.upvotes); 94 | set_downvotes.set(response.post_view.counts.downvotes); 95 | } 96 | }); 97 | 98 | let (saved, set_saved) = slice!(post_state.saved); 99 | let save_action = create_save_post_action(); 100 | Effect::new(move |_| { 101 | let response = save_action.value(); 102 | 103 | if let Some(response) = response.read().as_ref().and_then(|r| r.as_ref().ok()) { 104 | set_saved.set(response.post_view.saved); 105 | } 106 | }); 107 | 108 | let (hidden, set_hidden) = create_slice( 109 | post_state, 110 | |post_view| Hidden(post_view.hidden), 111 | |post_view, hidden| post_view.hidden = hidden, 112 | ); 113 | let hide_post_action = create_hide_post_action(); 114 | Effect::new(move |_| { 115 | let response = hide_post_action.value(); 116 | 117 | if response 118 | .read() 119 | .as_ref() 120 | .and_then(|r| r.as_ref().ok().map(|r| r.success)) 121 | .unwrap_or(false) 122 | { 123 | set_hidden.set(!hidden.get().0); 124 | } 125 | }); 126 | provide_context(hidden); 127 | provide_context(hide_post_action); 128 | 129 | let is_on_post_page = use_matched().read_untracked().starts_with("/post"); 130 | 131 | view! { 132 | <article class="w-full h-fit pe-1"> 133 | <div class="grid grid-areas-post-listing items-center gap-2.5"> 134 | <VoteButtons 135 | id=PostOrCommentId::Post(id) 136 | my_vote=my_vote 137 | score=score 138 | vote_action=vote_action 139 | class="grid-in-vote" 140 | /> 141 | <Thumbnail url=post_url image_url=image_url has_embed_url=has_embed_video_url id=id /> 142 | 143 | <Show 144 | when=move || is_on_post_page 145 | fallback=move || { 146 | view! { 147 | <h2 class="text-lg font-medium grid-in-title"> 148 | <A href=format!("/post/{id}")>{post_name}</A> 149 | </h2> 150 | } 151 | } 152 | > 153 | <h1 class="text-4xl font-bold grid-in-title">{post_name}</h1> 154 | </Show> 155 | <div class="grid-in-to"> 156 | <div class="flex flex-wrap items-center gap-1.5"> 157 | <CreatorListing creator=creator /> 158 | <div class="text-sm">to</div> 159 | <CommunityListing community=community /> 160 | </div> 161 | <div class="flex flex-wrap items-center gap-1.5 mt-2"> 162 | <div class="text-xs badge badge-ghost gap-x-0.5"> 163 | <Icon icon=IconType::Clock size=IconSize::Small /> 164 | {time_since_post} 165 | </div> 166 | <Transition> 167 | {move || Suspend::new(async move { 168 | site_resource 169 | .await 170 | .map(|site_response| { 171 | let language_id = post_state.read().post.language_id; 172 | (language_id.0 != 0) 173 | .then(|| { 174 | site_response 175 | .all_languages 176 | .into_iter() 177 | .find(|l| l.id == language_id) 178 | .map(|l| { 179 | view! { 180 | <div class="text-xs badge badge-ghost gap-x-0.5"> 181 | <Icon icon=IconType::Language size=IconSize::Small /> 182 | {l.name} 183 | </div> 184 | } 185 | }) 186 | }) 187 | }) 188 | })} 189 | </Transition> 190 | </div> 191 | </div> 192 | 193 | <div class="flex items-center gap-x-2 grid-in-actions"> 194 | <A 195 | href=move || { format!("/post/{id}") } 196 | attr:class="text-sm whitespace-nowrap" 197 | attr:title=num_comments_label 198 | attr:aria-label=num_comments_label 199 | > 200 | <Icon icon=IconType::Comment class="inline align-baseline" /> 201 | " " 202 | <span class="align-sub">{comments}</span> 203 | </A> 204 | <ContentActions 205 | post_or_comment_id=PostOrCommentId::Post(id) 206 | saved=saved 207 | save_action=save_action 208 | creator=creator 209 | ap_id=ap_id 210 | /> 211 | </div> 212 | </div> 213 | {move || { 214 | post_body 215 | .read() 216 | .as_ref() 217 | .map(|body| { 218 | is_on_post_page.then(|| view! { <MarkdownContent content=Arc::clone(body) /> }) 219 | }) 220 | }} 221 | </article> 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /public/icons.svg: -------------------------------------------------------------------------------- 1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 256 256"> 2 | <!-- Icons come from phosphor icon set: https://phosphoricons.com --> 3 | 4 | <style> 5 | use { 6 | display: none; 7 | } 8 | 9 | use:target { 10 | display: block; 11 | } 12 | </style> 13 | 14 | <symbol id="eye" fill="currentColor" viewBox="0 0 256 256"> 15 | <path d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z" /> 16 | </symbol> 17 | <symbol id="eye-slash" fill="currentColor" viewBox="0 0 256 256"> 18 | <path d="M53.92,34.62A8,8,0,1,0,42.08,45.38L61.32,66.55C25,88.84,9.38,123.2,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a127.11,127.11,0,0,0,52.07-10.83l22,24.21a8,8,0,1,0,11.84-10.76Zm47.33,75.84,41.67,45.85a32,32,0,0,1-41.67-45.85ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.16,133.16,0,0,1,25,128c4.69-8.79,19.66-33.39,47.35-49.38l18,19.75a48,48,0,0,0,63.66,70l14.73,16.2A112,112,0,0,1,128,192Zm6-95.43a8,8,0,0,1,3-15.72,48.16,48.16,0,0,1,38.77,42.64,8,8,0,0,1-7.22,8.71,6.39,6.39,0,0,1-.75,0,8,8,0,0,1-8-7.26A32.09,32.09,0,0,0,134,96.57Zm113.28,34.69c-.42.94-10.55,23.37-33.36,43.8a8,8,0,1,1-10.67-11.92A132.77,132.77,0,0,0,231.05,128a133.15,133.15,0,0,0-23.12-30.77C185.67,75.19,158.78,64,128,64a118.37,118.37,0,0,0-19.36,1.57A8,8,0,1,1,106,49.79,134,134,0,0,1,128,48c34.88,0,66.57,13.26,91.66,38.35,18.83,18.83,27.3,37.62,27.65,38.41A8,8,0,0,1,247.31,131.26Z" /> 19 | </symbol> 20 | <symbol id="notifications" fill="currentColor" viewBox="0 0 256 256"> 21 | <path d="M221.8,175.94C216.25,166.38,208,139.33,208,104a80,80,0,1,0-160,0c0,35.34-8.26,62.38-13.81,71.94A16,16,0,0,0,48,200H88.81a40,40,0,0,0,78.38,0H208a16,16,0,0,0,13.8-24.06ZM128,216a24,24,0,0,1-22.62-16h45.24A24,24,0,0,1,128,216ZM48,184c7.7-13.24,16-43.92,16-80a64,64,0,1,1,128,0c0,36.05,8.28,66.73,16,80Z" /> 22 | </symbol> 23 | <symbol id="search" fill="currentColor" viewBox="0 0 256 256"> 24 | <path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z" /> 25 | </symbol> 26 | <symbol id="upvote" fill="currentColor" viewBox="0 0 256 256"> 27 | <path d="M208,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32Zm0,176H48V48H208ZM90.34,125.66a8,8,0,0,1,0-11.32l32-32a8,8,0,0,1,11.32,0l32,32a8,8,0,0,1-11.32,11.32L136,107.31V168a8,8,0,0,1-16,0V107.31l-18.34,18.35A8,8,0,0,1,90.34,125.66Z" /> 28 | </symbol> 29 | <symbol id="downvote" fill="currentColor" viewBox="0 0 256 256"> 30 | <path d="M208,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32Zm0,176H48V48H208V208Zm-42.34-77.66a8,8,0,0,1,0,11.32l-32,32a8,8,0,0,1-11.32,0l-32-32a8,8,0,0,1,11.32-11.32L120,148.69V88a8,8,0,0,1,16,0v60.69l18.34-18.35A8,8,0,0,1,165.66,130.34Z" /> 31 | </symbol> 32 | <symbol id="crosspost" fill="currentColor" viewBox="0 0 256 256"> 33 | <path d="M174.63,81.37a80,80,0,1,0-93.26,93.26,80,80,0,1,0,93.26-93.26ZM100.69,136,120,155.31A63.48,63.48,0,0,1,96,160,63.48,63.48,0,0,1,100.69,136Zm33.75,11.13-25.57-25.57a64.65,64.65,0,0,1,12.69-12.69l25.57,25.57A64.65,64.65,0,0,1,134.44,147.13ZM155.31,120,136,100.69A63.48,63.48,0,0,1,160,96,63.48,63.48,0,0,1,155.31,120ZM32,96a64,64,0,0,1,126-16A80.08,80.08,0,0,0,80.05,158,64.11,64.11,0,0,1,32,96ZM160,224A64.11,64.11,0,0,1,98,176,80.08,80.08,0,0,0,176,98,64,64,0,0,1,160,224Z" /> 34 | </symbol> 35 | <symbol id="vertical-dots" fill="currentColor" viewBox="0 0 256 256"> 36 | <path d="M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128ZM128,72a12,12,0,1,0-12-12A12,12,0,0,0,128,72Zm0,112a12,12,0,1,0,12,12A12,12,0,0,0,128,184Z" /> 37 | </symbol> 38 | <symbol id="report" fill="currentColor" viewBox="0 0 256 256"> 39 | <path d="M34.76,42A8,8,0,0,0,32,48V216a8,8,0,0,0,16,0V171.77c26.79-21.16,49.87-9.75,76.45,3.41,16.4,8.11,34.06,16.85,53,16.85,13.93,0,28.54-4.75,43.82-18a8,8,0,0,0,2.76-6V48A8,8,0,0,0,210.76,42c-28,24.23-51.72,12.49-79.21-1.12C103.07,26.76,70.78,10.79,34.76,42ZM208,164.25c-26.79,21.16-49.87,9.74-76.45-3.41-25-12.35-52.81-26.13-83.55-8.4V51.79c26.79-21.16,49.87-9.75,76.45,3.4,25,12.35,52.82,26.13,83.55,8.4Z" /> 40 | </symbol> 41 | <symbol id="comment" fill="currentColor" viewBox="0 0 256 256"> 42 | <path d="M216,48H40A16,16,0,0,0,24,64V224a15.85,15.85,0,0,0,9.24,14.5A16.13,16.13,0,0,0,40,240a15.89,15.89,0,0,0,10.25-3.78l.09-.07L83,208H216a16,16,0,0,0,16-16V64A16,16,0,0,0,216,48ZM40,224h0ZM216,192H80a8,8,0,0,0-5.23,1.95L40,224V64H216ZM88,112a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H96A8,8,0,0,1,88,112Zm0,32a8,8,0,0,1,8-8h64a8,8,0,1,1,0,16H96A8,8,0,0,1,88,144Z" /> 43 | </symbol> 44 | <symbol id="comments" fill="currentColor" viewBox="0 0 256 256"> 45 | <path d="M216,80H184V48a16,16,0,0,0-16-16H40A16,16,0,0,0,24,48V176a8,8,0,0,0,13,6.22L72,154V184a16,16,0,0,0,16,16h93.59L219,230.22a8,8,0,0,0,5,1.78,8,8,0,0,0,8-8V96A16,16,0,0,0,216,80ZM66.55,137.78,40,159.25V48H168v88H71.58A8,8,0,0,0,66.55,137.78ZM216,207.25l-26.55-21.47a8,8,0,0,0-5-1.78H88V152h80a16,16,0,0,0,16-16V96h32Z" /> 46 | </symbol> 47 | <symbol id="block" fill="currentColor" viewBox="0 0 256 256"> 48 | <path d="M165.66,154.34a8,8,0,0,1-11.32,11.32l-64-64a8,8,0,0,1,11.32-11.32ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z" /> 49 | </symbol> 50 | <symbol id="save" fill="currentColor" viewBox="0 0 256 256"> 51 | <path d="M184,32H72A16,16,0,0,0,56,48V224a8,8,0,0,0,12.24,6.78L128,193.43l59.77,37.35A8,8,0,0,0,200,224V48A16,16,0,0,0,184,32Zm0,16V161.57l-51.77-32.35a8,8,0,0,0-8.48,0L72,161.56V48ZM132.23,177.22a8,8,0,0,0-8.48,0L72,209.57V180.43l56-35,56,35v29.14Z" /> 52 | </symbol> 53 | <symbol id="save-filled" fill="currentColor" viewBox="0 0 256 256"> 54 | <path d="M184,32H72A16,16,0,0,0,56,48V224a8,8,0,0,0,12.24,6.78L128,193.43l59.77,37.35A8,8,0,0,0,200,224V48A16,16,0,0,0,184,32ZM132.23,177.22a8,8,0,0,0-8.48,0L72,209.57V180.43l56-35,56,35v29.14Z" /> 55 | </symbol> 56 | <symbol id="create-post" fill="currentColor" viewBox="0 0 256 256"> 57 | <path d="M229.66,58.34l-32-32a8,8,0,0,0-11.32,0l-96,96A8,8,0,0,0,88,128v32a8,8,0,0,0,8,8h32a8,8,0,0,0,5.66-2.34l96-96A8,8,0,0,0,229.66,58.34ZM124.69,152H104V131.31l64-64L188.69,88ZM200,76.69,179.31,56,192,43.31,212.69,64ZM224,128v80a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32h80a8,8,0,0,1,0,16H48V208H208V128a8,8,0,0,1,16,0Z" /> 58 | </symbol> 59 | <symbol id="create-community" fill="currentColor" viewBox="0 0 256 256"> 60 | <path d="M208,32H160a16,16,0,0,0-16,16V208a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32Zm0,176H160V176h24a8,8,0,0,0,0-16H160V136h24a8,8,0,0,0,0-16H160V96h24a8,8,0,0,0,0-16H160V48h48V208ZM77.66,26.34a8,8,0,0,0-11.32,0l-32,32A8,8,0,0,0,32,64V208a16,16,0,0,0,16,16H96a16,16,0,0,0,16-16V64a8,8,0,0,0-2.34-5.66ZM48,176V80H64v96ZM80,80H96v96H80ZM72,43.31,92.69,64H51.31ZM48,208V192H96v16Z" /> 61 | </symbol> 62 | <symbol id="communities" fill="currentColor" viewBox="0 0 256 256"> 63 | <path d="M240,208h-8V88a8,8,0,0,0-8-8H160a8,8,0,0,0-8,8v40H104V40a8,8,0,0,0-8-8H32a8,8,0,0,0-8,8V208H16a8,8,0,0,0,0,16H240a8,8,0,0,0,0-16ZM168,96h48V208H168Zm-16,48v64H104V144ZM40,48H88V208H40ZM72,72V88a8,8,0,0,1-16,0V72a8,8,0,0,1,16,0Zm0,48v16a8,8,0,0,1-16,0V120a8,8,0,0,1,16,0Zm0,48v16a8,8,0,0,1-16,0V168a8,8,0,0,1,16,0Zm48,16V168a8,8,0,0,1,16,0v16a8,8,0,0,1-16,0Zm64,0V168a8,8,0,0,1,16,0v16a8,8,0,0,1-16,0Zm0-48V120a8,8,0,0,1,16,0v16a8,8,0,0,1-16,0Z" /> 64 | </symbol> 65 | <symbol id="community" fill="currentColor" viewBox="0 0 256 256"> 66 | <rect width="256" height="256" fill="none"/> 67 | <line x1="16" y1="216" x2="240" y2="216" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 68 | <polyline points="224 216 224 72 176 72 176 40 80 40 80 104 32 104 32 216" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 69 | <line x1="120" y1="72" x2="136" y2="72" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 70 | <line x1="120" y1="104" x2="136" y2="104" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 71 | <line x1="176" y1="104" x2="192" y2="104" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 72 | <line x1="64" y1="136" x2="80" y2="136" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 73 | <line x1="64" y1="168" x2="80" y2="168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 74 | <line x1="120" y1="136" x2="136" y2="136" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 75 | <line x1="176" y1="136" x2="192" y2="136" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 76 | <line x1="176" y1="168" x2="192" y2="168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 77 | <polyline points="112 216 112 168 144 168 144 216" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 78 | </symbol> 79 | <symbol id="documentation" fill="currentColor" viewBox="0 0 256 256"> 80 | <path d="M213.66,66.34l-40-40A8,8,0,0,0,168,24H88A16,16,0,0,0,72,40V56H56A16,16,0,0,0,40,72V216a16,16,0,0,0,16,16H168a16,16,0,0,0,16-16V200h16a16,16,0,0,0,16-16V72A8,8,0,0,0,213.66,66.34ZM168,216H56V72h76.69L168,107.31v84.53c0,.06,0,.11,0,.16s0,.1,0,.16V216Zm32-32H184V104a8,8,0,0,0-2.34-5.66l-40-40A8,8,0,0,0,136,56H88V40h76.69L200,75.31Zm-56-32a8,8,0,0,1-8,8H88a8,8,0,0,1,0-16h48A8,8,0,0,1,144,152Zm0,32a8,8,0,0,1-8,8H88a8,8,0,0,1,0-16h48A8,8,0,0,1,144,184Z" /> 81 | </symbol> 82 | <symbol id="code" fill="currentColor" viewBox="0 0 256 256"> 83 | <path d="M69.12,94.15,28.5,128l40.62,33.85a8,8,0,1,1-10.24,12.29l-48-40a8,8,0,0,1,0-12.29l48-40a8,8,0,0,1,10.24,12.3Zm176,27.7-48-40a8,8,0,1,0-10.24,12.3L227.5,128l-40.62,33.85a8,8,0,1,0,10.24,12.29l48-40a8,8,0,0,0,0-12.29ZM162.73,32.48a8,8,0,0,0-10.25,4.79l-64,176a8,8,0,0,0,4.79,10.26A8.14,8.14,0,0,0,96,224a8,8,0,0,0,7.52-5.27l64-176A8,8,0,0,0,162.73,32.48Z" /> 84 | </symbol> 85 | <symbol id="info" fill="currentColor" viewBox="0 0 256 256"> 86 | <path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z" /> 87 | </symbol> 88 | <symbol id="donate" fill="currentColor" viewBox="0 0 256 256"> 89 | <path d="M230.33,141.06a24.34,24.34,0,0,0-18.61-4.77C230.5,117.33,240,98.48,240,80c0-26.47-21.29-48-47.46-48A47.58,47.58,0,0,0,156,48.75,47.58,47.58,0,0,0,119.46,32C93.29,32,72,53.53,72,80c0,11,3.24,21.69,10.06,33a31.87,31.87,0,0,0-14.75,8.4L44.69,144H16A16,16,0,0,0,0,160v40a16,16,0,0,0,16,16H120a7.93,7.93,0,0,0,1.94-.24l64-16a6.94,6.94,0,0,0,1.19-.4L226,182.82l.44-.2a24.6,24.6,0,0,0,3.93-41.56ZM119.46,48A31.15,31.15,0,0,1,148.6,67a8,8,0,0,0,14.8,0,31.15,31.15,0,0,1,29.14-19C209.59,48,224,62.65,224,80c0,19.51-15.79,41.58-45.66,63.9l-11.09,2.55A28,28,0,0,0,140,112H100.68C92.05,100.36,88,90.12,88,80,88,62.65,102.41,48,119.46,48ZM16,160H40v40H16Zm203.43,8.21-38,16.18L119,200H56V155.31l22.63-22.62A15.86,15.86,0,0,1,89.94,128H140a12,12,0,0,1,0,24H112a8,8,0,0,0,0,16h32a8.32,8.32,0,0,0,1.79-.2l67-15.41.31-.08a8.6,8.6,0,0,1,6.3,15.9Z" /> 90 | </symbol> 91 | <symbol id="modlog" fill="currentColor" viewBox="0 0 256 256"> 92 | <path d="M92,136H40a16,16,0,0,1-11.76-5.21,16.21,16.21,0,0,1-4.17-12.37A103.83,103.83,0,0,1,67.65,42.93,16,16,0,0,1,90.75,48l26,45a8,8,0,1,1-13.86,8L76.89,56A87.83,87.83,0,0,0,40,119.86a.19.19,0,0,0,.07.16L92,120a8,8,0,0,1,0,16Zm139.93-17.58a103.83,103.83,0,0,0-43.58-75.49A16,16,0,0,0,165.25,48L139.3,93a8,8,0,0,0,13.86,8l26-45A87.87,87.87,0,0,1,216,119.86c0,.07,0,.12,0,.14H164a8,8,0,0,0,0,16h52a16,16,0,0,0,11.76-5.21A16.21,16.21,0,0,0,231.93,118.42Zm-79,36.76a8,8,0,1,0-13.86,8l25.84,44.73a88.22,88.22,0,0,1-73.81,0l25.83-44.73a8,8,0,1,0-13.86-8L77.25,199.91a16,16,0,0,0,7.12,22.52,104.24,104.24,0,0,0,87.26,0,16,16,0,0,0,7.12-22.52ZM128,140a12,12,0,1,0-12-12A12,12,0,0,0,128,140Z" /> 93 | </symbol> 94 | <symbol id="instances" fill="currentColor" viewBox="0 0 256 256"> 95 | <path d="M200,152a31.84,31.84,0,0,0-19.53,6.68l-23.11-18A31.65,31.65,0,0,0,160,128c0-.74,0-1.48-.08-2.21l13.23-4.41A32,32,0,1,0,168,104c0,.74,0,1.48.08,2.21l-13.23,4.41A32,32,0,0,0,128,96a32.59,32.59,0,0,0-5.27.44L115.89,81A32,32,0,1,0,96,88a32.59,32.59,0,0,0,5.27-.44l6.84,15.4a31.92,31.92,0,0,0-8.57,39.64L73.83,165.44a32.06,32.06,0,1,0,10.63,12l25.71-22.84a31.91,31.91,0,0,0,37.36-1.24l23.11,18A31.65,31.65,0,0,0,168,184a32,32,0,1,0,32-32Zm0-64a16,16,0,1,1-16,16A16,16,0,0,1,200,88ZM80,56A16,16,0,1,1,96,72,16,16,0,0,1,80,56ZM56,208a16,16,0,1,1,16-16A16,16,0,0,1,56,208Zm56-80a16,16,0,1,1,16,16A16,16,0,0,1,112,128Zm88,72a16,16,0,1,1,16-16A16,16,0,0,1,200,200Z" /> 96 | </symbol> 97 | <symbol id="legal" fill="currentColor" viewBox="0 0 256 256"> 98 | <path d="M239.43,133l-32-80h0a8,8,0,0,0-9.16-4.84L136,62V40a8,8,0,0,0-16,0V65.58L54.26,80.19A8,8,0,0,0,48.57,85h0v.06L16.57,165a7.92,7.92,0,0,0-.57,3c0,23.31,24.54,32,40,32s40-8.69,40-32a7.92,7.92,0,0,0-.57-3L66.92,93.77,120,82V208H104a8,8,0,0,0,0,16h48a8,8,0,0,0,0-16H136V78.42L187,67.1,160.57,133a7.92,7.92,0,0,0-.57,3c0,23.31,24.54,32,40,32s40-8.69,40-32A7.92,7.92,0,0,0,239.43,133ZM56,184c-7.53,0-22.76-3.61-23.93-14.64L56,109.54l23.93,59.82C78.76,180.39,63.53,184,56,184Zm144-32c-7.53,0-22.76-3.61-23.93-14.64L200,77.54l23.93,59.82C222.76,148.39,207.53,152,200,152Z" /> 99 | </symbol> 100 | <symbol id="theme" fill="currentColor" viewBox="0 0 256 256"> 101 | <path d="M200.77,53.89A103.27,103.27,0,0,0,128,24h-1.07A104,104,0,0,0,24,128c0,43,26.58,79.06,69.36,94.17A32,32,0,0,0,136,192a16,16,0,0,1,16-16h46.21a31.81,31.81,0,0,0,31.2-24.88,104.43,104.43,0,0,0,2.59-24A103.28,103.28,0,0,0,200.77,53.89Zm13,93.71A15.89,15.89,0,0,1,198.21,160H152a32,32,0,0,0-32,32,16,16,0,0,1-21.31,15.07C62.49,194.3,40,164,40,128a88,88,0,0,1,87.09-88h.9a88.35,88.35,0,0,1,88,87.25A88.86,88.86,0,0,1,213.81,147.6ZM140,76a12,12,0,1,1-12-12A12,12,0,0,1,140,76ZM96,100A12,12,0,1,1,84,88,12,12,0,0,1,96,100Zm0,56a12,12,0,1,1-12-12A12,12,0,0,1,96,156Zm88-56a12,12,0,1,1-12-12A12,12,0,0,1,184,100Z" /> 102 | </symbol> 103 | <symbol id="dropdown-caret" fill="currentColor" viewBox="0 0 256 256"> 104 | <path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z" /> 105 | </symbol> 106 | <symbol id="home" fill="currentColor" viewBox="0 0 256 256"> 107 | <path d="M240,208H224V136l2.34,2.34A8,8,0,0,0,237.66,127L139.31,28.68a16,16,0,0,0-22.62,0L18.34,127a8,8,0,0,0,11.32,11.31L32,136v72H16a8,8,0,0,0,0,16H240a8,8,0,0,0,0-16ZM48,120l80-80,80,80v88H160V152a8,8,0,0,0-8-8H104a8,8,0,0,0-8,8v56H48Zm96,88H112V160h32Z" /> 108 | </symbol> 109 | <symbol id="saved" fill="currentColor" viewBox="0 0 256 256"> 110 | <path d="M192,24H96A16,16,0,0,0,80,40V56H64A16,16,0,0,0,48,72V224a8,8,0,0,0,12.65,6.51L112,193.83l51.36,36.68A8,8,0,0,0,176,224V184.69l19.35,13.82A8,8,0,0,0,208,192V40A16,16,0,0,0,192,24ZM160,208.46l-43.36-31a8,8,0,0,0-9.3,0L64,208.45V72h96Zm32-32L176,165V72a16,16,0,0,0-16-16H96V40h96Z" /> 111 | </symbol> 112 | <symbol id="profile" fill="currentColor" viewBox="0 0 256 256"> 113 | <path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24ZM74.08,197.5a64,64,0,0,1,107.84,0,87.83,87.83,0,0,1-107.84,0ZM96,120a32,32,0,1,1,32,32A32,32,0,0,1,96,120Zm97.76,66.41a79.66,79.66,0,0,0-36.06-28.75,48,48,0,1,0-59.4,0,79.66,79.66,0,0,0-36.06,28.75,88,88,0,1,1,131.52,0Z" /> 114 | </symbol> 115 | <symbol id="hamburger" fill="currentColor" viewBox="0 0 256 256"> 116 | <path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z" /> 117 | </symbol> 118 | <symbol id="users" fill="currentColor" viewBox="0 0 256 256"> 119 | <path d="M244.8,150.4a8,8,0,0,1-11.2-1.6A51.6,51.6,0,0,0,192,128a8,8,0,0,1-7.37-4.89,8,8,0,0,1,0-6.22A8,8,0,0,1,192,112a24,24,0,1,0-23.24-30,8,8,0,1,1-15.5-4A40,40,0,1,1,219,117.51a67.94,67.94,0,0,1,27.43,21.68A8,8,0,0,1,244.8,150.4ZM190.92,212a8,8,0,1,1-13.84,8,57,57,0,0,0-98.16,0,8,8,0,1,1-13.84-8,72.06,72.06,0,0,1,33.74-29.92,48,48,0,1,1,58.36,0A72.06,72.06,0,0,1,190.92,212ZM128,176a32,32,0,1,0-32-32A32,32,0,0,0,128,176ZM72,120a8,8,0,0,0-8-8A24,24,0,1,1,87.24,82a8,8,0,1,0,15.5-4A40,40,0,1,0,37,117.51,67.94,67.94,0,0,0,9.6,139.19a8,8,0,1,0,12.8,9.61A51.6,51.6,0,0,1,64,128,8,8,0,0,0,72,120Z" /> 120 | </symbol> 121 | <symbol id="posts" fill="currentColor" viewBox="0 0 256 256"> 122 | <path d="M96,104a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H104A8,8,0,0,1,96,104Zm8,40h64a8,8,0,0,0,0-16H104a8,8,0,0,0,0,16Zm128,48a32,32,0,0,1-32,32H88a32,32,0,0,1-32-32V64a16,16,0,0,0-32,0c0,5.74,4.83,9.62,4.88,9.66h0A8,8,0,0,1,24,88a7.89,7.89,0,0,1-4.79-1.61h0C18.05,85.54,8,77.61,8,64A32,32,0,0,1,40,32H176a32,32,0,0,1,32,32V168h8a8,8,0,0,1,4.8,1.6C222,170.46,232,178.39,232,192ZM96.26,173.48A8.07,8.07,0,0,1,104,168h88V64a16,16,0,0,0-16-16H67.69A31.71,31.71,0,0,1,72,64V192a16,16,0,0,0,32,0c0-5.74-4.83-9.62-4.88-9.66A7.82,7.82,0,0,1,96.26,173.48ZM216,192a12.58,12.58,0,0,0-3.23-8h-94a26.92,26.92,0,0,1,1.21,8,31.82,31.82,0,0,1-4.29,16H200A16,16,0,0,0,216,192Z" /> 123 | </symbol> 124 | <symbol id="fediverse" fill="currentColor" viewBox="0 0 256 256"> 125 | <rect width="256" height="256" fill="none"/> 126 | <circle cx="148" cy="44" r="20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 127 | <circle cx="212" cy="124" r="20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 128 | <circle cx="156" cy="212" r="20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 129 | <circle cx="56" cy="184" r="20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 130 | <circle cx="52" cy="84" r="20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 131 | <line x1="201.26" y1="140.88" x2="166.74" y2="195.12" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 132 | <line x1="160.49" y1="59.62" x2="199.51" y2="108.38" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 133 | <line x1="70.47" y1="76.31" x2="129.53" y2="51.69" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 134 | <line x1="192.02" y1="124.95" x2="128" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 135 | <line x1="128" y1="128" x2="143.37" y2="63.46" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 136 | <line x1="69.31" y1="94.02" x2="128" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 137 | <line x1="128" y1="128" x2="149.67" y2="193.02" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 138 | <line x1="136.74" y1="206.61" x2="75.26" y2="189.39" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 139 | <line x1="71.79" y1="171.72" x2="128" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 140 | <line x1="55.2" y1="164.02" x2="52.8" y2="103.98" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 141 | </symbol> 142 | <symbol id="x" fill="currentColor" viewBox="0 0 256 256"> 143 | <rect width="256" height="256" fill="none"/> 144 | <line x1="160" y1="96" x2="96" y2="160" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 145 | <line x1="96" y1="96" x2="160" y2="160" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 146 | <circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> 147 | </symbol> 148 | <symbol id="image" fill="currentColor" viewBox="0 0 256 256"> 149 | <path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,16V158.75l-26.07-26.06a16,16,0,0,0-22.63,0l-20,20-44-44a16,16,0,0,0-22.62,0L40,149.37V56ZM40,172l52-52,80,80H40Zm176,28H194.63l-36-36,20-20L216,181.38V200ZM144,100a12,12,0,1,1,12,12A12,12,0,0,1,144,100Z" /> 150 | </symbol> 151 | <symbol id="video" fill="currentColor" viewBox="0 0 256 256"> 152 | <path d="M164.44,105.34l-48-32A8,8,0,0,0,104,80v64a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM120,129.05V95l25.58,17ZM216,40H40A16,16,0,0,0,24,56V168a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,128H40V56H216V168Zm16,40a8,8,0,0,1-8,8H32a8,8,0,0,1,0-16H224A8,8,0,0,1,232,208Z" /> 153 | </symbol> 154 | <symbol id="external-link" fill="currentColor" viewBox="0 0 256 256"> 155 | <path d="M224,104a8,8,0,0,1-16,0V59.32l-66.33,66.34a8,8,0,0,1-11.32-11.32L196.68,48H152a8,8,0,0,1,0-16h64a8,8,0,0,1,8,8Zm-40,24a8,8,0,0,0-8,8v72H48V80h72a8,8,0,0,0,0-16H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V136A8,8,0,0,0,184,128Z" /> 156 | </symbol> 157 | <symbol id="clock" fill="currentColor" viewBox="0 0 256 256"> 158 | <path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm64-88a8,8,0,0,1-8,8H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48A8,8,0,0,1,192,128Z" /> 159 | </symbol> 160 | <symbol id="language" fill="currentColor" viewBox="0 0 256 256"> 161 | <path d="M247.15,212.42l-56-112a8,8,0,0,0-14.31,0l-21.71,43.43A88,88,0,0,1,108,126.93,103.65,103.65,0,0,0,135.69,64H160a8,8,0,0,0,0-16H104V32a8,8,0,0,0-16,0V48H32a8,8,0,0,0,0,16h87.63A87.76,87.76,0,0,1,96,116.35a87.74,87.74,0,0,1-19-31,8,8,0,1,0-15.08,5.34A103.63,103.63,0,0,0,84,127a87.55,87.55,0,0,1-52,17,8,8,0,0,0,0,16,103.46,103.46,0,0,0,64-22.08,104.18,104.18,0,0,0,51.44,21.31l-26.6,53.19a8,8,0,0,0,14.31,7.16L148.94,192h70.11l13.79,27.58A8,8,0,0,0,240,224a8,8,0,0,0,7.15-11.58ZM156.94,176,184,121.89,211.05,176Z" /> 162 | </symbol> 163 | <symbol id="warning" fill="currentColor" viewBox="0 0 256 256"> 164 | <path d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z" /> 165 | </symbol> 166 | <symbol id="quote" fill="currentColor" viewBox="0 0 256 256"> 167 | <path d="M100,56H40A16,16,0,0,0,24,72v64a16,16,0,0,0,16,16h60v8a32,32,0,0,1-32,32,8,8,0,0,0,0,16,48.05,48.05,0,0,0,48-48V72A16,16,0,0,0,100,56Zm0,80H40V72h60ZM216,56H156a16,16,0,0,0-16,16v64a16,16,0,0,0,16,16h60v8a32,32,0,0,1-32,32,8,8,0,0,0,0,16,48.05,48.05,0,0,0,48-48V72A16,16,0,0,0,216,56Zm0,80H156V72h60Z" /> 168 | </symbol> 169 | 170 | <use id="css-caret" href="#dropdown-caret" xlink:href="#dropdown-caret" /> 171 | <use id="css-warning" href="#warning" xlink:href="#warning" /> 172 | <use id="css-quote" href="#quote" xlink:href="#quote" /> 173 | </svg> 174 | --------------------------------------------------------------------------------