diff --git a/Cargo.lock b/Cargo.lock index 75260ea..56b0651 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3318,7 +3318,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "14.0.0" +version = "15.0.0" dependencies = [ "ammonia", "async-stripe", @@ -3350,7 +3350,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "14.0.0" +version = "15.0.0" dependencies = [ "async-recursion", "base16ct", diff --git a/Cargo.toml b/Cargo.toml index b5beca0..c25b56f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,9 @@ package.homepage = "https://tetratto.com" incremental = true [profile.release] -opt-level = "z" +opt-level = 3 lto = true -codegen-units = 1 +codegen-units = 2 # panic = "abort" panic = "unwind" strip = true diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 4fb69e9..bbbe59c 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "14.0.0" +version = "15.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 3ab9d32..fcc76f0 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -38,6 +38,7 @@ pub const STREAMS_JS: &str = include_str!("./public/js/streams.js"); pub const CARP_JS: &str = include_str!("./public/js/carp.js"); pub const PROTO_LINKS_JS: &str = include_str!("./public/js/proto_links.js"); pub const APP_SDK_JS: &str = include_str!("./public/js/app_sdk.js"); +pub const ADS_JS: &str = include_str!("./public/js/ads.js"); // html pub const BODY: &str = include_str!("./public/html/body.lisp"); @@ -55,7 +56,6 @@ pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp"); pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp"); pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.lisp"); pub const AUTH_CONNECTION: &str = include_str!("./public/html/auth/connection.lisp"); -pub const AUTH_SELLER_CONNECTION: &str = include_str!("./public/html/auth/seller_connection.lisp"); pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.lisp"); pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.lisp"); @@ -70,6 +70,7 @@ pub const PROFILE_REPLIES: &str = include_str!("./public/html/profile/replies.li pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.lisp"); pub const PROFILE_OUTBOX: &str = include_str!("./public/html/profile/outbox.lisp"); pub const PROFILE_RESPONSES: &str = include_str!("./public/html/profile/responses.lisp"); +pub const PROFILE_SHOP: &str = include_str!("./public/html/profile/shop.lisp"); pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.lisp"); pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.lisp"); @@ -88,6 +89,8 @@ pub const POST_POST: &str = include_str!("./public/html/post/post.lisp"); pub const POST_REPOSTS: &str = include_str!("./public/html/post/reposts.lisp"); pub const POST_QUOTES: &str = include_str!("./public/html/post/quotes.lisp"); pub const POST_LIKES: &str = include_str!("./public/html/post/likes.lisp"); +pub const POST_FORUM_QUICK_REPLIES: &str = + include_str!("./public/html/post/forum_quick_replies.lisp"); pub const TIMELINES_HOME: &str = include_str!("./public/html/timelines/home.lisp"); pub const TIMELINES_POPULAR: &str = include_str!("./public/html/timelines/popular.lisp"); @@ -103,6 +106,8 @@ pub const TIMELINES_ALL_QUESTIONS: &str = include_str!("./public/html/timelines/all_questions.lisp"); pub const TIMELINES_SEARCH: &str = include_str!("./public/html/timelines/search.lisp"); pub const TIMELINES_SWISS_ARMY: &str = include_str!("./public/html/timelines/swiss_army.lisp"); +pub const TIMELINES_ALL_FORUM_POSTS: &str = + include_str!("./public/html/timelines/all_forum_posts.lisp"); pub const MOD_AUDIT_LOG: &str = include_str!("./public/html/mod/audit_log.lisp"); pub const MOD_REPORTS: &str = include_str!("./public/html/mod/reports.lisp"); @@ -139,13 +144,18 @@ pub const LITTLEWEB_SERVICE: &str = include_str!("./public/html/littleweb/servic pub const LITTLEWEB_DOMAIN: &str = include_str!("./public/html/littleweb/domain.lisp"); pub const LITTLEWEB_BROWSER: &str = include_str!("./public/html/littleweb/browser.lisp"); -pub const MARKETPLACE_SELLER: &str = include_str!("./public/html/marketplace/seller.lisp"); - pub const MAIL_RECEIVED: &str = include_str!("./public/html/mail/received.lisp"); pub const MAIL_SENT: &str = include_str!("./public/html/mail/sent.lisp"); pub const MAIL_COMPOSE: &str = include_str!("./public/html/mail/compose.lisp"); pub const MAIL_LETTER: &str = include_str!("./public/html/mail/letter.lisp"); +pub const ECONOMY_WALLET: &str = include_str!("./public/html/economy/wallet.lisp"); +pub const ECONOMY_PRODUCTS: &str = include_str!("./public/html/economy/products.lisp"); +pub const ECONOMY_EDIT: &str = include_str!("./public/html/economy/edit.lisp"); +pub const ECONOMY_PRODUCT: &str = include_str!("./public/html/economy/product.lisp"); +pub const ECONOMY_EDIT_AD: &str = include_str!("./public/html/economy/edit_ad.lisp"); +pub const ECONOMY_AD: &str = include_str!("./public/html/economy/ad.lisp"); + // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -293,7 +303,6 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config --lisp plugins); write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER) --config=config --lisp plugins); write_template!(html_path->"auth/connection.html"(crate::assets::AUTH_CONNECTION) --config=config --lisp plugins); - write_template!(html_path->"auth/seller_connection.html"(crate::assets::AUTH_SELLER_CONNECTION) --config=config --lisp plugins); write_template!(html_path->"profile/base.html"(crate::assets::PROFILE_BASE) -d "profile" --config=config --lisp plugins); write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config --lisp plugins); @@ -308,6 +317,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"profile/media.html"(crate::assets::PROFILE_MEDIA) --config=config --lisp plugins); write_template!(html_path->"profile/outbox.html"(crate::assets::PROFILE_OUTBOX) --config=config --lisp plugins); write_template!(html_path->"profile/responses.html"(crate::assets::PROFILE_RESPONSES) --config=config --lisp plugins); + write_template!(html_path->"profile/shop.html"(crate::assets::PROFILE_SHOP) --config=config --lisp plugins); write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config --lisp plugins); write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config --lisp plugins); @@ -325,6 +335,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"post/reposts.html"(crate::assets::POST_REPOSTS) --config=config --lisp plugins); write_template!(html_path->"post/quotes.html"(crate::assets::POST_QUOTES) --config=config --lisp plugins); write_template!(html_path->"post/likes.html"(crate::assets::POST_LIKES) --config=config --lisp plugins); + write_template!(html_path->"post/forum_quick_replies.html"(crate::assets::POST_FORUM_QUICK_REPLIES) --config=config --lisp plugins); write_template!(html_path->"timelines/home.html"(crate::assets::TIMELINES_HOME) -d "timelines" --config=config --lisp plugins); write_template!(html_path->"timelines/popular.html"(crate::assets::TIMELINES_POPULAR) --config=config --lisp plugins); @@ -336,6 +347,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"timelines/all_questions.html"(crate::assets::TIMELINES_ALL_QUESTIONS) --config=config --lisp plugins); write_template!(html_path->"timelines/search.html"(crate::assets::TIMELINES_SEARCH) --config=config --lisp plugins); write_template!(html_path->"timelines/swiss_army.html"(crate::assets::TIMELINES_SWISS_ARMY) --config=config --lisp plugins); + write_template!(html_path->"timelines/all_forum_posts.html"(crate::assets::TIMELINES_ALL_FORUM_POSTS) --config=config --lisp plugins); write_template!(html_path->"mod/audit_log.html"(crate::assets::MOD_AUDIT_LOG) -d "mod" --config=config --lisp plugins); write_template!(html_path->"mod/reports.html"(crate::assets::MOD_REPORTS) --config=config --lisp plugins); @@ -372,13 +384,18 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"littleweb/domain.html"(crate::assets::LITTLEWEB_DOMAIN) --config=config --lisp plugins); write_template!(html_path->"littleweb/browser.html"(crate::assets::LITTLEWEB_BROWSER) --config=config --lisp plugins); - write_template!(html_path->"marketplace/seller.html"(crate::assets::MARKETPLACE_SELLER) -d "marketplace" --config=config --lisp plugins); - write_template!(html_path->"mail/received.html"(crate::assets::MAIL_RECEIVED) -d "mail" --config=config --lisp plugins); write_template!(html_path->"mail/sent.html"(crate::assets::MAIL_SENT) --config=config --lisp plugins); write_template!(html_path->"mail/compose.html"(crate::assets::MAIL_COMPOSE) --config=config --lisp plugins); write_template!(html_path->"mail/letter.html"(crate::assets::MAIL_LETTER) --config=config --lisp plugins); + write_template!(html_path->"economy/wallet.html"(crate::assets::ECONOMY_WALLET) -d "economy" --config=config --lisp plugins); + write_template!(html_path->"economy/products.html"(crate::assets::ECONOMY_PRODUCTS) --config=config --lisp plugins); + write_template!(html_path->"economy/edit.html"(crate::assets::ECONOMY_EDIT) --config=config --lisp plugins); + write_template!(html_path->"economy/product.html"(crate::assets::ECONOMY_PRODUCT) --config=config --lisp plugins); + write_template!(html_path->"economy/edit_ad.html"(crate::assets::ECONOMY_EDIT_AD) --config=config --lisp plugins); + write_template!(html_path->"economy/ad.html"(crate::assets::ECONOMY_AD) --config=config --lisp plugins); + html_path } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index fdd6ffc..e06e6ed 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -20,6 +20,7 @@ version = "1.0.0" "general:link.achievements" = "Achievements" "general:link.little_web" = "Little web" "general:link.mail" = "Mail" +"general:link.wallet" = "Wallet" "general:action.save" = "Save" "general:action.delete" = "Delete" "general:action.purge" = "Purge" @@ -50,6 +51,8 @@ version = "1.0.0" "general:label.loading" = "Working on it!" "general:label.send_anonymously" = "Send anonymously" "general:label.must_activate_account" = "You need to activate your account!" +"general:action.load_more" = "Load more" +"general:action.show_thread" = "Show thread" "general:label.supporter_motivation" = "Become a supporter!" "general:action.become_supporter" = "Become supporter" @@ -85,6 +88,7 @@ version = "1.0.0" "auth:label.replies" = "Replies" "auth:label.media" = "Media" "auth:label.outbox" = "Outbox" +"auth:label.shop" = "Shop" "auth:label.before_you_view" = "Before you view" "auth:label.private_profile" = "Private profile" "auth:label.private_profile_message" = "This profile is private, meaning you can only view it if they follow you." @@ -116,7 +120,9 @@ version = "1.0.0" "communities:label.content" = "Content" "communities:label.title" = "Title" "communities:label.posts" = "Posts" +"communities:label.forum_posts" = "Forum posts" "communities:label.topics" = "Topics" +"communities:label.topic" = "Topic" "communities:label.questions" = "Questions" "communities:label.not_allowed_to_read" = "You're not allowed to view this community's posts" "communities:label.might_need_to_join" = "You might need to join this community in order to interact with it!" @@ -202,6 +208,7 @@ version = "1.0.0" "settings:tab.billing" = "Billing" "settings:tab.uploads" = "Uploads" "settings:tab.invites" = "Invites" +"setttings:label.applied_configurations" = "Applied configurations" "mod_panel:label.open_reported_content" = "Open reported content" "mod_panel:label.manage_profile" = "Manage profile" @@ -212,6 +219,7 @@ version = "1.0.0" "mod_panel:label.invited_by" = "Invited by" "mod_panel:label.send_debug_payload" = "Send debug payload" "mod_panel:label.ban_reason" = "Ban reason" +"mod_panel:label.ban_expiration" = "Ban expiration" "requests:label.requests" = "Requests" "requests:label.community_join_request" = "Community join request" @@ -221,6 +229,7 @@ version = "1.0.0" "requests:label.user_follow_request" = "User follow request" "requests:action.view_profile" = "View profile" "requests:label.user_follow_request_message" = "Accepting this request will not allow them to see your profile. For that, you must follow them back." +"requests:label.coin_transfer_request" = "Coin transfer request" "chats:label.my_chats" = "My chats" "chats:action.move" = "Move" @@ -311,12 +320,6 @@ version = "1.0.0" "littleweb:action.rename" = "Rename" "littleweb:action.add" = "Add" -"marketplace:label.products" = "Products" -"marketplace:label.status" = "Status" -"marketplace:action.get_started" = "Get started" -"marketplace:action.finsh_setting_up_account" = "Finish setting up my account" -"marketplace:action.open_seller_dashboard" = "Open seller dashboard" - "mail:label.received" = "Received" "mail:label.sent" = "Sent" "mail:label.compose" = "Compose" @@ -325,3 +328,32 @@ version = "1.0.0" "mail:label.content" = "Content" "mail:action.send" = "Send" "mail:action.send_mail" = "Send mail" + +"economy:label.recent_transfers" = "Recent transfers" +"economy:action.request" = "Request" +"economy:label.title" = "Title" +"economy:label.description" = "Description" +"economy:label.my_products" = "My products" +"economy:label.my_wallet" = "My wallet" +"economy:label.create_new" = "Create new product" +"economy:label.price" = "Price" +"economy:label.on_sale" = "On sale" +"economy:label.single_use" = "Only allow users to purchase once" +"economy:label.stock" = "Stock" +"economy:label.unlimited" = "Unlimited" +"economy:label.fulfillment_style" = "Fulfillment style" +"economy:label.use_automail" = "Use automail" +"economy:label.automail_message" = "Automail message" +"economy:action.buy" = "Buy" +"economy:label.already_purchased" = "Already purchased" +"economy:label.snippet_data" = "Snippet data" +"economy:action.apply" = "Apply" +"economy:action.unapply" = "Unapply" +"economy:label.thumbnails" = "Thumbnails" +"economy:label.my_ads" = "My ads" +"economy:label.create_new_ad" = "Create new advertisement" +"economy:label.target" = "Target URL" +"economy:label.image" = "Image" +"economy:label.size_base" = "Size base" +"economy:label.running" = "Running" +"economy:label.embed_ads_on_my_site" = "Embed ads on my site" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 9fbda85..c50c68d 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -87,10 +87,31 @@ macro_rules! get_user_from_token { { Ok(ua) => { if ua.permissions.check_banned() { - let mut banned_user = tetratto_core::model::auth::User::banned(); - banned_user.ban_reason = ua.ban_reason; + // check expiration + let now = tetratto_shared::unix_epoch_timestamp(); + let expired = ua.ban_expire <= now; - Some(banned_user) + if expired && ua.ban_expire != 0 { + $db.update_user_role( + ua.id, + ua.permissions + - tetratto_core::model::permissions::FinePermission::BANNED, + &ua, + true, + ) + .await + .expect("failed to auto unban user"); + + Some(ua) + } else { + // banned + let mut banned_user = tetratto_core::model::auth::User::banned(); + + banned_user.ban_reason = ua.ban_reason; + banned_user.ban_expire = ua.ban_expire; + + Some(banned_user) + } } else { Some(ua) } diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index a0c2a18..1a53a2e 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -5,22 +5,26 @@ --hue: 16; --sat: 6%; --lit: 0%; - --color-surface: hsl(var(--hue), var(--sat), calc(97% - var(--lit))); - --color-lowered: hsl(var(--hue), var(--sat), calc(94% - var(--lit))); - --color-raised: hsl(var(--hue), var(--sat), calc(99% - var(--lit))); + --color-surface: hsl(var(--hue), var(--sat), calc(94% - var(--lit))); + --color-lowered: hsl(var(--hue), var(--sat), calc(90% - var(--lit))); + --color-raised: hsl(var(--hue), var(--sat), calc(97% - var(--lit))); --color-super-lowered: hsl(var(--hue), var(--sat), calc(85% - var(--lit))); - --color-super-raised: hsl(var(--hue), var(--sat), calc(100% - var(--lit))); - --color-text: hsl(0, 0%, 0%); + --color-super-raised: hsl(var(--hue), var(--sat), calc(99% - var(--lit))); + --color-text: hsl(0, 0%, 9%); --color-text-raised: var(--color-text); --color-text-lowered: var(--color-text); --color-primary: hsl(330, 18%, 26%); --color-primary-lowered: hsl(330, 18%, 21%); - --color-text-primary: hsl(0, 0%, 100%); + --color-text-primary: hsl(0, 0%, 91%); - --color-secondary: hsl(6, 18%, 66%); - --color-secondary-lowered: hsl(6, 18%, 61%); - --color-text-secondary: hsl(0, 0%, 0%); + --color-secondary: hsl(277, 27%, 70%); + --color-secondary-lowered: hsl(277, 27%, 65%); + --color-text-secondary: hsl(0, 0%, 9%); + + --color-accent: hsl(237, 27%, 28%); + --color-accent-lowered: hsl(237, 27%, 23%); + --color-text-accent: hsl(0, 0%, 91%); --color-link: #2949b2; --color-shadow: rgba(0, 0, 0, 0.08); @@ -54,15 +58,19 @@ --color-raised: hsl(var(--hue), var(--sat), calc(2% + var(--lit))); --color-super-lowered: hsl(var(--hue), var(--sat), calc(12% + var(--lit))); --color-super-raised: hsl(var(--hue), var(--sat), calc(4% + var(--lit))); - --color-text: hsl(0, 0%, 95%); + --color-text: hsl(0, 0%, 91%); --color-primary: hsl(331, 18%, 74%); --color-primary-lowered: hsl(331, 18%, 69%); - --color-text-primary: hsl(0, 0%, 0%); + --color-text-primary: hsl(0, 0%, 9%); - --color-secondary: hsl(6, 18%, 34%); - --color-secondary-lowered: hsl(6, 18%, 29%); - --color-text-secondary: hsl(0, 0%, 100%); + --color-secondary: hsl(277, 27%, 30%); + --color-secondary-lowered: hsl(277, 27%, 25%); + --color-text-secondary: hsl(0, 0%, 91%); + + --color-accent: hsl(237, 27%, 72%); + --color-accent-lowered: hsl(237, 27%, 67%); + --color-text-accent: hsl(0, 0%, 9%); --color-link: #93c5fd; --color-red: hsl(0, 94%, 82%); @@ -193,8 +201,7 @@ p { .name { max-width: 250px; overflow: hidden; - /* overflow-wrap: break-word; */ - overflow-wrap: anywhere; + white-space: nowrap; text-overflow: ellipsis; } @@ -242,7 +249,8 @@ svg.icon { height: 1em; } -svg.icon.filled { +svg.icon.filled, +.filled svg.icon { fill: currentColor; } diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 88a1864..52f3464 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -1,5 +1,11 @@ @import url("root.css"); +/* ads */ +.tetratto_ad iframe { + border-radius: var(--radius); +} + +/* media gallery */ .media_gallery { display: grid; grid-auto-columns: 1fr 1fr; @@ -11,7 +17,9 @@ @media screen and (max-width: 900px) { .media_gallery { - grid-auto-flow: row dense; + /* grid-auto-flow: row dense; */ /* safari is the most shit browser ever dude, this property causes safari to make images overlap for lord knows why */ + display: flex; + flex-direction: column; } } @@ -271,6 +279,16 @@ table ol { } } +.card.button { + justify-content: flex-start; + align-items: flex-start; + flex-direction: column; + gap: var(--pad-2); + width: 100%; + height: max-content; + padding: var(--pad-4); +} + /* supporter card */ @property --border-angle { syntax: ""; @@ -312,7 +330,7 @@ button, transition: background 0.15s; width: max-content; height: 32px; - padding: var(--pad-1) var(--pad-4); + padding: 0 var(--pad-4); border-radius: var(--radius); cursor: pointer; display: flex; @@ -369,7 +387,17 @@ button.secondary, .button.secondary { background: var(--color-secondary); color: var(--color-text-secondary); - font-weight: 500; +} + +button.accent:hover, +.button.accent:hover { + background: var(--color-accent-lowered); +} + +button.accent, +.button.accent { + background: var(--color-accent); + color: var(--color-text-accent); } button.secondary:hover, @@ -439,7 +467,6 @@ textarea, select { padding: 0.35rem var(--pad-3); border-radius: var(--radius); - border: solid 1px var(--color-super-lowered); outline: none; transition: background 0.15s; resize: vertical; @@ -447,8 +474,10 @@ select { font-family: inherit; font-size: 16px; /* personality */ - background: transparent; - color: inherit; + --background: var(--color-lowered); + border: solid 1px var(--background); + background: var(--background); + color: var(--color-text-lowered); } textarea { @@ -458,8 +487,14 @@ textarea { input:focus, textarea:focus, select:focus { - background: var(--color-super-raised); - color: var(--color-text-raised); + border-color: var(--color-super-lowered); +} + +input:disabled, +textarea:disabled, +select:disabled { + opacity: 50%; + cursor: not-allowed; } .poll_bar { @@ -491,7 +526,7 @@ select:focus { } input[type="checkbox"] { - --color: #c9b1bc; + --color: #93c5fd; appearance: none; border-radius: var(--radius); transition: @@ -528,6 +563,10 @@ input[type="checkbox"]:checked { background-image: url("/icons/check.svg"); } +label { + cursor: pointer; +} + /* pillmenu */ .pillmenu { display: flex; @@ -860,18 +899,19 @@ dialog { display: none; background: var(--color-surface); border: solid 1px var(--color-super-lowered) !important; - border-radius: var(--radius); - max-width: 100%; + border-radius: calc(var(--radius) * 2); + max-width: 95%; border-style: none; margin: auto; color: var(--color-text); - animation: popin ease-in-out 1 0.1s forwards running; + animation: popin ease-in-out 1 0.15s forwards running; } dialog .inner { padding: var(--pad-4); width: 25rem; max-width: 100%; + font-weight: 500; } dialog .inner hr:not(.flipped):last-of-type { @@ -890,6 +930,11 @@ dialog[open] { dialog::backdrop { background: hsla(0, 0%, 0%, 50%); backdrop-filter: blur(5px); + animation: fadein ease-in-out 1 0.1s forwards running; +} + +dialog:is(.dark *)::backdrop { + background: hsla(0, 0%, 100%, 15%); } /* dropdown */ @@ -902,9 +947,9 @@ dialog::backdrop { display: none; position: absolute; background: var(--color-raised); - border: solid 1px var(--color-super-lowered); + /* border: solid 1px var(--color-super-lowered); */ z-index: 2; - border-radius: var(--radius); + border-radius: calc(var(--radius) * 2); top: calc(100% + 5px); right: 0; width: max-content; @@ -1404,3 +1449,55 @@ details.accordion .inner { background-color: var(--color-primary) !important; color: var(--color-text-primary) !important; } + +/* threads */ +.squig { + --color: var(--color-super-lowered); + --background: var(--color-raised); + --size: 10px; + position: relative; + width: 100%; + height: 20px; + display: block; +} + +.squig::before, +.squig::after { + content: ""; + position: absolute; + width: inherit; + height: inherit; + background-size: var(--size) 100%; +} + +.squig::before { + top: -2px; + background-image: + linear-gradient(45deg, var(--color) 35%, transparent 0), + linear-gradient(-45deg, var(--color) 35%, transparent 0); +} + +.squig::after { + top: 0px; + background-image: + linear-gradient(45deg, var(--background) 35%, transparent 0), + linear-gradient(-45deg, var(--background) 35%, transparent 0); +} + +.thread { + --pad: 15px; + --squig-height: 20px; + position: relative; + padding-left: var(--pad); +} + +.thread::before { + content: ""; + position: absolute; + background: var(--color-super-lowered); + height: calc(100% - var(--squig-height)); + width: 5px; + left: 0; + top: 0; + border-radius: var(--radius); +} diff --git a/crates/app/src/public/css/utility.css b/crates/app/src/public/css/utility.css index 4b0460f..f5f834f 100644 --- a/crates/app/src/public/css/utility.css +++ b/crates/app/src/public/css/utility.css @@ -35,7 +35,7 @@ justify-content: right; } -.justify-start { +.justify_start { justify-content: flex-start !important; } diff --git a/crates/app/src/public/html/auth/base.lisp b/crates/app/src/public/html/auth/base.lisp index 4940089..e9a8b9b 100644 --- a/crates/app/src/public/html/auth/base.lisp +++ b/crates/app/src/public/html/auth/base.lisp @@ -3,7 +3,7 @@ ("class" "flex flex_col gap_2") ("style" "max-width: 48ch") (h2 - ("class" "w_full text-center") + ("class" "w_full text_center") ; block for title (text "{% block title %}{% endblock %}")) (div diff --git a/crates/app/src/public/html/auth/login.lisp b/crates/app/src/public/html/auth/login.lisp index 9c1e2e4..c2e3cf0 100644 --- a/crates/app/src/public/html/auth/login.lisp +++ b/crates/app/src/public/html/auth/login.lisp @@ -113,10 +113,9 @@ (text "{% endblock %} {% block footer %}") (span - ("class" "small w_full text-center") + ("class" "small w_full text_center") (text "Or, ") (a ("href" "/auth/register") (text "register"))) - (text "{% endblock %}") diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp index 4beb2fb..0fd9ae9 100644 --- a/crates/app/src/public/html/auth/register.lisp +++ b/crates/app/src/public/html/auth/register.lisp @@ -170,10 +170,9 @@ (text "{% endblock %} {% block footer %}") (span - ("class" "small w_full text-center") + ("class" "small w_full text_center") (text "Or, ") (a ("href" "/auth/login") (text "login"))) - (text "{% endblock %}") diff --git a/crates/app/src/public/html/auth/seller_connection.lisp b/crates/app/src/public/html/auth/seller_connection.lisp deleted file mode 100644 index c13b498..0000000 --- a/crates/app/src/public/html/auth/seller_connection.lisp +++ /dev/null @@ -1,25 +0,0 @@ -(text "{% extends \"auth/base.html\" %} {% block head %}") -(title - (text "Connection")) - -(text "{% endblock %} {% block title %}Connection{% endblock %} {% block content %}") -(div - ("class" "w_full flex_col gap_2") - ("id" "status") - (b - (text "Working..."))) - -(text "{% if connection_type == \"refresh\" %}") -(script - ("defer" "true") - (text "setTimeout(async () => { - trigger(\"seller::onboarding\"); - }, 1000);")) -(text "{% elif connection_type == \"return\" %}") -(script - ("defer" "true") - (text "setTimeout(async () => { - document.getElementById(\"status\").innerHTML = - `Account updated. You can now close this tab.`; - }, 1000);")) -(text "{%- endif %} {% endblock %}") diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp index 00dcfd8..599106e 100644 --- a/crates/app/src/public/html/body.lisp +++ b/crates/app/src/public/html/body.lisp @@ -1,5 +1,10 @@ (div ("id" "toast_zone")) +; ads +(script ("src" "/js/ads.js?v=tetratto-{{ random_cache_breaker }}")) +(script + (text "TetrattoAds.init();")) + ; large text (text "{% if user and user.settings.large_text -%}") (style @@ -76,6 +81,8 @@ return; } + TetrattoAds.render_ads(\"{{ config.system_user }}\", \"\"); + atto.disconnect_observers(); atto.remove_false_options(); atto.clean_date_codes(); @@ -169,21 +176,20 @@ ("id" "littleweb") (div ("class" "inner flex flex_col gap_2") - (a - ("class" "button w_full lowered justify-start") + ("class" "button w_full lowered justify_start") ("href" "/net") (icon (text "globe")) (str (text "littleweb:label.browser"))) (a - ("class" "button w_full lowered justify-start") + ("class" "button w_full lowered justify_start") ("href" "/services") (icon (text "panel-top")) (str (text "littleweb:label.my_services"))) (a - ("class" "button w_full lowered justify-start") + ("class" "button w_full lowered justify_start") ("href" "/domains") (icon (text "panel-top")) (str (text "littleweb:label.my_domains"))) @@ -311,7 +317,7 @@ (div ("id" "tokens") ("style" "display: contents")) (div - ("class" "flex justify_between") + ("class" "flex justify_right gap_2") (a ("href" "/auth/login") ("class" "button") @@ -323,7 +329,8 @@ ("class" "lowered") ("onclick" "document.getElementById('tokens_dialog').close()") ("type" "button") - (icon (text "check"))))))) + (icon (text "check")) + (str (text "dialog:action.okay"))))))) ; user scripts (text "{%- endif %} {% if user and use_user_theme -%} {{ components::theme(user=user, theme_preference=user.settings.theme_preference) }} diff --git a/crates/app/src/public/html/chats/app.lisp b/crates/app/src/public/html/chats/app.lisp index 8d0b3d4..e64dc0b 100644 --- a/crates/app/src/public/html/chats/app.lisp +++ b/crates/app/src/public/html/chats/app.lisp @@ -73,7 +73,7 @@ (text "{%- endif %}")) (text "{% if can_manage_channels -%}") (a - ("class" "button w_full justify-start lowered") + ("class" "button w_full justify_start lowered") ("href" "/community/{{ selected_community }}/manage#/channels") (text "{{ icon \"plus\" }}") (span diff --git a/crates/app/src/public/html/chats/channels.lisp b/crates/app/src/public/html/chats/channels.lisp index 2e12b18..fb61be0 100644 --- a/crates/app/src/public/html/chats/channels.lisp +++ b/crates/app/src/public/html/chats/channels.lisp @@ -7,7 +7,7 @@ (div ("class" "flex flex_row gap_1") (a - ("class" "w_full justify-start button {% if selected_channel == channel.id -%}lowered{% else %}camo{%- endif %}") + ("class" "w_full justify_start button {% if selected_channel == channel.id -%}lowered{% else %}camo{%- endif %}") ("href" "/chats/{{ selected_community }}/{{ channel.id }}") ("data-turbo" "{{ selected_community == '0' }}") (text "{{ icon \"rss\" }}") diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index 09b55f8..62a1c00 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -109,6 +109,23 @@ ("class" "card flex flex_col gap_2") ("id" "create_form") ("onsubmit" "create_post_from_form(event)") + (text "{% if show_topics -%}") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "topic") + (str (text "communities:label.topic"))) + (select + ("id" "topic") + ("name" "topic") + (text "{% for id, topic in topics %}") + (option + ("value" "{{ id }}") + ("selected" "{% if selected_topic|int == id|int -%}true{% else %}false{%- endif %}") + (text "{{ topic.title }}")) + (text "{% endfor %}"))) + (text "{%- endif %}") + (div ("class" "flex flex_col gap_1 hidden") ("id" "title_field") @@ -134,7 +151,12 @@ ("placeholder" "content") ("minlength" "2") ("maxlength" "4096") - (text "{% if draft -%}{{ draft.content }}{%- endif %}"))) + (text "{% if draft -%}{{ draft.content }}{%- endif %}") + (text "{%- if use_signature %}") + (text " + +{{ user.settings.forum_signature }}") + (text "{%- endif %}"))) (div ("id" "files_list") ("class" "flex gap_2 flex_wrap")) @@ -160,11 +182,10 @@ (text "{%- endif %} {%- endif %}") (button ("class" "primary") - (text "{{ text \"communities:action.create\" }}")))))) + (str (text "communities:action.create"))))))) (text "{% if not quoting -%}") (script - (text "globalThis.SEARCH_PARAMS = new URLSearchParams(window.location.search); - async function create_post_from_form(e) { + (text "async function create_post_from_form(e) { e.preventDefault(); await trigger(\"atto::debounce\", [\"posts::create\"]); @@ -205,7 +226,7 @@ content: e.target.content.value, community: !is_selected_stack ? selected_community : \"0\", stack: is_selected_stack ? selected_community : \"0\", - topic: !is_selected_stack ? SEARCH_PARAMS.get(\"topic\") || \"0\" : \"0\", + topic: (e.target.topic || { selectedOptions: [{ value: \"0\" }] }).selectedOptions[0].value, poll: poll_data[1], title: e.target.title.value, }), @@ -452,14 +473,19 @@ } setTimeout(() => { - update_community_avatar({ - target: document.getElementById(\"community_to_post_to\"), - }); + const fake_select = { + parentElement: document.getElementById(\"community_to_post_to\").parentElement, + selectedOptions: [{ + value: \"{{ selected_community }}\", + getAttribute() { + return \"{{ selected_stack != 0 }}\"; + } + }], + }; - check_community_supports_title({ - target: document.getElementById(\"community_to_post_to\"), - }); - }, 250); + update_community_avatar({ target: fake_select }); + check_community_supports_title({ target: fake_select }); + }, 150); window.cancel_create_post = async () => { if ( diff --git a/crates/app/src/public/html/communities/feed.lisp b/crates/app/src/public/html/communities/feed.lisp index 9c73512..ccf868e 100644 --- a/crates/app/src/public/html/communities/feed.lisp +++ b/crates/app/src/public/html/communities/feed.lisp @@ -1,18 +1,7 @@ (text "{% import \"components.html\" as components %} {% extends \"communities/base.html\" %} {% block content %}") (div ("class" "flex flex_col gap_4 w_full") - (text "{{ macros::community_nav(community=community, selected=\"posts\") }} {% if pinned|length != 0 %}") - (div - ("class" "card_nest") - (div - ("class" "card small flex gap_2 items_center") - (text "{{ icon \"pin\" }}") - (span - (text "{{ text \"communities:label.pinned\" }}"))) - (div - ("class" "card flex flex_col gap_4") - (text "{% for post in pinned %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts, poll=post[4]) }} {%- endif %} {% endfor %}"))) - (text "{%- endif %}") + (text "{{ macros::community_nav(community=community, selected=\"posts\") }}") (div ("class" "card_nest") (div @@ -22,5 +11,6 @@ (text "{{ text \"communities:label.posts\" }}"))) (div ("class" "card flex flex_col gap_4") + (text "{% for post in pinned %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts, poll=post[4]) }} {%- endif %} {% endfor %}") (text "{% for post in feed %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts, poll=post[4]) }} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=feed|length) }}")))) (text "{% endblock %}") diff --git a/crates/app/src/public/html/communities/list.lisp b/crates/app/src/public/html/communities/list.lisp index 5c0abac..e65cd8a 100644 --- a/crates/app/src/public/html/communities/list.lisp +++ b/crates/app/src/public/html/communities/list.lisp @@ -37,9 +37,9 @@ ("name" "is_forum") ("class" "w_content")) (span - (text "Is forum"))) + (text "Make this a forum community"))) (button - (text "{{ text \"communities:action.create\" }}")))) + (str (text "communities:action.create"))))) (text "{% if list|length >= 4 -%} {{ components::supporter_ad(body=\"Become a supporter to create up to 10 communities!\") }} {%- endif %} {%- endif %}") (div ("class" "card_nest w_full") diff --git a/crates/app/src/public/html/communities/settings.lisp b/crates/app/src/public/html/communities/settings.lisp index 239d3cc..622b569 100644 --- a/crates/app/src/public/html/communities/settings.lisp +++ b/crates/app/src/public/html/communities/settings.lisp @@ -1,7 +1,6 @@ (text "{% extends \"root.html\" %} {% block head %}") (title (text "Community settings - {{ config.name }}")) - (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex_col gap_2") @@ -37,14 +36,14 @@ (text "{{ icon \"rss\" }}") (span (text "{{ text \"communities:tab.channels\" }}"))) - (text "{%- endif %} {% if community.is_forum -%}") + (text "{%- endif %}") (a ("href" "#/topics") ("data-tab-button" "topics") (icon (text "list")) (span (str (text "communities:tab.topics")))) - (text "{%- endif %} {% if can_manage_emojis -%}") + (text "{% if can_manage_emojis -%}") (a ("href" "#/emojis") ("data-tab-button" "emojis") @@ -303,7 +302,7 @@ ("minlength" "2") ("maxlength" "32"))) (button - (text "{{ text \"communities:action.create\" }}")))) + (str (text "communities:action.create"))))) (text "{% for channel in channels %}") (div ("class" "card_nest") @@ -447,7 +446,7 @@ (text "{{ text \"communities:label.upload\" }}"))) (form ("class" "card flex flex_col gap_2") - ("onsubmit" "upload_emoji(event)") + ("onsubmit" "create_emoji_from_form(event)") (div ("class" "flex flex_col gap_1") (label @@ -473,11 +472,10 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp") ("class" "w_full"))) (button - (text "{{ text \"communities:action.create\" }}")) + (str (text "communities:action.create"))) (span ("class" "fade") - (text "Emojis can be a maximum of 256 KiB, or 512x512px (width x - height).")))) + (text "Emojis can be a maximum of 256 KiB.")))) (text "{% for emoji in emojis %}") (div ("class" "card secondary flex flex_wrap gap_2 items_center justify_between") @@ -506,7 +504,7 @@ (text "{{ text \"stacks:label.remove\" }}"))))) (text "{% endfor %}")) (script - (text "globalThis.upload_emoji = (e) => { + (text "globalThis.create_emoji_from_form = (e) => { e.preventDefault(); e.target.querySelector(\"button\").style.display = \"none\"; @@ -525,6 +523,10 @@ ]); e.target.querySelector(\"button\").removeAttribute(\"style\"); + + if (res.ok) { + e.target.reset(); + } }); alert(\"Emoji upload in progress. Please wait!\"); @@ -644,7 +646,7 @@ ("min" "0") ("max" "256"))) (button - (text "{{ text \"communities:action.create\" }}")))) + (str (text "communities:action.create"))))) (text "{% for id, topic in community.topics %}") (div ("class" "card_nest") @@ -686,12 +688,12 @@ (div ("class" "flex flex_col gap_1") (label - ("for" "description") + ("for" "{{ id }}-description") (str (text "communities:label.description"))) (input ("type" "text") ("name" "description") - ("id" "description") + ("id" "{{ id }}-description") ("placeholder" "description") ("value" "{{ topic.description }}") ("required" "") @@ -700,12 +702,12 @@ (div ("class" "flex flex_col gap_1") (label - ("for" "color") + ("for" "{{ id }}-color") (str (text "communities:label.color"))) (input ("type" "color") ("name" "color") - ("id" "color") + ("id" "{{ id }}-color") ("placeholder" "color") ("required" "") ("value" "{{ topic.color }}") @@ -713,17 +715,37 @@ (div ("class" "flex flex_col gap_1") (label - ("for" "position") + ("for" "{{ id }}-position") (str (text "communities:label.position"))) (input ("type" "number") ("name" "position") - ("id" "position") + ("id" "{{ id }}-position") ("placeholder" "position") ("required" "") ("value" "{{ topic.position }}") ("min" "0") ("max" "256"))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "{{ id }}-write_access") + (text "Post permission")) + (select + ("name" "write_access") + ("id" "{{ id }}-write_access") + (option + ("value" "Everybody") + ("selected" "{% if topic.write_access == 'Everybody' -%}true{% else %}false{%- endif %}") + (text "Everybody")) + (option + ("value" "Joined") + ("selected" "{% if topic.write_access == 'Joined' -%}true{% else %}false{%- endif %}") + (text "Joined")) + (option + ("value" "Owner") + ("selected" "{% if topic.write_access == 'Owner' -%}true{% else %}false{%- endif %}") + (text "Owner only")))) (button (icon (text "check")) (str (text "general:action.save"))))))) @@ -794,6 +816,7 @@ description: e.target.description.value, color: e.target.color.value, position: Number.parseInt(e.target.position.value), + write_access: e.target.write_access.selectedOptions[0].value, }), }) .then((res) => res.json()) @@ -804,6 +827,42 @@ ]); }); }")) + (text "{% else %}") + (div + ("class" "card lowered w_full hidden flex flex_col gap_2") + ("data-tab" "topics") + (p (text "You can only manage topics for forum communities. You can convert this community into a forum, but you will not be able to go back.")) + (p (text "This will permanently change your community. Currently existing posts will no longer be visible on the community.")) + (button + ("onclick" "convert_to_forum()") + (icon (text "circle-fading-arrow-up")) + (text "Switch to forum"))) + + (script + (text "globalThis.convert_to_forum = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? It cannot be undone.\", + ])) + ) { + return; + } + + fetch(\"/api/v1/communities/{{ community.id }}/is_forum\", { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + }")) (text "{%- endif %}")) (script diff --git a/crates/app/src/public/html/communities/topic.lisp b/crates/app/src/public/html/communities/topic.lisp index 817c1b0..dba3c24 100644 --- a/crates/app/src/public/html/communities/topic.lisp +++ b/crates/app/src/public/html/communities/topic.lisp @@ -9,13 +9,15 @@ (text "{{ components::topic_display(id=topic_id, topic=topic, community=community, show_description=false) }}") (div ("class" "flex gap_2") + (text "{% if can_post -%}") (a - ("href" "/communities/intents/post?community={{ community.id }}&topic={{ topic_id }}") + ("href" "/communities/intents/post?community={{ community.id }}&topic={{ topic_id }}&sig=true&topics=true") ("class" "button small lowered") ("data-turbo" "false") (icon (text "plus")) (span (str (text "general:action.post")))) + (text "{%- endif %}") (a ("href" "/community/{{ community.title }}") ("class" "button lowered small") diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 55d3bf1..c797ea8 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -6,7 +6,6 @@ ("class" "avatar shadow") ("loading" "lazy") ("style" "--size: {{ size }}")) - (text "{%- endmacro %} {% macro community_avatar(id, community=false, size=\"24px\") -%} {% if community -%}") (img ("src" "/api/v1/communities/{{ id }}/avatar") @@ -14,7 +13,6 @@ ("class" "avatar shadow") ("loading" "lazy") ("style" "--size: {{ size }}")) - (text "{% else %}") (img ("src" "/api/v1/communities/{{ id }}/avatar") @@ -22,7 +20,6 @@ ("class" "avatar shadow") ("loading" "lazy") ("style" "--size: {{ size }}")) - (text "{%- endif %} {%- endmacro %} {% macro banner(username, border_radius=\"var(--radius)\") -%}") (img ("title" "{{ username }}'s banner") @@ -31,21 +28,18 @@ ("class" "banner shadow w_full") ("loading" "lazy") ("style" "border-radius: {{ border_radius }};")) - (text "{%- endmacro %} {% macro community_banner(id, community=false) -%} {% if community %}") (img ("src" "/api/v1/communities/{{ id }}/banner") ("alt" "{{ community.title }}'s banner") ("class" "banner shadow") ("loading" "lazy")) - (text "{% else %}") (img ("src" "/api/v1/communities/{{ id }}/banner") ("alt" "{{ id }}'s banner") ("class" "banner shadow") ("loading" "lazy")) - (text "{%- endif %} {%- endmacro %} {% macro community_listing_card(community) -%}") (a ("class" "card secondary w_full flex items_center gap_4") @@ -69,12 +63,10 @@ (b (text "{{ community.member_count }} ")) (text "members")))) - (text "{%- endmacro %} {% macro username(user) -%}") (div ("style" "display: contents") (text "{% if user.settings.display_name -%} {{ user.settings.display_name }} {% else %} {{ user.username }} {%- endif %}")) - (text "{%- endmacro %} {% macro likes(id, asset_type, likes=0, dislikes=0, secondary=false, disable_dislikes=false) -%}") (button ("title" "Like") @@ -85,7 +77,6 @@ (span (text "{{ likes }}")) (text "{%- endif %}")) - (text "{% if not user or not user.settings.hide_dislikes and not disable_dislikes -%}") (button ("title" "Dislike") @@ -96,21 +87,27 @@ (span (text "{{ dislikes }}")) (text "{%- endif %}")) - -(text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%} {% if user and user.username -%}") +(text "{%- endif %} {%- endmacro %} {% macro full_username(user, wrap=true, max_width=\"180px\") -%} {% if user and user.username -%}") (div - ("class" "flex items_center") + ("class" "flex {% if wrap -%} flex_wrap {%- endif %} items_center") (a ("href" "/@{{ user.username }}") - ("class" "flush") - ("style" "font-weight: 600") + ("class" "name flush flex gap_1") + ("style" "font-weight: 600; max-width: {{ max_width }}") ("target" "_top") + (text "{% if user.settings.private_profile -%}") + (span + ("title" "Private") + ("class" "flex items_center") + (icon (text "lock"))) + (text "{%- endif %}") + (text "{% if user.permissions|has_banned -%}") (del ("class" "fade") (text "{{ self::username(user=user) }}")) (text "{% else %}") (text "{{ self::username(user=user) }}") (text "{%- endif %}")) - (text "{{ self::online_indicator(user=user) }} {% if user.is_verified -%}") + (text "{{ self::online_indicator(user=user) }} {% if not user.settings.hide_username_badges -%} {% if user.is_verified -%}") (span ("title" "Verified") ("style" "color: var(--color-primary)") @@ -122,19 +119,24 @@ ("style" "color: var(--color-primary);") ("class" "flex items_center") (text "{{ icon \"star\" }}")) + (text "{%- endif %} {% if user.checkouts|length > 0 -%}") + (span + ("title" "Donator") + ("style" "color: var(--color-primary);") + ("class" "flex items_center") + (text "{{ icon \"hand-heart\" }}")) (text "{%- endif %} {% if user.permissions|has_staff_badge -%}") (span ("title" "Staff") ("style" "color: var(--color-primary);") ("class" "flex items_center") (text "{{ icon \"shield-user\" }}")) - (text "{%- endif %}")) + (text "{%- endif %} {%- endif %}")) (text "{%- endif %} {%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}") (div ("style" "display: contents") (text "{{ self::post(post=post, owner=owner, secondary=secondary, community=community, show_community=show_community, can_manage_post=can_manage_post, repost=repost, expect_repost=true) }}")) (text "{%- endmacro %}") - (text "{% macro post_info(post, community) -%}") ; info about the post: edited, date, etc. (text "{% if post.context.edited != 0 -%}") @@ -200,8 +202,7 @@ (text "{{ icon \"trash-2\" }}")) (text "{%- endif %}") (text "{%- endmacro %}") - -(text "{% macro post_buttons_box(post, community, owner, can_manage_post) -%}") +(text "{% macro post_buttons_box(post, community, owner, can_manage_post, show_comments=true) -%}") (div ("class" "flex justify_between items_center gap_2 w_full") (text "{% if user -%}") @@ -220,7 +221,7 @@ (text "{%- endif %}") (div ("class" "flex gap_1 buttons_box") - (text "{% if post.context.comments_enabled %}") + (text "{% if show_comments and post.context.comments_enabled %}") (a ("href" "/post/{{ post.id }}") ("class" "button camo small") @@ -358,8 +359,7 @@ ("class" "flush flex gap_1 items_center") (text "{{ self::community_avatar(id=post.community, community=community) }}") (b - (text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}")) - (text "{% if post.context.is_pinned or post.context.is_profile_pinned -%} {{ icon \"pin\" }} {%- endif %}"))) + (text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}")))) (text "{%- endif %} {%- endif %}") (div ("class" "card flex flex_col post gap_2 post:{{ post.id }} {% if secondary -%}secondary{%- endif %}") @@ -384,9 +384,10 @@ (text "{{ self::avatar(username=owner.username, size=\"24px\", selector_type=\"username\") }}")) (text "{%- endif %}") (span - ("class" "name") + ; ("class" "name") (text "{{ self::full_username(user=owner) }}")) - (text "{{ self::post_info(post=post, community=community) }}")) + (text "{{ self::post_info(post=post, community=community) }}") + (text "{% if post.context.is_pinned or post.context.is_profile_pinned -%} {{ icon \"pin\" }} {%- endif %}")) (text "{% if not dont_show_title and post.title and community and community.context.enable_titles -%}") ; post has a title AND whatever is rendering this component wants to see it (a @@ -464,14 +465,15 @@ (text "{% if community and show_community and community.id != config.town_square or question %}")) (text "{%- endif %} {%- endmacro %}") -(text "{% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}") +(text "{% macro post_media(upload_ids, custom_click=false) -%} {% if upload_ids|length > 0 -%}") (div ("class" "media_gallery gap_2") (text "{% for upload in upload_ids %}") (img ("src" "/api/v1/uploads/{{ upload }}") + ("data-upload-id" "{{ upload }}") ("alt" "Image upload") - ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}'])")) + ("onclick" "{% if custom_click -%} {{ custom_click }} {%- else -%} trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}']) {%- endif %}")) (text "{% endfor %}")) (text "{%- endif %} {%- endmacro %} {% macro notification(notification) -%}") (div @@ -526,7 +528,7 @@ (text "{{ text \"general:action.delete\" }}")))))) (text "{%- endmacro %}") -(text "{% macro forum_post(post, owner, community, can_manage_post, poll=false) -%}") +(text "{% macro forum_post(post, owner, community, can_manage_post, poll=false, show_show_thread=true) -%}") (div ("class" "card_nest_horizontal_wrapper post post:{{ post.id }}") ("data-community" "{{ post.community }}") @@ -574,12 +576,38 @@ (div ("class" "flex flex_col gap_2") ("style" "flex: 1 0 auto") + (b ("class" "no_p_margin") (text "{{ post.title|markdown|safe }}")) (span ("class" "no_p_margin") (text "{{ post.content|markdown|safe }}")) (text "{{ self::post_media(upload_ids=post.uploads) }}") (text "{% if poll -%} {{ self::poll(post=post, poll=poll) }} {%- endif %}")) (hr ("class" "margin_top")) - (text "{{ self::post_buttons_box(post=post, community=community, owner=owner, can_manage_post=can_manage_post) }}")))) + (text "{{ self::post_buttons_box(post=post, community=community, owner=owner, can_manage_post=can_manage_post, show_comments=false) }}")))) + +; show thread +(text "{% if show_show_thread and post.comment_count > 0 -%}") +(div + ("class" "flex gap_2") + (text "{% if post.context.comments_enabled %}") + (a + ("href" "/post/{{ post.id }}") + ("class" "button lowered") + (icon (text "message-circle")) + (span + (text "{{ post.comment_count }}"))) + (text "{% endif %}") + + (button + ("class" "lowered") + ("onclick" "globalThis.continue_thread(event.target, '{{ post.id }}', 'replies_{{ post.id }}', 0)") + (icon (text "chevron-down")) + (str (text "general:action.show_thread")))) +(text "{%- endif %}") + +; replies +(div + ("class" "flex flex_col gap_2 hidden thread") + ("id" "replies_{{ post.id }}")) (text "{%- endmacro %}") (text "{% macro user_card(user) -%}") @@ -596,9 +624,8 @@ (div ("class" "flex items_center") (b - (text "{{ self::username(user=user) }}")) + (text "{{ self::full_username(user=user) }}")) (text "{{ self::online_indicator(user=user) }}")))) - (text "{%- endmacro %} {% macro pagination(page=0, items=0, key=\"\", value=\"\") -%}") (div ("class" "flex justify_between gap_2 w_full") @@ -867,7 +894,7 @@ (div ("class" "flex gap_2") (button - (text "{{ text \"communities:action.create\" }}")) + (str (text "communities:action.create"))) (text "{% if drawing_enabled -%}") (button @@ -1323,15 +1350,15 @@ (text "Listening to ")) (text "{{ other_user.connections.Spotify[1].data.artist }}"))) -(text "{%- endif %} {%- endmacro %} {% macro user_plate(user, show_menu=false, show_kick=false, secondary=false) -%}") +(text "{%- endif %} {%- endmacro %} {% macro user_plate(user, show_menu=false, show_kick=false, secondary=false, full=false) -%}") (div - ("class" "flex gap_2 items_center card tiny user_plate {% if secondary -%}secondary{%- endif %}") + ("class" "flex gap_2 items_center card tiny user_plate {% if secondary -%}secondary{%- endif %} {% if full -%} w_full {%- endif %}") (a ("href" "/@{{ user.username }}") (text "{{ self::avatar(username=user.username, size=\"42px\", selector_type=\"username\") }}")) (div ("class" "flex justify_center flex_col") - ("style" "{% if show_menu or show_kick -%}width: 60%{% else %}max-width: 150px{%- endif %}") + ("style" "{% if show_menu or show_kick -%}width: 60%{% else %}max-width: calc(100% - 42px - var(--pad-4)){%- endif %}") (text "{{ self::full_username(user=user) }}") (div ("class" "user_status") @@ -1842,14 +1869,14 @@ ; option a (button - ("class" "hover_left_bar raised justify-start w_full poll_option") + ("class" "hover_left_bar raised justify_start w_full poll_option") ("onclick" "trigger('me::vote', ['{{ post.id }}', 'A'])") (icon (text "tally-1")) (text "{{ poll[0].option_a }}")) ; option b (button - ("class" "hover_left_bar raised justify-start w_full poll_option") + ("class" "hover_left_bar raised justify_start w_full poll_option") ("onclick" "trigger('me::vote', ['{{ post.id }}', 'B'])") (icon (text "tally-2")) (text "{{ poll[0].option_b }}")) @@ -1857,7 +1884,7 @@ ; option c (text "{% if poll[0].option_c -%}") (button - ("class" "hover_left_bar raised justify-start w_full poll_option") + ("class" "hover_left_bar raised justify_start w_full poll_option") ("onclick" "trigger('me::vote', ['{{ post.id }}', 'C'])") (icon (text "tally-3")) (text "{{ poll[0].option_c }}")) @@ -1866,7 +1893,7 @@ ; option d (text "{% if poll[0].option_d -%}") (button - ("class" "hover_left_bar raised justify-start w_full poll_option") + ("class" "hover_left_bar raised justify_start w_full poll_option") ("onclick" "trigger('me::vote', ['{{ post.id }}', 'D'])") (icon (text "tally-4")) (text "{{ poll[0].option_d }}")) @@ -2160,7 +2187,7 @@ ("class" "flex flex_row gap_1") (a ("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}") - ("class" "button justify-start lowered w_full") + ("class" "button justify_start lowered w_full") (icon (text "notebook")) (text "{{ journal.title }}")) @@ -2186,7 +2213,7 @@ (div ("class" "flex flex_row gap_1") (button - ("class" "justify-start lowered w_full") + ("class" "justify_start lowered w_full") (icon (text "arrow-down")) (text "{{ journal.title }}")) @@ -2236,7 +2263,7 @@ ; create note (text "{% if user and user.id == journal.owner -%}") (button - ("class" "lowered justify-start w_full") + ("class" "lowered justify_start w_full") ("onclick" "create_note()") (icon (text "plus")) (str (text "journals:action.create_note"))) @@ -2250,7 +2277,7 @@ (text "{% macro notes_list_dir_listing(dir, dirs, notes, owner, journal, view_mode=false) -%}") (details (summary - ("class" "button w_full justify-start raised w_full") + ("class" "button w_full justify_start raised w_full") (icon (text "folder")) (text "{{ dir[2] }}")) @@ -2278,7 +2305,7 @@ ("ui_ident" "{% if selected_note == note.id -%} active_note {%- else -%} inactive_note {%- endif %}") (a ("href" "{% if owner -%} /@{{ owner.username }}/{{ journal.title }}/{{ note.title }} {%- else -%} /journals/{{ journal.id }}/{{ note.id }} {%- endif %}") - ("class" "button justify-start w_full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}") + ("class" "button justify_start w_full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}") (icon (text "file-text")) (text "{{ note.title }}")) @@ -2359,7 +2386,7 @@ (div ("class" "flex flex_row gap_1") (button - ("class" "justify-start lowered w_full") + ("class" "justify_start lowered w_full") (icon (text "folder-open")) (text "{{ dir[2] }}")) @@ -2402,7 +2429,7 @@ (text "{% macro note_mover_dirs_listing(dir, dirs) -%}") (button ("onclick" "move_note_dir(window.NOTE_MOVER_NOTE_ID, '{{ dir[0] }}'); document.getElementById('note_mover_dialog').close()") - ("class" "justify-start lowered w_full") + ("class" "justify_start lowered w_full") (icon (text "folder-open")) (text "{{ dir[2] }}")) @@ -2464,6 +2491,13 @@ (text "Create infinite Littleweb sites")) (li (text "Create infinite Littleweb domains")) + (li + (text "Create and sell CSS snippet products")) + + (text "{% if config.enable_user_ads -%}") + (li + (text "No ads")) + (text "{%- endif %}") (text "{% if config.security.enable_invite_codes -%}") (li @@ -2636,8 +2670,18 @@ (text "{%- endif %}") (text "{%- endmacro %}") -(text "{% macro topic_post_display(post, owner, is_pinned=false) -%}") +(text "{% macro topic_post_display(post, owner, is_pinned=false, community=false) -%}") (tr + (text "{% if community %}") + (td + (a + ("href" "/community/{{ community.title }}/topic/{{ post.topic }}") + ("class" "flex gap_1 items_center w_content") + (text "{{ self::community_avatar(id=post.community, community=community) }}") + (span + (text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}")))) + (text "{%- endif %}") + (td ("class" "flex gap_1") (a @@ -2648,5 +2692,55 @@ (text "{{ self::full_username(user=owner) }}")) (td (text "{{ post.comment_count }}")) (td (text "{{ ((post.likes + 1) / (post.dislikes + 1))|round(method=\"ceil\", precision=2) }}")) - (td (span ("class" "date") (text "{{ post.created }}")))) + (td (span ("class" "date short") (text "{{ post.created }}")))) +(text "{%- endmacro %}") + +(text "{% macro product_listing_card(product, owner=false, edit=false) -%}") +(a + ("class" "card button lowered w_full flex flex_col gap_2") + ("href" "/product/{{ product.id }}{% if edit -%} /edit {%- endif %}") + (text "{% if owner -%}") + (text "{{ self::full_username(user=owner) }}") + (text "{%- endif %}") + + (h3 + ("class" "flex gap_2 items_center {% if not product.on_sale -%} fade {%- endif %}") + ("style" "height: 24px; text-decoration: {% if not product.on_sale -%} line-through {%- else -%} none {%- endif %}") + (icon (text "package")) + (text "{{ product.title }}")) + (h4 + ("class" "flex gap_2 items_center") + ("style" "height: 18px") + (icon (text "badge-cent")) + (text "{{ product.price }}"))) +(text "{%- endmacro %}") + +(text "{% macro ad_listing_card(ad) -%}") +(a + ("class" "card button lowered w_full flex flex_col gap_2") + ("href" "/product/ad/{{ ad.id }}/edit") + (b + ("class" "flex gap_2 items_center") + ("style" "height: 24px; text-decoration: {% if not ad.is_running -%} line-through {%- else -%} none {%- endif %}") + (icon (text "link")) + (text "{{ ad.target }}")) + (b + ("style" "height: 18px") + (text "{% if ad.is_running -%}") + (span + ("class" "green flex gap_2 items_center") + (icon (text "circle-check")) + (text "Running")) + (text "{% else %}") + (span + ("class" "red flex gap_2 items_center") + (icon (text "circle-x")) + (text "Not running")) + (text "{%- endif %}"))) +(text "{%- endmacro %}") + +(text "{% macro advertisement(size=\"Leaderboard\") -%}") +(text "{% if not is_supporter and config.enable_user_ads -%}") +(object ("class" "tetratto_ad") ("data-ad-size" "{{ size }}")) +(text "{%- endif %}") (text "{%- endmacro %}") diff --git a/crates/app/src/public/html/developer/home.lisp b/crates/app/src/public/html/developer/home.lisp index a5f31e9..21dc65e 100644 --- a/crates/app/src/public/html/developer/home.lisp +++ b/crates/app/src/public/html/developer/home.lisp @@ -54,7 +54,7 @@ ("placeholder" "redirect URL") ("minlength" "2"))) (button - (text "{{ text \"communities:action.create\" }}")))) + (str (text "communities:action.create"))))) ; app listing (div diff --git a/crates/app/src/public/html/economy/ad.lisp b/crates/app/src/public/html/economy/ad.lisp new file mode 100644 index 0000000..fe4581f --- /dev/null +++ b/crates/app/src/public/html/economy/ad.lisp @@ -0,0 +1,63 @@ +(text "") +(html + ("lang" "en") + (head + (meta ("charset" "UTF-8")) + (meta ("name" "viewport") ("content" "width=device-width, initial-scale=1.0")) + (meta ("http-equiv" "X-UA-Compatible") ("content" "ie=edge"))) + (body + (a + ("href" "{% if not disable_click -%} {{ config.host }}/api/v1/ads/host/{{ host }}/{{ ad.id }}/click {%- endif %}") + ("title" "Advertisement") + ("target" "_blank") + ("class" "ad")) + + (span ("class" "display_tag") (text "Ad")) + + (style + (text "* { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + html, + body { + line-height: 1.5; + letter-spacing: 0.15px; + font-family: + \"Inter\", \"Poppins\", \"Roboto\", ui-sans-serif, system-ui, sans-serif, + \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", + \"Noto Color Emoji\"; + } + + body { + overflow: hidden; + display: grid; + place-items: center; + } + + a.ad { + display: inline; + width: 100dvw; + height: 100dvh; + background-image: url(\"{{ config.host|safe }}/api/v1/uploads/{{ ad.upload_id }}\"); + background-position: center; + background-size: contain; + } + + .display_tag { + position: absolute; + top: 0.5rem; + left: 0.5rem; + padding: 0.15rem 0.5rem; + background: hsla(0, 0%, 0%, 50%); + color: white; + font-weight: 600; + font-size: 10px; + user-select: none; + pointer-events: none; + border-radius: 0.4rem; + box-shadow: 0 0 2px hsla(0, 0%, 0%, 25%); + opacity: 25%; + }")))) diff --git a/crates/app/src/public/html/economy/edit.lisp b/crates/app/src/public/html/economy/edit.lisp new file mode 100644 index 0000000..28e5db5 --- /dev/null +++ b/crates/app/src/public/html/economy/edit.lisp @@ -0,0 +1,568 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "Manage product - {{ config.name }}")) +(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}") +(main + ("class" "flex flex_col gap_2") + (div + ("class" "card_nest") + (div + ("class" "card small flex items_center gap_2") + (icon (text "images")) + (b + (str (text "economy:label.thumbnails")))) + (div + ("class" "card flex flex_col gap_2") + (text "{{ components::post_media(upload_ids=product.uploads.thumbnails, custom_click=\"remove_thumbnail(event.target)\") }}") + (text "{% if product.uploads.thumbnails|length < 4 -%}") + (button + ("onclick" "add_thumbnail()") + (icon (text "plus")) + (str (text "communities:label.upload"))) + (text "{%- endif %}"))) + + (div + ("class" "card_nest") + (div + ("class" "card small flex gap_2 items_center") + (icon (text "pencil-line")) + (b + (str (text "economy:label.title")))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "update_title_from_form(event)") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "title") + (str (text "economy:label.title"))) + (input + ("type" "text") + ("name" "title") + ("id" "title") + ("placeholder" "title") + ("required" "") + ("minlength" "2") + ("maxlength" "128") + ("value" "{{ product.title }}"))) + (button (str (text "general:action.save"))))) + + (div + ("class" "card_nest") + (div + ("class" "card small flex gap_2 items_center") + (icon (text "pencil-line")) + (b + (str (text "economy:label.description")))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "update_description_from_form(event)") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "description") + (str (text "economy:label.description"))) + (textarea + ("name" "description") + ("id" "description") + ("placeholder" "description") + ("required" "") + ("minlength" "2") + ("maxlength" "1024") + (text "{{ product.description }}"))) + (button (str (text "general:action.save"))))) + + (div + ("class" "card_nest") + (div + ("class" "card small flex gap_2 items_center") + (icon (text "badge-cent")) + (b + (str (text "economy:label.price")))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "update_price_from_form(event)") + (label + ("for" "on_sale") + ("class" "flex items_center gap_2") + (input + ("type" "checkbox") + ("id" "on_sale") + ("name" "on_sale") + ("class" "w_content") + ("checked" "{{ product.on_sale }}") + ("oninput" "event.preventDefault(); update_on_sale_from_form(event.target.checked)")) + (span + (str (text "economy:label.on_sale")))) + (label + ("for" "single_use") + ("class" "flex items_center gap_2") + (input + ("type" "checkbox") + ("id" "single_use") + ("name" "single_use") + ("class" "w_content") + ("checked" "{{ product.single_use }}") + ("oninput" "event.preventDefault(); update_single_use_from_form(event.target.checked)")) + (span + (str (text "economy:label.single_use")))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "price") + (str (text "economy:label.price"))) + (input + ("type" "number") + ("name" "price") + ("id" "price") + ("placeholder" "price") + ("required" "") + ("min" "0") + ("max" "1000000") + ("value" "{{ product.price }}"))) + (button (str (text "general:action.save"))))) + + (div + ("class" "card_nest") + (div + ("class" "card small flex gap_2 items_center") + (icon (text "weight")) + (b + (str (text "economy:label.stock")))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "update_stock_from_form(event)") + (label + ("for" "unlimited") + ("class" "flex items_center gap_2") + (input + ("type" "checkbox") + ("id" "unlimited") + ("name" "unlimited") + ("class" "w_content") + ("checked" "{{ product.stock == -1 }}") + ("oninput" "event.preventDefault(); event.target.checked ? document.getElementById('stock').value = -1 : document.getElementById('stock').value = 0")) + (span + (str (text "economy:label.unlimited")))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "stock") + (str (text "economy:label.stock"))) + (input + ("type" "number") + ("name" "stock") + ("id" "stock") + ("placeholder" "stock") + ("required" "") + ("min" "-1") + ("max" "1000000") + ("value" "{{ product.stock }}"))) + (button (str (text "general:action.save"))))) + + (div + ("class" "card_nest") + (div + ("class" "card small flex gap_2 items_center") + (icon (text "package-check")) + (b + (str (text "economy:label.fulfillment_style")))) + (div + ("class" "card flex flex_col gap_2") + (select + ("id" "fulfillment_style_select") + ("onchange" "mirror_fulfillment_style_select(true)") + (option ("value" "mail") (text "Mail") ("selected" "{{ not product.method == \"ProfileStyle\" }}")) + (option ("value" "snippet") (text "CSS Snippet") ("selected" "{{ product.method == \"ProfileStyle\" }}"))) + (form + ("class" "flex flex_col gap_2 hidden") + ("id" "mail_fulfillment") + ("onsubmit" "update_method_from_form(event)") + (p (text "If you choose to send an automated mail letter upon purchase, users will automatically receive the message you supply below.")) + (p (text "If you disable automail, you'll be required to manually mail users who have purchased your product before the transfer is finalized.")) + (text "{% set is_automail = product.method != \"ManualMail\" and product.method != \"ProfileStyle\" %}") + + (label + ("for" "use_automail") + ("class" "flex items_center gap_2") + (input + ("type" "checkbox") + ("id" "use_automail") + ("name" "use_automail") + ("class" "w_content") + ("oninput" "mirror_use_automail()") + ("checked" "{% if is_automail -%} true {%- else -%} false {%- endif %}")) + (span + (str (text "economy:label.use_automail")))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "automail_message") + (str (text "economy:label.automail_message"))) + (textarea + ("name" "automail_message") + ("id" "automail_message") + ("placeholder" "automail_message") + (text "{% if is_automail -%} {{ product.method.AutoMail }} {%- endif %}"))) + (button (str (text "general:action.save")))) + (form + ("class" "flex flex_col gap_2 hidden") + ("id" "snippet_fulfillment") + ("onsubmit" "update_data_from_form(event)") + (text "{{ components::supporter_ad(body=\"Become a supporter to create snippets!\") }}") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "data") + (str (text "economy:label.snippet_data"))) + (textarea + ("name" "data") + ("id" "data") + ("placeholder" "data") + (text "{{ product.data }}"))) + (button (str (text "general:action.save")))))) + + (div + ("class" "flex gap_2") + (a + ("class" "button secondary") + ("href" "/product/{{ product.id }}") + (icon (text "arrow-left")) + (str (text "general:action.back"))) + + (button + ("class" "lowered red") + ("onclick" "delete_product()") + (icon (text "trash")) + (str (text "general:action.delete"))))) + +(script + (text "async function add_thumbnail() { + await trigger(\"atto::debounce\", [\"products::update\"]); + + const picker = document.createElement(\"input\"); + picker.type = \"file\"; + picker.accept = \"image/*\"; + document.body.appendChild(picker); + picker.click(); + + picker.addEventListener(\"change\", () => { + // create body + const body = new FormData(); + + for (const file of picker.files) { + body.append(file.name, file); + } + + body.append( + \"body\", + JSON.stringify({ + target: \"Thumbnails\" + }), + ); + + // ... + picker.remove(); + fetch(\"/api/v1/products/{{ product.id }}/uploads\", { + method: \"POST\", + body, + }) + .then((res) => res.json()) + .then(async (res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.reload(); + }, 100); + } + }); + }); + } + + async function remove_thumbnail(target) { + await trigger(\"atto::debounce\", [\"products::update\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/products/{{ product.id }}/uploads/thumbnails\", { + method: \"DELETE\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + idx: Array.from(target.parentElement.children).findIndex((x) => x.getAttribute(\"data-upload-id\") === target.getAttribute(\"data-upload-id\")), + }), + }) + .then((res) => res.json()) + .then(async (res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.reload(); + }, 100); + } + }); + } + + async function update_title_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/title\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title: e.target.title.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_description_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/description\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + description: e.target.description.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_on_sale_from_form(on_sale) { + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/on_sale\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + on_sale, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_single_use_from_form(single_use) { + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/single_use\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + single_use, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_price_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/price\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + price: e.target.price.valueAsNumber, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_stock_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/stock\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + stock: e.target.stock.valueAsNumber, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_method_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/method\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + method: e.target.use_automail.checked ? { AutoMail: e.target.automail_message.value } : \"ManualMail\", + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_data_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/data\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + data: e.target.data.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function delete_product() { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/products/{{ product.id }}\", { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + + globalThis.mirror_use_automail = () => { + const use_automail = document.getElementById(\"use_automail\").checked; + + if (use_automail) { + document.getElementById(\"automail_message\").removeAttribute(\"disabled\"); + } else { + document.getElementById(\"automail_message\").setAttribute(\"disabled\", \"true\"); + } + } + + globalThis.mirror_fulfillment_style_select = (send = false) => { + const selected = document.getElementById(\"fulfillment_style_select\").selectedOptions[0].value; + + if (selected === \"mail\") { + document.getElementById(\"mail_fulfillment\").classList.remove(\"hidden\"); + document.getElementById(\"snippet_fulfillment\").classList.add(\"hidden\"); + + if (send) { + update_method_from_form({ + preventDefault: () => {}, + target: document.getElementById(\"mail_fulfillment\"), + }); + } + } else { + document.getElementById(\"mail_fulfillment\").classList.add(\"hidden\"); + document.getElementById(\"snippet_fulfillment\").classList.remove(\"hidden\"); + + if (send) { + fetch(\"/api/v1/products/{{ product.id }}/method\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + method: \"ProfileStyle\", + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + } + } + + setTimeout(() => { + mirror_use_automail(); + mirror_fulfillment_style_select(); + }, 150);")) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/economy/edit_ad.lisp b/crates/app/src/public/html/economy/edit_ad.lisp new file mode 100644 index 0000000..088a91a --- /dev/null +++ b/crates/app/src/public/html/economy/edit_ad.lisp @@ -0,0 +1,97 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "Manage advertisement - {{ config.name }}")) +(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}") +(main + ("class" "flex flex_col gap_2") + (div + ("class" "card_nest") + (div + ("class" "card small flex gap_2 items_center") + (icon (text "link")) + (b + (text "{{ ad.target }}"))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "event.preventDefault(); update_is_running_from_form(event.target.is_running.checked)") + (object ("class" "tetratto_ad") ("data-ad-size" "{{ ad.size }}") ("data-noclick" "true") ("data-ad-id" "{{ ad.id }}")) + (ul + (li + (text "{% if ad.last_charge_time != 0 -%}") + (text "Last charge: ") (span ("class" "date") (text "{{ ad.last_charge_time }}")) + (text "{% else %}") + (text "No previous charges") + (text "{%- endif %}"))) + (div ("class" "squig")) + (p (text "Each day your ad is viewed, you'll be charged 25 coins. This charge only applies to the very first view of the day.")) + (p (text "Additionally, you'll be charged 2 coins per click on your ad. This fee will be paid to the user which hosts the site your ad was shown on.")) + (p (text "Each of these transfers will be shown in your wallet's transfer table as either \"AdClick\" or \"AdCharge\".")) + (label + ("for" "is_running") + ("class" "flex items_center gap_2") + (input + ("type" "checkbox") + ("id" "is_running") + ("name" "is_running") + ("class" "w_content") + ("checked" "{{ ad.is_running }}")) + (span + (str (text "economy:label.running")))) + (button (str (text "general:action.save"))))) + + (div + ("class" "flex gap_2") + (a + ("class" "button secondary") + ("href" "/products") + (icon (text "arrow-left")) + (str (text "general:action.back"))) + + (button + ("class" "lowered red") + ("onclick" "delete_ad()") + (icon (text "trash")) + (str (text "general:action.delete"))))) + +(script + (text "async function update_is_running_from_form(is_running) { + await trigger(\"atto::debounce\", [\"products::update\"]); + fetch(\"/api/v1/ads/{{ ad.id }}/running\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + is_running, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function delete_ad() { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/ads/{{ ad.id }}\", { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + };")) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/economy/product.lisp b/crates/app/src/public/html/economy/product.lisp new file mode 100644 index 0000000..b59e4fb --- /dev/null +++ b/crates/app/src/public/html/economy/product.lisp @@ -0,0 +1,146 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "{{ product.title }} - {{ config.name }}")) +(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}") +(main + ("class" "flex flex_col gap_2") + (text "{{ components::post_media(upload_ids=product.uploads.thumbnails) }}") + (div + ("class" "card flex flex_col gap_2") + (h3 + ("style" "height: 32px") + (text "{{ product.title }}")) + (text "{{ components::full_username(user=owner) }}") + + (text "{% if product.stock >= 0 -%}") + (span ("class" "red") (text "{{ product.stock }} remaining")) + (text "{%- endif %}") + + (div + ("class" "card lowered w_full no_p_margin") + (text "{{ product.description|markdown|safe }}")) + + (text "{% if already_purchased -%}") + (span + ("class" "green flex items_center gap_2") + (icon (text "circle-check")) + (str (text "economy:label.already_purchased"))) + (text "{%- endif %}") + + (div + ("class" "flex gap_2 items_center") + (text "{% if user.id != product.owner -%}") + (text "{% if not already_purchased -%}") + ; price + (a + ("class" "button camo lowered") + ("href" "/wallet") + ("target" "_blank") + (icon (text "badge-cent")) + (text "{{ product.price }}")) + ; buy button + (button + ("onclick" "purchase()") + ("disabled" "{{ product.stock == 0 }}") + (icon (text "piggy-bank")) + (str (text "economy:action.buy"))) + (text "{% else %}") + ; profile style snippets + (text "{% if product.method == \"ProfileStyle\" -%} {% if not product.id in applied_configurations_mapped -%}") + (button + ("onclick" "apply()") + (icon (text "check")) + (str (text "economy:action.apply"))) + (text "{% else %}") + (button + ("onclick" "remove()") + (icon (text "x")) + (str (text "economy:action.unapply"))) + (text "{%- endif %} {%- endif %}") + ; ... + (text "{%- endif %}") + (text "{% else %}") + (a + ("class" "button") + ("href" "/product/{{ product.id }}/edit") + (icon (text "settings")) + (str (text "general:label.edit"))) + (text "{%- endif %}")))) + +(script + (text "async function purchase() { + await trigger(\"atto::debounce\", [\"products::buy\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? Your new balance will be {{ user.coins - product.price }} coins.\", + ])) + ) { + return; + } + + fetch(\"/api/v1/products/{{ product.id }}/buy\", { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + } + + async function apply() { + await trigger(\"atto::debounce\", [\"user::update\"]); + fetch(\"/api/v1/auth/user/{{ user.id }}/applied_configuration\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + \"type\": \"StyleSnippet\", + \"id\": \"{{ product.id }}\", + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + } + + async function remove() { + await trigger(\"atto::debounce\", [\"user::update\"]); + fetch(\"/api/v1/auth/user/{{ user.id }}/applied_configuration\", { + method: \"DELETE\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + \"id\": \"{{ product.id }}\", + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + }")) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/economy/products.lisp b/crates/app/src/public/html/economy/products.lisp new file mode 100644 index 0000000..fcd69f0 --- /dev/null +++ b/crates/app/src/public/html/economy/products.lisp @@ -0,0 +1,227 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "My products - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"products\") }}") +(main + ("class" "flex flex_col gap_2") + ; create new + (text "{{ components::supporter_ad(body=\"Become a supporter to create unlimited products!\") }}") + + (div + ("class" "card_nest") + (div + ("class" "card small") + (b + (str (text "economy:label.create_new")))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "create_product_from_form(event)") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "title") + (str (text "economy:label.title"))) + (input + ("type" "text") + ("name" "title") + ("id" "title") + ("placeholder" "title") + ("required" "") + ("minlength" "2") + ("maxlength" "128"))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "description") + (str (text "economy:label.description"))) + (textarea + ("name" "description") + ("id" "description") + ("placeholder" "description") + ("required" "") + ("minlength" "2") + ("maxlength" "1024"))) + (button + (str (text "communities:action.create"))))) + + ; product listing + (div + ("class" "card_nest") + (div + ("class" "card small flex items_center gap_2") + (icon (text "store")) + (str (text "economy:label.my_products"))) + + (div + ("class" "card flex flex_col gap_2") + (text "{% for item in list %} {{ components::product_listing_card(product=item, edit=true) }} {% endfor %}") + ; selective pagination + (text "{% if page_set_id == 0 -%}") + (text "{{ components::pagination(page=page, items=list|length) }}") + (text "{% else %}") + (text "{{ components::pagination(page=0, items=list|length) }}") + (text "{%- endif %}"))) + + (text "{% if config.enable_user_ads -%}") + (div ("class" "squig") ("style" "--background: var(--color-surface)")) + + ; create new ad + (div + ("class" "card_nest") + (div + ("class" "card small") + (b + (str (text "economy:label.create_new_ad")))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "create_ad_from_form(event)") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "target") + (str (text "economy:label.target"))) + (input + ("type" "url") + ("name" "target") + ("id" "target") + ("placeholder" "target url") + ("required" "") + ("minlength" "2") + ("maxlength" "128"))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "file") + (str (text "economy:label.image"))) + (input + ("id" "file") + ("name" "file") + ("type" "file") + ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") + ("required" "") + ("class" "w_content"))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "size_base") + (str (text "economy:label.size_base"))) + (select + ("id" "size_base") + ("name" "size_base") + (option ("value" "Leaderboard") (text "Leaderboard (720x90)")) + (option ("value" "Billboard") (text "Billboard (970x250)")) + (option ("value" "Skyscraper") (text "Skyscraper (160x600)")) + (option ("value" "MediumRectangle") (text "Medium rectangle (300x250)")) + (option ("value" "MobileLeaderboard") (text "Mobile leaderboard (320x50, mobile only)")))) + (button + (str (text "communities:action.create"))))) + + ; ad listing + (div + ("class" "card_nest") + (div + ("class" "card small flex items_center gap_2") + (icon (text "images")) + (str (text "economy:label.my_ads"))) + + (div + ("class" "card flex flex_col gap_2") + (text "{% for item in ads_list %} {{ components::ad_listing_card(ad=item) }} {% endfor %}") + ; selective pagination + (text "{% if page_set_id == 1 -%}") + (text "{{ components::pagination(page=page, items=ads_list|length, key=\"&page_set_id=1\") }}") + (text "{% else %}") + (text "{{ components::pagination(page=0, items=ads_list|length, key=\"&page_set_id=1\") }}") + (text "{%- endif %}"))) + + (div + ("class" "card_nest") + (div + ("class" "card small flex items_center gap_2") + (icon (text "code")) + (str (text "economy:label.embed_ads_on_my_site"))) + + (div + ("class" "card flex flex_col gap_2") + (p (text "You can embed the advertising network into your site to earn a (coin) commission from clicks.")) + (p (text "Place the following into your site's HTML:")) + (pre (code (text "<script src=\"{{ config.host }}\"/js/ads.js\"></script> +<script>TetrattoAds.init(); TetrattoAds.render_ads(\"{{ user.id }}\", \"{{ config.host }}\")</script>"))) + (p (text "After you've done that, you can place your ads like so:")) + (pre (code (text "<object class=\"tetratto_ad\" data-ad-size=\"$size$\"></object>"))) + (p + (text "In the above example, replace \"$size$\" with a size from ") + (a ("href" "https://tetratto.com/reference/tetratto/model/economy/enum.UserAdSize.html") (text "here")) + (text " keep in mind that the name of a size must be in title case. That means it's \"Leaderboard\", not \"leaderboard\".")))) + (text "{%- endif %}")) + +(script + (text "async function create_product_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"products::create\"]); + + fetch(\"/api/v1/products\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title: e.target.title.value, + description: e.target.description.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + e.target.reset(); + setTimeout(() => { + window.location.href = `/product/${res.payload}/edit`; + }, 100); + } + }); + } + + async function create_ad_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"products::create\"]); + + // create body + const body = new FormData(); + + for (const file of e.target.file.files) { + body.append(file.name, file); + } + + body.append( + \"body\", + JSON.stringify({ + target: e.target.target.value, + size: e.target.size_base.selectedOptions[0].value, + }), + ); + + // ... + fetch(\"/api/v1/ads\", { + method: \"POST\", + body, + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + e.target.reset(); + window.location.reload(); + } + }); + }")) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/economy/wallet.lisp b/crates/app/src/public/html/economy/wallet.lisp new file mode 100644 index 0000000..0981f09 --- /dev/null +++ b/crates/app/src/public/html/economy/wallet.lisp @@ -0,0 +1,153 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "Wallet - {{ config.name }}")) +(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"wallet\") }}") +(main + ("class" "flex flex_col gap_2") + (div + ("class" "card_nest") + (div + ("class" "card small flex items_center justify_between gap_2") + (span + ("class" "flex items_center gap_2") + (icon (text "piggy-bank")) + (span (str (text "general:link.wallet")))) + + (button + ("class" "lowered small square tiny big_icon") + ("onclick" "document.getElementById('buy_dialog').showModal()") + ("title" "Buy coins") + (icon (text "plus")))) + (div + ("class" "card lowered flex flex_col gap_4") + (button + ("class" "card button raised") + ("onclick" "document.getElementById('buy_dialog').showModal()") + (b (text "Coin balance")) + (h3 + ("class" "flex gap_2 items_center") + ("style" "height: 24px") + (icon (text "badge-cent")) + (text "{{ user.coins }}"))))) + (div + ("class" "card_nest") + (div + ("class" "card small flex items_center justify_between gap_2") + (span + ("class" "flex items_center gap_2") + (icon (text "clock")) + (span (str (text "economy:label.recent_transfers"))))) + (div + ("class" "card flex flex_col gap_4") + (div + ("class" "w_full") + ("style" "overflow: auto") + (table + ("class" "w_full") + (thead + (th (text "Created")) + (th (text "Sender")) + (th (text "Receiver")) + (th (text "Amount")) + (th (text "Product")) + (th (text "Source")) + (th (text "Actions"))) + (tbody + (text "{% for transfer in list -%}") + (tr + (td (span ("class" "date short") (text "{{ transfer[3].created }}"))) + (td ("class" "w_content") (text "{{ components::full_username(user=transfer[0], wrap=false) }}")) + (td ("class" "w_content") (text "{{ components::full_username(user=transfer[1], wrap=false) }}")) + (td + ("class" "flex items_center gap_1") + (text "{{ transfer[3].amount }}") + (text "{% if transfer[3].is_pending -%}") + (span ("class" "flex items_center gap_1") ("title" "Pending") (icon (text "clock"))) + (text "{%- endif %}")) + (td + (text "{% if transfer[2] -%}") + (a + ("href" "/product/{{ transfer[2].id }}") + (icon (text "external-link"))) + (text "{%- endif %}")) + (td (text "{{ transfer[3].source }}")) + (td + (text "{% if user.id == transfer[1].id and transfer[3].source == '\"Sale\"' -%}") + ; we're the receiver + (button + ("class" "small tiny square raised camo big_icon") + ("onclick" "issue_refund('{{ transfer[3].id }}')") + ("title" "Issue refund") + (icon (text "undo"))) + (text "{%- endif %}"))) + (text "{%- endfor %}")))) + (text "{{ components::pagination(page=page, items=list|length) }}")))) + +(dialog + ("id" "buy_dialog") + (div + ("class" "inner flex flex_col gap_2") + (p (text "All coin purchases are one-time and will not recur.")) + (p (text "If you do not receive your coins within a minute of purchase, please contact support.")) + + (button + ("class" "lowered w_full justify_start") + ("onclick" "checkout('Coins100')") + (text "100 coins ({{ config.stripe.price_texts.coins_100 }})")) + + (button + ("class" "w_full justify_start") + ("onclick" "checkout('Coins400')") + (text "400 coins ({{ config.stripe.price_texts.coins_400 }})")) + + (hr ("class" "margin")) + (div + ("class" "flex gap_2 justify_between") + (div null?) + (button + ("class" "lowered red") + ("type" "button") + ("onclick", "document.getElementById('buy_dialog').close()") + (icon (text "x")) + (str (text "dialog:action.cancel")))))) + +(script + (text "globalThis.checkout = (product) => { + document.getElementById('buy_dialog').close(); + fetch(\"/api/v1/service_hooks/stripe/checkout\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + product, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [res.ok ? \"success\" : \"error\", res.message]); + + if (res.ok) { + window.location.href = res.payload; + } + }); + } + + globalThis.issue_refund = async (transfer) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/transfers/${transfer}/refund`, { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [res.ok ? \"success\" : \"error\", res.message]); + }); + }")) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/forge/home.lisp b/crates/app/src/public/html/forge/home.lisp index a25cf5d..56f1d01 100644 --- a/crates/app/src/public/html/forge/home.lisp +++ b/crates/app/src/public/html/forge/home.lisp @@ -30,7 +30,7 @@ ("minlength" "2") ("maxlength" "32"))) (button - (text "{{ text \"communities:action.create\" }}")))) + (str (text "communities:action.create"))))) (text "{% else %}") (text "{{ components::developer_pass_ad(body=\"Get a developer pass to create forges!\") }}") (text "{%- endif %}") diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index 246e930..48e9e12 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -121,7 +121,7 @@ (div ("class" "flex flex_col gap_2 w_full") (button - ("class" "lowered justify-start w_full") + ("class" "lowered justify_start w_full") ("onclick" "create_journal()") (icon (text "plus")) (str (text "journals:action.create_journal"))) @@ -207,7 +207,7 @@ (details ("class" "w_full") (summary - ("class" "button lowered w_full justify-start") + ("class" "button lowered w_full justify_start") (icon (text "settings")) (str (text "general:action.manage"))) @@ -261,7 +261,7 @@ (details ("class" "w_full") (summary - ("class" "button lowered w_full justify-start") + ("class" "button lowered w_full justify_start") (icon (text "folders")) (str (text "journals:label.directories"))) diff --git a/crates/app/src/public/html/littleweb/domains.lisp b/crates/app/src/public/html/littleweb/domains.lisp index b39c716..c0a3779 100644 --- a/crates/app/src/public/html/littleweb/domains.lisp +++ b/crates/app/src/public/html/littleweb/domains.lisp @@ -59,7 +59,7 @@ (option ("value" "{{ tld }}") (text ".{{ tld|lower }}")) (text "{%- endfor %}"))) (button - (text "{{ text \"communities:action.create\" }}")) + (str (text "communities:action.create"))) (details (summary diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp index 1b7eea0..1325780 100644 --- a/crates/app/src/public/html/littleweb/services.lisp +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -45,7 +45,7 @@ ("minlength" "2") ("maxlength" "32"))) (button - (text "{{ text \"communities:action.create\" }}")))) + (str (text "communities:action.create"))))) (text "{%- endif %}") (div ("class" "card_nest w_full") diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index 2a49ecf..cadeeba 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -73,14 +73,22 @@ ("class" "inner") (a ("href" "/chats/0/0") - ("title" "Chats") (icon (text "message-circle")) (str (text "communities:label.chats"))) (a ("href" "/mail") - ("title" "Mail") (icon (text "mail")) (str (text "general:link.mail"))) + (text "{% if config.stripe -%}") + (a + ("href" "/wallet") + (icon (text "piggy-bank")) + (str (text "economy:label.my_wallet"))) + (a + ("href" "/products") + (icon (text "store")) + (str (text "economy:label.my_products"))) + (text "{%- endif %}") (a ("href" "/journals/0/0") (icon (text "notebook")) @@ -165,7 +173,7 @@ (text "{%- endif %}"))) (text "{%- endmacro %}") -(text "{% macro timelines_nav(selected=\"\", posts=\"\", questions=\"\", secondary_selected=\"posts\") -%}") +(text "{% macro timelines_nav(selected=\"\", posts=\"\", questions=\"\", secondary_selected=\"posts\", forum_posts=\"\") -%}") (div ("class" "mobile_nav mobile") ; primary nav @@ -184,7 +192,7 @@ (text "{% if posts and questions -%}") ; secondary nav - (text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected) }}") + (text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected, forum_posts=forum_posts) }}") (text "{%- endif %}")) (div @@ -194,7 +202,7 @@ ; secondary nav desktop only (text "{% if posts and questions -%}") - (text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected) }}") + (text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected, forum_posts=forum_posts) }}") (text "{%- endif %}")) (text "{%- endmacro %}") @@ -252,7 +260,7 @@ (text "{%- endif %}"))) (text "{%- endmacro %}") -(text "{% macro timelines_secondary_nav(posts=\"\", questions=\"\", selected=\"posts\") -%} {% if user -%}") +(text "{% macro timelines_secondary_nav(posts=\"\", questions=\"\", selected=\"posts\", forum_posts=\"\") -%} {% if user -%}") (div ("class" "pillmenu w_full") (a @@ -261,6 +269,14 @@ (icon (text "newspaper")) (span (str (text "communities:label.posts")))) + (text "{% if forum_posts|length > 0 -%}") + (a + ("href" "{{ forum_posts }}") + ("class" "{% if selected == 'forum_posts' -%}active{%- endif %}") + (icon (text "list-tree")) + (span (str (text "communities:label.forum_posts")))) + (text "{%- endif %}") + (a ("href" "{{ questions }}") ("class" "{% if selected == 'questions' -%}active{%- endif %}") @@ -310,6 +326,13 @@ ("class" "{% if selected == 'media' -%}active{%- endif %}") (str (text "auth:label.media"))) + (text "{% if user and profile.settings.enable_shop -%}") + (a + ("href" "/@{{ profile.username }}/shop") + ("class" "{% if selected == 'shop' -%}active{%- endif %}") + (str (text "auth:label.shop"))) + (text "{%- endif %}") + (text "{% if is_self or is_helper -%}") (a ("href" "/@{{ profile.username }}/outbox") @@ -368,17 +391,3 @@ (span (text "{{ text \"settings:tab.connections\" }}"))) (text "{%- endmacro %}") - -(text "{% macro seller_settings_nav_options() -%}") -(a - ("data-tab-button" "account") - ("class" "active") - ("href" "#/account") - (icon (text "smile")) - (span (str (text "settings:tab.account")))) -(a - ("data-tab-button" "products") - ("href" "#/products") - (icon (text "package")) - (span (str (text "marketplace:label.products")))) -(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/mail/compose.lisp b/crates/app/src/public/html/mail/compose.lisp index 5b9f401..430a8ff 100644 --- a/crates/app/src/public/html/mail/compose.lisp +++ b/crates/app/src/public/html/mail/compose.lisp @@ -112,6 +112,7 @@ subject: e.target.subject.value.trim(), receivers: RECEIVERS, replying_to: SEARCH_PARAMS.get(\"replying_to\") || \"0\", + transfer_id: SEARCH_PARAMS.get(\"transfer_id\") || \"0\", }), }) .then((res) => res.json()) diff --git a/crates/app/src/public/html/marketplace/seller.lisp b/crates/app/src/public/html/marketplace/seller.lisp deleted file mode 100644 index c16bb70..0000000 --- a/crates/app/src/public/html/marketplace/seller.lisp +++ /dev/null @@ -1,79 +0,0 @@ -(text "{% extends \"root.html\" %} {% block head %}") -(title - (text "Seller settings - {{ config.name }}")) -(text "{% endblock %} {% block body %} {{ macros::nav() }}") -(main - ("class" "flex flex_col gap_2") - - ; nav - (div - ("class" "mobile_nav mobile") - ; primary nav - (div - ("class" "dropdown") - ("style" "width: max-content") - (button - ("class" "raised small") - ("onclick" "trigger('atto::hooks::dropdown', [event])") - ("exclude" "dropdown") - (icon (text "sliders-horizontal")) - (span ("class" "current_tab_text") (text "account"))) - (div - ("class" "inner left") - (text "{{ macros::seller_settings_nav_options() }}")))) - - ; nav desktop - (div - ("class" "desktop pillmenu") - (text "{{ macros::seller_settings_nav_options() }}")) - - ; ... - (div - ("class" "card w_full lowered flex flex_col gap_2") - ("data-tab" "account") - (div - ("class" "card_nest w_full") - (div - ("class" "card small flex items_center gap_2") - (div - ("class" "notification") - ("style" "width: 46px") - (icon (text "stripe"))) - - (b (str (text "marketplace:label.status")))) - - (div - ("class" "card") - (text "{% if user.seller_data.account_id -%}") - (text "{% if user.seller_data.completed_onboarding -%}") - ; completed onboarding + has stripe account linked - (button - ("onclick" "trigger('seller::login')") - (icon (text "arrow-right")) - (str (text "marketplace:action.open_seller_dashboard"))) - (text "{% else %}") - ; not completed onboarding - (p (text "You've not finished setting up your Stripe account.")) - (p (text "Please complete onboarding to accept payments.")) - - (button - ("onclick" "trigger('seller::onboarding')") - (icon (text "arrow-right")) - (str (text "marketplace:action.finsh_setting_up_account"))) - (text "{%- endif %}") - (text "{% else %}") - ; doesn't have a stripe account linked - (button - ("onclick" "trigger('seller::register')") - (icon (text "arrow-right")) - (str (text "marketplace:action.get_started"))) - (text "{%- endif %}")))) - - (div - ("class" "card w_full lowered hidden flex flex_col gap_2") - ("data-tab" "products") - (div - ("class" "card w_full flex flex_wrap gap_2") - ))) - -(text "{% endblock %}") diff --git a/crates/app/src/public/html/misc/requests.lisp b/crates/app/src/public/html/misc/requests.lisp index 2735e0e..73d03ef 100644 --- a/crates/app/src/public/html/misc/requests.lisp +++ b/crates/app/src/public/html/misc/requests.lisp @@ -92,6 +92,37 @@ (text "{{ icon \"trash\" }}") (span (text "{{ text \"general:action.delete\" }}")))))) + (text "{% elif request.action_type == \"Transfer\" %}") + (div + ("class" "card_nest") + (div + ("class" "card small flex items_center gap_2") + (icon (text "piggy-bank")) + (span + (str (text "requests:label.coin_transfer_request")))) + (div + ("class" "card flex flex_col gap_2") + (span (a ("href" "/api/v1/auth/user/find/{{ request.linked_asset }}") (text "Somebody")) (text " is asking for a transfer of ") (b (text "{{ request.data.Int32 }} coins")) (text ".")) + (div + ("class" "card flex flex_wrap w_full secondary gap_2") + (a + ("href" "/api/v1/auth/user/find/{{ request.linked_asset }}") + ("class" "button") + (text "{{ icon \"external-link\" }}") + (span + (text "{{ text \"requests:action.view_profile\" }}"))) + (button + ("class" "lowered green") + ("onclick" "accept_transfer_request(event, '{{ request.id }}', '{{ request.linked_asset }}', {{ request.data.Int32 }})") + (text "{{ icon \"check\" }}") + (span + (text "{{ text \"general:action.accept\" }}"))) + (button + ("class" "lowered red") + ("onclick" "remove_request('{{ request.id }}', '{{ request.linked_asset }}')") + (text "{{ icon \"trash\" }}") + (span + (text "{{ text \"general:action.delete\" }}")))))) (text "{%- endif %} {% endfor %} {% for question in questions %}") (div ("class" "card_nest") @@ -138,13 +169,15 @@ (text "{{ components::pagination(page=page, items=requests|length, key=\"&id=\", value=profile.id) }}")) (script - (text "async function remove_request(id, linked_asset) { - if ( - !(await trigger(\"atto::confirm\", [ - \"Are you sure you want to do this?\", - ])) - ) { - return; + (text "async function remove_request(id, linked_asset, confirm = true) { + if (confirm) { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you want to do this?\", + ])) + ) { + return; + } } fetch(`/api/v1/requests/${id}/${linked_asset}`, { @@ -275,6 +308,41 @@ } } }); + }; + + globalThis.accept_transfer_request = async (e, id, receiver, amount) => { + await trigger(\"atto::debounce\", [\"economy::transfer\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/transfers`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + receiver, + amount, + }), + }) + .then((res) => res.json()) + .then(async (res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + e.target.parentElement.parentElement.parentElement.parentElement.remove(); + remove_request(id, receiver, false); + } + }); };")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/mod/file_report.lisp b/crates/app/src/public/html/mod/file_report.lisp index f1748c0..154583f 100644 --- a/crates/app/src/public/html/mod/file_report.lisp +++ b/crates/app/src/public/html/mod/file_report.lisp @@ -28,7 +28,7 @@ ("required" "") ("minlength" "16"))) (button - (text "{{ text \"communities:action.create\" }}"))))) + (str (text "communities:action.create")))))) (script (text "function create_report_from_form(e) { diff --git a/crates/app/src/public/html/mod/ip_bans.lisp b/crates/app/src/public/html/mod/ip_bans.lisp index c612cf9..d48b3cf 100644 --- a/crates/app/src/public/html/mod/ip_bans.lisp +++ b/crates/app/src/public/html/mod/ip_bans.lisp @@ -19,7 +19,7 @@ ("class" "lowered small") (text "{{ icon \"plus\" }}") (span - (text "{{ text \"communities:action.create\" }}")))) + (str (text "communities:action.create"))))) (div ("class" "card flex flex_col gap_2") (text "{% for item in items %}") diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 3201178..7e12f8c 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -84,7 +84,7 @@ const ui = await ns(\"ui\"); const element = document.getElementById(\"mod_options\"); - globalThis.profile_request = async (do_confirm, path, body) => { + globalThis.profile_request = async (do_confirm, path, body = null, headers = { \"Content-Type\": \"application/json\" }, method = \"POST\") => { if (do_confirm) { if ( !(await trigger(\"atto::confirm\", [ @@ -96,11 +96,9 @@ } fetch(`/api/v1/auth/user/{{ profile.id }}/${path}`, { - method: \"POST\", - headers: { - \"Content-Type\": \"application/json\", - }, - body: JSON.stringify(body), + method, + headers: headers != null ? headers : undefined, + body: body != null ? JSON.stringify(body) : undefined, }) .then((res) => res.json()) .then((res) => { @@ -265,9 +263,15 @@ (span (text "{{ text \"mod_panel:label.associations\" }}")))) (div - ("class" "card lowered flex flex_wrap gap_2") + ("class" "card flex flex_wrap gap_4 flex_collapse") (text "{% for user in associations -%}") - (text "{{ components::user_plate(user=user, show_menu=false) }}") + (div + ("class" "flex flex_row gap_2 items_center card small secondary") + (text "{{ components::user_plate(user=user, show_menu=false, secondary=true, full=true) }}") + (button + ("class" "small square red lowered") + ("onclick" "profile_request(true, 'associations/{{ user.id }}', null, null, 'DELETE')") + (icon (text "x")))) (text "{%- endfor %}"))) (text "{% if invite -%}") (div @@ -298,7 +302,7 @@ (div ("class" "flex flex_col gap_1") (label - ("for" "title") + ("for" "reason") (str (text "mod_panel:label.ban_reason"))) (textarea ("type" "text") @@ -309,6 +313,37 @@ (text "{{ profile.ban_reason|remove_script_tags|safe }}"))) (button (str (text "general:action.save"))))) + (div + ("class" "card_nest w_full") + (div + ("class" "card small flex items_center justify_between gap_2") + (div + ("class" "flex items_center gap_2") + (icon (text "scale")) + (span + (str (text "mod_panel:label.ban_expiration"))))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "event.preventDefault(); profile_request(false, 'ban_expire', { expire: new Date(event.target.expire.value).getTime() || 0 })") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "expire") + (str (text "mod_panel:label.ban_expiration"))) + (input + ("type" "datetime-local") + ("name" "expire") + ("id" "expire") + ("value" "{{ profile.ban_expire }}"))) + (div + ("class" "flex gap_2") + (button + (str (text "general:action.save"))) + (button + ("type" "button") + ("class" "lowered red") + ("onclick" "profile_request(false, 'ban_expire', { expire: 0 })") + (str (text "notifs:action.clear")))))) (div ("class" "card_nest w_full") (div diff --git a/crates/app/src/public/html/mod/warnings.lisp b/crates/app/src/public/html/mod/warnings.lisp index c5b783a..d84ab53 100644 --- a/crates/app/src/public/html/mod/warnings.lisp +++ b/crates/app/src/public/html/mod/warnings.lisp @@ -37,7 +37,7 @@ ("minlength" "2") ("maxlength" "4096"))) (button - (text "{{ text \"communities:action.create\" }}")))) + (str (text "communities:action.create"))))) (div ("class" "card_nest") (div diff --git a/crates/app/src/public/html/post/forum_quick_replies.lisp b/crates/app/src/public/html/post/forum_quick_replies.lisp new file mode 100644 index 0000000..383d5ba --- /dev/null +++ b/crates/app/src/public/html/post/forum_quick_replies.lisp @@ -0,0 +1,24 @@ +(text "{%- import \"components.html\" as components -%} {%- import \"macros.html\" as macros -%}") + +(div + ("class" "flex flex_col gap_2") + ("id" "replies_{{ post.id }}_{{ page }}") + ; replies + (text "{% for post in replies -%}") + (div + ("style" "display: contents") + (text "{{ components::forum_post(post=post[0], owner=post[1], community=community, can_manage_post=can_manage_posts, poll=post[4]) }}")) + (text "{%- endfor %}") + + ; load more button + (text "{% set len = replies|length %}") + (text "{% if len != 0 and (page * 12) + len != post.comment_count -%}") + (div + (button + ("class" "lowered") + ("onclick" "globalThis.continue_thread(event.target, '{{ post.id }}', 'replies_{{ post.id }}', {{ page + 1 }})") + (icon (text "chevron-down")) + (str (text "general:action.load_more")))) + (text "{% else %}") + (div ("class" "squig")) + (text "{%- endif %}")) diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index c645ae0..e53ca2e 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -56,7 +56,7 @@ (text "{% else %}") (div ("style" "display: contents") - (text "{{ components::forum_post(post=post, owner=owner, community=community, can_manage_post=can_manage_posts, poll=poll) }}")) + (text "{{ components::forum_post(post=post, owner=owner, community=community, can_manage_post=can_manage_posts, poll=poll, show_show_thread=false) }}")) (text "{%- endif %}") ; ... (text "{% if user and post.context.comments_enabled -%}") @@ -88,7 +88,7 @@ ("class" "flex gap_2") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}") (button - (text "{{ text \"communities:action.create\" }}"))))) + (str (text "communities:action.create")))))) (text "{%- endif %}") (div ("class" "pillmenu") @@ -332,7 +332,24 @@ (text "{%- endif %} {% endfor %} {{ components::pagination(page=page, items=replies|length) }}")))) (script - (text "async function create_reply_from_form(e) { + (text "globalThis.continue_thread = async (target, post_id, id, page = 0) => { + const btn_id = `tmp_${window.crypto.randomUUID()}`; + target.setAttribute(\"disabled\", \"true\"); + target.id = btn_id; + + document.getElementById(id).innerHTML += + await (await fetch(`/post/${post_id}/_quick_replies?page=${page}`)).text(); + document.getElementById(id).classList.remove(\"hidden\"); + + await trigger(\"atto::clean_date_codes\"); + await trigger(\"atto::link_filter\"); + await trigger(\"atto::hooks::check_reactions\"); + await trigger(\"atto::hooks::online_indicator\"); + + document.getElementById(btn_id).parentElement.remove(); + } + + async function create_reply_from_form(e) { e.preventDefault(); await trigger(\"atto::debounce\", [\"posts::create\"]); diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index a7e1cd3..1fde607 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -64,7 +64,7 @@ ("id" "username") ("class" "username flex items_center gap_2 flex_wrap w_full") (span - ("class" "name shorter") + ("class" "name") (text "{{ components::username(user=profile) }}")) (text "{% if profile.is_verified -%}") (span @@ -84,6 +84,12 @@ ("style" "color: var(--color-primary);") ("class" "flex items_center") (text "{{ icon \"id-card-lanyard\" }}")) + (text "{%- endif %} {% if profile.checkouts|length > 0 -%}") + (span + ("title" "Donator") + ("style" "color: var(--color-primary);") + ("class" "flex items_center") + (text "{{ icon \"hand-heart\" }}")) (text "{%- endif %} {% if profile.permissions|has_staff_badge -%}") (span ("title" "Staff") @@ -170,16 +176,6 @@ (text "Posts")) (span (text "{{ profile.post_count }}"))) - (text "{% if gpa and gpa > 0 and (not user.settings.disable_gpa_fun or is_helper) -%}") - (div - ("class" "w_full flex justify_between items_center") - ("title" "great post average (limited time fun)") - (span - ("class" "notification chip") - (text "GPA")) - (span - (text "{{ gpa|round(method=\"floor\", precision=2) }}"))) - (text "{%- endif %}") (text "{% if not profile.settings.private_last_seen or is_self or is_helper %}") (div ("class" "w_full flex justify_between items_center") @@ -266,6 +262,13 @@ (icon (text "mail-plus")) (span (str (text "mail:action.send_mail")))) + (text "{%- endif %} {% if not profile.settings.no_transfers -%}") + (button + ("onclick" "request_transfer()") + ("class" "lowered") + (icon (text "badge-cent")) + (span + (str (text "economy:action.request")))) (text "{%- endif %} {% if is_helper -%}") (a ("href" "/mod_panel/profile/{{ profile.id }}") @@ -299,6 +302,41 @@ }); }; + globalThis.request_transfer = async () => { + await trigger(\"atto::debounce\", [\"economy::transfer\"]); + const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"0\"); + + if (amount === 0) { + return; + } + + if ( + !(await trigger(\"atto::confirm\", [ + `Are you sure you would like to request ${amount} coins from {{ profile.username }}?`, + ])) + ) { + return; + } + + fetch(`/api/v1/transfers/ask`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + receiver: \"{{ profile.id }}\", + amount, + }), + }) + .then((res) => res.json()) + .then(async (res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + globalThis.toggle_follow_user = async (e) => { await trigger(\"atto::debounce\", [ \"users::follow\", @@ -430,6 +468,12 @@ ("class" "rhs w_full flex flex_col gap_4") (text "{% block content %}{% endblock %}"))))) +(text "{% if not use_user_theme -%}") +(text "{% for cnf in applied_configurations -%}") +(text "{{ cnf|safe }}") +(text "{%- endfor %}") +(text "{%- endif %}") + (text "{% if not is_self and profile.settings.warning -%}") (script (text "setTimeout(() => { diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp index ab33a08..e01660b 100644 --- a/crates/app/src/public/html/profile/posts.lisp +++ b/crates/app/src/public/html/profile/posts.lisp @@ -3,19 +3,8 @@ ("style" "display: contents") (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) -(text "{%- endif %} {% if not tag and pinned|length != 0 -%}") -(div - ("class" "card_nest") - (div - ("class" "card small flex gap_2 items_center") - (text "{{ icon \"pin\" }}") - (span - (text "{{ text \"communities:label.pinned\" }}"))) - (div - ("class" "card flex flex_col gap_4") - (text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}"))) - -(text "{%- endif %} {{ macros::profile_nav(selected=\"posts\") }}") +(text "{%- endif %}") +(text "{{ macros::profile_nav(selected=\"posts\") }}") (div ("class" "card_nest") (div @@ -42,6 +31,12 @@ (div ("class" "card w_full flex flex_col gap_2") ("ui_ident" "io_data_load") + ; pinned + (text "{% if pinned and pinned|length > 0 -%}") + (text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}") + (div ("class" "squig")) + (text "{%- endif %}") + ; ... (div ("ui_ident" "io_data_marker")))) (text "{% set paged = user and user.settings.paged_timelines %}") diff --git a/crates/app/src/public/html/profile/responses.lisp b/crates/app/src/public/html/profile/responses.lisp index 93c605e..925d271 100644 --- a/crates/app/src/public/html/profile/responses.lisp +++ b/crates/app/src/public/html/profile/responses.lisp @@ -3,19 +3,8 @@ ("style" "display: contents") (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) -(text "{%- endif %} {% if not tag and pinned|length != 0 -%}") -(div - ("class" "card_nest") - (div - ("class" "card small flex gap_2 items_center") - (text "{{ icon \"pin\" }}") - (span - (text "{{ text \"communities:label.pinned\" }}"))) - (div - ("class" "card flex flex_col gap_4") - (text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}"))) - -(text "{%- endif %} {{ macros::profile_nav(selected=\"responses\") }}") +(text "{%- endif %}") +(text "{{ macros::profile_nav(selected=\"responses\") }}") (div ("class" "card_nest") (div @@ -42,6 +31,12 @@ (div ("class" "card w_full flex flex_col gap_2") ("ui_ident" "io_data_load") + ; pinned + (text "{% if pinned and pinned|length > 0 -%}") + (text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}") + (div ("class" "squig")) + (text "{%- endif %}") + ; ... (div ("ui_ident" "io_data_marker")))) (text "{% set paged = user and user.settings.paged_timelines %}") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 52d41d8..8bda6c2 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1162,6 +1162,26 @@ ("class" "fade") (text "This represents the site theme shown to users viewing your profile."))))) + (text "{% if profile.applied_configurations|length > 0 -%}") + (div + ("class" "card_nest") + ("ui_ident" "applied_configurations") + (div + ("class" "card small flex items_center gap_2") + (icon (text "cog")) + (str (text "setttings:label.applied_configurations"))) + (div + ("class" "card") + (p (text "Products that you have purchased and applied to your profile are displayed below. Snippets are always synced to the product, meaning the owner could update it at any time.")) + (ul + (text "{% for cnf in profile.applied_configurations -%}") + (li + (text "{{ cnf[0] }} ") + (a + ("href" "/product/{{ cnf[1] }}") + (text "{{ cnf[1] }}"))) + (text "{%- endfor %}")))) + (text "{%- endif %}") (button ("onclick" "save_settings()") ("id" "save_button") @@ -1742,6 +1762,7 @@ \"import_export\", \"theme_preference\", \"profile_theme\", + \"applied_configurations\", ]); ui.generate_settings_ui( @@ -1906,6 +1927,14 @@ \"{{ profile.settings.hide_social_follows }}\", \"checkbox\", ], + [ + [ + \"hide_username_badges\", + \"Hide badges from your username (outside of your profile)\", + ], + \"{{ profile.settings.hide_username_badges }}\", + \"checkbox\", + ], [[], \"Questions\", \"title\"], [ [ @@ -1954,10 +1983,27 @@ \"textarea\", ], [ - [\"forum_signature\", \"Forum signature (coming soon)\"], + [\"forum_signature\", \"Forum signature\"], settings.forum_signature, \"textarea\", ], + [[], \"Economy\", \"title\"], + [ + [ + \"enable_shop\", + \"Show shop tab on my profile\", + ], + \"{{ profile.settings.enable_shop }}\", + \"checkbox\", + ], + [ + [ + \"no_transfers\", + \"Disable transfer requests\", + ], + \"{{ profile.settings.no_transfers }}\", + \"checkbox\", + ], [[], \"Misc\", \"title\"], [ [\"hide_dislikes\", \"Hide post dislikes\"], @@ -1970,11 +2016,6 @@ \"text\", ], [[], \"Fun\", \"title\"], - [ - [\"disable_gpa_fun\", \"Disable GPA\"], - \"{{ profile.settings.disable_gpa_fun }}\", - \"checkbox\", - ], [ [\"disable_achievements\", \"Disable achievements\"], \"{{ profile.settings.disable_achievements }}\", diff --git a/crates/app/src/public/html/profile/shop.lisp b/crates/app/src/public/html/profile/shop.lisp new file mode 100644 index 0000000..744d2f1 --- /dev/null +++ b/crates/app/src/public/html/profile/shop.lisp @@ -0,0 +1,17 @@ +(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") +(div + ("style" "display: contents") + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) +(text "{%- endif %}") +(text "{{ macros::profile_nav(selected=\"shop\") }}") +(div + ("class" "card_nest") + (div + ("class" "card small flex gap_2 items_center") + (icon (text "store")) + (str (text "auth:label.shop"))) + (div + ("class" "card w_full flex flex_col gap_2") + (text "{% for item in list %} {{ components::product_listing_card(product=item) }} {% endfor %}") + (text "{{ components::pagination(page=page, items=list|length) }}"))) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index 76b38d9..9a253fe 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -76,7 +76,17 @@ (span ("class" "fade") (text "The following reason was provided by a moderator:")) (div ("class" "card lowered w_full") - (text "{{ user.ban_reason|markdown|safe }}")))))) + (text "{{ user.ban_reason|markdown|safe }}")) + (text "{% if user.ban_expire != 0 -%}") + (hr) + (span + (text "Your ban will expire on: ") + (span ("id" "ban_expire"))) + (script + (text "document.getElementById(\"ban_expire\").innerText = new Date({{ user.ban_expire }}).toLocaleString();")) + (text "{% else %}") + (span (text "This ban is marked as permanent.")) + (text "{%- endif %}"))))) ; if we aren't banned, just show the page body (text "{% elif user and user.awaiting_purchase %}") ; account waiting for payment message diff --git a/crates/app/src/public/html/stacks/add_user.lisp b/crates/app/src/public/html/stacks/add_user.lisp index 7318cfc..5ae6f90 100644 --- a/crates/app/src/public/html/stacks/add_user.lisp +++ b/crates/app/src/public/html/stacks/add_user.lisp @@ -16,7 +16,7 @@ (span (text "Select a stack to add this user to:")) (text "{% for stack in stacks %}") (button - ("class" "justify-start lowered w_full") + ("class" "justify_start lowered w_full") ("onclick" "choose_stack('{{ stack.id }}')") (icon (text "layers")) (text "{{ stack.name }}")) diff --git a/crates/app/src/public/html/stacks/list.lisp b/crates/app/src/public/html/stacks/list.lisp index dbcf944..27714ce 100644 --- a/crates/app/src/public/html/stacks/list.lisp +++ b/crates/app/src/public/html/stacks/list.lisp @@ -29,7 +29,7 @@ ("minlength" "2") ("maxlength" "32"))) (button - (text "{{ text \"communities:action.create\" }}")))) + (str (text "communities:action.create"))))) (text "{%- endif %}") (div ("class" "card_nest w_full") diff --git a/crates/app/src/public/html/timelines/all.lisp b/crates/app/src/public/html/timelines/all.lisp index 4609e25..abf3ede 100644 --- a/crates/app/src/public/html/timelines/all.lisp +++ b/crates/app/src/public/html/timelines/all.lisp @@ -1,11 +1,12 @@ (text "{% extends \"root.html\" %} {% block head %}") (title (text "Latest posts - {{ config.name }}")) - (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex_col gap_2") - (text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\") }} {% if not user -%}") + (text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", forum_posts=\"/all/forum_posts\") }}") + (text "{{ components::advertisement(size=\"Leaderboard\") }}") + (text "{% if not user -%}") (div ("class" "card_nest") (div @@ -32,11 +33,9 @@ ("class" "card w_full flex flex_col gap_2") ("ui_ident" "io_data_load") (div ("ui_ident" "io_data_marker")))) - (text "{% set paged = user and user.settings.paged_timelines %}") (script (text "setTimeout(() => { trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&before=$1$&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); });")) - (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/all_forum_posts.lisp b/crates/app/src/public/html/timelines/all_forum_posts.lisp new file mode 100644 index 0000000..7a171fb --- /dev/null +++ b/crates/app/src/public/html/timelines/all_forum_posts.lisp @@ -0,0 +1,34 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "Latest forum posts - {{ config.name }}")) +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex_col gap_2") + (text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", secondary_selected=\"forum_posts\", forum_posts=\"/all/forum_posts\") }}") + (div + ("class" "card w_full flex flex_col gap_2") + (text "{% if config.town_square_forum != 0 -%}") + (a + ("href" "/communities/intents/post?community={{ config.town_square_forum }}&topic={{ config.town_square_forum_topic }}&sig=true&topics=true") + ("class" "button small lowered") + ("data-turbo" "false") + (icon (text "plus")) + (span + (str (text "general:action.post")))) + (text "{%- endif %}") + + (div + ("class" "w_full") + ("style" "overflow: auto") + (table + ("class" "w_full") + (thead + (th (text "In")) + (th (text "Title")) + (th (text "Replies")) + (th (text "Score")) + (th (text "Created"))) + (tbody + (text "{% for post in feed %} {{ components::topic_post_display(post=post[0], owner=post[1], community=post[2]) }} {% endfor %}")))) + (text "{{ components::pagination(page=page, items=feed|length) }}"))) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/all_questions.lisp b/crates/app/src/public/html/timelines/all_questions.lisp index 70243ee..00626de 100644 --- a/crates/app/src/public/html/timelines/all_questions.lisp +++ b/crates/app/src/public/html/timelines/all_questions.lisp @@ -1,13 +1,11 @@ (text "{% extends \"root.html\" %} {% block head %}") (title (text "Latest questions - {{ config.name }}")) - (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex_col gap_2") - (text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", secondary_selected=\"questions\") }}") + (text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", secondary_selected=\"questions\", forum_posts=\"/all/forum_posts\") }}") (div ("class" "card w_full flex flex_col gap_2") (text "{% for question in list %} {{ components::global_question(question=question, can_manage_questions=false, secondary=true) }} {% endfor %} {{ components::pagination(page=page, items=list|length) }}"))) - (text "{% endblock %}") diff --git a/crates/app/src/public/js/ads.js b/crates/app/src/public/js/ads.js new file mode 100644 index 0000000..654ae72 --- /dev/null +++ b/crates/app/src/public/js/ads.js @@ -0,0 +1,65 @@ +globalThis.TetrattoAds = { + AD_SIZES: { + Billboard: [970, 250], + Leaderboard: [720, 90], + Skyscraper: [160, 600], + MediumRectangle: [300, 250], + MobileLeaderboard: [320, 50], + }, + IS_MOBILE: window.innerWidth <= 900 && window.innerHeight <= 900, +}; + +globalThis.TetrattoAds.init = () => { + const styles = document.createElement("style"); + styles.id = "tetratto_ads_css"; + styles.setAttribute("data-turbo-permanent", "true"); + + styles.innerHTML = `.tetratto_ad { + width: 100%; + display: grid; + place-items: center; + } + + .tetratto_ad, + .tetratto_ad iframe { + max-width: 100%; + background: transparent; + }`; + + document.head.appendChild(styles); +}; + +globalThis.TetrattoAds.render_ads = ( + host_id = 0, + tetratto = "https://tetratto.com", +) => { + for (const element of Array.from( + document.querySelectorAll(".tetratto_ad"), + )) { + if (element.children.length > 0) { + continue; + } + + const iframe = document.createElement("iframe"); + let size = element.getAttribute("data-ad-size") || "MediumRectangle"; + + if (size === "Leaderboard" && TetrattoAds.IS_MOBILE) { + size = "MobileLeaderboard"; + } + + const size_px = TetrattoAds.AD_SIZES[size]; + + const noclick = + element.getAttribute("data-noclick") === "true" || false; + const ad_id = element.getAttribute("data-ad-id"); + + iframe.src = `${tetratto}/adn/${ad_id ? ad_id : "random"}?size=${size}&host=${host_id}&noclick=${noclick}`; + iframe.setAttribute("frameborder", "0"); + iframe.loading = "lazy"; + + iframe.style.width = `${size_px[0]}px`; + iframe.style.height = `${size_px[1]}px`; + + element.appendChild(iframe); + } +}; diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 8791aeb..ac6dda8 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -85,6 +85,10 @@ media_theme_pref(); element.removeAttribute("checked"); } + for (const element of document.querySelectorAll('[disabled="false"]')) { + element.removeAttribute("disabled"); + } + for (const element of document.querySelectorAll('[selected="true"]')) { element.parentElement.value = element.value; } diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 9b8ad1d..708baf4 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -726,7 +726,7 @@ element.innerHTML = ""; for (const token of Object.entries($.LOGIN_ACCOUNT_TOKENS)) { element.innerHTML += `
-