diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 77b28c8..bd03879 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2362,7 +2362,7 @@ (sup (a ("href" "#footnote-1") (text "1")))) (text "{%- endif %}")) (a - ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") + ("href" "{{ config.stripe.payment_links.supporter }}?client_reference_id={{ user.id }}") ("class" "button") ("target" "_blank") (text "Become a supporter ({{ config.stripe.supporter_price_text }})")) diff --git a/crates/app/src/public/html/profile/private.lisp b/crates/app/src/public/html/profile/private.lisp index c5acd7d..83d533f 100644 --- a/crates/app/src/public/html/profile/private.lisp +++ b/crates/app/src/public/html/profile/private.lisp @@ -20,7 +20,11 @@ (div ("class" "card flex flex-col gap-2") (span + ("class" "fade") (text "{{ text \"auth:label.private_profile_message\" }}")) + (span + ("class" "no_p_margin") + (text "{{ profile.settings.private_biography|markdown|safe }}")) (div ("class" "card w-full secondary flex gap-2") (text "{% if user -%} {% if not is_following -%}") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 4397155..c5566c7 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1433,6 +1433,15 @@ settings.biography, \"textarea\", ], + [ + [\"private_biography\", \"Private biography\"], + settings.private_biography, + \"textarea\", + { + embed_html: + 'This biography is only shown to users you are not following while your account is private.', + }, + ], [[\"status\", \"Status\"], settings.status, \"textarea\"], [ [\"warning\", \"Profile warning\"], diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index e62a0e8..3a4619e 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -17,9 +17,10 @@ pub async fn stripe_webhook( ) -> impl IntoResponse { let data = &(data.read().await).0; - if data.0.0.stripe.is_none() { - return Json(Error::MiscError("Disabled".to_string()).into()); - } + let stripe_cnf = match data.0.0.stripe { + Some(ref c) => c, + None => return Json(Error::MiscError("Disabled".to_string()).into()), + }; let sig = match headers.get("Stripe-Signature") { Some(s) => s, @@ -56,7 +57,7 @@ pub async fn stripe_webhook( Err(e) => return Json(e.into()), }; - tracing::info!("subscribe {} (stripe: {})", user.id, customer_id); + tracing::info!("payment {} (stripe: {})", user.id, customer_id); if let Err(e) = data .update_user_stripe_id(user.id, customer_id.as_str()) .await @@ -74,6 +75,48 @@ pub async fn stripe_webhook( }; let customer_id = invoice.customer.unwrap().id(); + let lines = invoice.lines.unwrap(); + + if lines.total_count.unwrap() > 1 { + if let Err(e) = data + .create_audit_log_entry(AuditLogEntry::new( + 0, + format!("too many invoice line items: stripe {customer_id}"), + )) + .await + { + return Json(e.into()); + } + + return Json(Error::MiscError("Too many line items".to_string()).into()); + } + + let item = match lines.data.get(0) { + Some(i) => i, + None => { + if let Err(e) = data + .create_audit_log_entry(AuditLogEntry::new( + 0, + format!("too few invoice line items: stripe {customer_id}"), + )) + .await + { + return Json(e.into()); + } + + return Json(Error::MiscError("Too few line items".to_string()).into()); + } + }; + + let product_id = item + .price + .as_ref() + .unwrap() + .product + .as_ref() + .unwrap() + .id() + .to_string(); // pull user and update role let mut retries: usize = 0; @@ -118,45 +161,54 @@ pub async fn stripe_webhook( } let user = user.unwrap(); - tracing::info!("found subscription user in {retries} tries"); - if user.permissions.check(FinePermission::SUPPORTER) { - return Json(ApiReturn { - ok: true, - message: "Already applied".to_string(), - payload: (), - }); - } + if product_id == stripe_cnf.product_ids.supporter { + // supporter + tracing::info!("found subscription user in {retries} tries"); - tracing::info!("invoice {} (stripe: {})", user.id, customer_id); - let new_user_permissions = user.permissions | FinePermission::SUPPORTER; + if user.permissions.check(FinePermission::SUPPORTER) { + return Json(ApiReturn { + ok: true, + message: "Already applied".to_string(), + payload: (), + }); + } - if let Err(e) = data - .update_user_role(user.id, new_user_permissions, user.clone(), true) - .await - { - return Json(e.into()); - } + tracing::info!("invoice {} (stripe: {})", user.id, customer_id); + let new_user_permissions = user.permissions | FinePermission::SUPPORTER; - if data.0.0.security.enable_invite_codes && user.awaiting_purchase { if let Err(e) = data - .update_user_awaiting_purchased_status(user.id, false, user.clone(), false) + .update_user_role(user.id, new_user_permissions, user.clone(), true) .await { return Json(e.into()); } - } - if let Err(e) = data - .create_notification(Notification::new( - "Welcome new supporter!".to_string(), - "Thank you for your support! Your account has been updated with your new role." - .to_string(), - user.id, - )) - .await - { - return Json(e.into()); + if data.0.0.security.enable_invite_codes && user.awaiting_purchase { + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, false, user.clone(), false) + .await + { + return Json(e.into()); + } + } + + if let Err(e) = data + .create_notification(Notification::new( + "Welcome new supporter!".to_string(), + "Thank you for your support! Your account has been updated with your new role." + .to_string(), + user.id, + )) + .await + { + return Json(e.into()); + } + } else { + tracing::error!( + "received an invalid stripe product id, please check config.stripe.product_ids" + ); + return Json(Error::MiscError("Unknown product ID".to_string()).into()); } } EventType::CustomerSubscriptionDeleted => { diff --git a/crates/app/src/routes/api/v1/layouts.rs b/crates/app/src/routes/api/v1/layouts.rs deleted file mode 100644 index b86bfd2..0000000 --- a/crates/app/src/routes/api/v1/layouts.rs +++ /dev/null @@ -1,175 +0,0 @@ -use crate::{ - get_user_from_token, - routes::{ - api::v1::{CreateLayout, UpdateLayoutName, UpdateLayoutPages, UpdateLayoutPrivacy}, - }, - State, -}; -use axum::{extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; -use tetratto_core::{ - model::{ - layouts::{Layout, LayoutPrivacy}, - oauth, - permissions::FinePermission, - ApiReturn, Error, - }, -}; - -pub async fn get_request( - jar: CookieJar, - Path(id): Path, - Extension(data): Extension, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadStacks) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - let layout = match data.get_layout_by_id(id).await { - Ok(x) => x, - Err(e) => return Json(e.into()), - }; - - if layout.privacy == LayoutPrivacy::Public - && user.id != layout.owner - && !user.permissions.check(FinePermission::MANAGE_USERS) - { - return Json(Error::NotAllowed.into()); - } - - Json(ApiReturn { - ok: true, - message: "Success".to_string(), - payload: Some(layout), - }) -} - -pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.get_layouts_by_user(user.id).await { - Ok(x) => Json(ApiReturn { - ok: true, - message: "Success".to_string(), - payload: Some(x), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn create_request( - jar: CookieJar, - Extension(data): Extension, - Json(req): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data - .create_layout(Layout::new(req.name, user.id, req.replaces)) - .await - { - Ok(s) => Json(ApiReturn { - ok: true, - message: "Layout created".to_string(), - payload: s.id.to_string(), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn update_name_request( - jar: CookieJar, - Extension(data): Extension, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.update_layout_title(id, &user, &req.name).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Layout updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn update_privacy_request( - jar: CookieJar, - Extension(data): Extension, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.update_layout_privacy(id, &user, req.privacy).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Layout updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn update_pages_request( - jar: CookieJar, - Extension(data): Extension, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.update_layout_pages(id, &user, req.pages).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Layout updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn delete_request( - jar: CookieJar, - Extension(data): Extension, - Path(id): Path, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.delete_layout(id, &user).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Layout deleted".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 506e74f..b3496db 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -4,7 +4,6 @@ pub mod channels; pub mod communities; pub mod domains; pub mod journals; -pub mod layouts; pub mod notes; pub mod notifications; pub mod reactions; @@ -29,7 +28,6 @@ use tetratto_core::model::{ }, communities_permissions::CommunityPermission, journals::JournalPrivacyPermission, - layouts::{CustomizablePage, LayoutPage, LayoutPrivacy}, littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, @@ -625,17 +623,6 @@ pub fn routes() -> Router { // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_request)) - // layouts - .route("/layouts", get(layouts::list_request)) - .route("/layouts", post(layouts::create_request)) - .route("/layouts/{id}", get(layouts::get_request)) - .route("/layouts/{id}", delete(layouts::delete_request)) - .route("/layouts/{id}/title", post(layouts::update_name_request)) - .route( - "/layouts/{id}/privacy", - post(layouts::update_privacy_request), - ) - .route("/layouts/{id}/pages", post(layouts::update_pages_request)) // services .route("/services", get(services::list_request)) .route("/services", post(services::create_request)) @@ -1055,27 +1042,6 @@ pub struct AwardAchievement { pub name: AchievementName, } -#[derive(Deserialize)] -pub struct CreateLayout { - pub name: String, - pub replaces: CustomizablePage, -} - -#[derive(Deserialize)] -pub struct UpdateLayoutName { - pub name: String, -} - -#[derive(Deserialize)] -pub struct UpdateLayoutPrivacy { - pub privacy: LayoutPrivacy, -} - -#[derive(Deserialize)] -pub struct UpdateLayoutPages { - pub pages: Vec, -} - #[derive(Deserialize)] pub struct CreateService { pub name: String, diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 309c851..44d1257 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -173,13 +173,13 @@ pub struct ConnectionsConfig { /// - Use testing card numbers: #[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct StripeConfig { - /// Payment link from the Stripe dashboard. + /// Payment links from the Stripe dashboard. /// /// 1. Create a product and set the price for your membership /// 2. Set the product price to a recurring subscription /// 3. Create a payment link for the new product /// 4. The payment link pasted into this config field should NOT include a query string - pub payment_link: String, + pub payment_links: StripePaymentLinks, /// To apply benefits to user accounts, you should then go into the Stripe developer /// "workbench" and create a new webhook. The webhook needs the scopes: /// `invoice.payment_succeeded`, `customer.subscription.deleted`, `checkout.session.completed`. @@ -194,6 +194,20 @@ pub struct StripeConfig { pub billing_portal_url: String, /// The text representation of the price of supporter. (like `$4 USD`) pub supporter_price_text: String, + /// Product IDs from the Stripe dashboard. + /// + /// These are checked when we receive a webhook to ensure we provide the correct product. + pub product_ids: StripeProductIds, +} + +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct StripePaymentLinks { + pub supporter: String, +} + +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct StripeProductIds { + pub supporter: String, } /// Manuals config (search help, etc) diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 969b014..f3d2668 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -40,7 +40,6 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_NOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap(); execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap(); - execute!(&conn, common::CREATE_TABLE_LAYOUTS).unwrap(); execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap(); execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index efa3eae..6a562e7 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -27,6 +27,5 @@ pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql" pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql"); pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql"); pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql"); -pub const CREATE_TABLE_LAYOUTS: &str = include_str!("./sql/create_layouts.sql"); pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql"); pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql"); diff --git a/crates/core/src/database/drivers/sql/create_layouts.sql b/crates/core/src/database/drivers/sql/create_layouts.sql deleted file mode 100644 index 3f28c0a..0000000 --- a/crates/core/src/database/drivers/sql/create_layouts.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS layouts ( - id BIGINT NOT NULL PRIMARY KEY, - created BIGINT NOT NULL, - owner BIGINT NOT NULL, - title TEXT NOT NULL, - privacy TEXT NOT NULL, - pages TEXT NOT NULL, - replaces TEXT NOT NULL -) diff --git a/crates/core/src/database/layouts.rs b/crates/core/src/database/layouts.rs deleted file mode 100644 index 052a733..0000000 --- a/crates/core/src/database/layouts.rs +++ /dev/null @@ -1,117 +0,0 @@ -use crate::model::{ - auth::User, - layouts::{Layout, LayoutPage, LayoutPrivacy}, - permissions::FinePermission, - Error, Result, -}; -use crate::{auto_method, DataManager}; -use oiseau::{PostgresRow, execute, get, query_rows, params, cache::Cache}; - -impl DataManager { - /// Get a [`Layout`] from an SQL row. - pub(crate) fn get_layout_from_row(x: &PostgresRow) -> Layout { - Layout { - id: get!(x->0(i64)) as usize, - created: get!(x->1(i64)) as usize, - owner: get!(x->2(i64)) as usize, - title: get!(x->3(String)), - privacy: serde_json::from_str(&get!(x->4(String))).unwrap(), - pages: serde_json::from_str(&get!(x->5(String))).unwrap(), - replaces: serde_json::from_str(&get!(x->6(String))).unwrap(), - } - } - - auto_method!(get_layout_by_id(usize as i64)@get_layout_from_row -> "SELECT * FROM layouts WHERE id = $1" --name="layout" --returns=Layout --cache-key-tmpl="atto.layout:{}"); - - /// Get all layouts by user. - /// - /// # Arguments - /// * `id` - the ID of the user to fetch layouts for - pub async fn get_layouts_by_user(&self, id: usize) -> Result> { - let conn = match self.0.connect().await { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let res = query_rows!( - &conn, - "SELECT * FROM layouts WHERE owner = $1 ORDER BY created DESC", - &[&(id as i64)], - |x| { Self::get_layout_from_row(x) } - ); - - if res.is_err() { - return Err(Error::GeneralNotFound("layout".to_string())); - } - - Ok(res.unwrap()) - } - - /// Create a new layout in the database. - /// - /// # Arguments - /// * `data` - a mock [`Layout`] object to insert - pub async fn create_layout(&self, data: Layout) -> Result { - // check values - if data.title.len() < 2 { - return Err(Error::DataTooShort("title".to_string())); - } else if data.title.len() > 32 { - return Err(Error::DataTooLong("title".to_string())); - } - - // ... - let conn = match self.0.connect().await { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let res = execute!( - &conn, - "INSERT INTO layouts VALUES ($1, $2, $3, $4, $5, $6, $7)", - params![ - &(data.id as i64), - &(data.created as i64), - &(data.owner as i64), - &data.title, - &serde_json::to_string(&data.privacy).unwrap(), - &serde_json::to_string(&data.pages).unwrap(), - &serde_json::to_string(&data.replaces).unwrap(), - ] - ); - - if let Err(e) = res { - return Err(Error::DatabaseError(e.to_string())); - } - - Ok(data) - } - - pub async fn delete_layout(&self, id: usize, user: &User) -> Result<()> { - let layout = self.get_layout_by_id(id).await?; - - // check user permission - if user.id != layout.owner && !user.permissions.check(FinePermission::MANAGE_STACKS) { - return Err(Error::NotAllowed); - } - - // ... - let conn = match self.0.connect().await { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let res = execute!(&conn, "DELETE FROM layouts WHERE id = $1", &[&(id as i64)]); - - if let Err(e) = res { - return Err(Error::DatabaseError(e.to_string())); - } - - // ... - self.0.1.remove(format!("atto.layout:{}", id)).await; - Ok(()) - } - - auto_method!(update_layout_title(&str)@get_layout_by_id:FinePermission::MANAGE_USERS; -> "UPDATE layouts SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.layout:{}"); - auto_method!(update_layout_privacy(LayoutPrivacy)@get_layout_by_id:FinePermission::MANAGE_USERS; -> "UPDATE layouts SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); - auto_method!(update_layout_pages(Vec)@get_layout_by_id:FinePermission::MANAGE_USERS; -> "UPDATE layouts SET pages = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); -} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 1009797..57873f9 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -13,7 +13,6 @@ mod invite_codes; mod ipbans; mod ipblocks; mod journals; -mod layouts; mod memberships; mod message_reactions; mod messages; diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index efea59a..a97b1fd 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -322,6 +322,9 @@ pub struct UserSettings { /// and the following timeline. #[serde(default)] pub auto_full_unlist: bool, + /// Biography shown on `profile/private.lisp` page. + #[serde(default)] + pub private_biography: String, } fn mime_avif() -> String { diff --git a/crates/core/src/model/layouts.rs b/crates/core/src/model/layouts.rs deleted file mode 100644 index a9d60a4..0000000 --- a/crates/core/src/model/layouts.rs +++ /dev/null @@ -1,403 +0,0 @@ -use std::{collections::HashMap, fmt::Display}; -use serde::{Deserialize, Serialize}; -use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; -use crate::model::auth::DefaultTimelineChoice; - -/// Each different page which can be customized. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub enum CustomizablePage { - Home, - All, - Popular, -} - -/// Layouts allow you to customize almost every page in the Tetratto UI through -/// simple blocks. -#[derive(Serialize, Deserialize)] -pub struct Layout { - pub id: usize, - pub created: usize, - pub owner: usize, - pub title: String, - pub privacy: LayoutPrivacy, - pub pages: Vec, - pub replaces: CustomizablePage, -} - -impl Layout { - /// Create a new [`Layout`]. - pub fn new(title: String, owner: usize, replaces: CustomizablePage) -> Self { - Self { - id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp(), - owner, - title, - privacy: LayoutPrivacy::Public, - pages: Vec::new(), - replaces, - } - } -} - -/// The privacy of the layout, which controls who has the ability to view it. -#[derive(Serialize, Deserialize, PartialEq, Eq)] -pub enum LayoutPrivacy { - Public, - Private, -} - -impl Display for Layout { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut out = String::new(); - - for (i, page) in self.pages.iter().enumerate() { - let mut x = page.to_string(); - - if i == 0 { - x = x.replace("%?%", ""); - } else { - x = x.replace("%?%", "hidden"); - } - - out.push_str(&x); - } - - f.write_str(&out) - } -} - -/// Layouts are able to contain subpages within them. -/// -/// Each layout is only allowed 2 subpages pages, meaning one main page and one extra. -#[derive(Serialize, Deserialize)] -pub struct LayoutPage { - pub name: String, - pub blocks: Vec, - pub css: String, - pub js: String, -} - -impl Display for LayoutPage { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&format!( - "
{}
", - { - let mut out = String::new(); - - for block in &self.blocks { - out.push_str(&block.to_string()); - } - - out - }, - self.css, - self.js - )) - } -} - -/// Blocks are the basis of each layout page. They are simple and composable. -#[derive(Serialize, Deserialize)] -pub struct LayoutBlock { - pub r#type: BlockType, - pub children: Vec, -} - -impl LayoutBlock { - pub fn render_children(&self) -> String { - let mut out = String::new(); - - for child in &self.children { - out.push_str(&child.to_string()); - } - - out - } -} - -impl Display for LayoutBlock { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut out = String::new(); - - // head - out.push_str(&match self.r#type { - BlockType::Block(ref x) => format!("<{} {}>", x.element, x), - BlockType::Flexible(ref x) => format!("<{} {}>", x.element, x), - BlockType::Markdown(ref x) => format!("<{} {}>", x.element, x), - BlockType::Timeline(ref x) => format!("<{} {}>", x.element, x), - }); - - // body - out.push_str(&match self.r#type { - BlockType::Block(_) => self.render_children(), - BlockType::Flexible(_) => self.render_children(), - BlockType::Markdown(ref x) => x.sub_options.content.to_string(), - BlockType::Timeline(ref x) => { - format!( - "
", - x.sub_options.timeline - ) - } - }); - - // tail - out.push_str(&self.r#type.unwrap_cloned().element.tail()); - - // ... - f.write_str(&out) - } -} - -/// Each different type of block has different attributes associated with it. -#[derive(Serialize, Deserialize)] -pub enum BlockType { - Block(GeneralBlockOptions), - Flexible(GeneralBlockOptions), - Markdown(GeneralBlockOptions), - Timeline(GeneralBlockOptions), -} - -impl BlockType { - pub fn unwrap(self) -> GeneralBlockOptions> { - match self { - Self::Block(x) => x.boxed(), - Self::Flexible(x) => x.boxed(), - Self::Markdown(x) => x.boxed(), - Self::Timeline(x) => x.boxed(), - } - } - - pub fn unwrap_cloned(&self) -> GeneralBlockOptions> { - match self { - Self::Block(x) => x.boxed_cloned::(), - Self::Flexible(x) => x.boxed_cloned::(), - Self::Markdown(x) => x.boxed_cloned::(), - Self::Timeline(x) => x.boxed_cloned::(), - } - } -} - -#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum HtmlElement { - Div, - Span, - Italics, - Bold, - Heading1, - Heading2, - Heading3, - Heading4, - Heading5, - Heading6, - Image, -} - -impl HtmlElement { - pub fn tail(&self) -> String { - match self { - Self::Image => String::new(), - _ => format!(""), - } - } -} - -impl Display for HtmlElement { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::Div => "div", - Self::Span => "span", - Self::Italics => "i", - Self::Bold => "b", - Self::Heading1 => "h1", - Self::Heading2 => "h2", - Self::Heading3 => "h3", - Self::Heading4 => "h4", - Self::Heading5 => "h5", - Self::Heading6 => "h6", - Self::Image => "img", - }) - } -} - -/// This trait is used to provide cloning capabilities to structs which DO implement -/// clone, but we aren't allowed to tell the compiler that they implement clone -/// (through a trait bound), as Clone is not dyn compatible. -/// -/// Implementations for this trait should really just take reference to another -/// value (T), then just run `.to_owned()` on it. This means T and F (Self) MUST -/// be the same type. -pub trait RefFrom { - fn ref_from(value: &T) -> Self; -} - -#[derive(Serialize, Deserialize)] -pub struct GeneralBlockOptions -where - T: Display, -{ - pub element: HtmlElement, - pub class_list: String, - pub id: String, - pub attributes: HashMap, - pub sub_options: T, -} - -impl GeneralBlockOptions { - pub fn boxed(self) -> GeneralBlockOptions> { - GeneralBlockOptions { - element: self.element, - class_list: self.class_list, - id: self.id, - attributes: self.attributes, - sub_options: Box::new(self.sub_options), - } - } - - pub fn boxed_cloned + 'static>( - &self, - ) -> GeneralBlockOptions> { - let x: F = F::ref_from(&self.sub_options); - GeneralBlockOptions { - element: self.element.clone(), - class_list: self.class_list.clone(), - id: self.id.clone(), - attributes: self.attributes.clone(), - sub_options: Box::new(x), - } - } -} - -impl Display for GeneralBlockOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&format!( - "class=\"{} {}\" {} id={} {}", - self.class_list, - self.sub_options.to_string(), - { - let mut attrs = String::new(); - - for (k, v) in &self.attributes { - attrs.push_str(&format!("{k}=\"{v}\"")); - } - - attrs - }, - self.id, - if self.element == HtmlElement::Image { - "/" - } else { - "" - } - )) - } -} -#[derive(Clone, Serialize, Deserialize)] -pub struct EmptyBlockOptions; - -impl Display for EmptyBlockOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("") - } -} - -impl RefFrom for EmptyBlockOptions { - fn ref_from(value: &EmptyBlockOptions) -> Self { - value.to_owned() - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct FlexibleBlockOptions { - pub gap: FlexibleBlockGap, - pub direction: FlexibleBlockDirection, - pub wrap: bool, - pub collapse: bool, -} - -impl Display for FlexibleBlockOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&format!( - "flex {} {} {} {}", - self.gap, - self.direction, - if self.wrap { "flex-wrap" } else { "" }, - if self.collapse { "flex-collapse" } else { "" } - )) - } -} - -impl RefFrom for FlexibleBlockOptions { - fn ref_from(value: &FlexibleBlockOptions) -> Self { - value.to_owned() - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub enum FlexibleBlockGap { - Tight, - Comfortable, - Spacious, - Large, -} - -impl Display for FlexibleBlockGap { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::Tight => "gap-1", - Self::Comfortable => "gap-2", - Self::Spacious => "gap-3", - Self::Large => "gap-4", - }) - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub enum FlexibleBlockDirection { - Row, - Column, -} - -impl Display for FlexibleBlockDirection { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::Row => "flex-row", - Self::Column => "flex-col", - }) - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct MarkdownBlockOptions { - pub content: String, -} - -impl Display for MarkdownBlockOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("") - } -} - -impl RefFrom for MarkdownBlockOptions { - fn ref_from(value: &MarkdownBlockOptions) -> Self { - value.to_owned() - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct TimelineBlockOptions { - pub timeline: DefaultTimelineChoice, -} - -impl Display for TimelineBlockOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("w-full flex flex-col gap-2\" ui_ident=\"io_data_load") - } -} - -impl RefFrom for TimelineBlockOptions { - fn ref_from(value: &TimelineBlockOptions) -> Self { - value.to_owned() - } -} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index e825340..2cd4955 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -6,7 +6,6 @@ pub mod channels; pub mod communities; pub mod communities_permissions; pub mod journals; -pub mod layouts; pub mod littleweb; pub mod moderation; pub mod oauth;